+130
-32
@@ -244,7 +244,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('ExoPlayer forwards external subtitle metadata at open', () async {
|
||||
test('ExoPlayer forwards complete container metadata and rejects empty sidecar URIs', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
@@ -252,18 +252,14 @@ void main() {
|
||||
eventChannelName: 'com.plezy/exo_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
switch (call.method) {
|
||||
case 'initialize':
|
||||
return Future.value(true);
|
||||
default:
|
||||
return Future.value(null);
|
||||
}
|
||||
return call.method == 'initialize' ? Future.value(true) : Future.value(null);
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerAndroid();
|
||||
try {
|
||||
const containerUri = 'https://example.test/movie.mkv';
|
||||
await player.open(
|
||||
Media('https://example.test/movie.mkv'),
|
||||
Media('https://example.test/transcode.m3u8'),
|
||||
externalSubtitles: const [
|
||||
SubtitleTrack(
|
||||
id: 'external-sub',
|
||||
@@ -275,20 +271,44 @@ void main() {
|
||||
isExternal: true,
|
||||
uri: 'https://example.test/sub.srt',
|
||||
),
|
||||
SubtitleTrack(
|
||||
id: 'container:1',
|
||||
title: 'English Dialogue',
|
||||
language: 'eng',
|
||||
codec: 'ass',
|
||||
isDefault: true,
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: containerUri,
|
||||
),
|
||||
SubtitleTrack(
|
||||
id: 'container:2',
|
||||
title: 'English Signs',
|
||||
language: 'eng',
|
||||
codec: 'ass',
|
||||
isForced: true,
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: containerUri,
|
||||
),
|
||||
SubtitleTrack(id: 'invalid', uri: '', isExternal: true, isContainer: true),
|
||||
],
|
||||
);
|
||||
|
||||
final openCall = calls.singleWhere((call) => call.method == 'open');
|
||||
final args = Map<Object?, Object?>.from(openCall.arguments as Map);
|
||||
final external = args['externalSubtitles'] as List;
|
||||
final subtitle = Map<Object?, Object?>.from(external.single as Map);
|
||||
final external = (args['externalSubtitles'] as List)
|
||||
.map((entry) => Map<Object?, Object?>.from(entry as Map))
|
||||
.toList();
|
||||
|
||||
expect(subtitle['uri'], 'https://example.test/sub.srt');
|
||||
expect(subtitle['title'], 'English Forced');
|
||||
expect(subtitle['language'], 'eng');
|
||||
expect(subtitle['codec'], 'srt');
|
||||
expect(subtitle['isDefault'], isTrue);
|
||||
expect(subtitle['isForced'], isTrue);
|
||||
expect(external, hasLength(3));
|
||||
expect(external.first['uri'], 'https://example.test/sub.srt');
|
||||
expect(external.first['title'], 'English Forced');
|
||||
expect(external.first['isDefault'], isTrue);
|
||||
expect(external.first['isForced'], isTrue);
|
||||
expect(external.skip(1).map((entry) => entry['uri']).toSet(), {containerUri});
|
||||
expect(external.skip(1).map((entry) => entry['title']), ['English Dialogue', 'English Signs']);
|
||||
expect(external.skip(1).every((entry) => entry['isContainer'] == true), isTrue);
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
@@ -612,41 +632,119 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV preserves external subtitle metadata for loadfile sidecars', () async {
|
||||
test('MPV restores per-stream metadata while loading a shared container once', () async {
|
||||
final calls = <MethodCall>[];
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
return call.method == 'initialize' ? Future.value(true) : Future.value(null);
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
const subtitleUri = 'https://example.test/subtitles/en-forced.srt';
|
||||
const subtitleUri = 'https://example.test/movie.mkv?X-Plex-Token=secret';
|
||||
await player.open(
|
||||
Media('https://example.test/movie.mkv'),
|
||||
Media('https://example.test/transcode.m3u8'),
|
||||
externalSubtitles: const [
|
||||
SubtitleTrack(
|
||||
id: 'server-subtitle',
|
||||
id: 'container:1',
|
||||
uri: subtitleUri,
|
||||
title: 'English Forced',
|
||||
title: 'English Dialogue',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
codec: 'ass',
|
||||
isDefault: true,
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
),
|
||||
SubtitleTrack(
|
||||
id: 'container:2',
|
||||
uri: subtitleUri,
|
||||
title: 'English Signs',
|
||||
language: 'eng',
|
||||
codec: 'ass',
|
||||
isForced: true,
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{'type': 'sub', 'id': '1', 'codec': 'subrip', 'external': true, 'external-filename': subtitleUri},
|
||||
expect(_loadfileArgs(calls), [
|
||||
'loadfile',
|
||||
'https://example.test/transcode.m3u8',
|
||||
'replace',
|
||||
'-1',
|
||||
'sub-files=${_fixedLengthPathList([subtitleUri])}',
|
||||
]);
|
||||
|
||||
final subtitle = player.state.tracks.subtitle.single;
|
||||
expect(subtitle.title, 'English Forced');
|
||||
expect(subtitle.language, 'eng');
|
||||
expect(subtitle.codec, 'srt');
|
||||
expect(subtitle.isDefault, isTrue);
|
||||
expect(subtitle.isForced, isTrue);
|
||||
expect(subtitle.uri, subtitleUri);
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{'type': 'audio', 'id': 'sidecar-audio', 'external': true, 'external-filename': subtitleUri},
|
||||
{
|
||||
'type': 'sub',
|
||||
'id': '1',
|
||||
'title': 'movie.mkv?X-Plex-Token=secret',
|
||||
'default': false,
|
||||
'forced': false,
|
||||
'external': true,
|
||||
'external-filename': subtitleUri,
|
||||
},
|
||||
{
|
||||
'type': 'sub',
|
||||
'id': '2',
|
||||
'title': 'movie.mkv?X-Plex-Token=secret',
|
||||
'default': false,
|
||||
'forced': false,
|
||||
'external': true,
|
||||
'external-filename': subtitleUri,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(player.state.tracks.audio, isEmpty);
|
||||
final subtitles = player.state.tracks.subtitle;
|
||||
expect(subtitles, hasLength(2));
|
||||
expect(subtitles.map((track) => track.title), ['English Dialogue', 'English Signs']);
|
||||
expect(subtitles.map((track) => track.language), ['eng', 'eng']);
|
||||
expect(subtitles.map((track) => track.codec), ['ass', 'ass']);
|
||||
expect(subtitles.map((track) => track.isDefault), [true, false]);
|
||||
expect(subtitles.map((track) => track.isForced), [false, true]);
|
||||
expect(subtitles.every((track) => track.uri == subtitleUri && track.isContainer), isTrue);
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV keeps native metadata fallbacks for non-container subtitles', () async {
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) => call.method == 'initialize' ? Future.value(true) : Future.value(null),
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
const sidecarUri = 'https://example.test/subtitle.srt';
|
||||
await player.open(
|
||||
Media('https://example.test/movie.mkv'),
|
||||
externalSubtitles: const [SubtitleTrack(id: 'external', uri: sidecarUri, isExternal: true)],
|
||||
);
|
||||
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{
|
||||
'type': 'sub',
|
||||
'id': 'external',
|
||||
'title': 'English Dialogue - SRT',
|
||||
'lang': 'eng',
|
||||
'codec': 'subrip',
|
||||
'external': true,
|
||||
'external-filename': sidecarUri,
|
||||
},
|
||||
{'type': 'sub', 'id': 'embedded', 'title': 'French Dialogue - ASS', 'lang': 'fre', 'codec': 'ass'},
|
||||
]);
|
||||
|
||||
expect(player.state.tracks.subtitle.map((track) => track.title), ['English Dialogue', 'French Dialogue']);
|
||||
expect(player.state.tracks.subtitle.map((track) => track.language), ['eng', 'fre']);
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../test_helpers/media_items.dart';
|
||||
MediaSubtitleTrack _sourceSubtitle(
|
||||
int id, {
|
||||
String language = 'eng',
|
||||
String codec = 'srt',
|
||||
bool forced = false,
|
||||
bool selected = false,
|
||||
bool external = false,
|
||||
@@ -20,6 +21,7 @@ MediaSubtitleTrack _sourceSubtitle(
|
||||
id: id,
|
||||
language: language,
|
||||
languageCode: language,
|
||||
codec: codec,
|
||||
title: 'Subtitle $id',
|
||||
selected: selected,
|
||||
forced: forced,
|
||||
@@ -28,16 +30,30 @@ MediaSubtitleTrack _sourceSubtitle(
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackSubtitleSidecar _sidecar(int id, {String language = 'eng', bool isDefault = false}) {
|
||||
PlaybackSubtitleSidecar _sidecar(
|
||||
int id, {
|
||||
String language = 'eng',
|
||||
bool isDefault = false,
|
||||
bool preload = false,
|
||||
bool isContainer = false,
|
||||
String? uri,
|
||||
}) {
|
||||
final sidecarUri = uri ?? 'https://example.test/subtitles/$id.srt';
|
||||
return PlaybackSubtitleSidecar(
|
||||
sourceStreamId: id,
|
||||
track: SubtitleTrack.uri(
|
||||
'https://example.test/subtitles/$id.srt',
|
||||
title: 'Subtitle $id',
|
||||
language: language,
|
||||
codec: 'srt',
|
||||
isDefault: isDefault,
|
||||
),
|
||||
preload: preload,
|
||||
track: isContainer
|
||||
? SubtitleTrack(
|
||||
id: 'container:$id',
|
||||
title: 'Subtitle $id',
|
||||
language: language,
|
||||
codec: 'srt',
|
||||
isDefault: isDefault,
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: sidecarUri,
|
||||
)
|
||||
: SubtitleTrack.uri(sidecarUri, title: 'Subtitle $id', language: language, codec: 'srt', isDefault: isDefault),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,17 +62,18 @@ MediaSourceInfo _mediaInfo(List<MediaSubtitleTrack> subtitles) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('direct-play source routing', () {
|
||||
group('source subtitle routing', () {
|
||||
test('matches an embedded source to its loaded native track', () {
|
||||
final source = _sourceSubtitle(2, language: 'eng');
|
||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
||||
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [native],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: false,
|
||||
isContainerSidecar: false,
|
||||
),
|
||||
native,
|
||||
);
|
||||
@@ -81,11 +98,12 @@ void main() {
|
||||
);
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
||||
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [other],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: true,
|
||||
isContainerSidecar: false,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
@@ -117,15 +135,50 @@ void main() {
|
||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
||||
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [native],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: false,
|
||||
isContainerSidecar: false,
|
||||
),
|
||||
native,
|
||||
);
|
||||
});
|
||||
test('matches a requested source among tracks from one container sidecar', () {
|
||||
final sources = [_sourceSubtitle(2, language: 'eng'), _sourceSubtitle(3, language: 'eng')];
|
||||
const nativeTracks = [
|
||||
SubtitleTrack(
|
||||
id: '7',
|
||||
title: 'Subtitle 2',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.test/video.mkv',
|
||||
),
|
||||
SubtitleTrack(
|
||||
id: '8',
|
||||
title: 'Subtitle 3',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.test/video.mkv',
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||
sourceTrack: sources.last,
|
||||
nativeTracks: nativeTracks,
|
||||
allSourceTracks: sources,
|
||||
isResolvedSidecar: true,
|
||||
isContainerSidecar: true,
|
||||
),
|
||||
nativeTracks.last,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
final metadata = testMediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
|
||||
@@ -160,6 +213,24 @@ void main() {
|
||||
expect(result.sidecarsAtOpen, isEmpty);
|
||||
});
|
||||
|
||||
test('retains each source metadata row for a shared preloaded container', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3)]),
|
||||
sidecars: [
|
||||
_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,
|
||||
);
|
||||
|
||||
expect(result.isOff, isTrue);
|
||||
expect(result.sidecarsAtOpen, hasLength(2));
|
||||
expect(result.sidecarsAtOpen.every((track) => track.isContainer), isTrue);
|
||||
expect(result.sidecarsAtOpen.map((track) => track.id), ['container:2', 'container:3']);
|
||||
expect(result.sidecarsAtOpen.map((track) => track.uri).toSet(), {'https://example.test/video.mkv'});
|
||||
});
|
||||
|
||||
test('explicit source selection wins over the server default', () {
|
||||
final mediaInfo = _mediaInfo([
|
||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
||||
@@ -179,6 +250,82 @@ void main() {
|
||||
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt');
|
||||
});
|
||||
|
||||
test('explicit source identity wins when subtitle metadata is identical', () {
|
||||
final mediaInfo = _mediaInfo([
|
||||
MediaSubtitleTrack(
|
||||
id: 2,
|
||||
language: 'eng',
|
||||
languageCode: 'eng',
|
||||
codec: 'ass',
|
||||
title: 'English',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
MediaSubtitleTrack(
|
||||
id: 3,
|
||||
language: 'eng',
|
||||
languageCode: 'eng',
|
||||
codec: 'ass',
|
||||
title: 'English',
|
||||
selected: false,
|
||||
forced: false,
|
||||
),
|
||||
]);
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: mediaInfo,
|
||||
sidecars: [
|
||||
_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),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 3);
|
||||
});
|
||||
|
||||
test('source identity is ignored across media-source changes', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(3, language: 'eng', selected: true, usesExternalDelivery: true),
|
||||
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [
|
||||
_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',
|
||||
),
|
||||
preserveSourceIdentity: false,
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 7);
|
||||
});
|
||||
|
||||
test('unmatched source identity cannot bind a reused id after a source change', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
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',
|
||||
),
|
||||
preserveSourceIdentity: false,
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 7);
|
||||
});
|
||||
|
||||
test('item-change semantic preference selects the matching new sidecar', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
@@ -240,6 +387,17 @@ void main() {
|
||||
expect(result.sidecarsAtOpen, isEmpty);
|
||||
});
|
||||
|
||||
test('selected metadata-free embedded subtitle resolves by source identity', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([MediaSubtitleTrack(id: 2, codec: 'ass', selected: true, forced: false)]),
|
||||
sidecars: const [],
|
||||
);
|
||||
|
||||
expect(result.isOff, isFalse);
|
||||
expect(result.primarySourceStreamId, 2);
|
||||
});
|
||||
|
||||
test('preferred secondary subtitle attaches a second distinct sidecar', () {
|
||||
final mediaInfo = _mediaInfo([
|
||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:plezy/media/media_backend.dart';
|
||||
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
@@ -44,8 +43,11 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
List<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
||||
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return client.buildTranscodeSidecarSubtitlesForTesting(
|
||||
mediaInfoWithSubtitles(subtitleTracks),
|
||||
'https://plex.example.com/video.mkv?X-Plex-Token=token',
|
||||
);
|
||||
}
|
||||
|
||||
test('selectStreams sends audio stream selection with allParts', () async {
|
||||
@@ -129,6 +131,97 @@ void main() {
|
||||
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
||||
});
|
||||
|
||||
test('transcode initialization wires a subtitle-free HLS request to the complete sidecar catalog', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/library/metadata/42') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
'ratingKey': '42',
|
||||
'type': 'movie',
|
||||
'title': 'Movie',
|
||||
'Media': [
|
||||
{
|
||||
'id': 7,
|
||||
'container': 'mkv',
|
||||
'Part': [
|
||||
{
|
||||
'id': 99,
|
||||
'key': '/library/parts/99/file.mkv',
|
||||
'Stream': [
|
||||
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
||||
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true},
|
||||
{
|
||||
'streamType': 3,
|
||||
'id': 401,
|
||||
'index': 1,
|
||||
'codec': 'ass',
|
||||
'languageCode': 'eng',
|
||||
'selected': true,
|
||||
},
|
||||
{
|
||||
'streamType': 3,
|
||||
'id': 402,
|
||||
'index': 2,
|
||||
'codec': 'srt',
|
||||
'languageCode': 'swe',
|
||||
'key': '/library/streams/402',
|
||||
'external': true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('unexpected request', 500);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final result = await client.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
||||
selectedMediaIndex: 0,
|
||||
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
),
|
||||
);
|
||||
|
||||
final decisionRequest = requests.singleWhere(
|
||||
(request) => request.url.path == '/video/:/transcode/universal/decision',
|
||||
);
|
||||
expect(decisionRequest.url.queryParameters['subtitles'], 'none');
|
||||
expect(decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), isFalse);
|
||||
expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
||||
expect(result.isTranscoding, isTrue);
|
||||
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
|
||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
|
||||
expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue);
|
||||
expect(result.subtitleSidecars.first.track.isContainer, isTrue);
|
||||
expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt'));
|
||||
});
|
||||
|
||||
test('playback uses metadata availability flags without probing part URLs', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = makeClient((request) async {
|
||||
@@ -331,16 +424,17 @@ void main() {
|
||||
expect(result.selectedMediaIndex, 1);
|
||||
});
|
||||
|
||||
test('transcode subtitle sidecars only use real Plex stream keys', () {
|
||||
test('transcode subtitle catalog includes embedded and keyed Plex streams', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false),
|
||||
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', title: 'Embedded', selected: true, forced: false),
|
||||
MediaSubtitleTrack(
|
||||
id: 402,
|
||||
codec: 'srt',
|
||||
languageCode: 'eng',
|
||||
languageCode: 'swe',
|
||||
title: 'External',
|
||||
selected: false,
|
||||
forced: false,
|
||||
key: '/library/streams/402',
|
||||
@@ -348,63 +442,50 @@ void main() {
|
||||
),
|
||||
]);
|
||||
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
|
||||
expect(subtitles, hasLength(2));
|
||||
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
|
||||
expect(subtitles.every((sidecar) => sidecar.preload), isTrue);
|
||||
expect(subtitles.first.track.isContainer, isTrue);
|
||||
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv?X-Plex-Token=token');
|
||||
expect(subtitles.last.track.isContainer, isFalse);
|
||||
expect(
|
||||
subtitles.last.track.uri,
|
||||
'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token',
|
||||
);
|
||||
});
|
||||
|
||||
test('transcode negotiation honors an explicit source subtitle preference', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
test('tokenless transcode keeps embedded and keyed subtitle sources', () {
|
||||
final client = testPlexClient(
|
||||
serverId: ServerId('server-id'),
|
||||
token: null,
|
||||
handler: (_) async => http.Response('not used', 500),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
final info = mediaInfoWithSubtitles([
|
||||
MediaSubtitleTrack(id: 401, languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 402, languageCode: 'swe', selected: false, forced: false),
|
||||
]);
|
||||
|
||||
final selected = client.resolveTranscodeSubtitleTrackForTesting(
|
||||
info,
|
||||
const SubtitleTrack(id: 'source:402', language: 'swe'),
|
||||
final subtitles = client.buildTranscodeSidecarSubtitlesForTesting(
|
||||
mediaInfoWithSubtitles([
|
||||
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(
|
||||
id: 402,
|
||||
codec: 'srt',
|
||||
languageCode: 'swe',
|
||||
selected: false,
|
||||
forced: false,
|
||||
key: '/library/streams/402',
|
||||
external: true,
|
||||
),
|
||||
]),
|
||||
'https://plex.example.com/video.mkv',
|
||||
);
|
||||
|
||||
expect(selected?.id, 402);
|
||||
expect(client.resolveTranscodeSubtitleTrackForTesting(info, SubtitleTrack.off), isNull);
|
||||
expect(subtitles, hasLength(2));
|
||||
expect(subtitles.first.track.isContainer, isTrue);
|
||||
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv');
|
||||
expect(subtitles.last.track.isContainer, isFalse);
|
||||
expect(subtitles.last.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8');
|
||||
});
|
||||
|
||||
test('transcode negotiation falls back to the new source default for a stale source id', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
final info = mediaInfoWithSubtitles([
|
||||
MediaSubtitleTrack(id: 501, languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 502, languageCode: 'swe', selected: false, forced: false),
|
||||
]);
|
||||
|
||||
final selected = client.resolveTranscodeSubtitleTrackForTesting(
|
||||
info,
|
||||
const SubtitleTrack(id: 'source:402', language: 'und'),
|
||||
);
|
||||
|
||||
expect(selected?.id, 501);
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are not attached as external sidecars', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'ass',
|
||||
language: 'English',
|
||||
languageCode: 'eng',
|
||||
title: 'Signs/Songs',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are segmented into the HLS transcode', () {
|
||||
test('video transcode stays subtitle-free while preserving the HLS profile', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
@@ -414,19 +495,12 @@ void main() {
|
||||
preset: TranscodeQualityPreset.p720_3mbps,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'ass',
|
||||
languageCode: 'eng',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(params['protocol'], 'hls');
|
||||
expect(params['subtitles'], 'segmented');
|
||||
expect(params['subtitleStreamID'], '401');
|
||||
expect(params['advancedSubtitles'], 'text');
|
||||
expect(params['subtitles'], 'none');
|
||||
expect(params.containsKey('subtitleStreamID'), isFalse);
|
||||
expect(params.containsKey('advancedSubtitles'), isFalse);
|
||||
expect(params.containsKey('X-Plex-Chunked'), isFalse);
|
||||
expect(params['X-Plex-Incomplete-Segments'], '1');
|
||||
expect(params['X-Plex-Client-Profile-Name'], 'Generic');
|
||||
@@ -444,8 +518,7 @@ void main() {
|
||||
profile,
|
||||
contains(
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
|
||||
'&protocol=hls&container=mpegts',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
@@ -495,43 +568,20 @@ void main() {
|
||||
expect(params['partIndex'], '2');
|
||||
});
|
||||
|
||||
test('selected image subtitles are burned into HLS without advancedSubtitles', () {
|
||||
test('image-based embedded subtitles use the shared container sidecar', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildTranscodeParamsForTesting(
|
||||
ratingKey: '42',
|
||||
mediaIndex: 0,
|
||||
preset: TranscodeQualityPreset.p720_3mbps,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'pgs',
|
||||
languageCode: 'eng',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(params['subtitles'], 'burn');
|
||||
expect(params['subtitleStreamID'], '401');
|
||||
expect(params['protocol'], 'hls');
|
||||
expect(params.containsKey('advancedSubtitles'), isFalse);
|
||||
});
|
||||
|
||||
test('image-based embedded subtitles are rendered by HLS, not attached as sidecars', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
// Embedded bitmap streams have no Plex `key`; the HLS request burns the
|
||||
// selected track into the video rendition.
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
|
||||
]);
|
||||
|
||||
expect(subtitles, isEmpty);
|
||||
expect(subtitles, hasLength(2));
|
||||
expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue);
|
||||
expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), {
|
||||
'https://plex.example.com/video.mkv?X-Plex-Token=token',
|
||||
});
|
||||
});
|
||||
|
||||
group('playback metadata failure contract', () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
@@ -7,6 +8,8 @@ import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
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/track_manager.dart';
|
||||
|
||||
@@ -369,6 +372,361 @@ void main() {
|
||||
expect(player.selectedSubtitle, hasLength(1));
|
||||
expect(player.selectedSubtitle.single.id, 'no');
|
||||
});
|
||||
|
||||
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
|
||||
await SettingsService.getInstance();
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
),
|
||||
);
|
||||
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [SubtitleTrack(id: '11', language: 'fre')],
|
||||
),
|
||||
);
|
||||
await _drainAsync();
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [
|
||||
SubtitleTrack(id: '11', language: 'fre'),
|
||||
SubtitleTrack(id: '10', language: 'eng'),
|
||||
],
|
||||
),
|
||||
);
|
||||
await _drainAsync();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
|
||||
});
|
||||
|
||||
test('waits for a preferred subtitle even when the server-selected track arrives first', () async {
|
||||
await SettingsService.getInstance();
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
),
|
||||
);
|
||||
final mgr = _make(
|
||||
player: player,
|
||||
mediaInfo: _mediaInfoWithSubtitles(selected: true),
|
||||
preferredSubtitleTrack: const SubtitleTrack(id: 'previous', language: 'fre'),
|
||||
);
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
|
||||
),
|
||||
);
|
||||
await _drainAsync();
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [
|
||||
SubtitleTrack(id: '10', language: 'eng'),
|
||||
SubtitleTrack(id: '11', language: 'fre'),
|
||||
],
|
||||
),
|
||||
);
|
||||
await _drainAsync();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['11']);
|
||||
});
|
||||
|
||||
test('source identity waits for the intended identical container track', () async {
|
||||
await SettingsService.getInstance();
|
||||
final mediaInfo = MediaSourceInfo(
|
||||
videoUrl: 'https://example.com/transcode.m3u8',
|
||||
partId: 99,
|
||||
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
|
||||
subtitleTracks: [
|
||||
MediaSubtitleTrack(
|
||||
id: 30,
|
||||
index: 0,
|
||||
languageCode: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
selected: false,
|
||||
forced: false,
|
||||
),
|
||||
MediaSubtitleTrack(
|
||||
id: 31,
|
||||
index: 1,
|
||||
languageCode: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
],
|
||||
chapters: const [],
|
||||
);
|
||||
const firstNativeTrack = SubtitleTrack(
|
||||
id: 'native-0',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.com/video.mkv',
|
||||
);
|
||||
const secondNativeTrack = SubtitleTrack(
|
||||
id: 'native-1',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.com/video.mkv',
|
||||
);
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: 'audio', language: 'eng')],
|
||||
subtitle: [firstNativeTrack],
|
||||
),
|
||||
);
|
||||
final mgr = _make(
|
||||
player: player,
|
||||
mediaInfo: mediaInfo,
|
||||
preferredSubtitleTrack: const SubtitleTrack(
|
||||
id: 'source:31',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.com/video.mkv',
|
||||
),
|
||||
);
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
await _drainAsync();
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: 'audio', language: 'eng')],
|
||||
subtitle: [firstNativeTrack, secondNativeTrack],
|
||||
),
|
||||
);
|
||||
await _drainAsync();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['native-1']);
|
||||
});
|
||||
|
||||
test('deferred transcode source choice applies after native discovery without requesting a reload', () async {
|
||||
await SettingsService.getInstance();
|
||||
final sourceTrack = MediaSubtitleTrack(
|
||||
id: 31,
|
||||
index: 0,
|
||||
languageCode: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
selected: false,
|
||||
forced: false,
|
||||
);
|
||||
final mediaInfo = MediaSourceInfo(
|
||||
videoUrl: 'https://example.com/transcode.m3u8',
|
||||
partId: 99,
|
||||
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
|
||||
subtitleTracks: [sourceTrack],
|
||||
chapters: const [],
|
||||
);
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: 'audio', language: 'eng')],
|
||||
),
|
||||
);
|
||||
final mgr = _make(player: player, mediaInfo: mediaInfo);
|
||||
addTearDown(mgr.dispose);
|
||||
SubtitleTrack? persistedTrack;
|
||||
int? persistedSourceStreamId;
|
||||
|
||||
final handledLocally = await deferTranscodeSubtitleSelection(
|
||||
trackManager: mgr,
|
||||
sourceTrack: sourceTrack,
|
||||
sourceSidecar: const PlaybackSubtitleSidecar(
|
||||
sourceStreamId: 31,
|
||||
preload: true,
|
||||
track: SubtitleTrack(
|
||||
id: 'container:31',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.com/video.mkv',
|
||||
),
|
||||
),
|
||||
sourceStreamId: 31,
|
||||
onSubtitleTrackChanged: (track, {sourceStreamId}) async {
|
||||
persistedTrack = track;
|
||||
persistedSourceStreamId = sourceStreamId;
|
||||
},
|
||||
shouldContinue: () => true,
|
||||
);
|
||||
|
||||
expect(handledLocally, isTrue);
|
||||
expect(mgr.preferredSubtitleTrack?.id, 'source:31');
|
||||
expect(persistedTrack?.id, 'source:31');
|
||||
expect(persistedSourceStreamId, 31);
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: 'audio', language: 'eng')],
|
||||
subtitle: [
|
||||
SubtitleTrack(
|
||||
id: 'native-0',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.com/video.mkv',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await _drainAsync();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['native-0']);
|
||||
});
|
||||
|
||||
test('five-second fallback keeps listening and applies a late advertised subtitle', () async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
fakeAsync((async) {
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
),
|
||||
);
|
||||
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(player.selectedAudio, hasLength(1));
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
|
||||
expect(async.nonPeriodicTimerCount, 0);
|
||||
mgr.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('five-second fallback keeps listening through a partial subtitle catalog', () async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
fakeAsync((async) {
|
||||
final mediaInfo = MediaSourceInfo(
|
||||
videoUrl: 'https://example.com/transcode.m3u8',
|
||||
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
|
||||
subtitleTracks: [
|
||||
MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: false, forced: false),
|
||||
MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: true, forced: false),
|
||||
],
|
||||
chapters: const [],
|
||||
);
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
|
||||
),
|
||||
);
|
||||
final mgr = _make(
|
||||
player: player,
|
||||
mediaInfo: mediaInfo,
|
||||
preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'),
|
||||
);
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(player.selectedAudio, hasLength(1));
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [
|
||||
SubtitleTrack(id: '10', language: 'eng'),
|
||||
SubtitleTrack(id: '11', language: 'fre'),
|
||||
],
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['11']);
|
||||
expect(async.nonPeriodicTimerCount, 0);
|
||||
mgr.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('late subtitle arrival queues behind an in-flight fallback selection', () async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
fakeAsync((async) {
|
||||
final audioSelection = Completer<void>();
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
),
|
||||
);
|
||||
var blockFirstAudioSelection = true;
|
||||
player.onSelectAudioTrack = (_) {
|
||||
if (!blockFirstAudioSelection) return Future<void>.value();
|
||||
blockFirstAudioSelection = false;
|
||||
return audioSelection.future;
|
||||
};
|
||||
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
async.flushMicrotasks();
|
||||
expect(player.selectedAudio, hasLength(1));
|
||||
|
||||
player.emitTracks(
|
||||
const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
audioSelection.complete();
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
|
||||
expect(async.nonPeriodicTimerCount, 0);
|
||||
mgr.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('applyTrackSelection ownership', () {
|
||||
|
||||
@@ -99,7 +99,18 @@ SubtitleTrack _sub(
|
||||
String? codec,
|
||||
bool isDefault = false,
|
||||
bool isForced = false,
|
||||
}) => SubtitleTrack(id: id, language: lang, title: title, codec: codec, isDefault: isDefault, isForced: isForced);
|
||||
bool isExternal = false,
|
||||
bool isContainer = false,
|
||||
}) => SubtitleTrack(
|
||||
id: id,
|
||||
language: lang,
|
||||
title: title,
|
||||
codec: codec,
|
||||
isDefault: isDefault,
|
||||
isForced: isForced,
|
||||
isExternal: isExternal,
|
||||
isContainer: isContainer,
|
||||
);
|
||||
|
||||
MediaAudioTrack _plexAudio(
|
||||
int id, {
|
||||
@@ -132,6 +143,8 @@ MediaSubtitleTrack _plexSub(
|
||||
bool selected = false,
|
||||
bool forced = false,
|
||||
String? codec,
|
||||
bool external = false,
|
||||
String? key,
|
||||
}) {
|
||||
return MediaSubtitleTrack(
|
||||
id: id,
|
||||
@@ -142,6 +155,8 @@ MediaSubtitleTrack _plexSub(
|
||||
selected: selected,
|
||||
forced: forced,
|
||||
codec: codec,
|
||||
external: external,
|
||||
key: key,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,14 +453,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 SubtitleTrack(id: 'no'), 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, _sub('99', lang: 'fre'), null)!;
|
||||
expect(result.priority, TrackSelectionPriority.navigation);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -458,11 +473,90 @@ void main() {
|
||||
_plexSub(11, language: 'fre', languageCode: 'fre', selected: true),
|
||||
],
|
||||
);
|
||||
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null);
|
||||
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.language, 'fre');
|
||||
});
|
||||
|
||||
test('partial native catalog stays undetermined until the selected Plex track arrives', () {
|
||||
final info = _info(
|
||||
subs: [
|
||||
_plexSub(10, language: 'eng', selected: true),
|
||||
_plexSub(11, language: 'fre'),
|
||||
],
|
||||
);
|
||||
|
||||
final result = _svc(info: info).selectSubtitleTrack([_sub('2', lang: 'fre')], null, null);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('preferred source waits for its ordinal in a partial identical container catalog', () {
|
||||
final info = _info(
|
||||
subs: [
|
||||
_plexSub(30, index: 0, language: 'eng', title: 'English', codec: 'ass'),
|
||||
_plexSub(31, index: 1, language: 'eng', title: 'English', codec: 'ass', selected: true),
|
||||
],
|
||||
);
|
||||
const preferred = SubtitleTrack(
|
||||
id: 'source:31',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.test/video.mkv',
|
||||
);
|
||||
const first = SubtitleTrack(
|
||||
id: 'native-0',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.test/video.mkv',
|
||||
);
|
||||
const second = SubtitleTrack(
|
||||
id: 'native-1',
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
codec: 'ass',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.test/video.mkv',
|
||||
);
|
||||
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');
|
||||
});
|
||||
|
||||
test('preferred keyed source does not fuzzy-match an early same-language container track', () {
|
||||
final info = _info(
|
||||
subs: [
|
||||
_plexSub(10, language: 'eng', codec: 'srt', external: true, key: '/library/streams/10'),
|
||||
_plexSub(11, language: 'eng', codec: 'srt'),
|
||||
],
|
||||
);
|
||||
const preferred = SubtitleTrack(
|
||||
id: 'source:10',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
uri: 'https://example.test/library/streams/10.srt',
|
||||
);
|
||||
const earlyContainer = SubtitleTrack(
|
||||
id: 'native-0',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
isContainer: true,
|
||||
uri: 'https://example.test/video.mkv',
|
||||
);
|
||||
|
||||
expect(_svc(info: info).selectSubtitleTrack(const [earlyContainer], preferred, null), isNull);
|
||||
});
|
||||
|
||||
test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () {
|
||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||
final info = _info(
|
||||
@@ -475,7 +569,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.language, 'fre');
|
||||
});
|
||||
@@ -489,7 +583,7 @@ void main() {
|
||||
_plexSub(11, language: 'fre'),
|
||||
],
|
||||
);
|
||||
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null);
|
||||
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
@@ -505,7 +599,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -522,7 +616,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.language, 'fre');
|
||||
});
|
||||
@@ -539,7 +633,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
@@ -549,7 +643,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.none),
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
@@ -563,7 +657,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -573,7 +667,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
@@ -583,7 +677,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -593,7 +687,7 @@ void main() {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
|
||||
).selectSubtitleTrack(tracks, null, null);
|
||||
).selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -607,7 +701,7 @@ void main() {
|
||||
defaultSubtitleLanguage: 'eng',
|
||||
subtitleMode: SubtitlePlaybackMode.smart,
|
||||
),
|
||||
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'));
|
||||
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'))!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
@@ -621,30 +715,57 @@ void main() {
|
||||
defaultSubtitleLanguage: 'eng',
|
||||
subtitleMode: SubtitlePlaybackMode.smart,
|
||||
),
|
||||
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'));
|
||||
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'))!;
|
||||
expect(result.priority, TrackSelectionPriority.profile);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
|
||||
test('Priority 3: default-flagged track when no Plex info', () {
|
||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)];
|
||||
final result = _svc().selectSubtitleTrack(tracks, null, null);
|
||||
final result = _svc().selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
||||
expect(result.track.id, '2');
|
||||
});
|
||||
|
||||
test('Priority 4: off when no default and no info', () {
|
||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||
final result = _svc().selectSubtitleTrack(tracks, null, null);
|
||||
final result = _svc().selectSubtitleTrack(tracks, null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.off);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
|
||||
test('Priority 4: off when no available tracks at all', () {
|
||||
final result = _svc().selectSubtitleTrack(const [], null, null);
|
||||
final result = _svc().selectSubtitleTrack(const [], null, null)!;
|
||||
expect(result.priority, TrackSelectionPriority.off);
|
||||
expect(result.track.id, 'no');
|
||||
});
|
||||
|
||||
test('empty native track list remains undetermined when Plex advertises subtitles', () {
|
||||
final info = _info(subs: [_plexSub(10, language: 'eng', selected: true)]);
|
||||
|
||||
final result = _svc(info: info).selectSubtitleTrack(const [], null, null);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('empty native track list is also undetermined when Plex selected no subtitle', () {
|
||||
final info = _info(subs: [_plexSub(10, language: 'eng')]);
|
||||
|
||||
final result = _svc(info: info).selectSubtitleTrack(const [], null, null);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('selected keyed sidecar remains pending when only a same-language container has arrived', () {
|
||||
final info = _info(
|
||||
subs: [_plexSub(10, language: 'eng', selected: true, external: true, key: '/library/streams/10')],
|
||||
);
|
||||
final earlyContainer = _sub('20', lang: 'eng', isExternal: true, isContainer: true);
|
||||
|
||||
final result = _svc(info: info).selectSubtitleTrack([earlyContainer], null, null);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -691,6 +812,22 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('container-sidecar ordinal fallback', () {
|
||||
final plexTracks = [_plexSub(40, index: 0), _plexSub(41, index: 1)];
|
||||
final nativeTracks = [
|
||||
_sub('2_0', isExternal: true, isContainer: true),
|
||||
_sub('2_1', isExternal: true, isContainer: true),
|
||||
];
|
||||
|
||||
test('maps a metadata-free Plex stream to its container track', () {
|
||||
expect(findMpvTrackForPlexSubtitle(plexTracks[1], nativeTracks, allPlexTracks: plexTracks), nativeTracks[1]);
|
||||
});
|
||||
|
||||
test('maps a metadata-free container track back to its Plex stream', () {
|
||||
expect(findPlexTrackForMpvSubtitle(nativeTracks[0], plexTracks, allMpvTracks: nativeTracks)?.id, 40);
|
||||
});
|
||||
});
|
||||
|
||||
group('findPlexTrackForMpvAudio - same-language disambiguation', () {
|
||||
// Two French audio tracks differing only by channel count, titles null.
|
||||
final plexTracks = [
|
||||
|
||||
Reference in New Issue
Block a user