Files
plezy/test/mpv/player_open_test.dart
T
edde746 baa31742cb fix(player): bind live TV clock generations to the source the load reports
Live-TV clock generations were matched to mpv sources first-in-first-out: every
start-file popped the oldest registered open, on the assumption of exactly one
start-file per open in dispatch order. Opens without a generation on the same
player, an Android loadfile rejected silently by nativeCommand, and the
independent delivery of the command ack and the start-file event all broke
that, so a seek that reopened the stream could calibrate against the wrong
source.

The loadfile reply now carries mpv's playlist_entry_id on Android, Apple, Linux
and Windows, PlayerNative.open resolves with it, and the live session binds
each generation to that id explicitly. Source events that land before the
reply are buffered per id and replayed on binding; a rejected or unreachable
load fails its generation instead of leaving a phantom; opens with no
generation are invisible to clock binding. Android now reports a rejected mpv
command as COMMAND_FAILED like the other cores.
2026-09-06 13:29:37 +02:00

1288 lines
48 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
group('player open', () {
test('ExoPlayer clears stale Dart track state before opening new media', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
testBody: () async {
final player = PlayerAndroid();
try {
_seedTracks(player);
expect(player.state.tracks.audio, isNotEmpty);
expect(player.state.track.audio, isNotNull);
await player.open(Media('https://example.test/next.mkv'));
expect(player.state.tracks.audio, isEmpty);
expect(player.state.tracks.subtitle, isEmpty);
expect(player.state.track.audio, isNull);
expect(player.state.track.subtitle, isNull);
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer clears stale timeline state before dispatching a new open', () async {
late PlayerAndroid player;
Duration? positionAtNativeOpen;
Duration? durationAtNativeOpen;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
switch (call.method) {
case 'initialize':
return Future.value(true);
case 'open':
positionAtNativeOpen = player.state.position;
durationAtNativeOpen = player.state.duration;
return Future.value(null);
default:
return Future.value(null);
}
},
testBody: () async {
player = PlayerAndroid();
try {
player.handlePropertyChange('time-pos', 188.0);
player.handlePropertyChange('duration', 439.968);
await player.open(Media('https://example.test/next.mkv'));
expect(positionAtNativeOpen, Duration.zero);
expect(durationAtNativeOpen, Duration.zero);
player.handlePropertyChange('duration', 401.0);
expect(player.state.duration, const Duration(seconds: 401));
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer restores the previous timeline when native open is rejected', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
if (call.method == 'initialize') return Future.value(true);
if (call.method == 'open') {
throw PlatformException(code: 'OPEN_FAILED', message: 'rejected');
}
return Future.value(null);
},
testBody: () async {
final player = _TestPlayerAndroid();
try {
_seedTracks(player);
player.seedExternalSubtitleMetadata(const [
SubtitleTrack(
id: 'old-external',
title: 'Old sidecar',
language: 'eng',
codec: 'srt',
isDefault: false,
isForced: true,
isExternal: true,
uri: 'https://example.test/old.srt',
),
]);
player.handlePropertyChange('time-pos', 188.0);
player.handlePropertyChange('duration', 439.968);
await expectLater(
player.open(
Media('https://example.test/rejected.mkv', start: const Duration(seconds: 12)),
timelineDuration: const Duration(seconds: 401),
externalSubtitles: const [
SubtitleTrack(
id: 'rejected-external',
title: 'Rejected sidecar',
language: 'spa',
codec: 'ass',
isDefault: false,
isForced: false,
isExternal: true,
uri: 'https://example.test/rejected.ass',
),
],
),
throwsA(isA<PlatformException>()),
);
expect(player.state.position, const Duration(seconds: 188));
expect(player.state.duration, const Duration(milliseconds: 439968));
expect(player.state.tracks.audio.single.title, 'English');
expect(player.state.tracks.subtitle.single.title, 'English');
player.handlePropertyChange('track-list', const [
{
'type': 'sub',
'id': 'restored-external',
'external': true,
'external-filename': 'https://example.test/old.srt',
'selected': true,
},
]);
expect(player.state.tracks.subtitle.single.title, 'Old sidecar');
expect(player.state.tracks.subtitle.single.isForced, isTrue);
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer applies audio settings queued before initialization', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'requestAudioFocus') return true;
return null;
},
testBody: () async {
final player = PlayerAndroid();
try {
await player.setAudioNormalization(true);
await player.setAudioDownmix(enabled: true, centerBoostDb: 4, normalize: false);
expect(calls.where((call) => call.method == 'setAudioNormalization'), isEmpty);
expect(calls.where((call) => call.method == 'setAudioDownmix'), isEmpty);
expect(await player.requestAudioFocus(), isTrue);
final normalization = calls.singleWhere((call) => call.method == 'setAudioNormalization');
expect((normalization.arguments as Map)['enabled'], isTrue);
final downmix = calls.singleWhere((call) => call.method == 'setAudioDownmix');
expect(downmix.arguments, {'enabled': true, 'centerBoostDb': 4, 'normalize': false});
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer leaves the mpv passthrough codec list to the native fallback', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'requestAudioFocus') return true;
return null;
},
testBody: () async {
final player = PlayerAndroid();
try {
expect(await player.requestAudioFocus(), isTrue);
await player.setAudioPassthrough(true);
final passthrough = calls.singleWhere((call) => call.method == 'setAudioPassthrough');
expect((passthrough.arguments as Map)['enabled'], isTrue);
// mpv force-passthroughs audio-spdif with no decode fallback, so the
// plugin derives it from the audio route instead (#1703).
expect(
calls.where(
(call) => call.method == 'setMpvProperty' && (call.arguments as Map)['name'] == 'audio-spdif',
),
isEmpty,
);
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer retries initialization after a recoverable native failure', () async {
var initializeAttempts = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return ++initializeAttempts > 1;
if (call.method == 'requestAudioFocus') return true;
return null;
},
testBody: () async {
final player = PlayerAndroid();
try {
await expectLater(player.requestAudioFocus(), throwsA(isA<Exception>()));
expect(await player.requestAudioFocus(), isTrue);
expect(initializeAttempts, 2);
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer initialization cannot commit after disposal starts', () async {
final initialize = Completer<bool>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
calls.add(call);
if (call.method == 'initialize') return initialize.future;
return Future.value(null);
},
testBody: () async {
final player = PlayerAndroid();
final initialization = player.requestAudioFocus();
final initializationFailure = expectLater(initialization, throwsA(isA<StateError>()));
await Future<void>.delayed(Duration.zero);
final disposal = player.dispose();
initialize.complete(true);
await initializationFailure;
await disposal;
expect(calls.where((call) => call.method == 'observeProperty'), isEmpty);
expect(calls.where((call) => call.method == 'requestAudioFocus'), isEmpty);
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
},
);
});
test('ExoPlayer forwards complete container metadata and rejects empty sidecar URIs', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
calls.add(call);
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/transcode.m3u8'),
externalSubtitles: const [
SubtitleTrack(
id: 'external-sub',
title: 'English Forced',
language: 'eng',
codec: 'srt',
isDefault: true,
isForced: true,
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)
.map((entry) => Map<Object?, Object?>.from(entry as Map))
.toList();
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();
}
},
);
});
test('ExoPlayer backend switch clears stale tracks before fallback tracks arrive', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
testBody: () async {
final player = PlayerAndroid();
try {
_seedTracks(player);
expect(player.state.tracks.audio, isNotEmpty);
expect(player.needsDecoderRefreshAfterDisplaySwitch, isFalse);
player.handlePlayerEvent('backend-switched', null);
expect(player.needsDecoderRefreshAfterDisplaySwitch, isTrue);
expect(player.state.tracks.audio, isEmpty);
expect(player.state.tracks.subtitle, isEmpty);
player.handlePropertyChange('track-list', const [
{'type': 'audio', 'id': '1', 'title': 'Fallback Audio', 'lang': 'eng'},
{'type': 'sub', 'id': '2', 'title': 'Fallback Subtitle', 'lang': 'eng'},
]);
expect(player.state.tracks.audio.single.id, '1');
expect(player.state.tracks.subtitle.single.id, '2');
player.handlePlayerEvent('backend-switched', null);
expect(player.state.tracks.audio.single.id, '1');
expect(player.state.tracks.subtitle.single.id, '2');
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer applies DV conversion mode changed during in-flight initialization', () async {
final initialize = Completer<bool>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return initialize.future;
case 'requestAudioFocus':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerAndroid();
try {
final focusFuture = player.requestAudioFocus();
await Future<void>.delayed(Duration.zero);
final modeFuture = player.setProperty('dv-conversion-mode', 'hevc_strip');
await Future<void>.delayed(Duration.zero);
final initCall = calls.singleWhere((call) => call.method == 'initialize');
final initArgs = Map<Object?, Object?>.from(initCall.arguments as Map);
expect(initArgs['dvConversionMode'], 'auto');
expect(calls.where((call) => call.method == 'setDvConversionMode'), isEmpty);
initialize.complete(true);
await modeFuture;
await focusFuture;
final dvCall = calls.singleWhere((call) => call.method == 'setDvConversionMode');
final dvArgs = Map<Object?, Object?>.from(dvCall.arguments as Map);
expect(dvArgs['mode'], 'hevc_strip');
} finally {
if (!initialize.isCompleted) initialize.complete(true);
await player.dispose();
}
},
);
});
test('ExoPlayer opens HLS transcodes at native timeline positions', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
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);
}
},
testBody: () async {
final player = PlayerAndroid();
try {
const timelineStart = Duration(seconds: 2058); // 34:18
const timelineDuration = Duration(seconds: 2903); // 48:23
await player.open(
Media('https://example.test/start.m3u8', start: timelineStart),
timelineDuration: timelineDuration,
);
expect(player.state.position, timelineStart);
expect(player.state.duration, timelineDuration);
final openCall = calls.singleWhere((call) => call.method == 'open');
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
expect(openArgs['startPositionMs'], timelineStart.inMilliseconds);
expect(openArgs['hasStartPosition'], isTrue);
await Future<void>.delayed(const Duration(milliseconds: 260));
player.handlePropertyChange('time-pos', 2058.0);
expect(player.state.position, timelineStart);
await player.seek(const Duration(minutes: 40));
final seekCall = calls.lastWhere((call) => call.method == 'seek');
final seekArgs = Map<Object?, Object?>.from(seekCall.arguments as Map);
expect(seekArgs['positionMs'], const Duration(minutes: 40).inMilliseconds);
expect(player.state.position, const Duration(minutes: 40));
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer HLS open keeps the requested position after stale native zero position', () async {
final calls = <MethodCall>[];
late PlayerAndroid player;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
case 'open':
player.handlePropertyChange('time-pos', 0.0);
player.handlePropertyChange('duration', 0.0);
player.handlePropertyChange('demuxer-cache-time', 0.0);
return Future.value(null);
default:
return Future.value(null);
}
},
testBody: () async {
player = PlayerAndroid();
try {
const timelineStart = Duration(seconds: 2058);
const timelineDuration = Duration(seconds: 2903);
await player.open(
Media('https://example.test/start.m3u8', start: timelineStart),
timelineDuration: timelineDuration,
);
expect(player.state.position, timelineStart);
expect(player.state.duration, timelineDuration);
final openCall = calls.singleWhere((call) => call.method == 'open');
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
expect(openArgs['startPositionMs'], timelineStart.inMilliseconds);
expect(openArgs['hasStartPosition'], isTrue);
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer marks explicit non-zero media starts for native fallback', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
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);
}
},
testBody: () async {
final player = PlayerAndroid();
try {
await player.open(Media('https://example.test/movie.mkv', start: const Duration(seconds: 12)));
final openCall = calls.singleWhere((call) => call.method == 'open');
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
expect(openArgs['startPositionMs'], 12000);
expect(openArgs['hasStartPosition'], isTrue);
} finally {
await player.dispose();
}
},
);
});
test('MPV clears stale Dart track state before opening new media', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
_seedTracks(player);
expect(player.state.tracks.audio, isNotEmpty);
expect(player.state.track.audio, isNotNull);
await player.open(Media('https://example.test/next.mkv'));
expect(player.state.tracks.audio, isEmpty);
expect(player.state.tracks.subtitle, isEmpty);
expect(player.state.track.audio, isNull);
expect(player.state.track.subtitle, isNull);
} finally {
await player.dispose();
}
},
);
});
test('MPV disables subtitles before loading media', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
await player.open(Media('https://example.test/next.mkv'));
final sidIndex = _setPropertyCallIndex(calls, 'sid');
final secondarySidIndex = _setPropertyCallIndex(calls, 'secondary-sid');
final loadIndex = _loadfileCallIndex(calls);
expect(sidIndex, greaterThanOrEqualTo(0));
expect(secondarySidIndex, greaterThanOrEqualTo(0));
expect(loadIndex, greaterThanOrEqualTo(0));
expect(sidIndex, lessThan(loadIndex));
expect(secondarySidIndex, lessThan(loadIndex));
expect(_setPropertyValue(calls[sidIndex]), 'no');
expect(_setPropertyValue(calls[secondarySidIndex]), 'no');
} finally {
await player.dispose();
}
},
);
});
test('MPV passes external subtitles through loadfile options', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
expect(player.attachesExternalSubtitlesAtOpen, isTrue);
const english = 'https://example.test/library/parts/1/subtitle.srt?token=a,b:c';
const french = 'https://example.test/subtitles/fr forced.ass';
await player.open(
Media('https://example.test/movie.mkv'),
externalSubtitles: const [
SubtitleTrack(id: 'external-en', uri: english, title: 'English', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 'external-fr', uri: french, title: 'French Forced', language: 'fra', codec: 'ass'),
],
);
expect(_loadfileArgs(calls), [
'loadfile',
'https://example.test/movie.mkv',
'replace',
'-1',
'sub-files=${_fixedLengthPathList([english, french])}',
]);
expect(_commandCalls(calls, 'sub-add'), isEmpty);
} finally {
await player.dispose();
}
},
);
});
test('MPV rebuilds HTTP headers with clr first, appends in map order, all before loadfile', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
final headers = <String, String>{
'X-Plex-Token': 'secret',
'X-Plex-Device': 'Mac17,9',
'X-Plex-Platform': 'macOS',
'User-Agent': 'Plezy',
};
await player.open(Media('https://example.test/movie.mkv', headers: headers));
final headerCommandIndices = <int>[];
for (var i = 0; i < calls.length; i++) {
final call = calls[i];
if (call.method != 'command') continue;
final args = Map<Object?, Object?>.from(call.arguments as Map)['args'] as List;
if (args.isNotEmpty && args.first == 'change-list') headerCommandIndices.add(i);
}
expect(headerCommandIndices, hasLength(1 + headers.length));
List commandArgs(int index) => Map<Object?, Object?>.from(calls[index].arguments as Map)['args'] as List;
// clr is dispatched before any append: the commands are pipelined
// without intermediate awaits, so channel-FIFO order is the only
// thing keeping a previous open's headers from leaking through.
expect(commandArgs(headerCommandIndices.first), ['change-list', 'http-header-fields', 'clr', '']);
// Appends arrive in header-map order.
expect(
headerCommandIndices.skip(1).map(commandArgs).toList(),
headers.entries
.map((entry) => ['change-list', 'http-header-fields', 'append', '${entry.key}: ${entry.value}'])
.toList(),
);
// Every header command lands before loadfile.
final loadIndex = _loadfileCallIndex(calls);
expect(loadIndex, greaterThanOrEqualTo(0));
expect(headerCommandIndices.last, lessThan(loadIndex));
} finally {
await player.dispose();
}
},
);
});
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/movie.mkv?X-Plex-Token=secret';
await player.open(
Media('https://example.test/transcode.m3u8'),
externalSubtitles: const [
SubtitleTrack(
id: 'container:1',
uri: subtitleUri,
title: 'English Dialogue',
language: 'eng',
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,
),
],
);
expect(_loadfileArgs(calls), [
'loadfile',
'https://example.test/transcode.m3u8',
'replace',
'-1',
'sub-files=${_fixedLengthPathList([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();
}
},
);
});
test('MPV open(play: true) unpauses after loadfile even when previously paused', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
// Simulate the in-place reload: the old file is paused before the
// replacement opens. mpv's pause property survives loadfile.
await player.pause();
await player.open(Media('https://example.test/next.mkv'));
final loadIndex = _loadfileCallIndex(calls);
final unpauseIndex = _setPropertyValueIndex(calls, 'pause', 'no');
expect(loadIndex, greaterThanOrEqualTo(0));
expect(unpauseIndex, greaterThan(loadIndex), reason: 'open(play: true) must clear pause after loadfile');
} finally {
await player.dispose();
}
},
);
});
test('MPV open(play: false) opens paused and never unpauses', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
await player.open(Media('https://example.test/next.mkv'), play: false);
final loadIndex = _loadfileCallIndex(calls);
final pauseIndex = _setPropertyCallIndex(calls, 'pause');
final unpauseIndex = _setPropertyValueIndex(calls, 'pause', 'no');
expect(pauseIndex, greaterThanOrEqualTo(0));
expect(pauseIndex, lessThan(loadIndex));
expect(_setPropertyValue(calls[pauseIndex]), 'yes');
expect(unpauseIndex, -1, reason: 'a paused open must stay paused');
} finally {
await player.dispose();
}
},
);
});
test('MPV exposes file-loaded events through PlayerStreams', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
final fileLoaded = expectLater(player.streams.fileLoaded, emits(isNull));
player.handlePlayerEvent('file-loaded', null);
await fileLoaded;
} finally {
await player.dispose();
}
},
);
});
test('MPV exposes load-scoped start and terminal failure events', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
final fileStarted = expectLater(player.streams.fileStarted, emits(isNull));
player.handlePlayerEvent('start-file', null);
await fileStarted;
final fileLoadFailed = expectLater(player.streams.fileLoadFailed, emits(isNull));
player.handlePlayerEvent('end-file', {'reason': 'error'});
await fileLoadFailed;
} finally {
await player.dispose();
}
},
);
});
test('MPV open resolves with the playlist entry id the loadfile reply names', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
switch (call.method) {
case 'initialize':
return Future.value(true);
case 'command':
final args = Map<Object?, Object?>.from(call.arguments as Map)['args'] as List;
return Future.value(args.first == 'loadfile' ? {'playlistEntryId': 17} : null);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
expect(await player.open(Media('https://example.test/live.m3u8'), isLive: true), 17);
} finally {
await player.dispose();
}
},
);
});
test('MPV open resolves null when the core does not name the source', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
expect(await player.open(Media('https://example.test/live.m3u8'), isLive: true), isNull);
} finally {
await player.dispose();
}
},
);
});
test('MPV source readiness carries the first rendered non-zero clock position once', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
final started = <PlayerSourceStarted>[];
final ready = <PlayerSourceReady>[];
final startedSubscription = player.streams.sourceStarted.listen(started.add);
final readySubscription = player.streams.sourceReady.listen(ready.add);
try {
player.handlePlayerEvent('start-file', {'sourceId': 17});
player.handlePlayerEvent('playback-restart', {'sourceId': 17, 'positionSeconds': 47.25});
player.handlePlayerEvent('playback-restart', {'sourceId': 17, 'positionSeconds': 52.0});
await Future<void>.delayed(Duration.zero);
expect(started.single.sourceId, 17);
expect(ready, hasLength(1));
expect(ready.single.sourceId, 17);
expect(ready.single.position, const Duration(milliseconds: 47250));
expect(player.currentPosition, const Duration(milliseconds: 47250));
} finally {
await startedSubscription.cancel();
await readySubscription.cancel();
await player.dispose();
}
},
);
});
test('MPV ignores delayed positions from a replaced source', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
player.handlePlayerEvent('start-file', {'sourceId': 17});
player.handlePropertyChange('time-pos', 47.0, sourceId: 17);
expect(player.currentPosition, const Duration(seconds: 47));
player.handlePlayerEvent('start-file', {'sourceId': 18});
player.handlePropertyChange('time-pos', 99.0, sourceId: 17);
expect(player.currentPosition, const Duration(seconds: 47));
player.handlePropertyChange('time-pos', 5.0, sourceId: 18);
expect(player.currentPosition, const Duration(seconds: 5));
} finally {
await player.dispose();
}
},
);
});
test('MPV exposes primary media readiness before external subtitles finish', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
var emissionCount = 0;
final subscription = player.streams.primaryMediaReady.listen((_) => emissionCount++);
try {
player.handlePlayerEvent('start-file', null);
player.handlePropertyChange('track-list', const [
{'type': 'sub', 'id': '2', 'external': true},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 0);
player.handlePropertyChange('track-list', const [
{'type': 'video', 'id': '4', 'external': false, 'image': true, 'albumart': true},
{'type': 'sub', 'id': '2', 'external': true},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 0);
player.handlePropertyChange('track-list', const [
{'type': 'video', 'id': '1', 'external': false},
{'type': 'sub', 'id': '2', 'external': true},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 1);
player.handlePropertyChange('track-list', const [
{'type': 'video', 'id': '1', 'external': false},
{'type': 'audio', 'id': '3', 'external': false},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 1);
} finally {
await subscription.cancel();
await player.dispose();
}
},
);
});
test('MPV opens HLS transcodes at native timeline positions', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
await player.open(
Media('https://example.test/start.m3u8', start: const Duration(seconds: 10)),
timelineDuration: const Duration(seconds: 100),
);
expect(player.state.position, const Duration(seconds: 10));
expect(player.state.duration, const Duration(seconds: 100));
player.handlePropertyChange('duration', 90.0);
expect(player.state.duration, const Duration(seconds: 100));
await player.seek(const Duration(seconds: 25));
final seekCall = calls.lastWhere((call) => call.method == 'command');
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
expect(args, ['seek', '25.0', 'absolute']);
expect(player.state.position, const Duration(seconds: 25));
} finally {
await player.dispose();
}
},
);
});
test('MPV HLS refresh seek preserves the requested position', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
const timelineStart = Duration(milliseconds: 143894);
await player.open(
Media('https://example.test/start.m3u8', start: timelineStart),
timelineDuration: const Duration(seconds: 1502),
);
expect(player.state.position, timelineStart);
await player.seek(timelineStart);
final seekCall = calls.lastWhere((call) => call.method == 'command');
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
expect(args, ['seek', '143.894', 'absolute']);
expect(player.state.position, timelineStart);
} finally {
await player.dispose();
}
},
);
});
test('MPV forwards preserve display mode flag on dispose', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
await player.dispose(preserveDisplayMode: true);
final disposeCall = calls.singleWhere((call) => call.method == 'dispose');
final args = Map<Object?, Object?>.from(disposeCall.arguments as Map);
expect(args['preserveDisplayMode'], isTrue);
},
);
});
test('dispose continues when native event stream cancellation is already detached', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return Future.value(null);
},
eventHandler: (call) {
if (call.method == 'cancel') {
throw PlatformException(code: 'error', message: 'No active stream to cancel');
}
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
final playingDone = expectLater(player.streams.playing, emitsDone);
await expectLater(player.dispose(), completes);
await playingDone;
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
},
);
});
});
}
class _TestPlayerAndroid extends PlayerAndroid {
void seedExternalSubtitleMetadata(List<SubtitleTrack> subtitles) {
setExternalSubtitleMetadata(subtitles);
}
}
void _seedTracks(dynamic player) {
player.handlePropertyChange('track-list', const [
{'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true},
{'type': 'sub', 'id': '3_0', 'title': 'English', 'lang': 'eng', 'selected': true},
]);
}
int _setPropertyCallIndex(List<MethodCall> calls, String name) {
return calls.indexWhere((call) => call.method == 'setProperty' && _setPropertyName(call) == name);
}
int _setPropertyValueIndex(List<MethodCall> calls, String name, String value) {
return calls.indexWhere(
(call) => call.method == 'setProperty' && _setPropertyName(call) == name && _setPropertyValue(call) == value,
);
}
String? _setPropertyName(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['name'] as String?;
String? _setPropertyValue(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['value'] as String?;
int _loadfileCallIndex(List<MethodCall> calls) {
return calls.indexWhere((call) {
if (call.method != 'command') return false;
final args = Map<Object?, Object?>.from(call.arguments as Map)['args'] as List;
return args.isNotEmpty && args.first == 'loadfile';
});
}
List _loadfileArgs(List<MethodCall> calls) {
final loadIndex = _loadfileCallIndex(calls);
expect(loadIndex, greaterThanOrEqualTo(0));
return Map<Object?, Object?>.from(calls[loadIndex].arguments as Map)['args'] as List;
}
Iterable<MethodCall> _commandCalls(List<MethodCall> calls, String command) {
return calls.where((call) {
if (call.method != 'command') return false;
final args = Map<Object?, Object?>.from(call.arguments as Map)['args'] as List;
return args.isNotEmpty && args.first == command;
});
}
String _fixedLengthPathList(List<String> values) {
final separator = Platform.isWindows ? ';' : ':';
final escaped = values.map((value) => _escapePathListEntry(value, separator)).join(separator);
return '%${utf8.encode(escaped).length}%$escaped';
}
String _escapePathListEntry(String value, String separator) {
return value.replaceAll(r'\', r'\\').replaceAll(separator, '\\$separator');
}