diff --git a/lib/screens/libraries/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart index 9a74f553..11e436bc 100644 --- a/lib/screens/libraries/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../focus/key_event_utils.dart'; import '../../media/library_first_character.dart'; import 'alpha_jump_helper.dart'; @@ -143,6 +144,13 @@ class _AlphaJumpBarState extends State { } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { + final selectResult = handleOneShotSelect(event, () { + if (_highlightedIndex < _displayed.length) { + _jumpToLetter(_displayed[_highlightedIndex]); + } + }); + if (selectResult != KeyEventResult.ignored) return selectResult; + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } @@ -171,7 +179,6 @@ class _AlphaJumpBarState extends State { widget.onBack?.call(); return KeyEventResult.handled; } - return KeyEventResult.ignored; } diff --git a/lib/screens/libraries/library_alpha_scroll_metrics.dart b/lib/screens/libraries/library_alpha_scroll_metrics.dart new file mode 100644 index 00000000..84615795 --- /dev/null +++ b/lib/screens/libraries/library_alpha_scroll_metrics.dart @@ -0,0 +1,51 @@ +/// Scroll geometry used by the library alpha jump bar. +/// +/// The browse tab can render either a fixed-grid layout or a one-column list. +/// This small value object keeps the shared index/offset math out of the +/// widget state so both modes use the same clamping behaviour. +class LibraryAlphaScrollMetrics { + final int columnCount; + final double rowHeight; + final double itemWidth; + final double itemHeight; + + const LibraryAlphaScrollMetrics({ + required this.columnCount, + required this.rowHeight, + required this.itemWidth, + required this.itemHeight, + }); + + static const empty = LibraryAlphaScrollMetrics(columnCount: 1, rowHeight: 0, itemWidth: 0, itemHeight: 0); + + bool get isUsable => columnCount > 0 && rowHeight > 0; + + LibraryAlphaScrollMetrics copyWith({int? columnCount, double? rowHeight, double? itemWidth, double? itemHeight}) { + return LibraryAlphaScrollMetrics( + columnCount: columnCount ?? this.columnCount, + rowHeight: rowHeight ?? this.rowHeight, + itemWidth: itemWidth ?? this.itemWidth, + itemHeight: itemHeight ?? this.itemHeight, + ); + } + + int itemIndexFromScrollOffset(double offset, {required double contentStartOffset, required int totalSize}) { + if (!isUsable) return 0; + final contentOffset = (offset - contentStartOffset).clamp(0.0, double.infinity); + final row = (contentOffset / rowHeight).floor(); + final maxIndex = totalSize > 0 ? totalSize - 1 : 0; + return (row * columnCount).clamp(0, maxIndex); + } + + double scrollOffsetForItemIndex(int index, {required double contentStartOffset}) { + if (!isUsable) return contentStartOffset; + final targetRow = index ~/ columnCount; + return contentStartOffset + targetRow * rowHeight; + } + + int visibleItemCount(double viewportHeight) { + if (!isUsable || !viewportHeight.isFinite) return 0; + final visibleRows = (viewportHeight / rowHeight).ceil() + 1; + return visibleRows * columnCount; + } +} diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index d304d49a..fb8a93da 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -27,11 +27,13 @@ import '../alpha_jump_bar.dart'; import '../alpha_jump_helper.dart'; import '../alpha_scroll_handle.dart'; import '../library_alpha_bar_strategy.dart'; +import '../library_alpha_scroll_metrics.dart'; import '../library_filter_sort_loader.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/loading_indicator_box.dart'; import '../../../widgets/media_grid_delegate.dart'; +import '../../../widgets/media_card_list_layout.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; import '../folder_tree_view.dart'; @@ -195,9 +197,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> _jellyfinFilterValues = const {}; final ValueNotifier _currentFirstVisibleIndex = ValueNotifier(0); - int _currentColumnCount = 1; - double _lastCrossAxisExtent = 0; + LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty; double _effectiveTopPadding = _gridTopPadding; + final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item'); + double? _measuredListRowHeight; + int? _listMetricsDensity; + bool? _listMetricsUsesWideRatio; final FocusNode _alphaJumpBarFocusNode = FocusNode(debugLabel: 'alpha_jump_bar'); // 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). @@ -360,6 +365,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? totalSize - 1 : 0; - final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex); + final lastInRow = (firstInRow + _scrollMetrics.columnCount - 1).clamp(0, maxIndex); if (lastInRow != _currentFirstVisibleIndex.value) { _currentFirstVisibleIndex.value = lastInRow; } @@ -967,20 +976,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? totalSize - 1 : 0; - return (row * _currentColumnCount).clamp(0, maxIndex); + return _scrollMetrics.itemIndexFromScrollOffset( + offset, + contentStartOffset: _contentStartScrollOffset, + totalSize: totalSize, + ); } + double get _contentStartScrollOffset => _chipsBarHeight + _effectiveTopPadding; + /// Handle a tap on the letter at [targetIndex] in the alpha bar. The /// active [LibraryAlphaBarStrategy] owns the per-backend behaviour and /// invokes one of the two callbacks — Plex scrolls the grid to the @@ -996,7 +1000,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState items) { final pos = _innerPosition; - if (pos == null || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return; + if (pos == null || !_scrollMetrics.isUsable) return; final offset = pos.pixels; final viewportHeight = pos.viewportDimension; if (!viewportHeight.isFinite) return; final firstVisible = _itemIndexFromScrollOffset(offset); - final itemWidth = _lastCrossAxisExtent / _currentColumnCount; - final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio; - final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing; - if (rowHeight <= 0) return; + final itemWidth = _scrollMetrics.itemWidth; + final itemHeight = _scrollMetrics.itemHeight; + if (itemWidth <= 0 || itemHeight <= 0) return; - final visibleRows = (viewportHeight / rowHeight).ceil() + 1; - final visibleEnd = firstVisible + visibleRows * _currentColumnCount; + final visibleEnd = firstVisible + _scrollMetrics.visibleItemCount(viewportHeight); // Prefetch 2 rows beyond visible area - final prefetchEnd = visibleEnd + 2 * _currentColumnCount; + final prefetchEnd = visibleEnd + 2 * _scrollMetrics.columnCount; final client = getMediaClientForLibrary(); final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context); @@ -1414,6 +1408,42 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _measureFirstListRowHeight()); + return KeyedSubtree(key: _firstListItemKey, child: child); + } + + void _measureFirstListRowHeight() { + if (!mounted) return; + if (SettingsService.instanceOrNull?.read(SettingsService.viewMode) != ViewMode.list) return; + final height = (_firstListItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height; + if (height == null || height <= 0) return; + if ((_measuredListRowHeight ?? 0) == height) return; + _measuredListRowHeight = height; + _scrollMetrics = _scrollMetrics.copyWith(rowHeight: height); + _updateVisibleIndex(); + } + /// Builds either a sliver list or sliver grid based on the view mode Widget _buildItemsSliver(BuildContext context) { final svc = SettingsService.instanceOrNull!; @@ -1426,26 +1456,36 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( - index, - isFirstRow: index == 0, - isFirstColumn: true, // List view = single column - disableScale: true, - columnCount: 1, - itemCount: itemCount, - ), + sliver: SliverLayoutBuilder( + builder: (context, _) { + _setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio); + return SliverList.builder( + itemCount: itemCount, + itemBuilder: (context, index) { + final child = _buildMediaCardItem( + index, + isFirstRow: index == 0, + isFirstColumn: true, // List view = single column + isLastColumn: true, + disableScale: true, + columnCount: 1, + itemCount: itemCount, + ); + return index == 0 ? _buildMeasuredFirstListItem(child) : child; + }, + ); + }, ), ); } else { // In grid view, calculate columns and pass to item builder // Use 16:9 aspect ratio when browsing episodes with episode thumbnail mode - final useWideRatio = _selectedGrouping == 'episodes' && episodePosterMode == EpisodePosterMode.episodeThumbnail; final baseMaxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity); final effectiveMaxExtent = useWideRatio ? baseMaxExtent * 1.8 : baseMaxExtent; final hasAlphaBarReservation = rightPadding > 8.0; @@ -1459,8 +1499,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState + MediaCardListLayout.posterWidth(density: density, usesWideAspectRatio: _usesWideAspectRatio()); - double _posterHeight(BuildContext context) { - final base = _basePosterWidth(); - // For episodes with thumbnail mode, use 16:9 aspect ratio - if (item is MediaItem) { - final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); - if ((item as MediaItem).usesWideAspectRatio(mode)) { - // 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9 - return base * 0.9; - } - } - return base * 1.5; // Default 2:3 aspect ratio - } + double _posterHeight(BuildContext context) => + MediaCardListLayout.posterHeight(density: density, usesWideAspectRatio: _usesWideAspectRatio()); double get _titleFontSize => 13 + LibraryDensity.factor(density) * 3; // 13–16 diff --git a/lib/widgets/media_card_list_layout.dart b/lib/widgets/media_card_list_layout.dart new file mode 100644 index 00000000..8e65471b --- /dev/null +++ b/lib/widgets/media_card_list_layout.dart @@ -0,0 +1,25 @@ +import '../services/settings_service.dart' show LibraryDensity; + +/// Shared sizing math for media cards rendered in list mode. +class MediaCardListLayout { + static const double padding = 8.0; + + static double basePosterWidth(int density) { + return 70 + LibraryDensity.factor(density) * 50; + } + + static double posterWidth({required int density, required bool usesWideAspectRatio}) { + final base = basePosterWidth(density); + return usesWideAspectRatio ? base * 1.6 : base; + } + + static double posterHeight({required int density, required bool usesWideAspectRatio}) { + final base = basePosterWidth(density); + return usesWideAspectRatio ? base * 0.9 : base * 1.5; + } + + static double estimatedRowHeight({required int density, required bool usesWideAspectRatio}) { + final poster = posterHeight(density: density, usesWideAspectRatio: usesWideAspectRatio); + return poster + padding * 2; + } +} diff --git a/test/screens/libraries/alpha_jump_bar_test.dart b/test/screens/libraries/alpha_jump_bar_test.dart new file mode 100644 index 00000000..231f6c65 --- /dev/null +++ b/test/screens/libraries/alpha_jump_bar_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/library_first_character.dart'; +import 'package:plezy/screens/libraries/alpha_jump_bar.dart'; + +void main() { + testWidgets('Enter jumps to the highlighted letter', (tester) async { + final focusNode = FocusNode(debugLabel: 'test_alpha_jump_bar'); + addTearDown(focusNode.dispose); + + int? jumpedTo; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 300, + child: AlphaJumpBar( + firstCharacters: const [ + LibraryFirstCharacter(key: 'A', title: 'A', size: 3), + LibraryFirstCharacter(key: 'B', title: 'B', size: 4), + LibraryFirstCharacter(key: 'C', title: 'C', size: 2), + ], + currentLetter: 'B', + focusNode: focusNode, + onJump: (index) => jumpedTo = index, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + expect(jumpedTo, 3); + }); +} diff --git a/test/screens/libraries/library_alpha_scroll_metrics_test.dart b/test/screens/libraries/library_alpha_scroll_metrics_test.dart new file mode 100644 index 00000000..10d6a478 --- /dev/null +++ b/test/screens/libraries/library_alpha_scroll_metrics_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/libraries/library_alpha_scroll_metrics.dart'; + +void main() { + test('maps scroll offsets to item indices by row and column count', () { + const metrics = LibraryAlphaScrollMetrics(columnCount: 4, rowHeight: 100, itemWidth: 150, itemHeight: 90); + + expect(metrics.itemIndexFromScrollOffset(0, contentStartOffset: 50, totalSize: 40), 0); + expect(metrics.itemIndexFromScrollOffset(149, contentStartOffset: 50, totalSize: 40), 0); + expect(metrics.itemIndexFromScrollOffset(150, contentStartOffset: 50, totalSize: 40), 4); + expect(metrics.itemIndexFromScrollOffset(1050, contentStartOffset: 50, totalSize: 40), 39); + }); + + test('maps item indices back to scroll offsets', () { + const metrics = LibraryAlphaScrollMetrics(columnCount: 4, rowHeight: 100, itemWidth: 150, itemHeight: 90); + + expect(metrics.scrollOffsetForItemIndex(0, contentStartOffset: 50), 50); + expect(metrics.scrollOffsetForItemIndex(3, contentStartOffset: 50), 50); + expect(metrics.scrollOffsetForItemIndex(4, contentStartOffset: 50), 150); + expect(metrics.scrollOffsetForItemIndex(9, contentStartOffset: 50), 250); + }); + + test('counts visible items using one extra trailing row', () { + const metrics = LibraryAlphaScrollMetrics(columnCount: 3, rowHeight: 100, itemWidth: 150, itemHeight: 90); + + expect(metrics.visibleItemCount(250), 12); + }); +}