feat(player): periodic PlaybackState refresh for smooth external scrubber

_broadcastState only set updatePosition on transitions, so the lock-
screen / Wear / Android Auto scrubber jumped in chunks (the in-app bar
uses positionStream and was fine). Add _positionBroadcastTimer: a 1s
periodic PlaybackState re-broadcast while actively playing so
updateTime/updatePosition stay fresh and external surfaces interpolate
smoothly. Idempotent (driven from _broadcastState, which the tick
itself calls — guarded against pile-up), cancelled when not playing
and in stop(). 6a: the notification progress bar now advances since
MediaItem.duration was already set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 16:28:46 -04:00
parent 41dd2892e3
commit 77a4a55522
@@ -70,6 +70,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// cancelled the moment playback resumes or a new queue is set.
Timer? _idleStopTimer;
/// Periodic PlaybackState re-broadcast while actively playing so
/// external surfaces (lock screen, Wear, Android Auto) interpolate the
/// scrubber smoothly. The in-app bar uses positionStream and doesn't
/// need this. Active only while playing; cancelled otherwise.
Timer? _positionBroadcastTimer;
/// Player volume captured when an OS duck interruption begins,
/// restored when it ends. Null when not currently ducked.
double? _volumeBeforeDuck;
@@ -544,6 +550,8 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
Future<void> stop() async {
_idleStopTimer?.cancel();
_idleStopTimer = null;
_positionBroadcastTimer?.cancel();
_positionBroadcastTimer = null;
try {
await _player.stop();
} catch (_) {}
@@ -712,6 +720,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
},
));
_reconcileIdleTimer();
_reconcilePositionBroadcast();
}
/// Arms (or cancels) the idle-cleanup timer based on the current
@@ -746,6 +755,28 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
unawaited(stop());
}
/// While actively playing, keeps a 1s periodic PlaybackState re-
/// broadcast running so updateTime/updatePosition stay fresh and
/// external surfaces interpolate the scrubber smoothly. Idempotent and
/// driven from _broadcastState — which the periodic tick itself calls,
/// so the "already running" guard prevents pile-up. Cancels the moment
/// playback is no longer active.
void _reconcilePositionBroadcast() {
final ps = _player.processingState;
final active = _player.playing &&
ps != ProcessingState.idle &&
ps != ProcessingState.completed;
if (!active) {
_positionBroadcastTimer?.cancel();
_positionBroadcastTimer = null;
return;
}
_positionBroadcastTimer ??= Timer.periodic(
const Duration(seconds: 1),
(_) => _broadcastState(null),
);
}
/// Configures the OS audio session for music playback and wires
/// interruption + becoming-noisy handling. just_audio auto-activates /
/// deactivates the session around playback once it's configured, but