264 lines
8.9 KiB
Dart
264 lines
8.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import '../../../focus/input_mode_tracker.dart';
|
|
import '../../../media/library_query.dart';
|
|
import '../../../media/media_item.dart';
|
|
import '../../../mixins/library_tab_focus_mixin.dart';
|
|
import '../../../mixins/paginated_item_loader.dart';
|
|
import '../../../services/settings_service.dart';
|
|
import '../../../utils/app_logger.dart';
|
|
import '../../../utils/grid_size_calculator.dart';
|
|
import '../../../utils/layout_constants.dart';
|
|
import '../../../utils/library_refresh_notifier.dart';
|
|
import '../../../utils/media_server_http_client.dart';
|
|
import '../../../utils/platform_detector.dart';
|
|
import '../../../widgets/card_inflation_budget.dart';
|
|
import '../../../widgets/focusable_media_card.dart';
|
|
import '../../../widgets/media_grid_delegate.dart';
|
|
import '../../../widgets/settings_builder.dart';
|
|
import '../../../widgets/skeleton_media_card.dart';
|
|
import '../../../widgets/sliver_child_memo.dart';
|
|
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
|
import '../../../i18n/strings.g.dart';
|
|
import '../../main_screen.dart';
|
|
import 'base_library_tab.dart';
|
|
|
|
/// Collections tab for library screen.
|
|
/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root.
|
|
class LibraryCollectionsTab extends BaseLibraryTab<MediaItem> {
|
|
const LibraryCollectionsTab({
|
|
super.key,
|
|
required super.library,
|
|
super.viewMode,
|
|
super.density,
|
|
super.onDataLoaded,
|
|
super.isActive,
|
|
super.suppressAutoFocus,
|
|
super.onBack,
|
|
});
|
|
|
|
@override
|
|
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
|
|
}
|
|
|
|
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab>
|
|
with
|
|
LibraryTabFocusMixin<LibraryCollectionsTab>,
|
|
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
|
|
SkeletonUpgradeScheduler {
|
|
static const int _pageSize = 36;
|
|
|
|
/// Reuses card widgets across delegate swaps so tab-level setStates
|
|
/// (pagination, refreshes) don't rebuild every realized card inside layout.
|
|
final SliverChildMemo<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
|
|
|
|
@override
|
|
String get focusNodeDebugLabel => 'collections_first_item';
|
|
|
|
@override
|
|
int get itemCount => totalSize;
|
|
|
|
@override
|
|
IconData get emptyIcon => Symbols.collections_rounded;
|
|
|
|
@override
|
|
String get emptyMessage => t.libraries.noCollections;
|
|
|
|
@override
|
|
String get errorContext => t.collections.title;
|
|
|
|
@override
|
|
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
|
|
|
|
@override
|
|
Future<List<MediaItem>> loadData() async => const [];
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
|
final client = getMediaClientForLibrary();
|
|
return client.fetchCollectionsPage(widget.library.id, start: start, size: size, abort: abort);
|
|
}
|
|
|
|
@override
|
|
Future<void> loadItems() async {
|
|
setState(() {
|
|
isLoading = true;
|
|
errorMessage = null;
|
|
items = [];
|
|
resetPaginationState();
|
|
});
|
|
|
|
try {
|
|
final initialPage = await loadInitialPageWithStatus(_pageSize);
|
|
if (!initialPage.applied || !mounted) return;
|
|
|
|
setState(() {
|
|
items = loadedItems.values.toList();
|
|
isLoading = false;
|
|
});
|
|
|
|
hasLoadedData = true;
|
|
tryFocus();
|
|
|
|
if (widget.onDataLoaded != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) widget.onDataLoaded!();
|
|
});
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Error loading $errorContext', error: e, stackTrace: st);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
errorMessage = 'Failed to load $errorContext: ${e.toString()}';
|
|
isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget buildContent(List<MediaItem> items) {
|
|
return SettingsBuilder(
|
|
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
|
builder: (context) {
|
|
final settings = SettingsService.instance;
|
|
final viewMode = settings.read(SettingsService.viewMode);
|
|
final density = settings.read(SettingsService.libraryDensity);
|
|
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
|
|
return CustomScrollView(
|
|
clipBehavior: Clip.none,
|
|
slivers: [
|
|
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
|
if (viewMode == ViewMode.list)
|
|
_buildListSliver(density)
|
|
else
|
|
_buildGridSliver(density, fullCardLayout: fullCardLayout),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
static const double _focusDecorationPadding = 3.0;
|
|
|
|
EdgeInsets get _effectivePadding {
|
|
final base = GridLayoutConstants.gridPadding;
|
|
return base.copyWith(top: base.top + _focusDecorationPadding);
|
|
}
|
|
|
|
Widget _buildListSliver(int density) {
|
|
return SliverPadding(
|
|
padding: _effectivePadding,
|
|
sliver: SliverList.builder(
|
|
// Inert on media lists (no keep-alive clients): dropping the
|
|
// per-child wrappers shrinks build + semantics work per item.
|
|
addAutomaticKeepAlives: false,
|
|
addSemanticIndexes: false,
|
|
itemCount: totalSize,
|
|
itemBuilder: (context, index) {
|
|
final item = loadedItems[index];
|
|
if (item == null) {
|
|
ensureIndexLoaded(index, pageSize: _pageSize);
|
|
return const SkeletonMediaCard();
|
|
}
|
|
return _cardMemo.widgetFor(
|
|
index,
|
|
item,
|
|
epoch: (ViewMode.list, totalSize, density),
|
|
build: () => _buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
|
|
return SliverPadding(
|
|
padding: _effectivePadding,
|
|
sliver: SliverCrossAxisLayoutBuilder(
|
|
builder: (context, crossAxisExtent) {
|
|
final geometry = MediaGridGeometry.resolve(
|
|
context: context,
|
|
crossAxisExtent: crossAxisExtent,
|
|
density: density,
|
|
fullBleedImage: fullCardLayout,
|
|
);
|
|
// Everything the card closures capture; a change flushes the memo.
|
|
final cardEpoch = (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density);
|
|
return SliverGrid.builder(
|
|
// Inert on media lists (no keep-alive clients): dropping the
|
|
// per-child wrappers shrinks build + semantics work per item.
|
|
addAutomaticKeepAlives: false,
|
|
addSemanticIndexes: false,
|
|
gridDelegate: geometry.delegate,
|
|
itemCount: totalSize,
|
|
itemBuilder: (context, index) {
|
|
final item = loadedItems[index];
|
|
if (item == null) {
|
|
ensureIndexLoaded(index, pageSize: _pageSize);
|
|
return const SkeletonMediaCard();
|
|
}
|
|
final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch);
|
|
if (cached != null) return cached;
|
|
// Budget fresh inflations while scrolling in pointer/touch mode
|
|
// (see CardInflationBudget); skeletons upgrade a frame later.
|
|
if (CardInflationBudget.isScrollingContext(context) &&
|
|
!InputModeTracker.isKeyboardMode(context) &&
|
|
!CardInflationBudget.tryTake()) {
|
|
scheduleSkeletonUpgrade();
|
|
return const SkeletonMediaCard();
|
|
}
|
|
return _cardMemo.widgetFor(
|
|
index,
|
|
item,
|
|
epoch: cardEpoch,
|
|
build: () => _buildMediaCardItem(
|
|
index,
|
|
isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
|
|
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
|
|
fullBleedImage: fullCardLayout,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMediaCardItem(
|
|
int index, {
|
|
required bool isFirstRow,
|
|
required bool isFirstColumn,
|
|
bool disableScale = false,
|
|
bool fullBleedImage = false,
|
|
}) {
|
|
final item = loadedItems[index];
|
|
if (item == null) {
|
|
ensureIndexLoaded(index, pageSize: _pageSize);
|
|
return const SkeletonMediaCard();
|
|
}
|
|
|
|
return FocusableMediaCard(
|
|
key: Key(item.id),
|
|
item: item,
|
|
focusNode: index == 0 ? firstItemFocusNode : null,
|
|
disableScale: disableScale,
|
|
fullBleedImage: fullBleedImage,
|
|
onListRefresh: loadItems,
|
|
onNavigateUp: isFirstRow ? widget.onBack : null,
|
|
onBack: widget.onBack,
|
|
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
|
|
);
|
|
}
|
|
|
|
void _navigateToSidebar() {
|
|
MainScreenFocusScope.focusSidebarOf(context);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
disposePagination();
|
|
super.dispose();
|
|
}
|
|
}
|