fix(playback): recover episode navigation without Plex queues
This commit is contained 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
|
||||
|
||||
Reference in New Issue
Block a user