Skip to content

fix(linux): recover cleanly when audio devices come and go (#403) - #617

Draft
TheZupZup wants to merge 3 commits into
mainfrom
fix/403-linux-audio-hotplug
Draft

fix(linux): recover cleanly when audio devices come and go (#403)#617
TheZupZup wants to merge 3 commits into
mainfrom
fix/403-linux-audio-hotplug

Conversation

@TheZupZup

Copy link
Copy Markdown
Owner

Closes #403.

The problem

Devices change while music is playing: headphones get 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. #402 only ever reads the output list — at launch and when Settings asks — so a device disappearing mid-track left playback pointed at a sink that was not there.

Architecture

This adds observation to the existing #402 seam rather than a second one.

AudioOutputDeviceService.deviceChanges is libmpv's audio-device-list watched instead of read once. It is observation only: it starts, stops and re-routes nothing. The rules for reacting live in AudioOutputController, next to the ones that already decide which output is chosen and whether it is remembered — they are the same decision seen from the other side.

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, 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 says why it moved. The preference is kept.
The chosen device comes back Hands playback back and clears the notice.
The fallback is refused too A recoverable "playback may be silent" state with Try again, rather than leaving playback silently muted.

No duplicate players or listeners

  • Nothing is ever re-loaded. Recovery is a routing decision — the queue, track and position are untouched and no player is created — so a device event cannot produce duplicate playback.
  • Exactly one subscription. The controller opens one in build and closes it with the notifier; the Linux service keeps at most one device-list listener per live player, keyed by the just_audio player id, so attaching is idempotent. One unplug is handled once.
  • Nothing polls. See below.

The one shared change: livePlayersChanged

The engine tears a player down and builds a new one on a stop, on suspend/resume and on some source switches, so a device-list listener attached to one player has to follow it. Without a signal the only way to notice would be to poll JustAudioMediaKit.livePlayers on a timer for the whole life of the app — exactly the kind of idle wake-up #344 removed.

So the vendored plugin gains a third hunk beside the two it already carries: a livePlayersChanged broadcast controller, with one event next to each of the two map writes that already existed. PATCHES.md documents it in the same shape as the others, and scripts/check_vendored_packages.sh passes (provenance + analysis).

This is contained in this PR — no other task in the batch needs it.

Memory remembers what disk forgets

A saved output that was never seen on this machine is still dropped at launch: that is the "saved on another machine" rule from #402 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 followed by a Refresh would quietly lose what the listener picked.

When nothing is playing

Watching needs a live player, and Linthra will not create one just to watch — a settings screen that spun up a second libmpv handle would be the next bug report. There is nothing to recover in that state either, since no audio is being interrupted; the next play, or the card's Refresh, picks up whatever changed. Documented rather than papered over.

Android

Untouched, structurally: the seam reports itself unsupported off Linux, its deviceChanges is an empty stream, and the controller never opens a subscription when the service is unsupported (asserted). No Android output routing code is in the diff.

Tests

test/features/settings/playback/audio_output_hotplug_test.dart (16) drives every row of the table against a fake backend:

  • the watch: exactly one subscription however many times the provider is read, released on dispose, never opened for an unsupported backend;
  • transparent continuation: a device appearing, an unrelated one disappearing, and the system default moving all leave playback alone (asserted as zero routing calls);
  • loss: wired-headphone unplug falls back and says so, the preference is kept, a refused fallback surfaces outputRecoveryFailed, and a retry after the backend recovers clears it;
  • reconnect: a Bluetooth speaker coming back takes playback back, routes once not twice, a repeated identical list changes nothing, and a device flapping over five connect/disconnect cycles ends correct with one subscription and the preference intact;
  • the UI list following the host, and an empty report not being treated as "every output is gone";
  • the [Linux] Add PipeWire audio output device selection #402 startup rules still holding, including a refresh during a dropout keeping a device that worked this session.

Plus two widget tests in audio_output_settings_section_test.dart for the recoverable note and its Try again, and a platform-split test that Linux forwards the watch while Android has nothing to watch.

Checks

  • dart format --set-exit-if-changed lib test — clean
  • flutter analyze lib test — no issues
  • flutter test — full suite, 4899 tests, all passing
  • scripts/check_vendored_packages.sh — provenance and analysis both pass

What needs a real Linux box

The policy is fully deterministic in tests; libmpv itself is not (flutter test runs on the Dart VM with no native bundle). docs/linux-desktop.md gains a Device hotplug section with the manual matrix: wired plug/unplug mid-track, Bluetooth out-of-range and back, HDMI sleep/wake, and changing the desktop's default sink while on "System default" — each checked for no restart, no duplicated audio, and the card matching reality.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LLUbVqJ6e9qfW5GTEFm1gN


Generated by Claude Code

TheZupZup and others added 3 commits September 10, 2026 14:45
Devices change while music is playing: headphones get 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. Until now Linthra only looked at the
output list when Settings asked, so a device disappearing mid-track left
playback pointed at a sink that was not there.

This adds observation to the existing #402 seam rather than a second one.
`AudioOutputDeviceService.deviceChanges` is libmpv's `audio-device-list`
watched instead of 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 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;
- it is gone -> fall back to the system default so audio stays audible, and say
  why it moved. The preference is kept;
- it comes back -> hand playback back and clear the notice;
- the fallback is refused too -> a recoverable "playback may be silent" state
  with a retry, instead of leaving playback silently muted;
- nothing was chosen -> the system default follows the host, including when the
  host changes it.

Three properties keep this from causing the bugs it is meant to fix. Nothing is
re-loaded: recovery is a routing decision, so the queue, track and position are
untouched and no second player is created. Exactly one subscription exists: one
in the controller, closed with the notifier, and at most one device-list
listener per live player, keyed by player id. And nothing polls.

That last one needs a signal from the vendored plugin, since the engine tears a
player down and builds a new one on stop/suspend/source-switch: `livePlayers`
gains a `livePlayersChanged` broadcast, one event beside each of the two writes
that already existed. Without it the service would have to poll the registry on
a timer for the life of the app.

Memory now remembers what disk forgets: a saved output never seen on this
machine is still dropped at launch, but one that has been playing this session
survives a dropout, so a refresh during a Bluetooth outage no longer loses the
listener's choice.

Android output routing is untouched: the seam reports itself unsupported there,
its watch is an empty stream, and no subscription is ever opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLUbVqJ6e9qfW5GTEFm1gN
#609 landed the Flatpak audio smoke, which regenerated the vendored
plugin's `upstream.patch` for a doc-comment change (`ao=alsa` became a
configurable `ao`). This branch regenerated the same file to add the
`livePlayersChanged` hunk, so the two collided on the generated artifact.

The vendored source auto-merged correctly and carries both changes.
`upstream.patch` was regenerated the way PATCHES.md says to — reverse-apply
main's recorded patch to recover pristine upstream, then diff pristine
against the merged tree — rather than resolved by hand.
`scripts/check_vendored_packages.sh` passes provenance and analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLUbVqJ6e9qfW5GTEFm1gN
#613 landed the Linux playback diagnostics. Two collisions, one git could see
and one it could not:

- both branches added a row to the same support table in docs/linux-desktop.md
  at the same place. Both rows are kept, hotplug next to the audio-output row
  it extends;
- #613's new `_FakeOutputService` implements `AudioOutputDeviceService`, and
  this branch adds `deviceChanges` to that interface, so the fake no longer
  satisfied it. It gets an empty stream, like the other fakes: the diagnostics
  collector never listens, and nothing plugs into a fake.

The second one merges clean and fails to compile, which is exactly the kind of
thing that turns CI red on a "trivial" merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLUbVqJ6e9qfW5GTEFm1gN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Linux] Handle audio device hotplug cleanly

1 participant