From b6b73fdd0c5d86985eeb9356bb0057da96354de8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 08:15:58 -0400 Subject: [PATCH] fix(flutter): live seek bar (~200ms) + breathing room above controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why the seek bar appeared frozen: it was reading PlaybackState.updatePosition, which audio_service only updates on event transitions (play/pause/buffer/seek). Between events it sits unchanged, so the bar only jumped at intervals. Expose just_audio's positionStream (~200ms cadence) from the audio handler, wrap as positionProvider, and read that in both the mini bar and the full player. Now the bar advances continuously while playing. While we're here: spread the full player's vertical layout per operator — gap to seek 20→32, seek-to-primary 8→24, primary-to- secondary 16→24, plus 24 below to match. The full player had visible slack above the controls; this redistributes it. --- flutter_client/lib/player/audio_handler.dart | 8 ++++++++ flutter_client/lib/player/now_playing_screen.dart | 14 +++++++++----- flutter_client/lib/player/player_bar.dart | 5 ++++- flutter_client/lib/player/player_provider.dart | 9 +++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 57c14bed..945ac971 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -31,6 +31,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl Stream get volumeStream => _player.volumeStream; double get volume => _player.volume; + /// Position stream for UI subscribers. just_audio emits roughly every + /// 200ms while playing, which is what makes the seek bar advance + /// smoothly. PlaybackState.updatePosition only changes on event + /// transitions (play/pause/buffer), so it's too coarse for live + /// scrubbing UI. + Stream get positionStream => _player.positionStream; + Duration get position => _player.position; + void configure({ required String baseUrl, required String? token, diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index 17b96e24..5cdc2e11 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -62,7 +62,11 @@ class _NowPlayingScreenState extends ConsumerState { return const Scaffold(body: Center(child: Text('Nothing playing.'))); } - final pos = playback?.updatePosition ?? Duration.zero; + // Use positionProvider (just_audio's positionStream, ~200ms) for + // the seek bar so it scrubs live; PlaybackState.updatePosition + // only fires on state transitions and would leave the bar frozen + // between them. + final pos = ref.watch(positionProvider).value ?? Duration.zero; final dur = media.duration ?? Duration.zero; final isPlaying = playback?.playing == true; final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; @@ -109,22 +113,22 @@ class _NowPlayingScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ], - const SizedBox(height: 20), + const SizedBox(height: 32), _SeekRow(position: pos, duration: dur, fs: fs, ref: ref), - const SizedBox(height: 8), + const SizedBox(height: 24), _PrimaryControls( fs: fs, ref: ref, isPlaying: isPlaying, ), - const SizedBox(height: 16), + const SizedBox(height: 24), _SecondaryControls( fs: fs, actions: actions, shuffleOn: shuffleOn, repeatMode: repeatMode, ), - const Spacer(), + const SizedBox(height: 24), ], ), ), diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 1fa84e16..4e40013d 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -36,7 +36,10 @@ class PlayerBar extends ConsumerWidget { final playback = ref.watch(playbackStateProvider).value; if (media == null) return const SizedBox.shrink(); - final pos = playback?.updatePosition ?? Duration.zero; + // positionProvider is just_audio's positionStream (~200ms cadence) + // so the mini bar's seek crawls forward smoothly. PlaybackState + // only updates on event transitions and would leave it frozen. + final pos = ref.watch(positionProvider).value ?? Duration.zero; final dur = media.duration ?? Duration.zero; return Material( diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index 8cbeff3c..eafcb9a7 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -38,6 +38,15 @@ final volumeProvider = StreamProvider( (ref) => ref.watch(audioHandlerProvider).volumeStream, ); +/// Live playback position. just_audio emits at ~200ms cadence so seek +/// bars driven by this provider scrub smoothly. Don't use +/// PlaybackState.updatePosition for that — it only changes on state +/// transitions (play/pause/buffer/seek) and the bar would appear +/// frozen between events. +final positionProvider = StreamProvider( + (ref) => ref.watch(audioHandlerProvider).positionStream, +); + class PlayerActions { PlayerActions(this._ref); final Ref _ref;