Files
plezy/test/widgets/video_controls_select_key_test.dart
T
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00

356 lines
14 KiB
Dart

import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import 'package:plezy/widgets/video_controls/desktop_video_controls.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
/// Pressing Enter on the player surface used to raise the chrome *and* drop
/// focus onto Play/Pause, while the global input-mode tracker separately
/// switched the whole app into keyboard mode — even with "Video Player
/// Navigation" off. Enter activates whatever already has focus; it is not a
/// request to start navigating, so it must leave focus where the viewer left it
/// while still doing its job. Tab and a remote's OK remain the deliberate ways
/// into the OSD.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _TogglePlayer player;
late PlayerChromeController chrome;
late PlayerToastController toast;
late VideoVolumeController volume;
late PlaybackStateProvider playbackState;
late WatchTogetherProvider watchTogether;
late AppDatabase database;
late ValueNotifier<bool> hasFirstFrame;
late SettingsService settings;
late FocusNode screenFocusNode;
var toggles = 0;
Future<void> setNavigationEnabled(bool value) => settings.write(SettingsService.videoPlayerNavigationEnabled, value);
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
// Desktop with a pointer: the configuration the report came from.
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(true);
database = AppDatabase.forTesting(NativeDatabase.memory());
player = _TogglePlayer();
chrome = PlayerChromeController(initiallyVisible: false);
toast = PlayerToastController();
volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
playbackState = PlaybackStateProvider();
watchTogether = WatchTogetherProvider();
hasFirstFrame = ValueNotifier<bool>(true);
screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
toggles = 0;
});
tearDown(() async {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
hasFirstFrame.dispose();
screenFocusNode.dispose();
volume.dispose();
playbackState.dispose();
watchTogether.dispose();
chrome.dispose();
toast.dispose();
await database.close();
});
/// Mirrors the production tree: the screen-level node autofocuses during the
/// loading phase and already holds the remote by the time the controls mount,
/// so the controls' own `autofocus` cannot win it back. Without that, the
/// surface would take focus by default and the startup claim would look
/// correct even when it is not.
Widget shell(Widget child) => InputModeTracker(
child: MultiProvider(
providers: [
Provider<AppDatabase>.value(value: database),
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.windows, extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: Focus(focusNode: screenFocusNode, autofocus: true, child: child),
),
),
),
),
);
Future<void> pumpPlayer(WidgetTester tester) async {
await tester.pumpWidget(shell(const SizedBox.expand()));
await tester.pump();
expect(screenFocusNode.hasPrimaryFocus, isTrue, reason: 'precondition: the screen node owns the remote');
await tester.pumpWidget(
shell(
PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'select-key'),
toastController: toast,
chromeController: chrome,
hasFirstFrame: hasFirstFrame,
canNavigateMediaItems: false,
onPlayPauseRequested: (_) async => toggles++,
),
),
);
await tester.pumpAndSettle();
}
Future<void> press(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pumpAndSettle();
}
String? focusLabel() => FocusManager.instance.primaryFocus?.debugLabel;
InputMode currentMode(WidgetTester tester) =>
InputModeTracker.of(tester.element(find.byType(PlexVideoControls)), listen: false);
/// Mounts the player, runs [body], then unmounts and disarms the auto-hide
/// timer so the harness's pending-timer check stays honest.
void playerTest(String description, Future<void> Function(WidgetTester tester) body) {
testWidgets(description, (tester) async {
await pumpPlayer(tester);
await body(tester);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
}
group('player navigation disabled', () {
setUp(() => setNavigationEnabled(false));
playerTest('the player surface, not the screen node, owns the remote once mounted', (tester) async {
expect(focusLabel(), 'PlayerSurface');
});
playerTest('Enter raises the chrome and toggles playback without taking focus', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
expect(chrome.controlsVisible, isTrue, reason: 'Select is the show-me-the-controls affordance');
expect(toggles, 1);
expect(focusLabel(), 'PlayerSurface', reason: 'a keyboard Enter must not start a focus session');
expect(currentMode(tester), InputMode.pointer, reason: 'and must not arm focus chrome app-wide');
});
playerTest('Enter keeps toggling once the chrome is up', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
await press(tester, LogicalKeyboardKey.enter);
expect(toggles, 2, reason: 'Select must not become a one-shot key when the chrome is visible');
expect(focusLabel(), 'PlayerSurface');
});
playerTest('a remote OK does hand the chrome focus', (tester) async {
await press(tester, LogicalKeyboardKey.select);
expect(chrome.controlsVisible, isTrue);
expect(focusLabel(), 'PlayPause', reason: 'a remote has no pointer, so OK is a navigation request');
expect(currentMode(tester), InputMode.keyboard);
});
playerTest('Tab is the keyboard way into the OSD, and keeps traversing inside it', (tester) async {
await press(tester, LogicalKeyboardKey.tab);
expect(chrome.controlsVisible, isTrue);
expect(focusLabel(), 'PlayPause', reason: 'Tab with the chrome down raises it and hands it focus');
// The surface handler must not swallow Tab once focus is inside the OSD,
// or the viewer is stranded on the control Tab first landed on.
await press(tester, LogicalKeyboardKey.tab);
expect(
focusLabel(),
isNot(anyOf('PlayPause', 'PlayerSurface')),
reason: 'app-level NextFocusAction must reach the next OSD control',
);
});
playerTest('an arrow seeks without switching the app into keyboard mode', (tester) async {
await press(tester, LogicalKeyboardKey.arrowRight);
expect(currentMode(tester), InputMode.pointer, reason: 'arrows are playback shortcuts here');
expect(focusLabel(), 'PlayerSurface');
});
});
group('player navigation enabled', () {
setUp(() => setNavigationEnabled(true));
playerTest('Enter shows the chrome and toggles, leaving focus put', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
expect(chrome.controlsVisible, isTrue);
expect(toggles, 1);
// Opting into player navigation buys arrow keys, not a focus session from
// a plain Enter: focus and input mode move together or not at all.
expect(focusLabel(), 'PlayerSurface');
expect(currentMode(tester), InputMode.pointer);
});
playerTest('ArrowUp raises the chrome onto Play/Pause', (tester) async {
await press(tester, LogicalKeyboardKey.arrowUp);
expect(chrome.controlsVisible, isTrue);
expect(focusLabel(), 'PlayPause');
expect(currentMode(tester), InputMode.keyboard);
});
playerTest('ArrowUp hands focus over when the chrome is already up', (tester) async {
chrome.show();
await tester.pumpAndSettle();
expect(focusLabel(), 'PlayerSurface', reason: 'precondition: nothing in the chrome owns focus');
await press(tester, LogicalKeyboardKey.arrowUp);
expect(focusLabel(), 'PlayPause', reason: 'the arrow must not be consumed into a dead end');
});
playerTest('a horizontal arrow hands focus over when the chrome is already up', (tester) async {
chrome.show();
await tester.pumpAndSettle();
expect(focusLabel(), 'PlayerSurface', reason: 'precondition: nothing in the chrome owns focus');
await press(tester, LogicalKeyboardKey.arrowRight);
// The surface only owns horizontals while the chrome is down, so this
// arrow promotes the app into keyboard mode; focus has to become visible
// with it or the two diverge.
expect(currentMode(tester), InputMode.keyboard);
expect(focusLabel(), 'PlayPause', reason: 'the arrow must not be consumed into a dead end');
});
playerTest('a horizontal arrow seeks under the chrome without arming focus', (tester) async {
await press(tester, LogicalKeyboardKey.arrowRight);
expect(currentMode(tester), InputMode.pointer, reason: 'a hidden-chrome seek is not navigation');
expect(chrome.controlsVisible, isFalse);
});
});
playerTest('the OSD focus entry point is raw mechanism, not policy', (tester) async {
await setNavigationEnabled(false);
chrome.show();
await tester.pumpAndSettle();
// Internal hand-offs — the skip-marker button's ArrowDown, an item swap —
// must keep working even with navigation off and no keyboard session.
tester.state<DesktopVideoControlsState>(find.byType(DesktopVideoControls)).requestPlayPauseFocus();
await tester.pumpAndSettle();
expect(focusLabel(), 'PlayPause');
});
// The player surface must own the remote from the moment it mounts, whatever
// the chrome is doing. When it does not, the screen node answers the first
// key with its chrome-raising self-heal instead of a playback shortcut.
group('startup hands the remote to the player surface', () {
setUp(() {
chrome.dispose();
chrome = PlayerChromeController(initiallyVisible: true);
});
playerTest('when the route opens with the chrome already up (desktop)', (tester) async {
expect(focusLabel(), 'PlayerSurface');
});
playerTest('and Enter there toggles playback instead of raising a self-heal', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
expect(toggles, 1);
expect(focusLabel(), 'PlayerSurface');
expect(currentMode(tester), InputMode.pointer);
});
});
group('startup on a television', () {
setUp(() async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
await setNavigationEnabled(true);
});
// A TV route opens with the chrome down, navigation on and keyboard mode
// already active — the exact combination that used to skip the claim.
playerTest('the surface owns the remote even though the chrome starts down', (tester) async {
expect(chrome.controlsVisible, isFalse);
expect(focusLabel(), 'PlayerSurface');
});
});
}
/// Minimal [Player] reporting steady playback; transport is routed to the
/// widget's `onPlayPauseRequested` callback so the test can count toggles.
class _TogglePlayer implements Player {
Duration _position = const Duration(minutes: 5);
@override
String get playerType => 'mpv';
@override
PlayerState get state =>
PlayerState(playing: true, position: _position, duration: const Duration(minutes: 45), seekable: true);
@override
Future<void> seek(Duration position) async => _position = position;
@override
PlayerStreams get 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(),
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}