@@ -106,7 +106,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
|
||||
/// Navigates to a new episode by reusing the current player whenever possible.
|
||||
Future<void> _navigateToEpisode(MediaItem episodeMetadata) async {
|
||||
if (player == null) {
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) {
|
||||
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
|
||||
return;
|
||||
}
|
||||
@@ -118,6 +119,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
_effectiveSelectedMediaIndex >= 0 && _effectiveSelectedMediaIndex < _availableVersions.length
|
||||
? _availableVersions[_effectiveSelectedMediaIndex].signature
|
||||
: null;
|
||||
final committedSubtitleSelection = _playbackSession?.subtitleSelection;
|
||||
final primarySubtitlePreference = subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: committedSubtitleSelection != null,
|
||||
committedTrack: committedSubtitleSelection?.primaryTrack,
|
||||
nativeTrack: currentPlayer.state.track.subtitle,
|
||||
);
|
||||
final secondarySubtitlePreference = subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: committedSubtitleSelection != null,
|
||||
committedTrack: committedSubtitleSelection?.secondaryTrack,
|
||||
nativeTrack: currentPlayer.state.track.secondarySubtitle,
|
||||
);
|
||||
await _reloadMediaInPlace(
|
||||
metadata: episodeMetadata,
|
||||
selectedMediaIndex: _effectiveSelectedMediaIndex,
|
||||
@@ -128,6 +140,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
// meaningless on the new item, so let preferences pick the track.
|
||||
useCurrentAudioStreamSelection: false,
|
||||
preserveCurrentTrackSelection: true,
|
||||
preservedSubtitleTrack: primarySubtitlePreference,
|
||||
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
|
||||
reason: 'episode navigation',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,48 @@ bool shouldAutoStartReloadedMedia({
|
||||
required bool startPaused,
|
||||
}) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused;
|
||||
|
||||
/// Builds an item-agnostic subtitle preference for an episode replacement.
|
||||
///
|
||||
/// Source ids and sidecar URIs belong to the current media item. Only the
|
||||
/// committed semantic choice may cross the item boundary; native state is a
|
||||
/// fallback for sessions created before source-backed selection was recorded.
|
||||
SubtitleTrack? subtitlePreferenceForItemChange({
|
||||
required bool hasCommittedSelection,
|
||||
required SubtitleTrack? committedTrack,
|
||||
required SubtitleTrack? nativeTrack,
|
||||
}) {
|
||||
SubtitleTrack? normalize(SubtitleTrack? track, {required bool preserveOff}) {
|
||||
if (track == null) return null;
|
||||
if (track.id == SubtitleTrack.off.id) return preserveOff ? SubtitleTrack.off : null;
|
||||
|
||||
final hasSemanticMetadata =
|
||||
(track.title?.isNotEmpty ?? false) ||
|
||||
(track.language?.isNotEmpty ?? false) ||
|
||||
(track.codec?.isNotEmpty ?? false);
|
||||
if (!hasSemanticMetadata) return null;
|
||||
|
||||
return SubtitleTrack(
|
||||
id: 'navigation',
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
isDefault: track.isDefault,
|
||||
isForced: track.isForced,
|
||||
isExternal: track.isExternal,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasCommittedSelection) {
|
||||
return normalize(nativeTrack, preserveOff: true);
|
||||
}
|
||||
|
||||
if (committedTrack == null) return SubtitleTrack.off;
|
||||
|
||||
final committedPreference = normalize(committedTrack, preserveOff: true);
|
||||
if (committedPreference != null) return committedPreference;
|
||||
return normalize(nativeTrack, preserveOff: false);
|
||||
}
|
||||
|
||||
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
|
||||
/// They are mutually exclusive by construction — entry points bail while a
|
||||
/// transition is in flight.
|
||||
|
||||
@@ -62,6 +62,7 @@ import 'jellyfin_playback_urls.dart';
|
||||
import 'jellyfin_trickplay_service.dart';
|
||||
import 'playback_initialization_types.dart';
|
||||
import 'scrub_preview_source.dart';
|
||||
import 'track_selection_service.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
|
||||
part 'jellyfin_client/parts/browse.dart';
|
||||
|
||||
@@ -329,11 +329,25 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
int? _validJellyfinSubtitleStreamId(SubtitleTrack? preferred, MediaSourceInfo mediaInfo) {
|
||||
if (preferred == null) return null;
|
||||
if (preferred.id == SubtitleTrack.off.id) return -1;
|
||||
|
||||
const sourcePrefix = 'source:';
|
||||
if (!preferred.id.startsWith(sourcePrefix)) return null;
|
||||
final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length));
|
||||
if (explicit == null) return null;
|
||||
return mediaInfo.subtitleTracks.any((track) => track.id == explicit) ? explicit : null;
|
||||
if (preferred.id.startsWith(sourcePrefix)) {
|
||||
final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length));
|
||||
if (explicit != null) {
|
||||
final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull;
|
||||
if (exactSource != null) {
|
||||
// A source id is authoritative only within one item. When semantic
|
||||
// metadata is available, reject a coincidentally reused episode id.
|
||||
final exactMatch = findPlexTrackForMpvSubtitle(preferred, [exactSource]);
|
||||
final hasLanguage = preferred.language?.isNotEmpty ?? false;
|
||||
if (!hasLanguage || (exactMatch != null && preferred.isForced == exactSource.forced)) {
|
||||
return explicit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findPlexTrackForMpvSubtitle(preferred, mediaInfo.subtitleTracks)?.id;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
|
||||
|
||||
@@ -76,6 +76,25 @@ class PlaybackSubtitleSelection {
|
||||
class PlaybackSubtitleResolver {
|
||||
const PlaybackSubtitleResolver._();
|
||||
|
||||
static SubtitleTrack? _sourceBackedPreference(
|
||||
SubtitleTrack? preferred,
|
||||
MediaSourceInfo? mediaInfo,
|
||||
List<_SubtitleCandidate> candidates,
|
||||
) {
|
||||
if (preferred == null || preferred.id == SubtitleTrack.off.id) return preferred;
|
||||
|
||||
final sourceMatch = findPlexTrackForMpvSubtitle(
|
||||
preferred,
|
||||
mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
||||
);
|
||||
if (sourceMatch == null) return preferred;
|
||||
|
||||
for (final candidate in candidates) {
|
||||
if (candidate.sourceStreamId == sourceMatch.id) return candidate.track;
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
|
||||
static PlaybackSubtitleSelection resolve({
|
||||
required MediaItem metadata,
|
||||
required MediaSourceInfo? mediaInfo,
|
||||
@@ -116,7 +135,8 @@ class PlaybackSubtitleResolver {
|
||||
plexMediaInfo: mediaInfo,
|
||||
);
|
||||
final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track;
|
||||
final primaryResult = service.selectSubtitleTrack(availableTracks, preferredSubtitleTrack, selectedAudio);
|
||||
final primaryPreference = _sourceBackedPreference(preferredSubtitleTrack, mediaInfo, candidates);
|
||||
final primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio);
|
||||
final primary = primaryResult.track;
|
||||
if (primary.id == SubtitleTrack.off.id) return const PlaybackSubtitleSelection.off();
|
||||
|
||||
@@ -124,8 +144,9 @@ class PlaybackSubtitleResolver {
|
||||
if (primaryCandidate == null) return const PlaybackSubtitleSelection.off();
|
||||
|
||||
_SubtitleCandidate? secondaryCandidate;
|
||||
if (preferredSecondarySubtitleTrack != null && preferredSecondarySubtitleTrack.id != SubtitleTrack.off.id) {
|
||||
final secondary = service.findBestSubtitleMatch(availableTracks, preferredSecondarySubtitleTrack);
|
||||
final secondaryPreference = _sourceBackedPreference(preferredSecondarySubtitleTrack, mediaInfo, candidates);
|
||||
if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) {
|
||||
final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference);
|
||||
secondaryCandidate = candidates
|
||||
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
||||
.firstOrNull;
|
||||
|
||||
Reference in New Issue
Block a user