fix(playback): recover episode navigation without Plex queues
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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<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;
|
||||
_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<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!);
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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<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,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<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(
|
||||
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<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.
|
||||
_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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user