feat(library): scroll tabs and chips with content
This commit is contained in:
@@ -96,39 +96,46 @@ class AdaptiveMediaGrid<T> 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<T> extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,14 @@ class FolderTreeView extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<FolderTreeView> createState() => _FolderTreeViewState();
|
||||
State<FolderTreeView> createState() => FolderTreeViewState();
|
||||
}
|
||||
|
||||
class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
/// Public state so parents can trigger a refresh via GlobalKey.
|
||||
class FolderTreeViewState extends State<FolderTreeView> {
|
||||
/// Reload the root folders. Exposed for parent-driven pull-to-refresh.
|
||||
Future<void> 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<FolderTreeView> {
|
||||
@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];
|
||||
|
||||
@@ -121,6 +121,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// 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<LibrariesScreen>
|
||||
}
|
||||
|
||||
/// Build the app bar title - either dropdown on mobile or simple title on desktop
|
||||
Widget _buildAppBarTitle(List<MediaLibrary> visibleLibraries, MediaLibrary? selectedLibrary) {
|
||||
Widget _buildAppBarTitle(
|
||||
List<MediaLibrary> 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<LibrariesScreen>
|
||||
}
|
||||
|
||||
// On mobile, show the dropdown
|
||||
return _buildLibraryDropdownTitle(visibleLibraries);
|
||||
return _buildLibraryDropdownTitle(visibleLibraries, groupByServer: groupByServer);
|
||||
}
|
||||
|
||||
Widget _buildLibraryDropdownTitle(List<MediaLibrary> visibleLibraries) {
|
||||
Widget _buildLibraryDropdownTitle(List<MediaLibrary> 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<SettingsProvider, bool>((p) => p.groupLibrariesByServer);
|
||||
final showServerHeaders = _hasMultipleServers(visibleLibraries) && groupByServerSetting;
|
||||
final showServerHeaders = _hasMultipleServers(visibleLibraries) && groupByServer;
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
key: _libraryDropdownKey,
|
||||
@@ -972,121 +986,140 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
? 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>();
|
||||
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<SettingsProvider, bool>((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>();
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,13 +223,23 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip');
|
||||
final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_chip');
|
||||
|
||||
// Scroll controller for the CustomScrollView
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
// The inner CustomScrollView attaches its position to NestedScrollView's
|
||||
// shared inner controller (via PrimaryScrollController), which has one
|
||||
// position per kept-alive tab — making `controller.position` ambiguous and
|
||||
// throw an assertion. We capture this tab's specific [ScrollPosition] from
|
||||
// a Builder placed inside the slivers list, and address it directly for
|
||||
// reads, listener attachment, and programmatic jumpTo/animateTo.
|
||||
ScrollPosition? _innerPosition;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScrollChanged);
|
||||
// Lets us trigger pull-to-refresh on the folder tree, which now lives as a
|
||||
// sliver inside the same CustomScrollView (no longer self-owning RefreshIndicator).
|
||||
final GlobalKey<FolderTreeViewState> _folderTreeKey = GlobalKey<FolderTreeViewState>();
|
||||
|
||||
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<MediaItem, LibraryBrows
|
||||
_scrollActivityTimer?.cancel();
|
||||
_scrollIdleTimer?.cancel();
|
||||
_alphaUpdateTimer?.cancel();
|
||||
_scrollController.removeListener(_onScrollChanged);
|
||||
_scrollController.dispose();
|
||||
_innerPosition?.removeListener(_onScrollChanged);
|
||||
// _innerPosition is owned by the inner CustomScrollView's Scrollable.
|
||||
_groupingChipFocusNode.dispose();
|
||||
_filtersChipFocusNode.dispose();
|
||||
_sortChipFocusNode.dispose();
|
||||
@@ -342,15 +352,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_jumpScrollGeneration++;
|
||||
_currentFirstVisibleIndex.value = 0;
|
||||
|
||||
// The browse tab state is kept alive across libraries, so ensure each
|
||||
// library starts from top instead of inheriting the previous offset.
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
// The browse tab state is kept alive across libraries, so ensure this
|
||||
// tab's scroll resets to 0 (other tabs keep their own positions).
|
||||
final pos = _innerPosition;
|
||||
if (pos != null) {
|
||||
pos.jumpTo(0);
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
}
|
||||
if (!mounted) return;
|
||||
_innerPosition?.jumpTo(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -737,11 +747,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
? lastFocusedGridIndex!
|
||||
: 0;
|
||||
|
||||
// Drop the chip's focus first so the targeted grid item's request isn't
|
||||
// racing against the chip's still-held primary focus state. Without this,
|
||||
// dpad DOWN from a chip occasionally fails to move focus to the grid.
|
||||
if (FocusManager.instance.primaryFocus == _groupingChipFocusNode ||
|
||||
FocusManager.instance.primaryFocus == _filtersChipFocusNode ||
|
||||
FocusManager.instance.primaryFocus == _sortChipFocusNode) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
|
||||
if (targetIndex == 0) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
final target = targetIndex == 0
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
|
||||
|
||||
// Defer to a post-frame so the focus node has a chance to attach if the
|
||||
// grid item is being built/rebuilt in the same frame.
|
||||
if (target.context != null) {
|
||||
target.requestFocus();
|
||||
} else {
|
||||
getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item').requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) target.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,6 +875,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
/// Track scroll position and trigger debounced range loading.
|
||||
void _onScrollChanged() {
|
||||
// The inner controller (PrimaryScrollController) is shared across tabs.
|
||||
// Skip if this tab isn't active so we don't react to a sibling tab's
|
||||
// scroll events.
|
||||
if (!widget.isActive) return;
|
||||
|
||||
// Debounced scroll-idle handler: load visible range when scrolling settles
|
||||
_scrollIdleTimer?.cancel();
|
||||
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
@@ -896,7 +928,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
/// Recompute the first-visible-index from the current scroll offset.
|
||||
void _updateVisibleIndex() {
|
||||
final offset = _scrollController.offset;
|
||||
final pos = _innerPosition;
|
||||
if (pos == null) return;
|
||||
final offset = pos.pixels;
|
||||
final firstInRow = _itemIndexFromScrollOffset(offset);
|
||||
// Use the last item in the first visible row so the highlighted letter
|
||||
// updates as soon as items with a new letter appear in that row.
|
||||
@@ -908,7 +942,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
|
||||
/// Compute the first visible item index from a scroll offset.
|
||||
/// The visible area starts below the chips bar, so we offset accordingly.
|
||||
/// Chips bar is the first sliver (height = _chipsBarHeight) followed by the
|
||||
/// grid's own top padding before the first row.
|
||||
int _itemIndexFromScrollOffset(double offset) {
|
||||
if (_lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return 0;
|
||||
|
||||
@@ -917,10 +952,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
|
||||
if (rowHeight <= 0) return 0;
|
||||
|
||||
// The visible area starts at _chipsBarHeight from the viewport top.
|
||||
// Grid content starts at _effectiveTopPadding in scroll coordinates.
|
||||
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
|
||||
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
|
||||
// Items start at `_chipsBarHeight + _effectiveTopPadding` in scroll coords.
|
||||
final contentOffset = (offset - _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
|
||||
final row = (contentOffset / rowHeight).floor();
|
||||
final maxIndex = totalSize > 0 ? totalSize - 1 : 0;
|
||||
return (row * _currentColumnCount).clamp(0, maxIndex);
|
||||
@@ -966,15 +999,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
resetPaginationState();
|
||||
isLoading = true;
|
||||
});
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
}
|
||||
_innerPosition?.jumpTo(0);
|
||||
_loadItems();
|
||||
}
|
||||
|
||||
/// Scroll the grid so that [index] is visible just below the chips bar
|
||||
void _scrollToItemIndex(int index) {
|
||||
if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || !_scrollController.hasClients) {
|
||||
final pos = _innerPosition;
|
||||
if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || pos == null) {
|
||||
_isJumpScrolling = false;
|
||||
return;
|
||||
}
|
||||
@@ -983,11 +1015,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
|
||||
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
|
||||
final targetRow = index ~/ _currentColumnCount;
|
||||
// Position the target row right below the chips bar
|
||||
final offset = _effectiveTopPadding + targetRow * rowHeight - _chipsBarHeight;
|
||||
// Position the target row at the top of the viewport. Chips and the grid's
|
||||
// top padding both precede the items in scroll coordinates.
|
||||
final offset = _chipsBarHeight + _effectiveTopPadding + targetRow * rowHeight;
|
||||
|
||||
final gen = _jumpScrollGeneration;
|
||||
final maxExtent = _scrollController.position.maxScrollExtent;
|
||||
final maxExtent = pos.maxScrollExtent;
|
||||
if (!maxExtent.isFinite) {
|
||||
_isJumpScrolling = false;
|
||||
return;
|
||||
@@ -997,55 +1030,37 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// If a newer jump already superseded this one, skip the animation
|
||||
// entirely — the next call will handle the final position.
|
||||
if (gen != _jumpScrollGeneration) {
|
||||
_scrollController.jumpTo(clampedOffset);
|
||||
pos.jumpTo(clampedOffset);
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollController
|
||||
.animateTo(clampedOffset, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut)
|
||||
.then((_) {
|
||||
// Only clear the flag if no newer jump has started.
|
||||
if (mounted && gen == _jumpScrollGeneration) {
|
||||
_isJumpScrolling = false;
|
||||
}
|
||||
});
|
||||
pos.animateTo(clampedOffset, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut).then((_) {
|
||||
// Only clear the flag if no newer jump has started.
|
||||
if (mounted && gen == _jumpScrollGeneration) {
|
||||
_isJumpScrolling = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
// For folders mode, use FolderTreeView instead of grid/list
|
||||
if (_selectedGrouping == 'folders') {
|
||||
return Column(
|
||||
children: [
|
||||
_buildChipsBar(),
|
||||
Expanded(
|
||||
child: FolderTreeView(
|
||||
libraryKey: widget.library.id,
|
||||
serverId: widget.library.serverId,
|
||||
onRefresh: updateItem,
|
||||
firstItemFocusNode: firstItemFocusNode,
|
||||
onNavigateUp: _navigateToChips,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// Chips are inline as a floating sliver in the inner scroll. The alpha
|
||||
// jump bar is the only overlay; we offset it by the typical app bar height
|
||||
// so it isn't obscured by the floating outer header. Using MediaQuery
|
||||
// (not the absorber handle) avoids rebuilding during layout — listening to
|
||||
// the handle from a builder fires notifyListeners during the build phase
|
||||
// and triggers a setState-in-build assertion.
|
||||
final media = MediaQuery.of(context);
|
||||
final overlayTopPadding = media.padding.top + kToolbarHeight;
|
||||
|
||||
// For list/grid modes, use Stack with chips layered on top of grid.
|
||||
// This allows the grid to use Clip.none for focus decorations while
|
||||
// the chips bar (with background) covers any overflow at the top.
|
||||
return Stack(
|
||||
children: [
|
||||
// Grid fills the entire area, with top padding for chips bar
|
||||
Positioned.fill(child: _buildScrollableContent()),
|
||||
// Chips bar on top with solid background
|
||||
Positioned(top: 0, left: 0, right: 0, child: _buildChipsBar()),
|
||||
// Alpha jump bar / scroll handle on the right edge
|
||||
if (_shouldShowAlphaJumpBar)
|
||||
Positioned(
|
||||
top: _chipsBarHeight,
|
||||
top: overlayTopPadding,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _isPhone(context)
|
||||
@@ -1077,9 +1092,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the scrollable content (grid/list) with scroll-idle loading
|
||||
/// Builds the scrollable content with chips, then either folder tree or grid/list.
|
||||
Widget _buildScrollableContent() {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
final isFolders = _selectedGrouping == 'folders';
|
||||
|
||||
Widget scrollView = NotificationListener<ScrollNotification>(
|
||||
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<MediaItem, LibraryBrows
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
slivers: _buildContentSlivers(),
|
||||
child: Builder(
|
||||
builder: (context) => 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<MediaItem, LibraryBrows
|
||||
}
|
||||
|
||||
/// Returns the first-visible index and visible count from the scroll
|
||||
/// controller + grid metrics, or null if the viewport isn't measured yet.
|
||||
/// position + grid metrics, or null if the viewport isn't measured yet.
|
||||
({int firstIndex, int visibleCount})? _computeVisibleRange() {
|
||||
if (_currentColumnCount < 1 || !_scrollController.hasClients || _lastCrossAxisExtent <= 0) return null;
|
||||
final offset = _scrollController.offset;
|
||||
final viewportHeight = _scrollController.position.viewportDimension;
|
||||
final pos = _innerPosition;
|
||||
if (_currentColumnCount < 1 || pos == null || _lastCrossAxisExtent <= 0) return null;
|
||||
final offset = pos.pixels;
|
||||
final viewportHeight = pos.viewportDimension;
|
||||
if (!viewportHeight.isFinite) return null;
|
||||
|
||||
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
|
||||
@@ -1156,10 +1218,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
/// Prefetch images for items near the viewport to reduce pop-in.
|
||||
void _prefetchImages(int startIndex, List<MediaItem> 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<MediaItem, LibraryBrows
|
||||
|
||||
/// Builds content as slivers for the CustomScrollView
|
||||
List<Widget> _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<MediaItem, LibraryBrows
|
||||
];
|
||||
}
|
||||
|
||||
// Top padding for grid content = chips bar height + extra space for focus decoration.
|
||||
// Chips bar is ~48px, focus ring extends ~6px beyond item bounds.
|
||||
// On phone there's no D-pad focus decoration so extra clearance is unnecessary.
|
||||
static const double _gridTopPadding = _chipsBarHeight + 12.0;
|
||||
static const double _gridTopPaddingPhone = _chipsBarHeight;
|
||||
// Top padding for grid content. Chips are inline above the grid now, so this
|
||||
// is purely focus-decoration clearance (~6px) on desktop. Phone has no D-pad
|
||||
// focus ring so no extra clearance is needed.
|
||||
static const double _gridTopPadding = 12.0;
|
||||
static const double _gridTopPaddingPhone = 0.0;
|
||||
|
||||
/// Width of the alpha jump bar widget
|
||||
static const double _alphaJumpBarWidth = 20.0;
|
||||
@@ -1347,6 +1425,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
isFirstRow: index == 0,
|
||||
isFirstColumn: true, // List view = single column
|
||||
disableScale: true,
|
||||
columnCount: 1,
|
||||
itemCount: itemCount,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1383,6 +1463,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
|
||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
|
||||
isLastColumn: (index % columnCount) == (columnCount - 1),
|
||||
columnCount: columnCount,
|
||||
itemCount: itemCount,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1397,6 +1479,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
required bool isFirstColumn,
|
||||
bool isLastColumn = false,
|
||||
bool disableScale = false,
|
||||
int columnCount = 1,
|
||||
int itemCount = 0,
|
||||
}) {
|
||||
final item = loadedItems[index];
|
||||
|
||||
@@ -1410,18 +1494,86 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// All other items get managed focus nodes for restoration
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'browse_grid_item');
|
||||
|
||||
// Explicit row navigation. Default directional focus traversal becomes
|
||||
// unreliable while items are mounting/unmounting under fast scrolling and
|
||||
// can spuriously jump to overlay widgets like the floating chips header.
|
||||
VoidCallback? navigateUp;
|
||||
if (isFirstRow) {
|
||||
navigateUp = _navigateToChips;
|
||||
} else if (columnCount > 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;
|
||||
}
|
||||
|
||||
@@ -136,28 +136,35 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
Widget buildContent(List<MediaHub> 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user