fix(player): tighten staged back navigation

This commit is contained in:
edde746
2026-07-11 23:11:25 +02:00
parent 5af3a926d7
commit a23c7f215c
5 changed files with 306 additions and 24 deletions
+36
View File
@@ -73,6 +73,17 @@ import '../watch_together/watch_together.dart';
// navigation/main_screen_scope.dart (re-exported above) so widgets like the
// browse rail can import the scope without an import cycle through this file.
@visibleForTesting
bool shouldHandleMacOsRootEscape({
required bool isMacOS,
required bool isPhysicalKeyboardEvent,
required LogicalKeyboardKey logicalKey,
required bool isCurrentRoute,
required bool isHomeTab,
}) {
return isMacOS && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab;
}
@visibleForTesting
({double left, double width}) mainScreenSideNavigationContentLayout({
required double viewportWidth,
@@ -1299,6 +1310,29 @@ class _MainScreenState extends State<MainScreen>
return KeyEventResult.handled;
}
/// On macOS, native fullscreen is window state shared by every route.
/// Player Escape therefore leaves it alone; only root Home owns the
/// conventional Escape-to-leave-fullscreen behavior.
KeyEventResult _handleMacOsRootEscape(KeyEvent event) {
final tabs = _getVisibleTabs(_isOffline);
final shouldHandle = shouldHandleMacOsRootEscape(
isMacOS: Platform.isMacOS,
isPhysicalKeyboardEvent: event.isPhysicalKeyboardEvent,
logicalKey: event.logicalKey,
isCurrentRoute: ModalRoute.of(context)?.isCurrent == true,
isHomeTab: tabs.isNotEmpty && _currentTab == tabs.first.id,
);
if (!shouldHandle) return KeyEventResult.ignored;
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
unawaited(FullscreenStateManager().exitFullscreenIfActive());
}
return event is KeyDownEvent || event is KeyRepeatEvent || event is KeyUpEvent
? KeyEventResult.handled
: KeyEventResult.ignored;
}
/// Handle Cmd+F (macOS) / Ctrl+F (Windows/Linux) to navigate to search.
KeyEventResult _handleSearchShortcut(KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
@@ -1693,6 +1727,8 @@ class _MainScreenState extends State<MainScreen>
canPop: false,
child: Focus(
onKeyEvent: (node, event) {
final rootEscapeResult = _handleMacOsRootEscape(event);
if (rootEscapeResult == KeyEventResult.handled) return rootEscapeResult;
final fullscreenResult = _handleFullscreenShortcut(event);
if (fullscreenResult == KeyEventResult.handled) return fullscreenResult;
final searchResult = _handleSearchShortcut(event);
+23 -1
View File
@@ -582,8 +582,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
chromeController: _chromeController,
isPromptOpen: () => _showPlayNextDialog || _showStillWatchingPrompt,
dismissPrompt: _dismissPlaybackPromptForBack,
isChromePresented: () => _isPlayerInitialized && player != null && _chromeController.controlsPresented,
isChromePresented: () =>
_isPlayerInitialized && player != null && _hasFirstFrame.value && _chromeController.controlsPresented,
exitFullscreenIfActive: FullscreenStateManager().exitFullscreenIfActive,
// macOS fullscreen belongs to the app window, not the player route.
// Escape stages through chrome/player Back and the root Home screen
// owns leaving native fullscreen.
physicalEscapeExitsFullscreen: !Platform.isMacOS,
exitPlayer: () => unawaited(_handleBackButton()),
navigateHome: _handleHomeButton,
isActive: () => mounted,
@@ -615,6 +620,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Ensures a single stable focus target across loading → initialized phases.
_screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
_screenFocusNode.addListener(_onScreenFocusChanged);
HardwareKeyboard.instance.addHandler(_primeInitializationNavigationFocus);
appLogger.d('VideoPlayerScreen initialized for: ${_currentMetadata.title}');
if (_preferredAudioTrack != null) {
@@ -1270,6 +1276,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_stillWatchingContinueFocusNode.dispose();
_screenFocusNode.removeListener(_onScreenFocusChanged);
HardwareKeyboard.instance.removeHandler(_primeInitializationNavigationFocus);
_screenFocusNode.dispose();
_mediaControlsManager?.clear();
@@ -1342,6 +1349,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
/// Loading and initialization-error phases can receive a Back key-down
/// before their autofocus request has settled. Claim focus immediately so
/// the matching key-up reaches the player route and exits exactly once.
bool _primeInitializationNavigationFocus(KeyEvent event) {
if (!mounted || _isExiting.value) return false;
primePlayerNavigationFocusForEvent(
event,
focusNode: _screenFocusNode,
playerReady: _isPlayerInitialized && player != null && _hasFirstFrame.value,
isCurrentRoute: ModalRoute.of(context)?.isCurrent == true,
isAppleTV: PlatformDetector.isAppleTV(),
);
return false;
}
void _setupAppleTvRemotePlaybackActions() {
if (!PlatformDetector.isAppleTV()) return;
+54 -22
View File
@@ -193,6 +193,7 @@ class PlayerNavigationCoordinator {
final VoidCallback dismissPrompt;
final bool Function() isChromePresented;
final Future<bool> Function() exitFullscreenIfActive;
final bool physicalEscapeExitsFullscreen;
final VoidCallback exitPlayer;
final VoidCallback navigateHome;
final bool Function() isActive;
@@ -205,6 +206,7 @@ class PlayerNavigationCoordinator {
required this.dismissPrompt,
required this.isChromePresented,
required this.exitFullscreenIfActive,
this.physicalEscapeExitsFullscreen = true,
required this.exitPlayer,
required this.navigateHome,
bool Function()? isActive,
@@ -221,23 +223,30 @@ class PlayerNavigationCoordinator {
dismissPrompt();
return;
}
if (chromeController.contentStripVisible) {
chromeController.setContentStripVisible(false);
return;
}
if (navigationKey == PlayerNavigationKey.physicalEscape) {
unawaited(_handlePhysicalEscape());
return;
}
_handleSemanticBack();
final disposition = resolvePlayerBackDisposition(
navigationKey: navigationKey,
contentStripVisible: chromeController.contentStripVisible,
controlsVisible: isChromePresented(),
physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreen,
);
_applyDisposition(disposition);
}
void _handleSemanticBack() {
if (isChromePresented()) {
chromeController.hide();
return;
void _applyDisposition(PlayerBackDisposition disposition) {
switch (disposition) {
case PlayerBackDisposition.closeContentStrip:
chromeController.setContentStripVisible(false);
return;
case PlayerBackDisposition.exitFullscreenIfActive:
unawaited(_handlePhysicalEscape());
return;
case PlayerBackDisposition.hideControls:
chromeController.hide();
return;
case PlayerBackDisposition.exitPlayer:
exitPlayer();
return;
}
exitPlayer();
}
Future<void> _handlePhysicalEscape() async {
@@ -247,13 +256,12 @@ class PlayerNavigationCoordinator {
try {
if (await exitFullscreenIfActive()) return;
if (!isActive()) return;
if (chromeController.contentStripVisible) {
chromeController.setContentStripVisible(false);
} else if (chromeWasPresented || isChromePresented()) {
chromeController.hide();
} else {
exitPlayer();
}
final disposition = resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.back,
contentStripVisible: chromeController.contentStripVisible,
controlsVisible: chromeWasPresented || isChromePresented(),
);
_applyDisposition(disposition);
} finally {
_handlingPhysicalEscape = false;
}
@@ -264,10 +272,11 @@ PlayerBackDisposition resolvePlayerBackDisposition({
required PlayerNavigationKey navigationKey,
required bool contentStripVisible,
required bool controlsVisible,
bool physicalEscapeExitsFullscreen = true,
}) {
assert(navigationKey == PlayerNavigationKey.physicalEscape || navigationKey == PlayerNavigationKey.back);
if (contentStripVisible) return PlayerBackDisposition.closeContentStrip;
if (navigationKey == PlayerNavigationKey.physicalEscape) {
if (navigationKey == PlayerNavigationKey.physicalEscape && physicalEscapeExitsFullscreen) {
return PlayerBackDisposition.exitFullscreenIfActive;
}
return controlsVisible ? PlayerBackDisposition.hideControls : PlayerBackDisposition.exitPlayer;
@@ -295,12 +304,35 @@ PlayerNavigationKey classifyPlayerNavigationKey(KeyEvent event, {required bool i
return PlayerNavigationKey.none;
}
/// Gives the player route ownership of navigation that arrives while its
/// loading/error body is still establishing focus. The matching key-up still
/// performs the action through the normal [Focus.onKeyEvent] path.
bool primePlayerNavigationFocusForEvent(
KeyEvent event, {
required FocusNode focusNode,
required bool playerReady,
required bool isCurrentRoute,
required bool isAppleTV,
}) {
if (!isCurrentRoute || playerReady || event is! KeyDownEvent) return false;
if (classifyPlayerNavigationKey(event, isAppleTV: isAppleTV) == PlayerNavigationKey.none) return false;
focusNode.requestFocus();
return true;
}
KeyEventResult handlePlayerNavigationKeyAction(
KeyEvent event,
PlayerNavigationKey navigationKey,
VoidCallback onAction,
) {
if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored;
// macOS may also translate Backspace / Escape / browser Back into a route
// pop on key-down. Suppress that parallel path before the player performs
// its single staged action on key-up.
if (navigationKey != PlayerNavigationKey.home && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
if (event.logicalKey.isBackKey) return handleBackKeyAction(event, onAction);
if (event is KeyUpEvent) {
+26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/main_screen.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
@@ -70,6 +71,31 @@ void main() {
expect(shouldPass(isAppleTV: false), isFalse);
});
test('macOS physical Escape is reserved for native fullscreen only at root Home', () {
bool shouldHandle({
bool isMacOS = true,
bool isPhysicalKeyboardEvent = true,
LogicalKeyboardKey logicalKey = LogicalKeyboardKey.escape,
bool isCurrentRoute = true,
bool isHomeTab = true,
}) {
return shouldHandleMacOsRootEscape(
isMacOS: isMacOS,
isPhysicalKeyboardEvent: isPhysicalKeyboardEvent,
logicalKey: logicalKey,
isCurrentRoute: isCurrentRoute,
isHomeTab: isHomeTab,
);
}
expect(shouldHandle(), isTrue);
expect(shouldHandle(isHomeTab: false), isFalse);
expect(shouldHandle(isCurrentRoute: false), isFalse);
expect(shouldHandle(isPhysicalKeyboardEvent: false), isFalse);
expect(shouldHandle(isMacOS: false), isFalse);
expect(shouldHandle(logicalKey: LogicalKeyboardKey.gameButtonB), isFalse);
});
test('profile switch invalidates nothing here — the keyed session remount owns it', () {
expect(
profileInvalidationAction(
+167 -1
View File
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/media_version.dart';
@@ -313,6 +314,7 @@ void main() {
PlayerNavigationKey.back,
() => actions++,
);
expect(BackKeyCoordinator.consumeIfHandled(), isTrue, reason: 'parallel route pop is suppressed on key down');
handlePlayerNavigationKeyAction(_keyUp(LogicalKeyboardKey.backspace), PlayerNavigationKey.back, () => actions++);
expect(actions, 1);
@@ -320,12 +322,112 @@ void main() {
});
});
group('primePlayerNavigationFocusForEvent', () {
testWidgets('claims loading-route focus on navigation key down', (tester) async {
final playerFocus = FocusNode();
final otherFocus = FocusNode();
addTearDown(playerFocus.dispose);
addTearDown(otherFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(focusNode: playerFocus, child: const SizedBox()),
Focus(focusNode: otherFocus, child: const SizedBox()),
],
),
),
);
otherFocus.requestFocus();
await tester.pump();
final primed = primePlayerNavigationFocusForEvent(
_keyDown(LogicalKeyboardKey.gameButtonB),
focusNode: playerFocus,
playerReady: false,
isCurrentRoute: true,
isAppleTV: false,
);
await tester.pump();
expect(primed, isTrue);
expect(playerFocus.hasPrimaryFocus, isTrue);
});
testWidgets('does not steal focus after the player is ready', (tester) async {
final playerFocus = FocusNode();
final otherFocus = FocusNode();
addTearDown(playerFocus.dispose);
addTearDown(otherFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(focusNode: playerFocus, child: const SizedBox()),
Focus(focusNode: otherFocus, child: const SizedBox()),
],
),
),
);
otherFocus.requestFocus();
await tester.pump();
final primed = primePlayerNavigationFocusForEvent(
_keyDown(LogicalKeyboardKey.gameButtonB),
focusNode: playerFocus,
playerReady: true,
isCurrentRoute: true,
isAppleTV: false,
);
await tester.pump();
expect(primed, isFalse);
expect(otherFocus.hasPrimaryFocus, isTrue);
});
testWidgets('does not steal focus from a route above the player', (tester) async {
final playerFocus = FocusNode();
final overlayFocus = FocusNode();
addTearDown(playerFocus.dispose);
addTearDown(overlayFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(focusNode: playerFocus, child: const SizedBox()),
Focus(focusNode: overlayFocus, child: const SizedBox()),
],
),
),
);
overlayFocus.requestFocus();
await tester.pump();
final primed = primePlayerNavigationFocusForEvent(
_keyDown(LogicalKeyboardKey.gameButtonB),
focusNode: playerFocus,
playerReady: false,
isCurrentRoute: false,
isAppleTV: false,
);
await tester.pump();
expect(primed, isFalse);
expect(overlayFocus.hasPrimaryFocus, isTrue);
});
});
group('PlayerNavigationCoordinator focus dispatch', () {
PlayerNavigationCoordinator coordinatorFor(
PlayerChromeController chromeController, {
bool Function()? isPromptOpen,
VoidCallback? dismissPrompt,
bool Function()? isChromePresented,
Future<bool> Function()? exitFullscreenIfActive,
bool physicalEscapeExitsFullscreen = true,
VoidCallback? exitPlayer,
VoidCallback? navigateHome,
bool Function()? isActive,
@@ -334,8 +436,9 @@ void main() {
chromeController: chromeController,
isPromptOpen: isPromptOpen ?? () => false,
dismissPrompt: dismissPrompt ?? () {},
isChromePresented: () => chromeController.controlsPresented,
isChromePresented: isChromePresented ?? () => chromeController.controlsPresented,
exitFullscreenIfActive: exitFullscreenIfActive ?? () async => false,
physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreen,
exitPlayer: exitPlayer ?? () {},
navigateHome: navigateHome ?? () {},
isActive: isActive,
@@ -377,6 +480,19 @@ void main() {
expect(exits, 1);
});
testWidgets('Back exits during pre-first-frame loading even when controls default visible', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, isChromePresented: () => false, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(chromeController.controlsVisible, isTrue);
expect(exits, 1);
});
testWidgets('physical Escape outside fullscreen hides presented chrome without exiting', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
@@ -441,6 +557,35 @@ void main() {
expect(exits, 0);
});
testWidgets('macOS physical Escape stages through chrome and player without leaving fullscreen', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var fullscreenChecks = 0;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async {
fullscreenChecks++;
return true;
},
physicalEscapeExitsFullscreen: false,
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(fullscreenChecks, 0);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
chromeController.markControlsHidden();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(fullscreenChecks, 0);
expect(exits, 1);
});
testWidgets('physical Escape does nothing after its player route is disposed', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
@@ -559,6 +704,27 @@ void main() {
);
});
test('macOS physical Escape uses the same staged disposition as semantic Back', () {
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.physicalEscape,
contentStripVisible: false,
controlsVisible: true,
physicalEscapeExitsFullscreen: false,
),
PlayerBackDisposition.hideControls,
);
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.physicalEscape,
contentStripVisible: false,
controlsVisible: false,
physicalEscapeExitsFullscreen: false,
),
PlayerBackDisposition.exitPlayer,
);
});
test('semantic Back hides visible controls then exits when hidden', () {
expect(
resolvePlayerBackDisposition(