From 9d64589c3dfa96f04923d63bf128aef5f9f0ed41 Mon Sep 17 00:00:00 2001 From: dwk001 Date: Sun, 18 Jan 2026 15:01:34 -0500 Subject: [PATCH 1/2] feat: Add natural D-pad navigation for hub sections - Add left navigation from hubs to sidebar when at leftmost item - Add up navigation from first hub to tab bar (Libraries) or hero section (Discover) - Enhance HubSection with onNavigateLeft and onNavigateUp callbacks - Update _handleVerticalNavigation to return false at top boundary for proper UP handling - Implement _navigateToSidebar() in Library Recommended tab and Discover screen - Update all HubSection instances with proper navigation callbacks --- lib/screens/discover_screen.dart | 17 +++++++++++++++++ .../tabs/library_recommended_tab.dart | 18 ++++++++++++++++-- lib/widgets/hub_section.dart | 19 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 5716fb90..a159a246 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -28,6 +28,7 @@ import '../mixins/watch_state_aware.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import 'main_screen.dart'; import '../utils/video_player_navigation.dart'; import '../utils/layout_constants.dart'; import '../utils/platform_detector.dart'; @@ -178,6 +179,12 @@ class _DiscoverScreenState extends State return false; } + /// Navigate to the sidebar (called when user presses left at leftmost item) + void _navigateToSidebar() { + final focusScope = MainScreenFocusScope.of(context); + focusScope?.focusSidebar(); + } + @override void initState() { super.initState(); @@ -1033,6 +1040,11 @@ class _DiscoverScreenState extends State onRemoveFromContinueWatching: _refreshContinueWatching, isInContinueWatching: true, onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp), + onNavigateLeft: _navigateToSidebar, + onNavigateUp: () { + _heroFocusNode.requestFocus(); + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + }, ), ), @@ -1047,6 +1059,11 @@ class _DiscoverScreenState extends State onRefresh: updateItem, // Hub index is i + 1 if continue watching exists, otherwise i onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp), + onNavigateLeft: _navigateToSidebar, + onNavigateUp: (i == 0 && _onDeck.isEmpty) ? () { + _heroFocusNode.requestFocus(); + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + } : null, ), ), diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index fac2fb7d..82b1f857 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -7,6 +7,7 @@ import '../../../mixins/item_updatable.dart'; import '../../../models/plex_hub.dart'; import '../../../models/plex_metadata.dart'; import '../../../widgets/hub_section.dart'; +import '../../main_screen.dart'; import 'base_library_tab.dart'; /// Recommended tab for library screen @@ -115,8 +116,13 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState= _hubKeys.length) { - // At boundary, block navigation + if (targetIndex < 0) { + // At top boundary - return false to allow onNavigateUp to handle it + return false; + } + + if (targetIndex >= _hubKeys.length) { + // At bottom boundary, block navigation return true; } @@ -130,6 +136,12 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _handleVerticalNavigation(index, isUp), onBack: widget.onBack, + onNavigateLeft: _navigateToSidebar, + onNavigateUp: index == 0 ? widget.onBack : null, ); }, ); diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 59b16af9..3903dcfa 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -39,6 +39,14 @@ class HubSection extends StatefulWidget { /// Used to navigate focus back to the tab bar. final VoidCallback? onBack; + /// Called when the user presses LEFT while at the leftmost item. + /// Used to navigate focus to the side navigation rail. + final VoidCallback? onNavigateLeft; + + /// Called when the user presses UP while at the topmost item (first hub). + /// Used to navigate focus to the tab bar. + final VoidCallback? onNavigateUp; + const HubSection({ super.key, required this.hub, @@ -49,6 +57,8 @@ class HubSection extends StatefulWidget { this.showServerName = false, this.onVerticalNavigation, this.onBack, + this.onNavigateLeft, + this.onNavigateUp, }); @override @@ -172,6 +182,9 @@ class HubSectionState extends State { HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); _scrollToIndex(_focusedIndex); setState(() {}); + } else if (widget.onNavigateLeft != null) { + // At leftmost item - navigate to sidebar + widget.onNavigateLeft!(); } return KeyEventResult.handled; } @@ -189,7 +202,11 @@ class HubSectionState extends State { // Up/Down: delegate to parent for vertical hub navigation, ALWAYS consume if (key.isUpKey) { - widget.onVerticalNavigation?.call(true); + final handled = widget.onVerticalNavigation?.call(true) ?? false; + // If not handled (at top boundary) and we have onNavigateUp, call it + if (!handled && widget.onNavigateUp != null) { + widget.onNavigateUp!(); + } return KeyEventResult.handled; } if (key.isDownKey) { From 85d07a7f72334fb7db0559a50d9cf05e94110cd1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:05:52 +0100 Subject: [PATCH 2/2] fix: Remove LEFT-to-sidebar from hubs --- lib/screens/discover_screen.dart | 8 -------- lib/screens/libraries/tabs/library_recommended_tab.dart | 8 -------- lib/widgets/hub_section.dart | 9 +-------- 3 files changed, 1 insertion(+), 24 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index a159a246..fb21b4b5 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -179,12 +179,6 @@ class _DiscoverScreenState extends State return false; } - /// Navigate to the sidebar (called when user presses left at leftmost item) - void _navigateToSidebar() { - final focusScope = MainScreenFocusScope.of(context); - focusScope?.focusSidebar(); - } - @override void initState() { super.initState(); @@ -1040,7 +1034,6 @@ class _DiscoverScreenState extends State onRemoveFromContinueWatching: _refreshContinueWatching, isInContinueWatching: true, onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp), - onNavigateLeft: _navigateToSidebar, onNavigateUp: () { _heroFocusNode.requestFocus(); _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); @@ -1059,7 +1052,6 @@ class _DiscoverScreenState extends State onRefresh: updateItem, // Hub index is i + 1 if continue watching exists, otherwise i onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp), - onNavigateLeft: _navigateToSidebar, onNavigateUp: (i == 0 && _onDeck.isEmpty) ? () { _heroFocusNode.requestFocus(); _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 82b1f857..58e981fe 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -7,7 +7,6 @@ import '../../../mixins/item_updatable.dart'; import '../../../models/plex_hub.dart'; import '../../../models/plex_metadata.dart'; import '../../../widgets/hub_section.dart'; -import '../../main_screen.dart'; import 'base_library_tab.dart'; /// Recommended tab for library screen @@ -136,12 +135,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _handleVerticalNavigation(index, isUp), onBack: widget.onBack, - onNavigateLeft: _navigateToSidebar, onNavigateUp: index == 0 ? widget.onBack : null, ); }, diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 3903dcfa..e53cd2c8 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -39,10 +39,6 @@ class HubSection extends StatefulWidget { /// Used to navigate focus back to the tab bar. final VoidCallback? onBack; - /// Called when the user presses LEFT while at the leftmost item. - /// Used to navigate focus to the side navigation rail. - final VoidCallback? onNavigateLeft; - /// Called when the user presses UP while at the topmost item (first hub). /// Used to navigate focus to the tab bar. final VoidCallback? onNavigateUp; @@ -57,7 +53,6 @@ class HubSection extends StatefulWidget { this.showServerName = false, this.onVerticalNavigation, this.onBack, - this.onNavigateLeft, this.onNavigateUp, }); @@ -182,10 +177,8 @@ class HubSectionState extends State { HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); _scrollToIndex(_focusedIndex); setState(() {}); - } else if (widget.onNavigateLeft != null) { - // At leftmost item - navigate to sidebar - widget.onNavigateLeft!(); } + // At leftmost item: do nothing, but consume event to prevent focus escape return KeyEventResult.handled; }