feat: dpadnavigation for folder tree view

close #513
This commit is contained in:
edde746
2026-02-23 03:42:07 +01:00
parent 7802beb41b
commit 1860a1cea6
4 changed files with 151 additions and 87 deletions
+5
View File
@@ -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<FocusableButton> {
focusNode: widget.focusNode,
disableScale: true,
borderRadius: 100,
useBackgroundFocus: widget.useBackgroundFocus,
descendantsAreFocusable: false,
onFocusChange: (f) => setState(() => _isFocused = f),
autoScroll: widget.autoScroll,
+104 -76
View File
@@ -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,
),
),
],
],
);
}
}
+19 -2
View File
@@ -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<FolderTreeView> createState() => _FolderTreeViewState();
@@ -165,6 +174,9 @@ class _FolderTreeViewState extends State<FolderTreeView> {
// 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<FolderTreeView> {
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<FolderTreeView> {
return RefreshIndicator(
onRefresh: _loadRootFolders,
child: ListView(children: _buildTreeItems(_rootFolders, 0)),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: _buildTreeItems(_rootFolders, 0),
),
);
}
}
@@ -223,9 +223,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
@override
Widget buildContent(List<PlexMetadata> 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<PlexMetadata, LibraryBr
/// Focus the chips bar (for navigating from tab bar to content).
/// Called by libraries screen when pressing DOWN on tab bar.
void focusChipsBar() {
// If in folders mode, no chips to focus - go directly to folder tree
if (_selectedGrouping == 'folders') {
focusFirstItem();
return;
}
// With Stack layout, chips are always visible on top of the grid.
// No need to scroll - just focus the chip.
// Grouping chip is always visible (including in folder mode)
_groupingChipFocusNode.requestFocus();
}
@@ -602,6 +608,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Navigate focus from chips down to the grid item.
/// Restores focus to the previously focused item if content hasn't changed.
void _navigateToGrid() {
// In folder mode, firstItemFocusNode is attached to the first folder tree item
if (_selectedGrouping == 'folders') {
firstItemFocusNode.requestFocus();
return;
}
if (items.isEmpty) return;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < items.length ? lastFocusedGridIndex! : 0;
@@ -819,6 +831,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
libraryKey: widget.library.key,
serverId: widget.library.serverId,
onRefresh: updateItem,
firstItemFocusNode: firstItemFocusNode,
onNavigateUp: () => _groupingChipFocusNode.requestFocus(),
),
),
],