From 031041adea1991c151d8c3b86e42d6d5ca30b25e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 21 May 2026 14:49:52 -0400 Subject: [PATCH] fix(player): keep MediaSession alive across idle teardown (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wear OS companion app's MediaController caches the MediaSession token at first bind. When our idle timer fired super.stop() — which calls stopSelf() on the AudioService and makes it eligible for OS destruction under memory pressure — the next play() spun up a fresh MediaSession with a different token. The companion's cached controller still pointed at the dead one, so transport taps from notification + watch silently no-op'd even though PlaybackState broadcasts kept flowing (those go through the live session). User-side workaround was unpair/repair of the Watch. Split stop() into: - _softTeardown: stops the player, clears mediaItem/queue, broadcasts idle. Display surfaces drop their visible state (this is what made notification + watch tile cleanup work today; not super.stop()). - stop(): _softTeardown + super.stop(). Reserved for explicit close (onTaskRemoved while idle). _onIdleTimeout now calls _softTeardown — the FGS + MediaSession stay alive across idle, preserving the Wear binding. Explicit user-close still terminates the service fully. Diagnostic debugPrints from the investigation phase removed. Research: ryanheise/audio_service 0.18.18 has been stale ~13 months, no Media3 migration in flight upstream. This is the surgical fix that respects the plugin's lifecycle contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/player/audio_handler.dart | 44 ++++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 5a432e4b..38614724 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -559,13 +559,24 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl mediaItem.add(current.copyWith(artUri: Uri.file(path))); } - /// Drives the session to a terminal state and tears down the - /// foreground service so external surfaces (Wear tile, lock screen, - /// in-app mini bar) drop it instead of showing a stale paused track. - /// Called on the idle timeout and from onTaskRemoved when not playing; - /// no other path stops the session (the notification is ongoing). - @override - Future stop() async { + /// Display-state-only teardown. Stops playback, clears the queue and + /// current track, and broadcasts idle so external surfaces (Wear tile, + /// notification, in-app mini bar) drop their visible state — but does + /// NOT call super.stop(), so the FGS and MediaSessionCompat remain + /// alive and addressable. + /// + /// Why this matters (Fable #472): the paired Wear OS companion app + /// caches a MediaController bound to our MediaSession's token. If + /// the system destroys our service (super.stop() → stopSelf() makes + /// it eligible under memory pressure), the next play() spins up a + /// new MediaSession with a fresh token; the companion's cached + /// controller still points at the dead one and transport taps from + /// notification + watch silently no-op. Keeping the service alive + /// across idle periods preserves the binding. + /// + /// Used by the #52 idle timeout. Full termination (super.stop) is + /// reserved for the explicit-close path (onTaskRemoved while idle). + Future _softTeardown() async { _idleStopTimer?.cancel(); _idleStopTimer = null; _positionBroadcastTimer?.cancel(); @@ -575,17 +586,22 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl try { await _player.stop(); } catch (_) {} - // Explicit terminal broadcast before super.stop() tears down the - // isolate/notification, so subscribers see idle even if the - // player's own event lags the service teardown. queue/mediaItem - // cleared so the in-app mini bar collapses in lockstep with the - // watch tile rather than lingering on the last track. playbackState.add(playbackState.value.copyWith( playing: false, processingState: AudioProcessingState.idle, )); mediaItem.add(null); queue.add([]); + } + + /// Full termination — soft teardown plus super.stop(), which calls + /// stopSelf() on the AudioService and lets the OS reclaim it. Reserved + /// for the explicit-close path (onTaskRemoved when not playing). On the + /// idle path we use _softTeardown instead to preserve the Wear OS + /// MediaController binding (Fable #472). + @override + Future stop() async { + await _softTeardown(); await super.stop(); } @@ -817,7 +833,9 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl if (_player.playing && ps != ProcessingState.completed) { return; // resumed between arm and fire } - unawaited(stop()); + // _softTeardown — not full stop() — to preserve the MediaSession + // binding for the paired Wear OS companion (Fable #472). + unawaited(_softTeardown()); } /// While actively playing, keeps a 1s periodic PlaybackState re-