fix(player): preserve subtitles across episode changes

close #1635
This commit is contained in:
edde746
2026-07-24 21:40:22 +02:00
parent db7bc17c49
commit c7b9eec087
8 changed files with 278 additions and 32 deletions
@@ -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>();
+64 -23
View File
@@ -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,