Files
plezy/lib/providers/playback_state_provider.dart
edde746 c68ffe9ed0 refactor: share the toolbar scrim and dedupe playback and download paths
Extracts the repeated toolbar fade into a single ToolbarScrim widget, folds
duplicated request/retry handling in the media server HTTP client, and
collapses the parallel playback-source, download-manager and live TV helper
paths into shared implementations.
2026-07-26 06:09:49 +02:00

426 lines
18 KiB
Dart

import 'package:flutter/foundation.dart';
import '../media/media_item.dart';
import '../media/play_queue.dart';
import '../models/plex/play_queue_response.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
/// Fetches a window of items from a server-side play queue. Provider calls
/// this when the currently loaded window doesn't contain the next item it
/// needs to surface. Wired to a backend that maintains queues server-side
/// (Plex's `/playQueues`); left null for client-side queues (Jellyfin's
/// [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;
final bool attemptedLoad;
final bool loadFailed;
const _IndexLookupResult({this.index, this.attemptedLoad = false, this.loadFailed = false});
}
/// Manages playback state using Plex's play queue API.
/// This provider is session-only and does not persist across app restarts.
class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
// Play queue state
int? _playQueueId;
int _playQueueTotalCount = 0;
bool _playQueueShuffled = false;
int? _currentPlayQueueItemID;
// Windowed items (loaded around current position)
List<MediaItem> _loadedItems = [];
final int _windowSize = 50; // Number of items to keep in memory
/// Synthetic per-item queue IDs for client-side queues (Jellyfin, etc.).
/// Parallel to [_loadedItems] — `_syntheticIds[i]` is the queue ID for
/// `_loadedItems[i]`. Empty when the queue is server-side (Plex), where
/// the real id lives on [PlexMediaItem.playQueueItemId].
List<int> _syntheticIds = const [];
String? _contextKey; // The show/season/playlist ratingKey for this session
bool _isQueueMode = false;
// Client reference for loading more items
PlayQueueWindowFetcher? _windowFetcher;
/// Returns the queue id for [item] within the current queue. For Plex
/// items this is the server's `playQueueItemID`; for client-side queues
/// (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) {
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;
return _syntheticIds[idx];
}
/// Whether shuffle mode is currently active
bool get isShuffleActive => _playQueueShuffled;
/// Whether playlist/collection mode is currently active
bool get isPlaylistActive => _isQueueMode;
/// Whether any queue-based playback is active
bool get isQueueActive => _playQueueId != null && _isQueueMode;
/// 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
/// playlist, collection, or shuffled show queue survives entry into the
/// player instead of being replaced with a sequential show queue.
bool isItemInActiveQueue(MediaItem item) => isQueueActive && playQueueItemIdFor(item) != null;
/// The context key (show/season/playlist ratingKey) for the current session
String? get shuffleContextKey => _contextKey;
/// Current play queue ID
int? get playQueueId => _playQueueId;
/// The currently loaded queue items (windowed subset of full queue)
List<MediaItem> get loadedItems => List.unmodifiable(_loadedItems);
/// The current play queue item ID
int? get currentPlayQueueItemID => _currentPlayQueueItemID;
/// The queue item the cursor currently points at, or null when no queue
/// is active or the cursor is outside the loaded window.
MediaItem? get currentQueueItem => _currentPlayQueueItemID == null ? null : _findLoadedItem(_currentPlayQueueItemID!);
/// Set the client reference for loading more items
void setPlayQueueWindowFetcher(PlayQueueWindowFetcher? fetcher) {
_windowFetcher = fetcher;
}
/// 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 || id == _currentPlayQueueItemID) return;
_currentPlayQueueItemID = id;
safeNotifyListeners();
}
/// Initialize playback from a play queue
/// Call this after creating a play queue via the API
Future<void> setPlaybackFromPlayQueue(PlayQueueResponse playQueue, String? contextKey) async {
_playQueueId = playQueue.playQueueID;
// Use size or items length as fallback if totalCount is null
_playQueueTotalCount = playQueue.playQueueTotalCount ?? playQueue.size ?? (playQueue.items?.length ?? 0);
_playQueueShuffled = playQueue.playQueueShuffled;
_currentPlayQueueItemID = playQueue.playQueueSelectedItemID;
// Items arrive pre-tagged with server info by the producing mapper.
_loadedItems = playQueue.items ?? [];
// Plex items carry their own playQueueItemId — no synthetic IDs needed.
_syntheticIds = const [];
_contextKey = contextKey;
_isQueueMode = true;
safeNotifyListeners();
}
/// Initialize playback from a [LocalPlayQueue] (Jellyfin / any backend
/// without a server-side queue). Synthetic per-item queue IDs are
/// recorded in [_syntheticIds] (parallel to [_loadedItems]) so the
/// existing Plex-shaped UI — Queue sheet, content strip, current item
/// highlight — keeps working without a parallel rendering path. Items
/// themselves are stored unmutated.
///
/// `playQueueId` is set to a sentinel so [isQueueActive] returns true.
/// Window-extension paths (`_ensureItemsLoaded`, `getNextEpisode`) consult
/// `_windowFetcher`, which stays null for client-side queues — JF callers
/// resolve adjacent items through [EpisodeNavigationService] instead.
void setPlaybackFromLocalQueue(LocalPlayQueue queue, {String? contextKey}) {
_playQueueId = -1; // sentinel for "client-side queue"
_playQueueTotalCount = queue.items.length;
_playQueueShuffled = queue.shuffled;
_loadedItems = List.of(queue.items);
_syntheticIds = [for (var i = 0; i < queue.items.length; i++) i];
_currentPlayQueueItemID = queue.currentIndex;
_contextKey = contextKey;
_isQueueMode = true;
_windowFetcher = null; // disable server-side window extension
safeNotifyListeners();
}
/// 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;
try {
final response = await _windowFetcher!(
_playQueueId!,
center: centerPlayQueueItemID.toString(),
window: _windowSize,
);
final items = response?.items;
if (response == null || items == null || items.isEmpty) return false;
_loadedItems = items;
_playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? items.length;
_playQueueShuffled = response.playQueueShuffled;
safeNotifyListeners();
return _findLoadedIndex(centerPlayQueueItemID) != -1;
} catch (_) {
return false;
}
}
/// 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();
}
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();
}
final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!);
if (!loaded) {
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
}
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.
int _findLoadedIndex(int playQueueItemId) {
for (var i = 0; i < _loadedItems.length; i++) {
final item = _loadedItems[i];
if (item is PlexMediaItem && item.playQueueItemId == playQueueItemId) {
return i;
}
if (i < _syntheticIds.length && _syntheticIds[i] == playQueueItemId) {
return i;
}
}
return -1;
}
MediaItem? _findLoadedItem(int playQueueItemId) {
final index = _findLoadedIndex(playQueueItemId);
return index == -1 ? null : _loadedItems[index];
}
/// Gets the next item in the playback queue.
///
/// 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(currentItemKey, loadIfMissing: true);
if (indexResult.index == 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 queue cannot spin.
for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemAtOffset(anchor, 1);
final candidate = result.item;
if (result.status != QueueNavigationStatus.found || candidate == null) {
return result;
}
if (!current.sharesFileWith(candidate, playedPartId: playedPartId)) {
return result;
}
anchor = candidate;
}
return const QueueNavigationResult.failed();
}
/// Gets the previous item in the playback queue.
/// Returns null if at the beginning of the queue or current item is not in queue.
///
/// Mirrors [getNextEpisode]'s multi-episode-file handling (#1500): entries
/// 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<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 current = _loadedItems[indexResult.index!];
MediaItem candidate = current;
for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemAtOffset(candidate, -1);
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 result = await _itemAtOffset(candidate, -1);
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 QueueNavigationResult.found(candidate);
}
/// The queue item [delta] steps from [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> _itemAtOffset(MediaItem anchor, int delta) async {
final anchorId = playQueueItemIdFor(anchor);
if (anchorId == null) return const QueueNavigationResult.unavailable();
var anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.unavailable();
var target = anchorIndex + delta;
if (target >= 0 && target < _loadedItems.length) {
return QueueNavigationResult.found(_loadedItems[target]);
}
// 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();
}
// Refresh around the actual anchor. Queue ids are opaque and need not be
// consecutive, so never guess the neighbour's id.
if (!await _loadServerWindow(anchorId)) {
return const QueueNavigationResult.failed();
}
anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.failed();
target = anchorIndex + delta;
return target >= 0 && target < _loadedItems.length
? QueueNavigationResult.found(_loadedItems[target])
: const QueueNavigationResult.boundary();
}
/// Queue items backed by the same physical file as [current] — the other
/// episodes of a Plex multi-episode file (#1500), which should share its
/// watched state. Scans the loaded window only: in sequential order
/// same-file episodes are adjacent, so they are always co-resident.
/// [current] may be a different object instance than the queue's copy;
/// exclusion is by item id.
List<MediaItem> sameFileSiblings(MediaItem current, {String? playedPartId}) {
if (!_isQueueMode) return const [];
final seenIds = <String>{current.id};
return [
for (final item in _loadedItems)
if (seenIds.add(item.id) && current.sharesFileWith(item, playedPartId: playedPartId)) item,
];
}
/// Clears the playback queue and exits queue mode
void clearShuffle() {
_playQueueId = null;
_playQueueTotalCount = 0;
_playQueueShuffled = false;
_currentPlayQueueItemID = null;
_loadedItems = [];
_syntheticIds = const [];
_contextKey = null;
_isQueueMode = false;
safeNotifyListeners();
}
}