From fdaff3687d2e4e3cefa0d6c6c26d4d6e67951506 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 4 May 2026 21:30:47 +0200 Subject: [PATCH] fix(playback): keep Plex playlist queue when entering the player close #978 --- lib/providers/playback_state_provider.dart | 9 ++++ .../video_player/parts/episode_queue.dart | 21 ++++---- lib/screens/video_player_screen.dart | 3 +- .../playback_state_provider_test.dart | 51 +++++++++++++++++-- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 70fb297a..909a89f7 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -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; diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index 4b49658c..0b059d25 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -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(); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5c2d4302..907e0c29 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -449,8 +449,7 @@ class VideoPlayerScreenState extends State 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(); diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart index 8cbed2d4..2dbd4f4b 100644 --- a/test/providers/playback_state_provider_test.dart +++ b/test/providers/playback_state_provider_test.dart @@ -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); }); }); }