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.
245 lines
9.2 KiB
Dart
245 lines
9.2 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();
|
|
|
|
// Mounting parks the remote on the player surface. A window blur then
|
|
// drops focus to the root scope and the screen node's reclaim takes it,
|
|
// which is the state this suite is about — stage it explicitly.
|
|
screenFocusNode.requestFocus();
|
|
await tester.pumpAndSettle();
|
|
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);
|
|
}
|