Files
plezy/test/widgets/video_controls_window_focus_test.dart
T
edde746 9d51a040c3 fix(player): keep the remote on the player surface after a window switch (#1797)
Returning to the desktop window with the chrome still up left arrow keys
navigating the OSD instead of seeking: the first press seeked and silently
moved focus onto Play/Pause, and every press after that walked the buttons.

A window blur drops Flutter's primary focus to the root scope, so the player
screen's reclaim parks it on its own node. The only handoff back down to the
controls was the chrome visible->hidden transition, so with the OSD up nothing
reclaimed it -- hence the reported workarounds of letting the controls hide, or
moving the pointer off the player and back. Pointer exit normally hides the
chrome and masks this, which is why it only shows when the pointer stays over
the player while another window takes focus.

Hand the surface back on window re-activation, next to the existing hide-path
claim, and rename the helper since it is no longer hidden-chrome specific. The
claim runs synchronously because a platform callback is not guaranteed to be
followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the
two no longer compete.

Also gate the screen's self-heal so a directional key no longer pulls focus into
the OSD when "Video Player Navigation" is off -- Tab and select keep their path
in, which the ungated return value would otherwise consume with nowhere to go.
2026-08-05 15:24:57 +02:00

243 lines
9.1 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:window_manager/window_manager.dart';
import 'package:plezy/database/app_database.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';
/// Regression coverage for #1797: returning to the desktop window while the
/// chrome is up left primary focus parked on the enclosing screen node. The
/// chrome-hide transition is the only other handoff back to the player surface,
/// so with the OSD visible nothing reclaimed it, and the screen's self-heal
/// turned the next arrow key into a jump onto the Play/Pause button — from
/// there every further arrow navigated the OSD instead of seeking.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('window refocus', () {
late _PlayingPlayer player;
late PlayerChromeController chrome;
late PlayerToastController toast;
late VideoVolumeController volume;
late PlaybackStateProvider playbackState;
late WatchTogetherProvider watchTogether;
late AppDatabase database;
late ValueNotifier<bool> hasFirstFrame;
late FocusNode screenFocusNode;
late List<LogicalKeyboardKey> keysReachingScreen;
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(false);
database = AppDatabase.forTesting(NativeDatabase.memory());
player = _PlayingPlayer();
// The reported state: the viewer came back with the OSD still up.
chrome = PlayerChromeController(initiallyVisible: true);
toast = PlayerToastController();
volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
playbackState = PlaybackStateProvider();
watchTogether = WatchTogetherProvider();
hasFirstFrame = ValueNotifier<bool>(true);
screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
keysReachingScreen = <LogicalKeyboardKey>[];
});
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();
});
Widget shell(Widget child) {
return 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,
onKeyEvent: (node, event) {
if (event is KeyDownEvent) keysReachingScreen.add(event.logicalKey);
return KeyEventResult.ignored;
},
child: child,
),
),
),
),
);
}
/// Reproduces the post-blur layout: the screen node owns primary focus (the
/// player screen reclaims it whenever focus leaves the subtree) while the
/// controls are mounted with their chrome up.
Future<void> pumpControlsUnderScreenFocus(WidgetTester tester) async {
await tester.pumpWidget(shell(const SizedBox.expand()));
await tester.pump();
expect(screenFocusNode.hasPrimaryFocus, isTrue);
await tester.pumpWidget(
shell(
PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'window-refocus'),
toastController: toast,
chromeController: chrome,
hasFirstFrame: hasFirstFrame,
canNavigateMediaItems: false,
),
),
);
await tester.pumpAndSettle();
// The controls' own `autofocus` cannot win the scope back, and a visible
// chrome never runs the hide transition that hands focus down — so this
// is exactly the state a window re-activation leaves behind.
expect(
screenFocusNode.hasPrimaryFocus,
isTrue,
reason: 'precondition: focus is stranded on the screen node with the OSD up',
);
}
testWidgets('re-activating the window hands the remote back to the player surface', (tester) async {
await pumpControlsUnderScreenFocus(tester);
(tester.state(find.byType(PlexVideoControls)) as WindowListener).onWindowFocus();
await tester.pumpAndSettle();
expect(
screenFocusNode.hasPrimaryFocus,
isFalse,
reason: 'the player surface owns the remote again, not the screen node',
);
// The reported symptom lands on the second press: the first is answered
// while the very same event also steals focus into the OSD.
for (var press = 0; press < 2; press++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
}
expect(
keysReachingScreen,
isEmpty,
reason: 'no arrow may reach the screen self-heal, or it jumps focus onto Play/Pause',
);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a control the viewer focused on purpose keeps the remote', (tester) async {
await pumpControlsUnderScreenFocus(tester);
// Deliberate focus inside the OSD, as traversal or a TV remote leaves it.
tester.state<DesktopVideoControlsState>(find.byType(DesktopVideoControls)).requestPlayPauseFocus();
await tester.pumpAndSettle();
final focusedControl = FocusManager.instance.primaryFocus;
expect(focusedControl?.debugLabel, 'PlayPause', reason: 'a control took the remote');
(tester.state(find.byType(PlexVideoControls)) as WindowListener).onWindowFocus();
await tester.pumpAndSettle();
expect(
FocusManager.instance.primaryFocus,
same(focusedControl),
reason: 'the surface claim must not yank focus off a control the viewer chose',
);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
});
}
/// Minimal [Player] reporting steady playback, the state the player settles
/// into once the media is open.
class _PlayingPlayer implements Player {
final List<Duration> seeks = [];
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 {
seeks.add(position);
_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);
}