@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user