fix(player): keep MediaSession alive across idle teardown (#472)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 14:49:52 -04:00
parent 6df02428b0
commit 031041adea
+31 -13
View File
@@ -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<void> 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<void> _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<void> 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-