diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index e8ff5f67..72e062f3 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -808,7 +808,14 @@ class _MainScreenState extends State receiver.onTabSearch = () => _selectTab(NavigationTabId.search); receiver.onTabDownloads = () => _selectTab(NavigationTabId.downloads); receiver.onTabSettings = () => _selectTab(NavigationTabId.settings); - receiver.onHome = () => _selectTab(NavigationTabId.discover); + receiver.onHome = () { + final tabs = _getVisibleTabs(_isOffline); + if (tabs.isEmpty) return; + _selectTab(tabs.first.id); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _sideNavKey.currentState?.focusHomeItem(); + }); + }; receiver.onSearchAction = (query) { final trimmed = query?.trim() ?? ''; final hasQuery = trimmed.isNotEmpty; diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 0635238a..d16fa304 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -287,6 +287,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!), onSeekCompleted: _notifyWatchTogetherSeek, onBack: _handleBackButton, + onHome: _handleHomeButton, onDismissPrompt: (_showPlayNextDialog || _showStillWatchingPrompt) ? _dismissPlaybackPromptForBack : null, diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index fabb6473..d9637ebb 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -50,7 +50,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { // Override home to exit the player first (main screen handler runs after pop) _savedOnHome = receiver.onHome; receiver.onHome = () { - if (mounted) _handleBackButton(); + if (mounted) _handleHomeButton(); }; // Store provider reference for use in dispose and notify remote diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0e4b91cb..8118ff79 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1071,8 +1071,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation - Future _handleBackButton() async { - if (_showPlayNextDialog || _showStillWatchingPrompt) { + Future _handleBackButton({bool navigateHome = false}) async { + if (!navigateHome && (_showPlayNextDialog || _showStillWatchingPrompt)) { _dismissPlaybackPromptForBack(); return; } @@ -1101,7 +1101,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; await _restoreSystemUiAndOrientation(); if (!mounted) return; - navigator.pop(true); + _finishPlayerNavigation(navigator, navigateHome: navigateHome); } } } @@ -1119,13 +1119,63 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; await _restoreSystemUiAndOrientation(); if (!mounted) return; - navigator.pop(true); + _finishPlayerNavigation(navigator, navigateHome: navigateHome); } } finally { _isHandlingBack = false; } } + void _handleHomeButton() { + unawaited(_handleBackButton(navigateHome: true)); + } + + void _finishPlayerNavigation(NavigatorState navigator, {required bool navigateHome}) { + if (!navigateHome) { + navigator.pop(true); + return; + } + + final onHome = _savedOnHome; + navigator.popUntil((route) => route.isFirst); + onHome?.call(); + } + + void _handleScreenPlayerNavigation(PlayerNavigationKey navigationKey) { + if (navigationKey == PlayerNavigationKey.home) { + _handleHomeButton(); + return; + } + if (_showPlayNextDialog || _showStillWatchingPrompt) { + _dismissPlaybackPromptForBack(); + return; + } + final disposition = resolvePlayerBackDisposition( + navigationKey: navigationKey, + contentStripVisible: _chromeController.contentStripVisible, + controlsVisible: _chromeController.controlsVisible, + ); + switch (disposition) { + case PlayerBackDisposition.closeContentStrip: + _chromeController.setContentStripVisible(false); + return; + case PlayerBackDisposition.exitFullscreenIfActive: + unawaited(_handleScreenPhysicalEscape()); + return; + case PlayerBackDisposition.hideControls: + _chromeController.hide(); + return; + case PlayerBackDisposition.exitPlayer: + unawaited(_handleBackButton()); + return; + } + } + + Future _handleScreenPhysicalEscape() async { + if (await FullscreenStateManager().exitFullscreenIfActive()) return; + if (mounted) _handleScreenPlayerNavigation(PlayerNavigationKey.back); + } + Future _restoreSystemUiAndOrientation() async { if (PlatformDetector.isDesktopOS() && _exitFullscreenOnPlayerClose) { unawaited(FullscreenStateManager().exitFullscreen()); @@ -1433,15 +1483,20 @@ class VideoPlayerScreenState extends State with WidgetsBindin canRequestFocus: isCurrentRoute, onKeyEvent: (node, event) { if (!isCurrentRoute) return KeyEventResult.ignored; - // On Windows/Linux with navigation off, consume ESC so Flutter's - // DismissAction doesn't trigger a route pop. The video controls' - // global key handler manages fullscreen/controls toggle instead. - if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { - return KeyEventResult.handled; + final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV()); + if (navigationKey != PlayerNavigationKey.none) { + // Descendants (controls and sheets) own staged Back while focused. + // This fallback covers loading/error phases and focus drift. + if (!node.hasPrimaryFocus) return KeyEventResult.ignored; + if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + } + return handlePlayerNavigationKeyAction( + event, + navigationKey, + () => _handleScreenPlayerNavigation(navigationKey), + ); } - // Back keys pass through — handled by PopScope (system back - // gesture) or overlay sheet's onKeyEvent. - if (event.logicalKey.isBackKey) return KeyEventResult.ignored; // Hardware media play/pause must act even when focus rests on this // node or a sibling overlay — otherwise the key only reveals the // chrome and leaks to the (possibly stale/suspended) Android diff --git a/lib/services/companion_remote/companion_remote_receiver.dart b/lib/services/companion_remote/companion_remote_receiver.dart index d04d80d7..1220dd89 100644 --- a/lib/services/companion_remote/companion_remote_receiver.dart +++ b/lib/services/companion_remote/companion_remote_receiver.dart @@ -60,7 +60,7 @@ class CompanionRemoteReceiver { case RemoteCommandType.select: simulateKeyPress(LogicalKeyboardKey.enter); case RemoteCommandType.back: - simulateKeyPress(LogicalKeyboardKey.escape); + simulateKeyPress(LogicalKeyboardKey.gameButtonB); case RemoteCommandType.contextMenu: simulateKeyPress(LogicalKeyboardKey.contextMenu); diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 72c708c2..3eec20be 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -96,6 +96,24 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { } } + /// Exits fullscreen when the platform window is currently fullscreen. + /// + /// Returns whether fullscreen consumed the request. Querying the native + /// source avoids relying on listener state that may still be catching up. + Future exitFullscreenIfActive() async { + if (!PlatformDetector.isDesktopOS()) return false; + + final isActive = Platform.isMacOS + ? await MacOSWindowService.isFullscreen() + : Platform.isWindows + ? await NativeWindowService.isFullScreen() + : await windowManager.isFullScreen(); + if (!isActive) return false; + + await exitFullscreen(); + return true; + } + void startMonitoring() { if (!_shouldMonitor() || _isListening) return; diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 1c62991d..54cd5b58 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -48,7 +48,7 @@ class GamepadDuplicateInputGuard { LogicalKeyboardKey.select, LogicalKeyboardKey.gameButtonA, }, - LogicalKeyboardKey.escape: { + LogicalKeyboardKey.gameButtonB: { LogicalKeyboardKey.escape, LogicalKeyboardKey.goBack, LogicalKeyboardKey.browserBack, @@ -127,7 +127,7 @@ class GamepadService with WindowListener { GamepadButton.dpadLeft: LogicalKeyboardKey.arrowLeft, GamepadButton.dpadRight: LogicalKeyboardKey.arrowRight, GamepadButton.a: LogicalKeyboardKey.enter, - GamepadButton.b: LogicalKeyboardKey.escape, + GamepadButton.b: LogicalKeyboardKey.gameButtonB, GamepadButton.x: LogicalKeyboardKey.gameButtonX, }; @@ -395,8 +395,8 @@ class GamepadService with WindowListener { _simulateKeyDown(LogicalKeyboardKey.gameButtonX); // Immediate actions on press case GamepadButton.b: - _logGamepadDiag('button simulates key press escape ${_describeGamepadButton(event)}'); - _simulateKeyPress(LogicalKeyboardKey.escape); + _logGamepadDiag('button simulates key press back ${_describeGamepadButton(event)}'); + _simulateKeyPress(LogicalKeyboardKey.gameButtonB); case GamepadButton.leftShoulder: onL1Pressed?.call(); case GamepadButton.rightShoulder: diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 1a56fcd4..f1a4c409 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -179,7 +179,6 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onNextSubtitleTrack, VoidCallback? onNextChapter, VoidCallback? onPreviousChapter, { - VoidCallback? onBack, VoidCallback? onToggleShader, VoidCallback? onSkipMarker, VoidCallback? onNextEpisode, @@ -196,11 +195,6 @@ class KeyboardShortcutsService extends ChangeNotifier { final isRepeat = event is KeyRepeatEvent; if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored; - if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { - onBack?.call(); - return KeyEventResult.handled; - } - final physicalKey = event.physicalKey; final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; final isControlPressed = HardwareKeyboard.instance.isControlPressed; diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 020b9464..94a91fc1 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -70,6 +70,56 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { _showControlsWithFocus(); } + KeyEventResult _handlePlayerNavigationKeyEvent(KeyEvent event) { + final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV()); + if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored; + + if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + } + + return handlePlayerNavigationKeyAction(event, navigationKey, () { + if (navigationKey == PlayerNavigationKey.home) { + (widget.onHome ?? widget.onBack ?? () => Navigator.of(context).pop(true))(); + return; + } + if (widget.onDismissPrompt != null) { + widget.onDismissPrompt!(); + return; + } + _handleStagedPlayerBack(navigationKey); + }); + } + + void _handleStagedPlayerBack(PlayerNavigationKey navigationKey) { + final disposition = resolvePlayerBackDisposition( + navigationKey: navigationKey, + contentStripVisible: widget.chromeController.contentStripVisible, + controlsVisible: _showControls, + ); + switch (disposition) { + case PlayerBackDisposition.closeContentStrip: + _desktopControlsKey.currentState?.dismissContentStrip(); + widget.chromeController.setContentStripVisible(false); + _restartHideTimerForCurrentPlaybackState(); + return; + case PlayerBackDisposition.exitFullscreenIfActive: + unawaited(_handlePhysicalEscape()); + return; + case PlayerBackDisposition.hideControls: + _hideControls(); + return; + case PlayerBackDisposition.exitPlayer: + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + return; + } + } + + Future _handlePhysicalEscape() async { + if (await FullscreenStateManager().exitFullscreenIfActive()) return; + if (mounted) _handleStagedPlayerBack(PlayerNavigationKey.back); + } + /// Global key event handler for focus-independent shortcuts (desktop only) bool _handleGlobalKeyEvent(KeyEvent event) { if (!mounted) return false; @@ -86,45 +136,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return false; } - // Back key fallback when _focusNode lost focus (TV, or desktop with nav on). - // Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here. - if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) { - if (!_focusNode.hasFocus) { - // Skip if an overlay sheet is open — the sheet's FocusScope handles - // back keys via its own onKeyEvent. Without this check, this global - // handler would call Navigator.pop() alongside the sheet's handler. - final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; - if (sheetOpen) return false; - // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult - // sees it before KeyUp — prevents the system back from racing ahead. - if (PlatformDetector.isTV() && event is KeyDownEvent) { - BackKeyCoordinator.markHandled(); - } - final promptBackResult = handlePromptDismissBackKey(event, widget.onDismissPrompt); - if (promptBackResult != KeyEventResult.ignored) return true; - final backResult = handleBackKeyAction(event, () { - if (PlatformDetector.isTV()) { - if (_showControls) { - if (widget.chromeController.contentStripVisible) { - _desktopControlsKey.currentState?.dismissContentStrip(); - widget.chromeController.setContentStripVisible(false); - _restartHideTimerForCurrentPlaybackState(); - return; - } - _hideControls(); - return; - } - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - return; - } - if (!_showControls) { - _showControlsWithFocus(); - } else { - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - } - }); - if (backResult != KeyEventResult.ignored) return true; - } + // Focus.onKeyEvent will not fire if focus drifted outside the controls. + if (!_focusNode.hasFocus) { + final navigationResult = _handlePlayerNavigationKeyEvent(event); + if (navigationResult != KeyEventResult.ignored) return true; } // Only handle when video player navigation is disabled (desktop mode without D-pad nav) @@ -145,20 +160,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { // (e.g. after controls auto-hide). The !hasFocus guard prevents // double-handling when the Focus onKeyEvent already processes the event. if (!_focusNode.hasFocus && _keyboardService != null) { - // On Windows/Linux with navigation off, ESC only exits fullscreen — - // never exits the player. Intercept before the keyboard shortcuts - // service which would call onBack and pop the route. - // Skip if an overlay sheet is open — let the sheet handle ESC. - if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { - final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; - if (!sheetOpen) { - if (event is KeyUpEvent) { - _exitFullscreenIfNeeded(); - } - _focusNode.requestFocus(); - return true; - } - } final result = _keyboardService!.handleVideoPlayerKeyEvent( event, widget.player, @@ -168,7 +169,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { _nextSubtitleTrack, _nextChapter, _previousChapter, - onBack: widget.onBack ?? () => Navigator.of(context).pop(true), onToggleShader: _toggleShader, onNextEpisode: widget.onNext, onPreviousEpisode: widget.onPrevious, @@ -190,47 +190,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { } KeyEventResult _handleControlsKeyEvent(KeyEvent event, bool isMobile) { - // On Windows/Linux with navigation off, ESC only exits fullscreen — - // never exits the player. Consume all back key events and check - // actual window state asynchronously. - if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { - if (event is KeyUpEvent) { - _exitFullscreenIfNeeded(); - } - return KeyEventResult.handled; - } - // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult - // sees it before KeyUp — prevents the system back from racing ahead. - if (PlatformDetector.isTV() && event.logicalKey.isBackKey && event is KeyDownEvent) { - BackKeyCoordinator.markHandled(); - } - final promptBackResult = handlePromptDismissBackKey(event, widget.onDismissPrompt); - if (promptBackResult != KeyEventResult.ignored) { - return promptBackResult; - } - final backResult = handleBackKeyAction(event, () { - if (PlatformDetector.isTV()) { - if (_showControls) { - if (widget.chromeController.contentStripVisible) { - _desktopControlsKey.currentState?.dismissContentStrip(); - widget.chromeController.setContentStripVisible(false); - _restartHideTimerForCurrentPlaybackState(); - return; - } - _hideControls(); - return; - } - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - return; - } - if (!_showControls) { - _showControlsWithFocus(); - return; - } - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - }); - if (backResult != KeyEventResult.ignored) { - return backResult; + final navigationResult = _handlePlayerNavigationKeyEvent(event); + if (navigationResult != KeyEventResult.ignored) { + return navigationResult; } // Only handle KeyDown and KeyRepeat events. @@ -325,7 +287,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { _nextSubtitleTrack, _nextChapter, _previousChapter, - onBack: widget.onBack ?? () => Navigator.of(context).pop(true), onToggleShader: _toggleShader, onSkipMarker: _performAutoSkip, onNextEpisode: widget.onNext, diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index a7e171c3..7c587e21 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -178,15 +178,6 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { await FullscreenStateManager().toggleFullscreen(); } - /// Exit fullscreen if the window is actually fullscreen (async check). - /// Used by ESC handler on Windows/Linux to avoid relying on _isFullscreen flag. - Future _exitFullscreenIfNeeded() async { - if (!Platform.isWindows && !Platform.isLinux) return; - if (await windowManager.isFullScreen()) { - await FullscreenStateManager().exitFullscreen(); - } - } - /// Initialize always-on-top state from window manager (desktop only) Future _initAlwaysOnTopState() async { final isOnTop = await windowManager.isAlwaysOnTop(); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 7900b05e..e4b1ef89 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -181,10 +181,60 @@ bool shouldShowSkipMarkerButton({ return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible); } -@visibleForTesting -KeyEventResult handlePromptDismissBackKey(KeyEvent event, VoidCallback? onDismissPrompt) { - if (onDismissPrompt == null || !event.logicalKey.isBackKey) return KeyEventResult.ignored; - return handleBackKeyAction(event, onDismissPrompt); +enum PlayerNavigationKey { none, physicalEscape, back, home } + +enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer } + +PlayerBackDisposition resolvePlayerBackDisposition({ + required PlayerNavigationKey navigationKey, + required bool contentStripVisible, + required bool controlsVisible, +}) { + assert(navigationKey == PlayerNavigationKey.physicalEscape || navigationKey == PlayerNavigationKey.back); + if (contentStripVisible) return PlayerBackDisposition.closeContentStrip; + if (navigationKey == PlayerNavigationKey.physicalEscape) { + return PlayerBackDisposition.exitFullscreenIfActive; + } + return controlsVisible ? PlayerBackDisposition.hideControls : PlayerBackDisposition.exitPlayer; +} + +PlayerNavigationKey classifyPlayerNavigationKey(KeyEvent event, {required bool isAppleTV, bool? hasModifiers}) { + final key = event.logicalKey; + if (key == LogicalKeyboardKey.escape) { + return event.isPhysicalKeyboardEvent && !isAppleTV ? PlayerNavigationKey.physicalEscape : PlayerNavigationKey.back; + } + if (key.isBackKey) return PlayerNavigationKey.back; + + final modifiersPressed = + hasModifiers ?? + (HardwareKeyboard.instance.isShiftPressed || + HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isAltPressed || + HardwareKeyboard.instance.isMetaPressed); + if (key == LogicalKeyboardKey.backspace && event.isPhysicalKeyboardEvent && !modifiersPressed) { + return PlayerNavigationKey.back; + } + if ((key == LogicalKeyboardKey.home || key == LogicalKeyboardKey.browserHome) && !modifiersPressed) { + return PlayerNavigationKey.home; + } + return PlayerNavigationKey.none; +} + +KeyEventResult handlePlayerNavigationKeyAction( + KeyEvent event, + PlayerNavigationKey navigationKey, + VoidCallback onAction, +) { + if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored; + if (event.logicalKey.isBackKey) return handleBackKeyAction(event, onAction); + + if (event is KeyUpEvent) { + if (navigationKey != PlayerNavigationKey.home) { + BackKeyCoordinator.markHandled(); + } + onAction(); + } + return KeyEventResult.handled; } @visibleForTesting @@ -251,6 +301,9 @@ class PlexVideoControls extends StatefulWidget { /// Called when back button is pressed (for Watch Together session leave confirmation) final VoidCallback? onBack; + /// Called for a direct Home command after player-specific input handling. + final VoidCallback? onHome; + /// Called when Back should dismiss a visible playback prompt before normal /// player back handling. final VoidCallback? onDismissPrompt; @@ -355,6 +408,7 @@ class PlexVideoControls extends StatefulWidget { this.onPlayPauseRequested, this.onSeekCompleted, this.onBack, + this.onHome, this.onDismissPrompt, this.onReachedEnd, this.canControl = true, diff --git a/test/services/companion_remote_receiver_test.dart b/test/services/companion_remote_receiver_test.dart new file mode 100644 index 00000000..2e64d4ac --- /dev/null +++ b/test/services/companion_remote_receiver_test.dart @@ -0,0 +1,39 @@ +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/models/companion_remote/remote_command.dart'; +import 'package:plezy/services/companion_remote/companion_remote_receiver.dart'; + +void main() { + testWidgets('Back command dispatches semantic gamepad B events', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final events = []; + + await tester.pumpWidget( + MaterialApp( + home: Focus( + focusNode: focusNode, + onKeyEvent: (_, event) { + events.add(event); + return KeyEventResult.handled; + }, + child: const SizedBox.expand(), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.back), null); + await tester.pump(); + + expect(events, hasLength(2)); + expect(events.first, isA()); + expect(events.last, isA()); + expect(events.map((event) => event.logicalKey), everyElement(LogicalKeyboardKey.gameButtonB)); + expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad)); + }); +} diff --git a/test/services/gamepad_duplicate_input_guard_test.dart b/test/services/gamepad_duplicate_input_guard_test.dart index 54844d6b..72f72678 100644 --- a/test/services/gamepad_duplicate_input_guard_test.dart +++ b/test/services/gamepad_duplicate_input_guard_test.dart @@ -57,7 +57,7 @@ void main() { guard.clear(); guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.browserBack)); - expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.escape), isTrue); + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.gameButtonB), isTrue); }); }); } diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index cd33b27f..5abe20af 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -215,35 +217,155 @@ void main() { }); }); - group('handlePromptDismissBackKey', () { - test('ignores back keys when no prompt is visible', () { - var dismissCount = 0; - - final result = handlePromptDismissBackKey(_keyUp(LogicalKeyboardKey.goBack), null); - - expect(result, KeyEventResult.ignored); - expect(dismissCount, 0); + group('classifyPlayerNavigationKey', () { + test('reserves only physical keyboard Escape for fullscreen', () { + expect( + classifyPlayerNavigationKey( + _navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.keyboard), + isAppleTV: false, + ), + PlayerNavigationKey.physicalEscape, + ); + expect( + classifyPlayerNavigationKey( + _navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.gamepad), + isAppleTV: false, + ), + PlayerNavigationKey.back, + ); + expect( + classifyPlayerNavigationKey( + _navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.directionalPad), + isAppleTV: false, + ), + PlayerNavigationKey.back, + ); }); - test('consumes key down and dismisses on key up', () { - var dismissCount = 0; - void dismissPrompt() => dismissCount++; + test('treats tvOS keyboard Escape as semantic Back', () { + expect( + classifyPlayerNavigationKey( + _navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.keyboard), + isAppleTV: true, + ), + PlayerNavigationKey.back, + ); + }); - final downResult = handlePromptDismissBackKey(_keyDown(LogicalKeyboardKey.goBack), dismissPrompt); - final upResult = handlePromptDismissBackKey(_keyUp(LogicalKeyboardKey.goBack), dismissPrompt); + test('recognizes controller and browser Back keys', () { + for (final key in [LogicalKeyboardKey.gameButtonB, LogicalKeyboardKey.goBack, LogicalKeyboardKey.browserBack]) { + expect( + classifyPlayerNavigationKey(_navigationKeyDown(key, ui.KeyEventDeviceType.gamepad), isAppleTV: false), + PlayerNavigationKey.back, + ); + } + }); + + test('recognizes only bare physical Backspace as player Back', () { + final event = _navigationKeyDown(LogicalKeyboardKey.backspace, ui.KeyEventDeviceType.keyboard); + + expect(classifyPlayerNavigationKey(event, isAppleTV: false, hasModifiers: false), PlayerNavigationKey.back); + expect(classifyPlayerNavigationKey(event, isAppleTV: false, hasModifiers: true), PlayerNavigationKey.none); + }); + + test('recognizes bare keyboard and browser Home', () { + for (final key in [LogicalKeyboardKey.home, LogicalKeyboardKey.browserHome]) { + expect( + classifyPlayerNavigationKey( + _navigationKeyDown(key, ui.KeyEventDeviceType.keyboard), + isAppleTV: false, + hasModifiers: false, + ), + PlayerNavigationKey.home, + ); + } + }); + }); + + group('handlePlayerNavigationKeyAction', () { + testWidgets('semantic Back activates once on key up', (tester) async { + var actions = 0; + + final downResult = handlePlayerNavigationKeyAction( + _keyDown(LogicalKeyboardKey.gameButtonB), + PlayerNavigationKey.back, + () => actions++, + ); + final upResult = handlePlayerNavigationKeyAction( + _keyUp(LogicalKeyboardKey.gameButtonB), + PlayerNavigationKey.back, + () => actions++, + ); expect(downResult, KeyEventResult.handled); expect(upResult, KeyEventResult.handled); - expect(dismissCount, 1); + expect(actions, 1); + await tester.pump(); }); - test('ignores non-back keys', () { - var dismissCount = 0; + testWidgets('Backspace alias activates once on key up', (tester) async { + var actions = 0; - final result = handlePromptDismissBackKey(_keyDown(LogicalKeyboardKey.arrowLeft), () => dismissCount++); + handlePlayerNavigationKeyAction( + _keyDown(LogicalKeyboardKey.backspace), + PlayerNavigationKey.back, + () => actions++, + ); + handlePlayerNavigationKeyAction(_keyUp(LogicalKeyboardKey.backspace), PlayerNavigationKey.back, () => actions++); - expect(result, KeyEventResult.ignored); - expect(dismissCount, 0); + expect(actions, 1); + await tester.pump(); + }); + }); + + group('resolvePlayerBackDisposition', () { + test('closes a content strip before other Back behavior', () { + expect( + resolvePlayerBackDisposition( + navigationKey: PlayerNavigationKey.physicalEscape, + contentStripVisible: true, + controlsVisible: true, + ), + PlayerBackDisposition.closeContentStrip, + ); + }); + + test('checks fullscreen only for physical Escape', () { + expect( + resolvePlayerBackDisposition( + navigationKey: PlayerNavigationKey.physicalEscape, + contentStripVisible: false, + controlsVisible: false, + ), + PlayerBackDisposition.exitFullscreenIfActive, + ); + expect( + resolvePlayerBackDisposition( + navigationKey: PlayerNavigationKey.back, + contentStripVisible: false, + controlsVisible: false, + ), + PlayerBackDisposition.exitPlayer, + ); + }); + + test('semantic Back hides visible controls then exits when hidden', () { + expect( + resolvePlayerBackDisposition( + navigationKey: PlayerNavigationKey.back, + contentStripVisible: false, + controlsVisible: true, + ), + PlayerBackDisposition.hideControls, + ); + expect( + resolvePlayerBackDisposition( + navigationKey: PlayerNavigationKey.back, + contentStripVisible: false, + controlsVisible: false, + ), + PlayerBackDisposition.exitPlayer, + ); }); }); @@ -821,6 +943,15 @@ KeyUpEvent _keyUp(LogicalKeyboardKey key) { return KeyUpEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero); } +KeyDownEvent _navigationKeyDown(LogicalKeyboardKey key, ui.KeyEventDeviceType deviceType) { + return KeyDownEvent( + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: key, + timeStamp: Duration.zero, + deviceType: deviceType, + ); +} + Future _pumpSkipMarkerButton( WidgetTester tester, { required FocusNode focusNode,