fixes to shuffle and queue

This commit is contained in:
Forrest 2026-07-30 15:30:07 -04:00
parent 351d47b3ff
commit 5bf85a5f44
3 changed files with 312 additions and 55 deletions

View file

@ -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<Widget> 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<List<Album>> 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<List<Album>> 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),
),
),
),

View file

@ -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<int?>(
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,
),