Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion docs/linux-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ not rendered at all.
| The seam | `lib/core/services/audio_output_device_service.dart` |
| Linux implementation | `lib/core/services/linux_audio_output_device_service.dart` |
| Platform split | `lib/core/services/platform_audio_output_device_service.dart` |
| Policy (restore, fallback, what is remembered) | `lib/features/settings/playback/audio_output_controller.dart` |
| Policy (restore, fallback, hotplug, what is remembered) | `lib/features/settings/playback/audio_output_controller.dart` |

Four decisions worth knowing:

Expand Down Expand Up @@ -351,6 +351,66 @@ desktop:
| Pick a USB output, quit, unplug it, relaunch | Playback uses the system default and the card says the saved output is unavailable. |
| Pick a USB output, quit, plug it back in, relaunch | Playback goes back to that output on its own. |

### Device hotplug

Devices come and go while music is playing: headphones are unplugged, a
Bluetooth speaker drops out of range and comes back, an HDMI sink appears when a
monitor wakes, the desktop moves its default sink
([issue #403](https://github.com/thezupzup/linthra/issues/403)). The same seam
handles all of it — `AudioOutputDeviceService.deviceChanges` is libmpv's
`audio-device-list` *observed* rather than read once, and the rules for reacting
live in `AudioOutputController` next to the ones that already decide which
output is chosen and whether it is remembered.

| The host does this | Linthra does this |
| --- | --- |
| A device appears | Adds it to the list. Playback is not moved. |
| An unrelated device disappears | Updates the list. Playback is not moved. |
| The system default moves, and nothing was chosen | Nothing. "System default" means the host decides, including when it changes its mind. |
| The chosen device is still listed | Nothing. libmpv carried playback through with no gap, and re-routing to a sink audio is already on would be an interruption caused purely by the recovery code. |
| The chosen device disappears | Falls back to the system default so audio stays audible, and the card says why it moved. The preference is **kept**. |
| The chosen device comes back | Hands playback back to it and clears the notice. |
| The fallback is refused too | Surfaces a recoverable "playback may be silent" state with a **Try again**, rather than leaving playback pointed at a sink that is not there. |

Three properties are what keep this from causing the bugs it is meant to fix:

* **Nothing is ever re-loaded.** Recovery is a *routing* decision: the queue,
the track and the position are untouched, and no second player is created, so
a device event cannot produce duplicate playback.
* **Exactly one subscription.** The controller opens one device-change
subscription in `build` and closes it with the notifier, and the Linux service
keeps at most one device-list listener per live player, keyed by the player
id. One unplug is handled once.
* **Nothing polls.** The service re-attaches when the engine rebuilds its
player, driven by the vendored plugin's `livePlayersChanged` signal
(`third_party/just_audio_media_kit/PATCHES.md`) rather than by a timer.

**Memory remembers what disk forgets.** A saved output that was *never seen* on
this machine is dropped at launch — that is the "saved on another machine" rule
and it has not changed. But a device that has been playing this session and then
vanished is a hotplug, not a stale preference, so the preference survives it and
a reconnect restores the choice. Without that split, a Bluetooth dropout plus a
refresh would quietly lose what the listener picked.

**When nothing is playing there are no events.** Watching needs a live player,
and Linthra will not create one just to watch — a diagnostics or settings screen
that spun up a second libmpv handle would be the next bug. There is nothing to
recover in that state either, because no audio is being interrupted; the next
play, or the card's Refresh, picks up whatever changed.

`test/features/settings/playback/audio_output_hotplug_test.dart` drives every
row of the table above against a fake backend, including a flapping device over
repeated connect/disconnect cycles. On a real desktop:

| Check | Expected |
| --- | --- |
| Play something on the built-in output, plug in wired headphones | Audio keeps playing; the new device appears in the list without playback moving. |
| Choose the headphones, then unplug them mid-track | Audio continues on the system default, the track does not restart, and the card explains the move. |
| Plug them back in | Playback returns to them on its own. |
| Choose a Bluetooth speaker, walk out of range and back | Same: fall back, then hand back, with no duplicated audio and no restart. |
| Choose an HDMI output, put the monitor to sleep, wake it | Same. |
| Change the desktop's default sink while playing on "System default" | Audio follows the desktop; Linthra does not fight it. |

libmpv provides broad codec/container support and PulseAudio/PipeWire output.
It is a native runtime dependency, not a binary downloaded when Linthra starts.
The Flatpak manifest therefore builds libmpv as a declared module and bundles
Expand Down Expand Up @@ -489,6 +549,7 @@ loaded:
| Local tag reading | Supported | `FilesystemLocalMetadataReader` reads title, artist, album artist, album, track number and duration from ID3, Vorbis comments, MP4 atoms, APEv2 and RIFF INFO through `audio_metadata_reader` ([issue #407](https://github.com/TheZupZup/Linthra/issues/407)). An unreadable or untagged file still appears, from its filename. Android is deliberately unchanged: its tags come from the native SAF walk. |
| Local embedded artwork | Unsupported | Tags are read without pulling cover images out of every file during a scan. Extracting and caching embedded art on desktop is [issue #408](https://github.com/TheZupZup/Linthra/issues/408); tracks keep the placeholder until then. |
| **Audio output device** | Supported | Settings → Music & playback → Audio output lists libmpv's `audio-device-list` and routes playback with `audio-device` ([issue #402](https://github.com/TheZupZup/Linthra/issues/402)). A saved device is re-applied at launch, and one that is no longer present falls back to the system default. See [Audio output device](#audio-output-device). |
| **Device hotplug** | Supported | A headset, Bluetooth speaker or HDMI sink appearing or disappearing mid-playback is recovered without restarting the track or creating a second player, and a chosen device that comes back takes playback back ([issue #403](https://github.com/thezupzup/linthra/issues/403)). If even the system default is refused, the card says so and offers a retry. See [Device hotplug](#device-hotplug). |
| **Playback diagnostics** | Supported | Settings → Diagnostics & support → Linux playback builds a copyable report of the backend, libmpv, the selected output subsystem and recent failure kinds ([issue #406](https://github.com/thezupzup/linthra/issues/406)). Safe by construction: no field can hold a URL, token, header, path, device name or raw error. See [Playback diagnostics](#playback-diagnostics). |
| Chromecast | Android/iOS only | Already gated in `cast_providers.dart`; Linux keeps the honest "cast unavailable" service. |
| Share sheet, launcher-icon switching | Android-only, by design | No desktop equivalent; the UI simply omits them. |
Expand Down
20 changes: 20 additions & 0 deletions lib/core/services/audio_output_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,24 @@ abstract interface class AudioOutputDeviceService {
/// as done: it is the difference between remembering an output that is
/// playing and remembering one that never started.
Future<bool> select(AudioOutputDevice device);

/// Emits the host's output list whenever the backend reports that it changed
/// — a headset plugged in or pulled out, a Bluetooth speaker connecting or
/// dropping, an HDMI sink appearing when a monitor wakes, the system default
/// moving.
///
/// Each event is the full list in the same shape [devices] returns, so a
/// listener compares lists rather than reconstructing a diff from events it
/// might have missed.
///
/// This is *observation only*: it starts, stops and re-routes nothing. What
/// to do about a device that vanished is policy, and it lives in
/// `AudioOutputController` — the same place that already owns which output
/// is chosen and whether it is remembered.
///
/// A broadcast stream: several listeners are fine and none of them changes
/// what the others see. Implementations that cannot observe (every platform
/// but Linux) return an empty stream rather than throwing, so a caller never
/// has to ask whether watching is supported before listening.
Stream<List<AudioOutputDevice>> get deviceChanges;
}
110 changes: 109 additions & 1 deletion lib/core/services/linux_audio_output_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ typedef LinuxAudioDeviceProbe = Future<List<({String id, String description})>>
/// Writes a device name back to the backend.
typedef LinuxAudioDeviceApply = Future<void> Function(String deviceId);

/// Watches the backend's raw output list, re-attaching as players come and go.
///
/// A seam so the *policy* around hotplug (see `AudioOutputController`) can be
/// tested without libmpv, and so the attachment strategy underneath can change
/// without the policy noticing.
typedef LinuxAudioDeviceWatch = Stream<List<({String id, String description})>>
Function();

/// Linux output-device routing, through media_kit/libmpv.
///
/// libmpv already models exactly what this feature needs, so nothing here goes
Expand All @@ -35,15 +43,42 @@ typedef LinuxAudioDeviceApply = Future<void> Function(String deviceId);
/// nothing is playing — libmpv reports `audio-device-list` on a fresh handle
/// without ever opening an output, so listing outputs from Settings never makes
/// a sound or grabs a device.
///
/// ## Watching for hotplug
///
/// [deviceChanges] is the same `audio-device-list` property, observed rather
/// than read once: libmpv republishes it when a headset is plugged in, a
/// Bluetooth sink connects or drops, an HDMI output appears, or the system
/// default moves. Three rules keep that from becoming a source of bugs of its
/// own:
///
/// * **One subscription per live player, ever.** The service keys them by the
/// just_audio player id, so re-attaching is idempotent and a device event
/// can never be delivered twice — which is what would turn one hotplug into
/// two recovery attempts, and one recovery into duplicate playback.
/// * **No player is created to watch.** Enumeration may build a throwaway
/// handle; watching never does. When nothing is playing there is no player,
/// so there are no events — and nothing to recover either, because no audio
/// is being interrupted.
/// * **It re-attaches on a signal, not on a timer.** The engine tears a player
/// down and builds a new one on a stop, on suspend/resume and on some source
/// switches, so a listener attached to one player has to follow. The
/// vendored plugin publishes `livePlayersChanged` for exactly this
/// (`third_party/just_audio_media_kit/PATCHES.md`), so nothing polls.
class LinuxAudioOutputDeviceService implements AudioOutputDeviceService {
LinuxAudioOutputDeviceService({
LinuxAudioDeviceProbe? probe,
LinuxAudioDeviceApply? apply,
LinuxAudioDeviceWatch? watch,
}) : _probe = probe ?? _probeThroughMediaKit,
_apply = apply ?? _applyThroughMediaKit;
_apply = apply ?? _applyThroughMediaKit,
_watch = watch ?? _watchThroughMediaKit;

final LinuxAudioDeviceProbe _probe;
final LinuxAudioDeviceApply _apply;
final LinuxAudioDeviceWatch _watch;

Stream<List<AudioOutputDevice>>? _deviceChanges;

/// How long libmpv gets to report its device list before Linthra gives up.
///
Expand All @@ -65,6 +100,24 @@ class LinuxAudioOutputDeviceService implements AudioOutputDeviceService {
}
}

/// The host's output list, re-emitted whenever the backend reports a change.
///
/// Built once and shared: the underlying watch attaches to the backend on the
/// first listen and detaches on the last, so a second listener costs nothing
/// and a page nobody opened holds no subscription at all.
///
/// A list that could not be read is dropped rather than emitted as empty —
/// "the backend did not answer" is not the same as "this machine has no
/// outputs", and a policy that acted on the difference would route playback
/// away from a device the listener is still using.
@override
Stream<List<AudioOutputDevice>> get deviceChanges {
return _deviceChanges ??= _watch()
.map(audioOutputDevicesFromBackend)
.handleError((Object _) {})
.asBroadcastStream();
}

@override
Future<bool> select(AudioOutputDevice device) async {
try {
Expand All @@ -79,6 +132,61 @@ class LinuxAudioOutputDeviceService implements AudioOutputDeviceService {
}
}

/// Follows libmpv's `audio-device-list` across the players the engine
/// creates and destroys.
///
/// The bookkeeping is deliberately boring: a map of player id → subscription,
/// re-synced on every `livePlayersChanged` event and on the first listen.
/// Attaching is idempotent (a player already in the map is skipped) and
/// detaching is total (the last listener leaves nothing behind), which
/// together are what keep one hotplug from being seen twice.
static Stream<List<({String id, String description})>>
_watchThroughMediaKit() {
final Map<String, StreamSubscription<List<AudioDevice>>> attached =
<String, StreamSubscription<List<AudioDevice>>>{};
StreamSubscription<void>? registry;
late StreamController<List<({String id, String description})>> controller;

void sync() {
final Map<String, Player> live = JustAudioMediaKit.livePlayers;
for (final MapEntry<String, Player> entry in live.entries) {
if (attached.containsKey(entry.key)) continue;
attached[entry.key] = entry.value.stream.audioDevices.listen(
(List<AudioDevice> devices) {
if (_isUnpopulated(devices)) return;
controller.add(<({String id, String description})>[
for (final AudioDevice device in devices)
(id: device.name, description: device.description),
]);
},
onError: (Object _) {},
);
}
for (final String id in attached.keys.toList()) {
if (live.containsKey(id)) continue;
unawaited(attached.remove(id)?.cancel());
}
}

controller = StreamController<List<({String id, String description})>>(
onListen: () {
registry = JustAudioMediaKit.livePlayersChanged.stream
.listen((void _) => sync());
sync();
},
onCancel: () async {
await registry?.cancel();
registry = null;
for (final StreamSubscription<List<AudioDevice>> subscription
in attached.values.toList()) {
await subscription.cancel();
}
attached.clear();
},
);
return controller.stream;
}

static Future<List<({String id, String description})>>
_probeThroughMediaKit() async {
final Iterable<Player> live = JustAudioMediaKit.livePlayers.values;
Expand Down
6 changes: 6 additions & 0 deletions lib/core/services/noop_audio_output_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ class NoopAudioOutputDeviceService implements AudioOutputDeviceService {

@override
Future<bool> select(AudioOutputDevice device) async => false;

/// Nothing to observe: an empty stream that closes immediately, so a listener
/// is never left waiting on events that cannot come.
@override
Stream<List<AudioOutputDevice>> get deviceChanges =>
const Stream<List<AudioOutputDevice>>.empty();
}
3 changes: 3 additions & 0 deletions lib/core/services/platform_audio_output_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,7 @@ class PlatformAudioOutputDeviceService implements AudioOutputDeviceService {

@override
Future<bool> select(AudioOutputDevice device) => _delegate.select(device);

@override
Stream<List<AudioOutputDevice>> get deviceChanges => _delegate.deviceChanges;
}
Loading
Loading