fix(detail): highlight next unwatched ep when not in continue watching

close #1269
This commit is contained in:
edde746
2026-06-07 18:42:41 +02:00
parent 9cebcb97c5
commit 381e82d61a
3 changed files with 313 additions and 4 deletions
+36 -1
View File
@@ -65,8 +65,40 @@ MediaItem? defaultPlaybackSeason(List<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> seasons, {int? initialSeasonIndex, MediaItem? onDeckEpisode}) {
if (seasons.isEmpty) return 0;
if (initialSeasonIndex != null) {
@@ -88,6 +120,9 @@ int preferredSeasonIndex(List<MediaItem> seasons, {int? initialSeasonIndex, Medi
}
}
final unwatched = firstUnwatchedSeasonIndex(seasons);
if (unwatched != null) return unwatched;
return defaultPlaybackSeasonIndex(seasons);
}
+21 -3
View File
@@ -1604,13 +1604,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaDetailScreen>
}
}
/// 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<void> _playFirstEpisode() async {
try {
// If seasons aren't loaded yet, wait for them or load them
+256
View File
@@ -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<MediaVersion>? 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<String, List<MediaItem>> childrenByParent;
final Map<String, List<MediaItem>> childrenPageByParent;
final Map<String, MediaItem> itemsById;
final childrenCalls = <String>[];
final childrenPageCalls = <({String parentId, int? start, int? size})>[];
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
childrenCalls.add(parentId);
return childrenByParent[parentId] ?? const [];
}
@override
Future<LibraryPage<MediaItem>> fetchChildrenPage(String parentId, {int? start, int? size, abort}) async {
childrenPageCalls.add((parentId: parentId, start: start, size: size));
final all = childrenPageByParent[parentId] ?? const <MediaItem>[];
final offset = start ?? 0;
final limit = size ?? all.length;
final end = (offset + limit).clamp(0, all.length).toInt();
final items = offset >= all.length ? const <MediaItem>[] : all.sublist(offset, end);
return LibraryPage(items: items, totalCount: all.length, offset: offset);
}
@override
Future<MediaItem?> 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)]);
});
}