diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index e50fbcc1..9253bf40 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -62,14 +62,15 @@ KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) { return KeyEventResult.handled; } - // AppleTV physical-keyboard back (Siri Remote Menu via engine-synthesized - // escape): run onBack on KeyDown only; consume KeyUp silently. The - // suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here + // AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack + // on KeyDown only; consume KeyUp silently. Some engine paths report Menu as + // a non-keyboard device, but the same down-only handling is still required. + // The suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here // because onBack typically calls Navigator.pop, swapping the focus tree // before the matching KeyUp is dispatched — the orphaned KeyUp then never // reaches a consumeIfSuppressed call, pinning the suppressor armed and // silently swallowing the next press's KeyDown. - if (PlatformDetector.isAppleTV() && event.isPhysicalKeyboardEvent) { + if (PlatformDetector.isAppleTV()) { if (event is KeyDownEvent) { BackKeyCoordinator.markHandled(); onBack(); diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 857dab91..020b9464 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -108,7 +108,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { if (widget.chromeController.contentStripVisible) { _desktopControlsKey.currentState?.dismissContentStrip(); widget.chromeController.setContentStripVisible(false); - _restartHideTimerIfPlaying(); + _restartHideTimerForCurrentPlaybackState(); return; } _hideControls(); @@ -214,7 +214,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { if (widget.chromeController.contentStripVisible) { _desktopControlsKey.currentState?.dismissContentStrip(); widget.chromeController.setContentStripVisible(false); - _restartHideTimerIfPlaying(); + _restartHideTimerForCurrentPlaybackState(); return; } _hideControls(); @@ -243,7 +243,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { // Reset hide timer on any keyboard/controller input when controls are visible. if (_showControls) { - _restartHideTimerIfPlaying(); + _restartHideTimerForCurrentPlaybackState(); } final key = event.logicalKey; diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index fa11b2ee..bbf1d16e 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -11,7 +11,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { return Listener( behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), + onPointerDown: (_) => _restartHideTimerForCurrentPlaybackState(), child: DesktopVideoControls( key: _desktopControlsKey, player: widget.player, @@ -33,7 +33,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { onSeekRequested: widget.onSeekRequested, getReplayIcon: getReplayIcon, getForwardIcon: getForwardIcon, - onFocusActivity: _restartHideTimerIfPlaying, + onFocusActivity: _restartHideTimerForCurrentPlaybackState, onHideControls: _hideControlsFromKeyboard, trackControlsState: trackControlsState, onBack: widget.onBack, diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index e61cceda..d04640b9 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -371,7 +371,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { _edgeAdjustmentStartValue = null; _lastEdgeAdjustmentWriteAt = null; _lastEdgeAdjustmentWriteValue = null; - _restartHideTimerIfPlaying(); + _restartHideTimerForCurrentPlaybackState(); _edgeAdjustmentIndicatorHideTimer?.cancel(); _edgeAdjustmentIndicatorClearTimer?.cancel(); if (_edgeAdjustmentIndicator.value.side == null) return; diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 5f528918..a7e171c3 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -63,8 +63,8 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { void _startHideTimer() => widget.chromeController.startAutoHide(); - /// Restart the hide timer on user interaction (if video is playing) - void _restartHideTimerIfPlaying() => widget.chromeController.restartAutoHideIfPlaying(); + /// Restart the hide timer on user interaction for the current playback state. + void _restartHideTimerForCurrentPlaybackState() => widget.chromeController.restartAutoHideForCurrentPlaybackState(); void _handlePointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent && _keyboardService != null) { diff --git a/lib/widgets/video_controls/player_chrome_controller.dart b/lib/widgets/video_controls/player_chrome_controller.dart index 4703c2a3..5fa7a46c 100644 --- a/lib/widgets/video_controls/player_chrome_controller.dart +++ b/lib/widgets/video_controls/player_chrome_controller.dart @@ -89,7 +89,7 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable _startAutoHideForCurrentPlaybackState(); void hideForPointerExit() { if (_holds.contains(PlayerChromeHold.pip)) return; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index b165b283..269615a7 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -798,7 +798,7 @@ class _PlexVideoControlsState extends State behavior: HitTestBehavior.translucent, onPointerDown: (_) { if (!widget.chromeController.contentStripVisible) { - _restartHideTimerIfPlaying(); + _restartHideTimerForCurrentPlaybackState(); } }, child: Builder( diff --git a/test/focus/key_event_utils_test.dart b/test/focus/key_event_utils_test.dart index 8323e4f8..28562fec 100644 --- a/test/focus/key_event_utils_test.dart +++ b/test/focus/key_event_utils_test.dart @@ -41,6 +41,37 @@ void main() { expect(downResult, KeyEventResult.handled); expect(upResult, KeyEventResult.handled); expect(backs, 1); + await tester.pump(); + }); + + testWidgets('tvOS remote back runs on key down for non-keyboard device types', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + var backs = 0; + + final downResult = handleBackKeyAction( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: LogicalKeyboardKey.escape, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + () => backs++, + ); + + final upResult = handleBackKeyAction( + const KeyUpEvent( + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: LogicalKeyboardKey.escape, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + () => backs++, + ); + + expect(downResult, KeyEventResult.handled); + expect(upResult, KeyEventResult.handled); + expect(backs, 1); + await tester.pump(); }); group('dpadKeyHandler trapHorizontalEdges', () { diff --git a/test/widgets/player_chrome_controller_test.dart b/test/widgets/player_chrome_controller_test.dart index 60a76dfa..12bbb177 100644 --- a/test/widgets/player_chrome_controller_test.dart +++ b/test/widgets/player_chrome_controller_test.dart @@ -71,6 +71,40 @@ void main() { expect(controller.controlsVisible, isFalse); }); + testWidgets('show while paused restarts paused auto-hide timer', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + + controller.configure(hideDelay: const Duration(milliseconds: 100)); + controller.setPlaying(true); + controller.setPlaying(false); + + await tester.pump(const Duration(milliseconds: 50)); + controller.show(); + + await tester.pump(const Duration(milliseconds: 99)); + expect(controller.controlsVisible, isTrue); + await tester.pump(const Duration(milliseconds: 1)); + expect(controller.controlsVisible, isFalse); + }); + + testWidgets('pointer activity while paused restarts paused auto-hide timer', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + + controller.configure(hideDelay: const Duration(milliseconds: 100)); + controller.setPlaying(true); + controller.setPlaying(false); + + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.recordPointerActivity(), isTrue); + + await tester.pump(const Duration(milliseconds: 99)); + expect(controller.controlsVisible, isTrue); + await tester.pump(const Duration(milliseconds: 1)); + expect(controller.controlsVisible, isFalse); + }); + test('show stores focus target and notifies even when already visible', () { final controller = PlayerChromeController(); addTearDown(controller.dispose);