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