import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../models/plex_hub.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/grid_size_calculator.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/overlay_sheet.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import 'libraries/sort_bottom_sheet.dart'; import 'libraries/state_messages.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; import 'focusable_detail_screen_mixin.dart'; /// Screen to display full content of a recommendation hub class HubDetailScreen extends StatefulWidget { final PlexHub hub; const HubDetailScreen({super.key, required this.hub}); @override State createState() => _HubDetailScreenState(); } class _HubDetailScreenState extends State with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin { PlexClient get client => _getClientForHub(); List _items = []; List _filteredItems = []; List _sortOptions = []; PlexSort? _selectedSort; bool _isSortDescending = false; bool _isLoading = false; String? _errorMessage; /// Key for getting a context below OverlaySheetHost final GlobalKey _overlayChildKey = GlobalKey(); @override bool get hasItems => _filteredItems.isNotEmpty; @override List getAppBarActions() { return [ FocusableAction(icon: Symbols.swap_vert_rounded, tooltip: t.libraries.sort, onPressed: _showSortBottomSheet), ]; } /// Override to add bounds check for filtered items (sorting can change item order) @override void navigateToGrid() { if (!hasItems) return; final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0; setState(() { isAppBarFocused = false; }); if (targetIndex == 0) { firstItemFocusNode.requestFocus(); } else { getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus(); } } /// Get the correct PlexClient for this hub's server PlexClient _getClientForHub() { return context.getClientForServer(widget.hub.serverId!); } @override void initState() { super.initState(); // Start with items already loaded in the hub _items = widget.hub.items; _filteredItems = widget.hub.items; // Load more items if available if (widget.hub.more) { _loadMoreItems(); } // Load sorts based on the library type _loadSorts(); // Auto-focus first grid item in keyboard mode after first frame autoFocusFirstItemAfterLoad(); } @override void dispose() { disposeFocusResources(); super.dispose(); } Future _loadSorts() async { try { final client = _getClientForHub(); // Get the library key from the hub key // Hub keys can have various formats: // - /hubs/sections/1/... // - /library/sections/1/all?... final hubKey = widget.hub.hubKey; appLogger.d('Hub key: $hubKey'); // Try different patterns RegExpMatch? match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey); match ??= RegExp(r'/library/sections/(\d+)').firstMatch(hubKey); match ??= RegExp(r'sections/(\d+)').firstMatch(hubKey); if (match != null) { final sectionId = match.group(1)!; appLogger.d('Loading sorts for section: $sectionId'); // Load sorts for this library final sorts = await client.getLibrarySorts(sectionId); appLogger.d('Loaded ${sorts.length} sorts'); if (!mounted) return; setState(() { _sortOptions = sorts.isNotEmpty ? sorts : _getDefaultSortOptions(); // Don't set a default sort - let items stay in original order }); } else { appLogger.w('Could not extract section ID from hub key: $hubKey'); // Provide default sort options even if we can't get library-specific ones if (!mounted) return; setState(() { _sortOptions = _getDefaultSortOptions(); // Don't set a default sort - let items stay in original order }); } } catch (e) { appLogger.e('Failed to load sorts', error: e); // Provide default sort options on error if (!mounted) return; setState(() { _sortOptions = _getDefaultSortOptions(); // Don't set a default sort - let items stay in original order }); } } List _getDefaultSortOptions() { return [ PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'), PlexSort(key: 'year', descKey: 'year:desc', title: t.hubDetail.releaseYear, defaultDirection: 'desc'), PlexSort(key: 'addedAt', descKey: 'addedAt:desc', title: t.hubDetail.dateAdded, defaultDirection: 'desc'), PlexSort(key: 'rating', descKey: 'rating:desc', title: t.hubDetail.rating, defaultDirection: 'desc'), ]; } void _applySort() { setState(() { _filteredItems = List.from(_items); // Apply sorting if (_selectedSort != null) { final sortKey = _selectedSort!.key; _filteredItems.sort((a, b) { int comparison = 0; switch (sortKey) { case 'titleSort': case 'title': comparison = (a.title ?? '').compareTo(b.title ?? ''); break; case 'addedAt': comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0); break; case 'originallyAvailableAt': case 'year': comparison = (a.year ?? 0).compareTo(b.year ?? 0); break; case 'rating': comparison = (a.rating ?? 0).compareTo(b.rating ?? 0); break; default: comparison = (a.title ?? '').compareTo(b.title ?? ''); } return _isSortDescending ? -comparison : comparison; }); } }); } void _showSortBottomSheet() { final overlayContext = _overlayChildKey.currentContext ?? context; OverlaySheetController.of(overlayContext).show( builder: (context) => SortBottomSheet( sortOptions: _sortOptions, selectedSort: _selectedSort, isSortDescending: _isSortDescending, onSortChanged: (sort, descending) { setState(() { _selectedSort = sort; _isSortDescending = descending; }); _applySort(); }, onClear: () { setState(() { // Reset to no sorting (original order) _selectedSort = null; _isSortDescending = false; }); _applySort(); }, ), ); } Future _loadMoreItems() async { if (_isLoading) return; setState(() { _isLoading = true; _errorMessage = null; }); try { final client = _getClientForHub(); // Fetch items from the hub, tagged with server info at the source var items = await client.getHubContent(widget.hub.hubKey); // Filter to specific library if this hub was split from a multi-library hub final sectionFilter = widget.hub.librarySectionID; if (sectionFilter != null) { items = items.where((item) => item.librarySectionID == sectionFilter).toList(); } if (!mounted) return; setState(() { _items = items; _filteredItems = items; _isLoading = false; }); // Apply any existing sort _applySort(); appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}'); } catch (e) { appLogger.e('Failed to load hub content', error: e); if (!mounted) return; setState(() { _errorMessage = t.messages.errorLoading(error: e.toString()); _isLoading = false; }); } } void _handleItemRefresh(String ratingKey) { // Refresh the specific item in the list setState(() { final index = _items.indexWhere((item) => item.ratingKey == ratingKey); if (index != -1) { // The item will be refreshed by the MediaCard itself appLogger.d('Item refresh requested for: $ratingKey'); } }); } @override void refresh() { _loadMoreItems(); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (BackKeyCoordinator.consumeIfHandled()) return; if (didPop) return; final shouldPop = handleBackNavigation(); if (shouldPop && mounted) { Navigator.pop(context); } }, child: OverlaySheetHost( child: Scaffold( key: _overlayChildKey, body: CustomScrollView( controller: scrollController, clipBehavior: Clip.none, slivers: [ CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), if (_errorMessage != null) SliverFillRemaining( child: ErrorStateWidget( message: _errorMessage!, icon: Symbols.error_outline_rounded, onRetry: _loadMoreItems, ), ) else if (_filteredItems.isEmpty && _isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) else if (_filteredItems.isEmpty) SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) else Builder( builder: (context) { final settings = context.watch(); final isListMode = settings.viewMode == ViewMode.list; final episodePosterMode = settings.episodePosterMode; // Determine hub content type for layout decisions final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); // Mixed hub = has both episodes AND non-episodes final isMixedHub = hasEpisodes && hasNonEpisodes; // Episode-only = all items are episodes with thumbnails final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) final useWideLayout = episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); if (isListMode) { return SliverPadding( padding: const EdgeInsets.all(8), sliver: SliverList.builder( itemCount: _filteredItems.length, itemBuilder: (context, index) { final item = _filteredItems[index]; final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'hub_detail_item'); return FocusableMediaCard( focusNode: focusNode, item: item, disableScale: true, onRefresh: _handleItemRefresh, onNavigateUp: index == 0 ? navigateToAppBar : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), mixedHubContext: isMixedHub, ); }, ), ); } return SliverPadding( padding: const EdgeInsets.all(8), sliver: SliverLayoutBuilder( builder: (context, constraints) { final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( context, settings.libraryDensity, 16, ); final columnCount = GridSizeCalculator.getColumnCount( constraints.crossAxisExtent, useWideLayout ? maxExtent * 1.8 : maxExtent, ); return SliverGrid( gridDelegate: MediaGridDelegate.createDelegate( context: context, density: settings.libraryDensity, usePaddingAware: true, horizontalPadding: 16, useWideAspectRatio: useWideLayout, ), delegate: SliverChildBuilderDelegate((context, index) { final item = _filteredItems[index]; final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'hub_detail_item'); final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); return FocusableMediaCard( focusNode: focusNode, item: item, onRefresh: _handleItemRefresh, onNavigateUp: isFirstRow ? navigateToAppBar : null, onNavigateLeft: isFirstColumn ? () {} : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), mixedHubContext: isMixedHub, ); }, childCount: _filteredItems.length), ); }, ), ); }, ), ], ), ), ), ); } }