fix: missing alpha jump

This commit is contained in:
edde746
2026-02-05 10:43:58 +01:00
parent 5c225bb89e
commit 5873914baa
@@ -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<PlexMetadata, LibraryBr
// Alpha jump bar state
List<PlexFirstCharacter> _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<PlexMetadata, LibraryBr
@override
void dispose() {
_cancelToken?.cancel();
_scrollActivityTimer?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
_groupingChipFocusNode.dispose();
@@ -210,6 +222,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_isSortDescending = false;
_selectedGrouping = _getDefaultGrouping();
_firstCharacters = [];
_alphaHelper = AlphaJumpHelper(const []);
_currentFirstVisibleIndex = 0;
});
@@ -556,6 +569,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_alphaJumpBarFocusNode.requestFocus();
}
/// Whether the device is a phone (not tablet/desktop/TV).
bool _isPhone(BuildContext context) => 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<PlexMetadata, LibraryBr
filters: filterParams.isNotEmpty ? filterParams : null,
);
if (mounted) {
setState(() => _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<PlexMetadata, LibraryBr
// If pinned from a completed jump, the next scroll event must be
// user-initiated (touch drag, mouse wheel, etc.) — clear the pin
// and resume normal tracking.
if (_pinnedJumpIndex != null) {
_pinnedJumpIndex = null;
if (_hasJumpPin) {
_hasJumpPin = false;
}
_updateVisibleIndex();
@@ -629,9 +651,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
if (rowHeight <= 0) return 0;
// The visible area starts at _chipsBarHeight from the viewport top.
// Grid content starts at _gridTopPadding in scroll coordinates.
// First visible row = (offset + chipsBarHeight - gridTopPadding) / rowHeight
final contentOffset = (offset + _chipsBarHeight - _gridTopPadding).clamp(0.0, double.infinity);
// 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);
final row = (contentOffset / rowHeight).floor();
return (row * _currentColumnCount).clamp(0, items.length - 1);
}
@@ -639,19 +661,49 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Scroll to the item at [targetIndex], loading more pages if necessary.
/// Pins the index so scroll events during the animation don't override
/// the highlighted letter. The pin is cleared on the next user scroll.
///
/// The [targetIndex] comes from [AlphaJumpHelper] which derives cumulative
/// indices from the `firstCharacters` API. That API may count by display
/// title (e.g. "The Simpsons" → T), while the grid sorts by `titleSort`
/// (e.g. "Simpsons" → S). We correct for this by searching the loaded
/// items' `titleSort` for the true start of the target letter.
void _jumpToIndex(int targetIndex) {
_jumpScrollGeneration++;
_isJumpScrolling = true;
_pinnedJumpIndex = targetIndex;
setState(() => _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<PlexMetadata, LibraryBr
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
final targetRow = index ~/ _currentColumnCount;
// Position the target row right below the chips bar
final offset = _gridTopPadding + targetRow * rowHeight - _chipsBarHeight;
final offset = _effectiveTopPadding + targetRow * rowHeight - _chipsBarHeight;
final gen = _jumpScrollGeneration;
_scrollController
@@ -720,20 +772,27 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
Positioned.fill(child: _buildScrollableContent()),
// Chips bar on top with solid background
Positioned(top: 0, left: 0, right: 0, child: _buildChipsBar()),
// Alpha jump bar on the right edge
// Alpha jump bar / scroll handle on the right edge
if (_shouldShowAlphaJumpBar)
Positioned(
top: _chipsBarHeight,
right: 0,
bottom: 0,
child: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGrid,
onBack: widget.onBack,
),
child: _isPhone(context)
? AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
isScrolling: _isScrollActive,
)
: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGrid,
onBack: widget.onBack,
),
),
],
);
@@ -746,6 +805,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
if (notification.metrics.pixels >= 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<PlexMetadata, LibraryBr
];
}
// Top padding for grid content = chips bar height + extra space for focus decoration
// Chips bar is ~48px, focus ring extends ~6px beyond item bounds
// 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;
/// Width of the alpha jump bar widget
static const double _alphaJumpBarWidth = 28.0;
@@ -870,12 +941,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0);
final rightPadding = _shouldShowAlphaJumpBar ? _alphaJumpBarWidth : 8.0;
final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
_effectiveTopPadding = topPadding;
final rightPadding = _shouldShowAlphaJumpBar && !isPhone ? _alphaJumpBarWidth : 8.0;
if (settingsProvider.viewMode == ViewMode.list) {
// In list view, all items are in a single column (first column)
return SliverPadding(
padding: EdgeInsets.fromLTRB(8, _gridTopPadding, rightPadding, 8),
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverList.builder(
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
@@ -892,7 +966,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_selectedGrouping == 'episodes' && settingsProvider.episodePosterMode == EpisodePosterMode.episodeThumbnail;
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
return SliverPadding(
padding: EdgeInsets.fromLTRB(8, _gridTopPadding, rightPadding, 8),
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent);
@@ -944,7 +1018,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onRefresh: updateItem,
onNavigateUp: isFirstRow ? _navigateToChips : null,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
onNavigateRight: isLastColumn && _shouldShowAlphaJumpBar ? _navigateToAlphaJumpBar : null,
onNavigateRight: isLastColumn && _shouldShowAlphaJumpBar && !_isPhone(context) ? _navigateToAlphaJumpBar : null,
onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems,