286 lines
11 KiB
Dart
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;
|
|
}
|
|
}
|