From 1b3c74550f5aa1e454ddf115f80fe3eb36d48217 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:41:19 +0200 Subject: [PATCH] fix(tvos): make remote input lifecycle engine-owned --- lib/screens/discover_screen.dart | 19 -- lib/screens/main_screen.dart | 55 ++++- .../apple_tv_remote_touch_service.dart | 199 ----------------- lib/services/gamepad_service.dart | 18 ++ lib/widgets/tv_browse_rail.dart | 74 ------- test/screens/main_screen_layout_test.dart | 24 ++ .../apple_tv_remote_touch_service_test.dart | 145 +----------- .../gamepad_duplicate_input_guard_test.dart | 18 ++ test/widgets/tv_browse_rail_test.dart | 206 ------------------ tvos/engine.sha256 | 2 +- tvos/engine.version | 2 +- 11 files changed, 111 insertions(+), 651 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 53303907..952e2ef7 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -3,7 +3,6 @@ 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'; @@ -15,7 +14,6 @@ import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import 'package:cached_network_image_ce/cached_network_image.dart'; -import '../services/apple_tv_remote_touch_service.dart'; import '../services/image_cache_service.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; @@ -250,7 +248,6 @@ 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; @@ -262,7 +259,6 @@ class _DiscoverScreenState extends State if (rail != null) { _pendingTvBrowseRailFocus = false; rail.requestFocus(); - if (suppressSelectUntilKeyUp) rail.suppressSelectUntilKeyUp(); return; } } @@ -278,21 +274,9 @@ 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(); } @@ -1164,9 +1148,6 @@ class _DiscoverScreenState extends State onNavigateUp: _focusTopActions, onNavigateToSidebar: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, - selectSuppressionGestureSignal: PlatformDetector.isAppleTV() - ? AppleTvRemoteTouchService.instance.touchActiveListenable - : null, ); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5158fd02..66a2a6f3 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -149,6 +149,33 @@ bool shouldPassTvosMenuToSystem({ isCurrentTabRoot; } +@visibleForTesting +class TvosMenuPolicyPublisher { + TvosMenuPolicyPublisher(this._compute, this._publish); + + final ValueGetter _compute; + final ValueChanged _publish; + int _transactionDepth = 0; + + void run(VoidCallback transaction) { + _transactionDepth++; + try { + transaction(); + } finally { + _transactionDepth--; + if (_transactionDepth == 0) { + _publish(_compute()); + } + } + } + + void update() { + if (_transactionDepth == 0) { + _publish(_compute()); + } + } +} + @visibleForTesting enum ProfileInvalidationAction { none, invalidateNow } @@ -252,6 +279,7 @@ class _MainScreenState extends State bool _isSidebarFocused = false; bool _isSidebarInteractionExpanded = false; bool _isOverlaySheetOpen = false; + late final TvosMenuPolicyPublisher _tvosMenuPolicyPublisher; /// The binder is now owned by a top-level [Provider] (see main.dart) so /// the splash can await its first settle before navigating here. We just @@ -298,6 +326,7 @@ class _MainScreenState extends State @override void initState() { super.initState(); + _tvosMenuPolicyPublisher = TvosMenuPolicyPublisher(() => _shouldPassTvosMenuToSystem, _setTvosMenuPassthrough); _isOffline = widget.isOfflineMode; _offlineUntilConnected = widget.isOfflineMode; @@ -1213,9 +1242,13 @@ class _MainScreenState extends State unawaited(TvosSystemNavigationService.setMenuPassthroughEnabled(enabled)); } + void _runNavigationTransaction(VoidCallback transaction) { + _tvosMenuPolicyPublisher.run(transaction); + } + void _updateTvosMenuPassthrough() { if (!mounted) return; - _setTvosMenuPassthrough(_shouldPassTvosMenuToSystem); + _tvosMenuPolicyPublisher.update(); } /// Suppress stray back events after a child route pops. @@ -1553,8 +1586,10 @@ class _MainScreenState extends State void _openSettings() { if (PlatformDetector.shouldUseSideNavigation(context)) { - _selectTab(NavigationTabId.settings); - _focusContent(restorePreviousFocus: false); + _runNavigationTransaction(() { + _selectTab(NavigationTabId.settings); + _focusContent(restorePreviousFocus: false); + }); return; } @@ -1816,13 +1851,17 @@ class _MainScreenState extends State isReconnecting: _isReconnecting, onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, onDestinationSelected: (tab) { - final restorePreviousFocus = tab == _currentTab; - _selectTab(tab); - _focusContent(restorePreviousFocus: restorePreviousFocus); + _runNavigationTransaction(() { + final restorePreviousFocus = tab == _currentTab; + _selectTab(tab); + _focusContent(restorePreviousFocus: restorePreviousFocus); + }); }, onLibrarySelected: (key) { - _selectLibrary(key); - _focusContent(restorePreviousFocus: false); + _runNavigationTransaction(() { + _selectLibrary(key); + _focusContent(restorePreviousFocus: false); + }); }, onNavigateToContent: _focusContent, onReconnect: _triggerReconnect, diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 249e0d9c..955d7de8 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -24,14 +23,11 @@ class AppleTvRemoteTouchService { static const double defaultSwipeThreshold = 180; static const double defaultAxisSwitchDominanceRatio = 1.5; static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140); - static const Duration defaultClickAfterDirectionSuppression = Duration(milliseconds: 220); static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService(); final BasicMessageChannel _channel; final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress; - final void Function(LogicalKeyboardKey logicalKey) _simulateKeyDown; - final void Function(LogicalKeyboardKey logicalKey) _simulateKeyUp; final VoidCallback _scheduleFrame; final DateTime Function() _now; final GamepadDuplicateInputGuard _duplicateInputGuard; @@ -40,31 +36,20 @@ class AppleTvRemoteTouchService { final double swipeThreshold; final double axisSwitchDominanceRatio; final Duration swipeRepeatInterval; - final Duration clickAfterDirectionSuppression; bool _listening = false; bool _nativeKeyHandlerRegistered = false; bool _touchActive = false; - final ValueNotifier _touchActiveNotifier = ValueNotifier(false); double _startX = 0; double _startY = 0; double _anchorX = 0; double _anchorY = 0; _SwipeAxis? _lastSwipeAxis; DateTime? _lastSwipeAt; - DateTime? _lastDirectionalInputAt; - DateTime? _lastSyntheticSelectAt; - DateTime? _lastAcceptedNativeSelectDownAt; - DateTime? _lastAcceptedNativeSelectUpAt; - int _suppressedNativeSelectDowns = 0; - bool _nativeSelectPressed = false; - bool _selectPressedFromClick = false; AppleTvRemoteTouchService({ BasicMessageChannel? channel, void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress, - void Function(LogicalKeyboardKey logicalKey)? simulateKeyDown, - void Function(LogicalKeyboardKey logicalKey)? simulateKeyUp, VoidCallback? scheduleFrame, DateTime Function()? now, GamepadDuplicateInputGuard? duplicateInputGuard, @@ -72,12 +57,9 @@ class AppleTvRemoteTouchService { this.swipeThreshold = defaultSwipeThreshold, this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio, this.swipeRepeatInterval = defaultSwipeRepeatInterval, - this.clickAfterDirectionSuppression = defaultClickAfterDirectionSuppression, }) : assert(axisSwitchDominanceRatio >= 1), _channel = channel ?? const BasicMessageChannel(_channelName, JSONMessageCodec()), _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress, - _simulateKeyDown = simulateKeyDown ?? key_sim.simulateKeyDown, - _simulateKeyUp = simulateKeyUp ?? key_sim.simulateKeyUp, _scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle, _now = now ?? DateTime.now, _duplicateInputGuard = @@ -85,14 +67,6 @@ class AppleTvRemoteTouchService { Stream get playPauseActions => _playPauseController.stream; - /// Whether a Siri-remote touch gesture is currently in progress (finger down). - /// Cleared when the touch ends or cancels. tvOS-only; `false` elsewhere. - bool get isTouchActive => _touchActive; - - /// Listenable mirror of [isTouchActive] so widgets can react when the active - /// touch gesture ends (used to extend Home-rail select suppression). - ValueListenable get touchActiveListenable => _touchActiveNotifier; - void start() { if (_listening) return; _channel.setMessageHandler(handleMessage); @@ -106,8 +80,6 @@ class AppleTvRemoteTouchService { _channel.setMessageHandler(null); _unregisterNativeKeyHandler(); _duplicateInputGuard.clear(); - _resetNativeSelectBurstState(); - _releaseSelectFromClick(source: 'stop'); _resetTouch(); _listening = false; } @@ -118,12 +90,6 @@ 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(); - } return _duplicateInputGuard.handleNativeKeyEvent(event); } @@ -159,10 +125,6 @@ class AppleTvRemoteTouchService { _resetTouch(); case 'cancelled': _resetTouch(); - case 'click_e': - _releaseSelectFromClick(source: 'click_e'); - case 'click_s': - _pressSelectFromClick(); case 'play_pause': final source = arguments['source'] is String ? arguments['source'] as String : 'native'; final detail = arguments['detail'] is String ? arguments['detail'] as String : null; @@ -189,7 +151,6 @@ class AppleTvRemoteTouchService { void _startTouch(double x, double y) { _touchActive = true; - _touchActiveNotifier.value = true; _startX = x; _startY = y; _anchorX = x; @@ -262,147 +223,6 @@ class AppleTvRemoteTouchService { return axis == _SwipeAxis.horizontal ? horizontal : vertical; } - void _pressSelectFromClick() { - final now = _now(); - final lastDirectionalInputAt = _lastDirectionalInputAt; - if (lastDirectionalInputAt != null && now.difference(lastDirectionalInputAt) <= clickAfterDirectionSuppression) { - final age = now.difference(lastDirectionalInputAt).inMilliseconds; - _log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-direction age=${age}ms'); - return; - } - - final lastSyntheticSelectAt = _lastSyntheticSelectAt; - if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) { - final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds; - _log( - 'suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-synthetic-select age=${age}ms', - ); - return; - } - - if (_duplicateInputGuard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter)) { - _log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-native'); - return; - } - - _setTraditionalFocusHighlight(); - _scheduleFrame(); - _selectPressedFromClick = true; - _log('emit keydown=${_keyName(LogicalKeyboardKey.enter)} source=click_s'); - _simulateKeyDown(LogicalKeyboardKey.enter); - } - - void _releaseSelectFromClick({required String source}) { - if (!_selectPressedFromClick) { - _log('ignore keyup=${_keyName(LogicalKeyboardKey.enter)} source=$source reason=no-click-select-down'); - return; - } - - _setTraditionalFocusHighlight(); - _scheduleFrame(); - _selectPressedFromClick = false; - _lastSyntheticSelectAt = _now(); - _log('emit keyup=${_keyName(LogicalKeyboardKey.enter)} source=$source'); - _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'); @@ -412,9 +232,6 @@ class AppleTvRemoteTouchService { _setTraditionalFocusHighlight(); _scheduleFrame(); _log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}'); - if (_isDirectionalKey(logicalKey)) { - _lastDirectionalInputAt = _now(); - } _simulateKeyPress(logicalKey); return true; } @@ -423,7 +240,6 @@ class AppleTvRemoteTouchService { void _resetTouch() { _touchActive = false; - _touchActiveNotifier.value = false; _lastSwipeAxis = null; _lastSwipeAt = null; } @@ -480,21 +296,6 @@ class AppleTvRemoteTouchService { return '0x${key.keyId.toRadixString(16)}'; } - bool _isDirectionalKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.arrowUp || - key == LogicalKeyboardKey.arrowDown || - key == LogicalKeyboardKey.arrowLeft || - 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 d549f0ab..93f01fea 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -109,6 +109,20 @@ class GamepadDuplicateInputGuard { } } +@visibleForTesting +bool isTvosEngineOwnedGamepadButton({required bool isAppleTV, required GamepadButton button}) { + if (!isAppleTV) return false; + return switch (button) { + GamepadButton.dpadUp || + GamepadButton.dpadDown || + GamepadButton.dpadLeft || + GamepadButton.dpadRight || + GamepadButton.a || + GamepadButton.b => true, + _ => false, + }; +} + /// Service that bridges gamepad input to Flutter's focus navigation system. /// /// Listens to gamepad events from the `universal_gamepad` package and translates @@ -382,6 +396,10 @@ class GamepadService with WindowListener { _logGamepadDiag('button ignored because window is not focused ${_describeGamepadButton(event)}'); return; } + if (isTvosEngineOwnedGamepadButton(isAppleTV: PlatformDetector.isAppleTV(), button: event.button)) { + _logGamepadDiag('button ignored because tvOS engine owns its key lifecycle ${_describeGamepadButton(event)}'); + return; + } // Switch to keyboard mode on any button press if (event.pressed) { diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 6c57e1e0..208b8b8a 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -2,7 +2,6 @@ import 'dart:async'; import '../media/ids.dart'; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -346,13 +345,6 @@ class TvBrowseRail extends StatefulWidget { /// itself, so sidebar flips never rebuild the rail — only the bleed layer. final double? backgroundBleedLeft; - /// Optional signal that is `true` while an input gesture (e.g. a Siri-remote - /// touch) is in progress. When select-suppression is armed during an active - /// gesture, it is held until the gesture ends (finger lift) rather than the - /// short no-touch timeout — one activation per touch. Generic by design: no - /// platform/service coupling here. - final ValueListenable? selectSuppressionGestureSignal; - const TvBrowseRail({ super.key, required this.hubs, @@ -381,7 +373,6 @@ class TvBrowseRail extends StatefulWidget { this.episodePosterModeForHub, this.widePosterScaleForHub, this.backgroundBleedLeft, - this.selectSuppressionGestureSignal, }); @override @@ -389,14 +380,6 @@ class TvBrowseRail extends StatefulWidget { } class TvBrowseRailState extends State { - // No-touch fallback only: clear suppression even if no select key-up is seen - // (e.g. a held-key carry-over on a non-touch remote). Touch-driven clicks use - // the gesture path instead, which is bounded by the physical touch. - static const _selectSuppressionTimeout = Duration(milliseconds: 220); - // Touch path safety net only. The deterministic clear is the touch ending - // (finger lift); this guards solely against a dropped touch-end event and is - // generous enough never to fire mid-gesture in practice. - static const _selectSuppressionGestureBackstop = Duration(seconds: 3); static const _navigationScrollDuration = Duration(milliseconds: 130); static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); static const _scrollCatchUpViewportDistance = 2.5; @@ -426,10 +409,6 @@ class TvBrowseRailState extends State { List _sectionOffsets = const []; double _sectionMaxScrollExtent = 0; final _selectLongPress = DpadSelectLongPressController(); - Timer? _selectSuppressionTimer; - Timer? _selectSuppressionMaxTimer; - VoidCallback? _gestureSignalListener; - bool _suppressSelectUntilKeyUp = false; bool _hasUserInteracted = false; bool _hasUserChangedHub = false; int _verticalScrollGeneration = 0; @@ -442,35 +421,6 @@ class TvBrowseRailState extends State { _focusNode.requestFocus(); } - void suppressSelectUntilKeyUp() { - _resetLongPressState(); - _suppressSelectUntilKeyUp = true; - _selectSuppressionTimer?.cancel(); - _selectSuppressionMaxTimer?.cancel(); - _detachGestureSignalListener(); - - final gesture = widget.selectSuppressionGestureSignal; - if (gesture != null && gesture.value) { - // A Siri-remote touch is in progress. The stray select that would auto-play - // a Continue Watching item is delivered within this same uninterrupted - // touch: one physical press navigates Home, then bounces a second select - // mid-drag (#1281). Hold suppression until the finger lifts — one - // activation per touch, no time heuristic. The next observed select key-up - // also clears it; the backstop only guards against a dropped touch-end. - _gestureSignalListener = () { - if (!(widget.selectSuppressionGestureSignal?.value ?? false)) { - _clearSelectSuppression(); - } - }; - gesture.addListener(_gestureSignalListener!); - _selectSuppressionMaxTimer = Timer(_selectSuppressionGestureBackstop, _clearSelectSuppression); - } else { - // No touch in progress (held-key carry-over on a non-touch remote): clear - // on the next select key-up, with the short legacy safety timeout. - _selectSuppressionTimer = Timer(_selectSuppressionTimeout, _clearSelectSuppression); - } - } - @override void initState() { super.initState(); @@ -604,9 +554,6 @@ class TvBrowseRailState extends State { @override void dispose() { _selectLongPress.dispose(); - _selectSuppressionTimer?.cancel(); - _selectSuppressionMaxTimer?.cancel(); - _detachGestureSignalListener(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); _focusModel.dispose(); @@ -632,23 +579,6 @@ class TvBrowseRailState extends State { _selectLongPress.reset(); } - void _clearSelectSuppression() { - _selectSuppressionTimer?.cancel(); - _selectSuppressionTimer = null; - _selectSuppressionMaxTimer?.cancel(); - _selectSuppressionMaxTimer = null; - _suppressSelectUntilKeyUp = false; - _detachGestureSignalListener(); - } - - void _detachGestureSignalListener() { - final listener = _gestureSignalListener; - if (listener != null) { - widget.selectSuppressionGestureSignal?.removeListener(listener); - _gestureSignalListener = null; - } - } - bool _hasTrailingFor(MediaHub hub) => _trailingFor(hub) != TvRailTrailing.none; int _totalItemCount(MediaHub hub) => hub.items.length + (_hasTrailingFor(hub) ? 1 : 0); @@ -705,10 +635,6 @@ class TvBrowseRailState extends State { final key = event.logicalKey; if (key.isSelectKey) { - if (_suppressSelectUntilKeyUp) { - if (event is KeyUpEvent) _clearSelectSuppression(); - return KeyEventResult.handled; - } _hasUserInteracted = true; return _selectLongPress.handleKeyEvent( diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart index 83564163..82e8642a 100644 --- a/test/screens/main_screen_layout_test.dart +++ b/test/screens/main_screen_layout_test.dart @@ -71,6 +71,30 @@ void main() { expect(shouldPass(isAppleTV: false), isFalse); }); + test('tvOS Menu policy transaction publishes only the settled navigation state', () { + var desired = false; + final published = []; + final publisher = TvosMenuPolicyPublisher(() => desired, published.add); + + publisher.run(() { + desired = true; + publisher.update(); + desired = false; + }); + + expect(published, [false]); + }); + + test('tvOS Menu policy publishes retained sidebar Home state immediately', () { + final desired = true; + final published = []; + final publisher = TvosMenuPolicyPublisher(() => desired, published.add); + + publisher.update(); + + expect(published, [true]); + }); + test('macOS physical Escape is reserved for native fullscreen only at root Home', () { bool shouldHandle({ bool isMacOS = true, diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index a8e248b7..abc7520e 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -134,119 +134,14 @@ void main() { expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); }); - test('click events emit held select key down and up', () async { - final harness = _Harness(); - - await harness.send('started', x: 500, y: 500); - await harness.send('ended', x: 500, y: 500); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keyDowns, [LogicalKeyboardKey.enter]); - expect(harness.keyUps, [LogicalKeyboardKey.enter]); - - harness.advance(const Duration(milliseconds: 121)); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keyDowns, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]); - expect(harness.keyUps, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]); - }); - - test('native select suppresses click fallback from physical remote path', () async { - final harness = _Harness(); - - harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keyDowns, isEmpty); - expect(harness.keyUps, isEmpty); - - harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)); - harness.advance(const Duration(milliseconds: 121)); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keyDowns, [LogicalKeyboardKey.enter]); - expect(harness.keyUps, [LogicalKeyboardKey.enter]); - }); - - test('native select during click fallback is consumed and releases synthetic select', () async { + test('legacy click messages do not synthesize 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]); + expect(harness.keys, isEmpty); }); - - 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(); - - harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft)); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keyDowns, isEmpty); - expect(harness.keyUps, isEmpty); - - harness.advance(const Duration(milliseconds: 221)); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keyDowns, [LogicalKeyboardKey.enter]); - expect(harness.keyUps, [LogicalKeyboardKey.enter]); - }); - - test('synthetic swipe suppresses click fallback', () async { - final harness = _Harness(); - - await harness.send('started', x: 500, y: 500); - await harness.send('move', x: 380, y: 500); - await harness.send('click_s'); - await harness.send('click_e'); - - expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); - expect(harness.keyDowns, isEmpty); - expect(harness.keyUps, isEmpty); - }); - test('cancelled touch does not emit select on a later ended message', () async { final harness = _Harness(); @@ -257,45 +152,15 @@ void main() { expect(harness.keys, isEmpty); }); - - test('isTouchActive and listenable track touch start and end', () async { - final harness = _Harness(); - final seen = []; - harness.service.touchActiveListenable.addListener(() => seen.add(harness.service.isTouchActive)); - - expect(harness.service.isTouchActive, isFalse); - - await harness.send('started', x: 500, y: 500); - expect(harness.service.isTouchActive, isTrue); - - await harness.send('ended', x: 500, y: 500); - expect(harness.service.isTouchActive, isFalse); - - expect(seen, [true, false]); - }); - - test('cancelled touch clears touch-active state', () async { - final harness = _Harness(); - - await harness.send('started', x: 500, y: 500); - expect(harness.service.isTouchActive, isTrue); - - await harness.send('cancelled'); - expect(harness.service.isTouchActive, isFalse); - }); }); } class _Harness { DateTime now = DateTime(2026, 5, 5, 12); final List keys = []; - final List keyDowns = []; - final List keyUps = []; late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService( simulateKeyPress: keys.add, - simulateKeyDown: keyDowns.add, - simulateKeyUp: keyUps.add, scheduleFrame: () {}, now: () => now, swipeThreshold: 100, @@ -310,12 +175,6 @@ class _Harness { } } -const _rawEnterKey = LogicalKeyboardKey(0x0d); - KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) { return KeyDownEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero); } - -KeyUpEvent _keyUp(LogicalKeyboardKey logicalKey) { - return KeyUpEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero); -} diff --git a/test/services/gamepad_duplicate_input_guard_test.dart b/test/services/gamepad_duplicate_input_guard_test.dart index 72f72678..e3a9bcd2 100644 --- a/test/services/gamepad_duplicate_input_guard_test.dart +++ b/test/services/gamepad_duplicate_input_guard_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/gamepad_service.dart'; +import 'package:universal_gamepad/universal_gamepad.dart'; void main() { group('GamepadDuplicateInputGuard', () { @@ -60,6 +61,23 @@ void main() { expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.gameButtonB), isTrue); }); }); + + test('tvOS engine exclusively owns standard navigation buttons', () { + for (final button in [ + GamepadButton.dpadUp, + GamepadButton.dpadDown, + GamepadButton.dpadLeft, + GamepadButton.dpadRight, + GamepadButton.a, + GamepadButton.b, + ]) { + expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: button), isTrue); + } + + expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: GamepadButton.x), isFalse); + expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: GamepadButton.leftShoulder), isFalse); + expect(isTvosEngineOwnedGamepadButton(isAppleTV: false, button: GamepadButton.a), isFalse); + }); } KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) { diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 74f7e29b..fd1eac83 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -2041,212 +2041,6 @@ void main() { expect(activations, 1); }); - testWidgets('suppresses transferred select activation until key up', (tester) async { - var activations = 0; - final person = testMediaItem(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( - focusMemory: focusMemory, - 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('without a gesture signal, suppression clears on the legacy safety timeout', (tester) async { - var activations = 0; - final person = testMediaItem(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( - focusMemory: focusMemory, - 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(); - - // With no touch gesture, suppression must not outlive the short safety - // timeout — a select after it elapses activates normally. - await tester.pump(const Duration(milliseconds: 300)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); - await tester.pump(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(activations, 1); - }); - - testWidgets('an active touch gesture holds select suppression past the legacy window', (tester) async { - var activations = 0; - final gesture = ValueNotifier(true); - addTearDown(gesture.dispose); - final person = testMediaItem(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( - focusMemory: focusMemory, - hubs: [hub], - iconForHub: (_, _) => Icons.person_rounded, - selectSuppressionGestureSignal: gesture, - onActivateItem: (_, _) { - activations++; - return Future.value(true); - }, - ), - ), - ), - ), - ), - ); - await tester.pump(); - - final railState = tester.state(find.byType(TvBrowseRail)); - railState.requestFocus(); - railState.suppressSelectUntilKeyUp(); - await tester.pump(); - - // Well past the legacy 220ms window, finger still down (gesture active): the - // stray same-gesture select (#1281) is still ignored. - await tester.pump(const Duration(milliseconds: 1000)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); - await tester.pump(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(activations, 0); - - // Once cleared, deliberate selects work again. - await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); - await tester.pump(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(activations, 1); - }); - - testWidgets('ending the gesture clears select suppression before the backstop', (tester) async { - var activations = 0; - final gesture = ValueNotifier(true); - addTearDown(gesture.dispose); - final person = testMediaItem(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( - focusMemory: focusMemory, - hubs: [hub], - iconForHub: (_, _) => Icons.person_rounded, - selectSuppressionGestureSignal: gesture, - onActivateItem: (_, _) { - activations++; - return Future.value(true); - }, - ), - ), - ), - ), - ), - ); - await tester.pump(); - - final railState = tester.state(find.byType(TvBrowseRail)); - railState.requestFocus(); - railState.suppressSelectUntilKeyUp(); - await tester.pump(); - - // Suppression holds while the gesture is active, past the legacy window. - await tester.pump(const Duration(milliseconds: 1000)); - // Finger lifts -> gesture ends -> suppression clears immediately, well before - // the safety backstop. - gesture.value = false; - await tester.pump(); - - 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(); diff --git a/tvos/engine.sha256 b/tvos/engine.sha256 index 8a21e963..8c3e1643 100644 --- a/tvos/engine.sha256 +++ b/tvos/engine.sha256 @@ -1 +1 @@ -db7b9740fbc38dd7ecead775f2c04cbfced2cab95717f2ed1d00d1f0f4438a55 +278584b13cf4def805f9ceaa97ee4e318d5e5f75c9222286295135e5691d23ef diff --git a/tvos/engine.version b/tvos/engine.version index cc75750f..f40e6cda 100644 --- a/tvos/engine.version +++ b/tvos/engine.version @@ -1 +1 @@ -3.44.0+3 +3.44.0+4