@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/focusable_button.dart';
|
||||
import 'package:plezy/providers/playback_state_provider.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/screens/video_player_screen.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -41,6 +42,69 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('item-change subtitle preference carries committed semantics without item identity', () {
|
||||
const committed = SubtitleTrack(
|
||||
id: 'source:4',
|
||||
title: 'French - SRT',
|
||||
language: 'fra',
|
||||
codec: 'srt',
|
||||
isForced: true,
|
||||
isExternal: true,
|
||||
uri: 'https://example.test/old-episode/subtitle.srt',
|
||||
);
|
||||
|
||||
final result = subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: true,
|
||||
committedTrack: committed,
|
||||
nativeTrack: SubtitleTrack.off,
|
||||
);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.id, 'navigation');
|
||||
expect(result.title, committed.title);
|
||||
expect(result.language, committed.language);
|
||||
expect(result.codec, committed.codec);
|
||||
expect(result.isForced, isTrue);
|
||||
expect(result.isExternal, isTrue);
|
||||
expect(result.uri, isNull);
|
||||
});
|
||||
|
||||
test('item-change subtitle preference preserves committed off and empty secondary slots', () {
|
||||
expect(
|
||||
subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: true,
|
||||
committedTrack: SubtitleTrack.off,
|
||||
nativeTrack: const SubtitleTrack(id: '7', language: 'eng'),
|
||||
),
|
||||
same(SubtitleTrack.off),
|
||||
);
|
||||
expect(
|
||||
subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: true,
|
||||
committedTrack: null,
|
||||
nativeTrack: const SubtitleTrack(id: '8', language: 'swe'),
|
||||
),
|
||||
same(SubtitleTrack.off),
|
||||
);
|
||||
});
|
||||
|
||||
test('item-change subtitle preference uses native metadata only without a committed selection', () {
|
||||
final result = subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: false,
|
||||
committedTrack: null,
|
||||
nativeTrack: const SubtitleTrack(
|
||||
id: '9',
|
||||
title: 'English',
|
||||
language: 'eng',
|
||||
uri: 'https://example.test/old-episode/native.srt',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result?.id, 'navigation');
|
||||
expect(result?.language, 'eng');
|
||||
expect(result?.uri, isNull);
|
||||
});
|
||||
|
||||
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
||||
final failedDispose = Completer<void>();
|
||||
final replacementInitialize = Completer<bool>();
|
||||
|
||||
@@ -924,7 +924,7 @@ void main() {
|
||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization negotiates the requested DirectStream subtitle and exposes delivery', () async {
|
||||
test('getPlaybackInitialization maps semantic subtitle preferences to current source rows', () async {
|
||||
Uri? playbackInfoUri;
|
||||
String? playbackInfoBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -944,6 +944,7 @@ void main() {
|
||||
{'Index': 0, 'Type': 'Video'},
|
||||
{'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'},
|
||||
{'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'},
|
||||
{'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -984,6 +985,16 @@ void main() {
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt',
|
||||
},
|
||||
{
|
||||
'Index': 5,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'srt',
|
||||
'Language': 'eng',
|
||||
'DisplayTitle': 'English Forced - SRT',
|
||||
'IsForced': true,
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -997,34 +1008,64 @@ void main() {
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final result = await scoped.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'item-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: 'srv-1',
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'source:4',
|
||||
title: 'French - SRT',
|
||||
language: 'fra',
|
||||
codec: 'srt',
|
||||
Future<PlaybackInitializationResult> initialize(SubtitleTrack preference) {
|
||||
return scoped.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'item-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: 'srv-1',
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
preferredSubtitleTrack: preference,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void expectRequestedSubtitleIndex(int? expected) {
|
||||
expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], expected?.toString());
|
||||
final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
|
||||
expect(playbackInfoJson['SubtitleStreamIndex'], expected);
|
||||
}
|
||||
|
||||
final result = await initialize(
|
||||
const SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
|
||||
);
|
||||
expectRequestedSubtitleIndex(4);
|
||||
|
||||
await initialize(const SubtitleTrack(id: 'navigation', title: 'English - SRT', language: 'eng', codec: 'srt'));
|
||||
expectRequestedSubtitleIndex(3);
|
||||
|
||||
await initialize(const SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'));
|
||||
expectRequestedSubtitleIndex(4);
|
||||
|
||||
await initialize(
|
||||
const SubtitleTrack(
|
||||
id: 'source:3',
|
||||
title: 'English Forced - SRT',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isForced: true,
|
||||
),
|
||||
);
|
||||
expectRequestedSubtitleIndex(5);
|
||||
|
||||
await initialize(SubtitleTrack.off);
|
||||
expectRequestedSubtitleIndex(-1);
|
||||
|
||||
await initialize(const SubtitleTrack(id: 'navigation', title: 'Japanese - SRT', language: 'jpn', codec: 'srt'));
|
||||
expectRequestedSubtitleIndex(null);
|
||||
|
||||
expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], '4');
|
||||
final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
|
||||
expect(playbackInfoJson['SubtitleStreamIndex'], 4);
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(2));
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(3));
|
||||
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
|
||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4]);
|
||||
expect(result.externalSubtitles, hasLength(2));
|
||||
expect(Uri.parse(result.externalSubtitles.first.uri!).path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
|
||||
expect(Uri.parse(result.externalSubtitles.last.uri!).path, '/Videos/item-1/src-1/Subtitles/4/Stream.srt');
|
||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]);
|
||||
expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [
|
||||
'/Videos/item-1/src-1/Subtitles/3/Stream.srt',
|
||||
'/Videos/item-1/src-1/Subtitles/4/Stream.srt',
|
||||
'/Videos/item-1/src-1/Subtitles/5/Stream.srt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../test_helpers/media_items.dart';
|
||||
MediaSubtitleTrack _sourceSubtitle(
|
||||
int id, {
|
||||
String language = 'eng',
|
||||
bool forced = false,
|
||||
bool selected = false,
|
||||
bool external = false,
|
||||
bool usesExternalDelivery = false,
|
||||
@@ -21,7 +22,7 @@ MediaSubtitleTrack _sourceSubtitle(
|
||||
languageCode: language,
|
||||
title: 'Subtitle $id',
|
||||
selected: selected,
|
||||
forced: false,
|
||||
forced: forced,
|
||||
external: external,
|
||||
usesExternalDelivery: usesExternalDelivery,
|
||||
);
|
||||
@@ -178,6 +179,54 @@ void main() {
|
||||
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt');
|
||||
});
|
||||
|
||||
test('item-change semantic preference selects the matching new sidecar', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(7, language: 'eng', usesExternalDelivery: true),
|
||||
_sourceSubtitle(9, language: 'fra', usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [
|
||||
_sidecar(7),
|
||||
_sidecar(9, language: 'fra'),
|
||||
],
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'navigation',
|
||||
title: 'English from the previous episode',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 7);
|
||||
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/7.srt');
|
||||
expect(result.sidecarsAtOpen, hasLength(1));
|
||||
});
|
||||
|
||||
test('item-change semantic preference distinguishes forced and full subtitles in one language', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(7, language: 'eng', usesExternalDelivery: true),
|
||||
_sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [_sidecar(7), _sidecar(8)],
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'navigation',
|
||||
title: 'English forced from the previous episode',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isForced: true,
|
||||
isExternal: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 8);
|
||||
expect(result.primaryTrack.isForced, isTrue);
|
||||
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt');
|
||||
});
|
||||
|
||||
test('selected embedded subtitle keeps sidecars out of the open', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
|
||||
Reference in New Issue
Block a user