Focus chrome was implemented twice, once in the focusable wrapper and once in the focus builders; both now go through FocusChrome. TvColorPicker's channel row was a copy of TvNumberSpinner and is now that widget in compact density. Also trims unused helpers and fields and simplifies the Jellyfin browse paths.
438 lines
14 KiB
Dart
438 lines
14 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
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/services/playback_initialization_types.dart';
|
|
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
|
|
|
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,
|
|
bool usesExternalDelivery = false,
|
|
}) {
|
|
return MediaSubtitleTrack(
|
|
id: id,
|
|
language: language,
|
|
languageCode: language,
|
|
codec: codec,
|
|
title: 'Subtitle $id',
|
|
selected: selected,
|
|
forced: forced,
|
|
external: external,
|
|
usesExternalDelivery: usesExternalDelivery,
|
|
);
|
|
}
|
|
|
|
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,
|
|
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),
|
|
);
|
|
}
|
|
|
|
MediaSourceInfo _mediaInfo(List<MediaSubtitleTrack> subtitles) {
|
|
return MediaSourceInfo(videoUrl: '', audioTracks: const [], subtitleTracks: subtitles, chapters: const []);
|
|
}
|
|
|
|
void main() {
|
|
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.nativeTrackForSource(
|
|
sourceTrack: source,
|
|
nativeTracks: const [native],
|
|
allSourceTracks: [source],
|
|
isResolvedSidecar: false,
|
|
isContainerSidecar: false,
|
|
),
|
|
native,
|
|
);
|
|
});
|
|
|
|
test('does not fuzzy-match a different loaded external sidecar', () {
|
|
final source = MediaSubtitleTrack(
|
|
id: 2,
|
|
codec: 'srt',
|
|
languageCode: 'eng',
|
|
key: '/library/streams/2',
|
|
external: true,
|
|
selected: false,
|
|
forced: false,
|
|
);
|
|
const other = SubtitleTrack(
|
|
id: '9',
|
|
language: 'eng',
|
|
codec: 'srt',
|
|
isExternal: true,
|
|
uri: 'https://server/library/streams/9.srt',
|
|
);
|
|
|
|
expect(
|
|
PlaybackSubtitleResolver.nativeTrackForSource(
|
|
sourceTrack: source,
|
|
nativeTracks: const [other],
|
|
allSourceTracks: [source],
|
|
isResolvedSidecar: true,
|
|
isContainerSidecar: false,
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('cycles through typed off and every authoritative source id, including zero', () {
|
|
final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)];
|
|
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 1),
|
|
const PlaybackSourceSubtitleChoice.source(0),
|
|
);
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0), 1),
|
|
const PlaybackSourceSubtitleChoice.source(2),
|
|
);
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2), 1),
|
|
const PlaybackSourceSubtitleChoice.off(),
|
|
);
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 4),
|
|
const PlaybackSourceSubtitleChoice.source(0),
|
|
);
|
|
});
|
|
|
|
test('fuzzy-matches Jellyfin external-delivery rows that remain embedded in direct play', () {
|
|
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
|
|
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
|
|
|
expect(
|
|
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);
|
|
|
|
test('attaches only the server-selected sidecar from the full catalog', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
|
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(2),
|
|
_sidecar(3, language: 'swe'),
|
|
],
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 2);
|
|
expect(result.sidecarsAtOpen, hasLength(1));
|
|
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
|
|
});
|
|
|
|
test('explicit off produces an open with zero sidecars', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
|
|
sidecars: [_sidecar(2)],
|
|
preferredSubtitleTrack: SubtitleTrack.off,
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
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),
|
|
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
|
]);
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: mediaInfo,
|
|
sidecars: [
|
|
_sidecar(2),
|
|
_sidecar(3, language: 'swe'),
|
|
],
|
|
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 3);
|
|
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,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'eng', usesExternalDelivery: true),
|
|
_sourceSubtitle(9, language: 'fra', usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(7),
|
|
_sidecar(9, language: 'fra'),
|
|
],
|
|
preferredSubtitleTrack: const SubtitleTrack(
|
|
id: 'navigation',
|
|
title: 'English from the previous episode',
|
|
language: 'eng',
|
|
codec: 'srt',
|
|
isExternal: true,
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 7);
|
|
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/7.srt');
|
|
expect(result.sidecarsAtOpen, hasLength(1));
|
|
});
|
|
|
|
test('item-change semantic preference distinguishes forced and full subtitles in one language', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'eng', usesExternalDelivery: true),
|
|
_sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [_sidecar(7), _sidecar(8)],
|
|
preferredSubtitleTrack: const SubtitleTrack(
|
|
id: 'navigation',
|
|
title: 'English forced from the previous episode',
|
|
language: 'eng',
|
|
codec: 'srt',
|
|
isForced: true,
|
|
isExternal: true,
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 8);
|
|
expect(result.primaryTrack.isForced, isTrue);
|
|
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt');
|
|
});
|
|
|
|
test('selected embedded subtitle keeps sidecars out of the open', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true)]),
|
|
sidecars: const [],
|
|
);
|
|
|
|
expect(result.isOff, isFalse);
|
|
expect(result.primarySourceStreamId, 2);
|
|
expect(result.primarySidecar, isNull);
|
|
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),
|
|
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
|
]);
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: mediaInfo,
|
|
sidecars: [
|
|
_sidecar(2),
|
|
_sidecar(3, language: 'swe'),
|
|
],
|
|
preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 2);
|
|
expect(result.secondarySourceStreamId, 3);
|
|
expect(result.sidecarsAtOpen, hasLength(2));
|
|
});
|
|
|
|
test('legacy sidecar without source metadata still follows default selection', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: null,
|
|
sidecars: [
|
|
PlaybackSubtitleSidecar(
|
|
sourceStreamId: null,
|
|
track: SubtitleTrack.uri('file:///tmp/subtitle.srt', title: 'Local', isDefault: true),
|
|
),
|
|
],
|
|
);
|
|
|
|
expect(result.isOff, isFalse);
|
|
expect(result.primarySourceStreamId, isNull);
|
|
expect(result.sidecarsAtOpen.single.uri, 'file:///tmp/subtitle.srt');
|
|
});
|
|
}
|