diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index cc4ccd7c8..9585723c8 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -152,33 +152,6 @@ 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 } @@ -282,7 +255,6 @@ 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 @@ -329,7 +301,6 @@ class _MainScreenState extends State @override void initState() { super.initState(); - _tvosMenuPolicyPublisher = TvosMenuPolicyPublisher(() => _shouldPassTvosMenuToSystem, _setTvosMenuPassthrough); _isOffline = widget.isOfflineMode; _offlineUntilConnected = widget.isOfflineMode; @@ -1235,13 +1206,9 @@ class _MainScreenState extends State unawaited(TvosSystemNavigationService.setMenuPassthroughEnabled(enabled)); } - void _runNavigationTransaction(VoidCallback transaction) { - _tvosMenuPolicyPublisher.run(transaction); - } - void _updateTvosMenuPassthrough() { if (!mounted) return; - _tvosMenuPolicyPublisher.update(); + _setTvosMenuPassthrough(_shouldPassTvosMenuToSystem); } /// Suppress stray back events after a child route pops. @@ -1553,10 +1520,8 @@ class _MainScreenState extends State void _openSettings() { if (PlatformDetector.shouldUseSideNavigation(context)) { - _runNavigationTransaction(() { - _selectTab(NavigationTabId.settings); - _focusContent(restorePreviousFocus: false); - }); + _selectTab(NavigationTabId.settings); + _focusContent(restorePreviousFocus: false); return; } @@ -1830,17 +1795,13 @@ class _MainScreenState extends State isReconnecting: _isReconnecting, onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, onDestinationSelected: (tab) { - _runNavigationTransaction(() { - final restorePreviousFocus = tab == _currentTab; - _selectTab(tab); - _focusContent(restorePreviousFocus: restorePreviousFocus); - }); + final restorePreviousFocus = tab == _currentTab; + _selectTab(tab); + _focusContent(restorePreviousFocus: restorePreviousFocus); }, onLibrarySelected: (key) { - _runNavigationTransaction(() { - _selectLibrary(key); - _focusContent(restorePreviousFocus: false); - }); + _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 041229695..5e844e323 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -18,6 +18,14 @@ class AppleTvRemotePlayPauseAction { const AppleTvRemotePlayPauseAction({required this.source, this.detail}); } +const double _axisSwitchDominanceRatio = 1.5; +const Duration _swipeRepeatInterval = Duration(milliseconds: 140); +// Device-tuned on an Apple TV 4K against native focus feel: UIKit's +// indirect-touch acceleration means roughly half an item's extent of +// reported travel already reads as "one deliberate swipe". +const double _swipeExtentGain = 0.55; +const double _maxSwipeThreshold = 180; + /// Bridges tvOS touch-surface events from Apple's iOS Remote app into the /// focus-tree key events Plezy already handles for D-pad navigation. /// @@ -29,39 +37,22 @@ class AppleTvRemotePlayPauseAction { class AppleTvRemoteTouchService { static const String _channelName = 'flutter/gamepadtouchevent'; static const double defaultSwipeThreshold = 180; - static const double defaultAxisSwitchDominanceRatio = 1.5; - static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140); - // Device-tuned on an Apple TV 4K against native focus feel: UIKit's - // indirect-touch acceleration means roughly half an item's extent of - // reported travel already reads as "one deliberate swipe". - static const double defaultSwipeExtentGain = 0.55; static const double defaultMinSwipeThreshold = 50; - static const double defaultMaxSwipeThreshold = 180; static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService(); - final BasicMessageChannel _channel; + final BasicMessageChannel _channel = const BasicMessageChannel(_channelName, JSONMessageCodec()); final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress; final VoidCallback _scheduleFrame; final DateTime Function() _now; final GamepadDuplicateInputGuard _duplicateInputGuard; - /// Announces that a pointerless device produced input. Injected so tests can - /// observe it without a widget tree; defaults to the app-wide tracker. - final void Function() reportNonPointerInput; final StreamController _playPauseController = StreamController.broadcast(); /// Fallback step distance when no usable focus geometry exists. final double swipeThreshold; - final double axisSwitchDominanceRatio; - final Duration swipeRepeatInterval; - - /// Multiplier from the focused control's extent to the pan distance for one - /// step. Empirical; see the default constants for the device calibration. - final double swipeExtentGain; final double minSwipeThreshold; - final double maxSwipeThreshold; /// Global rect of the control that prices a focus step, or null when no /// usable geometry exists. Injected so tests can supply fake geometry. @@ -78,30 +69,18 @@ class AppleTvRemoteTouchService { DateTime? _lastSwipeAt; AppleTvRemoteTouchService({ - BasicMessageChannel? channel, void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress, VoidCallback? scheduleFrame, DateTime Function()? now, - GamepadDuplicateInputGuard? duplicateInputGuard, - this.reportNonPointerInput = InputModeTracker.reportNonPointerInput, - Duration duplicateSuppressionWindow = GamepadDuplicateInputGuard.defaultSuppressionWindow, this.swipeThreshold = defaultSwipeThreshold, - this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio, - this.swipeRepeatInterval = defaultSwipeRepeatInterval, - this.swipeExtentGain = defaultSwipeExtentGain, this.minSwipeThreshold = defaultMinSwipeThreshold, - this.maxSwipeThreshold = defaultMaxSwipeThreshold, Rect? Function()? focusedItemRect, - }) : assert(axisSwitchDominanceRatio >= 1), - assert(swipeExtentGain > 0), - assert(minSwipeThreshold > 0 && minSwipeThreshold <= maxSwipeThreshold), - _channel = channel ?? const BasicMessageChannel(_channelName, JSONMessageCodec()), + }) : assert(minSwipeThreshold > 0 && minSwipeThreshold <= _maxSwipeThreshold), _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress, _scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle, _now = now ?? DateTime.now, _focusedItemRect = focusedItemRect ?? _defaultFocusedItemRect, - _duplicateInputGuard = - duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow); + _duplicateInputGuard = GamepadDuplicateInputGuard(now: now); Stream get playPauseActions => _playPauseController.stream; @@ -208,7 +187,7 @@ class AppleTvRemoteTouchService { final now = _now(); final lastSwipeAt = _lastSwipeAt; - if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) { + if (lastSwipeAt != null && now.difference(lastSwipeAt) < _swipeRepeatInterval) { // Travel during the repeat cooldown never counts toward the next step: // re-anchor on every frame so a fast flick's deceleration tail is // discarded instead of banked. Without this, the first post-cooldown @@ -272,8 +251,8 @@ class AppleTvRemoteTouchService { final lastAxisTotal = _axisValue(lastAxis, totalProgressX, totalProgressY); final candidateSegment = _axisValue(candidate, progressX, progressY); final lastAxisSegment = _axisValue(lastAxis, progressX, progressY); - if (candidateTotal >= lastAxisTotal * axisSwitchDominanceRatio && - candidateSegment >= lastAxisSegment * axisSwitchDominanceRatio) { + if (candidateTotal >= lastAxisTotal * _axisSwitchDominanceRatio && + candidateSegment >= lastAxisSegment * _axisSwitchDominanceRatio) { return candidate; } @@ -292,7 +271,7 @@ class AppleTvRemoteTouchService { double _thresholdForExtent(double extent) { if (!extent.isFinite || extent <= 0) return swipeThreshold; - return (extent * swipeExtentGain).clamp(minSwipeThreshold, maxSwipeThreshold).toDouble(); + return (extent * _swipeExtentGain).clamp(minSwipeThreshold, _maxSwipeThreshold).toDouble(); } /// Reads the primary focus geometry, rejecting nodes whose rect cannot @@ -317,15 +296,13 @@ class AppleTvRemoteTouchService { return false; } - reportNonPointerInput(); + InputModeTracker.reportNonPointerInput(); _scheduleFrame(); _log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}'); _simulateKeyPress(logicalKey); return true; } - Duration get duplicateSuppressionWindow => _duplicateInputGuard.suppressionWindow; - void _resetTouch() { _touchActive = false; _lastSwipeAxis = null; diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart index 3ac0624ae..6c0559e2c 100644 --- a/test/screens/main_screen_layout_test.dart +++ b/test/screens/main_screen_layout_test.dart @@ -69,30 +69,8 @@ void main() { expect(shouldPass(isOverlaySheetOpen: true), isFalse); expect(shouldPass(isRouteCurrent: false), isFalse); 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]); + expect(shouldPass(isShowingProfileSelection: true), isFalse); + expect(shouldPass(hasVisibleTabs: false), isFalse); }); test('desktop physical Escape is reserved for window fullscreen only at root Home', () {