fix(linux): recover cleanly when audio devices come and go (#403) - #617
Draft
TheZupZup wants to merge 3 commits into
Draft
fix(linux): recover cleanly when audio devices come and go (#403)#617TheZupZup wants to merge 3 commits into
TheZupZup wants to merge 3 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.deviceChangesis libmpv'saudio-device-listwatched instead of read once. It is observation only: it starts, stops and re-routes nothing. The rules for reacting live inAudioOutputController, 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.No duplicate players or listeners
buildand 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.The one shared change:
livePlayersChangedThe 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.livePlayerson 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
livePlayersChangedbroadcast controller, with one event next to each of the two map writes that already existed.PATCHES.mddocuments it in the same shape as the others, andscripts/check_vendored_packages.shpasses (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
deviceChangesis 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:outputRecoveryFailed, and a retry after the backend recovers clears it;Plus two widget tests in
audio_output_settings_section_test.dartfor 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— cleanflutter analyze lib test— no issuesflutter test— full suite, 4899 tests, all passingscripts/check_vendored_packages.sh— provenance and analysis both passWhat needs a real Linux box
The policy is fully deterministic in tests; libmpv itself is not (
flutter testruns on the Dart VM with no native bundle).docs/linux-desktop.mdgains 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