feat: alpha jump bar

close #349
This commit is contained in:
edde746
2026-02-05 10:15:59 +01:00
parent 2b5ccc28cb
commit 5c225bb89e
11 changed files with 896 additions and 132 deletions
@@ -6,10 +6,13 @@ import '../../../focus/dpad_navigator.dart';
import '../../../../services/plex_client.dart';
import '../../../models/plex_metadata.dart';
import '../../../models/plex_filter.dart';
import '../../../models/plex_first_character.dart';
import '../../../models/plex_sort.dart';
import '../../../providers/settings_provider.dart';
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/focusable_media_card.dart';
import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/media_grid_delegate.dart';
@@ -73,6 +76,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
bool _isSortDescending = false;
String _selectedGrouping = 'all'; // all, seasons, episodes, folders
// Alpha jump bar state
List<PlexFirstCharacter> _firstCharacters = [];
int _currentFirstVisibleIndex = 0;
int _currentColumnCount = 1;
double _lastCrossAxisExtent = 0;
final FocusNode _alphaJumpBarFocusNode = FocusNode(debugLabel: 'alpha_jump_bar');
// When the user taps a letter, pin that index so scroll-based recalculation
// doesn't immediately override it (e.g. when the letter has fewer items than a full row).
int? _pinnedJumpIndex;
// 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;
// Pagination state
int _currentPage = 0;
bool _hasMoreItems = true;
@@ -88,13 +106,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Scroll controller for the CustomScrollView
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScrollChanged);
}
@override
void dispose() {
_cancelToken?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
_groupingChipFocusNode.dispose();
_filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose();
_alphaJumpBarFocusNode.dispose();
disposeGridFocusNodes();
super.dispose();
}
@@ -183,6 +209,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_selectedSort = null;
_isSortDescending = false;
_selectedGrouping = _getDefaultGrouping();
_firstCharacters = [];
_currentFirstVisibleIndex = 0;
});
try {
@@ -219,8 +247,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
});
// Load items
await _loadItems();
// Load items and first characters in parallel
await Future.wait([_loadItems(), _loadFirstCharacters()]);
} catch (e) {
_handleLoadError(e, currentRequestId);
}
@@ -419,6 +447,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
storage.saveLibraryGrouping(widget.library.globalKey, pendingGrouping);
});
_loadItems();
_loadFirstCharacters();
});
}
@@ -442,6 +471,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
_loadItems();
_loadFirstCharacters();
},
),
);
@@ -480,6 +510,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_isSortDescending = false;
});
_loadItems();
_loadFirstCharacters();
} else if (pendingSort != null &&
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
setState(() {
@@ -490,6 +521,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
});
_loadItems();
_loadFirstCharacters();
}
});
}
@@ -519,6 +551,146 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
MainScreenFocusScope.of(context)?.focusSidebar();
}
/// Navigate focus to the alpha jump bar
void _navigateToAlphaJumpBar() {
_alphaJumpBarFocusNode.requestFocus();
}
/// Whether the alpha jump bar should be shown.
/// Only shown when sorting by title (titleSort) and not in folders mode.
bool get _shouldShowAlphaJumpBar {
if (_selectedGrouping == 'folders') return false;
if (_firstCharacters.isEmpty) return false;
// Show when no sort is selected (default is titleSort) or when explicitly sorting by title
final sortKey = _selectedSort?.key ?? '';
return sortKey.isEmpty || sortKey.startsWith('titleSort');
}
/// Fetch first characters for the current library/filter state
Future<void> _loadFirstCharacters() async {
final client = getClientForLibrary();
final filterParams = Map<String, String>.from(_selectedFilters);
final typeId = _getGroupingTypeId();
try {
final chars = await client.getFirstCharacters(
widget.library.key,
type: typeId.isNotEmpty ? int.tryParse(typeId) : null,
filters: filterParams.isNotEmpty ? filterParams : null,
);
if (mounted) {
setState(() => _firstCharacters = chars);
}
} catch (_) {
// Non-critical — hide the bar on failure
if (mounted) {
setState(() => _firstCharacters = []);
}
}
}
/// Track scroll position to highlight the current letter in the jump bar
void _onScrollChanged() {
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
// During a jump animation, skip all processing to avoid flashing.
if (_isJumpScrolling) return;
// 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;
}
_updateVisibleIndex();
}
/// Recompute the first-visible-index from the current scroll offset.
void _updateVisibleIndex() {
final offset = _scrollController.offset;
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.
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, items.length - 1);
if (lastInRow != _currentFirstVisibleIndex) {
setState(() => _currentFirstVisibleIndex = lastInRow);
}
}
/// Compute the first visible item index from a scroll offset.
/// The visible area starts below the chips bar, so we offset accordingly.
int _itemIndexFromScrollOffset(double offset) {
if (_lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return 0;
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
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);
final row = (contentOffset / rowHeight).floor();
return (row * _currentColumnCount).clamp(0, items.length - 1);
}
/// 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.
void _jumpToIndex(int targetIndex) {
_jumpScrollGeneration++;
_isJumpScrolling = true;
_pinnedJumpIndex = targetIndex;
setState(() => _currentFirstVisibleIndex = targetIndex);
if (targetIndex < items.length) {
_scrollToItemIndex(targetIndex);
} else {
_loadUntilIndex(targetIndex);
}
}
/// Scroll the grid so that [index] is visible just below the chips bar
void _scrollToItemIndex(int index) {
if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || !_scrollController.hasClients) {
_isJumpScrolling = false;
return;
}
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
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 = _gridTopPadding + targetRow * rowHeight - _chipsBarHeight;
final gen = _jumpScrollGeneration;
_scrollController
.animateTo(
offset.clamp(0.0, _scrollController.position.maxScrollExtent),
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;
}
});
}
/// Load pages until [targetIndex] is loaded, then scroll to it
Future<void> _loadUntilIndex(int targetIndex) async {
while (items.length <= targetIndex && _hasMoreItems) {
await _loadItems(loadMore: true);
}
if (mounted) {
_scrollToItemIndex(targetIndex.clamp(0, items.length - 1));
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
@@ -548,6 +720,21 @@ 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
if (_shouldShowAlphaJumpBar)
Positioned(
top: _chipsBarHeight,
right: 0,
bottom: 0,
child: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGrid,
onBack: widget.onBack,
),
),
],
);
}
@@ -677,14 +864,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Chips bar is ~48px, focus ring extends ~6px beyond item bounds
static const double _gridTopPadding = _chipsBarHeight + 12.0;
/// Width of the alpha jump bar widget
static const double _alphaJumpBarWidth = 28.0;
/// 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;
if (settingsProvider.viewMode == ViewMode.list) {
// In list view, all items are in a single column (first column)
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, _gridTopPadding, 8, 8),
padding: EdgeInsets.fromLTRB(8, _gridTopPadding, rightPadding, 8),
sliver: SliverList.builder(
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
@@ -701,10 +892,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_selectedGrouping == 'episodes' && settingsProvider.episodePosterMode == EpisodePosterMode.episodeThumbnail;
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, _gridTopPadding, 8, 8),
padding: EdgeInsets.fromLTRB(8, _gridTopPadding, rightPadding, 8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent);
// Cache grid metrics for alpha jump bar scroll calculations
_lastCrossAxisExtent = constraints.crossAxisExtent;
_currentColumnCount = columnCount;
return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
@@ -716,6 +910,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
index,
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
isLastColumn: (index % columnCount) == (columnCount - 1),
),
);
},
@@ -724,7 +919,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
Widget _buildMediaCardItem(int index, {required bool isFirstRow, required bool isFirstColumn}) {
Widget _buildMediaCardItem(
int index, {
required bool isFirstRow,
required bool isFirstColumn,
bool isLastColumn = false,
}) {
if (index >= items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
@@ -744,6 +944,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onRefresh: updateItem,
onNavigateUp: isFirstRow ? _navigateToChips : null,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
onNavigateRight: isLastColumn && _shouldShowAlphaJumpBar ? _navigateToAlphaJumpBar : null,
onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems,