diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index e5b1e210..9d2817f7 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -54,11 +54,18 @@ Future fetchFirstEpisodeForSeason( return null; } +/// A season number of 0 (or missing) denotes the Specials folder, which the +/// app treats as a last resort for "what to watch next" — see +/// [defaultPlaybackSeasonIndex], [firstUnwatchedSeasonIndex] and +/// [compareEpisodesByWatchOrder]. +bool isSpecialSeasonNumber(int? seasonNumber) => (seasonNumber ?? 0) == 0; + /// Prefer the first regular season over specials, falling back to the first /// season row when a show only has specials or lacks season indexes. int defaultPlaybackSeasonIndex(List seasons) { if (seasons.isEmpty) return 0; - final regularSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season && (season.index ?? 0) > 0); + final regularSeasonIndex = + seasons.indexWhere((season) => season.kind == MediaKind.season && !isSpecialSeasonNumber(season.index)); if (regularSeasonIndex != -1) return regularSeasonIndex; final firstSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season); return firstSeasonIndex == -1 ? 0 : firstSeasonIndex; @@ -84,7 +91,7 @@ int? firstUnwatchedSeasonIndex(List seasons) { 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 + if (!isSpecialSeasonNumber(season.index)) return i; // first regular season with unwatched firstSpecial ??= i; // specials only count as a last resort } return firstSpecial; @@ -96,12 +103,38 @@ int? firstUnwatchedSeasonIndex(List seasons) { MediaItem? firstUnwatchedEpisode(List episodes) { for (final episode in episodes) { if (episode.kind != MediaKind.episode) continue; - if (episode.isWatched && !episode.hasActiveProgress) continue; + if (!episode.isUnwatchedOrInProgress) continue; return episode; } return null; } +/// Orders episodes the way the app selects "what to watch next": regular +/// seasons first, Specials (season 0) last, then by season number, then +/// episode number. Mirrors the "specials are a last resort" convention used by +/// [defaultPlaybackSeasonIndex] / [firstUnwatchedSeasonIndex] and the offline +/// continue-watching sort, so a count-capped "next N" selection (download / +/// sync rule) takes the next regular episodes instead of the whole Specials +/// folder first (#1414). +/// +/// The trailing id comparison keeps the order deterministic for episodes that +/// share a season/episode index — Dart's [List.sort] is not stable — so the +/// "next N" cut is stable across runs. +int compareEpisodesByWatchOrder(MediaItem a, MediaItem b) { + final aSpecial = isSpecialSeasonNumber(a.parentIndex); + final bSpecial = isSpecialSeasonNumber(b.parentIndex); + if (aSpecial != bSpecial) return aSpecial ? 1 : -1; + final season = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0); + if (season != 0) return season; + final episode = (a.index ?? 0).compareTo(b.index ?? 0); + if (episode != 0) return episode; + return a.id.compareTo(b.id); +} + +/// In-place sort by [compareEpisodesByWatchOrder]. See that function for the +/// ordering rationale. +void sortEpisodesByWatchOrder(List episodes) => episodes.sort(compareEpisodesByWatchOrder); + /// Find the season index matching an explicit navigation target or on-deck /// episode. With neither, fall back to the first season that still has /// unwatched episodes (so a partially-watched show removed from Continue @@ -194,11 +227,20 @@ Future _collectPlayable( MediaItem? fallback, }) async { final leaves = await client.fetchPlayableDescendants(parentId); + // Collect into a local list and order it before handing back: the backend + // returns episodes in raw container order (Plex /grandchildren puts S00 + // first), and order-capped callers ("next N unwatched" download, sync-rule + // deficit) slice the front — so without this they'd grab Specials ahead of + // regular episodes (#1414). Sort the per-call slice, not the shared `out` + // accumulator, so multi-container callers don't interleave across shows. + final collected = []; for (final ep in leaves) { if (ep.kind != MediaKind.episode) continue; - if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue; - out.add(_withFallbackLibrary(ep, fallback)); + if (unwatchedOnly && !ep.isUnwatchedOrInProgress) continue; + collected.add(_withFallbackLibrary(ep, fallback)); } + sortEpisodesByWatchOrder(collected); + out.addAll(collected); } MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) { diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 9ad76d4b..d49488f3 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -387,6 +387,12 @@ sealed class MediaItem with _$MediaItem { return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!; } + /// Whether this item still counts toward an "unwatched only" selection: + /// not fully watched, or watched-but-resumable (has active progress). The + /// shared predicate behind every `unwatchedOnly` filter (downloads, sync + /// rules, the unwatched-episode lookups in episode_collection.dart). + bool get isUnwatchedOrInProgress => !isWatched || hasActiveProgress; + /// Whether this container (show/season) has some but not all leaves watched. bool get isPartiallyWatched => viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 998d4b41..8f19d8a1 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -921,7 +921,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin int count = 0; Future queueItem(MediaItem item) async { - if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return; + if (unwatchedOnly && !item.isUnwatchedOrInProgress) return; final queued = await _queueSingleDownload(item, client, relatedContext: relatedContext); if (queued) count++; } diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index cc5b4fcc..d0fd9857 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import '../media/ids.dart'; import '../i18n/strings.g.dart'; +import '../media/episode_collection.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; @@ -85,21 +86,13 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM return metadata?.viewOffsetMs; } - /// Get sorted episodes for a show (by season, then episode number). + /// Get sorted episodes for a show: regular seasons first, Specials last, + /// then season then episode — the shared [sortEpisodesByWatchOrder] order, + /// so the offline watch order matches what "download next N" selects (#1414). List _getSortedEpisodes(String showId) { final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId); if (episodes.isEmpty) return episodes; - - // Sort Season 0 (Specials) to the end so regular seasons play first - episodes.sort((a, b) { - final aIsSpecial = (a.parentIndex ?? 0) == 0; - final bIsSpecial = (b.parentIndex ?? 0) == 0; - if (aIsSpecial != bIsSpecial) return aIsSpecial ? 1 : -1; - final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0); - if (seasonCompare != 0) return seasonCompare; - return (a.index ?? 0).compareTo(b.index ?? 0); - }); - + sortEpisodesByWatchOrder(episodes); return episodes; } diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index c82535cf..452565b4 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -119,20 +119,11 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { if (episodes.isEmpty) return; - // Sort by aired date, falling back to season/episode number - final sorted = List.from(episodes) - ..sort((a, b) { - final aDate = a.originallyAvailableAt ?? ''; - final bDate = b.originallyAvailableAt ?? ''; - if (aDate.isEmpty && bDate.isEmpty) { - final seasonCmp = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0); - if (seasonCmp != 0) return seasonCmp; - return (a.index ?? 0).compareTo(b.index ?? 0); - } - if (aDate.isEmpty) return 1; - if (bDate.isEmpty) return -1; - return aDate.compareTo(bDate); - }); + // Regular seasons first, Specials last, then season/episode — the shared + // watch order, so offline next/prev matches what "download next N" selects + // and the offline continue-watching list use (#1414). Copy first so the + // provider's cached list isn't reordered. + final sorted = List.from(episodes)..sort(compareEpisodesByWatchOrder); final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index bcc15269..3ee82967 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -22,6 +22,7 @@ import '../media/media_server_user_profile.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; +import '../media/episode_collection.dart'; import '../media/live_tv_support.dart'; import '../models/livetv_channel.dart'; import '../services/live_seek_accumulator.dart'; diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index db0d1c9a..10296102 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import '../connection/connection.dart'; +import '../media/episode_collection.dart'; import '../media/library_filter_result.dart'; import '../media/library_first_character.dart'; import '../media/library_query.dart'; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index e401de68..09f075af 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -915,7 +915,13 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); } - /// All episodes of a series in air order, optimised for queue-building. + /// All episodes of a series in the app's watch order — regular seasons + /// first, Specials (season 0) last — so the client-side next/previous queue + /// matches what downloads and offline playback use (#1414). The server sort + /// ([_episodeOrderQueryParameters]) keeps paging stable (and lists Specials + /// first); [sortEpisodesByWatchOrder] then normalizes the assembled list to + /// the shared convention, leaving a single definition of "episode order". + /// /// Uses [_queueFields] (only `UserData`) instead of the browse field /// set so the response stays small even for shows with thousands of /// episodes. @@ -954,6 +960,9 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { startIndex += page.length; } + // Server lists Specials first (ParentIndexNumber asc); reorder to the + // shared watch order so online next/prev matches offline + downloads. + sortEpisodesByWatchOrder(all); return all; } diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 4c101cad..4d3927ac 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -428,7 +428,7 @@ class SyncRuleExecutor { switch (item.kind) { case MediaKind.movie: case MediaKind.episode: - if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break; + if (unwatchedOnly && !item.isUnwatchedOrInProgress) break; out.add(item); case MediaKind.show: await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); diff --git a/pubspec.yaml b/pubspec.yaml index b68629ce..b691fdac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex and Jellyfin client for Flutter" publish_to: "none" -version: 2.7.1+116 +version: 2.7.2+118 environment: sdk: ">=3.12.0 <4.0.0" diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart index 9fd9b709..5e1216a2 100644 --- a/test/utils/episode_collection_test.dart +++ b/test/utils/episode_collection_test.dart @@ -191,6 +191,49 @@ void main() { ); }); + test('sortEpisodesByWatchOrder puts regular seasons first and Specials last', () { + final s0e1 = _episode('s0e1', parentIndex: 0, index: 1); + final s0e2 = _episode('s0e2', parentIndex: 0, index: 2); + final s1e1 = _episode('s1e1', parentIndex: 1, index: 1); + final s1e2 = _episode('s1e2', parentIndex: 1, index: 2); + final s2e1 = _episode('s2e1', parentIndex: 2, index: 1); + + // Raw /grandchildren order would lead with the Specials folder. + final episodes = [s0e1, s0e2, s1e1, s1e2, s2e1]; + sortEpisodesByWatchOrder(episodes); + + // A "next 2 unwatched" cut now takes S01E01/S01E02, not the Specials. + expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's2e1', 's0e1', 's0e2']); + }); + + test('compareEpisodesByWatchOrder breaks index ties on id for deterministic cuts', () { + final a = _episode('a', parentIndex: 1, index: 1); + final b = _episode('b', parentIndex: 1, index: 1); + + expect(compareEpisodesByWatchOrder(a, b), lessThan(0)); + expect(compareEpisodesByWatchOrder(b, a), greaterThan(0)); + expect(compareEpisodesByWatchOrder(a, a), 0); + }); + + test('isSpecialSeasonNumber treats season 0 and missing numbers as Specials', () { + expect(isSpecialSeasonNumber(0), isTrue); + expect(isSpecialSeasonNumber(null), isTrue); + expect(isSpecialSeasonNumber(1), isFalse); + expect(isSpecialSeasonNumber(2), isFalse); + }); + + test('isUnwatchedOrInProgress keeps unwatched and resumable episodes', () { + // Unwatched. + expect(_episode('a', viewCount: 0).isUnwatchedOrInProgress, isTrue); + // Watched with no resume point — counts as done. + expect(_episode('b', viewCount: 1).isUnwatchedOrInProgress, isFalse); + // Watched but still resumable (re-watching) — counts as to-watch. + expect( + _episode('c', viewCount: 1, viewOffsetMs: 500, durationMs: 1000).isUnwatchedOrInProgress, + isTrue, + ); + }); + test('fetchFirstEpisodeForSeason requests only the first children page', () async { final episode = _episode('episode-1'); final client = _RecordingClient(