From f84a33bbf29a1fde48bd9aafae689a543f94fe9d Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 10 May 2026 19:09:38 +0700 Subject: [PATCH] feat: add library scroll-to-top and scroll-to-bottom quick buttons Add a pair of floating quick-scroll buttons on the library tab so long lists become easier to navigate. The buttons sit above the bottom navigation (or the selection toolbar in selection mode), fade in and out based on the active page's scroll metrics, and share their scroll-target keys per filter mode so switching filters does not carry over the previous page's scroll state. --- lib/screens/queue_tab.dart | 171 ++++++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 2 deletions(-) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 9e15970d..a0295fc1 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -154,6 +154,9 @@ class _QueueTabState extends ConsumerState { _queueLibraryCountsCache = {}; final Map<_QueueLibraryPageRequest, _QueueLibraryPageData> _queueLibraryPageDataCache = {}; + final Map _libraryScrollTargets = {}; + bool _showLibraryScrollTopButton = false; + bool _showLibraryScrollBottomButton = false; double _effectiveTextScale() { final textScale = MediaQuery.textScalerOf(context).scale(1.0); @@ -193,6 +196,13 @@ class _QueueTabState extends ConsumerState { _libraryGridScaleStartExtent = null; } + _LibraryScrollTargets _scrollTargetsForFilter(String filterMode) { + return _libraryScrollTargets.putIfAbsent( + filterMode, + _LibraryScrollTargets.new, + ); + } + @override void initState() { super.initState(); @@ -308,11 +318,17 @@ class _QueueTabState extends ConsumerState { required bool hasMoreLibrary, required bool isPageLoading, }) { - if (isPageLoading || !hasMoreLibrary || notification.depth != 0) { + if (notification.depth != 0) { return false; } final metrics = notification.metrics; + _updateLibraryScrollButtons(filterMode, metrics); + + if (isPageLoading || !hasMoreLibrary) { + return false; + } + if (metrics.maxScrollExtent <= 0) return false; final threshold = metrics.maxScrollExtent * 0.7; final nearEnd = @@ -324,6 +340,40 @@ class _QueueTabState extends ConsumerState { return false; } + void _updateLibraryScrollButtons(String filterMode, ScrollMetrics metrics) { + if (!mounted) return; + if (filterMode != ref.read(settingsProvider).historyFilterMode) { + return; + } + + final showTop = + metrics.maxScrollExtent > 0 && + metrics.pixels > metrics.viewportDimension * 0.45; + final showBottom = + metrics.maxScrollExtent > 0 && + metrics.extentAfter > metrics.viewportDimension * 0.75; + + if (showTop == _showLibraryScrollTopButton && + showBottom == _showLibraryScrollBottomButton) { + return; + } + + setState(() { + _showLibraryScrollTopButton = showTop; + _showLibraryScrollBottomButton = showBottom; + }); + } + + void _resetLibraryScrollButtons() { + if (!_showLibraryScrollTopButton && !_showLibraryScrollBottomButton) { + return; + } + setState(() { + _showLibraryScrollTopButton = false; + _showLibraryScrollBottomButton = false; + }); + } + void _invalidateFilterContentCache() { _filterContentDataCache.clear(); _filterCacheAllHistoryItems = null; @@ -474,6 +524,54 @@ class _QueueTabState extends ConsumerState { return unified; } + Widget _buildLibraryScrollButtons( + BuildContext context, + double bottomPadding, + ) { + final visible = + _showLibraryScrollTopButton || _showLibraryScrollBottomButton; + final colorScheme = Theme.of(context).colorScheme; + final bottomOffset = + bottomPadding + + (_isSelectionMode || _isPlaylistSelectionMode ? 104 : 20); + + return Positioned( + right: 16, + bottom: bottomOffset, + child: IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_showLibraryScrollTopButton) + _LibraryScrollButton( + icon: Icons.keyboard_arrow_up, + colorScheme: colorScheme, + onPressed: () { + unawaited(_scrollLibraryToTop()); + }, + ), + if (_showLibraryScrollTopButton && _showLibraryScrollBottomButton) + const SizedBox(height: 8), + if (_showLibraryScrollBottomButton) + _LibraryScrollButton( + icon: Icons.keyboard_arrow_down, + colorScheme: colorScheme, + onPressed: () { + unawaited(_scrollLibraryToBottom()); + }, + ), + ], + ), + ), + ), + ); + } + Set _downloadedPathKeys(List historyItems) { if (identical(historyItems, _cachedDownloadedPathKeysSource)) { return _cachedDownloadedPathKeys; @@ -700,6 +798,7 @@ class _QueueTabState extends ConsumerState { void _onFilterPageChanged(int index) { HapticFeedback.selectionClick(); final filterMode = _filterModes[index]; + _resetLibraryScrollButtons(); ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); } @@ -711,6 +810,30 @@ class _QueueTabState extends ConsumerState { ); } + Future _scrollLibraryToTop() async { + final filterMode = ref.read(settingsProvider).historyFilterMode; + final context = _libraryScrollTargets[filterMode]?.topKey.currentContext; + if (context == null) return; + await Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 450), + curve: Curves.easeOutCubic, + alignment: 0, + ); + } + + Future _scrollLibraryToBottom() async { + final filterMode = ref.read(settingsProvider).historyFilterMode; + final context = _libraryScrollTargets[filterMode]?.bottomKey.currentContext; + if (context == null) return; + await Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 550), + curve: Curves.easeOutCubic, + alignment: 1, + ); + } + void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { @@ -2799,6 +2922,7 @@ class _QueueTabState extends ConsumerState { ), ), ), // ScrollConfiguration + _buildLibraryScrollButtons(context, bottomPadding), ], ), ); @@ -3420,8 +3544,12 @@ class _QueueTabState extends ConsumerState { } } + final scrollTargets = _scrollTargetsForFilter(filterMode); final content = CustomScrollView( slivers: [ + SliverToBoxAdapter( + child: SizedBox(key: scrollTargets.topKey, height: 0), + ), if (totalTrackCount > 0 && filterMode == 'all') SliverToBoxAdapter( child: Padding( @@ -3851,7 +3979,10 @@ class _QueueTabState extends ConsumerState { (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty))) SliverToBoxAdapter( - child: SizedBox(height: _isSelectionMode ? 100 : 16), + child: SizedBox( + key: scrollTargets.bottomKey, + height: _isSelectionMode ? 100 : 16, + ), ), ], ); @@ -6384,6 +6515,42 @@ class _AnimatedLibrarySliverGrid extends StatefulWidget { _AnimatedLibrarySliverGridState(); } +class _LibraryScrollTargets { + final GlobalKey topKey = GlobalKey(); + final GlobalKey bottomKey = GlobalKey(); +} + +class _LibraryScrollButton extends StatelessWidget { + final IconData icon; + final ColorScheme colorScheme; + final VoidCallback onPressed; + + const _LibraryScrollButton({ + required this.icon, + required this.colorScheme, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: colorScheme.surfaceContainerHigh, + elevation: 3, + shadowColor: colorScheme.shadow.withValues(alpha: 0.18), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: SizedBox.square( + dimension: 44, + child: Icon(icon, color: colorScheme.onSurface), + ), + ), + ); + } +} + class _AnimatedLibrarySliverGridState extends State<_AnimatedLibrarySliverGrid> with SingleTickerProviderStateMixin { late final AnimationController _controller;