fix: missing alpha jump
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user