diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 6e39a924..6626ba40 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 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 onTaskRemoved() async { + if (_player.playing && + _player.processingState != ProcessingState.completed) { + await super.onTaskRemoved(); + return; + } + await stop(); + } + @override Future 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()); } }