From 0dd7d7c52283c7ee347de559dd3f1533206387ec Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 21 May 2026 21:01:14 +0200 Subject: [PATCH] feat(tv): push content beside sidebar --- lib/screens/discover_screen.dart | 8 +- .../tabs/library_recommended_tab.dart | 8 +- lib/screens/main_screen.dart | 137 +++++++++++------- lib/widgets/side_navigation_rail.dart | 48 +++++- test/screens/discover_screen_test.dart | 26 +++- test/widgets/side_navigation_rail_test.dart | 117 +++++++++++++++ 6 files changed, 273 insertions(+), 71 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 7f2fa873..8024be45 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -41,7 +41,6 @@ import '../providers/user_profile_provider.dart'; import '../services/storage_service.dart'; import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; -import '../widgets/side_navigation_rail.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_background.dart'; import '../mixins/refreshable.dart'; @@ -1535,9 +1534,10 @@ class _DiscoverScreenState extends State final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble(); final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); - final sidebarBleed = svc.read(SettingsService.alwaysKeepSidebarOpen) - ? 0.0 - : SideNavigationRailState.collapsedWidthForContext(context); + final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf( + context, + alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen), + ); return Material( color: theme.scaffoldBackgroundColor, diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index ee3c691b..98d95242 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -16,7 +16,6 @@ import '../../../utils/provider_extensions.dart'; import '../../../utils/watch_state_notifier.dart'; import '../../../widgets/hub_section.dart'; import '../../../widgets/settings_builder.dart'; -import '../../../widgets/side_navigation_rail.dart'; import '../../../widgets/tv_browse_rail.dart'; import '../../../widgets/tv_spotlight_background.dart'; import '../../main_screen.dart'; @@ -321,9 +320,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); - final sidebarBleed = svc.read(SettingsService.alwaysKeepSidebarOpen) - ? 0.0 - : SideNavigationRailState.collapsedWidthForContext(context); + final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf( + context, + alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen), + ); return Material( color: theme.scaffoldBackgroundColor, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index b2ca351a..e275aa8d 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -60,6 +60,7 @@ class MainScreenFocusScope extends InheritedWidget { final VoidCallback focusSidebar; final VoidCallback focusContent; final bool isSidebarFocused; + final double sideNavigationWidth; final void Function(String libraryGlobalKey)? selectLibrary; const MainScreenFocusScope({ @@ -67,6 +68,7 @@ class MainScreenFocusScope extends InheritedWidget { required this.focusSidebar, required this.focusContent, required this.isSidebarFocused, + required this.sideNavigationWidth, this.selectLibrary, required super.child, }); @@ -75,9 +77,15 @@ class MainScreenFocusScope extends InheritedWidget { return context.dependOnInheritedWidgetOfExactType(); } + static double sideNavigationBleedOf(BuildContext context, {required bool alwaysKeepSidebarOpen}) { + final width = of(context)?.sideNavigationWidth; + if (width != null) return width; + return alwaysKeepSidebarOpen ? 0.0 : SideNavigationRailState.collapsedWidthForContext(context); + } + @override bool updateShouldNotify(MainScreenFocusScope oldWidget) { - return isSidebarFocused != oldWidget.isSidebarFocused; + return isSidebarFocused != oldWidget.isSidebarFocused || sideNavigationWidth != oldWidget.sideNavigationWidth; } } @@ -150,6 +158,7 @@ class _MainScreenState extends State final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); bool _isSidebarFocused = false; + bool _isSidebarInteractionExpanded = 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 @@ -893,6 +902,18 @@ class _MainScreenState extends State }); } + void _handleSidebarInteractionExpandedChanged(bool expanded) { + if (_isSidebarInteractionExpanded == expanded) return; + setState(() => _isSidebarInteractionExpanded = expanded); + } + + double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) { + final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded; + return isExpanded + ? SideNavigationRailState.expandedWidth + : SideNavigationRailState.collapsedWidthForContext(context); + } + /// 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. @@ -1211,9 +1232,7 @@ class _MainScreenState extends State return SettingValueBuilder( pref: SettingsService.alwaysKeepSidebarOpen, builder: (context, alwaysExpanded, _) { - final contentLeftPadding = alwaysExpanded - ? SideNavigationRailState.expandedWidth - : SideNavigationRailState.collapsedWidthForContext(context); + final targetContentLeftPadding = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded); return OverlaySheetHost( child: PopScope( @@ -1228,59 +1247,65 @@ class _MainScreenState extends State if (searchResult == KeyEventResult.handled) return searchResult; return _handleBackKey(event); }, - child: MainScreenFocusScope( - focusSidebar: _focusSidebar, - focusContent: _focusContent, - isSidebarFocused: _isSidebarFocused, - selectLibrary: _selectLibrary, - child: SideNavigationScope( - child: Stack( - children: [ - // Content with animated left padding based on sidebar state - Positioned.fill( - child: AnimatedPadding( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - padding: EdgeInsets.only(left: contentLeftPadding), - child: FocusScope( - node: _contentFocusScope, - // No autofocus - we control focus programmatically to prevent - // autofocus from stealing focus back after setState() rebuilds - child: _buildTickerAwareStack(), - ), - ), - ), - // Sidebar overlays content when expanded (unless always expanded) - Positioned( - top: 0, - bottom: 0, - left: 0, - child: FocusScope( - node: _sidebarFocusScope, - child: SideNavigationRail( - key: _sideNavKey, - selectedTab: _currentTab, - selectedLibraryKey: _selectedLibraryGlobalKey, - isOfflineMode: _isOffline, - isSidebarFocused: _isSidebarFocused, - alwaysExpanded: alwaysExpanded, - isReconnecting: _isReconnecting, - onDestinationSelected: (tab) { - _selectTab(tab); - _focusContent(); - }, - onLibrarySelected: (key) { - _selectLibrary(key); - _focusContent(); - }, - onNavigateToContent: _focusContent, - onReconnect: _triggerReconnect, - ), - ), - ), - ], - ), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + tween: Tween(end: targetContentLeftPadding), + child: FocusScope( + node: _contentFocusScope, + // No autofocus - we control focus programmatically to prevent + // autofocus from stealing focus back after setState() rebuilds + child: _buildTickerAwareStack(), ), + builder: (context, contentLeftPadding, contentChild) { + return MainScreenFocusScope( + focusSidebar: _focusSidebar, + focusContent: _focusContent, + isSidebarFocused: _isSidebarFocused, + sideNavigationWidth: contentLeftPadding, + selectLibrary: _selectLibrary, + child: SideNavigationScope( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.only(left: contentLeftPadding), + child: contentChild!, + ), + ), + Positioned( + top: 0, + bottom: 0, + left: 0, + child: FocusScope( + node: _sidebarFocusScope, + child: SideNavigationRail( + key: _sideNavKey, + selectedTab: _currentTab, + selectedLibraryKey: _selectedLibraryGlobalKey, + isOfflineMode: _isOffline, + isSidebarFocused: _isSidebarFocused, + alwaysExpanded: alwaysExpanded, + isReconnecting: _isReconnecting, + onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, + onDestinationSelected: (tab) { + _selectTab(tab); + _focusContent(); + }, + onLibrarySelected: (key) { + _selectLibrary(key); + _focusContent(); + }, + onNavigateToContent: _focusContent, + onReconnect: _triggerReconnect, + ), + ), + ), + ], + ), + ), + ); + }, ), ), ), diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index f4fd012c..4be3cd0f 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -169,6 +169,9 @@ class SideNavigationRail extends StatefulWidget { /// Called when RIGHT arrow is pressed to navigate to content without selecting. final VoidCallback? onNavigateToContent; + /// Called when hover/touch expansion changes, so the shell can reserve width. + final ValueChanged? onInteractionExpandedChanged; + /// Called when the user taps the reconnect button in offline mode. final VoidCallback? onReconnect; @@ -183,6 +186,7 @@ class SideNavigationRail extends StatefulWidget { required this.onDestinationSelected, required this.onLibrarySelected, this.onNavigateToContent, + this.onInteractionExpandedChanged, this.onReconnect, }); @@ -195,6 +199,7 @@ class SideNavigationRailState extends State with MountedSetS bool _isHovered = false; bool _isTouchExpanded = false; + bool _lastReportedInteractionExpanded = false; Timer? _collapseTimer; static const double collapsedWidth = 80.0; static const double tvCollapsedWidth = 48.0; @@ -241,6 +246,8 @@ class SideNavigationRailState extends State with MountedSetS /// Whether the sidebar should be expanded (always, hover, or focus) bool get _shouldExpand => widget.alwaysExpanded || _isHovered || _isTouchExpanded || widget.isSidebarFocused; + bool get _interactionExpanded => _isHovered || _isTouchExpanded; + /// macOS has the system green button; mobile/TV have no OS fullscreen toggle. bool get _showFullscreenToggle => Platform.isWindows || Platform.isLinux; @@ -268,16 +275,36 @@ class SideNavigationRailState extends State with MountedSetS super.didUpdateWidget(oldWidget); // Auto-collapse after navigation (selection changed) if (oldWidget.selectedTab != widget.selectedTab || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) { + final wasInteractionExpanded = _interactionExpanded; _isTouchExpanded = false; + if (wasInteractionExpanded != _interactionExpanded) { + _scheduleInteractionExpandedNotification(); + } } } + void _scheduleInteractionExpandedNotification() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _notifyInteractionExpandedIfNeeded(); + }); + } + + void _notifyInteractionExpandedIfNeeded() { + final expanded = _interactionExpanded; + if (_lastReportedInteractionExpanded == expanded) return; + _lastReportedInteractionExpanded = expanded; + widget.onInteractionExpandedChanged?.call(expanded); + } + void _onHoverEnter() { _collapseTimer?.cancel(); - _isTouchExpanded = false; // Mouse takes over - if (!_isHovered) { - setState(() => _isHovered = true); - } + if (_isHovered && !_isTouchExpanded) return; + setState(() { + _isTouchExpanded = false; // Mouse takes over + _isHovered = true; + }); + _notifyInteractionExpandedIfNeeded(); } void _onHoverExit() { @@ -285,10 +312,17 @@ class SideNavigationRailState extends State with MountedSetS _collapseTimer = Timer(_collapseDelay, () { if (mounted && _isHovered) { setState(() => _isHovered = false); + _notifyInteractionExpandedIfNeeded(); } }); } + void _expandForTouch() { + if (_isTouchExpanded) return; + setState(() => _isTouchExpanded = true); + _notifyInteractionExpandedIfNeeded(); + } + /// The key of the last focused sidebar item (for pre-capture before focus shifts). String? get lastFocusedKey => _focusTracker.lastFocusedKey; @@ -472,6 +506,7 @@ class SideNavigationRailState extends State with MountedSetS void collapse() { if (_isTouchExpanded) { setState(() => _isTouchExpanded = false); + _notifyInteractionExpandedIfNeeded(); } } @@ -539,7 +574,7 @@ class SideNavigationRailState extends State with MountedSetS final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; - final surfaceOpacity = isCollapsed && PlatformDetector.isTV() ? 0.0 : 1.0; + final surfaceOpacity = PlatformDetector.isTV() ? 0.0 : 1.0; // Listen to fullscreen + groupLibrariesByServer setting so the rail // rebuilds when the user toggles "Group libraries by server" in Appearance. @@ -584,6 +619,7 @@ class SideNavigationRailState extends State with MountedSetS onTapOutside: (_) { if (_isTouchExpanded) { setState(() => _isTouchExpanded = false); + _notifyInteractionExpandedIfNeeded(); } }, child: MouseRegion( @@ -592,7 +628,7 @@ class SideNavigationRailState extends State with MountedSetS onExit: (_) => _onHoverExit(), child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: isCollapsed ? () => setState(() => _isTouchExpanded = true) : null, + onTap: isCollapsed ? _expandForTouch : null, child: AnimatedContainer( duration: t.normal, curve: Curves.easeOutCubic, diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 98fcffec..364999db 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -24,14 +24,18 @@ import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/discover_screen.dart'; +import 'package:plezy/screens/main_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/layout_constants.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/watch_together/watch_together.dart'; +import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; +import 'package:plezy/widgets/tv_spotlight_background.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; @@ -113,7 +117,13 @@ void main() { ], child: MaterialApp( theme: monoTheme(dark: true), - home: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)), + home: MainScreenFocusScope( + focusSidebar: () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: SideNavigationRailState.expandedWidth, + child: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)), + ), ), ), ), @@ -122,6 +132,20 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(TvBrowseRail), findsOneWidget); + final scale = TvLayoutConstants.scaleForSize(const Size(1280, 720)); + final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); + final spotlightBackground = tester.widget(find.byType(TvSpotlightBackground)); + expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + SideNavigationRailState.expandedWidth, 0.001)); + expect( + tester.widget(find.byType(TvBrowseRail)).backgroundBleedLeft, + SideNavigationRailState.expandedWidth, + ); + + final backgroundPosition = tester.widget( + find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first, + ); + expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth); + tester.state(find.byType(FocusableActionBar)).requestFocusOnFirst(); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index e0132aee..709b9226 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show PointerDeviceKind; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -122,6 +124,121 @@ void main() { expect((selectedItemContainer.decoration as BoxDecoration?)?.color, isNull); }); + testWidgets('expanded TV rail keeps its surface transparent', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + await SettingsService.getInstance(); + + final librariesProvider = LibrariesProvider(); + addTearDown(librariesProvider.dispose); + + final hiddenLibrariesProvider = HiddenLibrariesProvider(); + await hiddenLibrariesProvider.ensureInitialized(); + addTearDown(hiddenLibrariesProvider.dispose); + + final manager = MultiServerManager(); + final aggregation = DataAggregationService(manager); + final multiServerProvider = MultiServerProvider(manager, aggregation); + addTearDown(multiServerProvider.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: librariesProvider), + ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ChangeNotifierProvider.value(value: multiServerProvider), + ], + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SideNavigationRail( + selectedTab: NavigationTabId.discover, + isSidebarFocused: true, + alwaysExpanded: false, + onDestinationSelected: (_) {}, + onLibrarySelected: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final rail = find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedContainer)).first; + expect(tester.getSize(rail).width, SideNavigationRailState.expandedWidth); + + final surfaceOpacity = tester + .widgetList( + find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedOpacity)), + ) + .singleWhere((widget) => widget.child is ColoredBox); + expect(surfaceOpacity.opacity, 0.0); + }); + + testWidgets('reports interaction expansion for shell content push', (tester) async { + await SettingsService.getInstance(); + + final librariesProvider = LibrariesProvider(); + addTearDown(librariesProvider.dispose); + + final hiddenLibrariesProvider = HiddenLibrariesProvider(); + await hiddenLibrariesProvider.ensureInitialized(); + addTearDown(hiddenLibrariesProvider.dispose); + + final manager = MultiServerManager(); + final aggregation = DataAggregationService(manager); + final multiServerProvider = MultiServerProvider(manager, aggregation); + addTearDown(multiServerProvider.dispose); + + final reports = []; + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: librariesProvider), + ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ChangeNotifierProvider.value(value: multiServerProvider), + ], + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SideNavigationRail( + selectedTab: NavigationTabId.discover, + isSidebarFocused: false, + alwaysExpanded: false, + onInteractionExpandedChanged: reports.add, + onDestinationSelected: (_) {}, + onLibrarySelected: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final rail = find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedContainer)).first; + expect(tester.getSize(rail).width, SideNavigationRailState.collapsedWidth); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: const Offset(799, 599)); + await tester.pump(); + await gesture.moveTo(tester.getCenter(rail)); + await tester.pumpAndSettle(); + + expect(reports.last, isTrue); + expect(tester.getSize(rail).width, SideNavigationRailState.expandedWidth); + + await gesture.moveTo(tester.getBottomRight(rail) + const Offset(100, -10)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(reports.last, isFalse); + }); + testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async { await SettingsService.getInstance();