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:
@@ -57,6 +57,17 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
/// time the buffered-position stream emits (~200ms cadence).
|
/// time the buffered-position stream emits (~200ms cadence).
|
||||||
String? _cacheDirPath;
|
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
|
/// True while _fillRemainingSources is doing backward-fill inserts
|
||||||
/// at index 0..initialIndex-1. Each insert shifts the player's
|
/// at index 0..initialIndex-1. Each insert shifts the player's
|
||||||
/// currentIndex (it tracks the actively-playing source through
|
/// currentIndex (it tracks the actively-playing source through
|
||||||
@@ -138,6 +149,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
String? source,
|
String? source,
|
||||||
}) async {
|
}) async {
|
||||||
if (tracks.isEmpty) return;
|
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;
|
_queueSource = source;
|
||||||
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
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)));
|
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
|
@override
|
||||||
Future<void> play() => _player.play();
|
Future<void> play() => _player.play();
|
||||||
|
|
||||||
@@ -639,6 +695,39 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
LoopMode.all => AudioServiceRepeatMode.all,
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user