fix(player): preserve the forced-subtitle class across episode boundaries
Plex treats a subtitle stream as forced when its title says "Forced" even with the API flag unset. Every forced comparison now uses that effective forced-ness on both sides: the match scorer, the low-metadata hard gate, the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation. Carrying a track choice into the next episode no longer reuses the same-item identity matchers. A sealed SubtitlePreference (off / track reference / semantic intent) replaces the id-'navigation' pseudo-track through the whole preference channel, and cross-item intents hard-require language and forced-class parity. When the next episode has no track of the same class, the intent declines and selection falls through to the server's own per-episode choice instead of latching onto a full track by position and persisting that mistake back to the server. Intents wait for pending native tracks under the same catalog-completeness rule as source ids, so an early decline cannot retire the selection listener before the real track arrives. Ref #1716
This commit is contained in:
@@ -8,6 +8,7 @@ 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:plezy/services/subtitle_preference.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
@@ -59,14 +60,26 @@ void main() {
|
||||
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);
|
||||
expect(result, isA<SubtitleIntentPreference>());
|
||||
final intent = (result! as SubtitleIntentPreference).intent;
|
||||
expect(intent.title, committed.title);
|
||||
expect(intent.language, committed.language);
|
||||
expect(intent.codec, committed.codec);
|
||||
expect(intent.forced, isTrue);
|
||||
expect(intent.isExternal, isTrue);
|
||||
});
|
||||
|
||||
test('item-change subtitle preference derives forced-ness from a forced title (#1716)', () {
|
||||
const committed = SubtitleTrack(id: 'source:4', title: 'FR Forced [ASS]', language: 'fra', codec: 'ass');
|
||||
|
||||
final result = subtitlePreferenceForItemChange(
|
||||
hasCommittedSelection: true,
|
||||
committedTrack: committed,
|
||||
nativeTrack: SubtitleTrack.off,
|
||||
);
|
||||
|
||||
expect(result, isA<SubtitleIntentPreference>());
|
||||
expect((result! as SubtitleIntentPreference).intent.forced, isTrue);
|
||||
});
|
||||
|
||||
test('item-change subtitle preference preserves committed off and empty secondary slots', () {
|
||||
@@ -76,7 +89,7 @@ void main() {
|
||||
committedTrack: SubtitleTrack.off,
|
||||
nativeTrack: const SubtitleTrack(id: '7', language: 'eng'),
|
||||
),
|
||||
same(SubtitleTrack.off),
|
||||
const SubtitlePreference.off(),
|
||||
);
|
||||
expect(
|
||||
subtitlePreferenceForItemChange(
|
||||
@@ -84,7 +97,7 @@ void main() {
|
||||
committedTrack: null,
|
||||
nativeTrack: const SubtitleTrack(id: '8', language: 'swe'),
|
||||
),
|
||||
same(SubtitleTrack.off),
|
||||
const SubtitlePreference.off(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,9 +113,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result?.id, 'navigation');
|
||||
expect(result?.language, 'eng');
|
||||
expect(result?.uri, isNull);
|
||||
expect(result, isA<SubtitleIntentPreference>());
|
||||
expect((result! as SubtitleIntentPreference).intent.language, 'eng');
|
||||
});
|
||||
|
||||
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/subtitle_preference.dart';
|
||||
import 'package:plezy/utils/device_identity.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
@@ -1060,7 +1061,7 @@ void main() {
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
Future<PlaybackInitializationResult> initialize(SubtitleTrack preference) {
|
||||
Future<PlaybackInitializationResult> initialize(SubtitlePreference preference) {
|
||||
return scoped.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
@@ -1082,31 +1083,41 @@ void main() {
|
||||
}
|
||||
|
||||
final result = await initialize(
|
||||
const SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
|
||||
const SubtitlePreference.track(
|
||||
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'));
|
||||
await initialize(
|
||||
const SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'eng', forced: false, title: 'English - SRT', codec: 'srt'),
|
||||
),
|
||||
);
|
||||
expectRequestedSubtitleIndex(3);
|
||||
|
||||
await initialize(const SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'));
|
||||
await initialize(
|
||||
const SubtitlePreference.track(
|
||||
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,
|
||||
const SubtitlePreference.track(
|
||||
SubtitleTrack(id: 'source:3', title: 'English Forced - SRT', language: 'eng', codec: 'srt', isForced: true),
|
||||
),
|
||||
);
|
||||
expectRequestedSubtitleIndex(5);
|
||||
|
||||
await initialize(SubtitleTrack.off);
|
||||
await initialize(const SubtitlePreference.off());
|
||||
expectRequestedSubtitleIndex(-1);
|
||||
|
||||
await initialize(const SubtitleTrack(id: 'navigation', title: 'Japanese - SRT', language: 'jpn', codec: 'srt'));
|
||||
await initialize(
|
||||
const SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'jpn', forced: false, title: 'Japanese - SRT', codec: 'srt'),
|
||||
),
|
||||
);
|
||||
expectRequestedSubtitleIndex(null);
|
||||
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
||||
import 'package:plezy/services/subtitle_preference.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
@@ -17,13 +18,14 @@ MediaSubtitleTrack _sourceSubtitle(
|
||||
bool external = false,
|
||||
bool usesExternalDelivery = false,
|
||||
String? key,
|
||||
String? title,
|
||||
}) {
|
||||
return MediaSubtitleTrack(
|
||||
id: id,
|
||||
language: language,
|
||||
languageCode: language,
|
||||
codec: codec,
|
||||
title: 'Subtitle $id',
|
||||
title: title ?? 'Subtitle $id',
|
||||
selected: selected,
|
||||
forced: forced,
|
||||
// Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL
|
||||
@@ -229,7 +231,7 @@ void main() {
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
|
||||
sidecars: [_sidecar(2)],
|
||||
preferredSubtitleTrack: SubtitleTrack.off,
|
||||
preferredSubtitleTrack: const SubtitlePreference.off(),
|
||||
);
|
||||
|
||||
expect(result.isOff, isTrue);
|
||||
@@ -244,7 +246,7 @@ void main() {
|
||||
_sidecar(2, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||
_sidecar(3, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||
],
|
||||
preferredSubtitleTrack: SubtitleTrack.off,
|
||||
preferredSubtitleTrack: const SubtitlePreference.off(),
|
||||
);
|
||||
|
||||
expect(result.isOff, isTrue);
|
||||
@@ -266,7 +268,9 @@ void main() {
|
||||
_sidecar(2),
|
||||
_sidecar(3, language: 'swe'),
|
||||
],
|
||||
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
|
||||
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 3);
|
||||
@@ -301,7 +305,9 @@ void main() {
|
||||
_sidecar(2, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||
_sidecar(3, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||
],
|
||||
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
|
||||
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 3);
|
||||
@@ -318,13 +324,15 @@ void main() {
|
||||
_sidecar(3),
|
||||
_sidecar(7, language: 'fra'),
|
||||
],
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'source:3',
|
||||
title: 'French from the previous source',
|
||||
language: 'fra',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
uri: 'https://example.test/previous/3.srt',
|
||||
preferredSubtitleTrack: const SubtitlePreference.track(
|
||||
SubtitleTrack(
|
||||
id: 'source:3',
|
||||
title: 'French from the previous source',
|
||||
language: 'fra',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
uri: 'https://example.test/previous/3.srt',
|
||||
),
|
||||
),
|
||||
preserveSourceIdentity: false,
|
||||
);
|
||||
@@ -337,11 +345,8 @@ void main() {
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng'), _sourceSubtitle(7, language: 'spa', selected: true)]),
|
||||
sidecars: const [],
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'source:3',
|
||||
title: 'French from the previous source',
|
||||
language: 'fra',
|
||||
codec: 'srt',
|
||||
preferredSubtitleTrack: const SubtitlePreference.track(
|
||||
SubtitleTrack(id: 'source:3', title: 'French from the previous source', language: 'fra', codec: 'srt'),
|
||||
),
|
||||
preserveSourceIdentity: false,
|
||||
);
|
||||
@@ -360,12 +365,14 @@ void main() {
|
||||
_sidecar(7),
|
||||
_sidecar(9, language: 'fra'),
|
||||
],
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'navigation',
|
||||
title: 'English from the previous episode',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||
SubtitleIntent(
|
||||
language: 'eng',
|
||||
forced: false,
|
||||
title: 'English from the previous episode',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -382,13 +389,14 @@ void main() {
|
||||
_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,
|
||||
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||
SubtitleIntent(
|
||||
language: 'eng',
|
||||
forced: true,
|
||||
title: 'English forced from the previous episode',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -397,6 +405,82 @@ void main() {
|
||||
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt');
|
||||
});
|
||||
|
||||
group('issue #1716/#1717 forced-class carry-over', () {
|
||||
const forcedIntent = SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'fra', forced: true, title: 'FR Forced [ASS]', codec: 'srt', isExternal: true),
|
||||
);
|
||||
|
||||
test("picks the next episode's title-only forced row", () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
||||
_sourceSubtitle(8, language: 'fra', title: 'FR Forced', usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [_sidecar(7, language: 'fra'), _sidecar(8, language: 'fra')],
|
||||
preferredSubtitleTrack: forcedIntent,
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 8);
|
||||
expect(result.primaryTrack.isForced, isTrue);
|
||||
});
|
||||
|
||||
test('picks a flag-forced row for a title-forced intent (cross-form)', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
||||
_sourceSubtitle(8, language: 'fra', forced: true, usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [_sidecar(7, language: 'fra'), _sidecar(8, language: 'fra')],
|
||||
preferredSubtitleTrack: forcedIntent,
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 8);
|
||||
});
|
||||
|
||||
test('declines to the server-selected full row when no forced row exists', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(7, language: 'fra', selected: true, usesExternalDelivery: true),
|
||||
_sourceSubtitle(9, language: 'eng', usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [_sidecar(7, language: 'fra'), _sidecar(9)],
|
||||
preferredSubtitleTrack: forcedIntent,
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 7);
|
||||
});
|
||||
|
||||
test('subtitles stay off when no forced row exists and nothing is selected', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true)]),
|
||||
sidecars: [_sidecar(7, language: 'fra')],
|
||||
preferredSubtitleTrack: forcedIntent,
|
||||
);
|
||||
|
||||
expect(result.isOff, isTrue);
|
||||
});
|
||||
|
||||
test('a full intent does not inherit a forced-only catalog (symmetric)', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(8, language: 'fra', title: 'FR Forced', usesExternalDelivery: true),
|
||||
_sourceSubtitle(9, language: 'eng', selected: true, usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [_sidecar(8, language: 'fra'), _sidecar(9)],
|
||||
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'fra', forced: false, title: 'French', codec: 'srt', isExternal: true),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 9);
|
||||
});
|
||||
});
|
||||
|
||||
test('selected embedded subtitle keeps sidecars out of the open', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
@@ -433,7 +517,9 @@ void main() {
|
||||
_sidecar(2),
|
||||
_sidecar(3, language: 'swe'),
|
||||
],
|
||||
preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(
|
||||
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 2);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/subtitle_preference.dart';
|
||||
|
||||
void main() {
|
||||
group('SubtitleIntent.fromTrack', () {
|
||||
test('returns null for null, off, and metadata-less tracks', () {
|
||||
expect(SubtitleIntent.fromTrack(null), isNull);
|
||||
expect(SubtitleIntent.fromTrack(SubtitleTrack.off), isNull);
|
||||
expect(SubtitleIntent.fromTrack(const SubtitleTrack(id: '3')), isNull);
|
||||
});
|
||||
|
||||
test('captures effective forced-ness from the title (#1716)', () {
|
||||
final intent = SubtitleIntent.fromTrack(
|
||||
const SubtitleTrack(id: 'source:8', title: 'FR Forced [ASS]', language: 'fre', codec: 'ass'),
|
||||
);
|
||||
expect(intent, isNotNull);
|
||||
expect(intent!.forced, isTrue);
|
||||
expect(intent.language, 'fre');
|
||||
});
|
||||
|
||||
test('captures effective forced-ness from the flag', () {
|
||||
final intent = SubtitleIntent.fromTrack(
|
||||
const SubtitleTrack(id: '4', language: 'fre', isForced: true),
|
||||
);
|
||||
expect(intent!.forced, isTrue);
|
||||
});
|
||||
|
||||
test('full track produces a non-forced intent', () {
|
||||
final intent = SubtitleIntent.fromTrack(
|
||||
const SubtitleTrack(id: '4', title: 'French', language: 'fre', codec: 'srt'),
|
||||
);
|
||||
expect(intent!.forced, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('SubtitlePreference.trackOrNull', () {
|
||||
test('maps null, off, and real tracks', () {
|
||||
expect(SubtitlePreference.trackOrNull(null), isNull);
|
||||
expect(SubtitlePreference.trackOrNull(SubtitleTrack.off), const SubtitlePreference.off());
|
||||
const track = SubtitleTrack(id: 'source:5', language: 'eng');
|
||||
expect(SubtitlePreference.trackOrNull(track), const SubtitlePreference.track(track));
|
||||
});
|
||||
});
|
||||
|
||||
group('SubtitlePreference.demoteToIntent', () {
|
||||
test('off and intents pass through, null stays null', () {
|
||||
const off = SubtitlePreference.off();
|
||||
expect(SubtitlePreference.demoteToIntent(off), off);
|
||||
const intent = SubtitlePreference.intent(SubtitleIntent(language: 'fre', forced: true));
|
||||
expect(SubtitlePreference.demoteToIntent(intent), intent);
|
||||
expect(SubtitlePreference.demoteToIntent(null), isNull);
|
||||
});
|
||||
|
||||
test('track references become intents with effective forced-ness', () {
|
||||
final demoted = SubtitlePreference.demoteToIntent(
|
||||
const SubtitlePreference.track(SubtitleTrack(id: 'source:8', title: 'FR Forced', language: 'fre', codec: 'ass')),
|
||||
);
|
||||
expect(
|
||||
demoted,
|
||||
const SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced', codec: 'ass'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sidecar uri references also lose their identity', () {
|
||||
final demoted = SubtitlePreference.demoteToIntent(
|
||||
SubtitlePreference.track(
|
||||
SubtitleTrack.uri('https://server/library/streams/9.srt', language: 'eng', codec: 'srt'),
|
||||
),
|
||||
);
|
||||
expect(demoted, isA<SubtitleIntentPreference>());
|
||||
expect((demoted! as SubtitleIntentPreference).intent.isExternal, isTrue);
|
||||
});
|
||||
|
||||
test('metadata-less track references demote to null', () {
|
||||
expect(
|
||||
SubtitlePreference.demoteToIntent(const SubtitlePreference.track(SubtitleTrack(id: '2'))),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:plezy/mpv/player/player_stream_controllers.dart';
|
||||
import 'package:plezy/screens/video_player_screen.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/subtitle_preference.dart';
|
||||
import 'package:plezy/services/track_manager.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
@@ -162,7 +163,7 @@ TrackManager _make({
|
||||
metadata: metadata ?? _meta(),
|
||||
mediaInfo: mediaInfo,
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack),
|
||||
showMessage: showMessage,
|
||||
);
|
||||
}
|
||||
@@ -660,7 +661,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(handledLocally, isTrue);
|
||||
expect(mgr.preferredSubtitleTrack?.id, 'source:31');
|
||||
expect((mgr.preferredSubtitleTrack! as SubtitleTrackPreference).track.id, 'source:31');
|
||||
expect(persistedTrack?.id, 'source:31');
|
||||
expect(persistedSourceStreamId, 31);
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
@@ -1266,7 +1267,7 @@ void main() {
|
||||
await mgr.invalidatePendingSelection();
|
||||
|
||||
mgr.preferredAudioTrack = audioTracks[1];
|
||||
mgr.preferredSubtitleTrack = subtitleTracks[1];
|
||||
mgr.preferredSubtitleTrack = SubtitlePreference.track(subtitleTracks[1]);
|
||||
final replacementApplication = mgr.applyTrackSelection();
|
||||
await _drainAsync();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/models/jellyfin/jellyfin_user_profile.dart';
|
||||
import 'package:plezy/models/plex/plex_user_profile.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/subtitle_preference.dart';
|
||||
import 'package:plezy/services/track_selection_service.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
@@ -453,14 +454,14 @@ void main() {
|
||||
group('selectSubtitleTrack', () {
|
||||
test('Priority 1: preferred id="no" forces subtitles off', () {
|
||||
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
|
||||
final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null)!;
|
||||
final result = _svc().selectSubtitleTrack(tracks, const SubtitlePreference.off(), null)!;
|
||||
expect(result.priority, TrackSelectionPriority.navigation);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
|
||||
test('Priority 1: preferred subtitle from navigation matches by language', () {
|
||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||
final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null)!;
|
||||
final result = _svc().selectSubtitleTrack(tracks, SubtitlePreference.track(_sub('99', lang: 'fre')), null)!;
|
||||
expect(result.priority, TrackSelectionPriority.navigation);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -486,7 +487,7 @@ void main() {
|
||||
|
||||
final preferredResult = service.selectSubtitleTrack(
|
||||
[nativeTrack],
|
||||
const SubtitleTrack(id: 'source:20', codec: 'ass'),
|
||||
const SubtitlePreference.track(SubtitleTrack(id: 'source:20', codec: 'ass')),
|
||||
null,
|
||||
)!;
|
||||
final serverResult = service.selectSubtitleTrack([nativeTrack], null, null)!;
|
||||
@@ -523,7 +524,7 @@ void main() {
|
||||
|
||||
final preferredResult = service.selectSubtitleTrack(
|
||||
nativeTracks,
|
||||
const SubtitleTrack(id: 'source:40', codec: 'ass'),
|
||||
const SubtitlePreference.track(SubtitleTrack(id: 'source:40', codec: 'ass')),
|
||||
null,
|
||||
)!;
|
||||
final serverResult = service.selectSubtitleTrack(nativeTracks, null, null)!;
|
||||
@@ -540,7 +541,7 @@ void main() {
|
||||
|
||||
final result = _svc(
|
||||
info: _info(subs: plexTracks),
|
||||
).selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:50', codec: 'ass'), null)!;
|
||||
).selectSubtitleTrack(nativeTracks, const SubtitlePreference.track(SubtitleTrack(id: 'source:50', codec: 'ass')), null)!;
|
||||
|
||||
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
||||
expect(result.track.id, 'native-default');
|
||||
@@ -552,7 +553,11 @@ void main() {
|
||||
final service = _svc(info: _info(subs: plexTracks));
|
||||
|
||||
expect(
|
||||
service.selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:60', codec: 'ass'), null),
|
||||
service.selectSubtitleTrack(
|
||||
nativeTracks,
|
||||
const SubtitlePreference.track(SubtitleTrack(id: 'source:60', codec: 'ass')),
|
||||
null,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(service.selectSubtitleTrack(nativeTracks, null, null), isNull);
|
||||
@@ -607,8 +612,11 @@ void main() {
|
||||
);
|
||||
final service = _svc(info: info);
|
||||
|
||||
expect(service.selectSubtitleTrack(const [first], preferred, null), isNull);
|
||||
expect(service.selectSubtitleTrack(const [first, second], preferred, null)?.track.id, 'native-1');
|
||||
expect(service.selectSubtitleTrack(const [first], SubtitlePreference.track(preferred), null), isNull);
|
||||
expect(
|
||||
service.selectSubtitleTrack(const [first, second], SubtitlePreference.track(preferred), null)?.track.id,
|
||||
'native-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('preferred keyed source does not fuzzy-match an early same-language container track', () {
|
||||
@@ -634,7 +642,10 @@ void main() {
|
||||
uri: 'https://example.test/video.mkv',
|
||||
);
|
||||
|
||||
expect(_svc(info: info).selectSubtitleTrack(const [earlyContainer], preferred, null), isNull);
|
||||
expect(
|
||||
_svc(info: info).selectSubtitleTrack(const [earlyContainer], SubtitlePreference.track(preferred), null),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () {
|
||||
@@ -742,7 +753,9 @@ void main() {
|
||||
info: directPlayInfo(),
|
||||
).selectSubtitleTrack(
|
||||
nativeTracks,
|
||||
_sub('source:3', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true, isDefault: true),
|
||||
SubtitlePreference.track(
|
||||
_sub('source:3', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true, isDefault: true),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -756,7 +769,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: directPlayInfo(),
|
||||
).selectSubtitleTrack(nativeTracks, _sub('source:9', lang: 'kor', codec: 'srt'), null);
|
||||
).selectSubtitleTrack(nativeTracks, SubtitlePreference.track(_sub('source:9', lang: 'kor', codec: 'srt')), null);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.track.id, '1');
|
||||
@@ -811,7 +824,7 @@ void main() {
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
);
|
||||
final preferred = _sub('source:3', lang: 'eng', codec: 'srt');
|
||||
final preferred = SubtitlePreference.track(_sub('source:3', lang: 'eng', codec: 'srt'));
|
||||
|
||||
expect(service.selectSubtitleTrack(nativeTracks, preferred, null), isNull);
|
||||
|
||||
@@ -878,6 +891,16 @@ void main() {
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
|
||||
test('Jellyfin SubtitleMode.OnlyForced honors a title-only forced subtitle (#1716)', () {
|
||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'eng', title: 'English Forced')];
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
|
||||
test('Jellyfin SubtitleMode.OnlyForced turns off when no forced subtitle exists', () {
|
||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'jpn')];
|
||||
final result = _svc(
|
||||
@@ -1026,6 +1049,189 @@ void main() {
|
||||
expect(findPlexTrackForMpvSubtitle(mpvForcedByName, plexTracks, allMpvTracks: allMpv)?.id, 30);
|
||||
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 31);
|
||||
});
|
||||
|
||||
// Cross-form pairs: one side flags forced in the container, the other only
|
||||
// says it in the title. Effective-forced semantics (#1716) treat both forms
|
||||
// as the same class, so the pair still gets the +2 agreement nudge.
|
||||
test('flag-forced native track maps to a title-only forced row', () {
|
||||
final plexTracks = [
|
||||
_plexSub(50, index: 0, languageCode: 'fre', title: 'FR Forced', codec: 'ass', forced: false),
|
||||
_plexSub(51, index: 1, languageCode: 'fre', codec: 'ass', forced: false),
|
||||
];
|
||||
final mpvForced = _sub('2_0', lang: 'fre', codec: 'ass', isForced: true);
|
||||
final mpvRegular = _sub('2_1', lang: 'fre', codec: 'ass');
|
||||
final allMpv = [mpvForced, mpvRegular];
|
||||
|
||||
expect(findPlexTrackForMpvSubtitle(mpvForced, plexTracks, allMpvTracks: allMpv)?.id, 50);
|
||||
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 51);
|
||||
});
|
||||
|
||||
test('title-only forced native track maps to a flag-forced row', () {
|
||||
final plexTracks = [
|
||||
_plexSub(60, index: 0, languageCode: 'fre', codec: 'ass', forced: true),
|
||||
_plexSub(61, index: 1, languageCode: 'fre', codec: 'ass', forced: false),
|
||||
];
|
||||
final mpvForcedByName = _sub('2_0', lang: 'fre', title: 'FR Forced [ASS]', codec: 'ass');
|
||||
final mpvRegular = _sub('2_1', lang: 'fre', codec: 'ass');
|
||||
final allMpv = [mpvForcedByName, mpvRegular];
|
||||
|
||||
expect(findPlexTrackForMpvSubtitle(mpvForcedByName, plexTracks, allMpvTracks: allMpv)?.id, 60);
|
||||
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 61);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Cross-item intent matching (#1716/#1717): language and effective
|
||||
// forced-ness are hard requirements — the intent's class is preserved or
|
||||
// the match declines so the ladder falls to the server's own selection.
|
||||
// ============================================================
|
||||
|
||||
group('findSourceTrackForIntent', () {
|
||||
const forcedIntent = SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass');
|
||||
const fullIntent = SubtitleIntent(language: 'fre', forced: false, title: 'French', codec: 'srt');
|
||||
|
||||
test('forced intent picks the title-only forced row over the full row', () {
|
||||
final rows = [
|
||||
_plexSub(1, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||
_plexSub(2, languageCode: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||
];
|
||||
expect(findSourceTrackForIntent(forcedIntent, rows)?.id, 2);
|
||||
});
|
||||
|
||||
test('forced intent picks a flag-forced row (cross-form)', () {
|
||||
final rows = [
|
||||
_plexSub(1, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||
_plexSub(2, languageCode: 'fre', codec: 'ass', forced: true),
|
||||
];
|
||||
expect(findSourceTrackForIntent(forcedIntent, rows)?.id, 2);
|
||||
});
|
||||
|
||||
test('forced intent declines when only full same-language rows exist', () {
|
||||
final rows = [
|
||||
_plexSub(1, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||
_plexSub(2, languageCode: 'eng', title: 'English', codec: 'srt'),
|
||||
];
|
||||
expect(findSourceTrackForIntent(forcedIntent, rows), isNull);
|
||||
});
|
||||
|
||||
test('full intent declines when only forced rows share the language', () {
|
||||
final rows = [
|
||||
_plexSub(1, languageCode: 'fre', title: 'FR Forced', codec: 'srt'),
|
||||
_plexSub(2, languageCode: 'fre', codec: 'ass', forced: true),
|
||||
];
|
||||
expect(findSourceTrackForIntent(fullIntent, rows), isNull);
|
||||
});
|
||||
|
||||
test('codec and title break ties between same-class rows', () {
|
||||
final rows = [
|
||||
_plexSub(1, languageCode: 'fre', title: 'Commentary', codec: 'ass'),
|
||||
_plexSub(2, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||
];
|
||||
expect(findSourceTrackForIntent(fullIntent, rows)?.id, 2);
|
||||
});
|
||||
|
||||
test('language-less intents decline', () {
|
||||
const intent = SubtitleIntent(forced: false, title: 'French', codec: 'srt');
|
||||
expect(findSourceTrackForIntent(intent, [_plexSub(1, languageCode: 'fre')]), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findNativeTrackForIntent', () {
|
||||
const forcedIntent = SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass');
|
||||
|
||||
test('forced intent picks the forced native track in either form', () {
|
||||
final byTitle = [
|
||||
_sub('1', lang: 'fre', title: 'French', codec: 'srt'),
|
||||
_sub('2', lang: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||
];
|
||||
final byFlag = [
|
||||
_sub('1', lang: 'fre', title: 'French', codec: 'srt'),
|
||||
_sub('2', lang: 'fre', codec: 'ass', isForced: true),
|
||||
];
|
||||
expect(findNativeTrackForIntent(forcedIntent, byTitle)?.id, '2');
|
||||
expect(findNativeTrackForIntent(forcedIntent, byFlag)?.id, '2');
|
||||
});
|
||||
|
||||
test('declines symmetrically and skips the auto/off sentinels', () {
|
||||
final fullOnly = [SubtitleTrack.auto, SubtitleTrack.off, _sub('1', lang: 'fre', title: 'French', codec: 'srt')];
|
||||
expect(findNativeTrackForIntent(forcedIntent, fullOnly), isNull);
|
||||
|
||||
const fullIntent = SubtitleIntent(language: 'fre', forced: false);
|
||||
final forcedOnly = [_sub('1', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||
expect(findNativeTrackForIntent(fullIntent, forcedOnly), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('selectSubtitleTrack - intent preferences (#1716/#1717)', () {
|
||||
const forcedIntent = SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass'),
|
||||
);
|
||||
|
||||
test('a class-preserving intent match carries navigation priority', () {
|
||||
final tracks = [_sub('1', lang: 'fre', codec: 'ass'), _sub('2', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||
final result = _svc().selectSubtitleTrack(tracks, forcedIntent, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.navigation);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
|
||||
test('a declined forced intent falls to the server-selected full track', () {
|
||||
final tracks = [_sub('1', lang: 'fre', codec: 'ass')];
|
||||
final info = _info(subs: [_plexSub(10, languageCode: 'fre', codec: 'ass', selected: true)]);
|
||||
final result = _svc(info: info).selectSubtitleTrack(tracks, forcedIntent, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.id, '1');
|
||||
});
|
||||
|
||||
test("a declined forced intent honors the server's subtitles-off state", () {
|
||||
// #1717 headline: the next episode has no forced track and no selected
|
||||
// stream — the server's own decision (off) wins over the full track.
|
||||
final tracks = [_sub('1', lang: 'fre', codec: 'ass')];
|
||||
final info = _info(subs: [_plexSub(10, languageCode: 'fre', codec: 'ass')]);
|
||||
final result = _svc(info: info).selectSubtitleTrack(tracks, forcedIntent, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
|
||||
test('a servable intent stays pending until its native track arrives', () {
|
||||
final info = _info(
|
||||
subs: [
|
||||
_plexSub(10, languageCode: 'fre', codec: 'ass'),
|
||||
_plexSub(11, languageCode: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||
],
|
||||
);
|
||||
final service = _svc(info: info);
|
||||
final arrived = [_sub('1', lang: 'fre', codec: 'ass')];
|
||||
|
||||
expect(service.selectSubtitleTrack(arrived, forcedIntent, null), isNull);
|
||||
|
||||
final complete = [...arrived, _sub('2', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||
final resolved = service.selectSubtitleTrack(complete, forcedIntent, null)!;
|
||||
expect(resolved.priority, TrackSelectionPriority.navigation);
|
||||
expect(resolved.track.id, '2');
|
||||
});
|
||||
|
||||
test('a deadline pass resolves a pending intent through the ladder', () {
|
||||
final info = _info(
|
||||
subs: [
|
||||
_plexSub(10, languageCode: 'fre', codec: 'ass', selected: true),
|
||||
_plexSub(11, languageCode: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||
],
|
||||
);
|
||||
final arrived = [_sub('1', lang: 'fre', codec: 'ass', isDefault: true)];
|
||||
final result = _svc(info: info).selectSubtitleTrack(arrived, forcedIntent, null, waitForPendingSource: false)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.id, '1');
|
||||
});
|
||||
|
||||
test('a full intent does not grab a forced-only catalog (symmetric decline)', () {
|
||||
const fullIntent = SubtitlePreference.intent(
|
||||
SubtitleIntent(language: 'fre', forced: false, title: 'French', codec: 'ass'),
|
||||
);
|
||||
final tracks = [_sub('1', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||
final result = _svc().selectSubtitleTrack(tracks, fullIntent, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.off);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
});
|
||||
|
||||
group('container-sidecar ordinal fallback', () {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/utils/subtitle_forced_semantics.dart';
|
||||
|
||||
MediaSubtitleTrack _row({bool forced = false, String? title, String? displayTitle}) =>
|
||||
MediaSubtitleTrack(id: 1, selected: false, forced: forced, title: title, displayTitle: displayTitle);
|
||||
|
||||
void main() {
|
||||
group('MediaSubtitleTrack.effectiveForced', () {
|
||||
test('flag alone qualifies', () {
|
||||
expect(_row(forced: true).effectiveForced, isTrue);
|
||||
});
|
||||
|
||||
test('title alone qualifies', () {
|
||||
expect(_row(title: 'FR Forced').effectiveForced, isTrue);
|
||||
});
|
||||
|
||||
test('displayTitle alone qualifies', () {
|
||||
expect(_row(displayTitle: 'Français (Forced SRT)').effectiveForced, isTrue);
|
||||
});
|
||||
|
||||
test('neither flag nor title qualifies', () {
|
||||
expect(_row(title: 'French', displayTitle: 'Français (SRT)').effectiveForced, isFalse);
|
||||
expect(_row().effectiveForced, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('SubtitleTrack.effectiveForced', () {
|
||||
test('flag alone qualifies', () {
|
||||
expect(const SubtitleTrack(id: '1', isForced: true).effectiveForced, isTrue);
|
||||
});
|
||||
|
||||
test('title alone qualifies', () {
|
||||
expect(const SubtitleTrack(id: '1', title: 'FR Forced [ASS]').effectiveForced, isTrue);
|
||||
});
|
||||
|
||||
test('plain title does not qualify', () {
|
||||
expect(const SubtitleTrack(id: '1', title: 'French').effectiveForced, isFalse);
|
||||
expect(const SubtitleTrack(id: '1').effectiveForced, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,24 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('titleSaysForced', () {
|
||||
test('matches the forced token anywhere in the title', () {
|
||||
expect(titleSaysForced('Forced'), isTrue);
|
||||
expect(titleSaysForced('FORCED'), isTrue);
|
||||
expect(titleSaysForced('FR Forced [ASS]'), isTrue);
|
||||
expect(titleSaysForced('French (Forced)'), isTrue);
|
||||
expect(titleSaysForced('forced.eng'), isTrue);
|
||||
});
|
||||
|
||||
test('requires a whole token, not a substring', () {
|
||||
expect(titleSaysForced('unforced'), isFalse);
|
||||
expect(titleSaysForced('Reinforced'), isFalse);
|
||||
expect(titleSaysForced('French'), isFalse);
|
||||
expect(titleSaysForced(''), isFalse);
|
||||
expect(titleSaysForced(null), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveTrackLanguageDisplay', () {
|
||||
test('resolves 2-letter, 3-letter, and bibliographic codes', () {
|
||||
expect(resolveTrackLanguageDisplay(language: 'en'), 'English');
|
||||
|
||||
Reference in New Issue
Block a user