Files
plezy/lib/providers/playback_state_provider.dart
T

342 lines
13 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});
/// 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 (item is PlexMediaItem && item.playQueueItemId != null) {
return item.playQueueItemId;
}
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. True for Plex
/// items the server-side queue stamped with a `playQueueItemId`, and for
/// items present in a Jellyfin local queue (synthetic id). Gates the
/// player's "preserve vs. wipe launcher-set queue" decision in both
/// [VideoPlayerScreen.initState] and `_ensurePlayQueue`, so a playlist
/// or collection queue survives entry into the player instead of being
/// replaced with a 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;
/// Set the client reference for loading more items
void setPlayQueueWindowFetcher(PlayQueueWindowFetcher? fetcher) {
_windowFetcher = fetcher;
}
/// Update the current play queue item when playing a new item
void setCurrentItem(MediaItem metadata) {
if (!_isQueueMode) return;
final id = playQueueItemIdFor(metadata);
if (id != null) {
_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 more items from the play queue if needed
/// Returns true if more items were loaded
Future<bool> _ensureItemsLoaded(int targetPlayQueueItemID) 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(),
window: _windowSize,
);
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 true;
}
} catch (e) {
// Failed to load items
return false;
}
return false;
}
Future<_IndexLookupResult> _getCurrentIndex({bool loadIfMissing = false}) async {
if (!_isQueueMode || _loadedItems.isEmpty || _currentPlayQueueItemID == null) {
return const _IndexLookupResult();
}
var currentIndex = _findLoadedIndex(_currentPlayQueueItemID!);
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 = _findLoadedIndex(_currentPlayQueueItemID!);
if (currentIndex == -1) {
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
}
return _IndexLookupResult(index: currentIndex, attemptedLoad: true);
}
/// 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;
}
/// 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
Future<MediaItem?> getNextEpisode(String currentItemKey, {bool loopQueue = false}) async {
if (!_isQueueMode) {
// For sequential mode, let the video player handle next episode
return null;
}
final indexResult = await _getCurrentIndex(loadIfMissing: true);
if (indexResult.index == null) {
if (indexResult.loadFailed) {
clearShuffle();
}
return null;
}
final currentIndex = indexResult.index!;
// Check if there's a next item in the loaded window
if (currentIndex + 1 < _loadedItems.length) {
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
return _loadedItems[currentIndex + 1];
}
// Check if we're at the end of the entire queue
if (currentIndex + 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!;
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
return _loadedItems.first;
}
}
}
// At end of queue - return null but keep queue active so user can still go back
return null;
}
// Need to load next window
if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) {
// Load next window centered on the item after current. 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 loaded = await _ensureItemsLoaded(nextItemID + 1);
if (loaded) {
// Try again with newly loaded items
return getNextEpisode(currentItemKey, loopQueue: loopQueue);
}
}
}
return null;
}
/// Gets the previous item in the playback queue.
/// Returns null if at the beginning of the queue or current item is not in queue.
Future<MediaItem?> getPreviousEpisode(String currentItemKey) async {
if (!_isQueueMode) {
// For sequential mode, let the video player handle previous episode
return null;
}
final currentIndex = (await _getCurrentIndex()).index;
if (currentIndex == null) return null;
// Check if there's a previous item in the loaded window
if (currentIndex > 0) {
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
return _loadedItems[currentIndex - 1];
}
// Check if we're at the beginning of the entire queue
if (currentIndex == 0) {
return null;
}
// Need to load previous window
if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) {
// Plex-only path — _windowFetcher != null implies items are PlexMediaItem.
final first = _loadedItems.first;
final prevItemID = first is PlexMediaItem ? first.playQueueItemId : null;
if (prevItemID != null && prevItemID > 0) {
final loaded = await _ensureItemsLoaded(prevItemID - 1);
if (loaded) {
return getPreviousEpisode(currentItemKey);
}
}
}
return null;
}
/// 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();
}
}