fix(playback): keep Plex playlist queue when entering the player

close #978
This commit is contained in:
edde746
2026-05-04 21:34:14 +02:00
parent f573eda657
commit fdaff3687d
4 changed files with 66 additions and 18 deletions
@@ -67,6 +67,15 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
/// Whether any queue-based playback is active
bool get isQueueActive => _playQueueId != null && _isQueueMode;
/// 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.
bool isItemInActiveQueue(MediaItem item) => isQueueActive && playQueueItemIdFor(item) != null;
/// The context key (show/season/playlist ratingKey) for the current session
String? get shuffleContextKey => _contextKey;
@@ -34,20 +34,19 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
return;
}
// Check if there's already an active queue for THIS show.
// A leftover queue from a different show or — more importantly —
// from a different backend (Jellyfin's local queue is published
// here too) would otherwise mask the new show's navigation.
final existingContextKey = playbackState.shuffleContextKey;
final isQueueActive = playbackState.isQueueActive;
if (isQueueActive && existingContextKey == showRatingKey) {
// Preserve any queue this item belongs to — playlist, collection,
// or same-show queue. `isItemInActiveQueue` is the same gate
// VideoPlayerScreen.initState uses; a context-key check alone would
// wipe a playlist queue (its key is the playlist id, not the show).
// Only when the active queue is genuinely stale (item not in it)
// do we clobber and create a fresh show queue.
if (playbackState.isItemInActiveQueue(_currentMetadata)) {
playbackState.setCurrentItem(_currentMetadata);
appLogger.d('Using existing play queue (context: $existingContextKey)');
appLogger.d('Using existing play queue (context: ${playbackState.shuffleContextKey})');
return;
}
if (isQueueActive) {
appLogger.d('Resetting stale play queue (was: $existingContextKey, now: $showRatingKey)');
if (playbackState.isQueueActive) {
appLogger.d('Resetting stale play queue (was: ${playbackState.shuffleContextKey}, now: $showRatingKey)');
playbackState.clearShuffle();
}
+1 -2
View File
@@ -449,8 +449,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// playback (continue-watching, direct episode tap with no queue
// launcher) clear any stale queue so prev/next stays consistent.
final meta = widget.metadata;
final inActiveQueue = playbackState.isQueueActive && playbackState.playQueueItemIdFor(meta) != null;
if (inActiveQueue) {
if (playbackState.isItemInActiveQueue(meta)) {
playbackState.setCurrentItem(meta);
} else {
playbackState.clearShuffle();
@@ -227,11 +227,11 @@ void main() {
});
test('playQueueItemIdFor returns synthetic ids for Jellyfin local queue items', () {
// Anchor: VideoPlayerScreen.initState gates clearShuffle on
// `playQueueItemIdFor(meta) != null` so a Jellyfin playlist queue
// survives entry into the player. If this returns null for queue
// members, the player wipes the launcher-set queue and prev/next
// walks the show instead of the playlist.
// Anchor: VideoPlayerScreen.initState and `_ensurePlayQueue` both gate
// on `isItemInActiveQueue(meta)` (which delegates to `playQueueItemIdFor`)
// so a Jellyfin playlist queue survives entry into the player. If this
// returns null for queue members, the player wipes the launcher-set
// queue and prev/next walks the show instead of the playlist.
final p = PlaybackStateProvider();
addTearDown(p.dispose);
@@ -247,6 +247,47 @@ void main() {
expect(p.playQueueItemIdFor(ep1), 0);
expect(p.playQueueItemIdFor(ep2), 1);
expect(p.playQueueItemIdFor(outsider), isNull);
expect(p.isItemInActiveQueue(ep1), isTrue);
expect(p.isItemInActiveQueue(outsider), isFalse);
});
test('isItemInActiveQueue keeps Plex playlist/collection queues alive', () async {
// Anchor (Plex side): `_ensurePlayQueue` in episode_queue.dart gates
// its "preserve vs. clobber" decision on `isItemInActiveQueue`. A
// Plex playlist queue's contextKey is the playlist id (not the show),
// so a context-key-only check would wipe it. Membership via the
// server-stamped `playQueueItemId` is the right signal — see gh #978.
final p = PlaybackStateProvider();
addTearDown(p.dispose);
final inQueue = _item('ep-in-playlist', 5001);
// A real-world non-queue item (e.g. tapped from media detail) carries
// no `playQueueItemId` — that's how the helper distinguishes it from
// a launcher-seeded queue member.
final outsider = PlexMediaItem(id: 'ep-different-show', kind: MediaKind.episode);
await p.setPlaybackFromPlayQueue(
_queue(
playQueueID: 77,
selectedItemID: 5001,
totalCount: 2,
items: [inQueue, _item('ep-other-in-playlist', 5002)],
),
// contextKey is the playlist id, deliberately != grandparentId of any item
'playlist-Z',
);
expect(p.isItemInActiveQueue(inQueue), isTrue);
expect(p.isItemInActiveQueue(outsider), isFalse);
});
test('isItemInActiveQueue is false when no queue is active', () {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
final ep = _item('ep1', 1);
expect(p.isQueueActive, isFalse);
expect(p.isItemInActiveQueue(ep), isFalse);
});
});
}