import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../media/library_query.dart'; import '../media/media_item.dart'; import '../mixins/paginated_item_loader.dart'; import '../mixins/standard_paginated_view.dart'; import '../providers/download_provider.dart'; import '../utils/app_logger.dart'; import '../utils/content_utils.dart'; import '../utils/dialogs.dart'; import '../utils/error_message_utils.dart'; import '../utils/download_utils.dart'; import '../utils/media_server_http_client.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../i18n/strings.g.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../services/playlist_items_loader.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { final MediaItem collection; const CollectionDetailScreen({super.key, required this.collection}); @override State createState() => _CollectionDetailScreenState(); } class _CollectionDetailScreenState extends BaseMediaListDetailScreen with GridFocusNodeMixin, FocusableDetailScreenMixin, PaginatedItemLoader, PaginatedItemUpdatable, StandardPaginatedView { static const int _pageSize = 200; @override MediaItem get mediaItem => widget.collection; @override String get title => widget.collection.title!; @override String get emptyMessage => t.collections.empty; @override bool get hasItems => totalSize > 0; CardShape? get _contentShape { final loaded = loadedItems.values; return loaded.isNotEmpty && loaded.every((item) => item.kind.isMusic) ? CardShape.square : null; } @override void dispose() { disposePagination(); disposeFocusResources(); super.dispose(); } @override Future> fetchPage(int start, int size, AbortController? abort) { return mediaClient.fetchCollectionPage( widget.collection.id, start: start, size: size, abort: abort, libraryId: widget.collection.libraryId, libraryTitle: widget.collection.libraryTitle, ); } @override Future loadItems() { return loadStandardPaginatedItems( pageSize: _pageSize, errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection), onLoaded: (loadedCount, totalCount) { appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}'); autoFocusFirstItemAfterLoad(); }, ); } @override List getAppBarActions() { final ruleKey = syncRuleKey; // Select the specific bool we care about so unrelated DownloadProvider // ticks (e.g. active download progress) don't rebuild the app bar. final hasRule = context.select((p) => p.hasSyncRule(ruleKey)); return [ if (hasItems) ...[ FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), ], // Emptiness is handled inside [_downloadCollection], so the download // entry stays visible for empty collections. ...buildSyncRuleActions( context, ruleKey: ruleKey, displayTitle: widget.collection.displayTitle, hasRule: hasRule, showDownload: true, onDownload: _downloadCollection, ), FocusableAction( icon: Symbols.delete_rounded, tooltip: t.common.delete, onPressed: _deleteCollection, iconColor: Colors.red, ), ]; } Future _downloadCollection() async { if (!hasItems) { showErrorSnackBar(context, t.collections.empty); return; } final downloadProvider = context.read(); try { final allItems = await fetchAllCollectionItemsPaged( mediaClient, widget.collection.id, libraryId: widget.collection.libraryId, libraryTitle: widget.collection.libraryTitle, ); if (!mounted) return; final result = await showListDownloadOptionsAndQueue( context, rootMetadata: widget.collection, targetType: ContentTypes.collection, items: allItems, client: mediaClient, downloadProvider: downloadProvider, ); if (result == null || !mounted) return; showSuccessSnackBar(context, result.toSnackBarMessage()); } catch (e) { appLogger.e('Failed to queue collection download', error: e); if (mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } Future _deleteCollection() async { final confirmed = await showDeleteConfirmation( context, title: t.collections.deleteCollection, message: t.collections.deleteConfirm(title: widget.collection.displayTitle), ); if (!confirmed) return; if (!mounted) return; try { // Backend-neutral [deleteCollection] reads `libraryId` from the // [MediaItem] for Plex's section-id; Jellyfin ignores it. final success = await mediaClient.deleteCollection(widget.collection); if (!mounted) return; if (success) { showSuccessSnackBar(context, t.collections.deleted); Navigator.pop(context, true); } else { showErrorSnackBar(context, t.collections.deleteFailed); } } catch (e) { appLogger.e('Failed to delete collection', error: e); if (mounted) { showErrorSnackBar(context, t.collections.deleteFailedWithError(error: e.toString())); } } } @override Widget build(BuildContext context) { return buildDetailScaffold( slivers: [ CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()), ...buildStateSlivers(), if (hasItems) buildSparseFocusableGrid( totalItems: totalSize, itemAt: (index) => loadedItems[index], onRefresh: updateItem, onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize), collectionId: widget.collection.id, onListRefresh: loadItems, shape: _contentShape, ), ], ); } }