feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry HDR at all, and which forced a whole-window Flutter recomposite for every video frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with mpv rendering into an EGL window surface on it through the libmpv render API. The subsurface is desynchronized, so video and UI now present independently. With the plane in place HDR follows: the surface is described to the compositor through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG, BT.2020 - carrying whatever HDR10 static metadata the stream actually declares. The description and the buffer it describes land on the same commit, staged and validated before mpv is switched, so a PQ frame is never presented labelled sRGB. A five-second watchdog bounds the one wait a compositor could otherwise leave hanging. A session that cannot host the plane - X11, or a compositor without wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the reason: the texture path is gone, and refusing by name beats degrading to something the user cannot see. An SDR output, a missing capability or an 8-bit config keep the plane and simply leave it undescribed. The output's colour state is trusted only when it has been earned. Every landed property step records itself as it lands; a reset or sequence that cannot finish downgrades its result to unknown and marks the applied-output cache untrusted until a clean apply earns it back. A plane whose output state cannot be named is quarantined - hidden, its description withdrawn - and the quarantine is recorded state: an unrelated visibility change cannot put a mislabelled plane back on screen, and only a commit that resolves to a nameable outcome lifts it. A rect collapsing to zero detaches the buffer exactly as hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the next layout pass retries for free, and a refused tone-mapping pick tells the user instead of dying in a log. NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window configs, so the plane takes half-float as the tier between 10-bit unorm and 8-bit, declares the whole surface opaque so the compositor never reads the alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie. Whether the output is in HDR is read from luminance headroom above its own reference white rather than from the preferred transfer function, which current KWin no longer answers PQ for; the margin is half a stop, because KWin reports an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090 (driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs. Who tone-maps is a user choice. The default is the compositor: photographed on a 400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and separated where the player leg flattens them, because the player path drives mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the renderer, not the wiring. The decision itself - what the source carries, what the output supports, what to tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of Wayland and GTK so its luminance validation can be tested without a display server. Sending an incoherent luminance set is a protocol error that disconnects the client, so the rules are worth a unit test. The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL: the plane links them directly and bundle-libs.sh deliberately never bundles them, since they are coupled to the running compositor and GPU driver. lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware - it drives one clip with scripted mpv properties and reports the colour state mpv actually settled on. Nothing imports it, so it is tree-shaken out of the app. Verified on a Steam Deck against an external 400-nit HDR display: the compositor reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall - closer to the reference HDR player than to its own SDR fallback.
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
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';
|
||||
@@ -348,163 +346,146 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux texture bootstrap gates observations and commands until ready', () async {
|
||||
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||
final ready = Completer<void>();
|
||||
test('a Linux video plane that cannot start fails initialization by name', () async {
|
||||
PlayerNative.debugUseLinuxVideoPlane = true;
|
||||
addTearDown(() => PlayerNative.debugUseLinuxVideoPlane = null);
|
||||
final calls = <MethodCall>[];
|
||||
final errors = <String>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
methodHandler: (call) async {
|
||||
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');
|
||||
if (call.method == 'initialize') {
|
||||
throw PlatformException(
|
||||
code: 'VIDEO_PLANE_UNSUPPORTED',
|
||||
message: 'compositor does not advertise wl_subcompositor',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
final subscription = player.streams.error.listen((error) => errors.add(error.message));
|
||||
try {
|
||||
await expectLater(
|
||||
player.setLogLevel('warn'),
|
||||
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'INIT_FAILED')),
|
||||
throwsA(
|
||||
isA<PlatformException>()
|
||||
.having((error) => error.code, 'code', 'VIDEO_PLANE_UNSUPPORTED')
|
||||
.having((error) => error.message, 'message', contains('wl_subcompositor')),
|
||||
),
|
||||
);
|
||||
expect(player.textureId, isNull);
|
||||
|
||||
await player.setLogLevel('warn');
|
||||
expect(initializeCount, 2);
|
||||
expect(readinessCount, 2);
|
||||
expect(player.textureId, 81);
|
||||
// There is no second video path to degrade onto, so the only correct
|
||||
// outcome is a refusal that names its cause. Nothing may run past it:
|
||||
// a player that observed properties or accepted commands here would be
|
||||
// one playing audio at a black window.
|
||||
expect(calls.map((call) => call.method), ['initialize']);
|
||||
// The refusal reaches the error stream a turn behind the throw.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(errors.single, contains('wl_subcompositor'));
|
||||
|
||||
// Nor is the failure cached as a half-open player: the next caller
|
||||
// asks the plane again and is refused by name again, rather than
|
||||
// sliding through on a memoized "already initialized".
|
||||
await expectLater(player.setLogLevel('warn'), throwsA(isA<PlatformException>()));
|
||||
expect(calls.map((call) => call.method), ['initialize', 'initialize']);
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux disposal clears the published texture ID', () async {
|
||||
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||
test('the native hdr-output-changed event reaches the stream, and a typeless envelope does not', () async {
|
||||
// The only notice Dart gets that dragging the window changed the answer to
|
||||
// isHdrOutputSupported: Wayland raises no lifecycle event for it. Asserted
|
||||
// on the real event channel rather than a fake stream because the failure
|
||||
// mode is a rename on one side of the wire, which a fake cannot see.
|
||||
var changes = 0;
|
||||
|
||||
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));
|
||||
final subscription = player.streams.hdrOutputChanged.listen((_) => changes++);
|
||||
try {
|
||||
await player.setLogLevel('warn');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
const codec = StandardMethodCodec();
|
||||
|
||||
await player.setLogLevel('warn');
|
||||
expect(player.textureId, 73);
|
||||
await player.dispose();
|
||||
Future<void> sendEvent(Object? event) async {
|
||||
final done = Completer<void>();
|
||||
await messenger.handlePlatformMessage(
|
||||
'com.plezy/mpv_player/events',
|
||||
codec.encodeSuccessEnvelope(event),
|
||||
(_) => done.complete(),
|
||||
);
|
||||
await done.future;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
expect(textureIds, [73, null]);
|
||||
await sendEvent(const {'type': 'event', 'name': 'hdr-output-changed'});
|
||||
expect(changes, 1);
|
||||
|
||||
// The envelope needs both keys. Omitting `type` is not hypothetical -
|
||||
// it is exactly what the native side once sent, and the event was
|
||||
// dropped in silence, so the settings sheet kept whatever HDR verdict
|
||||
// it had from before the window moved.
|
||||
await sendEvent(const {'name': 'hdr-output-changed'});
|
||||
expect(changes, 1);
|
||||
|
||||
// And the channel is still live afterwards: a malformed sibling must
|
||||
// not take the subscription down with it.
|
||||
await sendEvent(const {'type': 'event', 'name': 'hdr-output-changed'});
|
||||
expect(changes, 2);
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Linux texture initialization skips the Linux readiness handshake', () async {
|
||||
PlayerNative.debugUseLinuxVideoBootstrap = false;
|
||||
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||
test('the HDR output probe asks the plane by name and answers what it said', () async {
|
||||
// The name is half the contract: nothing else in the app invokes
|
||||
// isHDRSupported on the player channel, so a misspelling here would simply
|
||||
// answer null forever and hide the HDR controls on every Linux session.
|
||||
PlayerNative.debugUseLinuxVideoPlane = true;
|
||||
addTearDown(() => PlayerNative.debugUseLinuxVideoPlane = null);
|
||||
final calls = <MethodCall>[];
|
||||
Object? reply;
|
||||
|
||||
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');
|
||||
}
|
||||
if (call.method == 'initialize') return true;
|
||||
if (call.method == 'isHDRSupported') return reply;
|
||||
return null;
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
await player.setLogLevel('warn');
|
||||
expect(player.textureId, 91);
|
||||
expect(calls.any((call) => call.method == 'waitForVideoReady'), isFalse);
|
||||
reply = true;
|
||||
expect(await player.isHdrOutputSupported(), isTrue);
|
||||
|
||||
// Not cached: the output under the window is what the answer folds in,
|
||||
// and that changes without Dart asking anything.
|
||||
reply = false;
|
||||
expect(await player.isHdrOutputSupported(), isFalse);
|
||||
|
||||
// A native that does not implement the method answers null, which is
|
||||
// "no HDR" rather than a crash or an optimistic yes.
|
||||
reply = null;
|
||||
expect(await player.isHdrOutputSupported(), isFalse);
|
||||
|
||||
expect(calls.where((call) => call.method == 'isHDRSupported'), hasLength(3));
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test_helpers/hdr_startup.dart';
|
||||
|
||||
/// The user's free-form mpv config is applied at the end of startup, after the
|
||||
/// HDR preferences have been pushed. `hdr-enabled` and `hdr-tone-mapping` are
|
||||
/// not mpv properties - the Linux plugin intercepts both and moves its own
|
||||
/// persistent HDR state - so a config line naming either would win the plane
|
||||
/// while SettingsService, which is the only thing the settings sheet renders
|
||||
/// from, kept the app's value. Startup therefore refuses those two names in the
|
||||
/// custom pass and logs the skip.
|
||||
///
|
||||
/// Removing the filter makes this fail on the write lists: the plane sees a
|
||||
/// second `hdr-enabled`/`hdr-tone-mapping` carrying the config's value. See
|
||||
/// installHdrStartupHarness for why this case needs an isolate of its own.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(installHdrStartupHarness);
|
||||
|
||||
testWidgets('a custom config naming the HDR properties cannot override the stored preferences', (tester) async {
|
||||
await expectCustomConfigCannotOverrideHdrPreferences(tester);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test_helpers/hdr_startup.dart';
|
||||
|
||||
/// The other arm of the preference. linux_hdr_startup_test.dart covers a stored
|
||||
/// `true`, which on its own cannot tell "sends the preference" apart from "sends
|
||||
/// `yes`": with HDR turned off, startup owes the plane an explicit `no` - the
|
||||
/// property is not simply skipped, since the plane may still be describing HDR
|
||||
/// from a previous session and would otherwise keep passthrough on.
|
||||
///
|
||||
/// Its own file because a second `VideoPlayerScreen` in the same isolate never
|
||||
/// reaches `initialize` - see installHdrStartupHarness.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => installHdrStartupHarness(enableHdr: false));
|
||||
|
||||
testWidgets('HDR turned off sends the passthrough preference as no', (tester) async {
|
||||
await expectStartupSurvivesHdrRefusal(
|
||||
tester,
|
||||
PlatformException(code: 'HDR_UNSUPPORTED', message: 'output is not in HDR'),
|
||||
title: 'Linux HDR disabled startup video',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test_helpers/hdr_startup.dart';
|
||||
|
||||
/// The negative control for the Linux HDR startup tolerance.
|
||||
///
|
||||
/// Swallowing a refused `hdr-enabled` write is scoped to the Linux video path;
|
||||
/// everywhere else an unexpected refusal must still abort initialization, which
|
||||
/// is the behaviour that shipped before this feature. Without this test the
|
||||
/// tolerance could be widened to every platform and both sibling tests would
|
||||
/// still pass.
|
||||
///
|
||||
/// Separate file, one test - see installHdrStartupHarness for why these cannot
|
||||
/// share an isolate.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => installHdrStartupHarness(linuxVideoPath: false));
|
||||
|
||||
testWidgets('a refusal still aborts startup off the Linux video path', (tester) async {
|
||||
await expectStartupAbortsOnHdrRefusal(
|
||||
tester,
|
||||
PlatformException(code: 'HDR_UNSUPPORTED', message: 'output is not in HDR'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test_helpers/hdr_startup.dart';
|
||||
|
||||
/// On Linux the `hdr-enabled` write must survive *whatever* it fails with, not
|
||||
/// only the one code the native plane answers with today.
|
||||
///
|
||||
/// The refusal used here deliberately carries no HDR-specific code, because the
|
||||
/// tolerance has to hold for more than one failure. `HDR_UNSUPPORTED` is the
|
||||
/// plane saying it can never carry HDR, but a refused colour transaction - mpv
|
||||
/// declining one of the four output properties - comes back as a generic
|
||||
/// property failure instead. A narrow `if (code != 'HDR_UNSUPPORTED') rethrow`
|
||||
/// would pass every other test in the suite and still turn "this session cannot
|
||||
/// do HDR" into "this session cannot play video". Why the tolerance exists at
|
||||
/// all is documented where it lives, in VideoPlayerScreen.
|
||||
///
|
||||
/// Separate file, one test - see installHdrStartupHarness for why these cannot
|
||||
/// share an isolate. The negative control is in
|
||||
/// linux_hdr_startup_non_linux_test.dart.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(installHdrStartupHarness);
|
||||
|
||||
testWidgets('a refusal that is not HDR_UNSUPPORTED still does not stop playback starting', (tester) async {
|
||||
await expectStartupSurvivesHdrRefusal(
|
||||
tester,
|
||||
PlatformException(code: 'SET_PROPERTY_FAILED', message: 'property not found'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test_helpers/hdr_startup.dart';
|
||||
|
||||
/// Startup pushes the HDR preference at a native plane that is allowed to refuse
|
||||
/// it: the Linux plugin answers `HDR_UNSUPPORTED` when the video plane and
|
||||
/// compositor cannot describe HDR, or the output the window sits on is not in HDR.
|
||||
/// That gate runs before any source is considered - startup happens before the
|
||||
/// media is open - so a plain SDR monitor is enough to trigger it, and losing
|
||||
/// playback over it would make the feature worse than not having it.
|
||||
///
|
||||
/// `audio-delay` is the write immediately after the HDR block, so its arrival is
|
||||
/// what says initialization carried on past the refusal - and it is asserted
|
||||
/// *after* `hdr-enabled` so a refactor that reorders the two cannot leave this
|
||||
/// test passing while proving nothing. Reinstating the rethrow makes it fail,
|
||||
/// because `audio-delay` never arrives.
|
||||
///
|
||||
/// Its companion - that the tolerance is *not* narrowed to `HDR_UNSUPPORTED`,
|
||||
/// so any refusal survives - is in linux_hdr_startup_refusal_test.dart; see
|
||||
/// installHdrStartupHarness for why the two cannot share an isolate. The
|
||||
/// negative control, that a non-Linux host still aborts, is in
|
||||
/// linux_hdr_startup_non_linux_test.dart.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(installHdrStartupHarness);
|
||||
|
||||
testWidgets('an SDR output refusing HDR passthrough does not stop playback starting', (tester) async {
|
||||
await expectStartupSurvivesHdrRefusal(
|
||||
tester,
|
||||
PlatformException(code: 'HDR_UNSUPPORTED', message: 'output is not in HDR'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test_helpers/hdr_startup.dart';
|
||||
|
||||
/// Startup pushes the stored tone-mapping mode at the plane, and the refusal is
|
||||
/// swallowed for the same reason `hdr-enabled`'s is: an older libmpv rejects the
|
||||
/// property outright, and a tone-mapping preference is no reason to fail
|
||||
/// playback. And as with `hdr-enabled`, the plugin holds a mode of its own and
|
||||
/// reverts it on a refused transaction, so swallowing alone leaves Dart naming
|
||||
/// `player` while the plane tone-maps in the compositor - a disagreement the
|
||||
/// settings sheet renders and no later write corrects.
|
||||
///
|
||||
/// Dropping the correction in video_player_screen.dart makes this fail on the
|
||||
/// stored-mode expectation. See installHdrStartupHarness for why this case needs
|
||||
/// an isolate of its own.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(installHdrStartupHarness);
|
||||
|
||||
testWidgets('a refused tone-mapping write leaves the stored mode matching the plane', (tester) async {
|
||||
await expectRefusedToneMappingRestoresStoredMode(
|
||||
tester,
|
||||
PlatformException(code: 'SET_PROPERTY_FAILED', message: 'property not found'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../test_helpers/media_items.dart';
|
||||
import '../../test_helpers/mock_player_channels.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
import '../../test_helpers/pump.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -384,7 +385,7 @@ void main() {
|
||||
testBody: () async {
|
||||
final key = GlobalKey<VideoPlayerScreenState>();
|
||||
await tester.pumpWidget(_screen(key));
|
||||
await _pumpUntil(tester, () => calls.any((call) => call.method == 'dispose'));
|
||||
await pumpUntil(tester, () => calls.any((call) => call.method == 'dispose'));
|
||||
|
||||
expect(key.currentState?.player, isNull);
|
||||
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
|
||||
@@ -392,7 +393,7 @@ void main() {
|
||||
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
|
||||
|
||||
failedDispose.complete();
|
||||
await _pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty);
|
||||
await pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty);
|
||||
|
||||
final retryButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Retry'));
|
||||
final retryFocusable = tester.widget<FocusableButton>(
|
||||
@@ -400,7 +401,7 @@ void main() {
|
||||
);
|
||||
retryButton.onPressed!();
|
||||
retryFocusable.onPressed!();
|
||||
await _pumpUntil(tester, () => initializeCount == 2);
|
||||
await pumpUntil(tester, () => initializeCount == 2);
|
||||
|
||||
expect(initializeCount, 2);
|
||||
expect(key.currentState?.player, isNull);
|
||||
@@ -409,7 +410,7 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
replacementInitialize.completeError(PlatformException(code: 'late_failure', message: 'forced late failure'));
|
||||
await _pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2);
|
||||
await pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2);
|
||||
|
||||
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
|
||||
expect(initializeCount, 2);
|
||||
@@ -432,13 +433,3 @@ Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -211,8 +211,9 @@ class FakePlayer implements Player {
|
||||
@override
|
||||
bool get audioPassthroughActive => false;
|
||||
|
||||
// Audio only; there is no video output to carry HDR.
|
||||
@override
|
||||
int? get textureId => null;
|
||||
Future<bool> isHdrOutputSupported() async => false;
|
||||
|
||||
@override
|
||||
String get playerType => 'fake';
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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 'media_items.dart';
|
||||
import 'mock_player_channels.dart';
|
||||
import 'prefs.dart';
|
||||
import 'pump.dart';
|
||||
|
||||
/// Shared scaffold for the four Linux HDR startup cases.
|
||||
///
|
||||
/// Each lives in its own file with one test, because a second `VideoPlayerScreen`
|
||||
/// in the same isolate never reaches `initialize`. Measured, repeatedly: the
|
||||
/// second test's wait fails with `calls=[isModeChanged, isHDRChanged,
|
||||
/// setVideoRect]` and no `initialize` among them, so nothing the HDR block does
|
||||
/// can be observed.
|
||||
///
|
||||
/// Partly explained, and the gap is worth knowing before you retry. `PlayerBase`
|
||||
/// keeps one event-channel owner at a time; a successor built before the
|
||||
/// predecessor's release settles inherits that future, and `PlayerBase.invoke`
|
||||
/// awaits it before touching the channel, returning null on timeout rather than
|
||||
/// calling (player_base.dart:1035-1041). That accounts for the missing
|
||||
/// `initialize`, and for `isModeChanged`/`isHDRChanged` arriving anyway since
|
||||
/// DisplayModeService drives the channel directly. It does *not* account for
|
||||
/// `setVideoRect`, which goes through the same gate and still lands - so the
|
||||
/// picture is incomplete and that is the loose end to pull on.
|
||||
///
|
||||
/// Two remedies were measured and neither works. Shortening
|
||||
/// `debugNativeOwnershipDisposeTimeout` only makes `invoke` give up sooner, which
|
||||
/// is still a dropped call; at its 3 s default it outlasts [pumpUntil]'s 2 s
|
||||
/// budget, so the wait fails first. Draining the predecessor's `dispose`/`cancel`
|
||||
/// does not help either - the owner entry clears a microtask after those calls
|
||||
/// land (player_base.dart:1426-1434).
|
||||
Future<void> installHdrStartupHarness({bool linuxVideoPath = true, bool enableHdr = true}) async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
// Non-zero so the write that follows the HDR block actually happens.
|
||||
await SettingsService.instance.write(SettingsService.audioSyncOffset, 250);
|
||||
// Seeded rather than left at its default, so the value the startup path sends
|
||||
// can be asserted against a preference this harness chose - both arms of it.
|
||||
await SettingsService.instance.write(SettingsService.enableHDR, enableHdr);
|
||||
// Reaches the Linux-only tolerance on any host, so this is real coverage
|
||||
// everywhere rather than something only Linux CI ever runs - and forcing it
|
||||
// off is what makes the non-Linux abort testable at all.
|
||||
PlayerNative.debugUseLinuxVideoPlane = linuxVideoPath;
|
||||
addTearDown(() => PlayerNative.debugUseLinuxVideoPlane = null);
|
||||
}
|
||||
|
||||
/// Answers like the native plane - `initialize` succeeds with a plain `true`,
|
||||
/// the surface itself being the compositor's subsurface rather than anything
|
||||
/// Dart holds - but fails the write of [property] with [refusal].
|
||||
Future<Object?> Function(MethodCall) _refusingPlane(
|
||||
List<MethodCall> calls,
|
||||
PlatformException refusal, {
|
||||
String property = 'hdr-enabled',
|
||||
}) => (call) {
|
||||
calls.add(call);
|
||||
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == property) {
|
||||
return Future<Object?>.error(refusal);
|
||||
}
|
||||
return switch (call.method) {
|
||||
'initialize' => Future<Object?>.value(true),
|
||||
_ => Future<Object?>.value(null),
|
||||
};
|
||||
};
|
||||
|
||||
// The title reaches the "VideoPlayerScreen initialized for:" log line, so each
|
||||
// case names itself in any log a failure is diagnosed from.
|
||||
Future<void> _mountPlayerScreen(WidgetTester tester, String title) => tester.pumpWidget(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => PlaybackStateProvider(),
|
||||
child: MaterialApp(
|
||||
home: VideoPlayerScreen(metadata: testMediaItem(title: title), isOffline: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Mounts the player screen against a native plane that fails the `hdr-enabled`
|
||||
/// write with [refusal], and asserts initialization ran through the HDR block
|
||||
/// into the `audio-delay` write that follows it - and that the stored
|
||||
/// preference came out of the refusal agreeing with the plane.
|
||||
Future<void> expectStartupSurvivesHdrRefusal(
|
||||
WidgetTester tester,
|
||||
PlatformException refusal, {
|
||||
String title = 'Linux HDR startup test video',
|
||||
}) async {
|
||||
// Read before mounting, because startup rewrites it: comparing the wire value
|
||||
// against a preference the refusal has already corrected would compare the
|
||||
// correction with itself and pass whatever was sent.
|
||||
final seededHdrEnabled = SettingsService.instance.read(SettingsService.enableHDR);
|
||||
final calls = <MethodCall>[];
|
||||
final eventCalls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: _refusingPlane(calls, refusal),
|
||||
eventHandler: (call) async {
|
||||
eventCalls.add(call);
|
||||
return null;
|
||||
},
|
||||
testBody: () async {
|
||||
await _mountPlayerScreen(tester, title);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => _propertyWrites(calls).contains('audio-delay'),
|
||||
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
|
||||
);
|
||||
|
||||
// The write was attempted, not skipped, and the sentinel came after it:
|
||||
// tolerating the refusal is only meaningful if the preference was actually
|
||||
// pushed, and `audio-delay` only proves anything downstream of the block.
|
||||
expect(_propertyWrites(calls), containsAllInOrder(['hdr-enabled', 'audio-delay']));
|
||||
|
||||
// And it carried the seeded preference, not a hard-coded arm: with the
|
||||
// ternary inverted, or a different preference read, everything asserted
|
||||
// above still holds because only the property *name* is involved.
|
||||
expect(_valueWrites(calls, 'hdr-enabled'), [_hdrEnabledWire(seededHdrEnabled)]);
|
||||
|
||||
// The refused transaction hands the plugin's hdr_wanted back to what it
|
||||
// held before the write, which on a plugin this session just created is
|
||||
// off. Dart has to follow it down: the settings switch renders straight
|
||||
// off this preference, so leaving it on shows HDR enabled over an SDR
|
||||
// plane, and every later internal re-apply reads the native side - the
|
||||
// two would stay apart until the user toggled twice. Asserted on both
|
||||
// arms of the preference, so the stored-off arm proves the correction
|
||||
// does not disturb a value that was already right.
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), isFalse);
|
||||
|
||||
// Unmount and let the dispose/cancel round-trip land while the mock
|
||||
// handlers are still registered, so teardown is deterministic instead of
|
||||
// racing withMockPlayerChannels' finally. It does not make a second mount
|
||||
// in this isolate work - see the note on installHdrStartupHarness.
|
||||
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()}',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Startup pushes the tone-mapping preference just before `hdr-enabled`, and a
|
||||
/// refusal there is swallowed the same way. The plugin, though, reverts to the
|
||||
/// mode it last accepted - the compositor default, since nothing has moved it
|
||||
/// this session - so the stored preference has to follow, or the settings sheet
|
||||
/// keeps naming a mode the plane never entered with no way back but a manual
|
||||
/// toggle.
|
||||
Future<void> expectRefusedToneMappingRestoresStoredMode(WidgetTester tester, PlatformException refusal) async {
|
||||
await SettingsService.instance.write(SettingsService.hdrToneMapping, HdrToneMapping.player);
|
||||
final calls = <MethodCall>[];
|
||||
final eventCalls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: _refusingPlane(calls, refusal, property: 'hdr-tone-mapping'),
|
||||
eventHandler: (call) async {
|
||||
eventCalls.add(call);
|
||||
return null;
|
||||
},
|
||||
testBody: () async {
|
||||
await _mountPlayerScreen(tester, 'Linux HDR tone-mapping refusal video');
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => _propertyWrites(calls).contains('audio-delay'),
|
||||
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
|
||||
);
|
||||
|
||||
// The stored mode is what was pushed and refused, so the correction below
|
||||
// is a real change of mind rather than a value that was never asked for.
|
||||
expect(_valueWrites(calls, 'hdr-tone-mapping'), ['player']);
|
||||
expect(_propertyWrites(calls), containsAllInOrder(['hdr-tone-mapping', 'audio-delay']));
|
||||
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.compositor);
|
||||
|
||||
// Same deterministic teardown as [expectStartupSurvivesHdrRefusal].
|
||||
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 custom mpv config is free-form `name=value` text, applied after startup
|
||||
/// has pushed the stored HDR preferences - and neither `hdr-enabled` nor
|
||||
/// `hdr-tone-mapping` is an mpv property: the Linux plugin intercepts both and
|
||||
/// moves the plane's own HDR state. An entry for either would therefore land
|
||||
/// last, change the plane, and never reach SettingsService, which is what the
|
||||
/// settings sheet renders from. Startup drops those two names for that reason.
|
||||
///
|
||||
/// Seeded with a config that contradicts both preferences, so restoring the
|
||||
/// unfiltered pass fails here: the plane would see a second write of each
|
||||
/// carrying the config's value while the stored preferences kept the app's. A
|
||||
/// third, ordinary entry is expected to survive, so a filter that simply
|
||||
/// skipped the whole pass would fail too.
|
||||
Future<void> expectCustomConfigCannotOverrideHdrPreferences(WidgetTester tester) async {
|
||||
await SettingsService.instance.write(SettingsService.hdrToneMapping, HdrToneMapping.player);
|
||||
await SettingsService.instance.write(
|
||||
SettingsService.mpvConfigText,
|
||||
// The last four are real mpv properties the video plane owns and caches, so
|
||||
// a config write would desynchronise that cache from mpv - see
|
||||
// _appOwnedMpvProperties. The first two are not mpv properties at all.
|
||||
'hdr-enabled=no\n'
|
||||
'hdr-tone-mapping=compositor\n'
|
||||
'target-trc=pq\n'
|
||||
'target-prim=bt.2020\n'
|
||||
'target-peak=4000\n'
|
||||
'tone-mapping=bt.2390\n'
|
||||
'sub-scale=1.5\n',
|
||||
);
|
||||
// Read before mounting for the same reason as [expectStartupSurvivesHdrRefusal].
|
||||
final seededHdrEnabled = SettingsService.instance.read(SettingsService.enableHDR);
|
||||
final calls = <MethodCall>[];
|
||||
final eventCalls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
// A plane that accepts everything: the hazard here is ordering, not refusal.
|
||||
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 HDR custom config video');
|
||||
// `volume-max` is the write immediately after the custom-config pass, so
|
||||
// its arrival is what makes the lists below complete rather than merely
|
||||
// not-appended-to-yet.
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => _propertyWrites(calls).contains('volume-max'),
|
||||
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
|
||||
);
|
||||
|
||||
// One write of each, carrying the preference rather than the config line
|
||||
// that contradicts it.
|
||||
expect(_valueWrites(calls, 'hdr-enabled'), [_hdrEnabledWire(seededHdrEnabled)]);
|
||||
expect(_valueWrites(calls, 'hdr-tone-mapping'), ['player']);
|
||||
|
||||
// The rest of the config still reaches mpv: the skip goes by name.
|
||||
expect(_valueWrites(calls, 'sub-scale'), ['1.5']);
|
||||
|
||||
// And none of the four the plane owns reached mpv at all: one arriving
|
||||
// behind the plane's back leaves its cache describing a colour state mpv
|
||||
// does not hold, and the next transaction then skips the write that would
|
||||
// have corrected it.
|
||||
for (final owned in ['target-trc', 'target-prim', 'target-peak', 'tone-mapping']) {
|
||||
expect(_valueWrites(calls, owned), isEmpty, reason: '$owned is owned by the video plane');
|
||||
}
|
||||
|
||||
// And nothing dragged the preferences down to what the config asked for,
|
||||
// so the settings sheet and the plane still describe the same session.
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), seededHdrEnabled);
|
||||
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.player);
|
||||
|
||||
// Same deterministic teardown as [expectStartupSurvivesHdrRefusal].
|
||||
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
|
||||
/// left exactly as it was - the reconciliation lives inside the tolerance, and
|
||||
/// a rethrown refusal says nothing about what the plane settled on.
|
||||
Future<void> expectStartupAbortsOnHdrRefusal(WidgetTester tester, PlatformException refusal) async {
|
||||
final calls = <MethodCall>[];
|
||||
final seededHdrEnabled = SettingsService.instance.read(SettingsService.enableHDR);
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: _refusingPlane(calls, refusal),
|
||||
testBody: () async {
|
||||
await _mountPlayerScreen(tester, 'Non-Linux HDR refusal video');
|
||||
// Wait for the abort to *show*, rather than for a fixed budget to elapse.
|
||||
// The error screen is the positive marker that initialization gave up, so
|
||||
// the absence asserted below is final rather than merely not-yet.
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty,
|
||||
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
|
||||
);
|
||||
|
||||
expect(_propertyWrites(calls), contains('hdr-enabled'));
|
||||
expect(_propertyWrites(calls), isNot(contains('audio-delay')));
|
||||
expect(_valueWrites(calls, 'hdr-enabled'), [_hdrEnabledWire(seededHdrEnabled)]);
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), seededHdrEnabled);
|
||||
|
||||
// Same deterministic teardown as the positive helper. There is no release
|
||||
// to drain here: initialization aborted, so no dispose round-trip follows.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _propertyWrites(List<MethodCall> calls) => [
|
||||
for (final call in calls)
|
||||
if (call.method == 'setProperty') (call.arguments as Map)['name'] as String,
|
||||
];
|
||||
|
||||
/// The values every `setProperty` write of [name] carried, in order.
|
||||
List<String> _valueWrites(List<MethodCall> calls, String name) => [
|
||||
for (final call in calls)
|
||||
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == name)
|
||||
(call.arguments as Map)['value'] as String,
|
||||
];
|
||||
|
||||
/// The wire value startup owes a preference seeded to [enabled].
|
||||
String _hdrEnabledWire(bool enabled) => enabled ? 'yes' : 'no';
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Pumps frames until [condition] holds, yielding to the real event loop
|
||||
/// between frames so work that is not driven by the test clock - platform
|
||||
/// channel replies, microtask chains behind `runAsync` - can land.
|
||||
///
|
||||
/// Fails the test if [condition] is still false after 200 frames - 2 s of test
|
||||
/// clock, plus a 5 ms real-time yield per frame. Worth knowing that 2 s is
|
||||
/// shorter than `PlayerBase.debugNativeOwnershipDisposeTimeout`'s 3 s default,
|
||||
/// so a wait blocked behind a player handover expires here first and reports an
|
||||
/// empty observation rather than the timeout. Pass [describe] to attach the
|
||||
/// observed state to that failure.
|
||||
Future<void> pumpUntil(WidgetTester tester, bool Function() condition, {String Function()? describe}) 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, reason: describe == null ? null : 'observed ${describe()}');
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/mpv/models.dart';
|
||||
import 'package:plezy/mpv/player/player.dart';
|
||||
import 'package:plezy/mpv/player/player_native.dart';
|
||||
import 'package:plezy/mpv/player/player_state.dart';
|
||||
import 'package:plezy/mpv/player/player_streams.dart';
|
||||
import 'package:plezy/screens/settings/subtitle_styling_screen.dart';
|
||||
import 'package:plezy/services/sleep_timer_service.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/sleep_timer_service.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
|
||||
import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
import 'package:shared_preferences_platform_interface/types.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/theme.dart';
|
||||
@@ -77,7 +84,7 @@ void main() {
|
||||
appliedRates.add(rate);
|
||||
},
|
||||
);
|
||||
await _pumpHostedSheet(tester, player);
|
||||
await _pumpSheetViaOverlayRoute(tester, player);
|
||||
|
||||
await tester.tap(find.text('Playback Speed'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -116,10 +123,10 @@ void main() {
|
||||
|
||||
testWidgets('failed HDR write restores the toggle without persisting', (tester) async {
|
||||
final propertyWrite = Completer<void>();
|
||||
var writeCount = 0;
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(
|
||||
onSetProperty: (_, _) {
|
||||
writeCount++;
|
||||
onSetProperty: (name, value) {
|
||||
writes.add((name, value));
|
||||
return propertyWrite.future;
|
||||
},
|
||||
);
|
||||
@@ -143,14 +150,20 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(tester.widget<Switch>(toggle).value, isTrue);
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
|
||||
expect(writeCount, 1);
|
||||
// The name carries as much weight as the count: the plane intercepts this
|
||||
// exact property, and any other name falls through to mpv as a real write.
|
||||
expect(writes, [('hdr-enabled', 'no')]);
|
||||
});
|
||||
|
||||
testWidgets('accepted HDR write persists once', (tester) async {
|
||||
var writeCount = 0;
|
||||
// The switch springing back on its own reads as a lost tap. This message is
|
||||
// what tells the user the surface itself cannot carry HDR and no retry will
|
||||
// change that, so it has to survive any rework of the write path.
|
||||
testWidgets('a plane that can never carry HDR says so', (tester) async {
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(
|
||||
onSetProperty: (_, _) async {
|
||||
writeCount++;
|
||||
onSetProperty: (name, value) async {
|
||||
writes.add((name, value));
|
||||
throw PlatformException(code: 'HDR_UNSUPPORTED', message: 'no colour-management protocol');
|
||||
},
|
||||
);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: true);
|
||||
@@ -162,38 +175,291 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(writeCount, 1);
|
||||
expect(find.text(t.videoSettings.hdrUnsupported), findsOneWidget);
|
||||
// Once. A refusal is not something to compensate for: nothing was recorded,
|
||||
// so there is nothing to put back and no second write to explain.
|
||||
expect(writes, [('hdr-enabled', 'no')]);
|
||||
expect(tester.widget<Switch>(toggle).value, isTrue);
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('an accepted HDR write pushes one hdr-enabled write per toggle', (tester) async {
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: true);
|
||||
await tester.scrollUntilVisible(find.text('HDR'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
final tile = find.ancestor(of: find.text('HDR'), matching: find.byType(ListTile)).first;
|
||||
final toggle = find.descendant(of: tile, matching: find.byType(Switch));
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(writes, [('hdr-enabled', 'no')]);
|
||||
expect(tester.widget<Switch>(toggle).value, isFalse);
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), isFalse);
|
||||
|
||||
// Toggled back so the expectation cannot be met by a sheet that sends 'no'
|
||||
// whichever way the switch went.
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(writes, [('hdr-enabled', 'no'), ('hdr-enabled', 'yes')]);
|
||||
expect(tester.widget<Switch>(toggle).value, isTrue);
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('hides the HDR controls when the host declares the surface cannot carry HDR', (tester) async {
|
||||
await _pumpSheet(tester);
|
||||
|
||||
// Scroll past where the HDR rows would sit. Without a following anchor the
|
||||
// absence would also be satisfied by the ListView simply not having built
|
||||
// that far yet, which is not the contract under test.
|
||||
await tester.scrollUntilVisible(find.text('Auto-Play Next'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
expect(find.text('HDR'), findsNothing);
|
||||
expect(find.text('HDR Tone Mapping'), findsNothing);
|
||||
});
|
||||
|
||||
// The sheet resolves both the capability probe and the tone-mapping row through
|
||||
// PlayerNative.usesLinuxVideoPlane, so setting the documented override puts the
|
||||
// plane's behaviour under test on any host.
|
||||
group('on the Linux video plane', () {
|
||||
setUp(() {
|
||||
PlayerNative.debugUseLinuxVideoPlane = true;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PlayerNative.debugUseLinuxVideoPlane = null;
|
||||
});
|
||||
|
||||
// supportsHdrControl left null so the sheet asks the player, which is the path
|
||||
// that ships on Linux. The cases above inject the answer and so cover only the
|
||||
// gate, not the probe behind it.
|
||||
testWidgets('hides the HDR controls when the capability probe answers no', (tester) async {
|
||||
final player = _FakeSettingsPlayer(hdrOutputSupported: false);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
|
||||
|
||||
expect(find.text('Auto-Play Next'), findsOneWidget, reason: 'the list should be fully built');
|
||||
expect(find.text('HDR'), findsNothing);
|
||||
expect(find.text('HDR Tone Mapping'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a player reporting an HDR output reveals the controls', (tester) async {
|
||||
final player = _FakeSettingsPlayer(hdrOutputSupported: true);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
|
||||
|
||||
expect(find.text('HDR'), findsOneWidget);
|
||||
expect(find.text('HDR Tone Mapping'), findsOneWidget);
|
||||
});
|
||||
|
||||
// Dragging the window onto an HDR monitor raises no lifecycle event on
|
||||
// Wayland, so this stream is the sheet's only notice that the probe now
|
||||
// answers differently.
|
||||
testWidgets('an HDR output arriving under the window reveals the controls', (tester) async {
|
||||
final player = _FakeSettingsPlayer(hdrOutputSupported: false);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
|
||||
expect(find.text('HDR'), findsNothing);
|
||||
|
||||
player.hdrOutputSupported = true;
|
||||
player.hdrOutputChanged.add(null);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('HDR'), findsOneWidget);
|
||||
expect(find.text('HDR Tone Mapping'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('losing the HDR output takes the controls away again', (tester) async {
|
||||
final player = _FakeSettingsPlayer(hdrOutputSupported: true);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
|
||||
expect(find.text('HDR'), findsOneWidget);
|
||||
|
||||
player.hdrOutputSupported = false;
|
||||
player.hdrOutputChanged.add(null);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Auto-Play Next'), findsOneWidget, reason: 'the list should be fully built');
|
||||
expect(find.text('HDR'), findsNothing);
|
||||
expect(find.text('HDR Tone Mapping'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the output-changed subscription does not outlive the sheet', (tester) async {
|
||||
final player = _FakeSettingsPlayer(hdrOutputSupported: false);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
|
||||
final probesWhileMounted = player.probeCount;
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
player.hdrOutputSupported = true;
|
||||
player.hdrOutputChanged.add(null);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The plane outlives any one sheet, so a subscription left behind keeps
|
||||
// probing - and setState()s - on a disposed State.
|
||||
expect(player.hdrOutputChanged.hasListener, isFalse);
|
||||
expect(player.probeCount, probesWhileMounted);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('selecting a tone-mapping mode pushes it to mpv and persists it', (tester) async {
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: true, withSheetHost: true);
|
||||
await tester.scrollUntilVisible(find.text('HDR Tone Mapping'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
await tester.tap(find.text('HDR Tone Mapping'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Player'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(writes, [('hdr-tone-mapping', 'player')]);
|
||||
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.player);
|
||||
});
|
||||
|
||||
testWidgets('a refused tone-mapping write leaves the stored mode alone', (tester) async {
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(
|
||||
onSetProperty: (name, value) async {
|
||||
writes.add((name, value));
|
||||
throw StateError('rejected');
|
||||
},
|
||||
);
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: true, withSheetHost: true);
|
||||
await tester.scrollUntilVisible(find.text('HDR Tone Mapping'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
await tester.tap(find.text('HDR Tone Mapping'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Player'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// mpv is asked before the setting is written, precisely so a refusal
|
||||
// cannot leave the stored mode claiming one the player never entered.
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(writes, [('hdr-tone-mapping', 'player')]);
|
||||
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.compositor);
|
||||
});
|
||||
});
|
||||
|
||||
// The refusals covered above all come from the player. This is the other half:
|
||||
// the player takes the value and the store loses it, which is the case that
|
||||
// used to leave the plane carrying a policy neither the sheet nor the stored
|
||||
// preference named for the rest of the session.
|
||||
group('when the preference store refuses the write', () {
|
||||
late _RejectingPrefsStore store;
|
||||
|
||||
setUp(() async {
|
||||
// The tone-mapping row is gated on the plane. Installing the store belongs
|
||||
// out here too: SharedPreferencesWithCache binds the platform when it is
|
||||
// created, and creating it reads the store off disk, which a testWidgets
|
||||
// body cannot await.
|
||||
PlayerNative.debugUseLinuxVideoPlane = true;
|
||||
store = _RejectingPrefsStore(
|
||||
// Seeded with the values both controls start on, so a rejected write is
|
||||
// a rejected *overwrite* and the surviving value is an explicit one
|
||||
// rather than the absence of a key.
|
||||
initial: {SettingsService.enableHDR.key: true, SettingsService.hdrToneMapping.key: 'compositor'},
|
||||
refused: {SettingsService.enableHDR.key, SettingsService.hdrToneMapping.key},
|
||||
);
|
||||
SharedPreferencesAsyncPlatform.instance = store;
|
||||
// resetSharedPreferencesForTest already registered the teardown that puts
|
||||
// the previous platform back.
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PlayerNative.debugUseLinuxVideoPlane = null;
|
||||
});
|
||||
|
||||
testWidgets('a lost HDR preference write puts the plane back on the stored policy', (tester) async {
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: true);
|
||||
await tester.scrollUntilVisible(find.text('HDR'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
final tile = find.ancestor(of: find.text('HDR'), matching: find.byType(ListTile)).first;
|
||||
final toggle = find.descendant(of: tile, matching: find.byType(Switch));
|
||||
expect(tester.widget<Switch>(toggle).value, isTrue);
|
||||
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
// The plane accepted 'no' and the store then lost it, so the plane has to
|
||||
// be told 'yes' again. Leaving it at 'no' is the divergence.
|
||||
expect(writes, [('hdr-enabled', 'no'), ('hdr-enabled', 'yes')]);
|
||||
expect(tester.widget<Switch>(toggle).value, isTrue);
|
||||
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
|
||||
expect(await store.durable(SettingsService.enableHDR.key), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a lost tone-mapping preference write puts the plane back on the stored mode', (tester) async {
|
||||
final writes = <(String, String)>[];
|
||||
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
|
||||
await _pumpSheet(tester, player: player, supportsHdrControl: true, withSheetHost: true);
|
||||
await tester.scrollUntilVisible(find.text('HDR Tone Mapping'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
await tester.tap(find.text('HDR Tone Mapping'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Player'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(writes, [('hdr-tone-mapping', 'player'), ('hdr-tone-mapping', 'compositor')]);
|
||||
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.compositor);
|
||||
expect(await store.durable(SettingsService.hdrToneMapping.key), 'compositor');
|
||||
// The pick did not take, so the picker stays open with the tick where it
|
||||
// was. A tick on 'Player' would mean the sheet is showing a mode the
|
||||
// stored preference does not name.
|
||||
expect(_tickOn('Compositor'), findsOneWidget);
|
||||
expect(_tickOn('Player'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The tick marking the selected option in one of the sheet's picker views.
|
||||
Finder _tickOn(String label) => find.descendant(
|
||||
of: find.ancestor(of: find.text(label), matching: find.byType(ListTile)).first,
|
||||
matching: find.byIcon(Symbols.check_rounded),
|
||||
);
|
||||
|
||||
Future<void> _pumpSheet(
|
||||
WidgetTester tester, {
|
||||
bool canControl = false,
|
||||
Player? player,
|
||||
bool supportsHdrControl = false,
|
||||
// Explicitly false by default so the sheet does not consult the platform.
|
||||
// Pass null to exercise the capability probe instead.
|
||||
bool? supportsHdrControl = false,
|
||||
// Option views that dismiss themselves on selection reach
|
||||
// OverlaySheetController.of(), which asserts without a host above it.
|
||||
bool withSheetHost = false,
|
||||
// The default is short enough that the ListView is lazy: callers that need a
|
||||
// row present without dragging to it pass a taller sheet, which builds all of
|
||||
// them.
|
||||
double height = 700,
|
||||
}) async {
|
||||
final sheet = SizedBox(
|
||||
width: 900,
|
||||
height: height,
|
||||
child: VideoSettingsSheet(
|
||||
player: player ?? _FakeSettingsPlayer(),
|
||||
supportsHdrControl: supportsHdrControl,
|
||||
trackControlsState: TrackControlsState(canControl: canControl),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(extensions: const [testMonoTokensAnimated]),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 900,
|
||||
height: 700,
|
||||
child: VideoSettingsSheet(
|
||||
player: player ?? _FakeSettingsPlayer(),
|
||||
supportsHdrControl: supportsHdrControl,
|
||||
trackControlsState: TrackControlsState(canControl: canControl),
|
||||
),
|
||||
),
|
||||
),
|
||||
home: Scaffold(body: withSheetHost ? OverlaySheetHost(child: sheet) : sheet),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _pumpHostedSheet(WidgetTester tester, Player player) async {
|
||||
Future<void> _pumpSheetViaOverlayRoute(WidgetTester tester, Player player) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(extensions: const [testMonoTokensAnimated]),
|
||||
@@ -221,31 +487,51 @@ Future<void> _pumpHostedSheet(WidgetTester tester, Player player) async {
|
||||
}
|
||||
|
||||
class _FakeSettingsPlayer implements Player {
|
||||
_FakeSettingsPlayer({this.onSetProperty, this.onSetRate})
|
||||
: _streams = PlayerStreams(
|
||||
playing: const Stream<bool>.empty(),
|
||||
completed: const Stream<bool>.empty(),
|
||||
buffering: const Stream<bool>.empty(),
|
||||
position: const Stream<Duration>.empty(),
|
||||
duration: const Stream<Duration>.empty(),
|
||||
seekable: const Stream<bool>.empty(),
|
||||
buffer: const Stream<Duration>.empty(),
|
||||
volume: const Stream<double>.empty(),
|
||||
rate: const Stream<double>.empty(),
|
||||
tracks: const Stream<Tracks>.empty(),
|
||||
track: const Stream<TrackSelection>.empty(),
|
||||
log: const Stream<PlayerLog>.empty(),
|
||||
error: const Stream<PlayerError>.empty(),
|
||||
audioDevice: const Stream<AudioDevice>.empty(),
|
||||
audioDevices: const Stream<List<AudioDevice>>.empty(),
|
||||
bufferRanges: const Stream<List<BufferRange>>.empty(),
|
||||
playbackRestart: const Stream<void>.empty(),
|
||||
backendSwitched: const Stream<void>.empty(),
|
||||
);
|
||||
_FakeSettingsPlayer({this.onSetProperty, this.onSetRate, this.hdrOutputSupported = false});
|
||||
|
||||
/// The plane's notice that the output under the window changed, which is the
|
||||
/// only thing that moves [isHdrOutputSupported]'s answer while a sheet is up.
|
||||
/// Closed by [dispose], which the tests that emit on it call through
|
||||
/// `addTearDown`.
|
||||
final hdrOutputChanged = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
||||
await hdrOutputChanged.close();
|
||||
}
|
||||
|
||||
late final PlayerStreams _streams = PlayerStreams(
|
||||
playing: const Stream<bool>.empty(),
|
||||
completed: const Stream<bool>.empty(),
|
||||
buffering: const Stream<bool>.empty(),
|
||||
position: const Stream<Duration>.empty(),
|
||||
duration: const Stream<Duration>.empty(),
|
||||
seekable: const Stream<bool>.empty(),
|
||||
buffer: const Stream<Duration>.empty(),
|
||||
volume: const Stream<double>.empty(),
|
||||
rate: const Stream<double>.empty(),
|
||||
tracks: const Stream<Tracks>.empty(),
|
||||
track: const Stream<TrackSelection>.empty(),
|
||||
log: const Stream<PlayerLog>.empty(),
|
||||
error: const Stream<PlayerError>.empty(),
|
||||
audioDevice: const Stream<AudioDevice>.empty(),
|
||||
audioDevices: const Stream<List<AudioDevice>>.empty(),
|
||||
bufferRanges: const Stream<List<BufferRange>>.empty(),
|
||||
playbackRestart: const Stream<void>.empty(),
|
||||
backendSwitched: const Stream<void>.empty(),
|
||||
hdrOutputChanged: hdrOutputChanged.stream,
|
||||
);
|
||||
|
||||
final PlayerStreams _streams;
|
||||
final Future<void> Function(String name, String value)? onSetProperty;
|
||||
final Future<void> Function(double rate)? onSetRate;
|
||||
bool hdrOutputSupported;
|
||||
int probeCount = 0;
|
||||
|
||||
@override
|
||||
Future<bool> isHdrOutputSupported() async {
|
||||
probeCount++;
|
||||
return hdrOutputSupported;
|
||||
}
|
||||
|
||||
@override
|
||||
PlayerState get state => const PlayerState();
|
||||
@@ -272,3 +558,37 @@ class _FakeSettingsPlayer implements Player {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
/// A preference store that loses the durable half of a write.
|
||||
///
|
||||
/// Substituting the platform is how this suite supplies a store at all (see
|
||||
/// [resetSharedPreferencesForTest]), and it is the only layer that can be lost:
|
||||
/// `SharedPreferencesWithCache` sits above it and is not subclassable.
|
||||
final class _RejectingPrefsStore extends InMemorySharedPreferencesAsync {
|
||||
_RejectingPrefsStore({required Map<String, Object> initial, required this.refused}) : super.withData(initial);
|
||||
|
||||
/// Only these keys. Creating the cache runs the legacy-to-async migration,
|
||||
/// which stores its own completion marker and must be allowed to.
|
||||
final Set<String> refused;
|
||||
|
||||
/// What survived, which is what the next launch reads. Not
|
||||
/// `SettingsService.read`: that answers from the in-process copy, which a
|
||||
/// refused write moves before the platform call it then fails.
|
||||
Future<Object?> durable(String key) async {
|
||||
final stored = await getPreferences(
|
||||
GetPreferencesParameters(filter: PreferencesFilters(allowList: {key})),
|
||||
const SharedPreferencesOptions(),
|
||||
);
|
||||
return stored[key];
|
||||
}
|
||||
|
||||
Future<bool> _refuse(String key) async => throw StateError('the preference store refused "$key"');
|
||||
|
||||
@override
|
||||
Future<bool> setBool(String key, bool value, SharedPreferencesOptions options) =>
|
||||
refused.contains(key) ? _refuse(key) : super.setBool(key, value, options);
|
||||
|
||||
@override
|
||||
Future<bool> setString(String key, String value, SharedPreferencesOptions options) =>
|
||||
refused.contains(key) ? _refuse(key) : super.setString(key, value, options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user