From f6c6808352d91aabfabea158bdc1b15e187b0ef4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 1 May 2026 22:28:42 +0200 Subject: [PATCH] feat(library): scroll tabs and chips with content --- .../libraries/adaptive_media_grid.dart | 75 ++-- lib/screens/libraries/folder_tree_view.dart | 35 +- lib/screens/libraries/libraries_screen.dart | 257 ++++++++------ .../libraries/tabs/library_browse_tab.dart | 326 +++++++++++++----- .../tabs/library_recommended_tab.dart | 45 ++- lib/widgets/desktop_app_bar.dart | 3 + lib/widgets/focusable_media_card.dart | 7 + 7 files changed, 483 insertions(+), 265 deletions(-) diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 8e78b76e..5a407e2a 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -96,39 +96,46 @@ class AdaptiveMediaGrid extends StatelessWidget { final effectivePadding = basePadding.copyWith(top: basePadding.top + _focusDecorationPadding); final effectiveAspectRatio = childAspectRatio ?? GridLayoutConstants.posterAspectRatio; - if (viewMode == ViewMode.list) { - // In list view, all items are in a single column (first column) - return ListView.builder( - padding: effectivePadding, - // Allow focus decoration to render outside scroll bounds - clipBehavior: Clip.none, - itemCount: items.length, - itemBuilder: (ctx, index) { - final gridContext = enableSidebarNavigation - ? GridItemContext( - isFirstRow: index == 0, - isFirstColumn: true, // List view = single column - isListMode: true, - navigateToSidebar: () => _navigateToSidebar(context), - ) - : null; - return itemBuilder(ctx, items[index], index, gridContext); - }, - ); - } else { - final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); - final horizontalPadding = effectivePadding.left + effectivePadding.right; - - // Use LayoutBuilder to get the actual available width (accounting for sidebar, etc.) - return LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth - horizontalPadding; - final columnCount = GridSizeCalculator.getColumnCount(availableWidth, maxCrossAxisExtent); - - return GridView.builder( + return CustomScrollView( + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + if (viewMode == ViewMode.list) + SliverPadding( padding: effectivePadding, - // Allow focus decoration to render outside scroll bounds - clipBehavior: Clip.none, + sliver: SliverList.builder( + itemCount: items.length, + itemBuilder: (ctx, index) { + final gridContext = enableSidebarNavigation + ? GridItemContext( + isFirstRow: index == 0, + isFirstColumn: true, // List view = single column + isListMode: true, + navigateToSidebar: () => _navigateToSidebar(context), + ) + : null; + return itemBuilder(ctx, items[index], index, gridContext); + }, + ), + ) + else + _buildGridSliver(context, density, effectivePadding, effectiveAspectRatio), + ], + ); + } + + Widget _buildGridSliver(BuildContext context, int density, EdgeInsets effectivePadding, double effectiveAspectRatio) { + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); + + return SliverPadding( + padding: effectivePadding, + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + // crossAxisExtent is the post-padding inner width. + final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxCrossAxisExtent); + + return SliverGrid.builder( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, childAspectRatio: effectiveAspectRatio, @@ -148,7 +155,7 @@ class AdaptiveMediaGrid extends StatelessWidget { }, ); }, - ); - } + ), + ); } } diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 6d4bac3b..a3ba00cc 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -30,10 +30,14 @@ class FolderTreeView extends StatefulWidget { }); @override - State createState() => _FolderTreeViewState(); + State createState() => FolderTreeViewState(); } -class _FolderTreeViewState extends State { +/// Public state so parents can trigger a refresh via GlobalKey. +class FolderTreeViewState extends State { + /// Reload the root folders. Exposed for parent-driven pull-to-refresh. + Future refresh() => _loadRootFolders(); + /// Folders/items returned by the Plex `/library/sections/{id}/folder` /// endpoint, mapped to neutral [MediaItem]s. The Plex `key` (folder URL) /// survives in [MediaItem.raw] under the `'key'` slot — see @@ -202,29 +206,34 @@ class _FolderTreeViewState extends State { @override Widget build(BuildContext context) { if (_isLoadingRoot) { - return const Center(child: CircularProgressIndicator()); + return const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())); } if (_errorMessage != null) { - return ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: _loadRootFolders, - retryLabel: t.common.retry, + return SliverFillRemaining( + hasScrollBody: false, + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _loadRootFolders, + retryLabel: t.common.retry, + ), ); } if (_rootFolders.isEmpty) { - return EmptyStateWidget(message: t.libraries.noFoldersFound, icon: Symbols.folder_open_rounded); + return SliverFillRemaining( + hasScrollBody: false, + child: EmptyStateWidget(message: t.libraries.noFoldersFound, icon: Symbols.folder_open_rounded), + ); } final flattened = <({MediaItem item, int depth, String path})>[]; _flattenTreeItems(_rootFolders, 0, '', flattened); - return RefreshIndicator( - onRefresh: _loadRootFolders, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 8), + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: SliverList.builder( itemCount: flattened.length, itemBuilder: (context, index) { final entry = flattened[index]; diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 0b07d18e..328804b7 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -121,6 +121,17 @@ class _LibrariesScreenState extends State // Scroll controller for the outer CustomScrollView final ScrollController _outerScrollController = ScrollController(); + /// Override the mixin's [focusTabBar] so we reveal the floating header + /// (which contains the tab chips) before requesting focus. Programmatic + /// requestFocus alone does not snap a floating SliverAppBar back into view. + @override + void focusTabBar() { + if (_outerScrollController.hasClients && _outerScrollController.offset > 0) { + _outerScrollController.jumpTo(0); + } + super.focusTabBar(); + } + @override void initState() { super.initState(); @@ -876,7 +887,11 @@ class _LibrariesScreenState extends State } /// Build the app bar title - either dropdown on mobile or simple title on desktop - Widget _buildAppBarTitle(List visibleLibraries, MediaLibrary? selectedLibrary) { + Widget _buildAppBarTitle( + List visibleLibraries, + MediaLibrary? selectedLibrary, { + required bool groupByServer, + }) { // No selection at all, or visible list is empty AND we're not browsing a hidden library if (_selectedLibraryGlobalKey == null || (visibleLibraries.isEmpty && selectedLibrary == null)) { return Text(t.libraries.title); @@ -902,16 +917,15 @@ class _LibrariesScreenState extends State } // On mobile, show the dropdown - return _buildLibraryDropdownTitle(visibleLibraries); + return _buildLibraryDropdownTitle(visibleLibraries, groupByServer: groupByServer); } - Widget _buildLibraryDropdownTitle(List visibleLibraries) { + Widget _buildLibraryDropdownTitle(List visibleLibraries, {required bool groupByServer}) { final selectedLibrary = visibleLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull ?? visibleLibraries.firstOrNull; if (selectedLibrary == null) return Text(t.libraries.title); - final groupByServerSetting = context.select((p) => p.groupLibrariesByServer); - final showServerHeaders = _hasMultipleServers(visibleLibraries) && groupByServerSetting; + final showServerHeaders = _hasMultipleServers(visibleLibraries) && groupByServer; return PopupMenuButton( key: _libraryDropdownKey, @@ -972,121 +986,140 @@ class _LibrariesScreenState extends State ? allLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull : null; - return Scaffold( - body: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: CustomScrollView( - controller: _outerScrollController, - slivers: [ - DesktopSliverAppBar( - title: _buildAppBarTitle(visibleLibraries, selectedLibrary), - pinned: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - actions: [ - FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(), - onNavigateDown: _focusCurrentTab, - actions: [ - if (allLibraries.isNotEmpty) - FocusableAction( - icon: Symbols.edit_rounded, - tooltip: t.libraries.manageLibraries, - onPressed: _showLibraryManagementSheet, - ), - FocusableAction( - icon: Symbols.refresh_rounded, - tooltip: t.common.refresh, - onPressed: _refreshCurrentTab, - ), - ], - ), - ], - ), - if (isLoadingLibraries) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (_errorMessage != null && visibleLibraries.isEmpty && selectedLibrary == null) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: () { - final librariesProvider = context.read(); - librariesProvider.refresh(); - }, - ), - ) - else if (visibleLibraries.isEmpty && selectedLibrary == null) - SliverFillRemaining( - child: allLibraries.isEmpty - ? EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded) - : EmptyStateWidget( - message: t.libraries.allLibrariesHidden, - icon: Symbols.visibility_off_rounded, - onAction: _showLibraryManagementSheet, - actionLabel: t.libraries.manageLibraries, - actionIcon: Symbols.edit_rounded, - ), - ) - else ...[ - // Tab selector chips (only on mobile - desktop has them in app bar) - if (selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context)) - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (int i = 0; i < _visibleTabs.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - buildTabChip( - _getTabLabel(_visibleTabs[i]), - i, - onSelectWhenActive: _focusCurrentTab, - onNavigateDown: _focusCurrentTabFromTabBar, - onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), - ), - ], - ], - ), - ), - ), - ), + final showMobileTabsRow = selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context); - // Tab content - if (selectedLibrary != null) - SliverFillRemaining( - child: TabBarView( - key: ValueKey(_selectedLibraryGlobalKey), - controller: tabController, - // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches - // See: https://github.com/flutter/flutter/issues/11132 - physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, - // Wrap each tab in ClipRect so horizontal overflow (e.g. hub rows - // with Clip.none) doesn't bleed into adjacent tabs during swipe transitions. - // The TabBarView's own clipBehavior only clips at the viewport level, - // not per-page, so we need per-child clipping. + // Hoist Provider lookup out of any closures: NestedScrollView's + // headerSliverBuilder is invoked from a Builder downstream and Provider + // refuses context.select inside closures invoked from foreign builds. + final groupByServerSetting = context.select((p) => p.groupLibrariesByServer); + + Widget appBar({required bool floating}) => DesktopSliverAppBar( + title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting), + // When showing the tab content, let the app bar float away with the + // content. Otherwise (loading / empty / error states) keep it pinned so + // it stays visible over the centered state widget. + pinned: !floating, + floating: floating, + snap: floating, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + if (allLibraries.isNotEmpty) + FocusableAction( + icon: Symbols.edit_rounded, + tooltip: t.libraries.manageLibraries, + onPressed: _showLibraryManagementSheet, + ), + FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshCurrentTab), + ], + ), + ], + ); + + Widget buildSimpleScroll({required Widget body}) { + return CustomScrollView( + controller: _outerScrollController, + slivers: [ + appBar(floating: false), + SliverFillRemaining(child: body), + ], + ); + } + + Widget body; + if (isLoadingLibraries) { + body = buildSimpleScroll(body: const Center(child: CircularProgressIndicator())); + } else if (_errorMessage != null && visibleLibraries.isEmpty && selectedLibrary == null) { + body = buildSimpleScroll( + body: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: () { + final librariesProvider = context.read(); + librariesProvider.refresh(); + }, + ), + ); + } else if (visibleLibraries.isEmpty && selectedLibrary == null) { + body = buildSimpleScroll( + body: allLibraries.isEmpty + ? EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded) + : EmptyStateWidget( + message: t.libraries.allLibrariesHidden, + icon: Symbols.visibility_off_rounded, + onAction: _showLibraryManagementSheet, + actionLabel: t.libraries.manageLibraries, + actionIcon: Symbols.edit_rounded, + ), + ); + } else if (selectedLibrary != null) { + body = NestedScrollView( + controller: _outerScrollController, + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: appBar(floating: true), + ), + if (showMobileTabsRow) + SliverToBoxAdapter( + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( children: [ - for (int i = 0; i < _visibleTabs.length; i++) - ClipRect( - child: _buildTabContent( - _visibleTabs[i], - library: selectedLibrary, - isActive: tabController.index == i, - tabIndex: i, - ), + for (int i = 0; i < _visibleTabs.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + buildTabChip( + _getTabLabel(_visibleTabs[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTabFromTabBar, + onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), ), + ], ], ), ), - ], + ), + ), + ], + body: TabBarView( + key: ValueKey(_selectedLibraryGlobalKey), + controller: tabController, + // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches + // See: https://github.com/flutter/flutter/issues/11132 + physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, + // Wrap each tab in ClipRect so horizontal overflow (e.g. hub rows + // with Clip.none) doesn't bleed into adjacent tabs during swipe transitions. + children: [ + for (int i = 0; i < _visibleTabs.length; i++) + ClipRect( + child: _buildTabContent( + _visibleTabs[i], + library: selectedLibrary, + isActive: tabController.index == i, + tabIndex: i, + ), + ), ], ), - ), + ); + } else { + body = buildSimpleScroll(body: const SizedBox.shrink()); + } + + return Scaffold( + body: ScrollConfiguration(behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: body), ); } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index fd20ba84..cfe0b1ba 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -223,13 +223,23 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _folderTreeKey = GlobalKey(); + + void _bindInnerPosition(ScrollPosition? position) { + if (position == _innerPosition) return; + _innerPosition?.removeListener(_onScrollChanged); + _innerPosition = position; + _innerPosition?.addListener(_onScrollChanged); } @override @@ -238,8 +248,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? totalSize - 1 : 0; return (row * _currentColumnCount).clamp(0, maxIndex); @@ -966,15 +999,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( + final isFolders = _selectedGrouping == 'folders'; + + Widget scrollView = NotificationListener( onNotification: (notification) { // Track scroll activity for phone scroll handle and range-load gating if (notification is ScrollStartNotification) { @@ -1093,13 +1110,57 @@ class _LibraryBrowseTabState extends BaseLibraryTabState CustomScrollView( + // No explicit controller: this picks up NestedScrollView's + // PrimaryScrollController, which is what wires inner scroll deltas + // through to the outer floating header. + // Allow focus decoration to render outside scroll bounds. + clipBehavior: Clip.none, + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + // Capture-only sliver: an invisible Builder whose context lives + // inside this CustomScrollView, used to grab the per-tab + // ScrollPosition. NSV's shared inner controller has one position + // per kept-alive tab; we need this tab's specific one. + SliverToBoxAdapter( + child: Builder( + builder: (innerCtx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _bindInnerPosition(Scrollable.maybeOf(innerCtx)?.position); + } + }); + return const SizedBox.shrink(); + }, + ), + ), + // Floating chips: scroll off with content but snap back into view + // on upward direction reversal, matching the outer floating + // SliverAppBar's behavior. + SliverPersistentHeader( + floating: true, + pinned: false, + delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight), + ), + ..._buildContentSlivers(), + ], + ), ), ); + + // Folders mode previously had its own RefreshIndicator inside FolderTreeView; + // it now lives at this level since FolderTreeView is a sliver. + if (isFolders) { + scrollView = RefreshIndicator( + onRefresh: () async { + await _folderTreeKey.currentState?.refresh(); + }, + child: scrollView, + ); + } + + return scrollView; } /// Self-healing: when a skeleton is rendered after scrolling stops, @@ -1118,11 +1179,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState items) { - if (!_scrollController.hasClients || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return; + final pos = _innerPosition; + if (pos == null || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return; - final offset = _scrollController.offset; - final viewportHeight = _scrollController.position.viewportDimension; + final offset = pos.pixels; + final viewportHeight = pos.viewportDimension; if (!viewportHeight.isFinite) return; final firstVisible = _itemIndexFromScrollOffset(offset); final itemWidth = _lastCrossAxisExtent / _currentColumnCount; @@ -1285,6 +1348,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildContentSlivers() { + // Folders mode: hand off to the FolderTreeView sliver, which owns its own + // loading/empty/error states. + if (_selectedGrouping == 'folders') { + return [ + FolderTreeView( + key: _folderTreeKey, + libraryKey: widget.library.id, + serverId: widget.library.serverId, + onRefresh: updateItem, + firstItemFocusNode: firstItemFocusNode, + onNavigateUp: _navigateToChips, + ), + ]; + } + if (isLoading && totalSize == 0 && loadedItems.isEmpty) { return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))]; } @@ -1319,11 +1397,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 && index >= columnCount) { + navigateUp = () => _focusGridItem(index - columnCount); + } + + VoidCallback? navigateDown; + if (columnCount > 0 && index + columnCount < itemCount) { + navigateDown = () => _focusGridItem(index + columnCount); + } + + VoidCallback? navigateLeft; + if (isFirstColumn) { + navigateLeft = _navigateToSidebar; + } else if (index > 0) { + navigateLeft = () => _focusGridItem(index - 1); + } + + VoidCallback? navigateRight; + if (isLastColumn && _shouldShowAlphaJumpBar && !_isPhone(context)) { + navigateRight = _navigateToAlphaJumpBar; + } else if (!isLastColumn && index + 1 < itemCount) { + navigateRight = () => _focusGridItem(index + 1); + } + return FocusableMediaCard( key: Key(item.id), item: item, focusNode: focusNode, disableScale: disableScale, onRefresh: updateItem, - onNavigateUp: isFirstRow ? _navigateToChips : null, - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, - onNavigateRight: isLastColumn && _shouldShowAlphaJumpBar && !_isPhone(context) ? _navigateToAlphaJumpBar : null, + onNavigateUp: navigateUp, + onNavigateDown: navigateDown, + onNavigateLeft: navigateLeft, + onNavigateRight: navigateRight, onBack: widget.onBack, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), onListRefresh: _loadItems, ); } + + /// Move focus to the grid item at [targetIndex] (or its skeleton's row). + /// Used by the explicit dpad navigation handlers. + void _focusGridItem(int targetIndex) { + if (targetIndex < 0 || targetIndex >= totalSize) return; + final node = targetIndex == 0 ? firstItemFocusNode : getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item'); + if (node.context != null) { + node.requestFocus(); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + } +} + +/// SliverPersistentHeader delegate for the chips bar. Fixed-height floating +/// header that snaps in on scroll direction reversal. +class _ChipsBarDelegate extends SliverPersistentHeaderDelegate { + final WidgetBuilder builder; + final double height; + + const _ChipsBarDelegate({required this.builder, required this.height}); + + @override + double get minExtent => height; + + @override + double get maxExtent => height; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return SizedBox(height: height, child: builder(context)); + } + + @override + bool shouldRebuild(covariant _ChipsBarDelegate oldDelegate) => + builder != oldDelegate.builder || height != oldDelegate.height; } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 98d951b2..02797a7a 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -136,28 +136,35 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState items) { _ensureHubKeys(items.length); - return ListView.builder( - padding: const EdgeInsets.fromLTRB(0, _focusDecorationPadding, 0, 8), + return CustomScrollView( // Allow focus decoration to render outside scroll bounds clipBehavior: Clip.none, - itemCount: items.length, - itemBuilder: (context, index) { - final hub = items[index]; - final isContinueWatching = _isContinueWatchingHub(hub); + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + SliverPadding( + padding: const EdgeInsets.fromLTRB(0, _focusDecorationPadding, 0, 8), + sliver: SliverList.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final hub = items[index]; + final isContinueWatching = _isContinueWatchingHub(hub); - return HubSection( - key: index < _hubKeys.length ? _hubKeys[index] : null, - hub: hub, - icon: _getHubIcon(hub), - isInContinueWatching: isContinueWatching, - onRefresh: updateItem, - onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null, - onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), - onBack: widget.onBack, - onNavigateUp: index == 0 ? widget.onBack : null, - onNavigateToSidebar: _navigateToSidebar, - ); - }, + return HubSection( + key: index < _hubKeys.length ? _hubKeys[index] : null, + hub: hub, + icon: _getHubIcon(hub), + isInContinueWatching: isContinueWatching, + onRefresh: updateItem, + onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null, + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onBack: widget.onBack, + onNavigateUp: index == 0 ? widget.onBack : null, + onNavigateToSidebar: _navigateToSidebar, + ); + }, + ), + ), + ], ); } diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index f60debe8..27f12797 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -102,6 +102,7 @@ class DesktopSliverAppBar extends StatelessWidget { final double? scrolledUnderElevation; final bool floating; final bool pinned; + final bool snap; final double? expandedHeight; final Widget? flexibleSpace; final PreferredSizeWidget? bottom; @@ -119,6 +120,7 @@ class DesktopSliverAppBar extends StatelessWidget { this.scrolledUnderElevation, this.floating = false, this.pinned = false, + this.snap = false, this.expandedHeight, this.flexibleSpace, this.bottom, @@ -145,6 +147,7 @@ class DesktopSliverAppBar extends StatelessWidget { scrolledUnderElevation: scrolledUnderElevation, floating: floating, pinned: pinned, + snap: snap, expandedHeight: expandedHeight, flexibleSpace: DesktopAppBarSections.buildFlexibleSpaceSection(flexibleSpace), bottom: bottom, diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 01f6bc4b..31b9725d 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -44,6 +44,11 @@ class FocusableMediaCard extends StatefulWidget { /// Used to navigate from the top row to filter chips. final VoidCallback? onNavigateUp; + /// Called when the user presses DOWN and there's no focusable item below. + /// When the grid wires explicit row navigation, this points at the item in + /// the next row (or null on the last row). + final VoidCallback? onNavigateDown; + /// Called when the user presses LEFT and there's no focusable item to the left. /// Used to navigate from the first column to the sidebar. final VoidCallback? onNavigateLeft; @@ -78,6 +83,7 @@ class FocusableMediaCard extends StatefulWidget { this.disableScale = false, this.focusNode, this.onNavigateUp, + this.onNavigateDown, this.onNavigateLeft, this.onNavigateRight, this.onBack, @@ -99,6 +105,7 @@ class _FocusableMediaCardState extends State { onSelect: () => _mediaCardKey.currentState?.handleTap(), onLongPress: () => _mediaCardKey.currentState?.showContextMenu(), onNavigateUp: widget.onNavigateUp, + onNavigateDown: widget.onNavigateDown, onNavigateLeft: widget.onNavigateLeft, onNavigateRight: widget.onNavigateRight, onBack: widget.onBack,