fix(jellyfin): repair music library browsing

Fixes #1557
This commit is contained in:
edde746
2026-07-13 22:58:12 +02:00
parent d6ad6a9506
commit ec249d2ed4
51 changed files with 674 additions and 82 deletions
@@ -8,8 +8,25 @@ class SliverErrorState extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
final String? retryLabel;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const SliverErrorState({super.key, required this.message, this.onRetry, this.retryLabel});
const SliverErrorState({
super.key,
required this.message,
this.onRetry,
this.retryLabel,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
Widget build(BuildContext context) => SliverFillRemaining(
@@ -18,6 +35,12 @@ class SliverErrorState extends StatelessWidget {
icon: Symbols.error_outline_rounded,
onRetry: onRetry,
retryLabel: retryLabel ?? t.common.retry,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
),
);
}
@@ -30,6 +53,12 @@ class SliverEmptyState extends StatelessWidget {
final VoidCallback? onAction;
final String? actionLabel;
final IconData? actionIcon;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const SliverEmptyState({
super.key,
@@ -39,6 +68,12 @@ class SliverEmptyState extends StatelessWidget {
this.onAction,
this.actionLabel,
this.actionIcon,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
@@ -50,6 +85,12 @@ class SliverEmptyState extends StatelessWidget {
onAction: onAction,
actionLabel: actionLabel,
actionIcon: actionIcon,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
),
);
}
+16 -7
View File
@@ -25,13 +25,15 @@ import '../../theme/mono_tokens.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/loading_indicator_box.dart';
/// Individual item in the folder tree
/// Can be either a folder (expandable) or a file (tappable)
/// Individual item in the folder tree. [isExpandable] controls hierarchy
/// behavior independently from [isFolder], which identifies plain directory
/// rows and their folder-specific visuals/actions.
class FolderTreeItem extends StatefulWidget {
final MediaItem item;
final int depth;
final bool isExpanded;
final bool isFolder;
final bool isExpandable;
final VoidCallback? onTap;
final VoidCallback? onExpand;
final VoidCallback? onPlayAll;
@@ -50,6 +52,7 @@ class FolderTreeItem extends StatefulWidget {
required this.depth,
this.isExpanded = false,
this.isFolder = false,
this.isExpandable = false,
this.onTap,
this.onExpand,
this.onPlayAll,
@@ -69,10 +72,8 @@ class FolderTreeItem extends StatefulWidget {
class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixin {
/// Whether the row is a real media item that gets the standard media
/// context menu. Jellyfin series/seasons act as expandable folders in the
/// tree but are still real media items.
bool get _isMediaRow =>
!widget.isFolder || widget.item.kind == MediaKind.show || widget.item.kind == MediaKind.season;
/// presentation and context menu, even when it is also expandable.
bool get _isMediaRow => !widget.isFolder;
/// Plain folders only offer the Play/Shuffle actions of their trailing buttons.
bool get _hasFolderMenu => !_isMediaRow && (widget.onPlayAll != null || widget.onShuffle != null);
@@ -109,7 +110,7 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
}
void _handleTap() {
if (widget.isFolder) {
if (widget.isExpandable) {
widget.onExpand?.call();
} else {
widget.onTap?.call();
@@ -259,6 +260,7 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
final showUnwatchedCount = svc.read(SettingsService.showUnwatchedCount);
final expandIcon = widget.isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded;
final isWide = widget.item.usesWideAspectRatio(episodePosterMode);
final thumbWidth = isWide ? 130.0 : 53.0;
@@ -272,6 +274,13 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
child: Row(
crossAxisAlignment: .center,
children: [
if (widget.isExpandable) ...[
SizedBox(
width: 24,
child: widget.isLoading ? const LoadingIndicatorBox(size: 16) : AppIcon(expandIcon, fill: 1, size: 20),
),
const SizedBox(width: 8),
],
// Thumbnail with progress overlay
SizedBox(
width: thumbWidth,
+24 -12
View File
@@ -26,6 +26,7 @@ class FolderTreeView extends StatefulWidget {
final FocusNode? firstItemFocusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateLeft;
final VoidCallback? onBack;
const FolderTreeView({
super.key,
@@ -36,6 +37,7 @@ class FolderTreeView extends StatefulWidget {
this.firstItemFocusNode,
this.onNavigateUp,
this.onNavigateLeft,
this.onBack,
});
@override
@@ -65,7 +67,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
/// Stable expand/cache key for an expandable row: the backend folder key
/// where one exists (Plex `/folder` rows), the item id otherwise.
String? _folderIdentity(MediaItem item) {
if (!_isFolder(item)) return null;
if (!_isExpandable(item)) return null;
return item.backendFolderKey ?? item.id;
}
@@ -278,14 +280,18 @@ class FolderTreeViewState extends State<FolderTreeView> {
);
}
/// Expandable rows: directory rows (classified as [MediaKind.folder] by the
/// backend's folder fetchers) plus Jellyfin shows/seasons, which surface as
/// expandable media containers in folder browsing.
bool _isFolder(MediaItem item) {
/// Expandable rows: directory rows plus Jellyfin media containers whose
/// direct children form the folder tree. Music libraries expose folder-
/// backed artists and albums as MusicArtist/MusicAlbum rather than generic
/// Folder DTOs, so those rows must expand instead of opening empty details.
bool _isExpandable(MediaItem item) {
return item.kind == MediaKind.folder || (item.backend == MediaBackend.jellyfin && _isJellyfinMediaContainer(item));
}
bool _isJellyfinMediaContainer(MediaItem item) => item.kind == MediaKind.show || item.kind == MediaKind.season;
bool _isJellyfinMediaContainer(MediaItem item) {
if (item.kind == MediaKind.show || item.kind == MediaKind.season) return true;
return widget.libraryKind?.isMusic == true && (item.kind == MediaKind.artist || item.kind == MediaKind.album);
}
bool _canPlayFolder(MediaItem item) {
if (item.backend == MediaBackend.plex) return true;
@@ -324,7 +330,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
out.add((item: item, depth: depth, path: itemPath, parent: parent));
final folderKey = _folderIdentity(item);
if (_isFolder(item) &&
if (_isExpandable(item) &&
folderKey != null &&
_expandedFolders.contains(folderKey) &&
_childrenCache.containsKey(folderKey)) {
@@ -347,6 +353,10 @@ class FolderTreeViewState extends State<FolderTreeView> {
icon: Symbols.error_outline_rounded,
onRetry: _loadRootFolders,
retryLabel: t.common.retry,
actionFocusNode: widget.firstItemFocusNode,
onActionNavigateUp: widget.onNavigateUp,
onActionNavigateLeft: widget.onNavigateLeft,
onActionBack: widget.onBack,
),
);
}
@@ -368,12 +378,13 @@ class FolderTreeViewState extends State<FolderTreeView> {
itemBuilder: (context, index) {
final entry = flattened[index];
final item = entry.item;
final isFolder = _isFolder(item);
final isExpandable = _isExpandable(item);
final isPlainFolder = item.kind == MediaKind.folder;
final folderKey = _folderIdentity(item);
final isExpanded = folderKey != null && _expandedFolders.contains(folderKey);
final isLoading = folderKey != null && _loadingFolders.contains(folderKey);
final isFirstRootItem = index == 0;
final canPlayFolder = isFolder && _canPlayFolder(item);
final canPlayFolder = isPlainFolder && _canPlayFolder(item);
return FolderTreeItem(
// Path alone isn't unique enough as identity (the same Plex item
@@ -381,12 +392,13 @@ class FolderTreeViewState extends State<FolderTreeView> {
key: ValueKey('${entry.path}:${item.id}'),
item: item,
depth: entry.depth,
isFolder: isFolder,
isFolder: isPlainFolder,
isExpandable: isExpandable,
isExpanded: isExpanded,
isLoading: isLoading,
serverId: widget.serverId,
onExpand: isFolder ? () => _toggleFolder(item) : null,
onTap: !isFolder ? () => _handleItemTap(item) : null,
onExpand: isExpandable ? () => _toggleFolder(item) : null,
onTap: !isExpandable ? () => _handleItemTap(item) : null,
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
@@ -37,7 +37,7 @@ class LibraryFilterSortLoader {
Future<LoadedFiltersAndSorts> load(MediaLibrary library, {String? sortLibraryType}) async {
final client = clientFor(library);
final results = await Future.wait([
client.fetchLibraryFiltersWithValues(library.id),
client.fetchLibraryFiltersWithValues(library.id, libraryKind: library.kind),
client.fetchSortOptions(library.id, libraryType: sortLibraryType ?? library.kind.id),
]);
final filterResult = results.first as LibraryFilterResult;
+61 -1
View File
@@ -36,6 +36,12 @@ class StateMessageWidget extends StatelessWidget {
/// Optional icon for the action button
final IconData? actionIcon;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const StateMessageWidget({
super.key,
@@ -48,6 +54,12 @@ class StateMessageWidget extends StatelessWidget {
this.subtitleColor,
this.onAction,
this.actionLabel,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
this.actionIcon,
});
@@ -87,6 +99,12 @@ class StateMessageWidget extends StatelessWidget {
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
FocusableButton(
focusNode: actionFocusNode,
onNavigateUp: onActionNavigateUp,
onNavigateDown: onActionNavigateDown,
onNavigateLeft: onActionNavigateLeft,
onNavigateRight: onActionNavigateRight,
onBack: onActionBack,
onPressed: onAction,
child: FilledButton.icon(
onPressed: onAction,
@@ -124,6 +142,12 @@ class EmptyStateWidget extends StatelessWidget {
/// Optional icon for the action button (defaults to a generic add icon)
final IconData? actionIcon;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const EmptyStateWidget({
super.key,
@@ -134,6 +158,12 @@ class EmptyStateWidget extends StatelessWidget {
this.onAction,
this.actionLabel,
this.actionIcon,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
@@ -146,6 +176,12 @@ class EmptyStateWidget extends StatelessWidget {
onAction: onAction,
actionLabel: actionLabel,
actionIcon: actionIcon ?? Symbols.add_rounded,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
);
}
}
@@ -163,8 +199,26 @@ class ErrorStateWidget extends StatelessWidget {
/// Optional label for the retry button
final String? retryLabel;
final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
const ErrorStateWidget({super.key, required this.message, this.icon, this.onRetry, this.retryLabel});
const ErrorStateWidget({
super.key,
required this.message,
this.icon,
this.onRetry,
this.retryLabel,
this.actionFocusNode,
this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
});
@override
Widget build(BuildContext context) {
@@ -176,6 +230,12 @@ class ErrorStateWidget extends StatelessWidget {
onAction: onRetry,
actionLabel: retryLabel ?? 'Retry',
actionIcon: Symbols.refresh_rounded,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
);
}
}
@@ -426,6 +426,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
@override
Widget buildContent(List<MediaItem> items) => const SizedBox.shrink();
bool get _hasFocusableStateAction =>
(errorMessage != null && loadedItems.isEmpty) || (totalSize == 0 && !isLoading && _selectedFilters.isNotEmpty);
/// Focus the first item in the grid/list/folder tree (for tab activation)
@override
void focusFirstItem() {
@@ -442,11 +445,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return;
}
if (loadedItems.isNotEmpty) {
if (loadedItems.isNotEmpty || _hasFocusableStateAction) {
// 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) {
if (mounted && (loadedItems.isNotEmpty || _hasFocusableStateAction) && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -457,7 +460,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
@override
bool get hasFocusableContent => _selectedGrouping == 'folders' || loadedItems.isNotEmpty;
bool get hasFocusableContent => _selectedGrouping == 'folders' || loadedItems.isNotEmpty || _hasFocusableStateAction;
@override
void focusContentOrChrome() {
@@ -610,7 +613,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final client = context.getMediaClientForLibrary(widget.library);
unawaited(
client
.fetchLibraryFiltersWithValues(widget.library.id)
.fetchLibraryFiltersWithValues(widget.library.id, libraryKind: widget.library.kind)
.then((result) {
if (generation != _contentRequestId || !mounted) return;
setState(() {
@@ -1113,10 +1116,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return;
}
if (totalSize == 0) return;
if (totalSize == 0 && !_hasFocusableStateAction) return;
final targetIndex =
shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
final targetIndex = totalSize == 0
? 0
: shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
? lastFocusedGridIndex!
: 0;
@@ -1748,6 +1752,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
firstItemFocusNode: firstItemFocusNode,
onNavigateUp: _navigateToChips,
onNavigateLeft: _navigateToSidebar,
onBack: widget.onBack,
),
];
}
@@ -1757,7 +1762,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
if (errorMessage != null && loadedItems.isEmpty) {
return [SliverErrorState(message: errorMessage!, onRetry: _loadContent)];
return [
SliverErrorState(
message: errorMessage!,
onRetry: _loadContent,
actionFocusNode: firstItemFocusNode,
onActionNavigateUp: _navigateToChips,
onActionNavigateLeft: _navigateToSidebar,
onActionBack: widget.onBack,
),
];
}
if (totalSize == 0 && !isLoading) {
@@ -1769,6 +1783,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
onAction: _resetFilters,
actionLabel: t.libraries.resetFilters,
actionIcon: Symbols.clear_all_rounded,
actionFocusNode: firstItemFocusNode,
onActionNavigateUp: _navigateToChips,
onActionNavigateLeft: _navigateToSidebar,
onActionBack: widget.onBack,
),
];
}
@@ -1869,6 +1887,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_selectedGrouping == browseGroupingAlbums ||
_selectedGrouping == browseGroupingTracks;
final browseShape = isMusicGrouping ? CardShape.square : null;
// Full-bleed TV cards intentionally hide captions. Music artwork alone
// is not a reliable identity, so artist/album/track grids always keep the
// standard captioned card while preserving their circular/square artwork.
final useFullCardLayout = fullCardLayout && !isMusicGrouping;
if (viewMode == ViewMode.list) {
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio, shape: browseShape);
@@ -1882,7 +1904,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
useWideAspectRatio: useWideRatio,
shape: browseShape,
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
crossAxisExtentForColumnCount: hasAlphaBarReservation
? (crossAxisExtent) => crossAxisExtent + (rightPadding - 8.0)
: null,
@@ -1899,7 +1921,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
ViewMode.grid,
geometry.columnCount,
itemCount,
fullCardLayout,
useFullCardLayout,
useWideRatio,
browseShape,
libraryDensity,
@@ -1951,7 +1973,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
isLastColumn: position.isLastColumn,
columnCount: position.columnCount,
itemCount: itemCount,
fullBleedImage: fullCardLayout,
fullBleedImage: useFullCardLayout,
),
);
},