fix(native): bound cross-platform lifecycle ownership

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 09656fa4d3
commit 9f2e050797
98 changed files with 12908 additions and 1599 deletions
+26
View File
@@ -50,4 +50,30 @@ void main() {
expect(MpvNodeDecoder.decodeMap(testCase.input), testCase.map);
});
}
test('rejects hostile structured payloads before traversal', () {
Object? acceptedDepth = 'leaf';
for (var i = 0; i < 31; i++) {
acceptedDepth = [acceptedDepth];
}
expect(MpvNodeDecoder.decodeList(acceptedDepth), isNotNull);
Object? excessiveDepth = acceptedDepth;
excessiveDepth = [excessiveDepth];
expect(MpvNodeDecoder.decodeList(excessiveDepth), isNull);
expect(MpvNodeDecoder.decodeList(List<Object?>.filled(16384, null)), isNull);
expect(MpvNodeDecoder.decodeList([double.nan]), isNull);
expect(MpvNodeDecoder.decodeMap({1: 'non-string key'}), isNull);
});
test('preflights deeply nested and oversized JSON', () {
final deepestAccepted = '${List.filled(31, '[').join()}null${List.filled(31, ']').join()}';
final tooDeep = '[$deepestAccepted]';
expect(MpvNodeDecoder.decodeList(deepestAccepted), isNotNull);
expect(MpvNodeDecoder.decodeList(tooDeep), isNull);
final tooManyEntries = '[${List.filled(16385, 'null').join(',')}]';
expect(MpvNodeDecoder.decodeList(tooManyEntries), isNull);
expect(MpvNodeDecoder.decodeList('["brackets [ and braces { stay quoted"]'), isNotNull);
});
}
+4 -1
View File
@@ -237,7 +237,7 @@ void main() {
});
});
test('dispose() settles an unconsumed armed fd', () async {
test('dispose() raw cleanup settles an unconsumed armed fd after admission closes', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
@@ -247,6 +247,9 @@ void main() {
await player.dispose();
expect(core.closedFds, [7]);
expect(core.commands('playlist-remove'), [
['playlist-remove', '1'],
]);
});
});
+739 -2
View File
@@ -1,14 +1,21 @@
import 'dart:async' show Completer;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/mpv/player/player_base.dart';
import 'package:plezy/mpv/video.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
final class _InvokingPlayerNative extends PlayerNative {
Future<T?> debugInvoke<T>(String method) => invoke<T>(method);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -53,6 +60,431 @@ void main() {
);
});
test('three overlapping players preserve the newest event owner and serialize native release', () async {
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
final firstNativeDisposeStarted = Completer<void>();
final releaseFirstNativeDispose = Completer<void>();
final secondNativeDisposeStarted = Completer<void>();
final releaseSecondNativeDispose = Completer<void>();
var nativeDisposeCount = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'dispose') {
switch (nativeDisposeCount++) {
case 0:
firstNativeDisposeStarted.complete();
await releaseFirstNativeDispose.future;
break;
case 1:
secondNativeDisposeStarted.complete();
await releaseSecondNativeDispose.future;
break;
}
}
return null;
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
final first = PlayerNative();
PlayerNative? second;
PlayerNative? third;
Future<void>? firstDisposal;
Future<void>? secondDisposal;
try {
await first.setLogLevel('warn');
second = PlayerNative();
third = PlayerNative();
await Future<void>.delayed(Duration.zero);
expect(eventCalls.where((call) => call.method == 'listen'), hasLength(3));
firstDisposal = first.dispose();
secondDisposal = second.dispose();
final thirdInitialization = third.setLogLevel('warn');
await firstNativeDisposeStarted.future;
await Future<void>.delayed(Duration.zero);
expect(secondNativeDisposeStarted.isCompleted, isFalse);
expect(calls.where((call) => call.method == 'initialize'), hasLength(1));
expect(eventCalls.where((call) => call.method == 'cancel'), isEmpty);
releaseFirstNativeDispose.complete();
await firstDisposal;
await secondNativeDisposeStarted.future;
expect(calls.where((call) => call.method == 'initialize'), hasLength(1));
expect(eventCalls.where((call) => call.method == 'cancel'), isEmpty);
releaseSecondNativeDispose.complete();
await Future.wait([secondDisposal, thirdInitialization]);
expect(
calls.where((call) => call.method == 'initialize' || call.method == 'dispose').map((call) => call.method),
['initialize', 'dispose', 'dispose', 'initialize'],
);
await third.dispose();
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
expect(calls.where((call) => call.method == 'dispose'), hasLength(3));
} finally {
if (!releaseFirstNativeDispose.isCompleted) releaseFirstNativeDispose.complete();
if (!releaseSecondNativeDispose.isCompleted) releaseSecondNativeDispose.complete();
await firstDisposal;
await secondDisposal;
await first.dispose();
await second?.dispose();
await third?.dispose();
}
},
);
});
test('dispose does not wait forever for a predecessor that never releases the native channel', () async {
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(milliseconds: 5);
addTearDown(() => PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3));
final stalledNativeDispose = Completer<void>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
if (call.method == 'initialize') return Future.value(true);
if (call.method == 'dispose' && !stalledNativeDispose.isCompleted) return stalledNativeDispose.future;
return Future.value(null);
},
testBody: () async {
final first = PlayerNative();
final second = PlayerNative();
Future<void>? firstDisposal;
try {
await first.setLogLevel('warn');
firstDisposal = first.dispose();
await Future<void>.delayed(Duration.zero);
await second.dispose().timeout(const Duration(seconds: 1));
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
} finally {
if (!stalledNativeDispose.isCompleted) stalledNativeDispose.complete();
await firstDisposal;
await second.dispose();
}
},
);
});
test('invoke returns null when a predecessor release remains stalled', () async {
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(milliseconds: 5);
addTearDown(() => PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3));
final stalledNativeDispose = Completer<void>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
if (call.method == 'initialize') return Future.value(true);
if (call.method == 'dispose' && !stalledNativeDispose.isCompleted) return stalledNativeDispose.future;
return Future.value(null);
},
testBody: () async {
final first = PlayerNative();
final second = PlayerNative();
final third = _InvokingPlayerNative();
Future<void>? firstDisposal;
try {
await first.setLogLevel('warn');
firstDisposal = first.dispose();
await Future<void>.delayed(Duration.zero);
await second.dispose();
expect(await third.debugInvoke<Object>('probe'), isNull);
expect(calls.where((call) => call.method == 'probe'), isEmpty);
stalledNativeDispose.complete();
await firstDisposal;
await third.setLogLevel('warn');
expect(calls.where((call) => call.method == 'initialize'), hasLength(2));
} finally {
if (!stalledNativeDispose.isCompleted) stalledNativeDispose.complete();
await firstDisposal;
await second.dispose();
await third.dispose();
}
},
);
});
test('initialization cannot publish readiness after disposal starts', () async {
final initialize = Completer<bool>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
if (call.method == 'initialize') return initialize.future;
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
final initialization = player.setLogLevel('warn');
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 == 'setLogLevel'), isEmpty);
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
},
);
});
test('dispose synchronously rejects public core traffic while an audio write is blocked', () async {
final speedStarted = Completer<void>();
final releaseSpeed = Completer<void>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'speed') {
speedStarted.complete();
await releaseSpeed.future;
}
return null;
},
testBody: () async {
final player = _InvokingPlayerNative();
Future<void>? disposal;
try {
await player.setLogLevel('warn');
final rate = player.setRate(1.25);
await speedStarted.future;
disposal = player.dispose();
expect(identical(disposal, player.dispose()), isTrue);
final callCountAtDisposeEntry = calls.length;
await Future.wait<void>([
player.command(['probe']),
player.open(Media('https://example.test/late.mkv')),
player.setProperty('pause', 'yes'),
player.setLogLevel('debug'),
player.setRate(1.5),
player.play(),
player.pause(),
player.stop(),
player.seek(const Duration(seconds: 3)),
player.setVolume(25),
player.setAudioPassthrough(true),
player.setAudioNormalization(true),
player.setAudioDownmix(enabled: true, centerBoostDb: 3, normalize: true),
player.updateFrame(),
player.abandonAudioFocus(),
]);
expect(await player.getProperty('pause'), isNull);
expect(await player.requestAudioFocus(), isFalse);
expect(await player.setVisible(false), isFalse);
expect(await player.debugInvoke<Object>('probe-direct'), isNull);
expect(calls, hasLength(callCountAtDisposeEntry));
releaseSpeed.complete();
await rate;
await disposal;
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
} finally {
if (!releaseSpeed.isCompleted) releaseSpeed.complete();
await disposal;
await player.dispose();
}
},
);
});
test('Linux texture bootstrap gates observations and commands until ready', () async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
final ready = Completer<void>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
if (call.method == 'initialize') return Future.value(73);
if (call.method == 'waitForVideoReady') return ready.future;
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
try {
final operation = player.setLogLevel('warn');
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(player.textureId, 73);
expect(player.textureIdListenable.value, 73);
expect(calls.any((call) => call.method == 'waitForVideoReady'), isTrue);
expect(calls.any((call) => call.method == 'observeProperty'), isFalse);
expect(calls.any((call) => call.method == 'setLogLevel'), isFalse);
ready.complete();
await operation;
expect(calls.any((call) => call.method == 'observeProperty'), isTrue);
expect(calls.where((call) => call.method == 'setLogLevel'), hasLength(1));
} finally {
if (!ready.isCompleted) ready.complete();
await player.dispose();
}
},
);
});
testWidgets('Linux texture handoff stays black until playback restarts', (tester) async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
final ready = Completer<void>();
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return 73;
if (call.method == 'waitForVideoReady') {
await ready.future;
}
return null;
},
testBody: () async {
final player = PlayerNative();
await tester.pumpWidget(MaterialApp(home: Video(player: player)));
expect(find.byType(Texture), findsNothing);
final initialization = player.setLogLevel('warn');
await tester.pump();
expect(find.byType(Texture), findsOneWidget);
final videoBox = find.descendant(of: find.byType(Video), matching: find.byType(ColoredBox));
expect(tester.widget<ColoredBox>(videoBox).color, Colors.black);
ready.complete();
await initialization;
player.handlePlayerEvent('playback-restart', null);
await tester.pump();
await tester.pump();
expect(tester.widget<ColoredBox>(videoBox).color, Colors.transparent);
await tester.pumpWidget(const SizedBox());
await tester.runAsync(player.dispose);
},
);
}, timeout: const Timeout(Duration(seconds: 30)));
test('Linux texture bootstrap failure clears the provisional ID and retries', () async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
var initializeCount = 0;
var readinessCount = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return 80 + initializeCount++;
if (call.method == 'waitForVideoReady' && readinessCount++ == 0) {
throw PlatformException(code: 'INIT_FAILED', message: 'GPU bootstrap failed');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await expectLater(
player.setLogLevel('warn'),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'INIT_FAILED')),
);
expect(player.textureId, isNull);
await player.setLogLevel('warn');
expect(initializeCount, 2);
expect(readinessCount, 2);
expect(player.textureId, 81);
} finally {
await player.dispose();
}
},
);
});
test('Linux disposal clears the published texture ID', () async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return 73;
return null;
},
testBody: () async {
final player = PlayerNative();
final textureIds = <int?>[];
player.textureIdListenable.addListener(() => textureIds.add(player.textureIdListenable.value));
await player.setLogLevel('warn');
expect(player.textureId, 73);
await player.dispose();
expect(textureIds, [73, null]);
},
);
});
test('non-Linux texture initialization skips the Linux readiness handshake', () async {
PlayerNative.debugUseLinuxVideoBootstrap = false;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return 91;
if (call.method == 'waitForVideoReady') {
throw StateError('non-Linux backends must not use Linux readiness');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setLogLevel('warn');
expect(player.textureId, 91);
expect(calls.any((call) => call.method == 'waitForVideoReady'), isFalse);
} finally {
await player.dispose();
}
},
);
});
test('MPV accepts nested node observations and null unsupported values', () async {
final observations = <String, int>{};
await withMockPlayerChannels(
@@ -73,17 +505,21 @@ void main() {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const codec = StandardMethodCodec();
Future<void> sendObservation(String name, Object? value) async {
Future<void> sendEvent(Object? event) async {
final done = Completer<void>();
await messenger.handlePlatformMessage(
'com.plezy/mpv_player/events',
codec.encodeSuccessEnvelope([observations[name], value]),
codec.encodeSuccessEnvelope(event),
(_) => done.complete(),
);
await done.future;
await Future<void>.delayed(Duration.zero);
}
Future<void> sendObservation(String name, Object? value) async {
await sendEvent([observations[name], value]);
}
await sendObservation('track-list', const [
{
'type': 'audio',
@@ -124,6 +560,50 @@ void main() {
expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250));
expect(player.state.audioDevices.single.name, 'speakers');
}
// Malformed envelopes and malformed siblings are ignored without
// taking down the event subscription or discarding valid siblings.
await sendEvent(['not-a-property-id', const {}]);
await sendEvent({'type': 'event', 'name': 7, 'data': const {}});
await sendEvent({'type': 'event', 'name': 'unknown', 'data': 'not-a-map'});
await sendObservation('track-list', const [
{'type': 7, 'id': 'bad'},
{
'type': 'audio',
'id': 8,
'title': 12,
'lang': false,
'codec': {'unexpected': true},
'demux-channel-count': 'many',
'selected': true,
},
]);
await sendObservation('demuxer-cache-state', const {
'cache-end': 'not-a-number',
'seekable-ranges': [
{'start': 'bad', 'end': 3},
{'start': 2, 'end': 6},
],
});
await sendObservation('audio-device-list', const [
{'name': 9, 'description': 'bad'},
{'name': 'headphones', 'description': 4},
]);
expect(player.state.tracks.audio.single.id, '8');
expect(player.state.tracks.audio.single.title, isNull);
expect(player.state.tracks.audio.single.channels, isNull);
expect(player.state.buffer, const Duration(milliseconds: 12500));
expect(player.state.bufferRanges.single.start, const Duration(seconds: 2));
expect(player.state.bufferRanges.single.end, const Duration(seconds: 6));
expect(player.state.audioDevices.single.name, 'headphones');
expect(player.state.audioDevices.single.description, isEmpty);
await sendObservation('track-list', [double.nan]);
expect(player.state.tracks.audio.single.id, '8');
player.handlePropertyChange('aid', 'no');
expect(player.state.track.audio, isNull);
} finally {
await player.dispose();
}
@@ -288,6 +768,263 @@ void main() {
);
});
test('typed rate restores native speed after a generic speed property write', () async {
final speedValues = <String>[];
var nativeRate = 1.0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'speed') {
final value = arguments['value'] as String;
speedValues.add(value);
nativeRate = double.parse(value);
}
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setProperty('speed', '2');
await player.setRate(1);
expect(speedValues, ['2', '1.0']);
expect(nativeRate, 1);
} finally {
await player.dispose();
}
},
);
});
test('late downmix failure force-restores the accepted native filter state', () async {
final nativeProperties = <String, String>{};
final writes = <(String, String)>[];
var rejectNextStereo = false;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
final name = arguments['name'] as String;
final value = arguments['value'] as String;
writes.add((name, value));
if (rejectNextStereo && name == 'audio-channels' && value == 'stereo') {
rejectNextStereo = false;
throw PlatformException(code: 'SET_PROPERTY_FAILED');
}
nativeProperties[name] = value;
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setAudioDownmix(enabled: true, centerBoostDb: 2, normalize: false);
writes.clear();
rejectNextStereo = true;
await expectLater(
player.setAudioDownmix(enabled: true, centerBoostDb: 9, normalize: true),
throwsA(isA<PlatformException>()),
);
expect(writes, [
('audio-swresample-o', 'center_mix_level=1.9953'),
('audio-normalize-downmix', 'yes'),
('audio-channels', 'auto-safe'),
('audio-channels', 'stereo'),
('audio-swresample-o', 'center_mix_level=0.8913'),
('audio-normalize-downmix', 'no'),
('audio-channels', 'auto-safe'),
('audio-channels', 'stereo'),
('af', ''),
]);
expect(nativeProperties['audio-swresample-o'], 'center_mix_level=0.8913');
expect(nativeProperties['audio-normalize-downmix'], 'no');
expect(nativeProperties['audio-channels'], 'stereo');
expect(nativeProperties['af'], '');
} finally {
await player.dispose();
}
},
);
});
test('failed older audio field is not revived by a queued different-field update', () async {
final normalizationStarted = Completer<void>();
final releaseNormalization = Completer<void>();
final speedValues = <String>[];
var normalizationAttempts = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
final name = arguments['name'] as String;
final value = arguments['value'] as String;
if (name == 'af' && value.isNotEmpty) {
normalizationAttempts++;
if (normalizationAttempts == 1) {
normalizationStarted.complete();
await releaseNormalization.future;
throw PlatformException(code: 'SET_PROPERTY_FAILED');
}
}
if (name == 'speed') speedValues.add(value);
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
final normalization = player.setAudioNormalization(true);
await normalizationStarted.future;
final rate = player.setRate(1.25);
releaseNormalization.complete();
await expectLater(normalization, throwsA(isA<PlatformException>()));
await rate;
expect(normalizationAttempts, 1);
expect(speedValues, ['1.25']);
} finally {
if (!releaseNormalization.isCompleted) releaseNormalization.complete();
await player.dispose();
}
},
);
});
test('failed passthrough write does not publish speculative active state', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'audio-spdif') {
throw PlatformException(code: 'SET_PROPERTY_FAILED');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await expectLater(player.setAudioPassthrough(true), throwsA(isA<PlatformException>()));
expect(player.audioPassthroughActive, isFalse);
} finally {
await player.dispose();
}
},
);
});
test('failed passthrough restores requested normalization', () async {
final propertyWrites = <(String, String)>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
final write = (arguments['name'] as String, arguments['value'] as String);
propertyWrites.add(write);
if (write.$1 == 'audio-spdif') throw PlatformException(code: 'SET_PROPERTY_FAILED');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setAudioNormalization(true);
await expectLater(player.setAudioPassthrough(true), throwsA(isA<PlatformException>()));
expect(propertyWrites.where((write) => write.$1 == 'af').map((write) => write.$2), [
'loudnorm=I=-14:TP=-3:LRA=4',
'',
'loudnorm=I=-14:TP=-3:LRA=4',
]);
expect(player.audioPassthroughActive, isFalse);
} finally {
await player.dispose();
}
},
);
});
test('exclusive-audio hint failure does not reject accepted passthrough', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'audio-exclusive') {
throw PlatformException(code: 'SET_PROPERTY_FAILED');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setAudioPassthrough(true);
expect(player.audioPassthroughActive, isTrue);
} finally {
await player.dispose();
}
},
);
});
test('failed rate write restores accepted passthrough state', () async {
var rejectSpeed = false;
final propertyWrites = <(String, String)>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
final name = arguments['name'] as String;
final value = arguments['value'] as String;
propertyWrites.add((name, value));
if (name == 'speed' && rejectSpeed) {
throw PlatformException(code: 'SET_PROPERTY_FAILED');
}
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setAudioPassthrough(true);
expect(player.audioPassthroughActive, isTrue);
rejectSpeed = true;
await expectLater(player.setRate(1.25), throwsA(isA<PlatformException>()));
expect(player.audioPassthroughActive, isTrue);
expect(propertyWrites.where((write) => write.$1 == 'audio-spdif').map((write) => write.$2), [
'ac3,eac3,dts,dts-hd,truehd',
'',
'ac3,eac3,dts,dts-hd,truehd',
]);
} finally {
await player.dispose();
}
},
);
});
for (final channel in [
(label: 'video', method: 'com.plezy/mpv_player', events: 'com.plezy/mpv_player/events', audio: false),
(label: 'audio', method: 'com.plezy/mpv_audio_player', events: 'com.plezy/mpv_audio_player/events', audio: true),
+85
View File
@@ -46,6 +46,91 @@ void main() {
);
});
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 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 external subtitle metadata at open', () async {
final calls = <MethodCall>[];
@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.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();
PlayerNative.debugUseLinuxVideoBootstrap = true;
});
tearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
testWidgets('Linux mounts its provisional texture while initialization is pending', (tester) async {
final ready = Completer<void>();
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return switch (call.method) {
'initialize' => Future<Object?>.value(73),
'waitForVideoReady' => ready.future,
_ => Future<Object?>.value(null),
};
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
final key = GlobalKey<VideoPlayerScreenState>();
await tester.pumpWidget(_screen(key));
await _pumpUntil(tester, () => calls.any((call) => call.method == 'waitForVideoReady'));
expect(tester.widget<Texture>(find.byType(Texture)).textureId, 73);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(key.currentState?.player, isNull);
await tester.pumpWidget(const SizedBox.shrink());
await _pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
);
ready.complete();
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 10)));
await tester.pump();
},
);
});
}
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
return ChangeNotifierProvider(
create: (_) => PlaybackStateProvider(),
child: MaterialApp(
home: VideoPlayerScreen(
key: key,
metadata: testMediaItem(title: 'Linux startup test video'),
isOffline: true,
),
),
);
}
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
for (var i = 0; i < 200 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
if (!condition()) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
}
}
expect(condition(), isTrue);
}
@@ -3,10 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_button.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/focus/focusable_button.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
@@ -22,6 +22,25 @@ void main() {
await SettingsService.getInstance();
});
test('in-place reload preserves the current playback intent', () {
expect(
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: false, watchTogetherOwnsStart: false, startPaused: false),
isFalse,
);
expect(
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: true, watchTogetherOwnsStart: false, startPaused: false),
isTrue,
);
expect(
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: true, watchTogetherOwnsStart: true, startPaused: false),
isFalse,
);
expect(
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: true, watchTogetherOwnsStart: false, startPaused: true),
isFalse,
);
});
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
final failedDispose = Completer<void>();
final replacementInitialize = Completer<bool>();
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/device_performance.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
DevicePerformance.debugReset();
addTearDown(DevicePerformance.debugReset);
});
test('concurrent callers wait for hardware detection', () async {
final detection = Completer<void>();
DevicePerformance.debugDetectionGate = detection.future;
final first = DevicePerformance.getInstance(override: VisualEffectsSetting.reduced);
var secondCompleted = false;
final second = DevicePerformance.getInstance();
unawaited(second.then((_) => secondCompleted = true));
await Future<void>.delayed(Duration.zero);
expect(secondCompleted, isFalse);
detection.complete();
final instances = await Future.wait([first, second]);
expect(identical(instances.first, instances.last), isTrue);
expect(DevicePerformance.isReduced, isTrue);
});
test('failed hardware detection can be retried', () async {
DevicePerformance.debugDetectionGate = Future<void>.error(StateError('detection failed'));
await expectLater(DevicePerformance.getInstance(), throwsStateError);
DevicePerformance.debugDetectionGate = null;
final recovered = await DevicePerformance.getInstance();
expect(recovered, isNotNull);
});
}
@@ -0,0 +1,172 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/display_mode_service.dart';
import 'package:plezy/services/fullscreen_state_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/app_logger.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test_display_mode_service');
late DisplayModeService service;
late List<String> calls;
void setHandler(Future<dynamic> Function(MethodCall call)? handler) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, handler);
}
Future<void> seedNativeState({required bool mode, required bool hdr}) async {
setHandler((call) async {
calls.add(call.method);
return switch (call.method) {
'isModeChanged' => mode,
'isHDRChanged' => hdr,
_ => throw StateError('Unexpected method ${call.method}'),
};
});
await service.syncWithNative();
calls.clear();
}
setUp(() async {
calls = <String>[];
MemoryLogOutput.clearLogs();
setLoggerLevel(true);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
service = DisplayModeService.forTesting(settings, FullscreenStateManager(), channel: channel);
});
tearDown(() {
setHandler(null);
MemoryLogOutput.clearLogs();
});
test('false display-mode payload retains state for a later retry', () async {
await seedNativeState(mode: true, hdr: false);
var accepted = false;
setHandler((call) async {
calls.add(call.method);
expect(call.method, 'restoreDisplayMode');
return accepted;
});
await service.restoreAll();
expect(service.anyChangeApplied, isTrue);
accepted = true;
await service.restoreAll();
expect(calls, ['restoreDisplayMode', 'restoreDisplayMode']);
expect(service.anyChangeApplied, isFalse);
});
test('false HDR payload warns without logging restoration success', () async {
await seedNativeState(mode: false, hdr: true);
var accepted = false;
setHandler((call) async {
calls.add(call.method);
expect(call.method, 'restoreSystemHDR');
return accepted;
});
await service.restoreAll();
expect(service.hdrStateChanged, isTrue);
var messages = MemoryLogOutput.getLogs().map((entry) => entry.message).join('\n');
expect(messages, contains('retaining retry state'));
expect(messages, isNot(contains('Restored system HDR state')));
accepted = true;
await service.restoreAll();
messages = MemoryLogOutput.getLogs().map((entry) => entry.message).join('\n');
expect(calls, ['restoreSystemHDR', 'restoreSystemHDR']);
expect(service.hdrStateChanged, isFalse);
expect(service.anyChangeApplied, isFalse);
expect(messages, contains('Restored system HDR state'));
});
test('HDR failure does not suppress successful mode restoration', () async {
await seedNativeState(mode: true, hdr: true);
var hdrAccepted = false;
setHandler((call) async {
calls.add(call.method);
return switch (call.method) {
'restoreSystemHDR' => hdrAccepted,
'restoreDisplayMode' => true,
_ => throw StateError('Unexpected method ${call.method}'),
};
});
await service.restoreAll();
expect(calls, ['restoreSystemHDR', 'restoreDisplayMode']);
expect(service.hdrStateChanged, isTrue);
expect(service.anyChangeApplied, isTrue);
calls.clear();
hdrAccepted = true;
await service.restoreAll();
expect(calls, ['restoreSystemHDR']);
expect(service.anyChangeApplied, isFalse);
});
test('mode failure does not suppress successful HDR restoration', () async {
await seedNativeState(mode: true, hdr: true);
var modeAccepted = false;
setHandler((call) async {
calls.add(call.method);
return switch (call.method) {
'restoreSystemHDR' => true,
'restoreDisplayMode' => modeAccepted,
_ => throw StateError('Unexpected method ${call.method}'),
};
});
await service.restoreAll();
expect(calls, ['restoreSystemHDR', 'restoreDisplayMode']);
expect(service.hdrStateChanged, isFalse);
expect(service.anyChangeApplied, isTrue);
calls.clear();
modeAccepted = true;
await service.restoreAll();
expect(calls, ['restoreDisplayMode']);
expect(service.anyChangeApplied, isFalse);
});
test('a channel exception retains only the throwing restoration', () async {
await seedNativeState(mode: true, hdr: true);
setHandler((call) async {
calls.add(call.method);
if (call.method == 'restoreSystemHDR') {
throw PlatformException(code: 'RESTORE_FAILED');
}
if (call.method == 'restoreDisplayMode') return true;
throw StateError('Unexpected method ${call.method}');
});
await service.restoreAll();
expect(calls, ['restoreSystemHDR', 'restoreDisplayMode']);
expect(service.hdrStateChanged, isTrue);
expect(service.anyChangeApplied, isTrue);
});
test('non-Windows override performs no native work', () async {
service = DisplayModeService.forTesting(
SettingsService.instance,
FullscreenStateManager(),
channel: channel,
isWindows: false,
);
setHandler((call) async {
calls.add(call.method);
return true;
});
await service.syncWithNative();
await service.restoreAll();
expect(calls, isEmpty);
});
}
@@ -22,6 +22,28 @@ void main() {
SettingsService.resetForTesting();
});
test('concurrent callers wait for settings binding', () async {
final preferences = _BlockingReadPreferences(const {});
SharedPreferencesAsyncPlatform.instance = preferences;
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
addTearDown(preferences.release);
final first = KeyboardShortcutsService.getInstance();
await preferences.entered;
var secondCompleted = false;
final second = KeyboardShortcutsService.getInstance();
unawaited(second.then((_) => secondCompleted = true));
await Future<void>.delayed(Duration.zero);
expect(secondCompleted, isFalse);
preferences.release();
final instances = await Future.wait([first, second]);
expect(identical(instances.first, instances.last), isTrue);
addTearDown(instances.first.dispose);
});
group('HotKey persistence', () {
test('loads shortcuts saved with the shipped pre-HID key format', () async {
resetSharedPreferencesForTest(
@@ -694,6 +716,29 @@ class _FakePlayer implements Player {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _BlockingReadPreferences extends InMemorySharedPreferencesAsync {
_BlockingReadPreferences(super.data) : super.withData();
final _entered = Completer<void>();
final _release = Completer<void>();
Future<void> get entered => _entered.future;
void release() {
if (!_release.isCompleted) _release.complete();
}
@override
Future<Map<String, Object>> getPreferences(
GetPreferencesParameters parameters,
SharedPreferencesOptions options,
) async {
if (!_entered.isCompleted) _entered.complete();
await _release.future;
return super.getPreferences(parameters, options);
}
}
final class _HotkeyPreferences extends InMemorySharedPreferencesAsync {
_HotkeyPreferences(super.data) : super.withData();
+41
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:plezy/media/ids.dart';
@@ -7,6 +8,19 @@ import 'package:plezy/services/storage_service.dart';
import '../test_helpers/prefs.dart';
class _GatedPreferencesService extends BaseSharedPreferencesService {
_GatedPreferencesService(this.started, this.release);
final Completer<void> started;
final Future<void> release;
@override
Future<void> onInit() async {
started.complete();
await release;
}
}
void main() {
setUp(resetSharedPreferencesForTest);
@@ -17,6 +31,33 @@ void main() {
expect(identical(a, b), isTrue);
});
test('coalesces callers until asynchronous initialization completes', () async {
final started = Completer<void>();
final release = Completer<void>();
var constructorCalls = 0;
Future<_GatedPreferencesService> acquire() => BaseSharedPreferencesService.initializeInstance(() {
constructorCalls++;
return _GatedPreferencesService(started, release.future);
});
final first = acquire();
await started.future;
var secondCompleted = false;
final second = acquire().then((instance) {
secondCompleted = true;
return instance;
});
await Future<void>.delayed(Duration.zero);
expect(secondCompleted, isFalse);
expect(constructorCalls, 1);
release.complete();
final instances = await Future.wait([first, second]);
expect(identical(instances.first, instances.last), isTrue);
});
test('reset rebuilds against current SharedPreferences', () async {
final first = await StorageService.getInstance();
await first.prefs.setString('plex_token', 'token-1');
+25
View File
@@ -1,7 +1,32 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/platform_detector.dart';
void main() {
setUp(() {
TvDetectionService.debugReset();
addTearDown(TvDetectionService.debugReset);
});
test('concurrent callers wait for TV detection', () async {
final detection = Completer<void>();
TvDetectionService.debugDetectionGate = detection.future;
final first = TvDetectionService.getInstance(forceTv: true);
var secondCompleted = false;
final second = TvDetectionService.getInstance();
unawaited(second.then((_) => secondCompleted = true));
await Future<void>.delayed(Duration.zero);
expect(secondCompleted, isFalse);
detection.complete();
final instances = await Future.wait([first, second]);
expect(identical(instances.first, instances.last), isTrue);
expect(instances.first.isTV, isTrue);
});
group('detectAndroidTvFromSystemFeatures', () {
test('detects leanback devices', () {
final detection = detectAndroidTvFromSystemFeatures([