196 lines
8.7 KiB
Dart
196 lines
8.7 KiB
Dart
import 'dart:async';
|
||
import '../media/ids.dart';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../media/media_item.dart';
|
||
import '../media/media_item_types.dart';
|
||
import '../media/play_queue.dart';
|
||
import '../providers/multi_server_provider.dart';
|
||
import '../providers/playback_state_provider.dart';
|
||
import '../services/multi_server_manager.dart';
|
||
import '../utils/app_logger.dart';
|
||
|
||
/// Result of loading adjacent episodes
|
||
class AdjacentEpisodes {
|
||
final MediaItem? next;
|
||
final MediaItem? previous;
|
||
|
||
AdjacentEpisodes({this.next, this.previous});
|
||
|
||
bool get hasNext => next != null;
|
||
bool get hasPrevious => previous != null;
|
||
}
|
||
|
||
/// Manages episode navigation for TV show playback.
|
||
///
|
||
/// Handles:
|
||
/// - Loading next/previous episodes from play queues
|
||
/// - 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.
|
||
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.
|
||
///
|
||
/// Bounded by [_seriesCacheCapacity] LRU-style: each entry holds up to
|
||
/// 200 episodes (~50–80 KB each at typical metadata sizes), so an
|
||
/// unbounded map opens an OOM door for users who hop between many shows
|
||
/// in one session. `LinkedHashMap` preserves insertion order; we re-touch
|
||
/// on hit to keep the most recently used at the back.
|
||
final Map<String, List<MediaItem>> _seriesEpisodeCache = <String, List<MediaItem>>{};
|
||
|
||
/// Maximum number of distinct series whose episode lists stay resident.
|
||
/// 5 covers any plausible "binge a few shows in parallel" workflow without
|
||
/// holding ~5–10 MB of metadata when the user wanders the library.
|
||
static const int _seriesCacheCapacity = 5;
|
||
|
||
/// Load the next and previous episodes for the current episode
|
||
///
|
||
/// Returns null for episodes if:
|
||
/// - Not applicable (e.g., movie content)
|
||
/// - Next episode doesn't exist (end of season/series)
|
||
/// - Previous episode doesn't exist (first episode)
|
||
///
|
||
/// [playedPartId] is the backend part id actually being played, when
|
||
/// known — it lets the queue skip sibling entries of a Plex
|
||
/// multi-episode file (#1500).
|
||
Future<AdjacentEpisodes> loadAdjacentEpisodes({
|
||
required BuildContext context,
|
||
required MediaItem metadata,
|
||
String? playedPartId,
|
||
}) async {
|
||
try {
|
||
// Resolve providers up-front so we don't reach for `context` after
|
||
// any of the awaits below — avoids the
|
||
// `use_build_context_synchronously` lint and the genuine "widget
|
||
// unmounted mid-load" race it warns about.
|
||
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);
|
||
|
||
// Both backends now read prev/next off PlaybackStateProvider.
|
||
if (!playbackState.isQueueActive) {
|
||
return AdjacentEpisodes();
|
||
}
|
||
final next = await playbackState.getNextEpisode(metadata.id, loopQueue: false, playedPartId: playedPartId);
|
||
final previous = await playbackState.getPreviousEpisode(metadata.id, playedPartId: playedPartId);
|
||
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();
|
||
}
|
||
}
|
||
|
||
/// 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(
|
||
MultiServerManager serverManager,
|
||
PlaybackStateProvider playbackState,
|
||
MediaItem metadata,
|
||
) async {
|
||
if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) {
|
||
return;
|
||
}
|
||
final seriesId = metadata.grandparentId!;
|
||
// Preserve any queue this item already belongs to — a launcher-seeded
|
||
// shuffled show queue (contextKey == seriesId), a playlist/collection
|
||
// queue, or a series queue this method built earlier. setCurrentItem
|
||
// re-anchors the cursor, replacing the re-anchor the rebuild used to
|
||
// provide. Without this gate a shuffled show queue was clobbered by a
|
||
// sequential rebuild after the first episode (#1466).
|
||
if (playbackState.isItemInActiveQueue(metadata)) {
|
||
playbackState.setCurrentItem(metadata);
|
||
return;
|
||
}
|
||
// Same-episode reload with a fresh object: a source/quality switch hands
|
||
// _reloadMediaInPlace a copyWith clone of the playing item, and MediaItem
|
||
// compares by identity, so the membership gate above misses. The cursor
|
||
// already points at this episode — the queue (and any shuffled order)
|
||
// must survive.
|
||
if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) {
|
||
return;
|
||
}
|
||
// 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.
|
||
// [JellyfinSequentialLauncher]) sets contextKey to the playlist or
|
||
// collection id; a series rebuild here would clobber it and prev/next
|
||
// would walk the show instead of the user's list.
|
||
final activeKey = playbackState.shuffleContextKey;
|
||
if (playbackState.isQueueActive && activeKey != null && activeKey != seriesId) {
|
||
return;
|
||
}
|
||
var allEpisodes = _readSeriesCache(seriesId);
|
||
if (allEpisodes == null) {
|
||
final client = serverManager.getClient(ServerId(metadata.serverId!));
|
||
if (client == null) return;
|
||
try {
|
||
allEpisodes = await client.fetchClientSideEpisodeQueue(seriesId);
|
||
} catch (e, st) {
|
||
appLogger.w('Failed series-episodes fetch for queue', error: e, stackTrace: st);
|
||
return;
|
||
}
|
||
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;
|
||
|
||
final queue = LocalPlayQueue(
|
||
id: '${metadata.backend.id}:$seriesId',
|
||
items: allEpisodes,
|
||
currentIndex: anchorIdx,
|
||
backendId: metadata.backend.id,
|
||
);
|
||
playbackState.setPlaybackFromLocalQueue(queue, contextKey: seriesId);
|
||
appLogger.d('Local episode queue (${allEpisodes.length} episodes, anchor: $anchorIdx)');
|
||
}
|
||
|
||
/// LRU-touching read: re-inserts the entry so it becomes the most recent.
|
||
/// Returns null on miss.
|
||
List<MediaItem>? _readSeriesCache(String seriesId) {
|
||
final value = _seriesEpisodeCache.remove(seriesId);
|
||
if (value != null) {
|
||
_seriesEpisodeCache[seriesId] = value;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/// LRU-bounded write: evicts the oldest entry when capacity is exceeded.
|
||
void _writeSeriesCache(String seriesId, List<MediaItem> episodes) {
|
||
_seriesEpisodeCache.remove(seriesId);
|
||
_seriesEpisodeCache[seriesId] = episodes;
|
||
while (_seriesEpisodeCache.length > _seriesCacheCapacity) {
|
||
_seriesEpisodeCache.remove(_seriesEpisodeCache.keys.first);
|
||
}
|
||
}
|
||
}
|