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-