Files
plezy/lib/screens/libraries/tabs/library_browse_tab.dart
T

1724 lines
64 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import '../../../media/library_first_character.dart';
import '../../../media/library_query.dart';
import '../../../media/media_backend.dart';
import '../../../media/media_item.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../exceptions/media_server_exceptions.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/media_filter.dart';
import '../../../media/media_sort.dart';
import '../../../widgets/settings_builder.dart';
import '../../../services/image_cache_service.dart';
import '../../../services/library_query_translator.dart';
import '../../../services/plex_constants.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/media_image_helper.dart';
import '../../../utils/provider_extensions.dart';
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';
import '../filters_bottom_sheet.dart';
import '../sort_bottom_sheet.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../content_state_builder.dart';
import '../../../services/storage_service.dart';
import '../../../services/settings_service.dart';
import '../../../mixins/grid_focus_node_mixin.dart';
import '../../../mixins/item_updatable.dart';
import '../../../mixins/watch_state_aware.dart';
import '../../../mixins/deletion_aware.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../utils/deletion_notifier.dart';
import '../../../utils/global_key_utils.dart';
import '../../../utils/watch_state_notifier.dart';
import '../../../utils/platform_detector.dart';
import '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
/// Browse tab for library screen
/// Shows library items with grouping, filtering, and sorting
class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
/// Invoked whenever the tab resets its inner scroll position to the top
/// (filter/sort change, library reload, etc.). Lets the parent resync the
/// outer floating header — see `_resetOuterScroll` in libraries_screen.
final VoidCallback? onResetScroll;
const LibraryBrowseTab({
super.key,
required super.library,
super.viewMode,
super.density,
super.onDataLoaded,
super.isActive,
super.suppressAutoFocus,
super.onBack,
this.onResetScroll,
});
@override
State<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
}
class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrowseTab>
with
ItemUpdatable,
LibraryTabFocusMixin,
GridFocusNodeMixin,
WatchStateAware,
DeletionAware,
PaginatedItemLoader<LibraryBrowseTab> {
@override
String? get itemServerId => widget.library.serverId;
String _toGlobalKey(String ratingKey, {String? serverId}) =>
buildGlobalKey(serverId ?? widget.library.serverId ?? '', ratingKey);
@override
String? get deletionServerId => widget.library.serverId;
@override
String? get watchStateServerId => widget.library.serverId;
@override
Set<String>? get watchedIds => loadedItems.values.map((e) => e.id).toSet();
@override
Set<String>? get watchedGlobalKeys {
if (loadedItems.isEmpty) return <String>{};
final keys = <String>{};
for (final item in loadedItems.values) {
final serverId = item.serverId ?? widget.library.serverId;
if (serverId == null) return null;
keys.add(_toGlobalKey(item.id, serverId: serverId));
}
return keys;
}
@override
Set<String>? get deletionIds => loadedItems.values.map((e) => e.id).toSet();
@override
Set<String>? get deletionGlobalKeys {
if (loadedItems.isEmpty) return <String>{};
final keys = <String>{};
for (final item in loadedItems.values) {
final serverId = item.serverId ?? widget.library.serverId;
if (serverId == null) return null;
keys.add(_toGlobalKey(item.id, serverId: serverId));
}
return keys;
}
@override
void onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate ||
event.changeType == WatchStateChangeType.removedFromContinueWatching) {
return;
}
final affectedIds = {event.itemId, ...event.parentChain};
for (final item in loadedItems.values) {
if (affectedIds.contains(item.id)) {
unawaited(updateItem(item.id));
}
}
}
@override
void onDeletionEvent(DeletionEvent event) {
// If we have an item that matches the rating key exactly, remove it and rebuild indices
final matchEntry = loadedItems.entries.where((e) => e.value.id == event.itemId).firstOrNull;
if (matchEntry != null) {
setState(() {
removeLoadedItemAndShift(matchEntry.key);
});
return;
}
// If a child item was deleted, update our item to reflect that.
// If all children were deleted, remove our item.
// Otherwise, just update the counts.
for (final parentKey in event.parentChain) {
final parentEntry = loadedItems.entries.where((e) => e.value.id == parentKey).firstOrNull;
if (parentEntry != null) {
final item = parentEntry.value;
final newLeafCount = (item.leafCount ?? 1) - event.leafCount;
if (newLeafCount <= 0) {
setState(() {
removeLoadedItemAndShift(parentEntry.key);
});
} else {
setState(() {
loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount);
});
}
return;
}
}
// If neither the item nor its parents are loaded (evicted), the event
// was already filtered by DeletionAware's upstream check against
// deletionGlobalKeys/deletionIds, so this point is unreachable.
// The grid self-corrects when the next page fetch updates totalSize from
// the server response on the next scroll.
}
@override
String get focusNodeDebugLabel => 'browse_first_item';
@override
int get itemCount => totalSize;
@override
void updateItemInLists(String itemId, MediaItem updatedMetadata) {
for (final entry in loadedItems.entries) {
if (entry.value.id == itemId) {
loadedItems[entry.key] = updatedMetadata;
break;
}
}
}
// Browse-specific state (not in base class)
List<MediaFilter> _filters = [];
List<MediaSort> _sortOptions = [];
Map<String, String> _selectedFilters = {};
MediaSort? _selectedSort;
bool _isSortDescending = false;
String _selectedGrouping = 'all'; // all, seasons, episodes, folders
// Alpha jump bar state
List<LibraryFirstCharacter> _firstCharacters = [];
AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []);
late final LibraryAlphaBarStrategy _alphaStrategy = LibraryAlphaBarStrategy.forBackend(
widget.library.backend,
// Resolved on demand and only invoked by [PlexAlphaBarStrategy], which is
// only constructed when the library's backend is Plex — the bang is safe.
plexClientProvider: () {
final manager = context.read<MultiServerProvider>().serverManager;
return manager.getPlexClient(widget.library.serverId ?? '')!;
},
libraryKey: widget.library.id,
isShared: widget.library.isShared,
);
/// On Jellyfin libraries the alpha bar acts as a filter (matches the
/// JF web client's UX). Holds the active letter (`#`, `A``Z`) or null
/// when no filter is applied.
String? _jellyfinAlphaPrefix;
/// Pre-fetched filter values for Jellyfin libraries — populated by
/// `_loadContent` and consumed by the FiltersBottomSheet so the sheet
/// 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);
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).
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 and range-load gating)
final ValueNotifier<bool> _isScrollActive = ValueNotifier<bool>(false);
Timer? _scrollActivityTimer;
// Alpha bar update: throttle (leading edge) + trailing timer (ensures final position)
DateTime? _lastAlphaUpdate;
Timer? _alphaUpdateTimer;
/// Generation counter for the filter/sort loading phase of [_loadContent].
/// Separate from the mixin's pagination generation so a filter reload can
/// invalidate in-flight filter/sort fetches without touching item pagination.
int _contentRequestId = 0;
int _firstCharactersRequestId = 0;
static const int _fetchSize = 200;
static const int _jellyfinFetchSize = 72;
Timer? _scrollIdleTimer;
bool _rangeLoadScheduled = false;
bool _topScrollResetScheduled = false;
bool get _isJellyfinLibrary => widget.library.backend == MediaBackend.jellyfin;
int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize;
// Focus nodes for filter chips
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip');
final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_chip');
// The inner CustomScrollView attaches its position to NestedScrollView's
// shared inner controller (via PrimaryScrollController), which has one
// position per kept-alive tab — making `controller.position` ambiguous and
// throw an assertion. We capture this tab's specific [ScrollPosition] from
// a Builder placed inside the slivers list, and address it directly for
// reads, listener attachment, and programmatic jumpTo/animateTo.
ScrollPosition? _innerPosition;
// Lets us trigger pull-to-refresh on the folder tree, which now lives as a
// sliver inside the same CustomScrollView (no longer self-owning RefreshIndicator).
final GlobalKey<FolderTreeViewState> _folderTreeKey = GlobalKey<FolderTreeViewState>();
void _bindInnerPosition(ScrollPosition? position) {
if (position == _innerPosition) return;
_innerPosition?.removeListener(_onScrollChanged);
_innerPosition = position;
_innerPosition?.addListener(_onScrollChanged);
}
@override
void dispose() {
disposePagination();
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_alphaUpdateTimer?.cancel();
_innerPosition?.removeListener(_onScrollChanged);
// _innerPosition is owned by the inner CustomScrollView's Scrollable.
_groupingChipFocusNode.dispose();
_filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose();
_alphaJumpBarFocusNode.dispose();
_currentFirstVisibleIndex.dispose();
_isScrollActive.dispose();
disposeGridFocusNodes();
super.dispose();
}
// Override tryFocus to use loadedItems instead of base class items list
@override
void tryFocus() {
if (widget.suppressAutoFocus) return;
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
if (widget.isActive && hasLoadedData && !hasFocused && loadedItems.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusFirstItem();
});
}
}
// Override loadData to use our custom _loadContent
@override
Future<List<MediaItem>> loadData() async {
// This is called by base class loadItems(), but we override loadItems() entirely
// So this just returns empty - actual loading is done in _loadContent
return [];
}
// Override loadItems to use our custom loading with pagination
@override
Future<void> loadItems() async {
await _loadContent();
}
// Required abstract implementations from base class
@override
IconData get emptyIcon => Symbols.folder_open_rounded;
@override
String get emptyMessage => t.libraries.thisLibraryIsEmpty;
@override
String get errorContext => t.libraries.content;
// Override buildContent - not used since we override build()
@override
Widget buildContent(List<MediaItem> items) => const SizedBox.shrink();
/// Focus the first item in the grid/list/folder tree (for tab activation)
@override
void focusFirstItem() {
// In folder mode, items list is empty — focus the first folder tree item directly
if (_selectedGrouping == 'folders') {
void request() {
if (mounted && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
request();
WidgetsBinding.instance.addPostFrameCallback((_) => request());
return;
}
if (loadedItems.isNotEmpty) {
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
request();
WidgetsBinding.instance.addPostFrameCallback((_) => request());
}
}
/// Height of the chips bar (padding + chip + padding)
static const double _chipsBarHeight = 32.0;
/// Focus the chips bar (for navigating from tab bar to content).
/// Called by libraries screen when pressing DOWN on tab bar.
void focusChipsBar() {
lastFocusedGridIndex = null;
_groupingChipFocusNode.requestFocus();
}
/// Reset transient browse state before loading a different library.
void _resetForFullReload() {
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_isScrollActive.value = false;
_hasJumpPin = false;
_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
// the jump because library changes call this from didUpdateWidget; jumping
// there dispatches scroll notifications while the AppBar is building.
_scheduleTopScrollReset();
}
void _scheduleTopScrollReset() {
if (_topScrollResetScheduled) return;
_topScrollResetScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_topScrollResetScheduled = false;
if (!mounted) return;
final pos = _innerPosition;
if (pos != null && pos.hasPixels && pos.pixels != 0) {
pos.jumpTo(0);
}
// Inner-only resets bypass NestedScrollView's natural delta surrender,
// leaving the outer floating header in its prior partially-hidden state.
widget.onResetScroll?.call();
});
}
Future<void> _loadContent() async {
final generation = ++_contentRequestId;
final firstCharactersGeneration = ++_firstCharactersRequestId;
_resetForFullReload();
_resetTopOfPageState();
_currentFirstVisibleIndex.value = 0;
// Plex returns categories from `/library/sections/{id}/filters` +
// `/sorts`; Jellyfin maps `/Items/Filters` into the same shape with
// values pre-cached and a hardcoded client-side sort list. Both flow
// through the unified [MediaServerClient.fetchLibraryFiltersWithValues].
final client = context.getMediaClientForLibrary(widget.library);
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
try {
final storage = await StorageService.getInstance();
final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey);
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
final LoadedFiltersAndSorts loaded;
if (_isJellyfinLibrary) {
// `/Items/Filters` can be much slower than the paged `/Items` browse
// request on large Jellyfin libraries. Load only the local sort list
// before page 1, then fill filter values in the background.
final sorts = await client.fetchSortOptions(widget.library.id, libraryType: widget.library.kind.id);
loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts);
} else {
// Plex filters+sorts must resolve before items so saved-sort restoration
// can match a saved key against the just-loaded sort list, and so the
// first item fetch already includes the restored sort param.
loaded = await loader.load(widget.library);
}
if (generation != _contentRequestId || !mounted) return;
setState(() {
_filters = loaded.filters;
_sortOptions = loaded.sorts;
// Plex returns no cached values (filters fetched lazily per-category);
// assigning the empty map is a no-op for Plex and a real payload for Jellyfin.
_jellyfinFilterValues = loaded.cachedValues;
_selectedFilters = Map.from(savedFilters);
_selectedGrouping = savedGrouping ?? _getDefaultGrouping();
// Restore sort
if (savedSort != null) {
final sortKey = savedSort['key'] as String?;
if (sortKey != null) {
final sort = loaded.sorts.where((s) => s.key == sortKey).firstOrNull;
if (sort != null) {
_selectedSort = sort;
_isSortDescending = (savedSort['descending'] as bool?) ?? false;
}
}
}
});
if (_isJellyfinLibrary) {
_loadJellyfinFiltersInBackground(generation);
}
// Load items and first characters in parallel
// _loadItems manages its own requestId internally
await Future.wait([_loadItems(), _loadFirstCharacters(requestId: firstCharactersGeneration)]);
} catch (e) {
if (!mounted) return;
setState(() {
errorMessage = _getErrorMessage(e);
isLoading = false;
});
}
}
void _loadJellyfinFiltersInBackground(int generation) {
final client = context.getMediaClientForLibrary(widget.library);
unawaited(
client
.fetchLibraryFiltersWithValues(widget.library.id)
.then((result) {
if (generation != _contentRequestId || !mounted) return;
setState(() {
_filters = result.filters;
_jellyfinFilterValues = result.cachedValues;
});
})
.catchError((Object e, StackTrace st) {
appLogger.w('Jellyfin library filters failed; browse content remains available', error: e, stackTrace: st);
}),
);
}
/// Initial UI state both Plex and Jellyfin paths need before fetching:
/// loading flag set, lists cleared, filter/sort caches reset.
void _resetTopOfPageState() {
setState(() {
isLoading = true;
errorMessage = null;
items = [];
resetPaginationState();
_filters = [];
_sortOptions = [];
_jellyfinFilterValues = const {};
_jellyfinAlphaPrefix = null;
_selectedFilters = {};
_selectedSort = null;
_isSortDescending = false;
_selectedGrouping = _getDefaultGrouping();
_firstCharacters = [];
_alphaHelper = AlphaJumpHelper(const []);
_scrollMetrics = LibraryAlphaScrollMetrics.empty;
_measuredListRowHeight = null;
});
}
/// Build the filter params map for API calls
Map<String, String> _buildFilterParams() {
final filterParams = Map<String, String>.from(_selectedFilters);
// Add grouping type filter (but not for 'all' or 'folders')
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
final typeId = _getGroupingTypeId();
if (typeId.isNotEmpty) {
filterParams['type'] = typeId;
}
} else if (_selectedGrouping == 'all' && widget.library.isShared) {
// Shared libraries: filter to video content only (exclude library section entries)
filterParams['type'] = PlexMetadataType.videoCsv;
}
// Add sort
if (_selectedSort != null) {
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
}
filterParams['includeCollections'] = '1';
// Jellyfin alpha-bar filter — picked up by DataAggregationService and
// converted to NameStartsWith / NameLessThan on the wire.
if (_jellyfinAlphaPrefix != null) {
filterParams['alphaPrefix'] = _jellyfinAlphaPrefix!;
}
return filterParams;
}
Future<void> _loadItems() async {
final generation = _contentRequestId;
setState(() {
isLoading = true;
items = [];
resetPaginationState();
// Increment content version when loading fresh content
// This invalidates the last focused index
gridContentVersion++;
cleanupGridFocusNodes(0);
});
try {
await loadInitialPage(_calculateInitialFetchSize());
if (generation != _contentRequestId || !mounted) return;
setState(() {
isLoading = false;
});
hasLoadedData = true;
tryFocus();
// Notify parent
if (widget.onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
});
}
} catch (e) {
if (generation != _contentRequestId || !mounted) return;
setState(() {
errorMessage = _getErrorMessage(e);
isLoading = false;
});
}
}
@override
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) async {
final client = context.getMediaClientForLibrary(widget.library);
final filterParams = _buildFilterParams();
final query = libraryQueryFromPlexMap(
map: filterParams,
libraryKind: filterParams.containsKey('type') ? null : widget.library.kind,
offset: start,
limit: size,
);
return client.fetchLibraryPagedContent(
widget.library.id,
query: query,
libraryKind: widget.library.kind,
abort: abort,
);
}
@override
void onPageLoaded(int start, List<MediaItem> pageItems) {
_prefetchImages(start, pageItems);
}
String _getDefaultGrouping() {
final type = widget.library.kind.id.toLowerCase();
if (type == 'show') return 'shows';
if (type == 'movie') return 'movies';
return 'all';
}
String _getGroupingTypeId() {
switch (_selectedGrouping) {
case 'movies':
return '1';
case 'shows':
return '2';
case 'seasons':
return '3';
case 'episodes':
return '4';
default:
return '';
}
}
List<String> _getGroupingOptions() {
final type = widget.library.kind.id.toLowerCase();
// Folder browsing relies on a section folder API
// (Plex `/library/sections/{id}/folders`); gated by capability so any
// future backend that exposes the same can opt in without touching
// this method.
final canFolder = context.tryGetMediaClientForServer(widget.library.serverId)?.capabilities.folderGrouping ?? false;
if (type == 'show') {
return ['shows', 'seasons', 'episodes', if (canFolder) 'folders'];
} else if (type == 'movie') {
return ['movies', if (canFolder) 'folders'];
} else if (type == 'mixed') {
// Shared libraries: all video content types, no folders
return ['all', 'movies', 'shows', 'seasons', 'episodes'];
}
return ['all', if (canFolder) 'folders'];
}
String _getGroupingLabel(String grouping) {
switch (grouping) {
case 'movies':
return t.libraries.groupings.movies;
case 'shows':
return t.libraries.groupings.shows;
case 'seasons':
return t.libraries.groupings.seasons;
case 'episodes':
return t.libraries.groupings.episodes;
case 'folders':
return t.libraries.groupings.folders;
default:
return t.libraries.groupings.all;
}
}
String _getErrorMessage(dynamic error) {
if (error is MediaServerHttpException) {
return mapHttpErrorToMessage(error, context: t.libraries.content);
}
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
}
void _showGroupingBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
final options = _getGroupingOptions();
final controller = OverlaySheetController.of(context);
controller
.show<String>(
showDragHandle: true,
builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text(
t.libraries.groupings.title,
style: Theme.of(sheetContext).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: options.map((grouping) {
final isSelected = _selectedGrouping == grouping;
return FocusableListTile(
key: ValueKey(grouping),
dense: true,
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(_getGroupingLabel(grouping)),
onTap: () => controller.close(grouping),
);
}).toList(),
),
),
),
],
),
)
.then((value) {
if (!mounted || value == null || value == _selectedGrouping) return;
setState(() {
_selectedGrouping = value;
});
StorageService.getInstance().then((storage) {
storage.saveLibraryGrouping(widget.library.globalKey, value);
});
_loadItems();
_loadFirstCharacters();
});
}
void _showFiltersBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
OverlaySheetController.of(context).show(
builder: (context) => FiltersBottomSheet(
filters: _filters,
selectedFilters: _selectedFilters,
serverId: widget.library.serverId!,
libraryKey: widget.library.globalKey,
// Pre-populated values arrive only from backends that bundle them
// with the category listing (Jellyfin's `/Items/Filters`). The empty
// map for Plex libraries falls through to lazy `getFilterValues`.
cachedValues: _jellyfinFilterValues.isEmpty ? null : _jellyfinFilterValues,
onFiltersChanged: (filters) async {
setState(() {
_selectedFilters.clear();
_selectedFilters.addAll(filters);
});
// Save filters to storage
final storage = await StorageService.getInstance();
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
},
),
);
}
void _showSortBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
// Track pending state in local variables so the callbacks don't trigger
// setState/_loadItems while the sheet is open (which would steal focus).
MediaSort? pendingSort = _selectedSort;
bool pendingDescending = _isSortDescending;
bool pendingCleared = false;
OverlaySheetController.of(context)
.show(
builder: (context) => SortBottomSheet(
sortOptions: _sortOptions,
selectedSort: _selectedSort,
isSortDescending: _isSortDescending,
onSortChanged: (sort, descending) {
pendingSort = sort;
pendingDescending = descending;
pendingCleared = false;
},
onClear: () {
pendingSort = null;
pendingDescending = false;
pendingCleared = true;
},
),
)
.then((_) {
if (!mounted) return;
if (pendingCleared) {
setState(() {
_selectedSort = null;
_isSortDescending = false;
});
_loadItems();
_loadFirstCharacters();
} else if (pendingSort != null &&
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
setState(() {
_selectedSort = pendingSort;
_isSortDescending = pendingDescending;
});
StorageService.getInstance().then((storage) {
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
});
_loadItems();
_loadFirstCharacters();
}
});
}
/// Navigate focus from chips down to the grid item.
/// Restores focus to the previously focused item if content hasn't changed.
void _navigateToGrid() {
// In folder mode, firstItemFocusNode is attached to the first folder tree item
if (_selectedGrouping == 'folders') {
firstItemFocusNode.requestFocus();
return;
}
if (totalSize == 0) return;
final targetIndex =
shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
? lastFocusedGridIndex!
: 0;
// Drop the chip's focus first so the targeted grid item's request isn't
// racing against the chip's still-held primary focus state. Without this,
// dpad DOWN from a chip occasionally fails to move focus to the grid.
if (FocusManager.instance.primaryFocus == _groupingChipFocusNode ||
FocusManager.instance.primaryFocus == _filtersChipFocusNode ||
FocusManager.instance.primaryFocus == _sortChipFocusNode) {
FocusManager.instance.primaryFocus?.unfocus();
}
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
final target = targetIndex == 0
? firstItemFocusNode
: getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
// Defer to a post-frame so the focus node has a chance to attach if the
// grid item is being built/rebuilt in the same frame.
if (target.context != null) {
target.requestFocus();
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) target.requestFocus();
});
}
}
/// Navigate from the alpha jump bar to the nearest visible grid item.
/// After a jump-scroll the previously focused item is off-screen (and its
/// FocusNode detached), so we target the last-column item in the first
/// visible row — the grid cell closest to the alpha bar.
void _navigateToGridNearScroll() {
final columnCount = _scrollMetrics.columnCount;
if (totalSize == 0 || columnCount < 1) return;
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)) {
// Search backwards first (items above are more likely visible)
int? found;
for (var i = targetIndex - 1; i >= 0; i--) {
if (loadedItems.containsKey(i)) {
found = i;
break;
}
}
// Then search forwards
if (found == null) {
for (var i = targetIndex + 1; i < totalSize; i++) {
if (loadedItems.containsKey(i)) {
found = i;
break;
}
}
}
if (found == null) return;
targetIndex = found;
}
if (targetIndex == 0) {
firstItemFocusNode.requestFocus();
} else {
getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item').requestFocus();
}
}
/// Navigate focus from grid up to the chips bar
void _navigateToChips() {
_groupingChipFocusNode.requestFocus();
}
/// Navigate focus to the sidebar
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
}
/// Navigate focus to the alpha jump bar
void _navigateToAlphaJumpBar() {
_alphaJumpBarFocusNode.requestFocus();
}
/// Whether the device is a phone (not tablet/desktop/TV).
bool _isPhone(BuildContext context) => PlatformDetector.isPhone(context);
/// The letter currently visible at the top of the grid, determined by
/// how many items we've scrolled past relative to the API's cumulative
/// firstCharacter counts.
String _alphaLetterFor(int index) =>
_alphaStrategy.currentLetter(index, _alphaHelper, jellyfinAlphaPrefix: _jellyfinAlphaPrefix);
/// Whether the alpha jump bar should be shown.
/// Only shown when sorting by title (titleSort) and not in folders mode.
bool get _shouldShowAlphaJumpBar => _alphaStrategy.shouldShow(
totalItemCount: totalSize,
loadedCharacterCount: _firstCharacters.length,
sortKey: _selectedSort?.key,
isFolderGrouping: _selectedGrouping == 'folders',
jellyfinAlphaPrefix: _jellyfinAlphaPrefix,
isPhone: _isPhone(context),
);
/// Fetch first characters for the current library/filter state
Future<void> _loadFirstCharacters({int? requestId}) async {
final currentRequestId = requestId ?? ++_firstCharactersRequestId;
final filterParams = Map<String, String>.from(_selectedFilters);
final typeId = _getGroupingTypeId();
try {
final result = await _alphaStrategy.loadCharacters(
filters: filterParams,
typeId: typeId.isNotEmpty ? int.tryParse(typeId) : null,
);
if (!mounted || currentRequestId != _firstCharactersRequestId) return;
setState(() {
_firstCharacters = result.chars;
_alphaHelper = result.helper;
});
} catch (_) {
// Non-critical — hide the bar on failure
if (!mounted || currentRequestId != _firstCharactersRequestId) return;
setState(() {
_firstCharacters = [];
_alphaHelper = AlphaJumpHelper(const []);
});
}
}
/// Track scroll position and trigger debounced range loading.
void _onScrollChanged() {
// The inner controller (PrimaryScrollController) is shared across tabs.
// Skip if this tab isn't active so we don't react to a sibling tab's
// scroll events.
if (!widget.isActive) return;
// Debounced scroll-idle handler: load visible range when scrolling settles
_scrollIdleTimer?.cancel();
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
if (!mounted) return;
// Discard stale in-flight tracking from eager prefetch during scroll so
// ensureRangeLoaded sees the full gap at the settled position.
clearPendingRanges();
final range = _computeVisibleRange();
if (range != null) {
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _activeFetchSize ~/ 2);
evictDistantItems(range.firstIndex, maxKeep: 500, threshold: 600);
evictDistantFocusNodes(range.firstIndex);
}
});
// Eager prefetch: fetch data before scroll stops
final range = _computeVisibleRange();
if (range != null) {
prefetchAhead(range.firstIndex, range.visibleCount, pageSize: _activeFetchSize);
}
if (!_shouldShowAlphaJumpBar || !_scrollMetrics.isUsable) return;
// During a jump animation, skip alpha bar 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 (_hasJumpPin) {
_hasJumpPin = false;
}
// Throttle alpha bar updates to ~50fps, with a trailing-edge timer
// so the final scroll position always gets an update
final now = DateTime.now();
if (_lastAlphaUpdate == null || now.difference(_lastAlphaUpdate!) >= const Duration(milliseconds: 20)) {
_lastAlphaUpdate = now;
_updateVisibleIndex();
}
_alphaUpdateTimer?.cancel();
_alphaUpdateTimer = Timer(const Duration(milliseconds: 20), () {
if (mounted) _updateVisibleIndex();
});
}
/// Recompute the first-visible-index from the current scroll offset.
void _updateVisibleIndex() {
final pos = _innerPosition;
if (pos == null) return;
final offset = pos.pixels;
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 maxIndex = totalSize > 0 ? totalSize - 1 : 0;
final lastInRow = (firstInRow + _scrollMetrics.columnCount - 1).clamp(0, maxIndex);
if (lastInRow != _currentFirstVisibleIndex.value) {
_currentFirstVisibleIndex.value = lastInRow;
}
}
/// Compute the first visible item index from a scroll offset.
/// Chips bar is the first sliver (height = _chipsBarHeight) followed by the
/// grid's own top padding before the first row.
int _itemIndexFromScrollOffset(double offset) {
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
/// cumulative item offset, Jellyfin toggles a `NameStartsWith` filter
/// (matches the JF web client UX).
void _jumpToIndex(int targetIndex) {
_alphaStrategy.onLetterPressed(
targetIndex,
_alphaHelper,
currentJellyfinPrefix: _jellyfinAlphaPrefix,
onPlexJump: _scrollGridToIndex,
onJellyfinPrefixChange: _applyJellyfinAlphaPrefix,
);
}
/// Scroll the current layout so the item at [targetIndex] becomes the first visible
/// row. Used by [PlexAlphaBarStrategy] via [_jumpToIndex].
void _scrollGridToIndex(int targetIndex) {
_jumpScrollGeneration++;
_isJumpScrolling = true;
_hasJumpPin = true;
final clamped = targetIndex.clamp(0, totalSize > 0 ? totalSize - 1 : 0);
_currentFirstVisibleIndex.value = clamped;
_scrollToItemIndex(clamped);
}
/// Apply the new Jellyfin `NameStartsWith` prefix from the alpha bar and
/// refetch from the top of the now-filtered dataset. Used by
/// [JellyfinAlphaBarStrategy] via [_jumpToIndex].
void _applyJellyfinAlphaPrefix(String? nextPrefix) {
setState(() {
_jellyfinAlphaPrefix = nextPrefix;
// Clear loaded items + total so the grid blanks while the new filtered
// page loads. PaginatedItemLoader internals will repopulate from
// offset 0 once the next fetchPage call returns.
resetPaginationState();
isLoading = true;
});
_scheduleTopScrollReset();
_loadItems();
}
/// Scroll the current layout so that [index] is visible just below the chips bar
void _scrollToItemIndex(int index) {
final pos = _innerPosition;
if (!_scrollMetrics.isUsable || pos == null) {
_isJumpScrolling = false;
return;
}
// 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 = _scrollMetrics.scrollOffsetForItemIndex(index, contentStartOffset: _contentStartScrollOffset);
final gen = _jumpScrollGeneration;
final maxExtent = pos.maxScrollExtent;
if (!maxExtent.isFinite) {
_isJumpScrolling = false;
return;
}
final clampedOffset = offset.clamp(0.0, maxExtent);
// If a newer jump already superseded this one, skip the animation
// entirely — the next call will handle the final position.
if (gen != _jumpScrollGeneration) {
pos.jumpTo(clampedOffset);
return;
}
pos.animateTo(clampedOffset, 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;
}
});
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
// Chips are inline as a floating sliver in the inner scroll. The alpha
// jump bar is the only overlay; we offset it by the typical app bar height
// so it isn't obscured by the floating outer header. Using MediaQuery
// (not the absorber handle) avoids rebuilding during layout — listening to
// the handle from a builder fires notifyListeners during the build phase
// and triggers a setState-in-build assertion.
final media = MediaQuery.of(context);
final overlayTopPadding = media.padding.top + kToolbarHeight;
return Stack(
children: [
Positioned.fill(child: _buildScrollableContent()),
if (_shouldShowAlphaJumpBar)
Positioned(
top: overlayTopPadding,
right: 0,
bottom: 0,
child: _isPhone(context)
? ValueListenableBuilder<int>(
valueListenable: _currentFirstVisibleIndex,
builder: (context, visibleIndex, _) => ValueListenableBuilder<bool>(
valueListenable: _isScrollActive,
builder: (context, scrolling, _) => AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: _alphaLetterFor(visibleIndex),
isScrolling: scrolling,
),
),
)
: ValueListenableBuilder<int>(
valueListenable: _currentFirstVisibleIndex,
builder: (context, visibleIndex, _) => AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: _alphaLetterFor(visibleIndex),
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
),
),
),
],
);
}
/// Builds the scrollable content with chips, then either folder tree or grid/list.
Widget _buildScrollableContent() {
final isFolders = _selectedGrouping == 'folders';
Widget scrollView = NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Track scroll activity for phone scroll handle and range-load gating
if (notification is ScrollStartNotification) {
_isScrollActive.value = true;
_scrollActivityTimer?.cancel();
} else if (notification is ScrollEndNotification) {
_scrollActivityTimer?.cancel();
_scrollActivityTimer = Timer(const Duration(milliseconds: 100), () {
if (mounted) _isScrollActive.value = false;
});
}
return false;
},
child: Builder(
builder: (context) => CustomScrollView(
// No explicit controller: this picks up NestedScrollView's
// PrimaryScrollController, which is what wires inner scroll deltas
// through to the outer floating header.
// Allow focus decoration to render outside scroll bounds.
clipBehavior: Clip.none,
slivers: [
// Capture-only sliver: an invisible Builder whose context lives
// inside this CustomScrollView, used to grab the per-tab
// ScrollPosition. NSV's shared inner controller has one position
// per kept-alive tab; we need this tab's specific one.
SliverToBoxAdapter(
child: Builder(
builder: (innerCtx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_bindInnerPosition(Scrollable.maybeOf(innerCtx)?.position);
}
});
return const SizedBox.shrink();
},
),
),
// Floating chips: scroll off with content but snap back into view
// on upward direction reversal, matching the outer floating
// SliverAppBar's behavior.
SliverPersistentHeader(
floating: true,
pinned: false,
delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight),
),
..._buildContentSlivers(),
],
),
),
);
// Folders mode previously had its own RefreshIndicator inside FolderTreeView;
// it now lives at this level since FolderTreeView is a sliver.
if (isFolders) {
scrollView = RefreshIndicator(
onRefresh: () async {
await _folderTreeKey.currentState?.refresh();
},
child: scrollView,
);
}
return scrollView;
}
/// Self-healing: when a skeleton is rendered after scrolling stops,
/// ensure the visible range gets loaded even if the scroll-idle path missed it.
void _scheduleRangeLoad() {
if (_rangeLoadScheduled || _isScrollActive.value) return;
_rangeLoadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_rangeLoadScheduled = false;
if (!mounted) return;
final range = _computeVisibleRange();
if (range != null) {
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _activeFetchSize ~/ 2);
}
});
}
/// Returns the first-visible index and visible count from the scroll
/// position + layout metrics, or null if the viewport isn't measured yet.
({int firstIndex, int visibleCount})? _computeVisibleRange() {
final pos = _innerPosition;
if (!_scrollMetrics.isUsable || pos == null) return null;
final offset = pos.pixels;
final viewportHeight = pos.viewportDimension;
if (!viewportHeight.isFinite) return null;
final visibleCount = _scrollMetrics.visibleItemCount(viewportHeight);
if (visibleCount <= 0) return null;
return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleCount);
}
/// Compute initial fetch size based on viewport dimensions.
int _calculateInitialFetchSize() {
try {
final screenSize = MediaQuery.sizeOf(context);
final density = context.settingsRead(SettingsService.libraryDensity);
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing;
final columnCount = ((screenSize.width + crossAxisSpacing) / (maxExtent + crossAxisSpacing)).ceil().clamp(1, 100);
final itemWidth = screenSize.width / columnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return _activeFetchSize;
final visibleRows = (screenSize.height / rowHeight).ceil() + 1;
final visibleCount = visibleRows * columnCount;
if (_isJellyfinLibrary) {
return (visibleCount * 2).clamp(36, _jellyfinFetchSize).toInt();
}
return (visibleCount * 3).clamp(100, 500).toInt();
} catch (_) {
return _activeFetchSize;
}
}
/// Prefetch images for items near the viewport to reduce pop-in.
void _prefetchImages(int startIndex, List<MediaItem> items) {
final pos = _innerPosition;
if (pos == null || !_scrollMetrics.isUsable) return;
final offset = pos.pixels;
final viewportHeight = pos.viewportDimension;
if (!viewportHeight.isFinite) return;
final firstVisible = _itemIndexFromScrollOffset(offset);
final itemWidth = _scrollMetrics.itemWidth;
final itemHeight = _scrollMetrics.itemHeight;
if (itemWidth <= 0 || itemHeight <= 0) return;
final visibleEnd = firstVisible + _scrollMetrics.visibleItemCount(viewportHeight);
// Prefetch 2 rows beyond visible area
final prefetchEnd = visibleEnd + 2 * _scrollMetrics.columnCount;
final client = getMediaClientForLibrary();
final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context);
final episodePosterMode = context.settingsRead(SettingsService.episodePosterMode);
for (var i = 0; i < items.length; i++) {
final index = startIndex + i;
if (index < firstVisible || index > prefetchEnd) continue;
final item = items[i];
final thumb = item.posterThumb(mode: episodePosterMode);
if (thumb == null || thumb.isEmpty) continue;
final imageType = item.usesWideAspectRatio(episodePosterMode) ? ImageType.thumb : ImageType.poster;
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: thumb,
maxWidth: itemWidth,
maxHeight: itemHeight,
devicePixelRatio: devicePixelRatio,
imageType: imageType,
);
if (imageUrl.isEmpty) continue;
final scaledWidth = itemWidth * devicePixelRatio;
final scaledHeight = itemHeight * devicePixelRatio;
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
imageType: imageType,
);
precacheImage(
CachedNetworkImageProvider(
imageUrl,
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
maxHeight: memHeight,
),
context,
).ignore();
}
}
/// Whether the filters chip is visible
bool get _isFiltersChipVisible =>
(_filters.isNotEmpty || _selectedFilters.isNotEmpty) && _selectedGrouping != 'folders';
/// Whether the sort chip is visible
bool get _isSortChipVisible => _sortOptions.isNotEmpty && _selectedGrouping != 'folders';
/// Builds the chips bar widget
Widget _buildChipsBar() {
VoidCallback? groupingNavigateRight;
if (_isFiltersChipVisible) {
groupingNavigateRight = () => _filtersChipFocusNode.requestFocus();
} else if (_isSortChipVisible) {
groupingNavigateRight = () => _sortChipFocusNode.requestFocus();
}
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Grouping chip
FocusableFilterChip(
focusNode: _groupingChipFocusNode,
icon: Symbols.category_rounded,
label: _getGroupingLabel(_selectedGrouping),
onPressed: _showGroupingBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: _navigateToSidebar,
onNavigateRight: groupingNavigateRight,
onBack: widget.onBack,
),
const SizedBox(width: 8),
// Filters chip
if (_isFiltersChipVisible)
FocusableFilterChip(
focusNode: _filtersChipFocusNode,
icon: Symbols.filter_alt_rounded,
label: _selectedFilters.isEmpty
? t.libraries.filters
: t.libraries.filtersWithCount(count: _selectedFilters.length),
onPressed: _showFiltersBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: () => _groupingChipFocusNode.requestFocus(),
onNavigateRight: _isSortChipVisible ? () => _sortChipFocusNode.requestFocus() : null,
onBack: widget.onBack,
),
if (_isFiltersChipVisible) const SizedBox(width: 8),
// Sort chip
if (_isSortChipVisible)
FocusableFilterChip(
focusNode: _sortChipFocusNode,
icon: Symbols.sort_rounded,
label: _selectedSort?.title ?? t.libraries.sort,
onPressed: _showSortBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: _isFiltersChipVisible
? () => _filtersChipFocusNode.requestFocus()
: () => _groupingChipFocusNode.requestFocus(),
onBack: widget.onBack,
),
],
),
);
}
/// Builds content as slivers for the CustomScrollView
List<Widget> _buildContentSlivers() {
// Folders mode: hand off to the FolderTreeView sliver, which owns its own
// loading/empty/error states.
if (_selectedGrouping == 'folders') {
return [
FolderTreeView(
key: _folderTreeKey,
libraryKey: widget.library.id,
serverId: widget.library.serverId,
onRefresh: updateItem,
firstItemFocusNode: firstItemFocusNode,
onNavigateUp: _navigateToChips,
),
];
}
if (isLoading && totalSize == 0 && loadedItems.isEmpty) {
return [LoadingIndicatorBox.sliver];
}
if (errorMessage != null && loadedItems.isEmpty) {
return [SliverErrorState(message: errorMessage!, onRetry: _loadContent)];
}
if (totalSize == 0 && !isLoading) {
return [SliverEmptyState(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded)];
}
return [
SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.episodePosterMode],
builder: (context) => _buildItemsSliver(context),
),
];
}
// Top padding for grid content. Chips are inline above the grid now, so this
// is purely focus-decoration clearance on desktop. Phone has no D-pad
// focus ring so no extra clearance is needed.
static const double _gridTopPadding = 6.0;
static const double _gridTopPaddingPhone = 0.0;
/// 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!;
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final itemCount = totalSize;
final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
_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: 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 baseMaxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity);
final effectiveMaxExtent = useWideRatio ? baseMaxExtent * 1.8 : baseMaxExtent;
final hasAlphaBarReservation = rightPadding > 8.0;
return SliverPadding(
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
// Compute column count from the width the grid would have without the alpha
// bar's reservation, so toggling the bar doesn't repack the grid into one
// fewer column and blow up poster size.
final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0);
final columnCount = GridSizeCalculator.getColumnCount(baselineWidth, effectiveMaxExtent);
// Cache grid metrics for alpha jump bar scroll calculations
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,
density: libraryDensity,
useWideAspectRatio: useWideRatio,
maxCrossAxisExtentOverride: hasAlphaBarReservation ? constraints.crossAxisExtent / columnCount : null,
),
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
index,
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
isLastColumn: (index % columnCount) == (columnCount - 1),
columnCount: columnCount,
itemCount: itemCount,
),
);
},
),
);
}
}
Widget _buildMediaCardItem(
int index, {
required bool isFirstRow,
required bool isFirstColumn,
bool isLastColumn = false,
bool disableScale = false,
int columnCount = 1,
int itemCount = 0,
}) {
final item = loadedItems[index];
// Show skeleton placeholder for unloaded items
if (item == null) {
_scheduleRangeLoad();
return const SkeletonMediaCard();
}
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
// All other items get managed focus nodes for restoration
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'browse_grid_item');
// Explicit row navigation. Default directional focus traversal becomes
// unreliable while items are mounting/unmounting under fast scrolling and
// can spuriously jump to overlay widgets like the floating chips header.
VoidCallback? navigateUp;
if (isFirstRow) {
navigateUp = _navigateToChips;
} else if (columnCount > 0 && index >= columnCount) {
navigateUp = () => _focusGridItem(index - columnCount);
}
VoidCallback? navigateDown;
if (columnCount > 0 && index + columnCount < itemCount) {
navigateDown = () => _focusGridItem(index + columnCount);
}
VoidCallback? navigateLeft;
if (isFirstColumn) {
navigateLeft = _navigateToSidebar;
} else if (index > 0) {
navigateLeft = () => _focusGridItem(index - 1);
}
VoidCallback? navigateRight;
if (isLastColumn && _shouldShowAlphaJumpBar && !_isPhone(context)) {
navigateRight = _navigateToAlphaJumpBar;
} else if (!isLastColumn && index + 1 < itemCount) {
navigateRight = () => _focusGridItem(index + 1);
}
return FocusableMediaCard(
key: Key(item.id),
item: item,
focusNode: focusNode,
disableScale: disableScale,
onRefresh: updateItem,
onNavigateUp: navigateUp,
onNavigateDown: navigateDown,
onNavigateLeft: navigateLeft,
onNavigateRight: navigateRight,
onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems,
);
}
/// Move focus to the grid item at [targetIndex] (or its skeleton's row).
/// Used by the explicit dpad navigation handlers.
void _focusGridItem(int targetIndex) {
if (targetIndex < 0 || targetIndex >= totalSize) return;
final node = targetIndex == 0 ? firstItemFocusNode : getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
if (node.context != null) {
node.requestFocus();
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) node.requestFocus();
});
}
}
}
/// SliverPersistentHeader delegate for the chips bar. Fixed-height floating
/// header that snaps in on scroll direction reversal.
class _ChipsBarDelegate extends SliverPersistentHeaderDelegate {
final WidgetBuilder builder;
final double height;
const _ChipsBarDelegate({required this.builder, required this.height});
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox(height: height, child: builder(context));
}
@override
bool shouldRebuild(covariant _ChipsBarDelegate oldDelegate) =>
builder != oldDelegate.builder || height != oldDelegate.height;
}