fixes to shuffle and queue
This commit is contained in:
parent
351d47b3ff
commit
5bf85a5f44
3 changed files with 312 additions and 55 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io' show File, Platform;
|
import 'dart:io' show File, Platform;
|
||||||
|
import 'dart:math' show Random;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -108,6 +109,14 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
|
|
||||||
AudioPlayer? _player;
|
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<Song>? _unshuffledOrder;
|
||||||
|
|
||||||
/// Coalesces rapid state changes (a position tick every second) into at most
|
/// Coalesces rapid state changes (a position tick every second) into at most
|
||||||
/// one disk write per window.
|
/// one disk write per window.
|
||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
|
|
@ -202,11 +211,26 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
// itself have been unstreamable).
|
// itself have been unstreamable).
|
||||||
final target = songs[startIndex.clamp(0, songs.length - 1)];
|
final target = songs[startIndex.clamp(0, songs.length - 1)];
|
||||||
final targetIndex = streamable.indexOf(target);
|
final targetIndex = streamable.indexOf(target);
|
||||||
final start =
|
var start =
|
||||||
targetIndex >= 0 ? targetIndex : startIndex.clamp(0, streamable.length - 1);
|
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(
|
state = state.copyWith(
|
||||||
queue: streamable,
|
queue: ordered,
|
||||||
currentIndex: start,
|
currentIndex: start,
|
||||||
position: Duration.zero,
|
position: Duration.zero,
|
||||||
);
|
);
|
||||||
|
|
@ -221,13 +245,30 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await player.setAudioSources(
|
await player.setAudioSources(
|
||||||
streamable.map(_sourceFor).toList(),
|
ordered.map(_sourceFor).toList(),
|
||||||
initialIndex: start,
|
initialIndex: start,
|
||||||
);
|
);
|
||||||
await player.play();
|
await player.play();
|
||||||
// currentIndexStream fires _notifyCurrent for the started track.
|
// 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<void> 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").
|
/// Insert [song] right after the current track (Timbre's "play next").
|
||||||
/// Falls back to [playSongs] when nothing is playing.
|
/// Falls back to [playSongs] when nothing is playing.
|
||||||
Future<void> playNext(Song song) async {
|
Future<void> playNext(Song song) async {
|
||||||
|
|
@ -313,15 +354,76 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
|
|
||||||
Future<void> seek(Duration position) async => _player?.seek(position);
|
Future<void> 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<void> toggleShuffle() async {
|
Future<void> toggleShuffle() async {
|
||||||
final enabled = !state.shuffle;
|
final enabled = !state.shuffle;
|
||||||
|
final q = state.queue;
|
||||||
|
final cur = state.currentIndex;
|
||||||
state = state.copyWith(shuffle: enabled);
|
state = state.copyWith(shuffle: enabled);
|
||||||
final player = _player;
|
|
||||||
if (player != null) {
|
if (q.isEmpty || cur == null) return;
|
||||||
await player.setShuffleModeEnabled(enabled);
|
|
||||||
|
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 = <Song>[
|
||||||
|
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<void> _applyOrder(List<Song> 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
|
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
|
||||||
/// single-track repeat).
|
/// single-track repeat).
|
||||||
Future<void> cycleLoop() async {
|
Future<void> cycleLoop() async {
|
||||||
|
|
@ -452,7 +554,8 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
streamable.map(_sourceFor).toList(),
|
streamable.map(_sourceFor).toList(),
|
||||||
initialIndex: start,
|
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);
|
await player.setLoopMode(loop);
|
||||||
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
|
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
|
||||||
// Intentionally no play() — restore leaves the queue paused.
|
// Intentionally no play() — restore leaves the queue paused.
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class HomeScreen extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
final recentAlbums = ref.watch(recentAlbumsProvider);
|
final recentAlbums = ref.watch(recentAlbumsProvider);
|
||||||
final rediscover = ref.watch(rediscoverProvider);
|
|
||||||
final newest = ref.watch(newestAlbumsProvider);
|
final newest = ref.watch(newestAlbumsProvider);
|
||||||
final random = ref.watch(randomAlbumsProvider);
|
final random = ref.watch(randomAlbumsProvider);
|
||||||
|
|
||||||
|
|
@ -62,31 +61,9 @@ class HomeScreen extends ConsumerWidget {
|
||||||
artFor: artFor,
|
artFor: artFor,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Made For You — rediscover artists you're neglecting.
|
// Random — a single spotlighted album, re-rolled via the shuffle action.
|
||||||
if (rediscover.isNotEmpty)
|
_RandomAlbum(
|
||||||
_Shelf(
|
album: random,
|
||||||
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,
|
|
||||||
artFor: artFor,
|
artFor: artFor,
|
||||||
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
|
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
|
/// A horizontal shelf: a header over a scrolling row of art cards.
|
||||||
/// scrolling row of art cards.
|
|
||||||
class _Shelf extends StatelessWidget {
|
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 String title;
|
||||||
final List<Widget> cards;
|
final List<Widget> cards;
|
||||||
final VoidCallback? onShuffle;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -279,7 +254,7 @@ class _Shelf extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_ShelfHeader(title: title, onShuffle: onShuffle),
|
_ShelfHeader(title: title),
|
||||||
const SizedBox(height: TimbreSpacing.md),
|
const SizedBox(height: TimbreSpacing.md),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 182,
|
height: 182,
|
||||||
|
|
@ -335,26 +310,22 @@ class _AlbumShelf extends StatelessWidget {
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.albums,
|
required this.albums,
|
||||||
required this.artFor,
|
required this.artFor,
|
||||||
this.onShuffle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final AsyncValue<List<Album>> albums;
|
final AsyncValue<List<Album>> albums;
|
||||||
final String? Function(String? coverArt, {int size}) artFor;
|
final String? Function(String? coverArt, {int size}) artFor;
|
||||||
final VoidCallback? onShuffle;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return albums.when(
|
return albums.when(
|
||||||
loading: () => _Shelf(
|
loading: () => _Shelf(
|
||||||
title: title,
|
title: title,
|
||||||
onShuffle: onShuffle,
|
|
||||||
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
|
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
|
||||||
),
|
),
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
data: (list) => _Shelf(
|
data: (list) => _Shelf(
|
||||||
title: title,
|
title: title,
|
||||||
onShuffle: onShuffle,
|
|
||||||
cards: [
|
cards: [
|
||||||
for (final a in list)
|
for (final a in list)
|
||||||
_ArtCard(
|
_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.
|
/// A fixed-width art tile with a caption — the shelf's building block.
|
||||||
class _ArtCard extends StatelessWidget {
|
class _ArtCard extends StatelessWidget {
|
||||||
const _ArtCard({
|
const _ArtCard({
|
||||||
|
|
@ -378,14 +477,12 @@ class _ArtCard extends StatelessWidget {
|
||||||
required this.artUri,
|
required this.artUri,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.fallbackIcon = Icons.album_outlined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String? artUri;
|
final String? artUri;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final IconData fallbackIcon;
|
|
||||||
|
|
||||||
static const double _size = 132;
|
static const double _size = 132;
|
||||||
|
|
||||||
|
|
@ -410,9 +507,10 @@ class _ArtCard extends StatelessWidget {
|
||||||
key: ValueKey(artUri),
|
key: ValueKey(artUri),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
errorBuilder: (_, _, _) =>
|
errorBuilder: (_, _, _) => const Icon(
|
||||||
Icon(fallbackIcon, color: TimbreColors.dimmed))
|
Icons.album_outlined, color: TimbreColors.dimmed))
|
||||||
: Icon(fallbackIcon, color: TimbreColors.dimmed),
|
: const Icon(Icons.album_outlined,
|
||||||
|
color: TimbreColors.dimmed),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -210,28 +210,84 @@ class _QueueToggle extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The play queue with reorder-free remove controls. Fills whichever space the
|
/// The play queue with reorder-free remove controls. Fills whichever space the
|
||||||
/// top region gives it.
|
/// top region gives it. Because the engine keeps `state.queue` in true play
|
||||||
class _QueuePanel extends ConsumerWidget {
|
/// 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();
|
const _QueuePanel();
|
||||||
|
|
||||||
@override
|
@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 state = ref.watch(playbackProvider);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
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(
|
return HairlinePanel(
|
||||||
title: 'Queue',
|
title: 'Queue',
|
||||||
trailing: '(${state.queue.length})',
|
trailing: '(${state.queue.length})',
|
||||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _controller,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
itemExtent: _rowExtent,
|
||||||
itemCount: state.queue.length,
|
itemCount: state.queue.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final song = state.queue[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(
|
return InkWell(
|
||||||
onTap: () => ref
|
onTap: () => ref.read(playbackProvider.notifier).jumpTo(i),
|
||||||
.read(playbackProvider.notifier)
|
|
||||||
.playSongs(state.queue, startIndex: i),
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: TimbreSpacing.lg,
|
horizontal: TimbreSpacing.lg,
|
||||||
|
|
@ -250,7 +306,7 @@ class _QueuePanel extends ConsumerWidget {
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isCurrent ? accent : TimbreColors.foreground,
|
color: titleColor,
|
||||||
fontWeight:
|
fontWeight:
|
||||||
isCurrent ? FontWeight.w700 : FontWeight.w400,
|
isCurrent ? FontWeight.w700 : FontWeight.w400,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue