From 1860a1cea6c43627dbdaff4871b8491976b15df7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:41:49 +0100 Subject: [PATCH] feat: dpadnavigation for folder tree view close #513 --- lib/focus/focusable_button.dart | 5 + lib/screens/libraries/folder_tree_item.dart | 180 ++++++++++-------- lib/screens/libraries/folder_tree_view.dart | 21 +- .../libraries/tabs/library_browse_tab.dart | 32 +++- 4 files changed, 151 insertions(+), 87 deletions(-) diff --git a/lib/focus/focusable_button.dart b/lib/focus/focusable_button.dart index ae09ebac..2c5cab4b 100644 --- a/lib/focus/focusable_button.dart +++ b/lib/focus/focusable_button.dart @@ -37,6 +37,9 @@ class FocusableButton extends StatefulWidget { /// Whether to scroll the widget into view when focused. final bool autoScroll; + /// Whether to use background color instead of border for focus indicator. + final bool useBackgroundFocus; + const FocusableButton({ super.key, required this.child, @@ -49,6 +52,7 @@ class FocusableButton extends StatefulWidget { this.onNavigateRight, this.onBack, this.autoScroll = true, + this.useBackgroundFocus = false, }); @override @@ -71,6 +75,7 @@ class _FocusableButtonState extends State { focusNode: widget.focusNode, disableScale: true, borderRadius: 100, + useBackgroundFocus: widget.useBackgroundFocus, descendantsAreFocusable: false, onFocusChange: (f) => setState(() => _isFocused = f), autoScroll: widget.autoScroll, diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 2ddf185c..b090ca4f 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_button.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../models/plex_metadata.dart'; import '../../i18n/strings.g.dart'; @@ -16,6 +18,8 @@ class FolderTreeItem extends StatelessWidget { final VoidCallback? onPlayAll; final VoidCallback? onShuffle; final bool isLoading; + final FocusNode? focusNode; + final VoidCallback? onNavigateUp; const FolderTreeItem({ super.key, @@ -28,6 +32,8 @@ class FolderTreeItem extends StatelessWidget { this.onPlayAll, this.onShuffle, this.isLoading = false, + this.focusNode, + this.onNavigateUp, }); IconData _getIcon() { @@ -66,88 +72,110 @@ class FolderTreeItem extends StatelessWidget { final indentation = depth * 24.0; final expandIcon = isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded; - return InkWell( - onTap: _handleTap, - child: Container( - padding: EdgeInsets.only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), - child: Row( - children: [ - // Expand/collapse icon for folders - if (isFolder) - SizedBox( - width: 24, - child: isLoading - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : AppIcon(expandIcon, fill: 1, size: 20), - ) - else - const SizedBox(width: 24), + final rowContent = Container( + padding: EdgeInsets.only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), + child: Row( + children: [ + // Expand/collapse icon for folders + if (isFolder) + SizedBox( + width: 24, + child: isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : AppIcon(expandIcon, fill: 1, size: 20), + ) + else + const SizedBox(width: 24), - const SizedBox(width: 8), + const SizedBox(width: 8), - // File/folder icon - AppIcon( - _getIcon(), - fill: 1, - size: 20, - color: isFolder - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + // File/folder icon + AppIcon( + _getIcon(), + fill: 1, + size: 20, + color: isFolder + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + + const SizedBox(width: 12), + + // Item title + Expanded( + child: Text( + item.title, + style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + ), - const SizedBox(width: 12), - - // Item title - Expanded( - child: Text( - item.title, - style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + // Additional metadata for files + if (!isFolder && item.year != null) + Text( + item.year.toString(), + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), ), - - // Play/Shuffle buttons for folders - if (isFolder) ...[ - IconButton( - onPressed: onPlayAll, - icon: AppIcon( - Symbols.play_arrow_rounded, - fill: 1, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - tooltip: t.common.play, - iconSize: 18, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - IconButton( - onPressed: onShuffle, - icon: AppIcon( - Symbols.shuffle_rounded, - fill: 1, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - tooltip: t.common.shuffle, - iconSize: 18, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - ], - - // Additional metadata for files - if (!isFolder && item.year != null) - Text( - item.year.toString(), - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), - ), - ], - ), + ], ), ); + + return Row( + children: [ + // Main item row — expand/navigate on select + Expanded( + child: FocusableWrapper( + focusNode: focusNode, + onSelect: _handleTap, + onNavigateUp: onNavigateUp, + useBackgroundFocus: true, + disableScale: true, + descendantsAreFocusable: false, + child: rowContent, + ), + ), + + // Play/Shuffle buttons for folders + if (isFolder) ...[ + FocusableButton( + useBackgroundFocus: true, + onPressed: onPlayAll, + child: IconButton( + onPressed: onPlayAll, + icon: AppIcon( + Symbols.play_arrow_rounded, + fill: 1, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + tooltip: t.common.play, + iconSize: 18, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + FocusableButton( + useBackgroundFocus: true, + onPressed: onShuffle, + child: IconButton( + onPressed: onShuffle, + icon: AppIcon( + Symbols.shuffle_rounded, + fill: 1, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + tooltip: t.common.shuffle, + iconSize: 18, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ], + ], + ); } } diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 17769382..41ad612e 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -16,8 +16,17 @@ class FolderTreeView extends StatefulWidget { final String libraryKey; final String? serverId; // Server this library belongs to final void Function(String)? onRefresh; + final FocusNode? firstItemFocusNode; + final VoidCallback? onNavigateUp; - const FolderTreeView({super.key, required this.libraryKey, this.serverId, this.onRefresh}); + const FolderTreeView({ + super.key, + required this.libraryKey, + this.serverId, + this.onRefresh, + this.firstItemFocusNode, + this.onNavigateUp, + }); @override State createState() => _FolderTreeViewState(); @@ -165,6 +174,9 @@ class _FolderTreeViewState extends State { // Create a unique key path that includes parent hierarchy and index final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i'; + // First root item gets the external focus node and navigate-up callback + final isFirstRootItem = depth == 0 && i == 0; + // Add the item itself widgets.add( FolderTreeItem( @@ -178,6 +190,8 @@ class _FolderTreeViewState extends State { onTap: !isFolder ? () => _handleItemTap(item) : null, onPlayAll: isFolder ? () => _handleFolderPlay(item) : null, onShuffle: isFolder ? () => _handleFolderShuffle(item) : null, + focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, + onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, ), ); @@ -212,7 +226,10 @@ class _FolderTreeViewState extends State { return RefreshIndicator( onRefresh: _loadRootFolders, - child: ListView(children: _buildTreeItems(_rootFolders, 0)), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + children: _buildTreeItems(_rootFolders, 0), + ), ); } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index c5289d05..7ee545eb 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -223,9 +223,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState items) => const SizedBox.shrink(); - /// Focus the first item in the grid/list (for tab activation) + /// Focus the first item in the grid/list/folder tree (for tab activation) @override void focusFirstItem() { + // In folder mode, items list is empty — focus the first folder tree item directly + if (_selectedGrouping == 'folders') { + void request() { + if (mounted && !firstItemFocusNode.hasFocus) { + firstItemFocusNode.requestFocus(); + } + } + + request(); + WidgetsBinding.instance.addPostFrameCallback((_) => request()); + return; + } + if (items.isNotEmpty) { // Request immediately, then once more on the next frame to handle cases // where the grid/list attaches after the initial focus attempt. @@ -246,14 +259,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _groupingChipFocusNode.requestFocus(), ), ), ],