From 9d37443e4ea5ab30bb84bd78a8607af93774d3ca Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 25 Jan 2026 01:43:10 +0100 Subject: [PATCH] feat: open sidenav with left --- lib/focus/focusable_chip_mixin.dart | 17 +- lib/focus/focusable_wrapper.dart | 11 ++ lib/screens/discover_screen.dart | 22 ++- lib/screens/downloads/downloads_screen.dart | 48 +++-- .../libraries/adaptive_media_grid.dart | 96 ++++++++-- lib/screens/libraries/libraries_screen.dart | 2 +- .../libraries/tabs/library_browse_tab.dart | 25 ++- .../tabs/library_collections_tab.dart | 5 +- .../tabs/library_grid_tab_state.dart | 7 +- .../libraries/tabs/library_playlists_tab.dart | 4 +- .../tabs/library_recommended_tab.dart | 7 + lib/screens/main_screen.dart | 1 + lib/screens/search_screen.dart | 178 +++++++++++------- lib/screens/settings/settings_screen.dart | 63 ++++--- lib/utils/grid_size_calculator.dart | 18 +- lib/widgets/focusable_media_card.dart | 6 + lib/widgets/hub_section.dart | 12 +- lib/widgets/side_navigation_rail.dart | 20 ++ 18 files changed, 397 insertions(+), 145 deletions(-) diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index ab68b3c4..0cbbff85 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -122,15 +122,22 @@ mixin FocusableChipStateMixin on State { return KeyEventResult.handled; } - // LEFT arrow - always consume to prevent default focus traversal + // LEFT arrow - call callback if provided, otherwise propagate to parent if (key.isLeftKey) { - callbacks.onNavigateLeft?.call(); - return KeyEventResult.handled; + if (callbacks.onNavigateLeft != null) { + callbacks.onNavigateLeft!(); + return KeyEventResult.handled; + } + // No callback - let parent handle (e.g., to focus sidebar) + return KeyEventResult.ignored; } - // RIGHT arrow - always consume to prevent default focus traversal + // RIGHT arrow - call callback if provided, otherwise consume to prevent escape if (key.isRightKey) { - callbacks.onNavigateRight?.call(); + if (callbacks.onNavigateRight != null) { + callbacks.onNavigateRight!(); + } + // Always consume RIGHT to prevent focus escape return KeyEventResult.handled; } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index ade56dbb..773a8a8c 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -33,6 +33,9 @@ class FocusableWrapper extends StatefulWidget { /// Called when the user presses UP and there's no focusable item above. final VoidCallback? onNavigateUp; + /// Called when the user presses LEFT and there's no focusable item to the left. + final VoidCallback? onNavigateLeft; + /// Called when the user presses BACK. final VoidCallback? onBack; @@ -88,6 +91,7 @@ class FocusableWrapper extends StatefulWidget { this.onLongPress, this.onFocusChange, this.onNavigateUp, + this.onNavigateLeft, this.onBack, this.autofocus = false, this.focusNode, @@ -366,6 +370,13 @@ class _FocusableWrapperState extends State with SingleTickerPr return KeyEventResult.handled; } + // LEFT arrow - if callback provided, navigate left (caller is responsible + // for only providing this callback when the item is at the left edge) + if (key == LogicalKeyboardKey.arrowLeft && widget.onNavigateLeft != null) { + widget.onNavigateLeft!(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 864346f5..e6eb3179 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -34,6 +34,7 @@ import '../utils/platform_detector.dart'; import '../theme/mono_tokens.dart'; import 'auth_screen.dart'; import 'libraries/state_messages.dart'; +import 'main_screen.dart'; import '../watch_together/watch_together.dart'; class DiscoverScreen extends StatefulWidget { @@ -184,6 +185,11 @@ class _DiscoverScreenState extends State return false; } + /// Navigate focus to the sidebar + void _navigateToSidebar() { + MainScreenFocusScope.of(context)?.focusSidebar(); + } + @override void initState() { super.initState(); @@ -247,10 +253,12 @@ class _DiscoverScreenState extends State return KeyEventResult.handled; } - // LEFT: Navigate hero carousel to previous + // LEFT: Navigate hero carousel to previous, or focus sidebar at index 0 if (key.isLeftKey) { if (_currentHeroIndex > 0) { _heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut); + } else { + _navigateToSidebar(); } return KeyEventResult.handled; } @@ -294,8 +302,14 @@ class _DiscoverScreenState extends State return KeyEventResult.handled; } - // LEFT/UP: Block at boundary - if (key.isLeftKey || key.isUpKey) { + // LEFT: Navigate to sidebar + if (key.isLeftKey) { + _navigateToSidebar(); + return KeyEventResult.handled; + } + + // UP: Block at boundary + if (key.isUpKey) { return KeyEventResult.handled; } @@ -1043,6 +1057,7 @@ class _DiscoverScreenState extends State _heroFocusNode.requestFocus(); _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); }, + onNavigateToSidebar: _navigateToSidebar, ), ), @@ -1061,6 +1076,7 @@ class _DiscoverScreenState extends State _heroFocusNode.requestFocus(); _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } : null, + onNavigateToSidebar: _navigateToSidebar, ), ), diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 7c80f6d3..9ac1dd6f 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -6,6 +6,8 @@ import '../../providers/download_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../providers/settings_provider.dart'; import '../../services/gamepad_service.dart'; +import '../../utils/grid_size_calculator.dart'; +import '../../utils/layout_constants.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/focusable_tab_chip.dart'; @@ -156,7 +158,7 @@ class DownloadsScreenState extends State with SingleTickerProvi }); _getTabChipFocusNode(newIndex).requestFocus(); } - : null, + : _onTabBarBack, onNavigateRight: index < tabCount - 1 ? () { final newIndex = index + 1; @@ -292,6 +294,16 @@ class _DownloadsGridContent extends StatelessWidget { const _DownloadsGridContent({required this.type, required this.suppressAutoFocus, this.onBack}); + /// Navigate focus to the sidebar + void _navigateToSidebar(BuildContext context) { + MainScreenFocusScope.of(context)?.focusSidebar(); + } + + /// Calculate column count based on actual available width. + int _calculateColumnCount(double availableWidth, double maxCrossAxisExtent, double crossAxisSpacing) { + return ((availableWidth + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); + } + @override Widget build(BuildContext context) { return Consumer2( @@ -304,16 +316,30 @@ class _DownloadsGridContent extends StatelessWidget { return _buildEmptyState(context); } - return GridView.builder( - padding: const EdgeInsets.symmetric(horizontal: 8), - gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return FocusableMediaCard( - item: item, - onBack: onBack, - isOffline: true, // Downloaded content works without server + const padding = EdgeInsets.symmetric(horizontal: 8); + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + const crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; + + // Use LayoutBuilder to get actual available width (accounting for sidebar) + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth - padding.left - padding.right; + final columnCount = _calculateColumnCount(availableWidth, maxCrossAxisExtent, crossAxisSpacing); + + return GridView.builder( + padding: padding, + gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); + return FocusableMediaCard( + item: item, + onBack: onBack, + isOffline: true, // Downloaded content works without server + onNavigateLeft: isFirstColumn ? () => _navigateToSidebar(context) : null, + ); + }, ); }, ); diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 650c9dca..030fc4d4 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -4,6 +4,25 @@ import '../../providers/settings_provider.dart'; import '../../services/settings_service.dart' show ViewMode, LibraryDensity; import '../../utils/grid_size_calculator.dart'; import '../../utils/layout_constants.dart'; +import '../main_screen.dart'; + +/// Context passed to the item builder with navigation information. +class GridItemContext { + /// Whether this item is in the first row of the grid. + final bool isFirstRow; + + /// Whether this item is in the first column of the grid. + final bool isFirstColumn; + + /// Callback to navigate to the sidebar (for first-column items). + final VoidCallback? navigateToSidebar; + + const GridItemContext({ + required this.isFirstRow, + required this.isFirstColumn, + this.navigateToSidebar, + }); +} /// A widget that automatically switches between grid and list view /// based on user settings, providing a consistent layout pattern @@ -14,8 +33,9 @@ class AdaptiveMediaGrid extends StatelessWidget { /// The list of items to display final List items; - /// Builder function for each item in the grid/list - final Widget Function(BuildContext context, T item, int index) itemBuilder; + /// Builder function for each item in the grid/list. + /// Receives the item, index, and optional grid context with navigation info. + final Widget Function(BuildContext context, T item, int index, [GridItemContext? gridContext]) itemBuilder; /// Callback when the list needs to be refreshed final VoidCallback? onRefresh; @@ -32,6 +52,9 @@ class AdaptiveMediaGrid extends StatelessWidget { /// Callback when back button is pressed (for hierarchical navigation) final VoidCallback? onBack; + /// Whether to enable sidebar navigation for first-column items. + final bool enableSidebarNavigation; + const AdaptiveMediaGrid({ super.key, required this.items, @@ -41,6 +64,7 @@ class AdaptiveMediaGrid extends StatelessWidget { this.childAspectRatio, this.firstItemFocusNode, this.onBack, + this.enableSidebarNavigation = false, }); @override @@ -55,6 +79,18 @@ class AdaptiveMediaGrid extends StatelessWidget { // Extra top padding for focus decoration (scale + border extends beyond item bounds) static const double _focusDecorationPadding = 8.0; + /// Navigate focus to the sidebar + void _navigateToSidebar(BuildContext context) { + MainScreenFocusScope.of(context)?.focusSidebar(); + } + + /// Calculate column count based on actual available width. + /// Uses the same formula as Flutter's SliverGridDelegateWithMaxCrossAxisExtent. + int _calculateColumnCount(double availableWidth, double maxCrossAxisExtent) { + final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; + return ((availableWidth + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); + } + /// Builds either a list or grid view based on the view mode Widget _buildItemsView(BuildContext context, ViewMode viewMode, LibraryDensity density) { final basePadding = padding ?? GridLayoutConstants.gridPadding; @@ -63,26 +99,56 @@ class AdaptiveMediaGrid extends StatelessWidget { final effectiveAspectRatio = childAspectRatio ?? GridLayoutConstants.posterAspectRatio; if (viewMode == ViewMode.list) { + // In list view, all items are in a single column (first column) return ListView.builder( padding: effectivePadding, // Allow focus decoration to render outside scroll bounds clipBehavior: Clip.none, itemCount: items.length, - itemBuilder: (context, index) => itemBuilder(context, items[index], index), + itemBuilder: (ctx, index) { + final gridContext = enableSidebarNavigation + ? GridItemContext( + isFirstRow: index == 0, + isFirstColumn: true, // List view = single column + navigateToSidebar: () => _navigateToSidebar(context), + ) + : null; + return itemBuilder(ctx, items[index], index, gridContext); + }, ); } else { - return GridView.builder( - padding: effectivePadding, - // Allow focus decoration to render outside scroll bounds - clipBehavior: Clip.none, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(context, density), - childAspectRatio: effectiveAspectRatio, - crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, - mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, - ), - itemCount: items.length, - itemBuilder: (context, index) => itemBuilder(context, items[index], index), + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); + final horizontalPadding = effectivePadding.left + effectivePadding.right; + + // Use LayoutBuilder to get the actual available width (accounting for sidebar, etc.) + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth - horizontalPadding; + final columnCount = _calculateColumnCount(availableWidth, maxCrossAxisExtent); + + return GridView.builder( + padding: effectivePadding, + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: maxCrossAxisExtent, + childAspectRatio: effectiveAspectRatio, + crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, + mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, + ), + itemCount: items.length, + itemBuilder: (ctx, index) { + final gridContext = enableSidebarNavigation + ? GridItemContext( + isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), + isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + navigateToSidebar: () => _navigateToSidebar(context), + ) + : null; + return itemBuilder(ctx, items[index], index, gridContext); + }, + ); + }, ); } } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 1e8b8e42..4bab2240 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -945,7 +945,7 @@ class _LibrariesScreenState extends State }); _getTabChipFocusNode(newIndex).requestFocus(); } - : null, + : _onTabBarBack, onNavigateRight: index < tabCount - 1 ? () { final newIndex = index + 1; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 5b49b537..71e3763a 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -21,6 +21,7 @@ import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode; import '../../../mixins/item_updatable.dart'; import '../../../i18n/strings.g.dart'; +import '../../main_screen.dart'; import 'base_library_tab.dart'; /// Browse tab for library screen @@ -506,6 +507,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filtersChipFocusNode.requestFocus() : _isSortChipVisible @@ -668,12 +675,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem(index, isFirstRow: index == 0), + itemBuilder: (context, index) => _buildMediaCardItem( + index, + isFirstRow: index == 0, + isFirstColumn: true, // List view = single column + ), ), ); } else { @@ -691,14 +702,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState - _buildMediaCardItem(index, isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount)), + itemBuilder: (context, index) => _buildMediaCardItem( + index, + isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), + isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + ), ), ); } } - Widget _buildMediaCardItem(int index, {required bool isFirstRow}) { + Widget _buildMediaCardItem(int index, {required bool isFirstRow, required bool isFirstColumn}) { if (index >= items.length) { return const Padding( padding: EdgeInsets.all(16.0), @@ -717,6 +731,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> extends BaseLibraryTabState with LibraryTabFocusMixin { /// Build a single grid item. - Widget buildGridItem(BuildContext context, T item, int index); + /// [gridContext] provides information about the item's position in the grid + /// and callbacks for navigation (e.g., navigating to sidebar from first column). + Widget buildGridItem(BuildContext context, T item, int index, [GridItemContext? gridContext]); @override int get itemCount => items.length; @@ -20,10 +22,11 @@ abstract class LibraryGridTabState> extends BaseL Widget buildContent(List items) { return AdaptiveMediaGrid( items: items, - itemBuilder: (context, item, index) => buildGridItem(context, item, index), + itemBuilder: (context, item, index, [gridContext]) => buildGridItem(context, item, index, gridContext), onRefresh: loadItems, firstItemFocusNode: firstItemFocusNode, onBack: widget.onBack, + enableSidebarNavigation: true, ); } } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 8624834b..08078345 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -4,6 +4,7 @@ import '../../../models/plex_playlist.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; +import '../adaptive_media_grid.dart'; import 'base_library_tab.dart'; import 'library_grid_tab_state.dart'; @@ -51,13 +52,14 @@ class _LibraryPlaylistsTabState extends LibraryGridTabState _handleVerticalNavigation(index, isUp), onBack: widget.onBack, onNavigateUp: index == 0 ? widget.onBack : null, + onNavigateToSidebar: _navigateToSidebar, ); }, ); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 4136f01b..dd109aef 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -662,6 +662,7 @@ class _MainScreenState extends State with RouteAware, WindowListener _selectLibrary(key); _focusContent(); }, + onNavigateToContent: _focusContent, ), ), ), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 2b4cb4b4..30147b63 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -9,13 +9,16 @@ import '../mixins/refreshable.dart'; import '../models/plex_metadata.dart'; import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; +import '../services/settings_service.dart' show ViewMode; import '../utils/app_logger.dart'; +import '../utils/grid_size_calculator.dart'; import '../utils/sliver_adaptive_media_builder.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; -import '../widgets/media_card.dart'; +import '../widgets/focusable_media_card.dart'; import '../utils/focus_utils.dart'; import 'libraries/state_messages.dart'; +import 'main_screen.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @@ -148,88 +151,119 @@ class _SearchScreenState extends State with Refreshable, FullRefre } } + /// Navigate focus to the sidebar + void _navigateToSidebar() { + MainScreenFocusScope.of(context)?.focusSidebar(); + } + + /// Calculate column count based on available width. + int _calculateColumnCount(double availableWidth, double maxCrossAxisExtent, double crossAxisSpacing) { + return ((availableWidth + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); + } + @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: CustomScrollView( - slivers: [ - DesktopSliverAppBar(title: Text(t.screens.search), floating: true), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - decoration: InputDecoration( - hintText: t.search.hint, - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const AppIcon(Symbols.clear_rounded, fill: 1), - onPressed: () { - _searchController.clear(); - // State update handled by listener - }, - ) - : null, - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide.none, + // Use LayoutBuilder to get the actual available width (accounting for sidebar) + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar(title: Text(t.screens.search), floating: true), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: t.search.hint, + prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const AppIcon(Symbols.clear_rounded, fill: 1), + onPressed: () { + _searchController.clear(); + // State update handled by listener + }, + ) + : null, + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ), - ), - ), - if (_isSearching) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (!_hasSearched) - SliverFillRemaining( - child: StateMessageWidget( - message: t.search.searchYourMedia, - subtitle: t.search.enterTitleActorOrKeyword, - icon: Symbols.search_rounded, - iconSize: 80, - ), - ) - else if (_searchResults.isEmpty) - SliverFillRemaining( - child: StateMessageWidget( - message: t.messages.noResultsFound, - subtitle: t.search.tryDifferentTerm, - icon: Symbols.search_off_rounded, - iconSize: 80, - ), - ) - else - Consumer( - builder: (context, settingsProvider, child) { - return buildAdaptiveMediaSliverBuilder( - context: context, - items: _searchResults, - itemBuilder: (context, item, index) { - return MediaCard(key: Key(item.ratingKey), item: item, onRefresh: updateItem); - }, + if (_isSearching) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (!_hasSearched) + SliverFillRemaining( + child: StateMessageWidget( + message: t.search.searchYourMedia, + subtitle: t.search.enterTitleActorOrKeyword, + icon: Symbols.search_rounded, + iconSize: 80, + ), + ) + else if (_searchResults.isEmpty) + SliverFillRemaining( + child: StateMessageWidget( + message: t.messages.noResultsFound, + subtitle: t.search.tryDifferentTerm, + icon: Symbols.search_off_rounded, + iconSize: 80, + ), + ) + else + Consumer( + builder: (context, settingsProvider, child) { + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + const gridPadding = EdgeInsets.all(16); + const crossAxisSpacing = 8.0; + final gridAvailableWidth = availableWidth - gridPadding.left - gridPadding.right; + final columnCount = _calculateColumnCount(gridAvailableWidth, maxCrossAxisExtent, crossAxisSpacing); + final isList = settingsProvider.viewMode == ViewMode.list; + + return buildAdaptiveMediaSliverBuilder( + context: context, + items: _searchResults, + itemBuilder: (context, item, index) { + // In list view, all items are in the first column + final isFirstColumn = isList || GridSizeCalculator.isFirstColumn(index, columnCount); + return FocusableMediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: () => updateItem(item.ratingKey), + onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + ); + }, viewMode: settingsProvider.viewMode, density: settingsProvider.libraryDensity, padding: const EdgeInsets.all(16), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, mainAxisSpacing: 8, - ); - }, - ), - ], - ), - ), + ); + }, + ), + ], + ), + ), + ); + }, ); } } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index c9dbe471..a0eccc51 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; @@ -10,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../focus/focus_memory_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../main_screen.dart'; import '../../mixins/refreshable.dart'; import '../../services/discord_rpc_service.dart'; import '../../services/download_storage_service.dart'; @@ -134,6 +136,20 @@ class _SettingsScreenState extends State with FocusableTab { _focusTracker.restoreFocus(fallbackKey: _kTheme); } + /// Navigate focus to the sidebar + void _navigateToSidebar() { + MainScreenFocusScope.of(context)?.focusSidebar(); + } + + /// Handle key events for LEFT arrow → sidebar navigation + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _navigateToSidebar(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + Future _loadSettings() async { _settingsService = await settings.SettingsService.getInstance(); if (_keyboardShortcutsSupported) { @@ -169,29 +185,32 @@ class _SettingsScreenState extends State with FocusableTab { } return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.settings.title), pinned: true), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildAppearanceSection(), - const SizedBox(height: 24), - _buildVideoPlaybackSection(), - const SizedBox(height: 24), - _buildDownloadsSection(), - const SizedBox(height: 24), - if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)], - _buildAdvancedSection(), - const SizedBox(height: 24), - if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)], - _buildAboutSection(), - const SizedBox(height: 24), - ]), + body: Focus( + onKeyEvent: _handleKeyEvent, + child: CustomScrollView( + slivers: [ + CustomAppBar(title: Text(t.settings.title), pinned: true), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildAppearanceSection(), + const SizedBox(height: 24), + _buildVideoPlaybackSection(), + const SizedBox(height: 24), + _buildDownloadsSection(), + const SizedBox(height: 24), + if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)], + _buildAdvancedSection(), + const SizedBox(height: 24), + if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)], + _buildAboutSection(), + const SizedBox(height: 24), + ]), + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index 40b33601..b91818a0 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -124,15 +124,25 @@ class GridSizeCalculator { /// Calculates the number of columns in a grid based on screen width and density. /// - /// Accounts for standard horizontal padding (16px total). - static int getColumnCount(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width - 16; + /// Uses the same formula as Flutter's SliverGridDelegateWithMaxCrossAxisExtent: + /// `((crossAxisExtent + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil()` + /// + /// [horizontalPadding] should match the grid's total horizontal padding. + static int getColumnCount(BuildContext context, LibraryDensity density, {double horizontalPadding = 16}) { + final screenWidth = MediaQuery.of(context).size.width - horizontalPadding; final maxCrossAxisExtent = getMaxCrossAxisExtent(context, density); - return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100); + final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; + // Match Flutter's grid delegate calculation + return ((screenWidth + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); } /// Check if the given index is in the first row of a grid with given column count. static bool isFirstRow(int index, int columnCount) { return index < columnCount; } + + /// Check if the given index is in the first column of a grid with given column count. + static bool isFirstColumn(int index, int columnCount) { + return index % columnCount == 0; + } } diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 4e16ad90..65443cf0 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -32,6 +32,10 @@ class FocusableMediaCard extends StatefulWidget { /// Used to navigate from the top row to filter chips. final VoidCallback? onNavigateUp; + /// Called when the user presses LEFT and there's no focusable item to the left. + /// Used to navigate from the first column to the sidebar. + final VoidCallback? onNavigateLeft; + /// Called when the user presses BACK. /// Used to navigate from tab content to tab bar. final VoidCallback? onBack; @@ -54,6 +58,7 @@ class FocusableMediaCard extends StatefulWidget { this.isOffline = false, this.focusNode, this.onNavigateUp, + this.onNavigateLeft, this.onBack, this.onFocusChange, }); @@ -73,6 +78,7 @@ class _FocusableMediaCardState extends State { onSelect: () => _mediaCardKey.currentState?.handleTap(), onLongPress: () => _mediaCardKey.currentState?.showContextMenu(), onNavigateUp: widget.onNavigateUp, + onNavigateLeft: widget.onNavigateLeft, onBack: widget.onBack, onFocusChange: widget.onFocusChange, enableLongPress: true, diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 11dff708..6ae87f18 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -47,6 +47,10 @@ class HubSection extends StatefulWidget { /// Used to navigate focus to the tab bar. final VoidCallback? onNavigateUp; + /// Called when the user presses LEFT while at the leftmost item (index 0). + /// Used to navigate focus to the sidebar. + final VoidCallback? onNavigateToSidebar; + const HubSection({ super.key, required this.hub, @@ -58,6 +62,7 @@ class HubSection extends StatefulWidget { this.onVerticalNavigation, this.onBack, this.onNavigateUp, + this.onNavigateToSidebar, }); @override @@ -225,15 +230,18 @@ class HubSectionState extends State { final itemCount = widget.hub.items.length; if (itemCount == 0) return KeyEventResult.ignored; - // Left: move to previous item, ALWAYS consume to prevent escape + // Left: move to previous item, or navigate to sidebar at left edge if (key.isLeftKey) { if (_focusedIndex > 0) { _focusedIndex--; HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); _scrollToIndex(_focusedIndex); setState(() {}); + } else if (widget.onNavigateToSidebar != null) { + // At leftmost item: navigate to sidebar + widget.onNavigateToSidebar!(); } - // At leftmost item: do nothing, but consume event to prevent focus escape + // Always consume to prevent focus escape return KeyEventResult.handled; } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 75e284d8..006b489d 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -32,6 +32,9 @@ class NavigationRailItem extends StatelessWidget { final BorderRadius borderRadius; final double iconSize; + /// Called when RIGHT arrow is pressed to navigate to content area. + final VoidCallback? onNavigateRight; + const NavigationRailItem({ super.key, required this.icon, @@ -46,6 +49,7 @@ class NavigationRailItem extends StatelessWidget { this.autofocus = false, this.borderRadius = const BorderRadius.all(Radius.circular(12)), this.iconSize = 22, + this.onNavigateRight, }); @override @@ -61,6 +65,11 @@ class NavigationRailItem extends StatelessWidget { onTap(); return KeyEventResult.handled; } + // RIGHT arrow navigates to content area + if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { + onNavigateRight!(); + return KeyEventResult.handled; + } return KeyEventResult.ignored; }, child: Material( @@ -124,6 +133,9 @@ class SideNavigationRail extends StatefulWidget { final ValueChanged onDestinationSelected; final ValueChanged onLibrarySelected; + /// Called when RIGHT arrow is pressed to navigate to content without selecting. + final VoidCallback? onNavigateToContent; + const SideNavigationRail({ super.key, required this.selectedIndex, @@ -133,6 +145,7 @@ class SideNavigationRail extends StatefulWidget { this.alwaysExpanded = false, required this.onDestinationSelected, required this.onLibrarySelected, + this.onNavigateToContent, }); @override @@ -408,6 +421,7 @@ class SideNavigationRailState extends State { onTap: onTap, focusNode: focusNode, autofocus: autofocus, + onNavigateRight: widget.onNavigateToContent, ); } @@ -431,6 +445,11 @@ class SideNavigationRailState extends State { }); return KeyEventResult.handled; } + // RIGHT arrow navigates to content area + if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { + widget.onNavigateToContent!(); + return KeyEventResult.handled; + } return KeyEventResult.ignored; }, child: Material( @@ -605,6 +624,7 @@ class SideNavigationRailState extends State { focusNode: focusNode, borderRadius: BorderRadius.circular(tokens(context).radiusSm), iconSize: 18, + onNavigateRight: widget.onNavigateToContent, ), ); }