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:
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
@@ -547,8 +548,36 @@ class _GatedVolumeWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue [Random] the tests can script. Real randomness by default;
|
||||
/// [lowestDraw] makes a shuffle deterministic without depending on the SDK's
|
||||
/// seeded-PRNG sequence (which carries no cross-release guarantee).
|
||||
class _ScriptedRandom implements Random {
|
||||
final Random _real = Random();
|
||||
late int Function(int max) _draw = _real.nextInt;
|
||||
|
||||
/// Every draw picks the lowest candidate index.
|
||||
void lowestDraw() => _draw = (_) => 0;
|
||||
|
||||
@override
|
||||
int nextInt(int max) => _draw(max);
|
||||
|
||||
@override
|
||||
bool nextBool() => _real.nextBool();
|
||||
|
||||
@override
|
||||
double nextDouble() => _real.nextDouble();
|
||||
}
|
||||
|
||||
class _Harness {
|
||||
_Harness._(this.service, this.resolver, this.client, this.controls, this.players, this.serverManager);
|
||||
_Harness._(
|
||||
this.service,
|
||||
this.resolver,
|
||||
this.client,
|
||||
this.controls,
|
||||
this.players,
|
||||
this.serverManager,
|
||||
this.queueRandom,
|
||||
);
|
||||
|
||||
final MusicPlaybackServiceImpl service;
|
||||
final FakeMusicSourceResolver resolver;
|
||||
@@ -557,6 +586,9 @@ class _Harness {
|
||||
final List<FakePlayer> players;
|
||||
final MultiServerManager serverManager;
|
||||
|
||||
/// Drives the queue's shuffle; script it before starting a shuffled queue.
|
||||
final _ScriptedRandom queueRandom;
|
||||
|
||||
/// Seeded into every created FakePlayer — lets a test configure arm
|
||||
/// failures before the first player exists.
|
||||
final Set<String> failingSetNextUris = {};
|
||||
@@ -564,6 +596,7 @@ class _Harness {
|
||||
FakePlayer get player => players.last;
|
||||
|
||||
factory _Harness.create({Future<void> Function(double)? volumePersistenceWriter}) {
|
||||
final queueRandom = _ScriptedRandom();
|
||||
final client = FakeMediaServerClient();
|
||||
final resolver = FakeMusicSourceResolver(client: client);
|
||||
final controls = FakeMediaControlsManager();
|
||||
@@ -584,8 +617,9 @@ class _Harness {
|
||||
// paths resolve within pumpEventQueue.
|
||||
completedConfirmDelay: Duration.zero,
|
||||
volumePersistenceWriter: volumePersistenceWriter,
|
||||
queueRandom: queueRandom,
|
||||
);
|
||||
harness = _Harness._(service, resolver, client, controls, players, serverManager);
|
||||
harness = _Harness._(service, resolver, client, controls, players, serverManager, queueRandom);
|
||||
return harness;
|
||||
}
|
||||
|
||||
@@ -622,6 +656,42 @@ void main() {
|
||||
h.serverManager.dispose();
|
||||
});
|
||||
|
||||
group('shuffled session start (#1811)', () {
|
||||
final playlist = [for (var i = 0; i < 6; i++) _track('p$i')];
|
||||
|
||||
test('no start track shuffles the head too instead of pinning the first track', () async {
|
||||
h.queueRandom.lowestDraw();
|
||||
|
||||
await h.playTracks(playlist, shuffle: true);
|
||||
|
||||
final opened = h.service.currentTrack!;
|
||||
expect(opened.id, isNot('p0'), reason: 'shuffle opened on the list head');
|
||||
expect(h.service.queue.first.id, opened.id);
|
||||
expect(h.service.queue.map((t) => t.id).toSet(), playlist.map((t) => t.id).toSet());
|
||||
expect(h.service.shuffled, isTrue);
|
||||
expect(h.player.openedUris, [_urlFor(opened)]);
|
||||
});
|
||||
|
||||
test('an explicit start track still anchors the shuffled queue', () async {
|
||||
h.queueRandom.lowestDraw();
|
||||
|
||||
await h.playTracks(playlist, startTrack: playlist[3], shuffle: true);
|
||||
|
||||
expect(h.service.currentTrack!.id, 'p3');
|
||||
expect(h.service.queue.first.id, 'p3');
|
||||
expect(h.service.queue.map((t) => t.id).toSet(), playlist.map((t) => t.id).toSet());
|
||||
});
|
||||
|
||||
test('a start track absent from the list drops the anchor rather than pinning the first track', () async {
|
||||
h.queueRandom.lowestDraw();
|
||||
|
||||
await h.playTracks(playlist, startTrack: _track('not-in-list'), shuffle: true);
|
||||
|
||||
expect(h.service.currentTrack!.id, isNot('p0'));
|
||||
expect(h.service.queue.map((t) => t.id).toSet(), playlist.map((t) => t.id).toSet());
|
||||
});
|
||||
});
|
||||
|
||||
test('volume updates notify only the dedicated volume listenable', () async {
|
||||
await h.playTracks([t1]);
|
||||
var serviceNotifications = 0;
|
||||
|
||||
Reference in New Issue
Block a user