fix(player): unify back navigation

close #1525
This commit is contained in:
edde746
2026-07-11 03:23:40 +02:00
parent 02a032d287
commit d86e328169
14 changed files with 405 additions and 154 deletions
+8 -1
View File
@@ -808,7 +808,14 @@ class _MainScreenState extends State<MainScreen>
receiver.onTabSearch = () => _selectTab(NavigationTabId.search); receiver.onTabSearch = () => _selectTab(NavigationTabId.search);
receiver.onTabDownloads = () => _selectTab(NavigationTabId.downloads); receiver.onTabDownloads = () => _selectTab(NavigationTabId.downloads);
receiver.onTabSettings = () => _selectTab(NavigationTabId.settings); 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) { receiver.onSearchAction = (query) {
final trimmed = query?.trim() ?? ''; final trimmed = query?.trim() ?? '';
final hasQuery = trimmed.isNotEmpty; final hasQuery = trimmed.isNotEmpty;
@@ -287,6 +287,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!), onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
onSeekCompleted: _notifyWatchTogetherSeek, onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton, onBack: _handleBackButton,
onHome: _handleHomeButton,
onDismissPrompt: (_showPlayNextDialog || _showStillWatchingPrompt) onDismissPrompt: (_showPlayNextDialog || _showStillWatchingPrompt)
? _dismissPlaybackPromptForBack ? _dismissPlaybackPromptForBack
: null, : null,
@@ -50,7 +50,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
// Override home to exit the player first (main screen handler runs after pop) // Override home to exit the player first (main screen handler runs after pop)
_savedOnHome = receiver.onHome; _savedOnHome = receiver.onHome;
receiver.onHome = () { receiver.onHome = () {
if (mounted) _handleBackButton(); if (mounted) _handleHomeButton();
}; };
// Store provider reference for use in dispose and notify remote // Store provider reference for use in dispose and notify remote
+67 -12
View File
@@ -1071,8 +1071,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Handle back button press /// Handle back button press
/// For non-host participants in Watch Together, shows leave session confirmation /// For non-host participants in Watch Together, shows leave session confirmation
Future<void> _handleBackButton() async { Future<void> _handleBackButton({bool navigateHome = false}) async {
if (_showPlayNextDialog || _showStillWatchingPrompt) { if (!navigateHome && (_showPlayNextDialog || _showStillWatchingPrompt)) {
_dismissPlaybackPromptForBack(); _dismissPlaybackPromptForBack();
return; return;
} }
@@ -1101,7 +1101,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!mounted) return; if (!mounted) return;
await _restoreSystemUiAndOrientation(); await _restoreSystemUiAndOrientation();
if (!mounted) return; if (!mounted) return;
navigator.pop(true); _finishPlayerNavigation(navigator, navigateHome: navigateHome);
} }
} }
} }
@@ -1119,13 +1119,63 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!mounted) return; if (!mounted) return;
await _restoreSystemUiAndOrientation(); await _restoreSystemUiAndOrientation();
if (!mounted) return; if (!mounted) return;
navigator.pop(true); _finishPlayerNavigation(navigator, navigateHome: navigateHome);
} }
} finally { } finally {
_isHandlingBack = false; _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<void> _handleScreenPhysicalEscape() async {
if (await FullscreenStateManager().exitFullscreenIfActive()) return;
if (mounted) _handleScreenPlayerNavigation(PlayerNavigationKey.back);
}
Future<void> _restoreSystemUiAndOrientation() async { Future<void> _restoreSystemUiAndOrientation() async {
if (PlatformDetector.isDesktopOS() && _exitFullscreenOnPlayerClose) { if (PlatformDetector.isDesktopOS() && _exitFullscreenOnPlayerClose) {
unawaited(FullscreenStateManager().exitFullscreen()); unawaited(FullscreenStateManager().exitFullscreen());
@@ -1433,15 +1483,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
canRequestFocus: isCurrentRoute, canRequestFocus: isCurrentRoute,
onKeyEvent: (node, event) { onKeyEvent: (node, event) {
if (!isCurrentRoute) return KeyEventResult.ignored; if (!isCurrentRoute) return KeyEventResult.ignored;
// On Windows/Linux with navigation off, consume ESC so Flutter's final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
// DismissAction doesn't trigger a route pop. The video controls' if (navigationKey != PlayerNavigationKey.none) {
// global key handler manages fullscreen/controls toggle instead. // Descendants (controls and sheets) own staged Back while focused.
if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { // This fallback covers loading/error phases and focus drift.
return KeyEventResult.handled; 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 // Hardware media play/pause must act even when focus rests on this
// node or a sibling overlay — otherwise the key only reveals the // node or a sibling overlay — otherwise the key only reveals the
// chrome and leaks to the (possibly stale/suspended) Android // chrome and leaks to the (possibly stale/suspended) Android
@@ -60,7 +60,7 @@ class CompanionRemoteReceiver {
case RemoteCommandType.select: case RemoteCommandType.select:
simulateKeyPress(LogicalKeyboardKey.enter); simulateKeyPress(LogicalKeyboardKey.enter);
case RemoteCommandType.back: case RemoteCommandType.back:
simulateKeyPress(LogicalKeyboardKey.escape); simulateKeyPress(LogicalKeyboardKey.gameButtonB);
case RemoteCommandType.contextMenu: case RemoteCommandType.contextMenu:
simulateKeyPress(LogicalKeyboardKey.contextMenu); simulateKeyPress(LogicalKeyboardKey.contextMenu);
@@ -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<bool> 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() { void startMonitoring() {
if (!_shouldMonitor() || _isListening) return; if (!_shouldMonitor() || _isListening) return;
+4 -4
View File
@@ -48,7 +48,7 @@ class GamepadDuplicateInputGuard {
LogicalKeyboardKey.select, LogicalKeyboardKey.select,
LogicalKeyboardKey.gameButtonA, LogicalKeyboardKey.gameButtonA,
}, },
LogicalKeyboardKey.escape: { LogicalKeyboardKey.gameButtonB: {
LogicalKeyboardKey.escape, LogicalKeyboardKey.escape,
LogicalKeyboardKey.goBack, LogicalKeyboardKey.goBack,
LogicalKeyboardKey.browserBack, LogicalKeyboardKey.browserBack,
@@ -127,7 +127,7 @@ class GamepadService with WindowListener {
GamepadButton.dpadLeft: LogicalKeyboardKey.arrowLeft, GamepadButton.dpadLeft: LogicalKeyboardKey.arrowLeft,
GamepadButton.dpadRight: LogicalKeyboardKey.arrowRight, GamepadButton.dpadRight: LogicalKeyboardKey.arrowRight,
GamepadButton.a: LogicalKeyboardKey.enter, GamepadButton.a: LogicalKeyboardKey.enter,
GamepadButton.b: LogicalKeyboardKey.escape, GamepadButton.b: LogicalKeyboardKey.gameButtonB,
GamepadButton.x: LogicalKeyboardKey.gameButtonX, GamepadButton.x: LogicalKeyboardKey.gameButtonX,
}; };
@@ -395,8 +395,8 @@ class GamepadService with WindowListener {
_simulateKeyDown(LogicalKeyboardKey.gameButtonX); _simulateKeyDown(LogicalKeyboardKey.gameButtonX);
// Immediate actions on press // Immediate actions on press
case GamepadButton.b: case GamepadButton.b:
_logGamepadDiag('button simulates key press escape ${_describeGamepadButton(event)}'); _logGamepadDiag('button simulates key press back ${_describeGamepadButton(event)}');
_simulateKeyPress(LogicalKeyboardKey.escape); _simulateKeyPress(LogicalKeyboardKey.gameButtonB);
case GamepadButton.leftShoulder: case GamepadButton.leftShoulder:
onL1Pressed?.call(); onL1Pressed?.call();
case GamepadButton.rightShoulder: case GamepadButton.rightShoulder:
@@ -179,7 +179,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onNextSubtitleTrack, VoidCallback? onNextSubtitleTrack,
VoidCallback? onNextChapter, VoidCallback? onNextChapter,
VoidCallback? onPreviousChapter, { VoidCallback? onPreviousChapter, {
VoidCallback? onBack,
VoidCallback? onToggleShader, VoidCallback? onToggleShader,
VoidCallback? onSkipMarker, VoidCallback? onSkipMarker,
VoidCallback? onNextEpisode, VoidCallback? onNextEpisode,
@@ -196,11 +195,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
final isRepeat = event is KeyRepeatEvent; final isRepeat = event is KeyRepeatEvent;
if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored; 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 physicalKey = event.physicalKey;
final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; final isShiftPressed = HardwareKeyboard.instance.isShiftPressed;
final isControlPressed = HardwareKeyboard.instance.isControlPressed; final isControlPressed = HardwareKeyboard.instance.isControlPressed;
@@ -70,6 +70,56 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
_showControlsWithFocus(); _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<void> _handlePhysicalEscape() async {
if (await FullscreenStateManager().exitFullscreenIfActive()) return;
if (mounted) _handleStagedPlayerBack(PlayerNavigationKey.back);
}
/// Global key event handler for focus-independent shortcuts (desktop only) /// Global key event handler for focus-independent shortcuts (desktop only)
bool _handleGlobalKeyEvent(KeyEvent event) { bool _handleGlobalKeyEvent(KeyEvent event) {
if (!mounted) return false; if (!mounted) return false;
@@ -86,45 +136,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
return false; return false;
} }
// Back key fallback when _focusNode lost focus (TV, or desktop with nav on). // Focus.onKeyEvent will not fire if focus drifted outside the controls.
// Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here. if (!_focusNode.hasFocus) {
if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) { final navigationResult = _handlePlayerNavigationKeyEvent(event);
if (!_focusNode.hasFocus) { if (navigationResult != KeyEventResult.ignored) return true;
// 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;
}
} }
// Only handle when video player navigation is disabled (desktop mode without D-pad nav) // 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 // (e.g. after controls auto-hide). The !hasFocus guard prevents
// double-handling when the Focus onKeyEvent already processes the event. // double-handling when the Focus onKeyEvent already processes the event.
if (!_focusNode.hasFocus && _keyboardService != null) { 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( final result = _keyboardService!.handleVideoPlayerKeyEvent(
event, event,
widget.player, widget.player,
@@ -168,7 +169,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
_nextSubtitleTrack, _nextSubtitleTrack,
_nextChapter, _nextChapter,
_previousChapter, _previousChapter,
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
onToggleShader: _toggleShader, onToggleShader: _toggleShader,
onNextEpisode: widget.onNext, onNextEpisode: widget.onNext,
onPreviousEpisode: widget.onPrevious, onPreviousEpisode: widget.onPrevious,
@@ -190,47 +190,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
} }
KeyEventResult _handleControlsKeyEvent(KeyEvent event, bool isMobile) { KeyEventResult _handleControlsKeyEvent(KeyEvent event, bool isMobile) {
// On Windows/Linux with navigation off, ESC only exits fullscreen — final navigationResult = _handlePlayerNavigationKeyEvent(event);
// never exits the player. Consume all back key events and check if (navigationResult != KeyEventResult.ignored) {
// actual window state asynchronously. return navigationResult;
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;
} }
// Only handle KeyDown and KeyRepeat events. // Only handle KeyDown and KeyRepeat events.
@@ -325,7 +287,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
_nextSubtitleTrack, _nextSubtitleTrack,
_nextChapter, _nextChapter,
_previousChapter, _previousChapter,
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
onToggleShader: _toggleShader, onToggleShader: _toggleShader,
onSkipMarker: _performAutoSkip, onSkipMarker: _performAutoSkip,
onNextEpisode: widget.onNext, onNextEpisode: widget.onNext,
@@ -178,15 +178,6 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
await FullscreenStateManager().toggleFullscreen(); 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<void> _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) /// Initialize always-on-top state from window manager (desktop only)
Future<void> _initAlwaysOnTopState() async { Future<void> _initAlwaysOnTopState() async {
final isOnTop = await windowManager.isAlwaysOnTop(); final isOnTop = await windowManager.isAlwaysOnTop();
+58 -4
View File
@@ -181,10 +181,60 @@ bool shouldShowSkipMarkerButton({
return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible); return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible);
} }
@visibleForTesting enum PlayerNavigationKey { none, physicalEscape, back, home }
KeyEventResult handlePromptDismissBackKey(KeyEvent event, VoidCallback? onDismissPrompt) {
if (onDismissPrompt == null || !event.logicalKey.isBackKey) return KeyEventResult.ignored; enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer }
return handleBackKeyAction(event, onDismissPrompt);
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 @visibleForTesting
@@ -251,6 +301,9 @@ class PlexVideoControls extends StatefulWidget {
/// Called when back button is pressed (for Watch Together session leave confirmation) /// Called when back button is pressed (for Watch Together session leave confirmation)
final VoidCallback? onBack; 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 /// Called when Back should dismiss a visible playback prompt before normal
/// player back handling. /// player back handling.
final VoidCallback? onDismissPrompt; final VoidCallback? onDismissPrompt;
@@ -355,6 +408,7 @@ class PlexVideoControls extends StatefulWidget {
this.onPlayPauseRequested, this.onPlayPauseRequested,
this.onSeekCompleted, this.onSeekCompleted,
this.onBack, this.onBack,
this.onHome,
this.onDismissPrompt, this.onDismissPrompt,
this.onReachedEnd, this.onReachedEnd,
this.canControl = true, this.canControl = true,
@@ -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 = <KeyEvent>[];
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<KeyDownEvent>());
expect(events.last, isA<KeyUpEvent>());
expect(events.map((event) => event.logicalKey), everyElement(LogicalKeyboardKey.gameButtonB));
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
});
}
@@ -57,7 +57,7 @@ void main() {
guard.clear(); guard.clear();
guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.browserBack)); guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.browserBack));
expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.escape), isTrue); expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.gameButtonB), isTrue);
}); });
}); });
} }
+150 -19
View File
@@ -1,3 +1,5 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -215,35 +217,155 @@ void main() {
}); });
}); });
group('handlePromptDismissBackKey', () { group('classifyPlayerNavigationKey', () {
test('ignores back keys when no prompt is visible', () { test('reserves only physical keyboard Escape for fullscreen', () {
var dismissCount = 0; expect(
classifyPlayerNavigationKey(
final result = handlePromptDismissBackKey(_keyUp(LogicalKeyboardKey.goBack), null); _navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.keyboard),
isAppleTV: false,
expect(result, KeyEventResult.ignored); ),
expect(dismissCount, 0); 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', () { test('treats tvOS keyboard Escape as semantic Back', () {
var dismissCount = 0; expect(
void dismissPrompt() => dismissCount++; classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.keyboard),
isAppleTV: true,
),
PlayerNavigationKey.back,
);
});
final downResult = handlePromptDismissBackKey(_keyDown(LogicalKeyboardKey.goBack), dismissPrompt); test('recognizes controller and browser Back keys', () {
final upResult = handlePromptDismissBackKey(_keyUp(LogicalKeyboardKey.goBack), dismissPrompt); 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(downResult, KeyEventResult.handled);
expect(upResult, KeyEventResult.handled); expect(upResult, KeyEventResult.handled);
expect(dismissCount, 1); expect(actions, 1);
await tester.pump();
}); });
test('ignores non-back keys', () { testWidgets('Backspace alias activates once on key up', (tester) async {
var dismissCount = 0; 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(actions, 1);
expect(dismissCount, 0); 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); 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<void> _pumpSkipMarkerButton( Future<void> _pumpSkipMarkerButton(
WidgetTester tester, { WidgetTester tester, {
required FocusNode focusNode, required FocusNode focusNode,