feat(player): tear down MediaSession when idle/dismissed

Nothing drove the audio_service session to a terminal state and the
notification is configured ongoing, so the Wear tile / lock screen /
notification lingered on a stale paused track indefinitely.

- stop() override: pause, broadcast idle, clear queue/mediaItem, then
  super.stop() so the foreground service + notification (and watch tile)
  tear down; in-app mini bar collapses in lockstep.
- onTaskRemoved(): keep playing if audio is active (standard media
  behaviour), otherwise stop so a dismissed-while-paused app doesn't
  leave a stale tile.
- 5-minute idle timer armed while paused or on a finished queue,
  cancelled on resume / new queue, re-checked at fire time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:38:42 -04:00
parent e68e1b10a6
commit 905f05c120
@@ -57,6 +57,17 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// time the buffered-position stream emits (~200ms cadence).
String? _cacheDirPath;
/// How long the session may sit not-actively-playing (paused, or a
/// finished queue) before we tear it down so the Wear tile / lock
/// screen / notification don't linger on a stale track. The
/// notification is configured ongoing (main.dart), so nothing else
/// ever drives the MediaSession to a terminal state.
static const _idleStopTimeout = Duration(minutes: 5);
/// Single-shot cleanup timer, armed while not actively playing and
/// cancelled the moment playback resumes or a new queue is set.
Timer? _idleStopTimer;
/// True while _fillRemainingSources is doing backward-fill inserts
/// at index 0..initialIndex-1. Each insert shifts the player's
/// currentIndex (it tracks the actively-playing source through
@@ -138,6 +149,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
String? source,
}) async {
if (tracks.isEmpty) return;
// New playback supersedes any pending idle cleanup. play() below
// re-broadcasts and _reconcileIdleTimer would cancel anyway; doing
// it up front avoids a race during the pause→swap window.
_idleStopTimer?.cancel();
_idleStopTimer = null;
_queueSource = source;
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
@@ -503,6 +519,46 @@ 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 {
_idleStopTimer?.cancel();
_idleStopTimer = null;
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([]);
await super.stop();
}
/// App swiped away from recents. Keep playing if audio is active
/// (standard media behaviour — music shouldn't die because the app
/// left recents); otherwise stop so the watch tile / notification
/// don't linger on a stale paused track.
@override
Future<void> onTaskRemoved() async {
if (_player.playing &&
_player.processingState != ProcessingState.completed) {
await super.onTaskRemoved();
return;
}
await stop();
}
@override
Future<void> play() => _player.play();
@@ -639,6 +695,39 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
LoopMode.all => AudioServiceRepeatMode.all,
},
));
_reconcileIdleTimer();
}
/// Arms (or cancels) the idle-cleanup timer based on the current
/// player state. Driven from _broadcastState, which fires on every
/// play/pause/buffer/complete transition, so paused AND finished-queue
/// both arm it. Idle (already stopped) never re-arms — otherwise stop()
/// would loop every _idleStopTimeout.
void _reconcileIdleTimer() {
final ps = _player.processingState;
if (ps == ProcessingState.idle) {
_idleStopTimer?.cancel();
_idleStopTimer = null;
return;
}
final activelyPlaying =
_player.playing && ps != ProcessingState.completed;
if (activelyPlaying) {
_idleStopTimer?.cancel();
_idleStopTimer = null;
return;
}
_idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout);
}
void _onIdleTimeout() {
_idleStopTimer = null;
final ps = _player.processingState;
if (ps == ProcessingState.idle) return; // already torn down
if (_player.playing && ps != ProcessingState.completed) {
return; // resumed between arm and fire
}
unawaited(stop());
}
}