diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index fa6f0824..ad323d04 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -227,13 +227,11 @@ abstract class MediaServerClient { /// branch if/when one does. Future> fetchPlayableDescendants(String parentId); - /// All episodes of a series across every season, ordered by air date — - /// used to build a centred 21-item navigation window when no server-side - /// play queue is available. Returns `null` for backends that maintain - /// queues server-side (Plex's `/playQueues`); returns the list (possibly - /// empty for an empty series) for backends without that capability - /// (Jellyfin). Callers distinguish "no client-side queue" from "empty - /// series" via the null vs `[]` distinction. + /// All episodes of a series across every season in playback order. Used + /// to build a local navigation queue for backends without server-side + /// queues and as a fallback when a server-side queue could not be created. + /// Returns null only when a backend cannot supply a client-side queue; + /// an empty list means the series itself was empty. Future?> fetchClientSideEpisodeQueue(String seriesId); /// Albums credited to [artist], newest first. Plex filters album rows in diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index acd0b486..377f99cf 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -11,6 +11,37 @@ import '../mixins/disposable_change_notifier_mixin.dart'; /// [LocalPlayQueue]) where the full list is already resident. typedef PlayQueueWindowFetcher = Future Function(int playQueueId, {String? center, int window}); +/// Outcome of resolving one direction in the active playback queue. +enum QueueNavigationStatus { + /// The adjacent queue item was found. + found, + + /// The active queue was loaded successfully and has no item in this direction. + boundary, + + /// No active queue or matching current item was available. + unavailable, + + /// A server-backed queue window could not be loaded or validated. + failed, +} + +@immutable +class QueueNavigationResult { + const QueueNavigationResult._(this.status, this.item); + + const QueueNavigationResult.found(MediaItem item) : this._(QueueNavigationStatus.found, item); + + const QueueNavigationResult.boundary() : this._(QueueNavigationStatus.boundary, null); + + const QueueNavigationResult.unavailable() : this._(QueueNavigationStatus.unavailable, null); + + const QueueNavigationResult.failed() : this._(QueueNavigationStatus.failed, null); + + final QueueNavigationStatus status; + final MediaItem? item; +} + /// Result of trying to locate the current queue index. class _IndexLookupResult { final int? index; @@ -50,8 +81,14 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { /// (Jellyfin) it's a synthetic index assigned in [setPlaybackFromLocalQueue]. /// Returns null when [item] isn't in the current loaded window. int? playQueueItemIdFor(MediaItem item) { + if (!_isQueueMode) return null; if (item is PlexMediaItem && item.playQueueItemId != null) { - return item.playQueueItemId; + final id = item.playQueueItemId!; + final loadedIndex = _findLoadedIndex(id); + if (loadedIndex == -1 || _loadedItems[loadedIndex].globalKey != item.globalKey) { + return null; + } + return id; } final idx = _loadedItems.indexOf(item); if (idx < 0 || idx >= _syntheticIds.length) return null; @@ -67,11 +104,10 @@ 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). Membership for - /// local queues is by object identity — [MediaItem] is `@Freezed(equal: - /// false)` — so only the exact instances stored in the queue match. + /// Whether [item] belongs to the currently active queue. Plex membership + /// requires both the server queue id and media identity to match a loaded + /// entry. Client-side membership uses the exact stored object because + /// duplicate media entries in playlists must remain distinguishable. /// Gates the player's "preserve vs. wipe launcher-set queue" decision in /// [VideoPlayerScreen.initState], `_ensurePlayQueue`, and /// [EpisodeNavigationService]'s `_ensureLocalEpisodeQueue`, so a @@ -100,14 +136,16 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { _windowFetcher = fetcher; } - /// Update the current play queue item when playing a new item + /// Update the queue cursor after playback of [metadata] starts. + /// + /// Items outside the active loaded window are rejected. A server-stamped + /// queue id alone is not proof that an item belongs to this queue. void setCurrentItem(MediaItem metadata) { if (!_isQueueMode) return; final id = playQueueItemIdFor(metadata); - if (id != null) { - _currentPlayQueueItemID = id; - safeNotifyListeners(); - } + if (id == null || id == _currentPlayQueueItemID) return; + _currentPlayQueueItemID = id; + safeNotifyListeners(); } /// Initialize playback from a play queue @@ -153,54 +191,60 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { safeNotifyListeners(); } - /// Load more items from the play queue if needed - /// Returns true if more items were loaded - Future _ensureItemsLoaded(int targetPlayQueueItemID) async { + /// Load a server queue window centered on [centerPlayQueueItemID]. + /// + /// Returns false for transport errors, malformed/empty responses, or when + /// the requested center is absent from the returned window. + Future _loadServerWindow(int centerPlayQueueItemID) async { if (_windowFetcher == null || _playQueueId == null) return false; - - // Plex queues only — items are PlexMediaItem with a real playQueueItemId. - final hasItem = _loadedItems.whereType().any( - (item) => item.playQueueItemId == targetPlayQueueItemID, - ); - - if (hasItem) return true; - - // Load a window around the target item try { final response = await _windowFetcher!( _playQueueId!, - center: targetPlayQueueItemID.toString(), + center: centerPlayQueueItemID.toString(), window: _windowSize, ); + final items = response?.items; + if (response == null || items == null || items.isEmpty) return false; - if (response != null && response.items != null) { - // Items arrive pre-tagged with server info by the producing mapper. - _loadedItems = response.items!; - // Use size or items length as fallback if totalCount is null - _playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? response.items!.length; - _playQueueShuffled = response.playQueueShuffled; - safeNotifyListeners(); - return _findLoadedIndex(targetPlayQueueItemID) != -1; - } - } catch (e) { - // Failed to load items + _loadedItems = items; + _playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? items.length; + _playQueueShuffled = response.playQueueShuffled; + safeNotifyListeners(); + return _findLoadedIndex(centerPlayQueueItemID) != -1; + } catch (_) { return false; } - - return false; } - Future<_IndexLookupResult> _getCurrentIndex({bool loadIfMissing = false}) async { + /// Load a missing queue item without refetching an item already resident. + Future _ensureItemsLoaded(int targetPlayQueueItemID) async { + if (_findLoadedIndex(targetPlayQueueItemID) != -1) return true; + return _loadServerWindow(targetPlayQueueItemID); + } + + Future<_IndexLookupResult> _getCurrentIndex(String currentItemKey, {bool loadIfMissing = false}) async { if (!_isQueueMode || _loadedItems.isEmpty || _currentPlayQueueItemID == null) { return const _IndexLookupResult(); } - var currentIndex = _findLoadedIndex(_currentPlayQueueItemID!); + int findCurrent() { + final cursorIndex = _findLoadedIndex(_currentPlayQueueItemID!); + if (cursorIndex != -1 && _matchesItemKey(_loadedItems[cursorIndex], currentItemKey)) { + return cursorIndex; + } - if (currentIndex != -1) { - return _IndexLookupResult(index: currentIndex); + var matchedIndex = -1; + for (var i = 0; i < _loadedItems.length; i++) { + if (!_matchesItemKey(_loadedItems[i], currentItemKey)) continue; + if (matchedIndex != -1) return -1; + matchedIndex = i; + } + return matchedIndex; } + var currentIndex = findCurrent(); + if (currentIndex != -1) return _IndexLookupResult(index: currentIndex); + if (!loadIfMissing || _windowFetcher == null || _playQueueId == null) { return const _IndexLookupResult(); } @@ -210,15 +254,16 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); } - currentIndex = _findLoadedIndex(_currentPlayQueueItemID!); - + currentIndex = findCurrent(); if (currentIndex == -1) { return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); } - return _IndexLookupResult(index: currentIndex, attemptedLoad: true); } + bool _matchesItemKey(MediaItem item, String currentItemKey) => + item.id == currentItemKey || item.globalKey == currentItemKey; + /// Returns the index of the item with [playQueueItemId] in [_loadedItems], /// or -1 if absent. Bridges Plex (real id on [PlexMediaItem]) and /// client-side (synthetic id in [_syntheticIds]) queues. @@ -241,40 +286,37 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } /// Gets the next item in the playback queue. - /// Returns null if queue is exhausted or current item is not in queue. - /// [loopQueue] - If true, restart from beginning when queue is exhausted /// - /// Entries backed by the same file as the one playing are skipped: Plex - /// lists each episode of a multi-episode file (`S02E24-E25.mkv`) as its - /// own queue item, and advancing to the sibling would replay the file - /// from the start (#1500). [playedPartId] pins the comparison to the file - /// of the part actually playing when known; otherwise any file overlap - /// with the current item counts. - Future getNextEpisode(String currentItemKey, {bool loopQueue = false, String? playedPartId}) async { - if (!_isQueueMode) { - // For sequential mode, let the video player handle next episode - return null; - } + /// Returns a typed result so a queue-window failure cannot be mistaken for + /// the confirmed end of the queue. Entries backed by the same file as the + /// one playing are skipped: Plex lists each episode of a multi-episode file + /// (`S02E24-E25.mkv`) as its own queue item, and advancing to the sibling + /// would replay the file from the start (#1500). [playedPartId] pins the + /// comparison to the file of the part actually playing when known; + /// otherwise any file overlap with the current item counts. + Future getNextEpisode(String currentItemKey, {String? playedPartId}) async { + if (!_isQueueMode) return const QueueNavigationResult.unavailable(); - final indexResult = await _getCurrentIndex(loadIfMissing: true); + final indexResult = await _getCurrentIndex(currentItemKey, loadIfMissing: true); if (indexResult.index == null) { - if (indexResult.loadFailed) { - clearShuffle(); - } - return null; + return indexResult.loadFailed ? const QueueNavigationResult.failed() : const QueueNavigationResult.unavailable(); } final current = _loadedItems[indexResult.index!]; var anchor = current; - // Bounded so a pathological all-same-file looping queue can't spin. + // Bounded so a pathological all-same-file queue cannot spin. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final candidate = await _itemAfter(anchor, loopQueue: loopQueue); - if (candidate == null || !current.sharesFileWith(candidate, playedPartId: playedPartId)) { - return candidate; + final result = await _itemAfter(anchor); + final candidate = result.item; + if (result.status != QueueNavigationStatus.found || candidate == null) { + return result; + } + if (!current.sharesFileWith(candidate, playedPartId: playedPartId)) { + return result; } anchor = candidate; } - return null; + return const QueueNavigationResult.failed(); } /// Gets the previous item in the playback queue. @@ -284,106 +326,100 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { /// backed by the file that's playing are skipped, and the result is /// collapsed to the first episode of its same-file group so a /// multi-episode file is entered at the episode that fronts it. - Future getPreviousEpisode(String currentItemKey, {String? playedPartId}) async { - if (!_isQueueMode) { - // For sequential mode, let the video player handle previous episode - return null; + Future getPreviousEpisode(String currentItemKey, {String? playedPartId}) async { + if (!_isQueueMode) return const QueueNavigationResult.unavailable(); + + final indexResult = await _getCurrentIndex(currentItemKey, loadIfMissing: true); + if (indexResult.index == null) { + return indexResult.loadFailed ? const QueueNavigationResult.failed() : const QueueNavigationResult.unavailable(); } - final currentIndex = (await _getCurrentIndex()).index; - if (currentIndex == null) return null; - - final current = _loadedItems[currentIndex]; - MediaItem? candidate = current; + final current = _loadedItems[indexResult.index!]; + MediaItem candidate = current; for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - candidate = await _itemBefore(candidate!); - if (candidate == null) return null; + final result = await _itemBefore(candidate); + final before = result.item; + if (result.status != QueueNavigationStatus.found || before == null) { + return result; + } + candidate = before; if (!current.sharesFileWith(candidate, playedPartId: playedPartId)) break; } // Collapse to the first episode of the candidate's same-file group. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final before = await _itemBefore(candidate!); - if (before == null || !candidate.sharesFileWith(before)) return candidate; + final result = await _itemBefore(candidate); + final before = result.item; + if (result.status == QueueNavigationStatus.failed) return result; + if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) { + return QueueNavigationResult.found(candidate); + } candidate = before; } - return candidate; + return QueueNavigationResult.found(candidate); } - /// The queue item immediately after [anchor], extending the loaded window - /// when needed. Returns null at the end of the queue unless [loopQueue]. - /// Does not move the queue cursor — setCurrentItem does that when - /// playback of the returned item actually starts. - Future _itemAfter(MediaItem anchor, {bool loopQueue = false}) async { + /// The queue item immediately after [anchor], extending a server-backed + /// window when needed. The centered response proves whether [anchor] is at + /// the global boundary; a window-local index is never compared with the + /// queue's global item count. + Future _itemAfter(MediaItem anchor) async { final anchorId = playQueueItemIdFor(anchor); - if (anchorId == null) return null; - final anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return null; + if (anchorId == null) return const QueueNavigationResult.unavailable(); + var anchorIndex = _findLoadedIndex(anchorId); + if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - // Check if there's a next item in the loaded window if (anchorIndex + 1 < _loadedItems.length) { - return _loadedItems[anchorIndex + 1]; + return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]); } - // Check if we're at the end of the entire queue - if (anchorIndex + 1 >= _playQueueTotalCount) { - if (loopQueue && _playQueueTotalCount > 0) { - // Loop back to beginning - load first item - if (_windowFetcher != null && _playQueueId != null) { - final response = await _windowFetcher!(_playQueueId!); - if (response != null && response.items != null && response.items!.isNotEmpty) { - // Items arrive pre-tagged with server info by the producing mapper. - _loadedItems = response.items!; - return _loadedItems.first; - } - } - } - // At end of queue - return null but keep queue active so user can still go back - return null; + // Local queues are fully resident, so their window edge is the queue edge. + if (_windowFetcher == null || _playQueueId == null) { + return const QueueNavigationResult.boundary(); + } + if (_playQueueTotalCount > 0 && _loadedItems.length >= _playQueueTotalCount) { + return const QueueNavigationResult.boundary(); } - // Need to load next window - if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) { - // Load next window centered on the item after the anchor. Plex-only - // path — _windowFetcher != null implies queue items are PlexMediaItem. - final last = _loadedItems.last; - final nextItemID = last is PlexMediaItem ? last.playQueueItemId : null; - if (nextItemID != null) { - final targetPlayQueueItemID = nextItemID + 1; - final loaded = await _ensureItemsLoaded(targetPlayQueueItemID); - if (loaded) return _findLoadedItem(targetPlayQueueItemID); - } + // Refresh around the actual anchor. Queue ids are opaque and need not be + // consecutive, so never guess `anchorId + 1`. + if (!await _loadServerWindow(anchorId)) { + return const QueueNavigationResult.failed(); } - - return null; + anchorIndex = _findLoadedIndex(anchorId); + if (anchorIndex == -1) return const QueueNavigationResult.failed(); + return anchorIndex + 1 < _loadedItems.length + ? QueueNavigationResult.found(_loadedItems[anchorIndex + 1]) + : const QueueNavigationResult.boundary(); } - /// The queue item immediately before [anchor], extending the loaded - /// window when needed. Returns null at the beginning of the queue. - Future _itemBefore(MediaItem anchor) async { + /// The queue item immediately before [anchor], extending a server-backed + /// window when needed. + Future _itemBefore(MediaItem anchor) async { final anchorId = playQueueItemIdFor(anchor); - if (anchorId == null) return null; - final anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return null; + if (anchorId == null) return const QueueNavigationResult.unavailable(); + var anchorIndex = _findLoadedIndex(anchorId); + if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - // Check if there's a previous item in the loaded window if (anchorIndex > 0) { - return _loadedItems[anchorIndex - 1]; + return QueueNavigationResult.found(_loadedItems[anchorIndex - 1]); } - // Need to load previous window. Plex-only path — _windowFetcher != null - // implies items are PlexMediaItem. - if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) { - final first = _loadedItems.first; - final prevItemID = first is PlexMediaItem ? first.playQueueItemId : null; - if (prevItemID != null && prevItemID > 0) { - final targetPlayQueueItemID = prevItemID - 1; - final loaded = await _ensureItemsLoaded(targetPlayQueueItemID); - if (loaded) return _findLoadedItem(targetPlayQueueItemID); - } + if (_windowFetcher == null || _playQueueId == null) { + return const QueueNavigationResult.boundary(); + } + if (_playQueueTotalCount > 0 && _loadedItems.length >= _playQueueTotalCount) { + return const QueueNavigationResult.boundary(); } - return null; + if (!await _loadServerWindow(anchorId)) { + return const QueueNavigationResult.failed(); + } + anchorIndex = _findLoadedIndex(anchorId); + if (anchorIndex == -1) return const QueueNavigationResult.failed(); + return anchorIndex > 0 + ? QueueNavigationResult.found(_loadedItems[anchorIndex - 1]) + : const QueueNavigationResult.boundary(); } /// Queue items backed by the same physical file as [current] — the other diff --git a/lib/screens/video_player/completion_latch.dart b/lib/screens/video_player/completion_latch.dart index 9eb3b435..d0293126 100644 --- a/lib/screens/video_player/completion_latch.dart +++ b/lib/screens/video_player/completion_latch.dart @@ -13,6 +13,15 @@ const int spuriousEofToleranceMs = 10000; /// How a player EOF signal should be interpreted. enum EofSignalClass { genuine, spurious, unknown } +/// End-of-media action after considering adjacent-episode discovery. +enum CompletionNavigationAction { presentNext, retryAdjacent, exit } + +CompletionNavigationAction completionNavigationAction({required bool hasNext, required bool adjacentLoadFailed}) { + if (hasNext) return CompletionNavigationAction.presentNext; + if (adjacentLoadFailed) return CompletionNavigationAction.retryAdjacent; + return CompletionNavigationAction.exit; +} + /// Classify a player EOF signal against the best-known media duration. /// /// mpv reports a clean EOF when a network stream dies mid-file (a reaped diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 028df556..20aef26a 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -735,6 +735,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { mediaInfo: _currentMediaInfo, ); + _setPlayerState(() { + _nextEpisode = null; + _previousEpisode = null; + _nextEpisodeStatus = QueueNavigationStatus.failed; + }); + try { playbackState.setCurrentItem(metadata); } catch (e) { diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index 037ef19c..c841f5fa 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -70,80 +70,84 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { ); appLogger.d('Sequential play queue created with ${playQueue.items!.length} items'); + } else { + appLogger.w('Plex returned no usable sequential play queue; falling back to a local series queue'); } - } catch (e) { - // Non-critical: Sequential playback will fall back to non-queue navigation - appLogger.d('Could not create play queue for sequential playback', error: e); + } catch (e, st) { + appLogger.w('Could not create Plex play queue; falling back to a local series queue', error: e, stackTrace: st); } } - Future _loadAdjacentEpisodes({MediaItem? metadata, _PlaybackAttempt? attempt}) async { - if (!mounted || widget.isLive) return; + Future _loadAdjacentEpisodes({MediaItem? metadata, _PlaybackAttempt? attempt}) async { + if (!mounted || widget.isLive) return const AdjacentEpisodes.unavailable(); final targetMetadata = metadata ?? _currentMetadata; - - if (_offlineLibraryMode) { - // Offline mode: find next/previous from downloaded episodes - _loadAdjacentEpisodesOffline(); - return; - } - try { - final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes( - context: context, - metadata: targetMetadata, - // The part actually being played, so the queue can skip sibling - // entries of a Plex multi-episode file (#1500). MediaSourceInfo - // carries the Plex numeric part id; MediaPart.id is its string form. - playedPartId: _currentMediaInfo?.partId?.toString(), - ); - - if (mounted && _currentMetadata.globalKey == targetMetadata.globalKey && (attempt == null || attempt.isCurrent)) { - _setPlayerState(() { - _nextEpisode = adjacentEpisodes.next; - _previousEpisode = adjacentEpisodes.previous; - }); - } - } catch (e) { - // Non-critical: Failed to load next/previous episode metadata - appLogger.d('Could not load adjacent episodes', error: e); + final adjacentEpisodes = _offlineLibraryMode + ? _loadAdjacentEpisodesOffline(targetMetadata) + : await _episodeNavigation.loadAdjacentEpisodes( + context: context, + metadata: targetMetadata, + // The part actually being played, so the queue can skip sibling + // entries of a Plex multi-episode file (#1500). MediaSourceInfo + // carries the Plex numeric part id; MediaPart.id is its string form. + playedPartId: _currentMediaInfo?.partId?.toString(), + ); + _commitAdjacentEpisodes(targetMetadata, adjacentEpisodes, attempt); + return adjacentEpisodes; + } catch (e, st) { + appLogger.w('Could not load adjacent episodes', error: e, stackTrace: st); + const failed = AdjacentEpisodes.failed(); + _commitAdjacentEpisodes(targetMetadata, failed, attempt); + return failed; } } - /// Load next/previous episodes from locally downloaded content - void _loadAdjacentEpisodesOffline() { - if (!_currentMetadata.isEpisode) return; + /// Load next/previous episodes from locally downloaded content. + AdjacentEpisodes _loadAdjacentEpisodesOffline(MediaItem metadata) { + if (!metadata.isEpisode) return const AdjacentEpisodes.unavailable(); - final showKey = _currentMetadata.grandparentId; - if (showKey == null) return; + final showKey = metadata.grandparentId; + if (showKey == null) return const AdjacentEpisodes.unavailable(); try { final downloadProvider = context.read(); final episodes = downloadProvider.getDownloadedEpisodesForShow(showKey); - - if (episodes.isEmpty) return; + if (episodes.isEmpty) return const AdjacentEpisodes.failed(); // Aired watch order (Specials interleaved by air date) — the shared // episode order, so offline next/prev matches streaming, what "download // next N" selects, and the offline OnDeck list (#1416/#1414). Copy first // so the provider's cached list isn't reordered. final sorted = List.from(episodes)..sort(compareEpisodesByWatchOrder); + final currentIdx = sorted.indexWhere((ep) => ep.id == metadata.id); + if (currentIdx == -1) return const AdjacentEpisodes.failed(); - final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id); - - if (currentIdx == -1) return; - - if (mounted) { - // Same-file siblings are skipped by file-path intersection of the - // stored metadata (#1500) — offline media info doesn't carry the - // server part id, so the helpers compare the items' own parts. - _setPlayerState(() { - _previousEpisode = previousEpisodeSkippingSameFile(sorted, currentIdx); - _nextEpisode = nextEpisodeSkippingSameFile(sorted, currentIdx); - }); - } - } catch (e) { - appLogger.d('Could not load offline adjacent episodes', error: e); + // Same-file siblings are skipped by file-path intersection of the + // stored metadata (#1500) — offline media info doesn't carry the + // server part id, so the helpers compare the items' own parts. + final previous = previousEpisodeSkippingSameFile(sorted, currentIdx); + final next = nextEpisodeSkippingSameFile(sorted, currentIdx); + return AdjacentEpisodes( + next: next, + previous: previous, + nextStatus: next == null ? QueueNavigationStatus.boundary : QueueNavigationStatus.found, + previousStatus: previous == null ? QueueNavigationStatus.boundary : QueueNavigationStatus.found, + ); + } catch (e, st) { + appLogger.w('Could not load offline adjacent episodes', error: e, stackTrace: st); + return const AdjacentEpisodes.failed(); } } + + void _commitAdjacentEpisodes(MediaItem targetMetadata, AdjacentEpisodes adjacentEpisodes, _PlaybackAttempt? attempt) { + if (!mounted || _currentMetadata.globalKey != targetMetadata.globalKey || (attempt != null && !attempt.isCurrent)) { + return; + } + _setPlayerState(() { + _nextEpisode = adjacentEpisodes.next; + _previousEpisode = adjacentEpisodes.previous; + _nextEpisodeStatus = adjacentEpisodes.nextStatus; + }); + } } diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 6fbccc6e..f25b8a50 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -9,6 +9,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { // Ignore spurious EOF from the old file during an in-place media-source // transition (episode swap, transcode restart, channel switch). if (_playbackTransition != _PlaybackTransition.idle) return; + if (_isResolvingCompletionAdjacency) return; // mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged // never fires false. Normalize all playback-dependent state. @@ -39,7 +40,33 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { return; } - if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionLatch.triggered) { + var navigationAction = completionNavigationAction( + hasNext: _nextEpisode != null, + adjacentLoadFailed: _currentMetadata.isEpisode && _nextEpisodeStatus == QueueNavigationStatus.failed, + ); + if (navigationAction == CompletionNavigationAction.retryAdjacent) { + _isResolvingCompletionAdjacency = true; + try { + await _loadAdjacentEpisodes(); + } finally { + _isResolvingCompletionAdjacency = false; + } + if (!mounted) return; + navigationAction = completionNavigationAction( + hasNext: _nextEpisode != null, + adjacentLoadFailed: _currentMetadata.isEpisode && _nextEpisodeStatus == QueueNavigationStatus.failed, + ); + if (navigationAction == CompletionNavigationAction.retryAdjacent) { + _completionLatch.latch(); + showGlobalErrorSnackBar(t.messages.errorLoadingSeries); + return; + } + } + + if (navigationAction == CompletionNavigationAction.presentNext && + !_showPlayNextDialog && + !_showStillWatchingPrompt && + !_completionLatch.triggered) { _completionLatch.latch(); // PiP: skip dialog (user can't interact), auto-play immediately @@ -77,7 +104,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { if (autoPlayEnabled) { _startAutoPlayTimer(); } - } else if (_nextEpisode == null && !_completionLatch.triggered) { + } else if (navigationAction == CompletionNavigationAction.exit && !_completionLatch.triggered) { _completionLatch.latch(); unawaited(_handleBackButton()); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f23ebcda..0b9c170a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -300,6 +300,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin late MediaItem _currentMetadata; MediaItem? _nextEpisode; MediaItem? _previousEpisode; + QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed; + bool _isResolvingCompletionAdjacency = false; bool _isLoadingNext = false; bool _isLoadingPrevious = false; diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index 2529af03..f455c8d1 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -14,15 +14,33 @@ import '../utils/app_logger.dart'; /// Result of loading adjacent episodes class AdjacentEpisodes { + const AdjacentEpisodes({this.next, this.previous, required this.nextStatus, required this.previousStatus}); + + const AdjacentEpisodes.unavailable() + : next = null, + previous = null, + nextStatus = QueueNavigationStatus.unavailable, + previousStatus = QueueNavigationStatus.unavailable; + + const AdjacentEpisodes.failed() + : next = null, + previous = null, + nextStatus = QueueNavigationStatus.failed, + previousStatus = QueueNavigationStatus.failed; + final MediaItem? next; final MediaItem? previous; - - AdjacentEpisodes({this.next, this.previous}); + final QueueNavigationStatus nextStatus; + final QueueNavigationStatus previousStatus; bool get hasNext => next != null; bool get hasPrevious => previous != null; + bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary; + bool get nextLoadFailed => nextStatus == QueueNavigationStatus.failed; } +enum _EpisodeQueueAvailability { active, unavailable, failed } + /// Manages episode navigation for TV show playback. /// /// Handles: @@ -30,18 +48,14 @@ class AdjacentEpisodes { /// - Navigating between episodes while preserving track selections /// - Supporting both sequential and shuffle playback modes /// -/// Plex episodes navigate through the server-side `/playQueues` queue; -/// Jellyfin (and any other backend whose -/// [MediaServerClient.fetchClientSideEpisodeQueue] returns rows) builds -/// a full-series local queue here and publishes it through -/// [PlaybackStateProvider] so the rest of the player reads prev/next from -/// the same source. +/// Plex normally enters with its server-side `/playQueues` queue. If that +/// setup failed, the same client-side full-series path used by Jellyfin is +/// used as a fallback. Both paths publish into [PlaybackStateProvider] so +/// the player reads previous/next from one source. class EpisodeNavigationService { - /// Cached client-side episode lists, keyed by `seriesId`. Populated by - /// backends without server-side play queues (Jellyfin); Plex skips this - /// path entirely. Fetched once per series; subsequent navigation within - /// the show re-uses the cache so jumping anywhere doesn't trigger a - /// refetch. + /// Cached client-side episode lists, keyed by `seriesId`. Populated for + /// Jellyfin and when Plex's server-side queue is unavailable. Fetched once + /// per series; subsequent navigation within the show reuses the cache. /// /// Bounded by [_seriesCacheCapacity] LRU-style: each entry holds up to /// 200 episodes (~50–80 KB each at typical metadata sizes), so an @@ -78,46 +92,56 @@ class EpisodeNavigationService { final serverManager = context.read().serverManager; final playbackState = context.read(); - // 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 - // a no-op (Plex's `fetchClientSideEpisodeQueue` returns null). - await _ensureLocalEpisodeQueue(serverManager, playbackState, metadata); + // Preserve a server-side Plex queue when available. Otherwise build a + // full-series local queue for Plex or Jellyfin before resolving + // adjacency. + final availability = await _ensureLocalEpisodeQueue(serverManager, playbackState, metadata); - // Both backends now read prev/next off PlaybackStateProvider. - if (!playbackState.isQueueActive) { - return AdjacentEpisodes(); + if (availability == _EpisodeQueueAvailability.unavailable) { + return const AdjacentEpisodes.unavailable(); } - final next = await playbackState.getNextEpisode(metadata.id, loopQueue: false, playedPartId: playedPartId); - final previous = await playbackState.getPreviousEpisode(metadata.id, playedPartId: playedPartId); + if (availability == _EpisodeQueueAvailability.failed || !playbackState.isQueueActive) { + return const AdjacentEpisodes.failed(); + } + + final nextResult = await playbackState.getNextEpisode(metadata.id, playedPartId: playedPartId); + final previousResult = await playbackState.getPreviousEpisode(metadata.id, playedPartId: playedPartId); + final nextStatus = nextResult.status == QueueNavigationStatus.unavailable + ? QueueNavigationStatus.failed + : nextResult.status; + final previousStatus = previousResult.status == QueueNavigationStatus.unavailable + ? QueueNavigationStatus.failed + : previousResult.status; final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential'; - appLogger.d('$mode mode - Next: ${next?.title}, Previous: ${previous?.title}'); - return AdjacentEpisodes(next: next, previous: previous); - } catch (e) { - // Non-critical: Failed to load next/previous episode metadata - appLogger.d('Could not load adjacent episodes', error: e); - return AdjacentEpisodes(); + appLogger.d( + '$mode mode - Next: ${nextResult.item?.title} ($nextStatus), ' + 'Previous: ${previousResult.item?.title} ($previousStatus)', + ); + return AdjacentEpisodes( + next: nextResult.item, + previous: previousResult.item, + nextStatus: nextStatus, + previousStatus: previousStatus, + ); + } catch (e, st) { + appLogger.w('Could not load adjacent episodes', error: e, stackTrace: st); + return const AdjacentEpisodes.failed(); } } - /// 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 _ensureLocalEpisodeQueue( + /// 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. Otherwise the + /// backend's full series episode list is published, anchored at the current + /// episode. For Plex this is the fallback when `/playQueues` was + /// unavailable; for Jellyfin it is the normal queue path. + Future<_EpisodeQueueAvailability> _ensureLocalEpisodeQueue( MultiServerManager serverManager, PlaybackStateProvider playbackState, MediaItem metadata, ) async { if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) { - return; + return _EpisodeQueueAvailability.unavailable; } final seriesId = metadata.grandparentId!; // Preserve any queue this item already belongs to — a launcher-seeded @@ -128,7 +152,7 @@ class EpisodeNavigationService { // sequential rebuild after the first episode (#1466). if (playbackState.isItemInActiveQueue(metadata)) { playbackState.setCurrentItem(metadata); - return; + return _EpisodeQueueAvailability.active; } // Same-episode reload with a fresh object: a source/quality switch hands // _reloadMediaInPlace a copyWith clone of the playing item, and MediaItem @@ -136,7 +160,7 @@ class EpisodeNavigationService { // already points at this episode — the queue (and any shuffled order) // must survive. if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) { - return; + return _EpisodeQueueAvailability.active; } // 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. @@ -145,24 +169,25 @@ class EpisodeNavigationService { // would walk the show instead of the user's list. final activeKey = playbackState.shuffleContextKey; if (playbackState.isQueueActive && activeKey != null && activeKey != seriesId) { - return; + return _EpisodeQueueAvailability.failed; } var allEpisodes = _readSeriesCache(seriesId); if (allEpisodes == null) { final client = serverManager.getClient(ServerId(metadata.serverId!)); - if (client == null) return; + if (client == null) return _EpisodeQueueAvailability.failed; try { allEpisodes = await client.fetchClientSideEpisodeQueue(seriesId); } catch (e, st) { appLogger.w('Failed series-episodes fetch for queue', error: e, stackTrace: st); - return; + return _EpisodeQueueAvailability.failed; + } + if (allEpisodes == null || allEpisodes.isEmpty) { + return _EpisodeQueueAvailability.failed; } - if (allEpisodes == null) return; // backend uses a server-side queue (Plex) - if (allEpisodes.isEmpty) return; // empty series _writeSeriesCache(seriesId, allEpisodes); } final anchorIdx = allEpisodes.indexWhere((m) => m.id == metadata.id); - if (anchorIdx < 0) return; + if (anchorIdx < 0) return _EpisodeQueueAvailability.failed; final queue = LocalPlayQueue( id: '${metadata.backend.id}:$seriesId', @@ -172,6 +197,7 @@ class EpisodeNavigationService { ); playbackState.setPlaybackFromLocalQueue(queue, contextKey: seriesId); appLogger.d('Local episode queue (${allEpisodes.length} episodes, anchor: $anchorIdx)'); + return _EpisodeQueueAvailability.active; } /// LRU-touching read: re-inserts the entry so it becomes the most recent. diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 1f370978..86941a2b 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../media/download_resolution.dart'; +import '../media/episode_collection.dart'; import '../media/library_filter_result.dart'; import '../media/library_first_character.dart'; import '../media/library_query.dart'; @@ -2960,11 +2961,16 @@ class PlexClient ); } - /// Plex maintains episode queues server-side via `/playQueues`, so the - /// client-side window EpisodeNavigationService builds for Jellyfin isn't - /// needed here. + /// Full-series fallback for episode navigation when Plex `/playQueues` + /// creation is unavailable. Grandchildren includes watched episodes; sort + /// locally so the fallback uses the same interleaved-specials watch order + /// as the server queue. @override - Future?> fetchClientSideEpisodeQueue(String seriesId) async => null; + Future?> fetchClientSideEpisodeQueue(String seriesId) async { + final episodes = await fetchPlayableDescendants(seriesId); + sortEpisodesByWatchOrder(episodes); + return episodes; + } /// Plex's artist `/children` response only contains the primary album /// bucket. Filter album rows in the artist's music section to include every diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart index bff02fd0..05938164 100644 --- a/test/providers/playback_state_provider_test.dart +++ b/test/providers/playback_state_provider_test.dart @@ -133,31 +133,30 @@ void main() { p.dispose(); }); - test('setCurrentItem updates id only when in queue mode', () async { + test('setCurrentItem updates the cursor only for validated queue members', () async { final p = PlaybackStateProvider(); - // Not in queue mode → no-op var notified = 0; p.addListener(() => notified++); - p.setCurrentItem(_miItem('a', 5)); + p.setCurrentItem(_miItem('a', 1001)); expect(p.currentPlayQueueItemID, isNull); expect(notified, 0); - // Enter queue mode await p.setPlaybackFromPlayQueue( - _queue(playQueueID: 1, selectedItemID: 1001, totalCount: 1, items: [_item('a', 1001)]), + _queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: [_item('a', 1001), _item('b', 1002)]), null, ); - // setPlaybackFromPlayQueue notifies once final preNotify = notified; - p.setCurrentItem(_miItem('b', 2002)); - expect(p.currentPlayQueueItemID, 2002); + // A fresh copy of a real loaded member is accepted. + p.setCurrentItem(_miItem('b', 1002)); + expect(p.currentPlayQueueItemID, 1002); expect(notified, preNotify + 1); - // Item without playQueueItemId → no update, no notify - p.setCurrentItem(testMediaItem(id: 'd', backend: MediaBackend.plex, kind: MediaKind.episode)); - expect(p.currentPlayQueueItemID, 2002); + // A stamped item outside this queue cannot poison the cursor. + p.setCurrentItem(_miItem('outsider', 2002)); + expect(p.currentPlayQueueItemID, 1002); + expect(notified, preNotify + 1); p.dispose(); }); @@ -168,9 +167,9 @@ void main() { await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null); final next = await p.getNextEpisode('b'); - expect(next, isNotNull); - expect(next!.id, 'c'); - expect((next as PlexMediaItem).playQueueItemId, 1003); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'c'); + expect((next.item as PlexMediaItem).playQueueItemId, 1003); // currentPlayQueueItemID is NOT updated by getNextEpisode (setCurrentItem does that). expect(p.currentPlayQueueItemID, 1002); @@ -178,17 +177,74 @@ void main() { p.dispose(); }); - test('getNextEpisode returns null at end of queue without loop', () async { + test('getNextEpisode reports the queue boundary at the end', () async { final p = PlaybackStateProvider(); final items = [_item('a', 1001), _item('b', 1002)]; await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 2, items: items), null); final next = await p.getNextEpisode('b'); - expect(next, isNull); + expect(next.status, QueueNavigationStatus.boundary); + expect(next.item, isNull); p.dispose(); }); + test('getNextEpisode anchors on the supplied media key instead of a stale cursor', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + final items = [_item('a', 1001), _item('b', 1002), _item('c', 1003)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 3, items: items), null); + + final next = await p.getNextEpisode('b'); + + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'c'); + expect(p.currentPlayQueueItemID, 1001, reason: 'read-only lookup must not move the playback cursor'); + }); + + test('server window extension uses opaque queue ids and the real anchor', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + final first = _item('a', 1001); + final nextItem = _item('b', 9007); + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: [first]), + null, + ); + String? requestedCenter; + p.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async { + requestedCenter = center; + return _queue(playQueueID: playQueueId, selectedItemID: 1001, totalCount: 2, items: [first, nextItem]); + }); + + final next = await p.getNextEpisode('a'); + + expect(requestedCenter, '1001'); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'b'); + }); + + test('windowed queue confirms its global end with a centered fetch', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + final items = [_item('y', 5001), _item('z', 9007)]; + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 1, selectedItemID: 9007, totalCount: 100, items: items), + null, + ); + var fetchCount = 0; + p.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async { + fetchCount++; + expect(center, '9007'); + return _queue(playQueueID: playQueueId, selectedItemID: 9007, totalCount: 100, items: items); + }); + + final next = await p.getNextEpisode('z'); + + expect(next.status, QueueNavigationStatus.boundary); + expect(fetchCount, 1); + }); + test('getNextEpisode does not retry recursively when loaded window misses target', () async { final p = PlaybackStateProvider(); addTearDown(p.dispose); @@ -201,14 +257,15 @@ void main() { return _queue(playQueueID: playQueueId, selectedItemID: 1002, totalCount: 3, items: items); }); - expect(await p.getNextEpisode('b'), isNull); + expect((await p.getNextEpisode('b')).status, QueueNavigationStatus.boundary); expect(fetchCount, 1); }); - test('getNextEpisode with no queue returns null (sequential mode)', () async { + test('getNextEpisode reports unavailable with no active queue', () async { final p = PlaybackStateProvider(); final next = await p.getNextEpisode('any-key'); - expect(next, isNull); + expect(next.status, QueueNavigationStatus.unavailable); + expect(next.item, isNull); p.dispose(); }); @@ -217,10 +274,10 @@ void main() { final items = [_item('a', 1001), _item('b', 1002), _item('c', 1003)]; await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null); - final prev = await p.getPreviousEpisode('b'); - expect(prev, isNotNull); - expect(prev!.id, 'a'); - expect((prev as PlexMediaItem).playQueueItemId, 1001); + final previous = await p.getPreviousEpisode('b'); + expect(previous.status, QueueNavigationStatus.found); + expect(previous.item!.id, 'a'); + expect((previous.item as PlexMediaItem).playQueueItemId, 1001); p.dispose(); }); @@ -230,16 +287,18 @@ void main() { final items = [_item('a', 1001), _item('b', 1002)]; await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: items), null); - final prev = await p.getPreviousEpisode('a'); - expect(prev, isNull); + final previous = await p.getPreviousEpisode('a'); + expect(previous.status, QueueNavigationStatus.boundary); + expect(previous.item, isNull); p.dispose(); }); test('getPreviousEpisode without queue mode returns null', () async { final p = PlaybackStateProvider(); - final prev = await p.getPreviousEpisode('any-key'); - expect(prev, isNull); + final previous = await p.getPreviousEpisode('any-key'); + expect(previous.status, QueueNavigationStatus.unavailable); + expect(previous.item, isNull); p.dispose(); }); @@ -316,6 +375,20 @@ void main() { expect(p.isItemInActiveQueue(outsider), isFalse); }); + test('isItemInActiveQueue rejects foreign server-stamped queue items', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + final member = _item('ep-in-queue', 5001); + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 77, selectedItemID: 5001, totalCount: 1, items: [member]), + 'playlist-Z', + ); + + expect(p.isItemInActiveQueue(_item('ep-in-queue', 5001)), isTrue); + expect(p.isItemInActiveQueue(_item('foreign', 9001)), isFalse); + expect(p.isItemInActiveQueue(_item('foreign', 5001)), isFalse); + }); + test('isItemInActiveQueue is false when no queue is active', () { final p = PlaybackStateProvider(); addTearDown(p.dispose); @@ -351,7 +424,8 @@ void main() { addTearDown(p.dispose); final next = await p.getNextEpisode('e24', playedPartId: 'part-e24'); - expect(next!.id, 'e26'); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'e26'); }); test('getNextEpisode skips the same-file sibling via file intersection without playedPartId', () async { @@ -359,7 +433,8 @@ void main() { addTearDown(p.dispose); final next = await p.getNextEpisode('e24'); - expect(next!.id, 'e26'); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'e26'); }); test('getNextEpisode skips multiple siblings of a triple-episode file', () async { @@ -381,7 +456,8 @@ void main() { ); final next = await p.getNextEpisode('e1', playedPartId: 'part-e1'); - expect(next!.id, 'e4'); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'e4'); }); test('getNextEpisode returns null when only same-file siblings remain', () async { @@ -397,7 +473,7 @@ void main() { null, ); - expect(await p.getNextEpisode('e24', playedPartId: 'part-e24'), isNull); + expect((await p.getNextEpisode('e24', playedPartId: 'part-e24')).status, QueueNavigationStatus.boundary); }); test('items without file data keep positional behavior even with playedPartId', () async { @@ -407,7 +483,8 @@ void main() { await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: items), null); final next = await p.getNextEpisode('a', playedPartId: 'part-a'); - expect(next!.id, 'b'); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'b'); }); test('skip past the loaded window extends it and lands on the next distinct file', () async { @@ -432,7 +509,8 @@ void main() { }); final next = await p.getNextEpisode('e24', playedPartId: 'part-e24'); - expect(next!.id, 'e26'); + expect(next.status, QueueNavigationStatus.found); + expect(next.item!.id, 'e26'); expect(fetchCount, 1); }); @@ -441,8 +519,9 @@ void main() { addTearDown(p.dispose); // From e26, previous is the e24-e25 file, entered at e24 (not e25). - final prev = await p.getPreviousEpisode('e26', playedPartId: 'part-e26'); - expect(prev!.id, 'e24'); + final previous = await p.getPreviousEpisode('e26', playedPartId: 'part-e26'); + expect(previous.status, QueueNavigationStatus.found); + expect(previous.item!.id, 'e24'); }); test('getPreviousEpisode skips same-file siblings of the playing item', () async { @@ -450,8 +529,9 @@ void main() { addTearDown(p.dispose); // Playing the file as e25: previous must not land inside the same file. - final prev = await p.getPreviousEpisode('e25', playedPartId: 'part-e25'); - expect(prev!.id, 'e23'); + final previous = await p.getPreviousEpisode('e25', playedPartId: 'part-e25'); + expect(previous.status, QueueNavigationStatus.found); + expect(previous.item!.id, 'e23'); }); test('sameFileSiblings returns the other episodes of the playing file', () async { diff --git a/test/screens/video_player/completion_latch_test.dart b/test/screens/video_player/completion_latch_test.dart index 9e2864e3..0ec81216 100644 --- a/test/screens/video_player/completion_latch_test.dart +++ b/test/screens/video_player/completion_latch_test.dart @@ -77,6 +77,26 @@ void main() { expect(l.triggered, isFalse); }); + group('completionNavigationAction', () { + test('presents a resolved next episode', () { + expect( + completionNavigationAction(hasNext: true, adjacentLoadFailed: false), + CompletionNavigationAction.presentNext, + ); + }); + + test('retries adjacency instead of exiting after a load failure', () { + expect( + completionNavigationAction(hasNext: false, adjacentLoadFailed: true), + CompletionNavigationAction.retryAdjacent, + ); + }); + + test('exits only after the queue boundary was resolved', () { + expect(completionNavigationAction(hasNext: false, adjacentLoadFailed: false), CompletionNavigationAction.exit); + }); + }); + group('classifyEofSignal', () { EofSignalClass classify(int positionMs, {int playerDurationMs = 0, int? metadataDurationMs}) => classifyEofSignal( positionMs: positionMs, diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index db657e4f..23dfc67b 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -26,6 +26,16 @@ MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId}) grandparentId: seriesId, ); +MediaItem _plexEpisode(String id, {required String seriesId, int? viewCount}) => testMediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $id', + serverId: 'srv-plex', + grandparentId: seriesId, + viewCount: viewCount, +); + /// MultiServerManager subclass that returns a pre-supplied client without /// going through the production add-connection flow. The base class doesn't /// expose a way to inject clients into its private `_clients` map, so we @@ -40,18 +50,22 @@ class _StubManager extends MultiServerManager { /// Recording client whose `fetchClientSideEpisodeQueue` is observable — /// callers can assert it was (or wasn't) hit. class _RecordingClient implements MediaServerClient { - _RecordingClient({required this.seriesEpisodes}); + _RecordingClient({required this.seriesEpisodes, this.clientBackend = MediaBackend.jellyfin, this.fetchError}); final List seriesEpisodes; + final MediaBackend clientBackend; + final Object? fetchError; final List seriesQueueCalls = []; @override Future?> fetchClientSideEpisodeQueue(String seriesId) async { seriesQueueCalls.add(seriesId); + final error = fetchError; + if (error != null) throw error; return seriesEpisodes; } @override - MediaBackend get backend => MediaBackend.jellyfin; + MediaBackend get backend => clientBackend; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @@ -88,31 +102,36 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('loadAdjacentEpisodes', () { - testWidgets('returns empty AdjacentEpisodes when no play queue is active', (tester) async { - // Bare provider — no setPlaybackFromPlayQueue() call → isQueueActive = false. + testWidgets('returns unavailable when no play queue is active for non-series media', (tester) async { final playback = PlaybackStateProvider(); addTearDown(playback.dispose); + final manager = _StubManager(null); + final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(serverProvider.dispose); AdjacentEpisodes? result; await tester.pumpWidget( - ChangeNotifierProvider.value( - value: playback, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: playback), + ChangeNotifierProvider.value(value: serverProvider), + ], child: _ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r), ), ); - // Drain the post-frame callback and the awaited service call. await tester.pump(); await tester.pump(); expect(result, isNotNull); + expect(result!.nextStatus, QueueNavigationStatus.unavailable); expect(result!.hasNext, isFalse); expect(result!.hasPrevious, isFalse); + expect(playback.isQueueActive, isFalse); }); - testWidgets('catches downstream exceptions and returns empty AdjacentEpisodes', (tester) async { - // PlaybackStateProvider not provided → context.read throws. The service - // wraps the entire body in try/catch and returns AdjacentEpisodes() so - // the UI never crashes when the queue subsystem is unavailable. + testWidgets('catches downstream exceptions and reports failed adjacency', (tester) async { + // Required providers are absent, so context.read throws. The service + // converts the exception into an explicit failed result. AdjacentEpisodes? result; await tester.pumpWidget(_ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r)); await tester.pump(); @@ -121,6 +140,7 @@ void main() { expect(result, isNotNull); expect(result!.hasNext, isFalse); expect(result!.hasPrevious, isFalse); + expect(result!.nextStatus, QueueNavigationStatus.failed); }); testWidgets('preserves an active playlist/collection queue against series rebuild', (tester) async { @@ -182,6 +202,96 @@ void main() { expect(result!.next?.id, 'ep3'); expect(result!.previous?.id, 'ep1'); }); + + testWidgets('builds a Plex local fallback queue with watched episodes', (tester) async { + final ep1 = _plexEpisode('ep1', seriesId: 'series-P', viewCount: 1); + final ep2 = _plexEpisode('ep2', seriesId: 'series-P', viewCount: 1); + final ep3 = _plexEpisode('ep3', seriesId: 'series-P', viewCount: 1); + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3], clientBackend: MediaBackend.plex); + final manager = _StubManager(client); + final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(serverProvider.dispose); + + AdjacentEpisodes? result; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: playback), + ChangeNotifierProvider.value(value: serverProvider), + ], + child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(client.seriesQueueCalls, ['series-P']); + expect(playback.loadedItems.map((item) => item.id), ['ep1', 'ep2', 'ep3']); + expect(result!.nextStatus, QueueNavigationStatus.found); + expect(result!.next?.id, 'ep3'); + expect(result!.previous?.id, 'ep1'); + }); + + testWidgets('distinguishes a fallback fetch failure from the end of a series', (tester) async { + final current = _plexEpisode('ep2', seriesId: 'series-P'); + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + final client = _RecordingClient( + seriesEpisodes: const [], + clientBackend: MediaBackend.plex, + fetchError: StateError('network unavailable'), + ); + final manager = _StubManager(client); + final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(serverProvider.dispose); + + AdjacentEpisodes? result; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: playback), + ChangeNotifierProvider.value(value: serverProvider), + ], + child: _ProbeWidget(metadata: current, onResult: (r) => result = r), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(result!.nextStatus, QueueNavigationStatus.failed); + expect(result!.isEndConfirmed, isFalse); + expect(playback.isQueueActive, isFalse); + }); + + testWidgets('confirms the end only after loading a queue containing the current episode', (tester) async { + final ep1 = _plexEpisode('ep1', seriesId: 'series-P', viewCount: 1); + final ep2 = _plexEpisode('ep2', seriesId: 'series-P', viewCount: 1); + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + final client = _RecordingClient(seriesEpisodes: [ep1, ep2], clientBackend: MediaBackend.plex); + final manager = _StubManager(client); + final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(serverProvider.dispose); + + AdjacentEpisodes? result; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: playback), + ChangeNotifierProvider.value(value: serverProvider), + ], + child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(result!.nextStatus, QueueNavigationStatus.boundary); + expect(result!.isEndConfirmed, isTrue); + expect(result!.next, isNull); + }); }); // =========================================================== diff --git a/test/services/plex_library_details_test.dart b/test/services/plex_library_details_test.dart index 8cafc4bf..f82cd486 100644 --- a/test/services/plex_library_details_test.dart +++ b/test/services/plex_library_details_test.dart @@ -585,6 +585,62 @@ void main() { expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10'); }); + test('client-side episode fallback retains watched rows and sorts by watch order', () async { + Uri? requestUri; + final client = makeClient((request) async { + if (request.url.path == '/library/metadata/show-1/grandchildren') { + requestUri = request.url; + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 3, + 'totalSize': 3, + 'Metadata': [ + { + 'ratingKey': 'special', + 'type': 'episode', + 'title': 'Special', + 'parentIndex': 0, + 'index': 1, + 'originallyAvailableAt': '2024-01-02', + 'viewCount': 1, + }, + { + 'ratingKey': 'ep-2', + 'type': 'episode', + 'title': 'Episode 2', + 'parentIndex': 1, + 'index': 2, + 'originallyAvailableAt': '2024-01-03', + 'viewCount': 1, + }, + { + 'ratingKey': 'ep-1', + 'type': 'episode', + 'title': 'Episode 1', + 'parentIndex': 1, + 'index': 1, + 'originallyAvailableAt': '2024-01-01', + 'viewCount': 1, + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final episodes = await client.fetchClientSideEpisodeQueue('show-1'); + + expect(requestUri!.path, '/library/metadata/show-1/grandchildren'); + expect(episodes!.map((episode) => episode.id), ['ep-1', 'special', 'ep-2']); + expect(episodes.every((episode) => episode.isWatched), isTrue); + }); + test('hub content pages by filtered video item offset', () async { final requests = []; final client = makeClient((request) async {