fix(playback): remember last played media version for continue watching
close #1492
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user