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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user