From f3e108e8679c40a16f8ac320dddbf0758e56b1ae Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:31:15 +0200 Subject: [PATCH] fix(tvos): suppress duplicate select playback --- lib/screens/discover_screen.dart | 15 ++ .../apple_tv_remote_touch_service.dart | 115 ++++++++++ lib/services/gamepad_service.dart | 37 +++- lib/utils/key_event_simulator.dart | 10 +- lib/utils/video_player_navigation.dart | 196 +++++++++++++----- lib/widgets/tv_browse_rail.dart | 24 +++ .../apple_tv_remote_touch_service_test.dart | 46 ++++ test/utils/key_event_simulator_test.dart | 53 +++++ test/utils/video_player_navigation_test.dart | 38 ++++ test/widgets/tv_browse_rail_test.dart | 50 +++++ 10 files changed, 525 insertions(+), 59 deletions(-) create mode 100644 test/utils/video_player_navigation_test.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c01cb662..ca797788 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -3,6 +3,7 @@ import '../media/ids.dart'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show HardwareKeyboard, LogicalKeyboardKey; import 'package:plezy/widgets/app_icon.dart'; import '../widgets/server_activities_button.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -337,6 +338,7 @@ class _DiscoverScreenState extends State void _focusTvBrowseRailWhenReady({bool immediate = false}) { if (!PlatformDetector.isTV()) return; + final suppressSelectUntilKeyUp = _isSelectKeyPressed; if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) { _pendingTvBrowseRailFocus = false; return; @@ -348,6 +350,7 @@ class _DiscoverScreenState extends State if (rail != null) { _pendingTvBrowseRailFocus = false; rail.requestFocus(); + if (suppressSelectUntilKeyUp) rail.suppressSelectUntilKeyUp(); return; } } @@ -363,9 +366,21 @@ class _DiscoverScreenState extends State if (rail == null) return; _pendingTvBrowseRailFocus = false; rail.requestFocus(); + if (suppressSelectUntilKeyUp) rail.suppressSelectUntilKeyUp(); }); } + bool get _isSelectKeyPressed { + return HardwareKeyboard.instance.logicalKeysPressed.any( + (key) => + key == LogicalKeyboardKey.enter || + key.keyId == 0x0d || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.gameButtonA, + ); + } + void _applyPendingTvBrowseRailFocus() { if (_pendingTvBrowseRailFocus) _focusTvBrowseRailWhenReady(); } diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index cb2e9a16..2846c8a3 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -52,6 +52,10 @@ class AppleTvRemoteTouchService { DateTime? _lastSwipeAt; DateTime? _lastDirectionalInputAt; DateTime? _lastSyntheticSelectAt; + DateTime? _lastAcceptedNativeSelectDownAt; + DateTime? _lastAcceptedNativeSelectUpAt; + int _suppressedNativeSelectDowns = 0; + bool _nativeSelectPressed = false; bool _selectPressedFromClick = false; AppleTvRemoteTouchService({ @@ -92,6 +96,7 @@ class AppleTvRemoteTouchService { _channel.setMessageHandler(null); _unregisterNativeKeyHandler(); _duplicateInputGuard.clear(); + _resetNativeSelectBurstState(); _releaseSelectFromClick(source: 'stop'); _resetTouch(); _listening = false; @@ -103,6 +108,9 @@ class AppleTvRemoteTouchService { _log('consume native media key reason=direct-playback-action'); return true; } + if (_shouldConsumeNativeSelectDuplicate(event)) { + return true; + } if (event is KeyDownEvent && _isDirectionalKey(event.logicalKey)) { _lastDirectionalInputAt = _now(); } @@ -287,6 +295,103 @@ class AppleTvRemoteTouchService { _simulateKeyUp(LogicalKeyboardKey.enter); } + bool _shouldConsumeNativeSelectDuplicate(KeyEvent event) { + if (!_isSelectKey(event.logicalKey)) return false; + + final now = _now(); + if (_selectPressedFromClick) { + _log( + 'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} ' + 'reason=synthetic-select-in-flight', + ); + if (event is KeyUpEvent) { + _releaseSelectFromClick(source: 'native_select'); + } + return true; + } + + final lastSyntheticSelectAt = _lastSyntheticSelectAt; + if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) { + final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds; + _log( + 'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} ' + 'reason=recent-synthetic-select age=${age}ms', + ); + return true; + } + + if (event is KeyDownEvent) { + final lastAcceptedNativeSelectUpAt = _lastAcceptedNativeSelectUpAt; + final duplicateCompletedPress = + lastAcceptedNativeSelectUpAt != null && + now.difference(lastAcceptedNativeSelectUpAt).abs() <= duplicateSuppressionWindow; + if (_nativeSelectPressed || duplicateCompletedPress) { + _suppressedNativeSelectDowns++; + final reason = _nativeSelectPressed ? 'native-select-already-down' : 'recent-native-select'; + _log( + 'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} ' + 'reason=$reason', + ); + return true; + } + + _nativeSelectPressed = true; + _lastAcceptedNativeSelectDownAt = now; + return false; + } + + if (event is KeyRepeatEvent) { + if (_nativeSelectPressed) return false; + final lastAcceptedNativeSelectDownAt = _lastAcceptedNativeSelectDownAt; + if (lastAcceptedNativeSelectDownAt != null && + now.difference(lastAcceptedNativeSelectDownAt).abs() <= duplicateSuppressionWindow) { + _log( + 'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} ' + 'reason=recent-native-select', + ); + return true; + } + return false; + } + + if (event is KeyUpEvent) { + if (_suppressedNativeSelectDowns > 0) { + _suppressedNativeSelectDowns--; + _log( + 'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} ' + 'reason=suppressed-native-select-down', + ); + return true; + } + + if (!_nativeSelectPressed) { + final lastAcceptedNativeSelectUpAt = _lastAcceptedNativeSelectUpAt; + if (lastAcceptedNativeSelectUpAt != null && + now.difference(lastAcceptedNativeSelectUpAt).abs() <= duplicateSuppressionWindow) { + _log( + 'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} ' + 'reason=recent-native-select-up', + ); + return true; + } + return false; + } + + _nativeSelectPressed = false; + _lastAcceptedNativeSelectUpAt = now; + return false; + } + + return false; + } + + void _resetNativeSelectBurstState() { + _lastAcceptedNativeSelectDownAt = null; + _lastAcceptedNativeSelectUpAt = null; + _suppressedNativeSelectDowns = 0; + _nativeSelectPressed = false; + } + bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) { if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) { _log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native'); @@ -352,6 +457,8 @@ class AppleTvRemoteTouchService { if (key == LogicalKeyboardKey.arrowLeft) return 'arrowLeft'; if (key == LogicalKeyboardKey.arrowRight) return 'arrowRight'; if (key == LogicalKeyboardKey.enter) return 'enter'; + if (key.keyId == 0x0d) return 'rawEnter'; + if (key == LogicalKeyboardKey.numpadEnter) return 'numpadEnter'; if (key == LogicalKeyboardKey.select) return 'select'; if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA'; if (key == LogicalKeyboardKey.escape) return 'escape'; @@ -368,6 +475,14 @@ class AppleTvRemoteTouchService { key == LogicalKeyboardKey.arrowRight; } + bool _isSelectKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.enter || + key.keyId == 0x0d || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.gameButtonA; + } + bool _isMediaPlaybackKey(LogicalKeyboardKey key) { return key == LogicalKeyboardKey.mediaPlayPause || key == LogicalKeyboardKey.mediaPlay || diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index c34f17a5..f2a884eb 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -35,6 +35,7 @@ void _logGamepadDiag(String message) { /// equivalent native key event, which happens with Steam Input on Windows. class GamepadDuplicateInputGuard { static const defaultSuppressionWindow = Duration(milliseconds: 120); + static const LogicalKeyboardKey _rawEnterKey = LogicalKeyboardKey(0x0d); static final Map> _nativeAliasesBySyntheticKey = { LogicalKeyboardKey.arrowUp: {LogicalKeyboardKey.arrowUp}, @@ -43,6 +44,7 @@ class GamepadDuplicateInputGuard { LogicalKeyboardKey.arrowRight: {LogicalKeyboardKey.arrowRight}, LogicalKeyboardKey.enter: { LogicalKeyboardKey.enter, + _rawEnterKey, LogicalKeyboardKey.numpadEnter, LogicalKeyboardKey.select, LogicalKeyboardKey.gameButtonA, @@ -68,9 +70,10 @@ class GamepadDuplicateInputGuard { GamepadDuplicateInputGuard({ DateTime Function()? now, - this._enabled, + bool Function()? enabled, this.suppressionWindow = defaultSuppressionWindow, - }) : _now = now ?? DateTime.now; + }) : _now = now ?? DateTime.now, + _enabled = enabled; bool get _isEnabled => _enabled?.call() ?? true; @@ -164,6 +167,7 @@ class GamepadService with WindowListener { // Track button states to prevent repeated events from button holds final Set _pressedButtons = {}; final Set _suppressedButtons = {}; + final Map _heldFocusNodes = {}; // Whether the app window is currently focused — ignore gamepad input when false bool _windowFocused = true; @@ -225,6 +229,7 @@ class GamepadService with WindowListener { _subscription = null; _duplicateInputGuard.clear(); _suppressedButtons.clear(); + _heldFocusNodes.clear(); if (_isDesktop) { windowManager.removeListener(this); } @@ -253,6 +258,7 @@ class GamepadService with WindowListener { } _pressedButtons.clear(); _suppressedButtons.clear(); + _heldFocusNodes.clear(); _duplicateInputGuard.clear(); // Reset analog stick state so re-focus doesn't inherit stale direction @@ -294,6 +300,7 @@ class GamepadService with WindowListener { _stopDirectionRepeat(); _pressedButtons.clear(); _suppressedButtons.clear(); + _heldFocusNodes.clear(); _duplicateInputGuard.clear(); } @@ -527,6 +534,13 @@ class GamepadService with WindowListener { } void _dispatchKeyDown(LogicalKeyboardKey logicalKey) { + final focusNode = FocusManager.instance.primaryFocus; + if (focusNode == null) { + _logGamepadDiag('dispatchKeyDown dropped reason=no-focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); + return; + } + + _heldFocusNodes[logicalKey] = focusNode; _logGamepadDiag('dispatchKeyDown logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); _dispatchKeyEvent( KeyDownEvent( @@ -535,10 +549,24 @@ class GamepadService with WindowListener { timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), deviceType: ui.KeyEventDeviceType.gamepad, ), + startNode: focusNode, ); } void _dispatchKeyUp(LogicalKeyboardKey logicalKey) { + final heldFocusNode = _heldFocusNodes.remove(logicalKey); + final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus; + if (focusNode == null) { + _logGamepadDiag('dispatchKeyUp dropped reason=no-focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); + return; + } + if (heldFocusNode != null && heldFocusNode.context == null) { + _logGamepadDiag( + 'dispatchKeyUp dropped reason=held-focus-detached logical=${logicalKey.keyLabel}/${logicalKey.keyId}', + ); + return; + } + _logGamepadDiag('dispatchKeyUp logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); _dispatchKeyEvent( KeyUpEvent( @@ -547,11 +575,12 @@ class GamepadService with WindowListener { timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), deviceType: ui.KeyEventDeviceType.gamepad, ), + startNode: focusNode, ); } - void _dispatchKeyEvent(KeyEvent event) { - FocusNode? node = FocusManager.instance.primaryFocus; + void _dispatchKeyEvent(KeyEvent event, {FocusNode? startNode}) { + FocusNode? node = startNode ?? FocusManager.instance.primaryFocus; _logGamepadDiag('dispatch start focus=${node?.debugLabel} key=(${_describeGamepadKeyEvent(event)})'); while (node != null) { if (node.onKeyEvent != null) { diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart index 3a3e2a2d..04633c48 100644 --- a/lib/utils/key_event_simulator.dart +++ b/lib/utils/key_event_simulator.dart @@ -15,6 +15,8 @@ void _logKeySimulator(String message) { TextInputDiagnostics.log('KeySimulator', message); } +final Map _heldFocusNodes = {}; + /// Shared utility for simulating key press events through the focus tree. /// /// Used by companion remotes, Apple TV touch input, and gamepad services to @@ -63,6 +65,7 @@ void simulateKeyDown(LogicalKeyboardKey logicalKey) { final focusNode = FocusManager.instance.primaryFocus; if (focusNode == null) return; + _heldFocusNodes[logicalKey] = focusNode; _dispatchKeyEvent( focusNode, KeyDownEvent( @@ -80,8 +83,13 @@ void simulateKeyUp(LogicalKeyboardKey logicalKey) { _logKeySimulator('simulateKeyUp scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); scheduleFrameIfIdle(); SchedulerBinding.instance.addPostFrameCallback((_) { - final focusNode = FocusManager.instance.primaryFocus; + final heldFocusNode = _heldFocusNodes.remove(logicalKey); + final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus; if (focusNode == null) return; + if (heldFocusNode != null && heldFocusNode.context == null) { + _logKeySimulator('simulateKeyUp dropped detached held focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); + return; + } _dispatchKeyEvent( focusNode, diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 6ed3bc21..aa398112 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -18,6 +18,64 @@ import 'app_logger.dart'; const String kVideoPlayerRouteName = '/video_player'; +class VideoPlayerNavigationInFlightGuard { + final Set _keys = {}; + + bool tryStart( + MediaItem metadata, { + required int mediaIndex, + required String? selectedMediaSourceId, + required TranscodeQualityPreset? selectedQualityPreset, + required bool isOffline, + }) { + return _keys.add( + _keyFor( + metadata, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + selectedQualityPreset: selectedQualityPreset, + isOffline: isOffline, + ), + ); + } + + void finish( + MediaItem metadata, { + required int mediaIndex, + required String? selectedMediaSourceId, + required TranscodeQualityPreset? selectedQualityPreset, + required bool isOffline, + }) { + _keys.remove( + _keyFor( + metadata, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + selectedQualityPreset: selectedQualityPreset, + isOffline: isOffline, + ), + ); + } + + String _keyFor( + MediaItem metadata, { + required int mediaIndex, + required String? selectedMediaSourceId, + required TranscodeQualityPreset? selectedQualityPreset, + required bool isOffline, + }) { + return [ + metadata.globalKey, + mediaIndex, + selectedMediaSourceId ?? '', + selectedQualityPreset?.name ?? 'auto', + isOffline, + ].join('|'); + } +} + +final _videoPlayerNavigationInFlightGuard = VideoPlayerNavigationInFlightGuard(); + class WatchTogetherPlaybackNavigationException implements Exception { final String message; @@ -82,24 +140,53 @@ Future navigateToVideoPlayer( } catch (_) {} } - // Check if external player is enabled - try { - final settingsService = await SettingsService.getInstance(); - if (settingsService.read(SettingsService.useExternalPlayer)) { - bool launched = false; + var markedInFlight = false; + if (!usePushReplacement) { + markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart( + metadata, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + selectedQualityPreset: selectedQualityPreset, + isOffline: isOffline, + ); + if (!markedInFlight) { + appLogger.d( + 'Video player navigation already in flight for ${metadata.id} (mediaIndex=$mediaIndex), ' + 'skipping duplicate navigation', + ); + return null; + } + } - if (isOffline) { - final globalKey = metadata.globalKey; - final videoPath = await downloadProvider.getVideoFilePath( - globalKey, - mediaIndex: mediaIndex, - mediaSourceId: selectedMediaSourceId, - ); - if (videoPath != null && context.mounted) { - final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; + try { + // Check if external player is enabled + try { + final settingsService = await SettingsService.getInstance(); + if (settingsService.read(SettingsService.useExternalPlayer)) { + bool launched = false; + + if (isOffline) { + final globalKey = metadata.globalKey; + final videoPath = await downloadProvider.getVideoFilePath( + globalKey, + mediaIndex: mediaIndex, + mediaSourceId: selectedMediaSourceId, + ); + if (videoPath != null && context.mounted) { + final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; + launched = await ExternalPlayerService.launch( + context: context, + videoUrl: videoUrl, + metadata: metadata, + client: mediaClient, + offlineWatchService: offlineWatchService, + mediaIndex: mediaIndex, + mediaSourceId: selectedMediaSourceId, + ); + } + } else if (context.mounted) { launched = await ExternalPlayerService.launch( context: context, - videoUrl: videoUrl, metadata: metadata, client: mediaClient, offlineWatchService: offlineWatchService, @@ -107,50 +194,51 @@ Future navigateToVideoPlayer( mediaSourceId: selectedMediaSourceId, ); } - } else if (context.mounted) { - launched = await ExternalPlayerService.launch( - context: context, - metadata: metadata, - client: mediaClient, - offlineWatchService: offlineWatchService, - mediaIndex: mediaIndex, - mediaSourceId: selectedMediaSourceId, - ); + + if (launched) return null; } - - if (launched) return null; + } catch (e) { + appLogger.w('External player launch failed, falling back to built-in player', error: e); } - } catch (e) { - appLogger.w('External player launch failed, falling back to built-in player', error: e); - } - // Prevent stacking an identical video player when already active - if (!usePushReplacement && - VideoPlayerScreenState.activeId == metadata.id && - VideoPlayerScreenState.activeMediaIndex == mediaIndex) { - appLogger.d( - 'Video player already active for ${metadata.id} (mediaIndex=$mediaIndex), skipping duplicate navigation', + // Prevent stacking an identical video player when already active + if (!usePushReplacement && + VideoPlayerScreenState.activeId == metadata.id && + VideoPlayerScreenState.activeMediaIndex == mediaIndex) { + appLogger.d( + 'Video player already active for ${metadata.id} (mediaIndex=$mediaIndex), skipping duplicate navigation', + ); + return null; + } + + final route = PageRouteBuilder( + settings: const RouteSettings(name: kVideoPlayerRouteName), + pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + metadata: metadata, + preferredAudioTrack: preferredAudioTrack, + preferredSubtitleTrack: preferredSubtitleTrack, + preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, + selectedMediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + selectedQualityPreset: selectedQualityPreset, + isOffline: isOffline, + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, ); - return null; + + return usePushReplacement ? navigator.pushReplacement(route) : navigator.push(route); + } finally { + if (markedInFlight) { + _videoPlayerNavigationInFlightGuard.finish( + metadata, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + selectedQualityPreset: selectedQualityPreset, + isOffline: isOffline, + ); + } } - - final route = PageRouteBuilder( - settings: const RouteSettings(name: kVideoPlayerRouteName), - pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( - metadata: metadata, - preferredAudioTrack: preferredAudioTrack, - preferredSubtitleTrack: preferredSubtitleTrack, - preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, - selectedMediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - selectedQualityPreset: selectedQualityPreset, - isOffline: isOffline, - ), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - ); - - return usePushReplacement ? navigator.pushReplacement(route) : navigator.push(route); } /// Navigates to the video player and optionally refreshes content when returning. diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index da4ea799..b368cb68 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -334,6 +334,7 @@ class TvBrowseRail extends StatefulWidget { class TvBrowseRailState extends State { static const _longPressDuration = Duration(milliseconds: 500); + static const _selectSuppressionTimeout = Duration(milliseconds: 220); static const _navigationScrollDuration = Duration(milliseconds: 130); static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); static const _scrollCatchUpViewportDistance = 2.5; @@ -352,8 +353,10 @@ class TvBrowseRailState extends State { List _sectionOffsets = const []; double _sectionMaxScrollExtent = 0; Timer? _longPressTimer; + Timer? _selectSuppressionTimer; bool _isSelectKeyDown = false; bool _longPressTriggered = false; + bool _suppressSelectUntilKeyUp = false; bool _hasUserChangedHub = false; bool _hasUserChangedItem = false; @@ -364,6 +367,15 @@ class TvBrowseRailState extends State { _focusNode.requestFocus(); } + void suppressSelectUntilKeyUp() { + _resetLongPressState(); + _suppressSelectUntilKeyUp = true; + _selectSuppressionTimer?.cancel(); + _selectSuppressionTimer = Timer(_selectSuppressionTimeout, () { + _suppressSelectUntilKeyUp = false; + }); + } + @override void initState() { super.initState(); @@ -452,6 +464,7 @@ class TvBrowseRailState extends State { @override void dispose() { _longPressTimer?.cancel(); + _selectSuppressionTimer?.cancel(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); for (final controller in _scrollControllers.values) { @@ -473,6 +486,12 @@ class TvBrowseRailState extends State { _longPressTriggered = false; } + void _clearSelectSuppression() { + _selectSuppressionTimer?.cancel(); + _selectSuppressionTimer = null; + _suppressSelectUntilKeyUp = false; + } + int _totalItemCount(MediaHub hub) => hub.items.length + (hub.more ? 1 : 0); bool _isPersonHub(MediaHub hub) => TvBrowseRailLayout.isPersonHub(hub); @@ -517,6 +536,11 @@ class TvBrowseRailState extends State { final key = event.logicalKey; if (key.isSelectKey) { + if (_suppressSelectUntilKeyUp) { + if (event is KeyUpEvent) _clearSelectSuppression(); + return KeyEventResult.handled; + } + if (event is KeyDownEvent) { if (!_isSelectKeyDown) { _isSelectKeyDown = true; diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index b8f61701..61ba9112 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -172,6 +172,50 @@ void main() { expect(harness.keyUps, [LogicalKeyboardKey.enter]); }); + test('native select during click fallback is consumed and releases synthetic select', () async { + final harness = _Harness(); + + await harness.send('click_s'); + + expect(harness.keyDowns, [LogicalKeyboardKey.enter]); + expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.enter)), isTrue); + expect(harness.keyUps, isEmpty); + + expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.enter)), isTrue); + + expect(harness.keyUps, [LogicalKeyboardKey.enter]); + + await harness.send('click_e'); + + expect(harness.keyUps, [LogicalKeyboardKey.enter]); + }); + + test('native select burst consumes duplicate native pairs', () async { + final harness = _Harness(); + + expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)), isFalse); + expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)), isFalse); + + expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)), isTrue); + expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)), isTrue); + + harness.advance(const Duration(milliseconds: 121)); + + expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)), isFalse); + expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)), isFalse); + }); + + test('raw native enter suppresses click fallback from tvOS engine path', () async { + final harness = _Harness(); + + harness.service.handleNativeKeyEvent(_keyDown(_rawEnterKey)); + await harness.send('click_s'); + await harness.send('click_e'); + + expect(harness.keyDowns, isEmpty); + expect(harness.keyUps, isEmpty); + }); + test('recent directional input suppresses click fallback', () async { final harness = _Harness(); @@ -240,6 +284,8 @@ class _Harness { } } +const _rawEnterKey = LogicalKeyboardKey(0x0d); + KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) { return KeyDownEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero); } diff --git a/test/utils/key_event_simulator_test.dart b/test/utils/key_event_simulator_test.dart index 375a7957..45ae776f 100644 --- a/test/utils/key_event_simulator_test.dart +++ b/test/utils/key_event_simulator_test.dart @@ -32,6 +32,59 @@ void main() { expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad)); }); + testWidgets('simulateKeyUp returns to the key-down focus when focus changes', (tester) async { + final firstNode = FocusNode(debugLabel: 'first'); + final secondNode = FocusNode(debugLabel: 'second'); + addTearDown(firstNode.dispose); + addTearDown(secondNode.dispose); + + final firstEvents = []; + final secondEvents = []; + + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + Focus( + focusNode: firstNode, + onKeyEvent: (_, event) { + firstEvents.add(event); + return KeyEventResult.handled; + }, + child: const SizedBox(width: 10, height: 10), + ), + Focus( + focusNode: secondNode, + onKeyEvent: (_, event) { + secondEvents.add(event); + return KeyEventResult.handled; + }, + child: const SizedBox(width: 10, height: 10), + ), + ], + ), + ), + ); + firstNode.requestFocus(); + await tester.pump(); + + simulateKeyDown(LogicalKeyboardKey.enter); + await tester.pump(); + expect(firstEvents, hasLength(1)); + expect(firstEvents.single, isA()); + + secondNode.requestFocus(); + await tester.pump(); + expect(secondNode.hasPrimaryFocus, isTrue); + + simulateKeyUp(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(firstEvents, hasLength(2)); + expect(firstEvents.last, isA()); + expect(secondEvents, isEmpty); + }); + testWidgets('simulateKeyPress stops at skipRemainingHandlers', (tester) async { final childEvents = []; final parentEvents = []; diff --git a/test/utils/video_player_navigation_test.dart b/test/utils/video_player_navigation_test.dart new file mode 100644 index 00000000..18da6d46 --- /dev/null +++ b/test/utils/video_player_navigation_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/utils/video_player_navigation.dart'; + +void main() { + test('in-flight video player navigation rejects duplicate requests', () { + final guard = VideoPlayerNavigationInFlightGuard(); + final item = MediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + serverId: 'server_1', + ); + + expect( + guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), + isTrue, + ); + expect( + guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), + isFalse, + ); + expect( + guard.tryStart(item, mediaIndex: 1, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), + isTrue, + ); + + guard.finish(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false); + + expect( + guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), + isTrue, + ); + }); +} diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 7edb6b03..fe41bfa1 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -1228,6 +1228,56 @@ void main() { expect(activations, 1); }); + testWidgets('suppresses transferred select activation until key up', (tester) async { + var activations = 0; + final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); + final serverManager = MultiServerManager(); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + hubs: [hub], + iconForHub: (_, _) => Icons.person_rounded, + onActivateItem: (_, _) { + activations++; + return Future.value(true); + }, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final railState = tester.state(find.byType(TvBrowseRail)); + railState.requestFocus(); + railState.suppressSelectUntilKeyUp(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(activations, 0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(activations, 1); + }); + testWidgets('does not autofocus unless requested', (tester) async { FocusManager.instance.primaryFocus?.unfocus();