diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index feba390a..5d02adb3 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -13,6 +15,8 @@ import '../../../utils/error_message_utils.dart'; import '../../../utils/grid_size_calculator.dart'; import '../../../utils/layout_constants.dart'; import '../../../widgets/alpha_jump_bar.dart'; +import '../../../widgets/alpha_jump_helper.dart'; +import '../../../widgets/alpha_scroll_handle.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/media_grid_delegate.dart'; @@ -25,6 +29,7 @@ import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode; import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/item_updatable.dart'; +import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; import 'base_library_tab.dart'; @@ -78,19 +83,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _firstCharacters = []; + AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []); int _currentFirstVisibleIndex = 0; int _currentColumnCount = 1; double _lastCrossAxisExtent = 0; + double _effectiveTopPadding = _gridTopPadding; final FocusNode _alphaJumpBarFocusNode = FocusNode(debugLabel: 'alpha_jump_bar'); - // When the user taps a letter, pin that index so scroll-based recalculation + // When the user taps a letter, pin the highlight so scroll-based recalculation // doesn't immediately override it (e.g. when the letter has fewer items than a full row). - int? _pinnedJumpIndex; + bool _hasJumpPin = false; // True while a jump-triggered animateTo is in progress — suppresses all // scroll-based letter recalculation to prevent flashing. bool _isJumpScrolling = false; // Incremented on each jump so that overlapping animations don't clobber each other. int _jumpScrollGeneration = 0; + // Scroll activity tracking (for phone scroll handle) + bool _isScrollActive = false; + Timer? _scrollActivityTimer; + // Pagination state int _currentPage = 0; bool _hasMoreItems = true; @@ -115,6 +126,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState PlatformDetector.isPhone(context); + /// Whether the alpha jump bar should be shown. /// Only shown when sorting by title (titleSort) and not in folders mode. bool get _shouldShowAlphaJumpBar { @@ -579,12 +595,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _firstCharacters = chars); + setState(() { + _firstCharacters = chars; + _alphaHelper = AlphaJumpHelper(chars); + }); } } catch (_) { // Non-critical — hide the bar on failure if (mounted) { - setState(() => _firstCharacters = []); + setState(() { + _firstCharacters = []; + _alphaHelper = AlphaJumpHelper(const []); + }); } } } @@ -599,8 +621,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _currentFirstVisibleIndex = targetIndex); - if (targetIndex < items.length) { - _scrollToItemIndex(targetIndex); + // Determine the intended letter from the helper's model + final targetLetter = _alphaHelper.currentLetter(targetIndex); + + // Correct the index using actual items' titleSort when available + final correctedIndex = _findFirstItemForLetter(targetLetter) ?? targetIndex; + + _hasJumpPin = true; + setState(() => _currentFirstVisibleIndex = correctedIndex); + + if (correctedIndex < items.length) { + _scrollToItemIndex(correctedIndex); } else { - _loadUntilIndex(targetIndex); + _loadUntilIndex(correctedIndex); } } + /// Returns the first character (A-Z or #) of an item's sort title. + static String _sortTitleFirstChar(String title) { + if (title.isEmpty) return '#'; + final ch = title[0].toUpperCase(); + return ch.codeUnitAt(0) >= 65 && ch.codeUnitAt(0) <= 90 ? ch : '#'; + } + + /// Find the index of the first loaded item whose `titleSort` starts with + /// [letter]. Returns null if no match is found (e.g. items not yet loaded). + int? _findFirstItemForLetter(String letter) { + for (int i = 0; i < items.length; i++) { + final sortTitle = items[i].titleSort ?? items[i].title; + if (_sortTitleFirstChar(sortTitle) == letter) return i; + } + return null; + } + /// Scroll the grid so that [index] is visible just below the chips bar void _scrollToItemIndex(int index) { if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || !_scrollController.hasClients) { @@ -664,7 +716,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) { _loadItems(loadMore: true); } + // Track scroll activity for phone scroll handle + if (notification is ScrollStartNotification) { + if (!_isScrollActive) setState(() => _isScrollActive = true); + _scrollActivityTimer?.cancel(); + } else if (notification is ScrollEndNotification) { + _scrollActivityTimer?.cancel(); + _scrollActivityTimer = Timer(const Duration(milliseconds: 100), () { + if (mounted) setState(() => _isScrollActive = false); + }); + } return false; }, child: CustomScrollView( @@ -860,9 +929,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( @@ -892,7 +966,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState trackGridItemFocus(index, hasFocus), onListRefresh: _loadItems,