From 5bf85a5f4479eedd49ed3bc4b4f2dfb3a24970a7 Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 30 Jul 2026 15:30:07 -0400 Subject: [PATCH] fixes to shuffle and queue --- lib/playback/playback_engine.dart | 117 ++++++++++++++++-- lib/screens/home_screen.dart | 178 +++++++++++++++++++++------- lib/screens/now_playing_screen.dart | 72 +++++++++-- 3 files changed, 312 insertions(+), 55 deletions(-) diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index b532b1b..0fbd747 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' show File, Platform; +import 'dart:math' show Random; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -108,6 +109,14 @@ class PlaybackController extends StateNotifier { AudioPlayer? _player; + final Random _random = Random(); + + /// Snapshot of the queue order taken the moment shuffle was enabled, so + /// disabling it can restore the original (album / track-list) order. Null + /// while shuffle is off, or after a restart restored a shuffled queue (the + /// pre-shuffle order isn't persisted, so we can't rebuild it then). + List? _unshuffledOrder; + /// Coalesces rapid state changes (a position tick every second) into at most /// one disk write per window. Timer? _saveTimer; @@ -202,11 +211,26 @@ class PlaybackController extends StateNotifier { // itself have been unstreamable). final target = songs[startIndex.clamp(0, songs.length - 1)]; final targetIndex = streamable.indexOf(target); - final start = + var start = targetIndex >= 0 ? targetIndex : startIndex.clamp(0, streamable.length - 1); + // With shuffle on, a fresh play still plays the chosen song first and + // shuffles the rest (we own the order — the player's own shuffle stays off). + // Remember the incoming order so a later un-shuffle can restore it. + var ordered = streamable; + if (state.shuffle && streamable.length > 1) { + _unshuffledOrder = [...streamable]; + final first = streamable[start]; + final rest = [...streamable]..removeAt(start); + rest.shuffle(_random); + ordered = [first, ...rest]; + start = 0; + } else { + _unshuffledOrder = null; + } + state = state.copyWith( - queue: streamable, + queue: ordered, currentIndex: start, position: Duration.zero, ); @@ -221,13 +245,30 @@ class PlaybackController extends StateNotifier { return; } await player.setAudioSources( - streamable.map(_sourceFor).toList(), + ordered.map(_sourceFor).toList(), initialIndex: start, ); await player.play(); // currentIndexStream fires _notifyCurrent for the started track. } + /// Start playing the queue entry at [index] without rebuilding the queue — + /// used by the queue list so tapping a row jumps to it and keeps the current + /// (possibly shuffled) order intact. + Future jumpTo(int index) async { + final q = state.queue; + if (index < 0 || index >= q.length) return; + final player = _player; + if (player == null) { + state = state.copyWith(currentIndex: index, position: Duration.zero); + _notifyCurrent(); + return; + } + await player.seek(Duration.zero, index: index); + await player.play(); + // currentIndexStream fires _notifyCurrent for the jumped-to track. + } + /// Insert [song] right after the current track (Timbre's "play next"). /// Falls back to [playSongs] when nothing is playing. Future playNext(Song song) async { @@ -313,15 +354,76 @@ class PlaybackController extends StateNotifier { Future seek(Duration position) async => _player?.seek(position); + /// Toggle shuffle by physically reordering the queue. + /// + /// just_audio's built-in shuffle only reorders an invisible internal index — + /// `state.queue` (and therefore the queue UI) stays in album order, so the + /// playing track appears to jump around and `playNext` / `addToQueue` insert + /// relative to a position the shuffle ignores. Instead we own the order: + /// enabling shuffles only the *upcoming* songs and keeps the current track + /// (and already-played history) in place; disabling restores the pre-shuffle + /// order, dropping anything since removed and appending anything since added. + /// The player's own shuffle mode is left off permanently. Future toggleShuffle() async { final enabled = !state.shuffle; + final q = state.queue; + final cur = state.currentIndex; state = state.copyWith(shuffle: enabled); - final player = _player; - if (player != null) { - await player.setShuffleModeEnabled(enabled); + + if (q.isEmpty || cur == null) return; + + if (enabled) { + _unshuffledOrder = [...q]; + final head = q.sublist(0, cur + 1); + final tail = q.sublist(cur + 1)..shuffle(_random); + await _applyOrder([...head, ...tail]); + } else { + final original = _unshuffledOrder; + _unshuffledOrder = null; + if (original == null) return; // e.g. a shuffled queue restored on launch. + final live = q.toSet(); + final originalSet = original.toSet(); + final restored = [ + for (final s in original) if (live.contains(s)) s, + for (final s in q) if (!originalSet.contains(s)) s, + ]; + await _applyOrder(restored); } } + /// Reorder both `state.queue` and the player playlist to match [target]. + /// + /// [target] must be a permutation of the current queue by object identity. + /// We rebuild the player's sources from [target] in one shot (rather than a + /// sequence of live moves) so `state.queue` and the player can never drift + /// out of alignment — the playing track is reloaded at its current position, + /// so at most it re-buffers briefly. `currentIndex` follows the playing track + /// to its new slot. + Future _applyOrder(List target) async { + final cur = state.current; + var newIndex = state.currentIndex ?? 0; + if (cur != null) { + final idx = target.indexWhere((s) => identical(s, cur)); + if (idx >= 0) newIndex = idx; + } + final position = state.position; + final wasPlaying = state.playing; + + state = state.copyWith(queue: target, currentIndex: newIndex); + + final player = _player; + if (player == null) return; + + // The current song is unchanged, so [_notifyCurrent]'s id dedupe suppresses + // a spurious re-scrobble when currentIndexStream re-fires for the rebuild. + await player.setAudioSources( + target.map(_sourceFor).toList(), + initialIndex: newIndex, + initialPosition: position, + ); + if (wasPlaying) await player.play(); + } + /// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with /// single-track repeat). Future cycleLoop() async { @@ -452,7 +554,8 @@ class PlaybackController extends StateNotifier { streamable.map(_sourceFor).toList(), initialIndex: start, ); - await player.setShuffleModeEnabled(shuffle); + // The persisted queue is already stored in play order, so shuffle is a UI + // flag only — the player's own shuffle mode stays off (see [toggleShuffle]). await player.setLoopMode(loop); if (savedPos > Duration.zero) await player.seek(savedPos, index: start); // Intentionally no play() — restore leaves the queue paused. diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8fae648..afc793d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -18,7 +18,6 @@ class HomeScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(subsonicClientProvider); final recentAlbums = ref.watch(recentAlbumsProvider); - final rediscover = ref.watch(rediscoverProvider); final newest = ref.watch(newestAlbumsProvider); final random = ref.watch(randomAlbumsProvider); @@ -62,31 +61,9 @@ class HomeScreen extends ConsumerWidget { artFor: artFor, ), - // Made For You — rediscover artists you're neglecting. - if (rediscover.isNotEmpty) - _Shelf( - title: 'Made For You', - onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++, - cards: [ - for (final a in rediscover) - _ArtCard( - artUri: null, - title: a.name, - subtitle: 'Artist', - fallbackIcon: Icons.person_outline, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ArtistScreen(id: a.artistId), - ), - ), - ), - ], - ), - - // Random — server discovery shelf, shares the shuffle affordance. - _AlbumShelf( - title: 'Random', - albums: random, + // Random — a single spotlighted album, re-rolled via the shuffle action. + _RandomAlbum( + album: random, artFor: artFor, onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++, ), @@ -262,14 +239,12 @@ class _HeroShell extends StatelessWidget { } } -/// A horizontal shelf: a header (with an optional shuffle action) over a -/// scrolling row of art cards. +/// A horizontal shelf: a header over a scrolling row of art cards. class _Shelf extends StatelessWidget { - const _Shelf({required this.title, required this.cards, this.onShuffle}); + const _Shelf({required this.title, required this.cards}); final String title; final List cards; - final VoidCallback? onShuffle; @override Widget build(BuildContext context) { @@ -279,7 +254,7 @@ class _Shelf extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _ShelfHeader(title: title, onShuffle: onShuffle), + _ShelfHeader(title: title), const SizedBox(height: TimbreSpacing.md), SizedBox( height: 182, @@ -335,26 +310,22 @@ class _AlbumShelf extends StatelessWidget { required this.title, required this.albums, required this.artFor, - this.onShuffle, }); final String title; final AsyncValue> albums; final String? Function(String? coverArt, {int size}) artFor; - final VoidCallback? onShuffle; @override Widget build(BuildContext context) { return albums.when( loading: () => _Shelf( title: title, - onShuffle: onShuffle, cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()], ), error: (_, _) => const SizedBox.shrink(), data: (list) => _Shelf( title: title, - onShuffle: onShuffle, cards: [ for (final a in list) _ArtCard( @@ -371,6 +342,134 @@ class _AlbumShelf extends StatelessWidget { } } +/// Random spotlight — a single album shown as cover-left / details-right. +/// Details are the album title, artist, release year and genre. Re-rolls via +/// the header's shuffle affordance. Hidden while loading errors or is empty. +class _RandomAlbum extends StatelessWidget { + const _RandomAlbum({ + required this.album, + required this.artFor, + this.onShuffle, + }); + + final AsyncValue> album; + final String? Function(String? coverArt, {int size}) artFor; + final VoidCallback? onShuffle; + + static const double _size = 132; + + @override + Widget build(BuildContext context) { + final Album? a = album.maybeWhen( + data: (list) => list.isEmpty ? null : list.first, + orElse: () => null, + ); + final bool loading = album.isLoading; + if (a == null && !loading) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: TimbreSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ShelfHeader(title: 'Random', onShuffle: onShuffle), + const SizedBox(height: TimbreSpacing.md), + if (a == null) + const _RandomSkeleton() + else + InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), + ), + child: _RandomBody(album: a, artUri: artFor(a.coverArt)), + ), + ], + ), + ); + } +} + +class _RandomBody extends StatelessWidget { + const _RandomBody({required this.album, required this.artUri}); + + final Album album; + final String? artUri; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: _RandomAlbum._size, + height: _RandomAlbum._size, + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network(artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const Icon( + Icons.album_outlined, color: TimbreColors.dimmed)) + : const Icon(Icons.album_outlined, color: TimbreColors.dimmed), + ), + ), + ), + const SizedBox(width: TimbreSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(album.name ?? 'Unknown album', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700)), + const SizedBox(height: TimbreSpacing.xs), + if (album.artist != null) + Text(album.artist!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: TimbreColors.dimmed)), + if (album.year != null) + Text('${album.year}', + style: const TextStyle( + color: TimbreColors.dimmed, fontSize: 12)), + if (album.genre != null) + Text(album.genre!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: TimbreColors.dimmed, fontSize: 12)), + ], + ), + ), + ], + ); + } +} + +class _RandomSkeleton extends StatelessWidget { + const _RandomSkeleton(); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: const SizedBox( + width: _RandomAlbum._size, + height: _RandomAlbum._size, + child: ColoredBox(color: TimbreColors.surface), + ), + ); + } +} + /// A fixed-width art tile with a caption — the shelf's building block. class _ArtCard extends StatelessWidget { const _ArtCard({ @@ -378,14 +477,12 @@ class _ArtCard extends StatelessWidget { required this.artUri, this.subtitle, this.onTap, - this.fallbackIcon = Icons.album_outlined, }); final String title; final String? artUri; final String? subtitle; final VoidCallback? onTap; - final IconData fallbackIcon; static const double _size = 132; @@ -410,9 +507,10 @@ class _ArtCard extends StatelessWidget { key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, - errorBuilder: (_, _, _) => - Icon(fallbackIcon, color: TimbreColors.dimmed)) - : Icon(fallbackIcon, color: TimbreColors.dimmed), + errorBuilder: (_, _, _) => const Icon( + Icons.album_outlined, color: TimbreColors.dimmed)) + : const Icon(Icons.album_outlined, + color: TimbreColors.dimmed), ), ), ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f122c15..9853074 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -210,28 +210,84 @@ class _QueueToggle extends StatelessWidget { } /// The play queue with reorder-free remove controls. Fills whichever space the -/// top region gives it. -class _QueuePanel extends ConsumerWidget { +/// top region gives it. Because the engine keeps `state.queue` in true play +/// order (even when shuffled), rows read top-to-bottom as history → now +/// playing → up next: already-played rows are dimmed and the list auto-scrolls +/// to keep the current track at the top as playback advances. +class _QueuePanel extends ConsumerStatefulWidget { const _QueuePanel(); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_QueuePanel> createState() => _QueuePanelState(); +} + +class _QueuePanelState extends ConsumerState<_QueuePanel> { + /// Fixed row height: a [TimbreSpacing.minTouchTarget] tall remove button plus + /// the [TimbreSpacing.xs] vertical padding above and below it. Pinning the + /// extent lets us scroll to a row by index without measuring. + static const double _rowExtent = + TimbreSpacing.minTouchTarget + TimbreSpacing.xs * 2; + + final ScrollController _controller = ScrollController(); + bool _didInitialScroll = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// Bring the row at [index] to the top of the viewport (clamped), after the + /// current frame so the list has laid out. + void _scrollToIndex(int? index) { + if (index == null || index < 0) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_controller.hasClients) return; + final target = + (index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent); + _controller.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + }); + } + + @override + Widget build(BuildContext context) { final state = ref.watch(playbackProvider); final accent = Theme.of(context).colorScheme.primary; + + // Follow the playing track as it advances (or as shuffle reorders things). + ref.listen( + playbackProvider.select((s) => s.currentIndex), + (_, next) => _scrollToIndex(next), + ); + // Jump to the current track the first time the queue is populated. + if (!_didInitialScroll && state.queue.isNotEmpty) { + _didInitialScroll = true; + _scrollToIndex(state.currentIndex); + } + return HairlinePanel( title: 'Queue', trailing: '(${state.queue.length})', padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: ListView.builder( + controller: _controller, padding: EdgeInsets.zero, + itemExtent: _rowExtent, itemCount: state.queue.length, itemBuilder: (context, i) { final song = state.queue[i]; - final isCurrent = i == state.currentIndex; + final current = state.currentIndex; + final isCurrent = i == current; + final isPast = current != null && i < current; + final titleColor = isCurrent + ? accent + : (isPast ? TimbreColors.dimmed : TimbreColors.foreground); return InkWell( - onTap: () => ref - .read(playbackProvider.notifier) - .playSongs(state.queue, startIndex: i), + onTap: () => ref.read(playbackProvider.notifier).jumpTo(i), child: Padding( padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.lg, @@ -250,7 +306,7 @@ class _QueuePanel extends ConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: isCurrent ? accent : TimbreColors.foreground, + color: titleColor, fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w400, ),