@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user