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;