fix: browse library chips focus

This commit is contained in:
edde746
2026-01-24 03:26:24 +01:00
parent 8c97df6ff5
commit ec57cd436e
5 changed files with 112 additions and 66 deletions
+6 -6
View File
@@ -114,15 +114,15 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.handled;
}
// LEFT arrow
if (key.isLeftKey && callbacks.onNavigateLeft != null) {
callbacks.onNavigateLeft!();
// LEFT arrow - always consume to prevent default focus traversal
if (key.isLeftKey) {
callbacks.onNavigateLeft?.call();
return KeyEventResult.handled;
}
// RIGHT arrow
if (key.isRightKey && callbacks.onNavigateRight != null) {
callbacks.onNavigateRight!();
// RIGHT arrow - always consume to prevent default focus traversal
if (key.isRightKey) {
callbacks.onNavigateRight?.call();
return KeyEventResult.handled;
}
+18 -4
View File
@@ -246,10 +246,24 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
}
// Scroll to alignment
Scrollable.ensureVisible(
context,
alignment: widget.scrollAlignment,
// Calculate target scroll offset for the immediate scrollable only.
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables,
// which can cause issues with nested scroll views (e.g., chips bar scrolling
// out of view when focusing grid items in library browse tab).
final position = scrollable.position;
final currentOffset = position.pixels;
// Target: item center should be at scrollAlignment of viewport
final targetViewportY = viewportHeight * widget.scrollAlignment;
final scrollDelta = itemVerticalCenter - targetViewportY;
final targetOffset = (currentOffset + scrollDelta).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
position.animateTo(
targetOffset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
@@ -115,6 +115,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections');
final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists');
// Scroll controller for the outer CustomScrollView
final ScrollController _outerScrollController = ScrollController();
@override
void initState() {
super.initState();
@@ -183,6 +186,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
});
}
// Scroll outer view to top to ensure tab content (including chips bar) is visible
if (_outerScrollController.hasClients && _outerScrollController.offset > 0) {
_outerScrollController.jumpTo(0);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -221,6 +229,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Focus without additional frame delay (used for retry)
void _focusCurrentTabImmediate() {
// Scroll outer view to top to ensure tab content (including chips bar) is visible
if (_outerScrollController.hasClients && _outerScrollController.offset > 0) {
_outerScrollController.jumpTo(0);
}
State? tabState;
switch (_tabController.index) {
case 0:
@@ -313,6 +326,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_cancelToken?.cancel();
_outerScrollController.dispose();
_recommendedTabChipFocusNode.dispose();
_browseTabChipFocusNode.dispose();
_collectionsTabChipFocusNode.dispose();
@@ -1077,6 +1091,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return Scaffold(
body: CustomScrollView(
controller: _outerScrollController,
slivers: [
DesktopSliverAppBar(
title: _buildAppBarTitle(visibleLibraries),
@@ -88,9 +88,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
int _lastFocusedContentVersion = 0;
final Map<int, FocusNode> _gridItemFocusNodes = {};
// Scroll controller for the CustomScrollView
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_cancelToken?.cancel();
_scrollController.dispose();
_groupingChipFocusNode.dispose();
_filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose();
@@ -161,6 +165,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
/// Height of the chips bar (padding + chip + padding)
static const double _chipsBarHeight = 48.0;
/// Focus the chips bar (for navigating from tab bar to content).
/// Called by libraries screen when pressing DOWN on tab bar.
void focusChipsBar() {
@@ -170,25 +177,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
// Scroll chips into view and focus the grouping chip
if (_groupingChipFocusNode.context != null) {
Scrollable.ensureVisible(
_groupingChipFocusNode.context!,
alignment: 0.0,
duration: const Duration(milliseconds: 200),
).then((_) {
if (mounted) {
_groupingChipFocusNode.requestFocus();
}
});
} else {
// Context not available yet, try again next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_groupingChipFocusNode.requestFocus();
}
});
// Scroll the inner grid up by the chips bar height so the chips become
// visible while keeping the grid content roughly in the same position.
if (_scrollController.hasClients) {
final newOffset = (_scrollController.offset - _chipsBarHeight).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(newOffset);
}
_groupingChipFocusNode.requestFocus();
}
Future<void> _loadContent() async {
@@ -501,7 +500,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_lastFocusedIndex != null && _lastFocusedContentVersion == _contentVersion && _lastFocusedIndex! < items.length;
final targetIndex = shouldRestoreFocus ? _lastFocusedIndex! : 0;
_getGridItemFocusNode(targetIndex).requestFocus();
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
if (targetIndex == 0) {
firstItemFocusNode.requestFocus();
} else {
_getGridItemFocusNode(targetIndex).requestFocus();
}
}
/// Navigate focus from grid up to the grouping chip
@@ -525,7 +530,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
// For folders mode, keep the column structure with FolderTreeView
// For folders mode, use FolderTreeView instead of grid/list
if (_selectedGrouping == 'folders') {
return Column(
children: [
@@ -541,8 +546,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
// For list/grid modes, use CustomScrollView with slivers
// Chips are pinned at the top while content scrolls underneath
// For list/grid modes, use Column with chips always visible at top
// and scrollable content below. This avoids nested scroll complications
// that caused focus issues on Android TV.
return Column(
children: [
_buildChipsBar(),
Expanded(
child: _buildScrollableContent(),
),
],
);
}
/// Builds the scrollable content (grid/list) with pagination support
Widget _buildScrollableContent() {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) {
@@ -551,21 +569,23 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return false;
},
child: CustomScrollView(
slivers: [
// Floating chips bar - hides on scroll down, reappears on scroll up
SliverPersistentHeader(floating: true, delegate: _ChipsHeaderDelegate(child: _buildChipsBar())),
// Content slivers
..._buildContentSlivers(),
],
controller: _scrollController,
slivers: _buildContentSlivers(),
),
);
}
/// Whether the filters chip is visible
bool get _isFiltersChipVisible => _filters.isNotEmpty && _selectedGrouping != 'folders';
/// Whether the sort chip is visible
bool get _isSortChipVisible => _sortOptions.isNotEmpty && _selectedGrouping != 'folders';
/// Builds the chips bar widget
Widget _buildChipsBar() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
color: Theme.of(context).scaffoldBackgroundColor,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -578,11 +598,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onPressed: _showGroupingBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateRight: _isFiltersChipVisible
? () => _filtersChipFocusNode.requestFocus()
: _isSortChipVisible
? () => _sortChipFocusNode.requestFocus()
: null,
onBack: widget.onBack,
),
const SizedBox(width: 8),
// Filters chip
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
if (_isFiltersChipVisible)
FocusableFilterChip(
focusNode: _filtersChipFocusNode,
icon: Symbols.filter_alt_rounded,
@@ -592,11 +617,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onPressed: _showFiltersBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: () => _groupingChipFocusNode.requestFocus(),
onNavigateRight: _isSortChipVisible ? () => _sortChipFocusNode.requestFocus() : null,
onBack: widget.onBack,
),
if (_filters.isNotEmpty && _selectedGrouping != 'folders') const SizedBox(width: 8),
if (_isFiltersChipVisible) const SizedBox(width: 8),
// Sort chip
if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders')
if (_isSortChipVisible)
FocusableFilterChip(
focusNode: _sortChipFocusNode,
icon: Symbols.sort_rounded,
@@ -604,6 +631,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onPressed: _showSortBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: _isFiltersChipVisible
? () => _filtersChipFocusNode.requestFocus()
: () => _groupingChipFocusNode.requestFocus(),
onBack: widget.onBack,
),
],
@@ -711,26 +741,3 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
}
/// Delegate for pinned chips header that stays fixed at the top while content scrolls underneath
class _ChipsHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
_ChipsHeaderDelegate({required this.child});
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(color: Theme.of(context).scaffoldBackgroundColor, child: child);
}
@override
double get maxExtent => 40.0; // Height of chips bar
@override
double get minExtent => 40.0; // Same as max (no shrinking)
@override
bool shouldRebuild(covariant _ChipsHeaderDelegate oldDelegate) {
return child != oldDelegate.child;
}
}
+10
View File
@@ -23,6 +23,12 @@ class FocusableFilterChip extends StatefulWidget {
/// Called when the user presses UP from this chip.
final VoidCallback? onNavigateUp;
/// Called when the user presses LEFT from this chip.
final VoidCallback? onNavigateLeft;
/// Called when the user presses RIGHT from this chip.
final VoidCallback? onNavigateRight;
/// Called when the user presses BACK from this chip.
final VoidCallback? onBack;
@@ -34,6 +40,8 @@ class FocusableFilterChip extends StatefulWidget {
this.focusNode,
this.onNavigateDown,
this.onNavigateUp,
this.onNavigateLeft,
this.onNavigateRight,
this.onBack,
});
@@ -74,6 +82,8 @@ class _FocusableFilterChipState extends State<FocusableFilterChip> with Focusabl
onSelect: widget.onPressed,
onNavigateDown: widget.onNavigateDown,
onNavigateUp: widget.onNavigateUp,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
onBack: widget.onBack,
),
);