fix(playback): recover episode navigation without Plex queues

This commit is contained in:
edde746
2026-07-24 08:08:10 +02:00
parent 54273ab09c
commit 269bb7a322
13 changed files with 681 additions and 301 deletions
+5 -7
View File
@@ -227,13 +227,11 @@ abstract class MediaServerClient {
/// branch if/when one does.
Future<List<MediaItem>> 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<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId);
/// Albums credited to [artist], newest first. Plex filters album rows in
+169 -133
View File
@@ -11,6 +11,37 @@ import '../mixins/disposable_change_notifier_mixin.dart';
/// [LocalPlayQueue]) where the full list is already resident.
typedef PlayQueueWindowFetcher = Future<PlayQueueResponse?> 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,15 +136,17 @@ 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) {
if (id == null || id == _currentPlayQueueItemID) return;
_currentPlayQueueItemID = id;
safeNotifyListeners();
}
}
/// Initialize playback from a play queue
/// Call this after creating a play queue via the API
@@ -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<bool> _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<bool> _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<PlexMediaItem>().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;
_loadedItems = items;
_playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? items.length;
_playQueueShuffled = response.playQueueShuffled;
safeNotifyListeners();
return _findLoadedIndex(targetPlayQueueItemID) != -1;
}
} catch (e) {
// Failed to load items
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<bool> _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!);
if (currentIndex != -1) {
return _IndexLookupResult(index: currentIndex);
int findCurrent() {
final cursorIndex = _findLoadedIndex(_currentPlayQueueItemID!);
if (cursorIndex != -1 && _matchesItemKey(_loadedItems[cursorIndex], currentItemKey)) {
return cursorIndex;
}
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<MediaItem?> 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<QueueNavigationResult> 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<MediaItem?> getPreviousEpisode(String currentItemKey, {String? playedPartId}) async {
if (!_isQueueMode) {
// For sequential mode, let the video player handle previous episode
return null;
Future<QueueNavigationResult> 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<MediaItem?> _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<QueueNavigationResult> _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;
// Local queues are fully resident, so their window edge is the queue edge.
if (_windowFetcher == null || _playQueueId == null) {
return const QueueNavigationResult.boundary();
}
}
}
// At end of queue - return null but keep queue active so user can still go back
return null;
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();
}
anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.failed();
return anchorIndex + 1 < _loadedItems.length
? QueueNavigationResult.found(_loadedItems[anchorIndex + 1])
: const QueueNavigationResult.boundary();
}
return null;
}
/// The queue item immediately before [anchor], extending the loaded
/// window when needed. Returns null at the beginning of the queue.
Future<MediaItem?> _itemBefore(MediaItem anchor) async {
/// The queue item immediately before [anchor], extending a server-backed
/// window when needed.
Future<QueueNavigationResult> _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
@@ -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
@@ -735,6 +735,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
mediaInfo: _currentMediaInfo,
);
_setPlayerState(() {
_nextEpisode = null;
_previousEpisode = null;
_nextEpisodeStatus = QueueNavigationStatus.failed;
});
try {
playbackState.setCurrentItem(metadata);
} catch (e) {
@@ -70,26 +70,22 @@ 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<void> _loadAdjacentEpisodes({MediaItem? metadata, _PlaybackAttempt? attempt}) async {
if (!mounted || widget.isLive) return;
Future<AdjacentEpisodes> _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(
final adjacentEpisodes = _offlineLibraryMode
? _loadAdjacentEpisodesOffline(targetMetadata)
: await _episodeNavigation.loadAdjacentEpisodes(
context: context,
metadata: targetMetadata,
// The part actually being played, so the queue can skip sibling
@@ -97,53 +93,61 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
// 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);
_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<DownloadProvider>();
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<MediaItem>.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.
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(() {
_previousEpisode = previousEpisodeSkippingSameFile(sorted, currentIdx);
_nextEpisode = nextEpisodeSkippingSameFile(sorted, currentIdx);
_nextEpisode = adjacentEpisodes.next;
_previousEpisode = adjacentEpisodes.previous;
_nextEpisodeStatus = adjacentEpisodes.nextStatus;
});
}
} catch (e) {
appLogger.d('Could not load offline adjacent episodes', error: e);
}
}
}
@@ -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());
}
+2
View File
@@ -300,6 +300,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
late MediaItem _currentMetadata;
MediaItem? _nextEpisode;
MediaItem? _previousEpisode;
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
bool _isResolvingCompletionAdjacency = false;
bool _isLoadingNext = false;
bool _isLoadingPrevious = false;
+76 -50
View File
@@ -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 (~5080 KB each at typical metadata sizes), so an
@@ -78,46 +92,56 @@ class EpisodeNavigationService {
final serverManager = context.read<MultiServerProvider>().serverManager;
final playbackState = context.read<PlaybackStateProvider>();
// 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<void> _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.
+10 -4
View File
@@ -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<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async => null;
Future<List<MediaItem>?> 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
+117 -37
View File
@@ -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 {
@@ -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,
@@ -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<MediaItem> seriesEpisodes;
final MediaBackend clientBackend;
final Object? fetchError;
final List<String> seriesQueueCalls = [];
@override
Future<List<MediaItem>?> 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<PlaybackStateProvider>.value(
value: playback,
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.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<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.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<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.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<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.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);
});
});
// ===========================================================
@@ -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 = <Uri>[];
final client = makeClient((request) async {