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:
@@ -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-
|
||||
|
||||
Reference in New Issue
Block a user