fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user