From 77a4a555223b04b703bcf5985bfc5d32c3d5c022 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 18 May 2026 16:28:46 -0400 Subject: [PATCH] feat(player): periodic PlaybackState refresh for smooth external scrubber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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) --- flutter_client/lib/player/audio_handler.dart | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index a2713d98..8e60c65d 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 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