diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index f5aace88..e3c8566b 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -65,8 +65,40 @@ MediaItem? defaultPlaybackSeason(List seasons) { return season.kind == MediaKind.season ? season : null; } +/// Index of the first season that still has unwatched episodes, preferring +/// regular seasons over specials (mirrors [defaultPlaybackSeasonIndex]). Uses +/// leafCount/viewedLeafCount, so no episodes need to be fetched. Returns null +/// when every season is fully watched (or counts are unavailable). +int? firstUnwatchedSeasonIndex(List seasons) { + int? firstSpecial; + for (var i = 0; i < seasons.length; i++) { + final season = seasons[i]; + if (season.kind != MediaKind.season) continue; + final leaf = season.leafCount; + if (leaf == null || leaf <= 0) continue; + if ((season.viewedLeafCount ?? 0) >= leaf) continue; // fully watched + if ((season.index ?? 0) > 0) return i; // first regular season with unwatched + firstSpecial ??= i; // specials only count as a last resort + } + return firstSpecial; +} + +/// First episode that is unwatched or still in progress, in list order. +/// Same predicate as [_collectPlayable]'s `unwatchedOnly` filter, returned in +/// the order the episodes are displayed so the highlight matches the list. +MediaItem? firstUnwatchedEpisode(List episodes) { + for (final episode in episodes) { + if (episode.kind != MediaKind.episode) continue; + if (episode.isWatched && !episode.hasActiveProgress) continue; + return episode; + } + return null; +} + /// Find the season index matching an explicit navigation target or on-deck -/// episode, then fall back to [defaultPlaybackSeasonIndex]. +/// episode. With neither, fall back to the first season that still has +/// unwatched episodes (so a partially-watched show removed from Continue +/// Watching still opens on the right season), then [defaultPlaybackSeasonIndex]. int preferredSeasonIndex(List seasons, {int? initialSeasonIndex, MediaItem? onDeckEpisode}) { if (seasons.isEmpty) return 0; if (initialSeasonIndex != null) { @@ -88,6 +120,9 @@ int preferredSeasonIndex(List seasons, {int? initialSeasonIndex, Medi } } + final unwatched = firstUnwatchedSeasonIndex(seasons); + if (unwatched != null) return unwatched; + return defaultPlaybackSeasonIndex(seasons); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 8e7fec80..e173d28f 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1604,13 +1604,16 @@ class _MediaDetailScreenState extends State if (shouldShowEpisodesDirectly) { await _fetchAllEpisodes(); + _ensureFallbackOnDeckEpisode(); } else if (seasonsWithServerId.isNotEmpty) { // Load only the on-deck season's first page; other seasons load lazily - // when focused (strict on-demand — no whole-show pre-warm). + // when focused (strict on-demand — no whole-show pre-warm). Once the + // on-deck season is loaded, synthesize on-deck if the backend omitted it. + final fetchOnDeckSeason = _fetchSeasonEpisodes(onDeckSeasonIndex).then((_) => _ensureFallbackOnDeckEpisode()); if (PlatformDetector.isTV()) { - await _fetchSeasonEpisodes(onDeckSeasonIndex); + await fetchOnDeckSeason; } else { - unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex)); + unawaited(fetchOnDeckSeason); } } } catch (e, st) { @@ -2929,6 +2932,21 @@ class _MediaDetailScreenState extends State } } + /// Online counterpart to [_loadOfflineOnDeckEpisode]: when the backend + /// omitted an on-deck episode (e.g. the show was removed from Continue + /// Watching) synthesize one from the on-deck season's already-loaded episodes + /// so the next unwatched episode is highlighted/focused and the Play button + /// resumes it. No-op once a backend on-deck episode exists, or when every + /// loaded episode is watched (keep the default S1E1 for a rewatch). + void _ensureFallbackOnDeckEpisode() { + if (_onDeckEpisode != null) return; + final next = firstUnwatchedEpisode(_episodes); + if (next == null) return; + setStateIfMounted(() { + _onDeckEpisode = _applyLocalProgress(next); + }); + } + Future _playFirstEpisode() async { try { // If seasons aren't loaded yet, wait for them or load them diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart new file mode 100644 index 00000000..6a9888cf --- /dev/null +++ b/test/utils/episode_collection_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/media_version.dart'; +import 'package:plezy/utils/download_version_utils.dart'; +import 'package:plezy/media/episode_collection.dart'; + +MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount}) => MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'Season $index', + index: index, + leafCount: leafCount, + viewedLeafCount: viewedLeafCount, +); + +MediaItem _episode( + String id, { + List? versions, + String? parentId, + int? parentIndex, + int? index, + String? grandparentId, + int? viewCount, + int? viewOffsetMs, + int? durationMs, +}) => MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode', + mediaVersions: versions, + parentId: parentId, + parentIndex: parentIndex, + index: index, + grandparentId: grandparentId, + viewCount: viewCount, + viewOffsetMs: viewOffsetMs, + durationMs: durationMs, +); + +class _RecordingClient implements MediaServerClient { + _RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}}); + + final Map> childrenByParent; + final Map> childrenPageByParent; + final Map itemsById; + final childrenCalls = []; + final childrenPageCalls = <({String parentId, int? start, int? size})>[]; + + @override + Future> fetchChildren(String parentId) async { + childrenCalls.add(parentId); + return childrenByParent[parentId] ?? const []; + } + + @override + Future> fetchChildrenPage(String parentId, {int? start, int? size, abort}) async { + childrenPageCalls.add((parentId: parentId, start: start, size: size)); + final all = childrenPageByParent[parentId] ?? const []; + final offset = start ?? 0; + final limit = size ?? all.length; + final end = (offset + limit).clamp(0, all.length).toInt(); + final items = offset >= all.length ? const [] : all.sublist(offset, end); + return LibraryPage(items: items, totalCount: all.length, offset: offset); + } + + @override + Future fetchItem(String id) async => itemsById[id]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('defaultPlaybackSeason skips specials when a regular season exists', () { + final special = _season('specials', index: 0); + final season1 = _season('season-1'); + final episodeRow = _episode('episode-row', parentIndex: 99); + + expect(defaultPlaybackSeason([special, season1]), same(season1)); + expect(defaultPlaybackSeasonIndex([special, season1]), 1); + expect(preferredSeasonIndex([episodeRow, special, season1], initialSeasonIndex: 99), 2); + }); + + test('preferredSeasonIndex honors explicit and on-deck season choices', () { + final special = _season('specials', index: 0); + final season1 = _season('season-1'); + final season2 = _season('season-2', index: 2); + + expect(preferredSeasonIndex([special, season1, season2], initialSeasonIndex: 2), 2); + expect( + preferredSeasonIndex([special, season1, season2], onDeckEpisode: _episode('episode-2', parentId: season2.id)), + 2, + ); + expect(preferredSeasonIndex([special, season1, season2], onDeckEpisode: _episode('episode-1', parentIndex: 1)), 1); + }); + + test('firstUnwatchedSeasonIndex prefers the first regular season with unwatched episodes', () { + final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 0); + final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 5); // fully watched + final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 2); // partially watched + final season3 = _season('season-3', index: 3, leafCount: 5, viewedLeafCount: 0); + + expect(firstUnwatchedSeasonIndex([special, season1, season2, season3]), 2); + }); + + test('firstUnwatchedSeasonIndex falls back to specials only when no regular season qualifies', () { + final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 1); + final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4); + + expect(firstUnwatchedSeasonIndex([special, season1]), 0); + }); + + test('firstUnwatchedSeasonIndex returns null when fully watched or counts are missing', () { + final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4); + final season2 = _season('season-2', index: 2, leafCount: 6, viewedLeafCount: 6); + expect(firstUnwatchedSeasonIndex([season1, season2]), isNull); + + // No leaf counts at all → can't tell, so returns null and callers fall back. + expect(firstUnwatchedSeasonIndex([_season('season-1'), _season('season-2', index: 2)]), isNull); + }); + + test('preferredSeasonIndex falls back to the first unwatched season without an on-deck episode', () { + final special = _season('specials', index: 0, leafCount: 2, viewedLeafCount: 0); + final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 5); + final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 1); + + // No explicit target, no backend on-deck → first season with unwatched episodes. + expect(preferredSeasonIndex([special, season1, season2]), 2); + }); + + test('preferredSeasonIndex keeps the default season for a fully-unwatched show', () { + final special = _season('specials', index: 0, leafCount: 2, viewedLeafCount: 0); + final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 0); + final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 0); + + // Identical to defaultPlaybackSeasonIndex (first regular season). + expect(preferredSeasonIndex([special, season1, season2]), 1); + expect(preferredSeasonIndex([special, season1, season2]), defaultPlaybackSeasonIndex([special, season1, season2])); + }); + + test('firstUnwatchedEpisode skips watched but keeps in-progress episodes', () { + final watched = _episode('e1', index: 1, viewCount: 1); + final inProgress = _episode('e2', index: 2, viewCount: 1, viewOffsetMs: 500, durationMs: 1000); + final unwatched = _episode('e3', index: 3); + + // In-progress is returned even though it is flagged watched. + expect(firstUnwatchedEpisode([watched, inProgress, unwatched]), same(inProgress)); + // Otherwise the first fully-unwatched episode wins. + expect(firstUnwatchedEpisode([watched, unwatched]), same(unwatched)); + // Non-episode rows are ignored. + expect(firstUnwatchedEpisode([_season('season-1'), unwatched]), same(unwatched)); + }); + + test('firstUnwatchedEpisode returns null when every episode is watched', () { + expect( + firstUnwatchedEpisode([_episode('e1', index: 1, viewCount: 1), _episode('e2', index: 2, viewCount: 2)]), + isNull, + ); + }); + + test('fetchFirstEpisodeForSeason requests only the first children page', () async { + final episode = _episode('episode-1'); + final client = _RecordingClient( + childrenPageByParent: { + 'season-1': [episode, _episode('episode-2')], + }, + ); + + final result = await fetchFirstEpisodeForSeason(client, 'season-1'); + + expect(result, same(episode)); + expect(client.childrenCalls, isEmpty); + expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]); + }); + + test('fetchSeasonEpisodePage normalizes show and season identity', () async { + final show = MediaItem( + id: 'show-1', + backend: MediaBackend.plex, + kind: MediaKind.show, + title: 'Show', + serverId: 'server-1', + serverName: 'Server', + libraryId: 'lib-1', + libraryTitle: 'Library', + ); + final season = _season('season-1').copyWith(index: 1, libraryId: show.libraryId, libraryTitle: show.libraryTitle); + final row = _episode('episode-1'); + final client = _RecordingClient( + childrenPageByParent: { + season.id: [row], + }, + ); + + final page = await fetchSeasonEpisodePage(client, show: show, season: season, start: 0, size: 200); + + expect(client.childrenCalls, isEmpty); + expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 200)]); + expect(page.totalCount, 1); + expect(page.items.single.serverId, show.serverId); + expect(page.items.single.serverName, show.serverName); + expect(page.items.single.grandparentId, show.id); + expect(page.items.single.grandparentTitle, show.title); + expect(page.items.single.parentId, season.id); + expect(page.items.single.parentIndex, season.index); + expect(page.items.single.libraryId, show.libraryId); + }); + + test('fetchRepresentativeVersions uses paged lookup for season metadata', () async { + final versions = [const MediaVersion(id: '1080', videoResolution: '1080')]; + final episodeRow = _episode('episode-1'); + final fullEpisode = _episode('episode-1', versions: versions); + final client = _RecordingClient( + childrenPageByParent: { + 'season-1': [episodeRow], + }, + itemsById: {'episode-1': fullEpisode}, + ); + + final result = await fetchRepresentativeVersions(client, _season('season-1')); + + expect(result, same(versions)); + expect(client.childrenCalls, isEmpty); + expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]); + }); + + test('fetchRepresentativeVersions keeps full season lookup but pages selected season episodes', () async { + final versions = [const MediaVersion(id: '1080', videoResolution: '1080')]; + final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show'); + final special = _season('specials', index: 0); + final firstRegularSeason = _season('season-1'); + final episodeRow = _episode('episode-1'); + final fullEpisode = _episode('episode-1', versions: versions); + final client = _RecordingClient( + childrenByParent: { + 'show-1': [special, firstRegularSeason], + }, + childrenPageByParent: { + 'season-1': [episodeRow], + }, + itemsById: {'episode-1': fullEpisode}, + ); + + final result = await fetchRepresentativeVersions(client, show); + + expect(result, same(versions)); + expect(client.childrenCalls, ['show-1']); + expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]); + }); +}