Files
plezy/lib/widgets/video_controls/parts/key_events.dart
T

286 lines
11 KiB
Dart

part of '../video_controls.dart';
extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
Future<void> _initKeyboardService() async {
_keyboardService = await KeyboardShortcutsService.getInstance();
}
void _showScreenshotToast() {
widget.toastController.show(Symbols.photo_camera_rounded, t.videoControls.screenshotSaved);
}
bool _isDirectionalKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.arrowUp ||
key == LogicalKeyboardKey.arrowDown ||
key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.arrowRight;
}
bool _isSelectKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.gameButtonA;
}
/// Determine if the key event should toggle play/pause based on configured hotkeys.
bool _isPlayPauseKey(KeyEvent event) {
final logicalKey = event.logicalKey;
final physicalKey = event.physicalKey;
// Always accept hardware media play/pause keys (Android TV remotes)
if (logicalKey == LogicalKeyboardKey.mediaPlayPause ||
logicalKey == LogicalKeyboardKey.mediaPlay ||
logicalKey == LogicalKeyboardKey.mediaPause) {
return true;
}
// When the shortcuts service is available, respect the configured play/pause hotkey
if (_keyboardService != null) {
final hotkey = _keyboardService!.hotkeys['play_pause'];
if (hotkey == null) return false;
return hotkey.key == physicalKey;
}
// Fallback to defaults while the service is loading
return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause;
}
bool _isMediaSeekKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.mediaFastForward ||
key == LogicalKeyboardKey.mediaRewind ||
key == LogicalKeyboardKey.mediaSkipForward ||
key == LogicalKeyboardKey.mediaSkipBackward;
}
bool _isMediaTrackKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.mediaTrackNext || key == LogicalKeyboardKey.mediaTrackPrevious;
}
bool _isPlayPauseActivation(KeyEvent event) {
return event is KeyDownEvent && _isPlayPauseKey(event);
}
void _activateHiddenControlsPrimaryAction() {
if (_isSkipMarkerButtonVisible) {
_activateSkipMarker();
return;
}
_playOrPause();
_showControlsWithFocus();
}
KeyEventResult _handleLocalPlayerNavigationKeyEvent(KeyEvent event, PlayerNavigationKey navigationKey) {
if (navigationKey == PlayerNavigationKey.none || navigationKey == PlayerNavigationKey.home) {
return KeyEventResult.ignored;
}
if (PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController?.isOpen ?? false) {
return handlePlayerNavigationKeyAction(event, navigationKey, sheetController!.pop);
}
if (widget.chromeController.contentStripVisible) {
return handlePlayerNavigationKeyAction(event, navigationKey, () {
_desktopControlsKey.currentState?.dismissContentStrip();
widget.chromeController.setContentStripVisible(false);
_restartHideTimerForCurrentPlaybackState();
});
}
// The enclosing player screen is the sole owner of fullscreen, chrome,
// prompt, and route-exit stages.
return KeyEventResult.ignored;
}
/// Global key event handler for focus-independent shortcuts (desktop only)
bool _handleGlobalKeyEvent(KeyEvent event) {
if (!mounted) return false;
if (ModalRoute.of(context)?.isCurrent != true) return false;
// Any actionable key (keyboard / dpad / controller) cancels an in-progress
// auto-skip countdown. Non-consuming — we fall through so the key still
// performs its normal action. Single cancel point for keys.
if (event.isActionable) _cancelAutoSkipFromUserInteraction();
// When an overlay sheet is open (e.g. subtitle search with text fields),
// don't consume key events — let text input work normally.
if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) {
return false;
}
// Native key events also continue through the focus tree after global
// handlers run. Player navigation must only mutate state there.
if (classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV()) != PlayerNavigationKey.none) {
return false;
}
// Only handle when video player navigation is disabled (desktop mode without D-pad nav)
if (_videoPlayerNavigationEnabled) return false;
// Skip on mobile (unless TV)
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
if (isMobile) return false;
// Handle play/pause globally - works regardless of focus
if (_isPlayPauseActivation(event)) {
_playOrPause();
_showControlsWithFocus(requestFocus: false);
return true; // Event handled, stop propagation
}
// Fallback: handle all other shortcuts when focus has drifted away
// (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) {
final result = _keyboardService!.handleVideoPlayerKeyEvent(
event,
widget.player,
_toggleFullscreen,
_toggleSubtitles,
_nextAudioTrack,
_nextSubtitleTrack,
_nextChapter,
_previousChapter,
onToggleShader: _toggleShader,
onNextEpisode: widget.onNext,
onPreviousEpisode: widget.onPrevious,
onScreenshot: _showScreenshotToast,
onZoomIn: widget.onZoomIn,
onZoomOut: widget.onZoomOut,
onZoomReset: widget.onResetVideoZoom,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onLiveSeekBy: widget.onLiveSeekBy,
);
if (result == KeyEventResult.handled) {
_focusNode.requestFocus(); // self-heal focus
return true;
}
}
return false;
}
KeyEventResult _handleControlsKeyEvent(KeyEvent event, bool isMobile) {
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
final navigationResult = _handleLocalPlayerNavigationKeyEvent(event, navigationKey);
if (navigationResult != KeyEventResult.ignored) {
return navigationResult;
}
if (navigationKey != PlayerNavigationKey.none) return KeyEventResult.ignored;
// Only handle KeyDown and KeyRepeat events.
// Consume KeyUp events for navigation keys to prevent leaking to previous routes.
// Let non-navigation keys (volume, etc.) pass through to the OS.
if (!event.isActionable) {
if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored;
return KeyEventResult.handled;
}
// Reset hide timer on any keyboard/controller input when controls are visible.
if (_showControls) {
_restartHideTimerForCurrentPlaybackState();
}
final key = event.logicalKey;
final isPlayPauseKey = _isPlayPauseKey(event);
// Always consume play/pause keys to prevent propagation to background routes.
// On TV/mobile, handle play/pause here; on desktop, the global handler does it.
if (isPlayPauseKey) {
if (_videoPlayerNavigationEnabled || isMobile) {
if (_isPlayPauseActivation(event)) {
_playOrPause();
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
}
}
return KeyEventResult.handled;
}
// Handle media seek keys (Android TV remotes).
// Uses chapter navigation if chapters are available, otherwise seeks by configured time.
if (event is KeyDownEvent && _isMediaSeekKey(key)) {
if (widget.canControl) {
final isForward = key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward;
unawaited(_seekToChapter(forward: isForward));
}
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled;
}
// Handle next/previous track keys (Android TV remotes).
// Uses same behavior as seek keys: chapter navigation or time-based seek.
if (event is KeyDownEvent && _isMediaTrackKey(key)) {
if (widget.canControl) {
unawaited(_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext));
}
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled;
}
// Handle Select/Enter when controls are hidden.
// Only intercept if this Focus node itself has primary focus (not a descendant).
// When the skip marker button is the only visible affordance, Select activates
// it; otherwise it falls back to play/pause + show controls.
if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) {
return handleOneShotSelect(event, _activateHiddenControlsPrimaryAction);
}
// On desktop/TV, show controls on directional input.
// LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause.
if (!isMobile && _isDirectionalKey(key) && (_videoPlayerNavigationEnabled || PlatformDetector.isTV())) {
if (!_showControls) {
final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight;
if (isHorizontal) {
_showControlsWithTimelineFocus();
if (widget.canControl) {
final forward = key == LogicalKeyboardKey.arrowRight;
unawaited(_seekByTime(forward: forward));
}
} else {
_showControlsWithFocus();
}
return KeyEventResult.handled;
}
// Children (DesktopVideoControls) handle navigation first via their own onKeyEvent.
// If we reach here, children already declined the event — consume it to prevent leaking.
return KeyEventResult.handled;
}
// Pass other events to the keyboard shortcuts service.
if (_keyboardService == null) {
return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored;
}
final result = _keyboardService!.handleVideoPlayerKeyEvent(
event,
widget.player,
_toggleFullscreen,
_toggleSubtitles,
_nextAudioTrack,
_nextSubtitleTrack,
_nextChapter,
_previousChapter,
onToggleShader: _toggleShader,
onSkipMarker: _performAutoSkip,
onNextEpisode: widget.onNext,
onPreviousEpisode: widget.onPrevious,
onScreenshot: _showScreenshotToast,
onZoomIn: widget.onZoomIn,
onZoomOut: widget.onZoomOut,
onZoomReset: widget.onResetVideoZoom,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onLiveSeekBy: widget.onLiveSeekBy,
onSeekRequested: widget.onSeekRequested,
);
if (!event.logicalKey.isNavigationKey) return result;
// Never return .ignored for navigation keys — prevent leaking to previous routes.
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
}
}