Files
plezy/lib/screens/hub_detail_screen.dart
T
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00

428 lines
15 KiB
Dart

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<HubDetailScreen> createState() => _HubDetailScreenState();
}
class _HubDetailScreenState extends State<HubDetailScreen>
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin {
PlexClient get client => _getClientForHub();
List<PlexMetadata> _items = [];
List<PlexMetadata> _filteredItems = [];
List<PlexSort> _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<FocusableAction> 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<void> _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<PlexSort> _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<void> _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<SettingsProvider>();
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,
),
);
},
),
);
},
),
],
),
),
),
);
}
}