fix(music): shuffle the head of a shuffled queue too

Starting a music playlist, album, or artist on shuffle always opened on
the list's first track: MusicQueueController.load anchored _order[cursor]
and shuffled only the rest, and _startQueue collapsed "no start track"
into startIndex 0, so the anchor was always the head.

Anchoring is right for the two callers that do have a track which must
play first -- the now-playing shuffle toggle, and a load with an explicit
start track -- so make "no explicit start" representable instead of
inferring it from the index: load takes int? startIndex and shuffles the
whole list, head included, when it is null. A start track the list turns
out not to contain now drops the anchor rather than falling back to 0.

Video playback was never affected: Plex shuffles server-side via
/playQueues and Jellyfin already shuffles its full local list.

The queue's Random is injectable so the service-level regression is
deterministic without depending on the SDK's seeded-PRNG sequence.

Close #1811
This commit is contained in:
edde746
2026-08-06 06:11:12 +02:00
parent f5488cb7ff
commit f63d0fe49e
5 changed files with 126 additions and 14 deletions
@@ -73,8 +73,9 @@ abstract class MusicPlaybackService extends ChangeNotifier {
int get queueSessionRevision;
/// Start a new queue from [tracks], optionally at [startTrack] (defaults
/// to the first track). [shuffle] shuffles with the start track anchored
/// first.
/// to the first track). [shuffle] anchors [startTrack] first and shuffles
/// the rest after it; with no [startTrack] the whole list shuffles, so the
/// queue opens on a random track rather than always the first one (#1811).
Future<void> playFromList({
required List<MediaItem> tracks,
MediaItem? startTrack,
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart' show ValueListenable, visibleForTesting;
import 'package:flutter/widgets.dart';
@@ -70,10 +71,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
this._completedConfirmDelay = const Duration(milliseconds: 400),
PlaybackCoordinator? coordinator,
@visibleForTesting Future<void> Function(double)? volumePersistenceWriter,
@visibleForTesting Random? queueRandom,
}) : assert(resolver != null || database != null, 'database is required to build the default resolver'),
_serverManager = serverManager,
_resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!),
_coordinator = coordinator ?? PlaybackCoordinator.instance,
_queue = MusicQueueController(random: queueRandom),
_volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
// tvOS has no background-audio session in v1, so it pauses on
@@ -110,7 +113,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
final PlaybackCoordinator _coordinator;
final Future<void> Function(double) _volumePersistenceWriter;
final MusicQueueController _queue = MusicQueueController();
final MusicQueueController _queue;
/// Persisted music volume (0100), applied to every audio player instance
/// (the core is recreated after video claims playback). Falls back to full
@@ -336,10 +339,14 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
final generation = ++_generation;
_invalidateArmRequests();
_finalizeCurrentTrack();
var startIndex = 0;
// Null start index = "no track has to play first", which is what lets a
// shuffled queue randomize its head too. Collapsing that into 0 pinned
// every shuffle launch to the list's first track (#1811) — including a
// start track the list turns out not to contain.
int? startIndex;
if (startTrack != null) {
startIndex = tracks.indexWhere((t) => t.globalKey == startTrack.globalKey);
if (startIndex < 0) startIndex = 0;
final index = tracks.indexWhere((t) => t.globalKey == startTrack.globalKey);
if (index >= 0) startIndex = index;
}
_queue.load(tracks, startIndex: startIndex, shuffle: shuffle);
_playContext = playContext;
+25 -6
View File
@@ -45,17 +45,28 @@ class MusicQueueController {
MediaItem? trackAt(int queueIndex) =>
queueIndex >= 0 && queueIndex < _order.length ? _items[_order[queueIndex]] : null;
/// Replace the queue with [tracks], starting at [startIndex]. With
/// [shuffle] the start track is anchored first and the rest shuffle after
/// it (it keeps playing / plays first).
void load(List<MediaItem> tracks, {int startIndex = 0, bool shuffle = false}) {
/// Replace the queue with [tracks], starting at [startIndex]. A null
/// [startIndex] (the default) means *no explicit start* — playback simply
/// begins at the head.
///
/// [shuffle] reads that distinction. With an explicit [startIndex] the
/// start track is anchored first and the rest shuffle after it (it keeps
/// playing / plays first); with none the whole list shuffles, head
/// included. Collapsing "no start track" into index 0 is what pinned every
/// shuffled playlist to its first track (#1811).
void load(List<MediaItem> tracks, {int? startIndex, bool shuffle = false}) {
_items
..clear()
..addAll(tracks);
_order = List.generate(tracks.length, (i) => i);
_shuffled = false;
_cursor = tracks.isEmpty ? -1 : startIndex.clamp(0, tracks.length - 1);
if (shuffle && tracks.isNotEmpty) _shuffleAnchoringCurrent();
_cursor = tracks.isEmpty ? -1 : (startIndex ?? 0).clamp(0, tracks.length - 1);
if (!shuffle || tracks.isEmpty) return;
if (startIndex == null) {
_shuffleAll();
} else {
_shuffleAnchoringCurrent();
}
}
void clear() {
@@ -174,6 +185,14 @@ class MusicQueueController {
_shuffled = true;
}
/// Shuffle every entry, head included — a session started *as* shuffled
/// has no track that must play first.
void _shuffleAll() {
_order.shuffle(_random);
_cursor = 0;
_shuffled = true;
}
/// Drop everything after the current entry (playback order), including
/// the underlying canonical items.
void clearUpcoming() {