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: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<PlaybackState> {
|
|||
|
||||
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
|
||||
/// one disk write per window.
|
||||
Timer? _saveTimer;
|
||||
|
|
@ -202,11 +211,26 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
// 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<PlaybackState> {
|
|||
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<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").
|
||||
/// Falls back to [playSongs] when nothing is playing.
|
||||
Future<void> playNext(Song song) async {
|
||||
|
|
@ -313,15 +354,76 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
|
||||
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 {
|
||||
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 = <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
|
||||
/// single-track repeat).
|
||||
Future<void> cycleLoop() async {
|
||||
|
|
@ -452,7 +554,8 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue