diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index ee52493f..9a0e2c76 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import '../i18n/strings.g.dart'; import '../services/app_exit_service.dart'; +import '../services/tvos_system_navigation_service.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; import '../widgets/auth_error_banner.dart'; @@ -176,6 +177,25 @@ bool shouldRenderMainScreenOffline({ return providerOffline || (startupOfflineUntilConnected && !hasVisibleConnectedServers); } +@visibleForTesting +bool shouldPassTvosMenuToSystem({ + required bool isAppleTV, + required bool isShowingProfileSelection, + required bool isOverlaySheetOpen, + required bool isRouteCurrent, + required bool isSidebarFocused, + required bool hasVisibleTabs, + required bool isCurrentTabRoot, +}) { + return isAppleTV && + isSidebarFocused && + !isShowingProfileSelection && + !isOverlaySheetOpen && + isRouteCurrent && + hasVisibleTabs && + isCurrentTabRoot; +} + class MainScreen extends StatefulWidget { final bool isOfflineMode; @@ -241,6 +261,7 @@ class _MainScreenState extends State final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); bool _isSidebarFocused = false; bool _isSidebarInteractionExpanded = false; + bool _isOverlaySheetOpen = false; /// 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 @@ -352,6 +373,8 @@ class _MainScreenState extends State _contentFocusScope.requestFocus(); } + _updateTvosMenuPassthrough(); + // Check for updates on startup unawaited(_checkForUpdatesOnStartup()); }); @@ -549,10 +572,12 @@ class _MainScreenState extends State if (!hasNoActive && !requireOnOpen) return; _isShowingProfileSelection = true; + _setTvosMenuPassthrough(false); await Navigator.of( context, ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); _isShowingProfileSelection = false; + _updateTvosMenuPassthrough(); } Future _checkForUpdatesOnStartup() async { @@ -785,6 +810,7 @@ class _MainScreenState extends State _startupSettleTimeout = null; _sidebarFocusScope.dispose(); _contentFocusScope.dispose(); + _setTvosMenuPassthrough(false); // Clean up companion remote callbacks if (_companionRemoteSetup) { @@ -829,10 +855,12 @@ class _MainScreenState extends State if (!activeProfile.hasMultipleProfiles) return; _isShowingProfileSelection = true; + _setTvosMenuPassthrough(false); await Navigator.of( context, ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); _isShowingProfileSelection = false; + _updateTvosMenuPassthrough(); } /// IndexedStack that disables tickers for offscreen children to prevent @@ -932,6 +960,7 @@ class _MainScreenState extends State _screens = _buildScreens(_isOffline); _currentTab = _normalizeTabForMode(_currentTab, _isOffline); }); + _updateTvosMenuPassthrough(); } void _handleOfflineStatusChanged() { @@ -977,6 +1006,7 @@ class _MainScreenState extends State _autoSwitchedToDownloads = false; } }); + _updateTvosMenuPassthrough(); // Refresh sidebar focus after rebuilding navigation WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1005,6 +1035,7 @@ class _MainScreenState extends State // and overwrites lastFocusedKey (e.g. to the Libraries toggle button). final targetKey = _sideNavKey.currentState?.lastFocusedKey; setState(() => _isSidebarFocused = true); + _updateTvosMenuPassthrough(); _sidebarFocusScope.requestFocus(); // Focus the active item after the focus scope has focus WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1014,6 +1045,7 @@ class _MainScreenState extends State void _focusContent({bool restorePreviousFocus = true}) { setState(() => _isSidebarFocused = false); + _updateTvosMenuPassthrough(); if (restorePreviousFocus) { _contentFocusScope.requestFocus(); } @@ -1040,6 +1072,12 @@ class _MainScreenState extends State setState(() => _isSidebarInteractionExpanded = expanded); } + void _handleOverlaySheetOpenChanged(bool open) { + if (_isOverlaySheetOpen == open) return; + _isOverlaySheetOpen = open; + _updateTvosMenuPassthrough(); + } + double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) { final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded; return isExpanded @@ -1047,12 +1085,34 @@ class _MainScreenState extends State : SideNavigationRailState.collapsedWidthForContext(context); } + bool get _shouldPassTvosMenuToSystem { + final tabs = _getVisibleTabs(_isOffline); + return shouldPassTvosMenuToSystem( + isAppleTV: PlatformDetector.isAppleTV(), + isShowingProfileSelection: _isShowingProfileSelection, + isOverlaySheetOpen: _isOverlaySheetOpen, + isRouteCurrent: ModalRoute.of(context)?.isCurrent == true, + isSidebarFocused: _isSidebarFocused, + hasVisibleTabs: tabs.isNotEmpty, + isCurrentTabRoot: tabs.isNotEmpty && _currentTab == tabs.first.id, + ); + } + + void _setTvosMenuPassthrough(bool enabled) { + if (!PlatformDetector.isAppleTV()) return; + unawaited(TvosSystemNavigationService.setMenuPassthroughEnabled(enabled)); + } + + void _updateTvosMenuPassthrough() { + _setTvosMenuPassthrough(_shouldPassTvosMenuToSystem); + } + /// Suppress stray back events after a child route pops. /// On Android TV the platform popRoute can arrive before the key events, /// so BackKeySuppressorObserver misses them and they leak into _handleBackKey. bool _suppressBackAfterPop = false; - KeyEventResult _handleMainBack({bool allowTvSystemExit = false}) { + KeyEventResult _handleMainBack() { final tabs = _getVisibleTabs(_isOffline); if (tabs.isEmpty) return KeyEventResult.handled; @@ -1063,11 +1123,18 @@ class _MainScreenState extends State return KeyEventResult.handled; } + // The tvOS engine normally passes root Menu presses through to UIKit. If a + // stale event still reaches Flutter, avoid showing an exit prompt that + // cannot be honored app-side. + if (PlatformDetector.isAppleTV()) { + _lastBackPressAt = null; + return KeyEventResult.handled; + } + final now = DateTime.now(); final lastBackPressAt = _lastBackPressAt; if (lastBackPressAt != null && now.difference(lastBackPressAt) < _backExitWindow) { _lastBackPressAt = null; - if (allowTvSystemExit && PlatformDetector.isAppleTV()) return KeyEventResult.skipRemainingHandlers; unawaited(AppExitService.requestExit()); return KeyEventResult.handled; } @@ -1088,7 +1155,7 @@ class _MainScreenState extends State // matching comment in handleBackKeyAction for why the suppressor pattern // doesn't fit here. if (PlatformDetector.isAppleTV() && event is KeyDownEvent) { - final result = _handleMainBack(allowTvSystemExit: true); + final result = _handleMainBack(); if (result == KeyEventResult.handled) { BackKeyCoordinator.markHandled(); } @@ -1099,7 +1166,7 @@ class _MainScreenState extends State } if (event is KeyUpEvent) { - final result = _handleMainBack(allowTvSystemExit: PlatformDetector.isAppleTV()); + final result = _handleMainBack(); if (result == KeyEventResult.handled) { BackKeyCoordinator.markHandled(); } @@ -1176,6 +1243,7 @@ class _MainScreenState extends State @override void didPushNext() { + _setTvosMenuPassthrough(false); // Called when a child route is pushed on top (e.g., video player) if (_currentTab == NavigationTabId.discover) { if (_discoverKey.currentState case final TabVisibilityAware aware) { @@ -1196,6 +1264,7 @@ class _MainScreenState extends State }); // Called when returning to this route from a child route (e.g., from video player) + _updateTvosMenuPassthrough(); if (_currentTab == NavigationTabId.discover) { if (_discoverKey.currentState case final TabVisibilityAware aware) { aware.onTabShown(); @@ -1282,6 +1351,7 @@ class _MainScreenState extends State _autoSwitchedToDownloads = false; } }); + _updateTvosMenuPassthrough(); if (previousTab != tab) { // Notify previous screen it's being hidden @@ -1371,6 +1441,7 @@ class _MainScreenState extends State : SideNavigationRailState.collapsedWidthForContext(context); return OverlaySheetHost( + onOpenChanged: _handleOverlaySheetOpenChanged, child: PopScope( canPop: false, // Prevent system back from popping on Android TV // ignore: no-empty-block - required callback, back navigation handled by _handleBackKey @@ -1474,6 +1545,7 @@ class _MainScreenState extends State _handleMainBack(); }, child: OverlaySheetHost( + onOpenChanged: _handleOverlaySheetOpenChanged, child: ScaffoldMessenger( key: mainScaffoldMessengerKey, child: Scaffold( diff --git a/lib/services/tvos_system_navigation_service.dart b/lib/services/tvos_system_navigation_service.dart new file mode 100644 index 00000000..43d78ff9 --- /dev/null +++ b/lib/services/tvos_system_navigation_service.dart @@ -0,0 +1,20 @@ +import 'package:flutter/services.dart'; + +import '../utils/platform_detector.dart'; + +class TvosSystemNavigationService { + static const BasicMessageChannel _channel = BasicMessageChannel( + 'flutter/tvos_system_navigation', + JSONMessageCodec(), + ); + + static bool? _menuPassthroughEnabled; + + static Future setMenuPassthroughEnabled(bool enabled) async { + if (!PlatformDetector.isAppleTV()) return; + if (_menuPassthroughEnabled == enabled) return; + + _menuPassthroughEnabled = enabled; + await _channel.send({'menuPassthroughEnabled': enabled}); + } +} diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 9292bbba..164dce5d 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -191,8 +191,9 @@ class OverlaySheetController { /// and skip their own back handling when a sheet is open. class OverlaySheetHost extends StatefulWidget { final Widget child; + final ValueChanged? onOpenChanged; - const OverlaySheetHost({super.key, required this.child}); + const OverlaySheetHost({super.key, required this.child, this.onOpenChanged}); @override State createState() => _OverlaySheetHostState(); @@ -264,6 +265,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr bool showDragHandle = false, }) { // If already open, close first (instant) + final wasOpen = _isOpen; if (_isOpen) { for (final entry in _pageStack) { if (!entry.completer.isCompleted) { @@ -291,6 +293,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr _dragOffset = 0; _isDragging = false; }); + if (!wasOpen) widget.onOpenChanged?.call(true); BackKeyUpSuppressor.clearSuppression(); _animationController.forward(from: 0); @@ -351,6 +354,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr _isDragging = false; _sheetHorizontalAnchor = null; }); + widget.onOpenChanged?.call(false); // Clear stale back-key flags. handleBackKeyAction sets // markClosedViaBackKey() expecting a route pop, but the overlay // doesn't pop a route. Without clearing, the flag leaks into the diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart index 662f6616..9241f229 100644 --- a/test/screens/main_screen_layout_test.dart +++ b/test/screens/main_screen_layout_test.dart @@ -41,6 +41,35 @@ void main() { expect(expanded.left + expanded.width, viewportWidth); }); + test('tvOS Menu pass-through only enables at root with sidebar focus', () { + bool shouldPass({ + bool isAppleTV = true, + bool isShowingProfileSelection = false, + bool isOverlaySheetOpen = false, + bool isRouteCurrent = true, + bool isSidebarFocused = true, + bool hasVisibleTabs = true, + bool isCurrentTabRoot = true, + }) { + return shouldPassTvosMenuToSystem( + isAppleTV: isAppleTV, + isShowingProfileSelection: isShowingProfileSelection, + isOverlaySheetOpen: isOverlaySheetOpen, + isRouteCurrent: isRouteCurrent, + isSidebarFocused: isSidebarFocused, + hasVisibleTabs: hasVisibleTabs, + isCurrentTabRoot: isCurrentTabRoot, + ); + } + + expect(shouldPass(), isTrue); + expect(shouldPass(isSidebarFocused: false), isFalse); + expect(shouldPass(isCurrentTabRoot: false), isFalse); + expect(shouldPass(isOverlaySheetOpen: true), isFalse); + expect(shouldPass(isRouteCurrent: false), isFalse); + expect(shouldPass(isAppleTV: false), isFalse); + }); + testWidgets('side navigation bleed animates from the previous value', (tester) async { Widget build(double targetBleed) { return Directionality( diff --git a/tvos/engine.version b/tvos/engine.version index faf0dcbb..e33b7896 100644 --- a/tvos/engine.version +++ b/tvos/engine.version @@ -1 +1 @@ -3.44.0 +3.44.0+1