fix(plex): repair alpha jump bar
This commit is contained in:
@@ -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<AlphaJumpBar> {
|
||||
}
|
||||
|
||||
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<AlphaJumpBar> {
|
||||
widget.onBack?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<MediaItem, LibraryBrows
|
||||
/// doesn't need to call back into a Plex client for value listings.
|
||||
Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {};
|
||||
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(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<MediaItem, LibraryBrows
|
||||
_isJumpScrolling = false;
|
||||
_jumpScrollGeneration++;
|
||||
_currentFirstVisibleIndex.value = 0;
|
||||
_measuredListRowHeight = null;
|
||||
|
||||
// The browse tab state is kept alive across libraries, so ensure this
|
||||
// tab's scroll resets to 0 (other tabs keep their own positions). Defer
|
||||
@@ -462,6 +468,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_selectedGrouping = _getDefaultGrouping();
|
||||
_firstCharacters = [];
|
||||
_alphaHelper = AlphaJumpHelper(const []);
|
||||
_scrollMetrics = LibraryAlphaScrollMetrics.empty;
|
||||
_measuredListRowHeight = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -799,10 +807,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// FocusNode detached), so we target the last-column item in the first
|
||||
/// visible row — the grid cell closest to the alpha bar.
|
||||
void _navigateToGridNearScroll() {
|
||||
if (totalSize == 0 || _currentColumnCount < 1) return;
|
||||
final columnCount = _scrollMetrics.columnCount;
|
||||
if (totalSize == 0 || columnCount < 1) return;
|
||||
|
||||
final row = _currentFirstVisibleIndex.value ~/ _currentColumnCount;
|
||||
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, totalSize - 1);
|
||||
final row = _currentFirstVisibleIndex.value ~/ columnCount;
|
||||
var targetIndex = ((row + 1) * columnCount - 1).clamp(0, totalSize - 1);
|
||||
|
||||
// Find nearest loaded item — skeleton cards have no FocusNode
|
||||
if (!loadedItems.containsKey(targetIndex)) {
|
||||
@@ -923,7 +932,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
prefetchAhead(range.firstIndex, range.visibleCount, pageSize: _fetchSize);
|
||||
}
|
||||
|
||||
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
|
||||
if (!_shouldShowAlphaJumpBar || !_scrollMetrics.isUsable) return;
|
||||
|
||||
// During a jump animation, skip alpha bar processing to avoid flashing.
|
||||
if (_isJumpScrolling) return;
|
||||
@@ -957,7 +966,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// 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 maxIndex = totalSize > 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<MediaItem, LibraryBrows
|
||||
/// Chips bar is the first sliver (height = _chipsBarHeight) followed by the
|
||||
/// grid's own top padding before the first row.
|
||||
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;
|
||||
|
||||
// Items start at `_chipsBarHeight + _effectiveTopPadding` in scroll coords.
|
||||
final contentOffset = (offset - _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
|
||||
final row = (contentOffset / rowHeight).floor();
|
||||
final maxIndex = totalSize > 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<MediaItem, LibraryBrows
|
||||
);
|
||||
}
|
||||
|
||||
/// Scroll the grid so the item at [targetIndex] becomes the first visible
|
||||
/// Scroll the current layout so the item at [targetIndex] becomes the first visible
|
||||
/// row. Used by [PlexAlphaBarStrategy] via [_jumpToIndex].
|
||||
void _scrollGridToIndex(int targetIndex) {
|
||||
_jumpScrollGeneration++;
|
||||
@@ -1025,21 +1029,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_loadItems();
|
||||
}
|
||||
|
||||
/// Scroll the grid so that [index] is visible just below the chips bar
|
||||
/// Scroll the current layout so that [index] is visible just below the chips bar
|
||||
void _scrollToItemIndex(int index) {
|
||||
final pos = _innerPosition;
|
||||
if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || pos == null) {
|
||||
if (!_scrollMetrics.isUsable || pos == null) {
|
||||
_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 at the top of the viewport. Chips and the grid's
|
||||
// top padding both precede the items in scroll coordinates.
|
||||
final offset = _chipsBarHeight + _effectiveTopPadding + targetRow * rowHeight;
|
||||
final offset = _scrollMetrics.scrollOffsetForItemIndex(index, contentStartOffset: _contentStartScrollOffset);
|
||||
|
||||
final gen = _jumpScrollGeneration;
|
||||
final maxExtent = pos.maxScrollExtent;
|
||||
@@ -1201,21 +1201,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
|
||||
/// Returns the first-visible index and visible count from the scroll
|
||||
/// position + grid metrics, or null if the viewport isn't measured yet.
|
||||
/// position + layout metrics, or null if the viewport isn't measured yet.
|
||||
({int firstIndex, int visibleCount})? _computeVisibleRange() {
|
||||
final pos = _innerPosition;
|
||||
if (_currentColumnCount < 1 || pos == null || _lastCrossAxisExtent <= 0) return null;
|
||||
if (!_scrollMetrics.isUsable || pos == null) return null;
|
||||
final offset = pos.pixels;
|
||||
final viewportHeight = pos.viewportDimension;
|
||||
if (!viewportHeight.isFinite) return null;
|
||||
|
||||
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
|
||||
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
|
||||
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
|
||||
if (rowHeight <= 0) return null;
|
||||
|
||||
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
|
||||
return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleRows * _currentColumnCount);
|
||||
final visibleCount = _scrollMetrics.visibleItemCount(viewportHeight);
|
||||
if (visibleCount <= 0) return null;
|
||||
return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleCount);
|
||||
}
|
||||
|
||||
/// Compute initial fetch size based on viewport dimensions.
|
||||
@@ -1241,21 +1237,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// Prefetch images for items near the viewport to reduce pop-in.
|
||||
void _prefetchImages(int startIndex, List<MediaItem> 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<MediaItem, LibraryBrows
|
||||
/// Width of the alpha jump bar widget
|
||||
static const double _alphaJumpBarWidth = 20.0;
|
||||
|
||||
void _setListScrollMetrics({required int density, required bool usesWideAspectRatio}) {
|
||||
if (_listMetricsDensity != density || _listMetricsUsesWideRatio != usesWideAspectRatio) {
|
||||
_measuredListRowHeight = null;
|
||||
_listMetricsDensity = density;
|
||||
_listMetricsUsesWideRatio = usesWideAspectRatio;
|
||||
}
|
||||
|
||||
final itemWidth = MediaCardListLayout.posterWidth(density: density, usesWideAspectRatio: usesWideAspectRatio);
|
||||
final itemHeight = MediaCardListLayout.posterHeight(density: density, usesWideAspectRatio: usesWideAspectRatio);
|
||||
final rowHeight =
|
||||
_measuredListRowHeight ??
|
||||
MediaCardListLayout.estimatedRowHeight(density: density, usesWideAspectRatio: usesWideAspectRatio);
|
||||
_scrollMetrics = LibraryAlphaScrollMetrics(
|
||||
columnCount: 1,
|
||||
rowHeight: rowHeight,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemHeight,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMeasuredFirstListItem(Widget child) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _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<MediaItem, LibraryBrows
|
||||
_effectiveTopPadding = topPadding;
|
||||
final rightPadding = _shouldShowAlphaJumpBar && !isPhone ? _alphaJumpBarWidth : 8.0;
|
||||
|
||||
final useWideRatio = _selectedGrouping == 'episodes' && episodePosterMode == EpisodePosterMode.episodeThumbnail;
|
||||
|
||||
if (viewMode == ViewMode.list) {
|
||||
// In list view, all items are in a single column (first column)
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => _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<MediaItem, LibraryBrows
|
||||
final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(baselineWidth, effectiveMaxExtent);
|
||||
// Cache grid metrics for alpha jump bar scroll calculations
|
||||
_lastCrossAxisExtent = constraints.crossAxisExtent;
|
||||
_currentColumnCount = columnCount;
|
||||
final itemWidth = constraints.crossAxisExtent / columnCount;
|
||||
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
|
||||
_scrollMetrics = LibraryAlphaScrollMetrics(
|
||||
columnCount: columnCount,
|
||||
rowHeight: itemHeight + GridLayoutConstants.mainAxisSpacing,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemHeight,
|
||||
);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../theme/mono_tokens.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'media_progress_bar.dart';
|
||||
import 'media_card_list_layout.dart';
|
||||
import 'optimized_media_image.dart';
|
||||
|
||||
class MediaCard extends StatefulWidget {
|
||||
@@ -341,34 +342,17 @@ class _MediaCardList extends StatelessWidget {
|
||||
this.showServerName = false,
|
||||
});
|
||||
|
||||
double _basePosterWidth() {
|
||||
return 70 + LibraryDensity.factor(density) * 50; // 70–120
|
||||
bool _usesWideAspectRatio() {
|
||||
if (item is! MediaItem) return false;
|
||||
final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
|
||||
return (item as MediaItem).usesWideAspectRatio(mode);
|
||||
}
|
||||
|
||||
double _posterWidth(BuildContext context) {
|
||||
final base = _basePosterWidth();
|
||||
// For episodes with thumbnail mode, use wider width to maintain reasonable thumbnail size
|
||||
if (item is MediaItem) {
|
||||
final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
|
||||
if ((item as MediaItem).usesWideAspectRatio(mode)) {
|
||||
return base * 1.6; // Wider for 16:9 thumbnails
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
double _posterWidth(BuildContext context) =>
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user