fix(playback): remember last played media version for continue watching

close #1492
This commit is contained in:
edde746
2026-07-05 14:29:57 +02:00
parent 90c4fe5458
commit ee3c75ba9e
24 changed files with 1033 additions and 30 deletions
+13
View File
@@ -377,6 +377,19 @@ sealed class MediaItem with _$MediaItem {
String? get libraryGlobalKey =>
serverId != null && libraryId != null ? buildGlobalKey(ServerId(serverId!), libraryId!) : null;
/// Global unique identifier of this item's series, for episodes/seasons.
/// Null for movies and shows themselves — their own [globalKey] is already
/// series-level.
String? get seriesGlobalKey {
final seriesId = switch (kind) {
MediaKind.episode => grandparentId,
MediaKind.season => grandparentId ?? parentId,
_ => null,
};
if (seriesId == null) return null;
return serverId != null ? buildGlobalKey(ServerId(serverId!), seriesId) : seriesId;
}
/// Parent rating keys for hierarchical invalidation. For an episode:
/// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`.
List<String> get parentChain => [?parentId, ?grandparentId];
+73
View File
@@ -0,0 +1,73 @@
import 'media_version.dart';
/// A remembered media-version choice for a series or standalone item, stored
/// in `SettingsService.mediaVersionPreferences` (#1492).
///
/// Persisted as `{"id":…,"sig":…,"idx":…,"at":…}`. Values written before the
/// record form existed were bare positional ints; [MediaVersionPreference.fromJson]
/// still accepts those so old preferences keep working.
class MediaVersionPreference {
/// Backend-opaque [MediaVersion.id] of the chosen version. Exact match only
/// holds for the item the pick was made on (ids differ per episode).
final String? versionId;
/// [MediaVersion.signature] ("res:codec:container") of the chosen version,
/// for matching the equivalent version on sibling episodes.
final String? signature;
/// Positional index into the Media list at pick time. Last-resort fallback
/// and the only field legacy int values carry.
final int index;
/// Epoch ms of the last write; used to evict the oldest entries when the
/// preference map is pruned. Null on legacy entries (evicted first).
final int? updatedAt;
const MediaVersionPreference({this.versionId, this.signature, required this.index, this.updatedAt});
/// Capture [version] (at [index] in its Media list) as a preference.
factory MediaVersionPreference.forVersion(MediaVersion version, int index) => MediaVersionPreference(
versionId: version.id.isEmpty ? null : version.id,
signature: version.signature,
index: index,
updatedAt: DateTime.now().millisecondsSinceEpoch,
);
factory MediaVersionPreference.fromJson(Object? raw) {
if (raw is int) return MediaVersionPreference(index: raw);
if (raw is Map) {
return MediaVersionPreference(
versionId: raw['id'] as String?,
signature: raw['sig'] as String?,
index: raw['idx'] is int ? raw['idx'] as int : 0,
updatedAt: raw['at'] as int?,
);
}
return const MediaVersionPreference(index: 0);
}
Map<String, dynamic> toJson() => {
if (versionId != null) 'id': versionId,
if (signature != null) 'sig': signature,
'idx': index,
if (updatedAt != null) 'at': updatedAt,
};
/// Resolve this preference against an actual version list: exact id match,
/// then signature match (3-tier, see [MediaVersion.findMatchingIndex]), then
/// the stored index when still in range. Null when nothing applies.
int? resolveIndex(List<MediaVersion> versions) {
if (versions.isEmpty) return null;
final id = versionId;
if (id != null && id.isNotEmpty) {
final byId = versions.indexWhere((v) => v.id == id);
if (byId >= 0) return byId;
}
final sig = signature;
if (sig != null && sig.isNotEmpty) {
final bySignature = MediaVersion.findMatchingIndex(versions, {sig});
if (bySignature != null) return bySignature;
}
return index >= 0 && index < versions.length ? index : null;
}
}
@@ -108,10 +108,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
return;
}
// Carry the playing version to the next episode by signature — its Media
// list may order versions differently, so the bare index is a guess and
// the source id is per-episode.
final currentVersionSignature =
_effectiveSelectedMediaIndex >= 0 && _effectiveSelectedMediaIndex < _availableVersions.length
? _availableVersions[_effectiveSelectedMediaIndex].signature
: null;
await _reloadMediaInPlace(
metadata: episodeMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: null,
preferredVersionSignature: currentVersionSignature,
qualityPreset: _selectedQualityPreset,
// Stream ids are per-part: the previous episode's audio id is
// meaningless on the new item, so let preferences pick the track.
@@ -160,7 +168,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
try {
if (isVersionChange) {
await saveMediaVersionIndexFor(_currentMetadata, effectiveMediaIndex);
await saveMediaVersionPreferenceFor(_currentMetadata, index: effectiveMediaIndex, versions: _availableVersions);
}
if (isSubtitleChange || (isAudioChange && isPlexBacked)) {
@@ -206,6 +214,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
required MediaItem metadata,
int? selectedMediaIndex,
String? selectedMediaSourceId,
String? preferredVersionSignature,
TranscodeQualityPreset? qualityPreset,
int? selectedAudioStreamId,
Duration? resumePosition,
@@ -306,6 +315,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
metadata: metadata,
selectedMediaIndex: targetMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
offlineLibraryMode: _offlineLibraryMode,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
@@ -172,10 +172,14 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
return true;
}
// fetchItem populates mediaVersions, so the saved preference resolves to
// a verified index/id here rather than a raw stored index.
final savedVersion = await resolveSavedMediaVersionFor(metadata);
final handled = await _reloadMediaInPlace(
metadata: metadata,
selectedMediaIndex: await savedMediaVersionIndexFor(metadata) ?? 0,
selectedMediaSourceId: null,
selectedMediaIndex: savedVersion?.index ?? 0,
selectedMediaSourceId: savedVersion?.sourceId,
preferredVersionSignature: savedVersion?.signature,
qualityPreset: _selectedQualityPreset,
preserveCurrentTrackSelection: false,
useCurrentAudioStreamSelection: false,
+15
View File
@@ -49,6 +49,7 @@ import '../services/apple_tv_remote_touch_service.dart';
import '../services/media_controls_manager.dart';
import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart';
import '../services/local_playback_history.dart';
import '../services/playback_session.dart';
import '../services/playback_progress_tracker.dart';
import '../services/playback_source_resolver.dart';
@@ -196,6 +197,12 @@ class VideoPlayerScreen extends StatefulWidget {
final SubtitleTrack? preferredSecondarySubtitleTrack;
final int selectedMediaIndex;
final String? selectedMediaSourceId;
/// Version signature of a saved preference backing [selectedMediaIndex]
/// when that index is unverified (see
/// [PlaybackInitializationOptions.preferredVersionSignature]). Null for
/// explicit user selections.
final String? preferredVersionSignature;
final bool isOffline;
/// Quality preset override for this playback. When `null`, the screen uses
@@ -221,6 +228,7 @@ class VideoPlayerScreen extends StatefulWidget {
this.preferredSecondarySubtitleTrack,
this.selectedMediaIndex = 0,
this.selectedMediaSourceId,
this.preferredVersionSignature,
this.isOffline = false,
this.selectedQualityPreset,
this.selectedAudioStreamId,
@@ -455,6 +463,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_requestedMediaSourceId = session.mediaSourceId;
_selectedQualityPreset = session.qualityPreset;
_selectedAudioStreamId = session.audioStreamId;
// Every successful open passes through here (never live TV), making it
// the chokepoint for the local last-played history. Offline plays are
// excluded — like version prefs, the history describes online intent.
if (!session.isOffline) {
unawaited(LocalPlaybackHistory.recordPlayback(session.metadata));
}
}
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
@@ -697,6 +711,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
metadata: _currentMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _requestedMediaSourceId,
preferredVersionSignature: widget.preferredVersionSignature,
offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
+56 -3
View File
@@ -11,6 +11,7 @@ import '../utils/app_logger.dart';
import '../utils/external_ids.dart';
import '../utils/global_key_utils.dart';
import '../utils/search_relevance.dart';
import 'local_playback_history.dart';
import 'multi_server_manager.dart';
typedef OnDeckAggregationResult = ({
@@ -193,7 +194,17 @@ class DataAggregationService {
}
await Future.wait(identityKeyLoads);
final seenKeys = <String>{};
// Group duplicates instead of greedily dropping them: the first item to
// claim an identity key anchors the group and holds its shelf slot;
// later items sharing a claimed key join as members without claiming
// their own keys (same transitive semantics as the old drop). Each slot
// then shows the member the user most recently played on this device —
// servers sync watch state across guid-linked siblings, so their
// lastViewedAt ties and can't tell the 4K copy from the 1080p one
// (#1492). Without local history the anchor (recency order) stands.
final keyToGroup = <String, int>{};
final groups = <List<MediaItem>>[];
final groupSlots = <int, int>{};
final result = <MediaItem>[];
for (var i = 0; i < items.length; i++) {
final item = items[i];
@@ -208,15 +219,57 @@ class DataAggregationService {
continue;
}
if (identityKeys.any(seenKeys.contains)) continue;
var joined = false;
for (final key in identityKeys) {
final groupIndex = keyToGroup[key];
if (groupIndex != null) {
groups[groupIndex].add(item);
joined = true;
break;
}
}
if (joined) continue;
seenKeys.addAll(identityKeys);
final groupIndex = groups.length;
groups.add([item]);
for (final key in identityKeys) {
keyToGroup[key] = groupIndex;
}
groupSlots[result.length] = groupIndex;
result.add(item);
}
if (groupSlots.isEmpty) return result;
final lastPlayed = await LocalPlaybackHistory.snapshot();
for (final slot in groupSlots.entries) {
final members = groups[slot.value];
if (members.length > 1) {
result[slot.key] = _preferLocallyLastPlayed(members, lastPlayed);
}
}
return result;
}
/// The duplicate-group member most recently played on this device (by item
/// or series key), or the anchor — `members.first`, the group's most recent
/// item by [MediaItem.recencySortKey] — when the local history has nothing
/// newer to say.
MediaItem _preferLocallyLastPlayed(List<MediaItem> members, Map<String, int> lastPlayed) {
var winner = members.first;
var winnerLastPlayedAt = 0;
for (final member in members) {
final itemTs = lastPlayed[member.globalKey] ?? 0;
final seriesKey = member.seriesGlobalKey;
final seriesTs = seriesKey != null ? (lastPlayed[seriesKey] ?? 0) : 0;
final lastPlayedAt = itemTs > seriesTs ? itemTs : seriesTs;
if (lastPlayedAt > winnerLastPlayedAt) {
winner = member;
winnerLastPlayedAt = lastPlayedAt;
}
}
return winner;
}
String? _continueWatchingTitleBucket(MediaItem item) {
final scope = _continueWatchingIdentityScope(item);
if (scope == null) return null;
+1
View File
@@ -40,6 +40,7 @@ import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../media/media_version.dart';
import '../utils/app_logger.dart';
import '../utils/device_identity.dart';
import '../utils/failover_http_client.dart';
@@ -2,7 +2,12 @@ part of '../../jellyfin_client.dart';
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId});
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
String itemId, {
int sourceIndex = 0,
String? sourceId,
String? preferredSignature,
});
String buildDirectStreamUrl(
String itemId, {
String? container,
@@ -139,6 +139,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
metadata.id,
sourceIndex: options.selectedMediaIndex,
sourceId: options.selectedMediaSourceId,
preferredSignature: options.preferredVersionSignature,
);
if (bundle == null) {
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
@@ -362,7 +363,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
/// [sourceId] wins when present because Jellyfin plugins may reorder merged
/// `MediaSources` between requests. [sourceIndex] is clamped to the valid
/// range as a fallback to mirror Plex's `parseVideoPlaybackDataFromJson`.
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId}) async {
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
String itemId, {
int sourceIndex = 0,
String? sourceId,
String? preferredSignature,
}) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
@@ -371,9 +377,20 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
final availableVersions = jellyfinSourcesToVersions(sources);
var index = sourceIndex;
final requestedSourceId = sourceId?.trim();
var resolvedBySourceId = false;
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
final byId = sources.indexWhere((source) => source is Map<String, dynamic> && source['Id'] == requestedSourceId);
if (byId >= 0) index = byId;
if (byId >= 0) {
index = byId;
resolvedBySourceId = true;
}
}
// Saved-preference signature: only meaningful when the id didn't pin a
// source (Resume rows omit MediaSources, so launch passes a signature and
// a stored index that may not fit this item's source ordering).
if (!resolvedBySourceId && preferredSignature != null && preferredSignature.isNotEmpty) {
final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredSignature});
if (bySignature != null) index = bySignature;
}
if (index < 0 || index >= sources.length) index = 0;
final source = sources[index];
+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter/foundation.dart';
import '../media/media_item.dart';
import '../utils/app_logger.dart';
import 'settings_service.dart';
/// Device-local record of when items were last played, keyed by item
/// [MediaItem.globalKey] and, for episodes/seasons, also by
/// [MediaItem.seriesGlobalKey] (#1492).
///
/// Servers have no per-version/per-sibling "last played" signal (a Plex
/// timeline report carries no media id, and guid-linked duplicates share
/// synced watch state), so this local history is what lets the Continue
/// Watching dedup keep the sibling the user actually played.
class LocalPlaybackHistory {
LocalPlaybackHistory._();
static const _maxEntries = 400;
/// Repeat writes for the same item within this window are skipped —
/// in-place reloads (seek transcode restarts, track switches) re-commit
/// the same session and don't need to re-serialize the map each time.
static const _rewriteIntervalMs = 60 * 1000;
static String? _lastRecordedKey;
static int _lastRecordedAtMs = 0;
/// Test-only: clear the same-item rewrite suppression.
@visibleForTesting
static void resetForTesting() {
_lastRecordedKey = null;
_lastRecordedAtMs = 0;
}
/// Record that [item] just started playing. Best-effort: playback must
/// never fail on a preferences error.
static Future<void> recordPlayback(MediaItem item) async {
final now = DateTime.now().millisecondsSinceEpoch;
final key = item.globalKey;
if (key == _lastRecordedKey && now - _lastRecordedAtMs < _rewriteIntervalMs) return;
try {
final settings = await SettingsService.getInstance();
final updated = {...settings.read(SettingsService.localLastPlayedAt), key: now};
final seriesKey = item.seriesGlobalKey;
if (seriesKey != null) updated[seriesKey] = now;
await settings.write(SettingsService.localLastPlayedAt, _prune(updated));
_lastRecordedKey = key;
_lastRecordedAtMs = now;
} catch (e) {
appLogger.w('Failed to record local playback history for $key', error: e);
}
}
/// One read of the full history for a dedup pass. Empty on any error.
static Future<Map<String, int>> snapshot() async {
try {
final settings = await SettingsService.getInstance();
return settings.read(SettingsService.localLastPlayedAt);
} catch (_) {
return const {};
}
}
static Map<String, int> _prune(Map<String, int> history) {
if (history.length <= _maxEntries) return history;
final entries = history.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
return Map.fromEntries(entries.take(_maxEntries));
}
}
@@ -153,6 +153,7 @@ class PlaybackInitializationService {
required MediaItem metadata,
required int selectedMediaIndex,
String? selectedMediaSourceId,
String? preferredVersionSignature,
bool preferOffline = false,
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
int? selectedAudioStreamId,
@@ -197,6 +198,7 @@ class PlaybackInitializationService {
metadata: metadata,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
qualityPreset: qualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier,
@@ -17,6 +17,13 @@ class PlaybackInitializationOptions {
/// versions can reorder between item fetches, so this wins over index there.
final String? selectedMediaSourceId;
/// Version signature ("res:codec:container") of a saved preference whose
/// [selectedMediaIndex] is a guess (stored index, or resolved on another
/// episode's version list). Backends re-match it against the authoritative
/// list. Never set alongside an explicit user selection — the priority is
/// sourceId > signature > index > backend fallback.
final String? preferredVersionSignature;
/// Transcode preset. `original` means direct-play; anything else asks the
/// server to transcode when supported.
final TranscodeQualityPreset qualityPreset;
@@ -36,6 +43,7 @@ class PlaybackInitializationOptions {
required this.metadata,
required this.selectedMediaIndex,
this.selectedMediaSourceId,
this.preferredVersionSignature,
this.qualityPreset = TranscodeQualityPreset.original,
this.selectedAudioStreamId,
this.sessionIdentifier,
@@ -21,6 +21,7 @@ class PlaybackSourceResolver {
required MediaItem metadata,
required int selectedMediaIndex,
String? selectedMediaSourceId,
String? preferredVersionSignature,
required bool offlineLibraryMode,
required TranscodeQualityPreset qualityPreset,
int? selectedAudioStreamId,
@@ -34,6 +35,7 @@ class PlaybackSourceResolver {
metadata: metadata,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal),
qualityPreset: qualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
+26 -4
View File
@@ -1512,12 +1512,19 @@ class PlexClient
/// Parse video playback data from raw metadata JSON (no network call).
/// Used by [getVideoPlaybackData] to avoid redundant fetches when the
/// response is already available.
PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map<String, dynamic>? metadataJson, {int mediaIndex = 0}) {
PlexVideoPlaybackData parseVideoPlaybackDataFromJson(
Map<String, dynamic>? metadataJson, {
int mediaIndex = 0,
String? selectedMediaSourceId,
String? preferredVersionSignature,
}) {
return parsePlexVideoPlaybackDataFromJson(
metadataJson,
baseUrl: config.baseUrl,
token: config.token,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
onVersionFallback: (requested, fallback) {
appLogger.w('Version $requested inaccessible/missing — falling back to version $fallback');
},
@@ -1527,7 +1534,12 @@ class PlexClient
/// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call.
/// This is the primary method for playback initialization.
/// Uses cache for offline mode support and network fallback.
Future<PlexVideoPlaybackData> getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async {
Future<PlexVideoPlaybackData> getVideoPlaybackData(
String ratingKey, {
int mediaIndex = 0,
String? selectedMediaSourceId,
String? preferredVersionSignature,
}) async {
Map<String, dynamic>? data;
try {
data = await fetchWithCacheFallback<Map<String, dynamic>>(
@@ -1545,7 +1557,12 @@ class PlexClient
// Gracefully degrade: return empty playback data on total failure
}
final metadataJson = _getFirstMetadataJsonFromData(data);
return parseVideoPlaybackDataFromJson(metadataJson, mediaIndex: mediaIndex);
return parseVideoPlaybackDataFromJson(
metadataJson,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
);
}
/// Get file information for a media item.
@@ -3386,7 +3403,12 @@ class PlexClient
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
try {
final data = await getVideoPlaybackData(options.metadata.id, mediaIndex: options.selectedMediaIndex);
final data = await getVideoPlaybackData(
options.metadata.id,
mediaIndex: options.selectedMediaIndex,
selectedMediaSourceId: options.selectedMediaSourceId,
preferredVersionSignature: options.preferredVersionSignature,
);
if (!data.hasValidVideoUrl) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
+20
View File
@@ -61,6 +61,8 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
required String baseUrl,
required String? token,
int mediaIndex = 0,
String? selectedMediaSourceId,
String? preferredVersionSignature,
void Function(int requestedIndex, int fallbackIndex)? onVersionFallback,
}) {
String? videoUrl;
@@ -77,6 +79,24 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
.map((media) => PlexMappers.mediaVersionFromJson(Map<String, dynamic>.from(media)))
.toList();
// Re-resolve version evidence against this (authoritative) Media list:
// stable id first, then signature. The positional index is the last
// resort — and all an explicit user pick carries besides its id, so a
// saved-preference signature can never override one.
final requestedSourceId = selectedMediaSourceId?.trim();
var resolvedBySourceId = false;
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
final byId = availableVersions.indexWhere((v) => v.id == requestedSourceId);
if (byId >= 0) {
mediaIndex = byId;
resolvedBySourceId = true;
}
}
if (!resolvedBySourceId && preferredVersionSignature != null && preferredVersionSignature.isNotEmpty) {
final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredVersionSignature});
if (bySignature != null) mediaIndex = bySignature;
}
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
mediaIndex = 0;
}
+14 -1
View File
@@ -1,5 +1,6 @@
import 'dart:convert';
import '../media/ids.dart';
import '../media/media_version_preference.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
@@ -514,9 +515,20 @@ class SettingsService extends BaseSharedPreferencesService {
encode: (v) => json.encode(v.map((k, hk) => MapEntry(k, SettingsService.serializeHotKey(hk)))),
decode: _decodeKeyboardHotkeys,
);
static final mediaVersionPreferences = JsonPref<Map<String, int>>(
static final mediaVersionPreferences = JsonPref<Map<String, MediaVersionPreference>>(
'media_version_preferences',
defaultValue: const {},
encode: (v) => json.encode(v.map((k, pref) => MapEntry(k, pref.toJson()))),
// Legacy values were bare ints; MediaVersionPreference.fromJson accepts both.
decode: (raw) => (raw as Map<String, dynamic>).map((k, v) => MapEntry(k, MediaVersionPreference.fromJson(v))),
);
/// Local record of when items were last played on this device
/// (item/show globalKey → epoch ms). Written by LocalPlaybackHistory; used
/// to pick the last-played sibling in the Continue Watching dedup (#1492).
static final localLastPlayedAt = JsonPref<Map<String, int>>(
'local_last_played_at',
defaultValue: const {},
encode: json.encode,
decode: (raw) => (raw as Map<String, dynamic>).map((k, v) => MapEntry(k, v as int)),
);
@@ -857,6 +869,7 @@ class SettingsService extends BaseSharedPreferencesService {
libraryDensity,
episodePosterMode,
mediaVersionPreferences,
localLastPlayedAt,
appLocale,
customDownloadPath,
videoPlayerNavigationEnabled,
+90 -16
View File
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../media/media_item.dart';
import '../media/media_version.dart';
import '../media/media_version_preference.dart';
import '../mpv/mpv.dart';
import '../models/transcode_quality_preset.dart';
import '../providers/download_provider.dart';
@@ -14,9 +16,11 @@ import '../providers/watch_state_store.dart';
import '../watch_together/providers/watch_together_provider.dart';
import '../screens/video_player_screen.dart';
import '../services/external_player_service.dart';
import '../services/local_playback_history.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/settings_service.dart';
import 'app_logger.dart';
import 'global_key_utils.dart';
import 'platform_detector.dart';
const String kVideoPlayerRouteName = '/video_player';
@@ -89,28 +93,82 @@ class WatchTogetherPlaybackNavigationException implements Exception {
}
/// Series (keyed by grandparent) or standalone-item key under
/// [SettingsService.mediaVersionPreferences].
String _mediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id;
/// [SettingsService.mediaVersionPreferences], scoped by server — raw Plex
/// rating keys are small integers that can collide across servers.
String _mediaVersionPreferenceKey(MediaItem metadata) {
final serverId = serverIdOrNull(metadata.serverId);
final id = metadata.grandparentId ?? metadata.id;
return serverId != null ? buildGlobalKey(serverId, id) : id;
}
/// Saved media-version preference for [metadata], or null when none is
/// stored. Shared by launch navigation and in-player version switching so
/// reads and writes can't drift onto different keys.
Future<int?> savedMediaVersionIndexFor(MediaItem metadata) async {
/// Key entries were stored under before server scoping. Reads fall back to
/// it; writes migrate it to the scoped key.
String _legacyMediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id;
/// Entry cap for [SettingsService.mediaVersionPreferences]; oldest entries
/// (by write time, legacy entries first) are evicted past it.
const _maxMediaVersionPreferences = 500;
/// Saved media-version preference for [metadata]'s series/movie, or null when
/// none is stored. Shared by launch navigation and in-player version
/// switching so reads and writes can't drift onto different keys.
Future<MediaVersionPreference?> savedMediaVersionPreferenceFor(MediaItem metadata) async {
try {
final settingsService = await SettingsService.getInstance();
return settingsService.read(SettingsService.mediaVersionPreferences)[_mediaVersionPreferenceKey(metadata)];
final prefs = settingsService.read(SettingsService.mediaVersionPreferences);
return prefs[_mediaVersionPreferenceKey(metadata)] ?? prefs[_legacyMediaVersionPreferenceKey(metadata)];
} catch (_) {
return null;
}
}
/// Persist [index] as the preferred media version for [metadata]'s series/movie.
Future<void> saveMediaVersionIndexFor(MediaItem metadata, int index) async {
/// Persist the version at [index] in [versions] as the preferred media
/// version for [metadata]'s series/movie. Callers are explicit-selection
/// sites only — plain plays and backend fallbacks must not write, so a
/// server-side clamp can't silently overwrite the user's choice.
Future<void> saveMediaVersionPreferenceFor(
MediaItem metadata, {
required int index,
required List<MediaVersion> versions,
}) async {
final settingsService = await SettingsService.getInstance();
await settingsService.write(SettingsService.mediaVersionPreferences, {
...settingsService.read(SettingsService.mediaVersionPreferences),
_mediaVersionPreferenceKey(metadata): index,
});
final pref = index >= 0 && index < versions.length
? MediaVersionPreference.forVersion(versions[index], index)
: MediaVersionPreference(index: index, updatedAt: DateTime.now().millisecondsSinceEpoch);
final updated = {...settingsService.read(SettingsService.mediaVersionPreferences)}
..remove(_legacyMediaVersionPreferenceKey(metadata))
..[_mediaVersionPreferenceKey(metadata)] = pref;
await settingsService.write(SettingsService.mediaVersionPreferences, _pruneMediaVersionPreferences(updated));
}
Map<String, MediaVersionPreference> _pruneMediaVersionPreferences(Map<String, MediaVersionPreference> prefs) {
if (prefs.length <= _maxMediaVersionPreferences) return prefs;
final entries = prefs.entries.toList()..sort((a, b) => (b.value.updatedAt ?? 0).compareTo(a.value.updatedAt ?? 0));
return Map.fromEntries(entries.take(_maxMediaVersionPreferences));
}
/// A saved preference resolved for launch: the index to request plus the
/// id/signature evidence for re-resolving it against the authoritative
/// version list during playback initialization.
typedef ResolvedMediaVersionPreference = ({int index, String? sourceId, String? signature});
/// Resolve the saved preference for [metadata] against its version list.
///
/// When [MediaItem.mediaVersions] is populated (Plex hub/detail fetches) the
/// index is verified and the matched version's real id is returned. When it
/// isn't (Jellyfin resume rows omit `MediaSources`), the stored index and
/// signature pass through with a null sourceId — an unverified id from a
/// sibling episode would be meaningless downstream, while a signature is
/// safely re-matched there.
Future<ResolvedMediaVersionPreference?> resolveSavedMediaVersionFor(MediaItem metadata) async {
final pref = await savedMediaVersionPreferenceFor(metadata);
if (pref == null) return null;
final versions = metadata.mediaVersions ?? const <MediaVersion>[];
if (versions.isEmpty) return (index: pref.index, sourceId: null, signature: pref.signature);
final index = pref.resolveIndex(versions);
if (index == null) return null;
final version = versions[index];
return (index: index, sourceId: version.id.isEmpty ? null : version.id, signature: version.signature);
}
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
@@ -177,8 +235,18 @@ Future<bool?> navigateToVideoPlayer(
}
}
final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? await savedMediaVersionIndexFor(metadata) ?? 0;
final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId;
// Saved preferences only apply when nothing explicit is in play — an
// explicit caller selection or a downloaded version must never be
// second-guessed by a remembered choice.
ResolvedMediaVersionPreference? savedVersion;
if (selectedMediaIndex == null &&
selectedMediaSourceId == null &&
downloadedMediaIndex == null &&
downloadedMediaSourceId == null) {
savedVersion = await resolveSavedMediaVersionFor(metadata);
}
final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? savedVersion?.index ?? 0;
final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId ?? savedVersion?.sourceId;
var markedInFlight = false;
if (!usePushReplacement) {
@@ -235,7 +303,12 @@ Future<bool?> navigateToVideoPlayer(
);
}
if (launched) return null;
if (launched) {
// External playback never reaches the in-player session commit, so
// record the local last-played history here.
if (!isOffline) unawaited(LocalPlaybackHistory.recordPlayback(metadata));
return null;
}
}
} catch (e) {
appLogger.w('External player launch failed, falling back to built-in player', error: e);
@@ -260,6 +333,7 @@ Future<bool?> navigateToVideoPlayer(
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: mediaIndex,
selectedMediaSourceId: mediaSourceId,
preferredVersionSignature: savedVersion?.signature,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
),
+7
View File
@@ -902,6 +902,13 @@ class MediaContextMenuState extends State<MediaContextMenu> {
selectedQuality = picked;
}
// Remember the pick so Continue Watching / plain Play resume this version
// (#1492) — same store the in-player version switch writes.
if (versions.length > 1) {
await saveMediaVersionPreferenceFor(item, index: selectedVersionIndex, versions: versions);
if (!context.mounted) return false;
}
await navigateToVideoPlayer(
context,
metadata: item,
@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/media/media_version_preference.dart';
void main() {
const versions = [
MediaVersion(id: '101', videoResolution: '1080', videoCodec: 'h264', container: 'mkv'),
MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'),
];
group('MediaVersionPreference.fromJson', () {
test('decodes legacy bare int as index-only record', () {
final pref = MediaVersionPreference.fromJson(1);
expect(pref.index, 1);
expect(pref.versionId, isNull);
expect(pref.signature, isNull);
expect(pref.updatedAt, isNull);
});
test('round-trips the record form', () {
final pref = MediaVersionPreference.forVersion(versions[1], 1);
final decoded = MediaVersionPreference.fromJson(pref.toJson());
expect(decoded.versionId, '102');
expect(decoded.signature, '4k:hevc:mkv');
expect(decoded.index, 1);
expect(decoded.updatedAt, pref.updatedAt);
});
});
group('MediaVersionPreference.resolveIndex', () {
test('exact version id wins over stored index', () {
const pref = MediaVersionPreference(versionId: '102', signature: '4k:hevc:mkv', index: 0);
expect(pref.resolveIndex(versions), 1);
});
test('signature matches when the id is from a sibling episode', () {
const pref = MediaVersionPreference(versionId: '999', signature: '4k:hevc:mkv', index: 0);
expect(pref.resolveIndex(versions), 1);
});
test('signature matches by resolution when codec/container differ', () {
const pref = MediaVersionPreference(versionId: '999', signature: '4k:av1:mp4', index: 0);
expect(pref.resolveIndex(versions), 1);
});
test('falls back to stored index when id and signature miss', () {
const pref = MediaVersionPreference(versionId: '999', signature: '720:vp9:webm', index: 1);
expect(pref.resolveIndex(versions), 1);
});
test('returns null for out-of-range index with no match', () {
const pref = MediaVersionPreference(index: 5);
expect(pref.resolveIndex(versions), isNull);
expect(pref.resolveIndex(const []), isNull);
});
});
}
@@ -18,6 +18,9 @@ import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/prefs.dart';
JellyfinConnection _conn() => JellyfinConnection(
id: 'srv-1/user-1',
@@ -72,6 +75,8 @@ void main() {
late DataAggregationService service;
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
manager = MultiServerManager();
@@ -366,6 +371,170 @@ void main() {
expect(result.succeededServerIds, {'plex-1'});
});
test('getOnDeckFromAllServers prefers the locally last-played duplicate sibling', () async {
// Two libraries carry the same show (1080p + 4K, matched by tvdb id);
// the server syncs watch state so lastViewedAt favours neither
// reliably. The locally recorded play must decide the surviving card —
// and it must keep the winner in the group's original shelf slot,
// ahead of the unrelated movie sorted between the two episodes (#1492).
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient((req) async {
if (req.url.path == '/hubs') {
return _json({
'MediaContainer': {
'Hub': [
{
'key': '/hubs/home/continueWatching',
'title': 'Continue Watching',
'type': 'mixed',
'hubIdentifier': 'home.continue',
'size': 3,
'Metadata': [
{
'ratingKey': 'hd-episode',
'type': 'episode',
'title': 'Episode 1',
'grandparentRatingKey': 'hd-show',
'grandparentTitle': 'Shared Show',
'guid': 'plex://episode/shared-episode-hd',
'lastViewedAt': 200,
'librarySectionID': 1,
},
{'ratingKey': 'movie-between', 'type': 'movie', 'title': 'Unrelated Movie', 'lastViewedAt': 150},
{
'ratingKey': 'uhd-episode',
'type': 'episode',
'title': 'Episode 1',
'grandparentRatingKey': 'uhd-show',
'grandparentTitle': 'Shared Show',
'guid': 'plex://episode/shared-episode-uhd',
'lastViewedAt': 100,
'librarySectionID': 2,
},
],
},
],
},
});
}
if (req.url.path == '/library/metadata/hd-show' || req.url.path == '/library/metadata/uhd-show') {
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': req.url.pathSegments.last,
'type': 'show',
'title': 'Shared Show',
'Guid': [
{'id': 'tvdb://12345'},
],
},
],
},
});
}
return http.Response('unexpected request', 500);
}),
);
addTearDown(client.close);
manager.debugRegisterClientForTesting(client);
// The user last played something in the 4K library's show tree.
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.localLastPlayedAt, {'plex-1:uhd-show': 999999});
final result = await service.getOnDeckFromAllServers(limit: 10);
// uhd-episode wins the duplicate group and takes the group's slot
// (before the movie); without local history hd-episode (newest
// lastViewedAt) would have survived.
expect(result.items.map((item) => item.id), ['uhd-episode', 'movie-between']);
});
test('getOnDeckFromAllServers prefers a duplicate recorded by item key', () async {
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient((req) async {
if (req.url.path == '/hubs') {
return _json({
'MediaContainer': {
'Hub': [
{
'key': '/hubs/home/continueWatching',
'title': 'Continue Watching',
'type': 'mixed',
'hubIdentifier': 'home.continue',
'size': 2,
'Metadata': [
{
'ratingKey': 'movie-hd',
'type': 'movie',
'title': 'Shared Movie',
'guid': 'plex://movie/shared-movie',
'lastViewedAt': 200,
'librarySectionID': 1,
},
{
'ratingKey': 'movie-uhd',
'type': 'movie',
'title': 'Shared Movie',
'guid': 'plex://movie/shared-movie',
'lastViewedAt': 100,
'librarySectionID': 2,
},
],
},
],
},
});
}
if (req.url.path == '/library/metadata/movie-hd' || req.url.path == '/library/metadata/movie-uhd') {
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': req.url.pathSegments.last,
'type': 'movie',
'title': 'Shared Movie',
'Guid': [
{'id': 'tmdb://777'},
],
},
],
},
});
}
return http.Response('unexpected request', 500);
}),
);
addTearDown(client.close);
manager.debugRegisterClientForTesting(client);
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.localLastPlayedAt, {'plex-1:movie-uhd': 999999});
final result = await service.getOnDeckFromAllServers(limit: 10);
expect(result.items.map((item) => item.id), ['movie-uhd']);
});
test('getOnDeckFromAllServers keeps duplicate titles without stable ids', () async {
final client = PlexClient.forTesting(
config: PlexConfig(
@@ -168,6 +168,49 @@ void main() {
client.close();
});
test('selects source by preferred signature when no sourceId pins one', () async {
final body = jsonEncode({
'Id': 'item-5',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-1080',
'Container': 'mp4',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920},
],
},
{
'Id': 'src-4k',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840},
],
},
],
});
final client = buildClient(body);
// Grab the real signature of the 4K source, as a saved preference would
// have captured it on a previous play.
final probe = await client.fetchPlaybackBundle('item-5');
final signature = probe!.availableVersions[1].signature;
final bundle = await client.fetchPlaybackBundle('item-5', sourceIndex: 0, preferredSignature: signature);
expect(bundle!.selectedSourceId, 'src-4k');
expect(bundle.selectedSourceIndex, 1);
// An explicit sourceId still wins over the signature.
final pinned = await client.fetchPlaybackBundle(
'item-5',
sourceIndex: 0,
sourceId: 'src-1080',
preferredSignature: signature,
);
expect(pinned!.selectedSourceId, 'src-1080');
client.close();
});
test('chapters defaults to empty list when item has no Chapters field', () async {
final body = jsonEncode({
'Id': 'item-3',
@@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.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/services/local_playback_history.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/prefs.dart';
void main() {
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocalPlaybackHistory.resetForTesting();
});
test('recordPlayback writes item and series keys for an episode', () async {
final episode = MediaItem(
id: 'ep-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
serverId: 'srv-1',
grandparentId: 'show-1',
);
await LocalPlaybackHistory.recordPlayback(episode);
final history = await LocalPlaybackHistory.snapshot();
expect(history.keys, containsAll(['srv-1:ep-1', 'srv-1:show-1']));
expect(history['srv-1:ep-1'], history['srv-1:show-1']);
});
test('recordPlayback writes only the item key for a movie', () async {
final movie = MediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv-1',
);
await LocalPlaybackHistory.recordPlayback(movie);
final history = await LocalPlaybackHistory.snapshot();
expect(history.keys, ['srv-1:movie-1']);
});
test('repeat writes for the same item within the rewrite window are skipped', () async {
final movie = MediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv-1',
);
await LocalPlaybackHistory.recordPlayback(movie);
final first = (await LocalPlaybackHistory.snapshot())['srv-1:movie-1'];
await LocalPlaybackHistory.recordPlayback(movie);
final second = (await LocalPlaybackHistory.snapshot())['srv-1:movie-1'];
expect(second, first);
});
test('prunes the oldest entries past the cap', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.localLastPlayedAt, {for (var i = 0; i < 400; i++) 'srv-1:old-$i': i + 1});
final movie = MediaItem(
id: 'fresh',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Fresh',
serverId: 'srv-1',
);
await LocalPlaybackHistory.recordPlayback(movie);
final history = await LocalPlaybackHistory.snapshot();
expect(history.length, 400);
expect(history, contains('srv-1:fresh'));
// The oldest entry (value 1) was evicted to make room.
expect(history, isNot(contains('srv-1:old-0')));
});
}
@@ -118,6 +118,140 @@ void main() {
expect(result.availableVersions.single.parts.last.isPlayable, isTrue);
});
test('selects version by media source id over the requested index', () {
final result = parsePlexVideoPlaybackDataFromJson(
{
'Media': [
{
'id': 101,
'videoResolution': '1080',
'videoCodec': 'h264',
'container': 'mkv',
'Part': [
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
],
},
{
'id': 102,
'videoResolution': '4k',
'videoCodec': 'hevc',
'container': 'mkv',
'Part': [
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1},
],
},
],
},
baseUrl: 'http://plex:32400',
token: 'tok',
mediaIndex: 0,
selectedMediaSourceId: '102',
);
expect(result.selectedMediaIndex, 1);
expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok');
});
test('selects version by preferred signature when the id misses', () {
final result = parsePlexVideoPlaybackDataFromJson(
{
'Media': [
{
'id': 201,
'videoResolution': '1080',
'videoCodec': 'h264',
'container': 'mkv',
'Part': [
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
],
},
{
'id': 202,
'videoResolution': '4k',
'videoCodec': 'hevc',
'container': 'mkv',
'Part': [
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1},
],
},
],
},
baseUrl: 'http://plex:32400',
token: 'tok',
mediaIndex: 0,
// Sibling episode's id — meaningless here; the signature must decide.
selectedMediaSourceId: '999',
preferredVersionSignature: '4k:hevc:mkv',
);
expect(result.selectedMediaIndex, 1);
});
test('keeps the requested index when id and signature both miss', () {
final result = parsePlexVideoPlaybackDataFromJson(
{
'Media': [
{
'id': 301,
'videoResolution': '1080',
'Part': [
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
],
},
{
'id': 302,
'videoResolution': '720',
'Part': [
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1},
],
},
],
},
baseUrl: 'http://plex:32400',
token: 'tok',
mediaIndex: 1,
preferredVersionSignature: '4k:av1:mp4',
);
expect(result.selectedMediaIndex, 1);
});
test('signature-resolved version still falls back when unplayable', () {
late (int, int) fallback;
final result = parsePlexVideoPlaybackDataFromJson(
{
'Media': [
{
'id': 401,
'videoResolution': '1080',
'videoCodec': 'h264',
'container': 'mkv',
'Part': [
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
],
},
{
'id': 402,
'videoResolution': '4k',
'videoCodec': 'hevc',
'container': 'mkv',
'Part': [
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 0, 'exists': 0},
],
},
],
},
baseUrl: 'http://plex:32400',
token: 'tok',
mediaIndex: 0,
preferredVersionSignature: '4k:hevc:mkv',
onVersionFallback: (requested, selected) => fallback = (requested, selected),
);
expect(fallback, (1, 0));
expect(result.selectedMediaIndex, 0);
});
test('maps server display criteria from selected video stream', () {
final result = parsePlexVideoPlaybackDataFromJson(
{
@@ -1,9 +1,15 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.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_version.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/video_player_navigation.dart';
import '../test_helpers/prefs.dart';
void main() {
test('in-flight video player navigation rejects duplicate requests', () {
final guard = VideoPlayerNavigationInFlightGuard();
@@ -35,4 +41,105 @@ void main() {
isTrue,
);
});
group('media version preference persistence', () {
const versions = [
MediaVersion(id: '101', videoResolution: '1080', videoCodec: 'h264', container: 'mkv'),
MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'),
];
final episode = MediaItem(
id: 'ep-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
serverId: 'srv-1',
grandparentId: 'show-1',
mediaVersions: versions,
);
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
});
test('save writes under the server-scoped series key', () async {
await saveMediaVersionPreferenceFor(episode, index: 1, versions: versions);
final settings = await SettingsService.getInstance();
final prefs = settings.read(SettingsService.mediaVersionPreferences);
expect(prefs.keys, ['srv-1:show-1']);
expect(prefs['srv-1:show-1']!.versionId, '102');
expect(prefs['srv-1:show-1']!.signature, '4k:hevc:mkv');
expect(prefs['srv-1:show-1']!.index, 1);
});
test('reads legacy unscoped-key int entries and migrates them on write', () async {
resetSharedPreferencesForTest(
initialAsync: {
'media_version_preferences': jsonEncode({'show-1': 1}),
},
);
SettingsService.resetForTesting();
final saved = await savedMediaVersionPreferenceFor(episode);
expect(saved, isNotNull);
expect(saved!.index, 1);
expect(saved.versionId, isNull);
await saveMediaVersionPreferenceFor(episode, index: 0, versions: versions);
final settings = await SettingsService.getInstance();
final prefs = settings.read(SettingsService.mediaVersionPreferences);
expect(prefs.keys, ['srv-1:show-1']);
expect(prefs['srv-1:show-1']!.versionId, '101');
});
test('resolveSavedMediaVersionFor verifies against populated mediaVersions', () async {
// Stored index points at 0, but the id pins version 102 → index 1.
resetSharedPreferencesForTest(
initialAsync: {
'media_version_preferences': jsonEncode({
'srv-1:show-1': {'id': '102', 'sig': '4k:hevc:mkv', 'idx': 0},
}),
},
);
SettingsService.resetForTesting();
final resolved = await resolveSavedMediaVersionFor(episode);
expect(resolved, isNotNull);
expect(resolved!.index, 1);
expect(resolved.sourceId, '102');
expect(resolved.signature, '4k:hevc:mkv');
});
test('resolveSavedMediaVersionFor passes stored index/signature through without versions', () async {
resetSharedPreferencesForTest(
initialAsync: {
'media_version_preferences': jsonEncode({
'srv-1:show-1': {'id': '102', 'sig': '4k:hevc:mkv', 'idx': 1},
}),
},
);
SettingsService.resetForTesting();
final bare = MediaItem(
id: 'ep-2',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'Episode 2',
serverId: 'srv-1',
grandparentId: 'show-1',
);
final resolved = await resolveSavedMediaVersionFor(bare);
expect(resolved, isNotNull);
expect(resolved!.index, 1);
// An id from another item must not be forwarded as an explicit pick.
expect(resolved.sourceId, isNull);
expect(resolved.signature, '4k:hevc:mkv');
});
test('resolveSavedMediaVersionFor returns null when nothing is stored', () async {
expect(await resolveSavedMediaVersionFor(episode), isNull);
});
});
}