Files
plezy/lib/services/episode_navigation_service.dart
T
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00

225 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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;
final QueueNavigationStatus nextStatus;
final QueueNavigationStatus previousStatus;
bool get hasNext => next != null;
bool get hasPrevious => previous != null;
bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary;
}
enum _EpisodeQueueAvailability { active, unavailable, failed }
/// Manages queue-based adjacency for video playback.
///
/// Handles:
/// - Loading next/previous items from verified play queues
/// - Building episode-series fallback queues when needed
/// - Navigating between episodes while preserving track selections
/// - Supporting both sequential and shuffle playback modes
///
/// Plex normally enters with its server-side `/playQueues` queue. If that
/// setup failed for an episode, the same client-side full-series path used by
/// Jellyfin is used as a fallback. Playlist and collection queues may contain
/// movies; once membership is verified, they use the same adjacency path.
class EpisodeNavigationService {
/// 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 (~5080 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 ~510 MB of metadata when the user wanders the library.
static const int _seriesCacheCapacity = 5;
/// Load the next and previous items for the current video.
///
/// Series queue construction is episode-only. Movies can still resolve
/// adjacency when they are proven members of the active playlist or
/// collection queue.
///
/// [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>();
// 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);
if (availability == _EpisodeQueueAvailability.unavailable) {
return const AdjacentEpisodes.unavailable();
}
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: ${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 for both movies and
/// episodes. Otherwise an episode's full backend series list is published,
/// anchored at the current episode. For Plex this series build is the
/// fallback when `/playQueues` was unavailable; for Jellyfin it is the
/// normal episode path.
Future<_EpisodeQueueAvailability> _ensureLocalEpisodeQueue(
MultiServerManager serverManager,
PlaybackStateProvider playbackState,
MediaItem metadata,
) async {
// Queue membership and current-cursor identity are media-kind agnostic.
// Prove them before applying the episode-only series-build prerequisites
// so playlist and collection movies retain their launcher-seeded queue.
// 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 _EpisodeQueueAvailability.active;
}
// Same-item 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 item — the queue (and any shuffled order) must
// survive.
if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) {
return _EpisodeQueueAvailability.active;
}
if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) {
return playbackState.isQueueActive ? _EpisodeQueueAvailability.failed : _EpisodeQueueAvailability.unavailable;
}
final seriesId = metadata.grandparentId!;
// 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 _EpisodeQueueAvailability.failed;
}
var allEpisodes = _readSeriesCache(seriesId);
if (allEpisodes == null) {
final client = serverManager.getClient(ServerId(metadata.serverId!));
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 _EpisodeQueueAvailability.failed;
}
if (allEpisodes == null || allEpisodes.isEmpty) {
return _EpisodeQueueAvailability.failed;
}
_writeSeriesCache(seriesId, allEpisodes);
}
final anchorIdx = allEpisodes.indexWhere((m) => m.id == metadata.id);
if (anchorIdx < 0) return _EpisodeQueueAvailability.failed;
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)');
return _EpisodeQueueAvailability.active;
}
/// 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);
}
}
}