fix(tv): pass root menu to system

This commit is contained in:
edde746
2026-05-26 18:27:26 +02:00
parent be86a33057
commit 4eadec5b17
5 changed files with 131 additions and 6 deletions
+76 -4
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/app_exit_service.dart'; import '../services/app_exit_service.dart';
import '../services/tvos_system_navigation_service.dart';
import '../services/update_service.dart'; import '../services/update_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../widgets/auth_error_banner.dart'; import '../widgets/auth_error_banner.dart';
@@ -176,6 +177,25 @@ bool shouldRenderMainScreenOffline({
return providerOffline || (startupOfflineUntilConnected && !hasVisibleConnectedServers); 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 { class MainScreen extends StatefulWidget {
final bool isOfflineMode; final bool isOfflineMode;
@@ -241,6 +261,7 @@ class _MainScreenState extends State<MainScreen>
final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content');
bool _isSidebarFocused = false; bool _isSidebarFocused = false;
bool _isSidebarInteractionExpanded = false; bool _isSidebarInteractionExpanded = false;
bool _isOverlaySheetOpen = false;
/// The binder is now owned by a top-level [Provider] (see main.dart) so /// 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 /// the splash can await its first settle before navigating here. We just
@@ -352,6 +373,8 @@ class _MainScreenState extends State<MainScreen>
_contentFocusScope.requestFocus(); _contentFocusScope.requestFocus();
} }
_updateTvosMenuPassthrough();
// Check for updates on startup // Check for updates on startup
unawaited(_checkForUpdatesOnStartup()); unawaited(_checkForUpdatesOnStartup());
}); });
@@ -549,10 +572,12 @@ class _MainScreenState extends State<MainScreen>
if (!hasNoActive && !requireOnOpen) return; if (!hasNoActive && !requireOnOpen) return;
_isShowingProfileSelection = true; _isShowingProfileSelection = true;
_setTvosMenuPassthrough(false);
await Navigator.of( await Navigator.of(
context, context,
).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true)));
_isShowingProfileSelection = false; _isShowingProfileSelection = false;
_updateTvosMenuPassthrough();
} }
Future<void> _checkForUpdatesOnStartup() async { Future<void> _checkForUpdatesOnStartup() async {
@@ -785,6 +810,7 @@ class _MainScreenState extends State<MainScreen>
_startupSettleTimeout = null; _startupSettleTimeout = null;
_sidebarFocusScope.dispose(); _sidebarFocusScope.dispose();
_contentFocusScope.dispose(); _contentFocusScope.dispose();
_setTvosMenuPassthrough(false);
// Clean up companion remote callbacks // Clean up companion remote callbacks
if (_companionRemoteSetup) { if (_companionRemoteSetup) {
@@ -829,10 +855,12 @@ class _MainScreenState extends State<MainScreen>
if (!activeProfile.hasMultipleProfiles) return; if (!activeProfile.hasMultipleProfiles) return;
_isShowingProfileSelection = true; _isShowingProfileSelection = true;
_setTvosMenuPassthrough(false);
await Navigator.of( await Navigator.of(
context, context,
).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true)));
_isShowingProfileSelection = false; _isShowingProfileSelection = false;
_updateTvosMenuPassthrough();
} }
/// IndexedStack that disables tickers for offscreen children to prevent /// IndexedStack that disables tickers for offscreen children to prevent
@@ -932,6 +960,7 @@ class _MainScreenState extends State<MainScreen>
_screens = _buildScreens(_isOffline); _screens = _buildScreens(_isOffline);
_currentTab = _normalizeTabForMode(_currentTab, _isOffline); _currentTab = _normalizeTabForMode(_currentTab, _isOffline);
}); });
_updateTvosMenuPassthrough();
} }
void _handleOfflineStatusChanged() { void _handleOfflineStatusChanged() {
@@ -977,6 +1006,7 @@ class _MainScreenState extends State<MainScreen>
_autoSwitchedToDownloads = false; _autoSwitchedToDownloads = false;
} }
}); });
_updateTvosMenuPassthrough();
// Refresh sidebar focus after rebuilding navigation // Refresh sidebar focus after rebuilding navigation
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -1005,6 +1035,7 @@ class _MainScreenState extends State<MainScreen>
// and overwrites lastFocusedKey (e.g. to the Libraries toggle button). // and overwrites lastFocusedKey (e.g. to the Libraries toggle button).
final targetKey = _sideNavKey.currentState?.lastFocusedKey; final targetKey = _sideNavKey.currentState?.lastFocusedKey;
setState(() => _isSidebarFocused = true); setState(() => _isSidebarFocused = true);
_updateTvosMenuPassthrough();
_sidebarFocusScope.requestFocus(); _sidebarFocusScope.requestFocus();
// Focus the active item after the focus scope has focus // Focus the active item after the focus scope has focus
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -1014,6 +1045,7 @@ class _MainScreenState extends State<MainScreen>
void _focusContent({bool restorePreviousFocus = true}) { void _focusContent({bool restorePreviousFocus = true}) {
setState(() => _isSidebarFocused = false); setState(() => _isSidebarFocused = false);
_updateTvosMenuPassthrough();
if (restorePreviousFocus) { if (restorePreviousFocus) {
_contentFocusScope.requestFocus(); _contentFocusScope.requestFocus();
} }
@@ -1040,6 +1072,12 @@ class _MainScreenState extends State<MainScreen>
setState(() => _isSidebarInteractionExpanded = expanded); setState(() => _isSidebarInteractionExpanded = expanded);
} }
void _handleOverlaySheetOpenChanged(bool open) {
if (_isOverlaySheetOpen == open) return;
_isOverlaySheetOpen = open;
_updateTvosMenuPassthrough();
}
double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) { double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) {
final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded; final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded;
return isExpanded return isExpanded
@@ -1047,12 +1085,34 @@ class _MainScreenState extends State<MainScreen>
: SideNavigationRailState.collapsedWidthForContext(context); : 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. /// Suppress stray back events after a child route pops.
/// On Android TV the platform popRoute can arrive before the key events, /// On Android TV the platform popRoute can arrive before the key events,
/// so BackKeySuppressorObserver misses them and they leak into _handleBackKey. /// so BackKeySuppressorObserver misses them and they leak into _handleBackKey.
bool _suppressBackAfterPop = false; bool _suppressBackAfterPop = false;
KeyEventResult _handleMainBack({bool allowTvSystemExit = false}) { KeyEventResult _handleMainBack() {
final tabs = _getVisibleTabs(_isOffline); final tabs = _getVisibleTabs(_isOffline);
if (tabs.isEmpty) return KeyEventResult.handled; if (tabs.isEmpty) return KeyEventResult.handled;
@@ -1063,11 +1123,18 @@ class _MainScreenState extends State<MainScreen>
return KeyEventResult.handled; 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 now = DateTime.now();
final lastBackPressAt = _lastBackPressAt; final lastBackPressAt = _lastBackPressAt;
if (lastBackPressAt != null && now.difference(lastBackPressAt) < _backExitWindow) { if (lastBackPressAt != null && now.difference(lastBackPressAt) < _backExitWindow) {
_lastBackPressAt = null; _lastBackPressAt = null;
if (allowTvSystemExit && PlatformDetector.isAppleTV()) return KeyEventResult.skipRemainingHandlers;
unawaited(AppExitService.requestExit()); unawaited(AppExitService.requestExit());
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1088,7 +1155,7 @@ class _MainScreenState extends State<MainScreen>
// matching comment in handleBackKeyAction for why the suppressor pattern // matching comment in handleBackKeyAction for why the suppressor pattern
// doesn't fit here. // doesn't fit here.
if (PlatformDetector.isAppleTV() && event is KeyDownEvent) { if (PlatformDetector.isAppleTV() && event is KeyDownEvent) {
final result = _handleMainBack(allowTvSystemExit: true); final result = _handleMainBack();
if (result == KeyEventResult.handled) { if (result == KeyEventResult.handled) {
BackKeyCoordinator.markHandled(); BackKeyCoordinator.markHandled();
} }
@@ -1099,7 +1166,7 @@ class _MainScreenState extends State<MainScreen>
} }
if (event is KeyUpEvent) { if (event is KeyUpEvent) {
final result = _handleMainBack(allowTvSystemExit: PlatformDetector.isAppleTV()); final result = _handleMainBack();
if (result == KeyEventResult.handled) { if (result == KeyEventResult.handled) {
BackKeyCoordinator.markHandled(); BackKeyCoordinator.markHandled();
} }
@@ -1176,6 +1243,7 @@ class _MainScreenState extends State<MainScreen>
@override @override
void didPushNext() { void didPushNext() {
_setTvosMenuPassthrough(false);
// Called when a child route is pushed on top (e.g., video player) // Called when a child route is pushed on top (e.g., video player)
if (_currentTab == NavigationTabId.discover) { if (_currentTab == NavigationTabId.discover) {
if (_discoverKey.currentState case final TabVisibilityAware aware) { if (_discoverKey.currentState case final TabVisibilityAware aware) {
@@ -1196,6 +1264,7 @@ class _MainScreenState extends State<MainScreen>
}); });
// Called when returning to this route from a child route (e.g., from video player) // Called when returning to this route from a child route (e.g., from video player)
_updateTvosMenuPassthrough();
if (_currentTab == NavigationTabId.discover) { if (_currentTab == NavigationTabId.discover) {
if (_discoverKey.currentState case final TabVisibilityAware aware) { if (_discoverKey.currentState case final TabVisibilityAware aware) {
aware.onTabShown(); aware.onTabShown();
@@ -1282,6 +1351,7 @@ class _MainScreenState extends State<MainScreen>
_autoSwitchedToDownloads = false; _autoSwitchedToDownloads = false;
} }
}); });
_updateTvosMenuPassthrough();
if (previousTab != tab) { if (previousTab != tab) {
// Notify previous screen it's being hidden // Notify previous screen it's being hidden
@@ -1371,6 +1441,7 @@ class _MainScreenState extends State<MainScreen>
: SideNavigationRailState.collapsedWidthForContext(context); : SideNavigationRailState.collapsedWidthForContext(context);
return OverlaySheetHost( return OverlaySheetHost(
onOpenChanged: _handleOverlaySheetOpenChanged,
child: PopScope( child: PopScope(
canPop: false, // Prevent system back from popping on Android TV canPop: false, // Prevent system back from popping on Android TV
// ignore: no-empty-block - required callback, back navigation handled by _handleBackKey // ignore: no-empty-block - required callback, back navigation handled by _handleBackKey
@@ -1474,6 +1545,7 @@ class _MainScreenState extends State<MainScreen>
_handleMainBack(); _handleMainBack();
}, },
child: OverlaySheetHost( child: OverlaySheetHost(
onOpenChanged: _handleOverlaySheetOpenChanged,
child: ScaffoldMessenger( child: ScaffoldMessenger(
key: mainScaffoldMessengerKey, key: mainScaffoldMessengerKey,
child: Scaffold( child: Scaffold(
@@ -0,0 +1,20 @@
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
class TvosSystemNavigationService {
static const BasicMessageChannel<Object?> _channel = BasicMessageChannel<Object?>(
'flutter/tvos_system_navigation',
JSONMessageCodec(),
);
static bool? _menuPassthroughEnabled;
static Future<void> setMenuPassthroughEnabled(bool enabled) async {
if (!PlatformDetector.isAppleTV()) return;
if (_menuPassthroughEnabled == enabled) return;
_menuPassthroughEnabled = enabled;
await _channel.send({'menuPassthroughEnabled': enabled});
}
}
+5 -1
View File
@@ -191,8 +191,9 @@ class OverlaySheetController {
/// and skip their own back handling when a sheet is open. /// and skip their own back handling when a sheet is open.
class OverlaySheetHost extends StatefulWidget { class OverlaySheetHost extends StatefulWidget {
final Widget child; final Widget child;
final ValueChanged<bool>? onOpenChanged;
const OverlaySheetHost({super.key, required this.child}); const OverlaySheetHost({super.key, required this.child, this.onOpenChanged});
@override @override
State<OverlaySheetHost> createState() => _OverlaySheetHostState(); State<OverlaySheetHost> createState() => _OverlaySheetHostState();
@@ -264,6 +265,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
bool showDragHandle = false, bool showDragHandle = false,
}) { }) {
// If already open, close first (instant) // If already open, close first (instant)
final wasOpen = _isOpen;
if (_isOpen) { if (_isOpen) {
for (final entry in _pageStack) { for (final entry in _pageStack) {
if (!entry.completer.isCompleted) { if (!entry.completer.isCompleted) {
@@ -291,6 +293,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
_dragOffset = 0; _dragOffset = 0;
_isDragging = false; _isDragging = false;
}); });
if (!wasOpen) widget.onOpenChanged?.call(true);
BackKeyUpSuppressor.clearSuppression(); BackKeyUpSuppressor.clearSuppression();
_animationController.forward(from: 0); _animationController.forward(from: 0);
@@ -351,6 +354,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
_isDragging = false; _isDragging = false;
_sheetHorizontalAnchor = null; _sheetHorizontalAnchor = null;
}); });
widget.onOpenChanged?.call(false);
// Clear stale back-key flags. handleBackKeyAction sets // Clear stale back-key flags. handleBackKeyAction sets
// markClosedViaBackKey() expecting a route pop, but the overlay // markClosedViaBackKey() expecting a route pop, but the overlay
// doesn't pop a route. Without clearing, the flag leaks into the // doesn't pop a route. Without clearing, the flag leaks into the
+29
View File
@@ -41,6 +41,35 @@ void main() {
expect(expanded.left + expanded.width, viewportWidth); 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 { testWidgets('side navigation bleed animates from the previous value', (tester) async {
Widget build(double targetBleed) { Widget build(double targetBleed) {
return Directionality( return Directionality(
+1 -1
View File
@@ -1 +1 @@
3.44.0 3.44.0+1