fix(playback): keep the Jellyfin shuffle queue across episodes

close #1466
This commit is contained in:
edde746
2026-07-03 10:20:13 +02:00
parent 3f9c25fe5e
commit 536e8e3d2a
5 changed files with 178 additions and 21 deletions
+13 -5
View File
@@ -69,11 +69,14 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
/// Whether [item] belongs to the currently active queue. True for Plex
/// items the server-side queue stamped with a `playQueueItemId`, and for
/// items present in a Jellyfin local queue (synthetic id). Gates the
/// player's "preserve vs. wipe launcher-set queue" decision in both
/// [VideoPlayerScreen.initState] and `_ensurePlayQueue`, so a playlist
/// or collection queue survives entry into the player instead of being
/// replaced with a show queue.
/// items present in a Jellyfin local queue (synthetic id). Membership for
/// local queues is by object identity — [MediaItem] is `@Freezed(equal:
/// false)` — so only the exact instances stored in the queue match.
/// Gates the player's "preserve vs. wipe launcher-set queue" decision in
/// [VideoPlayerScreen.initState], `_ensurePlayQueue`, and
/// [EpisodeNavigationService]'s `_ensureLocalEpisodeQueue`, so a
/// playlist, collection, or shuffled show queue survives entry into the
/// player instead of being replaced with a sequential show queue.
bool isItemInActiveQueue(MediaItem item) => isQueueActive && playQueueItemIdFor(item) != null;
/// The context key (show/season/playlist ratingKey) for the current session
@@ -88,6 +91,11 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
/// The current play queue item ID
int? get currentPlayQueueItemID => _currentPlayQueueItemID;
/// The queue item the cursor currently points at, or null when no queue
/// is active or the cursor is outside the loaded window.
MediaItem? get currentQueueItem =>
_currentPlayQueueItemID == null ? null : _findLoadedItem(_currentPlayQueueItemID!);
/// Set the client reference for loading more items
void setPlayQueueWindowFetcher(PlayQueueWindowFetcher? fetcher) {
_windowFetcher = fetcher;
+4 -4
View File
@@ -547,10 +547,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
WidgetsBinding.instance.addPostFrameCallback((_) {
// Keep the queue when this item belongs to it — that covers both
// server-side queues (Plex `playQueueItemId`) and client-side
// launcher-seeded queues (Jellyfin playlist/collection, with
// synthetic ids tracked in the provider). For genuine standalone
// playback (continue-watching, direct episode tap with no queue
// launcher) clear any stale queue so prev/next stays consistent.
// launcher-seeded queues (Jellyfin playlist/collection/shuffled
// show, with synthetic ids tracked in the provider). For genuine
// standalone playback (continue-watching, direct episode tap with no
// queue launcher) clear any stale queue so prev/next stays consistent.
final meta = _currentMetadata;
if (playbackState.isItemInActiveQueue(meta)) {
playbackState.setCurrentItem(meta);
+33 -11
View File
@@ -33,7 +33,7 @@ class AdjacentEpisodes {
/// Plex episodes navigate through the server-side `/playQueues` queue;
/// Jellyfin (and any other backend whose
/// [MediaServerClient.fetchClientSideEpisodeQueue] returns rows) builds
/// a centred 21-item local queue here and publishes it through
/// a full-series local queue here and publishes it through
/// [PlaybackStateProvider] so the rest of the player reads prev/next from
/// the same source.
class EpisodeNavigationService {
@@ -70,8 +70,8 @@ class EpisodeNavigationService {
final serverManager = context.read<MultiServerProvider>().serverManager;
final playbackState = context.read<PlaybackStateProvider>();
// For Jellyfin, build (or refresh) the centered 21-item window and
// publish it into PlaybackStateProvider so the rest of this method —
// For Jellyfin, make sure a local queue covering the current item is
// published into PlaybackStateProvider so the rest of this method —
// and the queue button/sheet — can read prev/next from the same
// place Plex does. Plex playback comes in here with its server-side
// queue already populated by `_ensurePlayQueue` so this branch is
@@ -94,10 +94,13 @@ class EpisodeNavigationService {
}
}
/// Ensure [PlaybackStateProvider] holds a centered 21-item window of
/// the current series. Cached per-series, so jumping anywhere in the
/// show only triggers one wire fetch per session. No-op for movies,
/// items without a series anchor, or backends whose
/// Ensure [PlaybackStateProvider] holds a queue covering the current
/// item. A queue the item already belongs to (launcher-seeded shuffle,
/// playlist, collection, or an earlier series build) is preserved as-is;
/// otherwise the full series episode list is published, anchored at the
/// current episode. Episode lists are cached per-series, so jumping
/// anywhere in the show only triggers one wire fetch per session. No-op
/// for movies, items without a series anchor, or backends whose
/// [MediaServerClient.fetchClientSideEpisodeQueue] returns null (Plex's
/// queue lives server-side and is populated elsewhere).
Future<void> _ensureLocalEpisodeQueue(
@@ -109,10 +112,29 @@ class EpisodeNavigationService {
return;
}
final seriesId = metadata.grandparentId!;
// Don't replace a playlist/collection queue with a series queue.
// The launcher (e.g. [JellyfinSequentialLauncher]) sets contextKey to
// the playlist/collection id; a series rebuild here would clobber it
// and prev/next would walk the show instead of the user's list.
// Preserve any queue this item already belongs to — a launcher-seeded
// shuffled show queue (contextKey == seriesId), a playlist/collection
// queue, or a series queue this method built earlier. setCurrentItem
// re-anchors the cursor, replacing the re-anchor the rebuild used to
// provide. Without this gate a shuffled show queue was clobbered by a
// sequential rebuild after the first episode (#1466).
if (playbackState.isItemInActiveQueue(metadata)) {
playbackState.setCurrentItem(metadata);
return;
}
// Same-episode reload with a fresh object: a source/quality switch hands
// _reloadMediaInPlace a copyWith clone of the playing item, and MediaItem
// compares by identity, so the membership gate above misses. The cursor
// already points at this episode — the queue (and any shuffled order)
// must survive.
if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) {
return;
}
// The playing item isn't in the active queue. Still don't replace a
// playlist/collection queue with a series queue: the launcher (e.g.
// [JellyfinSequentialLauncher]) sets contextKey to the playlist or
// collection id; a series rebuild here would clobber it and prev/next
// would walk the show instead of the user's list.
final activeKey = playbackState.shuffleContextKey;
if (playbackState.isQueueActive && activeKey != null && activeKey != seriesId) {
return;
@@ -200,7 +200,12 @@ abstract class MediaListPlaybackLauncher {
await navigateForTesting(itemToPlay);
} else {
if (!context.mounted) return const PlayQueueError('Context not mounted');
await navigateToVideoPlayer(context, metadata: itemToPlay);
// The queue holds these exact instances and the player's initState gate
// matches by identity — a WatchStateStore clone here would wipe the
// launcher-set queue on entry. The items were fetched from the server
// in this same user action, so session watch patches are already
// reflected.
await navigateToVideoPlayer(context, metadata: itemToPlay, resolveWatchState: false);
}
return const PlayQueueSuccess();
}
@@ -237,4 +237,126 @@ void main() {
expect(result!.previous?.id, 'ep1');
});
});
// ===========================================================
// loadAdjacentEpisodes: shuffled same-series queue (#1466)
// ===========================================================
group('loadAdjacentEpisodes with a shuffled same-series queue', () {
// Mirrors JellyfinSequentialLauncher.launchShuffledShow: the full series
// episode list, locally shuffled, published with contextKey == seriesId.
// The regression under test: _ensureLocalEpisodeQueue used to rebuild a
// sequential series queue whenever contextKey == seriesId, so shuffle
// held for exactly one episode (#1466).
final ep1 = _jfEpisode('ep1', seriesId: 'series-A');
final ep2 = _jfEpisode('ep2', seriesId: 'series-A');
final ep3 = _jfEpisode('ep3', seriesId: 'series-A');
final ep4 = _jfEpisode('ep4', seriesId: 'series-A');
final ep5 = _jfEpisode('ep5', seriesId: 'series-A');
final shuffledOrder = [ep3, ep1, ep5, ep2, ep4];
const shuffledIds = ['ep3', 'ep1', 'ep5', 'ep2', 'ep4'];
// Stub server answers with the sequential list, so an (unwanted) queue
// rebuild is observable both via seriesQueueCalls and via reordered
// loadedItems.
(PlaybackStateProvider, _RecordingClient, MultiServerProvider) buildShuffledSession() {
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:series-A',
items: shuffledOrder,
currentIndex: 0,
shuffled: true,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'series-A',
);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
return (playback, client, serverProvider);
}
Future<AdjacentEpisodes?> probe(
WidgetTester tester,
PlaybackStateProvider playback,
MultiServerProvider serverProvider,
MediaItem metadata,
) async {
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: metadata, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
return result;
}
testWidgets('preserves the shuffled order instead of rebuilding sequentially', (tester) async {
final (playback, client, serverProvider) = buildShuffledSession();
final result = await probe(tester, playback, serverProvider, ep3);
expect(client.seriesQueueCalls, isEmpty);
expect(playback.loadedItems.map((e) => e.id), shuffledIds);
expect(playback.isShuffleActive, isTrue);
expect(playback.shuffleContextKey, 'series-A');
expect(result!.next?.id, 'ep1');
expect(result.previous, isNull);
});
testWidgets('continues the shuffled order after advancing to the next episode', (tester) async {
final (playback, client, serverProvider) = buildShuffledSession();
// What _reloadMediaInPlace does when the player swaps to the next item.
playback.setCurrentItem(ep1);
final result = await probe(tester, playback, serverProvider, ep1);
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isShuffleActive, isTrue);
expect(result!.next?.id, 'ep5');
expect(result.previous?.id, 'ep3');
});
testWidgets('same-episode clone from a source switch does not clobber the queue', (tester) async {
final (playback, client, serverProvider) = buildShuffledSession();
// _switchPlaybackSource reloads with a copyWith clone of the playing
// item; MediaItem compares by identity, so queue membership misses and
// only the cursor-globalKey gate keeps the queue alive.
final result = await probe(tester, playback, serverProvider, ep3.copyWith(viewOffsetMs: 42));
expect(client.seriesQueueCalls, isEmpty);
expect(playback.loadedItems.map((e) => e.id), shuffledIds);
expect(playback.isShuffleActive, isTrue);
expect(result!.next?.id, 'ep1');
});
testWidgets('still builds a sequential series queue when no queue is active', (tester) async {
// Direct episode tap with no launcher: the preserve gates must not get
// in the way of the normal series-queue build.
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
final result = await probe(tester, playback, serverProvider, ep3);
expect(client.seriesQueueCalls, ['series-A']);
expect(playback.loadedItems.map((e) => e.id), ['ep1', 'ep2', 'ep3', 'ep4', 'ep5']);
expect(playback.isShuffleActive, isFalse);
expect(result!.next?.id, 'ep4');
expect(result.previous?.id, 'ep2');
});
});
}