fix: browse library chips focus
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user