diff --git a/test/screens/video_player/linux_mpv_config_vo_override_test.dart b/test/screens/video_player/linux_mpv_config_vo_override_test.dart new file mode 100644 index 000000000..11e988264 --- /dev/null +++ b/test/screens/video_player/linux_mpv_config_vo_override_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_helpers/hdr_startup.dart'; + +/// The user's free-form mpv config is applied as runtime mpv_set_property +/// writes, and the embedded renderer owns the windowed-VO family: a +/// `vo=gpu-next` line (with the gpu-context/gpu-api it rides on) makes mpv +/// re-create its output as a separate, uncontrollable window and orphans the +/// embedded plane. Startup therefore skips those three names in the custom +/// pass and logs the skip with the real reason. +/// +/// Removing the filter makes this fail on the write lists: `vo` reaches mpv +/// and the plane is orphaned. See installHdrStartupHarness for why this case +/// needs an isolate of its own. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(installHdrStartupHarness); + + testWidgets('a custom config naming the embedded VO family cannot detach the renderer', (tester) async { + await expectCustomConfigCannotOverrideEmbeddedVo(tester); + }); +} diff --git a/test/screens/video_player/linux_subtitle_color_sanitization_test.dart b/test/screens/video_player/linux_subtitle_color_sanitization_test.dart new file mode 100644 index 000000000..2a16eba26 --- /dev/null +++ b/test/screens/video_player/linux_subtitle_color_sanitization_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../../test_helpers/hdr_startup.dart'; + +/// mpv 0.40's OPT_COLOR parser accepts only #RRGGBB/#AARRGGBB, so a stored +/// subtitle colour that does not parse (named colour, 3-digit hex) would make +/// the `sub-color`/`sub-border-color`/`sub-back-color` writes fail with +/// MPV_ERROR_PROPERTY_FORMAT. Startup canonicalizes the values to hex and +/// replaces unparseable ones with the defaults, so a bad preference degrades +/// to the default colour instead of failing playback. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await installHdrStartupHarness(); + // Deliberately unparseable stored values: a named colour, a 3-digit hex, + // and another named colour for the background (with a full opacity so the + // composed sub-back-color is deterministic). + await SettingsService.instance.write(SettingsService.subtitleTextColor, 'white'); + await SettingsService.instance.write(SettingsService.subtitleBorderColor, '#FFF'); + await SettingsService.instance.write(SettingsService.subtitleBackgroundColor, 'red'); + await SettingsService.instance.write(SettingsService.subtitleBackgroundOpacity, 100); + }); + + testWidgets('unparseable subtitle colours are canonicalized to defaults on the wire', (tester) async { + await expectSanitizedSubtitleColorsOnTheWire(tester); + }); +} diff --git a/test/screens/video_player/linux_subtitle_style_refusal_test.dart b/test/screens/video_player/linux_subtitle_style_refusal_test.dart new file mode 100644 index 000000000..ebcf831eb --- /dev/null +++ b/test/screens/video_player/linux_subtitle_style_refusal_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_helpers/hdr_startup.dart'; + +/// A refused subtitle-styling write used to escape _runPlayerInitializationAttempt +/// into the initialization error screen on every open (mpv refuses a colour +/// value its OPT_COLOR parser cannot read with MPV_ERROR_PROPERTY_FORMAT). +/// Styling is a preference, so the refusal is now contained: the write is +/// attempted, logged, and playback continues with mpv's own default styling. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(installHdrStartupHarness); + + testWidgets('a refused subtitle style write does not abort initialization', (tester) async { + await expectSubtitleStyleRefusalDoesNotAbortStartup(tester); + }); +} diff --git a/test/test_helpers/hdr_startup.dart b/test/test_helpers/hdr_startup.dart index e4317b1de..fbe7ba7db 100644 --- a/test/test_helpers/hdr_startup.dart +++ b/test/test_helpers/hdr_startup.dart @@ -281,6 +281,166 @@ Future expectCustomConfigCannotOverrideHdrPreferences(WidgetTester tester) ); } +/// The custom mpv config is applied as runtime mpv_set_property writes, and +/// vo/gpu-context/gpu-api are owned by the embedded renderer: a vo=gpu-next +/// line would make mpv re-create its output as a separate window and orphan +/// the plane (the render API is OpenGL-only, so gpu-next cannot be embedded). +/// Startup must skip the whole VO family by name - with a key-aware log, not +/// the HDR-settings pointer - and let ordinary entries through. +Future expectCustomConfigCannotOverrideEmbeddedVo(WidgetTester tester) async { + await SettingsService.instance.write( + SettingsService.mpvConfigText, + 'vo=gpu-next\n' + 'gpu-context=waylandvk\n' + 'gpu-api=vulkan\n' + 'sub-scale=1.5\n', + ); + final calls = []; + final eventCalls = []; + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }, + eventHandler: (call) async { + eventCalls.add(call); + return null; + }, + testBody: () async { + await _mountPlayerScreen(tester, 'Linux embedded VO override video'); + // `volume-max` is the write immediately after the custom-config pass, so + // its arrival is what makes the lists below complete. + await pumpUntil( + tester, + () => _propertyWrites(calls).contains('volume-max'), + describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}', + ); + + // None of the VO family reached mpv: a vo switch would detach the + // embedded render context into a separate window. + for (final owned in ['vo', 'gpu-context', 'gpu-api']) { + expect(_valueWrites(calls, owned), isEmpty, reason: '$owned is owned by the embedded renderer'); + } + // The rest of the config still reaches mpv: the skip goes by name. + expect(_valueWrites(calls, 'sub-scale'), ['1.5']); + + // Same deterministic teardown as [expectCustomConfigCannotOverrideHdrPreferences]. + await tester.pumpWidget(const SizedBox.shrink()); + await pumpUntil( + tester, + () => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'), + describe: () => + 'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}', + ); + }, + ); +} + +/// A refused subtitle-styling write must not abort initialization: styling is +/// a preference, and the same refusal that used to land as +/// SET_PROPERTY_FAILED on the channel used to escape +/// _runPlayerInitializationAttempt into the error screen. The mock plane +/// refuses `sub-color`; initialization must run through to `volume-max` and +/// no error screen may appear. +Future expectSubtitleStyleRefusalDoesNotAbortStartup(WidgetTester tester) async { + final refusal = PlatformException( + code: 'SET_PROPERTY_FAILED', + message: "setProperty 'sub-color'='#FFFFFF' failed: unsupported format for accessing property", + ); + final calls = []; + final eventCalls = []; + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) async { + calls.add(call); + if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'sub-color') { + return Future.error(refusal); + } + return call.method == 'initialize' ? true : null; + }, + eventHandler: (call) async { + eventCalls.add(call); + return null; + }, + testBody: () async { + await _mountPlayerScreen(tester, 'Linux subtitle style refusal video'); + // `volume-max` follows the styling block; reaching it proves the refusal + // was contained and initialization ran on. + await pumpUntil( + tester, + () => _propertyWrites(calls).contains('volume-max'), + describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}', + ); + + // The write was attempted (not skipped) and the refusal was swallowed: + // playback must not die over styling. + expect(_propertyWrites(calls), contains('sub-color')); + expect(find.text('Retry'), findsNothing, reason: 'a styling refusal must not show the error screen'); + + // Same deterministic teardown as the HDR cases. + await tester.pumpWidget(const SizedBox.shrink()); + await pumpUntil( + tester, + () => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'), + describe: () => + 'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}', + ); + }, + ); +} + +/// The stored subtitle colours are free-form strings and mpv 0.40's OPT_COLOR +/// parser rejects anything but #RRGGBB/#AARRGGBB, so startup sanitizes the +/// values before writing: parseable hex passes through, everything else is +/// replaced by the preference default. The caller seeds the unparseable +/// values; this asserts what reaches the wire. +Future expectSanitizedSubtitleColorsOnTheWire(WidgetTester tester) async { + final calls = []; + final eventCalls = []; + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }, + eventHandler: (call) async { + eventCalls.add(call); + return null; + }, + testBody: () async { + await _mountPlayerScreen(tester, 'Linux subtitle colour sanitization video'); + // `volume-max` follows the subtitle styling block, so its arrival makes + // the styling writes complete. + await pumpUntil( + tester, + () => _propertyWrites(calls).contains('volume-max'), + describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}', + ); + + // The defaults, not the unparseable seeds. + expect(_valueWrites(calls, 'sub-color'), ['#FFFFFF']); + expect(_valueWrites(calls, 'sub-border-color'), ['#000000']); + expect(_valueWrites(calls, 'sub-back-color'), ['#FF000000']); + + // Same deterministic teardown as the HDR cases. + await tester.pumpWidget(const SizedBox.shrink()); + await pumpUntil( + tester, + () => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'), + describe: () => + 'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}', + ); + }, + ); +} + /// The negative side of [expectStartupSurvivesHdrRefusal]: with the Linux video /// path forced off, the same refusal must abort initialization rather than be /// swallowed, so `audio-delay` never follows it and the stored preference is