fix(folders): row context menus, dpad left to sidebar, faster Jellyfin listings

This commit is contained in:
edde746
2026-06-12 02:00:42 +02:00
parent c89c88647e
commit d816d82a38
7 changed files with 317 additions and 51 deletions
+9
View File
@@ -389,6 +389,15 @@ sealed class MediaItem with _$MediaItem {
return viewCount != null && viewCount! > 0; return viewCount != null && viewCount! > 0;
} }
/// Unwatched leaf count for container badges. Falls back to Jellyfin's
/// `UserData.UnplayedItemCount` when leaf totals weren't requested
/// (e.g. the folder tree's slim field set).
int? get unwatchedCount {
if (leafCount != null && viewedLeafCount != null) return leafCount! - viewedLeafCount!;
final userData = raw?['UserData'];
return userData is Map<String, dynamic> ? userData['UnplayedItemCount'] as int? : null;
}
/// Copy with the watched flag applied so [isWatched] reflects it for every /// Copy with the watched flag applied so [isWatched] reflects it for every
/// kind: containers need their leaf counts patched, not just [viewCount]. /// kind: containers need their leaf counts patched, not just [viewCount].
MediaItem withWatchedFlag(bool isWatched) { MediaItem withWatchedFlag(bool isWatched) {
+4
View File
@@ -12,6 +12,10 @@ mixin ContextMenuTapMixin<T extends StatefulWidget> on State<T> {
_tapPosition = details.globalPosition; _tapPosition = details.globalPosition;
} }
/// Last stored tap position, for menus shown outside [MediaContextMenu]
/// (null when activated via keyboard/gamepad).
Offset? get lastTapPosition => _tapPosition;
bool get isContextMenuOpen => contextMenuKey.currentState?.isContextMenuOpen ?? false; bool get isContextMenuOpen => contextMenuKey.currentState?.isContextMenuOpen ?? false;
/// Show at the last tap position (long-press, mouse). /// Show at the last tap position (long-press, mouse).
+155 -25
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import '../../media/ids.dart'; import '../../media/ids.dart';
@@ -10,19 +11,23 @@ import '../../focus/focusable_wrapper.dart';
import '../../media/media_item.dart'; import '../../media/media_item.dart';
import '../../media/media_item_types.dart'; import '../../media/media_item_types.dart';
import '../../media/media_kind.dart'; import '../../media/media_kind.dart';
import '../../mixins/context_menu_tap_mixin.dart';
import '../../services/settings_service.dart'; import '../../services/settings_service.dart';
import '../../widgets/settings_builder.dart'; import '../../widgets/settings_builder.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../utils/provider_extensions.dart'; import '../../utils/provider_extensions.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/media_progress_bar.dart'; import '../../widgets/media_progress_bar.dart';
import '../../widgets/optimized_media_image.dart'; import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart';
import '../../theme/mono_tokens.dart'; import '../../theme/mono_tokens.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../widgets/loading_indicator_box.dart'; import '../../widgets/loading_indicator_box.dart';
/// Individual item in the folder tree /// Individual item in the folder tree
/// Can be either a folder (expandable) or a file (tappable) /// Can be either a folder (expandable) or a file (tappable)
class FolderTreeItem extends StatelessWidget { class FolderTreeItem extends StatefulWidget {
final MediaItem item; final MediaItem item;
final int depth; final int depth;
final bool isExpanded; final bool isExpanded;
@@ -34,6 +39,9 @@ class FolderTreeItem extends StatelessWidget {
final bool isLoading; final bool isLoading;
final FocusNode? focusNode; final FocusNode? focusNode;
final VoidCallback? onNavigateUp; final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateLeft;
final void Function(String itemId)? onRefresh;
final VoidCallback? onListRefresh;
final String? serverId; final String? serverId;
const FolderTreeItem({ const FolderTreeItem({
@@ -49,15 +57,34 @@ class FolderTreeItem extends StatelessWidget {
this.isLoading = false, this.isLoading = false,
this.focusNode, this.focusNode,
this.onNavigateUp, this.onNavigateUp,
this.onNavigateLeft,
this.onRefresh,
this.onListRefresh,
this.serverId, this.serverId,
}); });
@override
State<FolderTreeItem> createState() => _FolderTreeItemState();
}
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;
/// Plain folders only offer the Play/Shuffle actions of their trailing buttons.
bool get _hasFolderMenu => !_isMediaRow && (widget.onPlayAll != null || widget.onShuffle != null);
bool get _hasMenu => _isMediaRow || _hasFolderMenu;
IconData _getIcon() { IconData _getIcon() {
if (isFolder) { if (widget.isFolder) {
return Symbols.folder_rounded; return Symbols.folder_rounded;
} }
return switch (item.kind) { return switch (widget.item.kind) {
MediaKind.movie => Symbols.movie_rounded, MediaKind.movie => Symbols.movie_rounded,
MediaKind.show => Symbols.tv_rounded, MediaKind.show => Symbols.tv_rounded,
MediaKind.season => Symbols.video_library_rounded, MediaKind.season => Symbols.video_library_rounded,
@@ -68,12 +95,12 @@ class FolderTreeItem extends StatelessWidget {
} }
String _rowTitle() { String _rowTitle() {
final title = item.title?.trim(); final title = widget.item.title?.trim();
if (title != null && title.isNotEmpty) return title; if (title != null && title.isNotEmpty) return title;
return item.displayTitle; return widget.item.displayTitle;
} }
MediaItem _effectiveItem(BuildContext context) => context.withFreshWatchState(item); MediaItem _effectiveItem(BuildContext context) => context.withFreshWatchState(widget.item);
String? _dedupeSubtitle(String? subtitle) { String? _dedupeSubtitle(String? subtitle) {
final value = subtitle?.trim(); final value = subtitle?.trim();
@@ -82,14 +109,89 @@ class FolderTreeItem extends StatelessWidget {
} }
void _handleTap() { void _handleTap() {
if (isFolder) { if (widget.isFolder) {
onExpand?.call(); widget.onExpand?.call();
} else { } else {
onTap?.call(); widget.onTap?.call();
}
}
void _showRowMenu() {
if (_isMediaRow) {
showContextMenuFromTap();
} else {
_showFolderMenu();
}
}
/// Center of the row, used to anchor the folder menu for keyboard/d-pad
/// activation (mirrors [MediaContextMenuState.showContextMenu]).
Offset _rowCenter() {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return Offset.zero;
final size = renderBox.size;
final topLeft = renderBox.localToGlobal(Offset.zero);
return Offset(topLeft.dx + size.width / 2, topLeft.dy + size.height / 2);
}
/// Ad-hoc Play/Shuffle menu for plain folder rows. These map to pseudo
/// [MediaItem]s (Plex directories, Jellyfin folders), so [MediaContextMenu]
/// would offer actions that don't apply to them.
Future<void> _showFolderMenu() async {
final entries = <AppMenuEntry<String>>[
if (widget.onPlayAll != null)
AppMenuItem<String>(value: 'play', icon: Symbols.play_arrow_rounded, label: t.common.play),
if (widget.onShuffle != null)
AppMenuItem<String>(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.common.shuffle),
];
if (entries.isEmpty) return;
final previousFocus = FocusManager.instance.primaryFocus;
final position = lastTapPosition;
final fromKeyboard = position == null;
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
String? selected;
if (useBottomSheet) {
selected = await OverlaySheetController.showAdaptive<String>(
context,
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(title: _rowTitle(), entries: entries, focusFirstItem: fromKeyboard),
);
} else {
selected = await showAppMenu<String>(
context,
entries: entries,
position: position ?? _rowCenter(),
focusFirstItem: fromKeyboard,
);
}
if (!mounted) return;
if (selected == null) {
// Dismissed — restore focus to the row. Play/Shuffle hand off to the
// player instead (mirrors MediaContextMenu's didNavigate handling).
if (previousFocus != null && previousFocus.canRequestFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (previousFocus.canRequestFocus) {
previousFocus.requestFocus();
}
});
}
return;
}
switch (selected) {
case 'play':
widget.onPlayAll?.call();
case 'shuffle':
widget.onShuffle?.call();
} }
} }
String? _buildSubtitle() { String? _buildSubtitle() {
final item = widget.item;
if (item.isEpisode) { if (item.isEpisode) {
final parts = <String>[]; final parts = <String>[];
if (item.parentIndex != null && item.index != null) { if (item.parentIndex != null && item.index != null) {
@@ -104,6 +206,7 @@ class FolderTreeItem extends StatelessWidget {
} }
String _buildMetadataLine() { String _buildMetadataLine() {
final item = widget.item;
final parts = <String>[]; final parts = <String>[];
if (item.contentRating != null && item.contentRating!.isNotEmpty) { if (item.contentRating != null && item.contentRating!.isNotEmpty) {
@@ -123,8 +226,8 @@ class FolderTreeItem extends StatelessWidget {
} }
Widget _buildFolderRow(BuildContext context) { Widget _buildFolderRow(BuildContext context) {
final indentation = depth * 24.0; final indentation = widget.depth * 24.0;
final expandIcon = isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded; final expandIcon = widget.isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded;
return Container( return Container(
padding: .only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), padding: .only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0),
@@ -132,7 +235,7 @@ class FolderTreeItem extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: 24, width: 24,
child: isLoading ? const LoadingIndicatorBox(size: 16) : AppIcon(expandIcon, fill: 1, size: 20), child: widget.isLoading ? const LoadingIndicatorBox(size: 16) : AppIcon(expandIcon, fill: 1, size: 20),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AppIcon(_getIcon(), fill: 1, size: 20, color: Theme.of(context).colorScheme.primary), AppIcon(_getIcon(), fill: 1, size: 20, color: Theme.of(context).colorScheme.primary),
@@ -151,13 +254,13 @@ class FolderTreeItem extends StatelessWidget {
} }
Widget _buildMediaRow(BuildContext context) { Widget _buildMediaRow(BuildContext context) {
final indentation = depth * 24.0; final indentation = widget.depth * 24.0;
final svc = SettingsService.instance; final svc = SettingsService.instance;
final episodePosterMode = svc.read(SettingsService.episodePosterMode); final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final hideSpoilers = svc.read(SettingsService.hideSpoilers); final hideSpoilers = svc.read(SettingsService.hideSpoilers);
final showUnwatchedCount = svc.read(SettingsService.showUnwatchedCount); final showUnwatchedCount = svc.read(SettingsService.showUnwatchedCount);
final isWide = item.usesWideAspectRatio(episodePosterMode); final isWide = widget.item.usesWideAspectRatio(episodePosterMode);
final thumbWidth = isWide ? 130.0 : 53.0; final thumbWidth = isWide ? 130.0 : 53.0;
final thumbHeight = isWide ? 73.0 : 80.0; final thumbHeight = isWide ? 73.0 : 80.0;
@@ -240,9 +343,10 @@ class FolderTreeItem extends StatelessWidget {
double width, double width,
double height, double height,
) { ) {
final item = widget.item;
final posterUrl = item.posterThumb(mode: episodePosterMode); final posterUrl = item.posterThumb(mode: episodePosterMode);
// Backend-neutral so Jellyfin items render via Jellyfin's transcoder. // Backend-neutral so Jellyfin items render via Jellyfin's transcoder.
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(serverId)); final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.serverId));
final shouldBlur = final shouldBlur =
hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail;
@@ -277,6 +381,7 @@ class FolderTreeItem extends StatelessWidget {
// Shadows the field with the session-fresh view; everything below reads it. // Shadows the field with the session-fresh view; everything below reads it.
final item = _effectiveItem(context); final item = _effectiveItem(context);
final hasActiveProgress = item.hasActiveProgress; final hasActiveProgress = item.hasActiveProgress;
final unwatchedCount = item.unwatchedCount;
return Stack( return Stack(
children: [ children: [
@@ -299,7 +404,8 @@ class FolderTreeItem extends StatelessWidget {
if (showUnwatchedCount && if (showUnwatchedCount &&
!item.isWatched && !item.isWatched &&
(item.kind == MediaKind.show || item.kind == MediaKind.season) && (item.kind == MediaKind.show || item.kind == MediaKind.season) &&
(item.leafCount != null && item.leafCount! > 0 && item.viewedLeafCount != null)) unwatchedCount != null &&
unwatchedCount > 0)
Positioned( Positioned(
top: 3, top: 3,
right: 3, right: 3,
@@ -313,7 +419,7 @@ class FolderTreeItem extends StatelessWidget {
), ),
alignment: .center, alignment: .center,
child: Text( child: Text(
'${item.leafCount! - item.viewedLeafCount!}', '$unwatchedCount',
style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: .bold), style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: .bold),
), ),
), ),
@@ -351,9 +457,10 @@ class FolderTreeItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final playAll = onPlayAll; final playAll = widget.onPlayAll;
final shuffle = onShuffle; final shuffle = widget.onShuffle;
final rowContent = isFolder final hasMenu = _hasMenu;
final rowContent = widget.isFolder
? _buildFolderRow(context) ? _buildFolderRow(context)
: SettingsBuilder( : SettingsBuilder(
prefs: const [ prefs: const [
@@ -364,23 +471,46 @@ class FolderTreeItem extends StatelessWidget {
builder: _buildMediaRow, builder: _buildMediaRow,
); );
Widget gesture = GestureDetector(
onTap: _handleTap,
onTapDown: hasMenu ? storeTapPosition : null,
onLongPress: hasMenu ? _showRowMenu : null,
onSecondaryTapDown: hasMenu ? storeTapPosition : null,
onSecondaryTap: hasMenu ? _showRowMenu : null,
behavior: HitTestBehavior.opaque,
child: rowContent,
);
if (_isMediaRow) {
gesture = MediaContextMenu(
key: contextMenuKey,
item: widget.item,
onRefresh: widget.onRefresh,
onListRefresh: widget.onListRefresh,
child: gesture,
);
}
return Row( return Row(
children: [ children: [
// Main item row // Main item row
Expanded( Expanded(
child: FocusableWrapper( child: FocusableWrapper(
focusNode: focusNode, focusNode: widget.focusNode,
onSelect: _handleTap, onSelect: _handleTap,
onNavigateUp: onNavigateUp, enableLongPress: hasMenu,
onLongPress: hasMenu ? _showRowMenu : null,
onNavigateUp: widget.onNavigateUp,
onNavigateLeft: widget.onNavigateLeft,
useBackgroundFocus: true, useBackgroundFocus: true,
disableScale: true, disableScale: true,
descendantsAreFocusable: false, descendantsAreFocusable: false,
child: GestureDetector(onTap: _handleTap, behavior: HitTestBehavior.opaque, child: rowContent), child: gesture,
), ),
), ),
// Play/Shuffle buttons for folders when the backend supports them. // Play/Shuffle buttons for folders when the backend supports them.
if (isFolder && playAll != null) ...[ if (widget.isFolder && playAll != null) ...[
FocusableButton( FocusableButton(
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: playAll, onPressed: playAll,
@@ -400,7 +530,7 @@ class FolderTreeItem extends StatelessWidget {
), ),
), ),
], ],
if (isFolder && shuffle != null) ...[ if (widget.isFolder && shuffle != null) ...[
FocusableButton( FocusableButton(
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: shuffle, onPressed: shuffle,
+107 -21
View File
@@ -27,6 +27,7 @@ class FolderTreeView extends StatefulWidget {
final void Function(String)? onRefresh; final void Function(String)? onRefresh;
final FocusNode? firstItemFocusNode; final FocusNode? firstItemFocusNode;
final VoidCallback? onNavigateUp; final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateLeft;
const FolderTreeView({ const FolderTreeView({
super.key, super.key,
@@ -36,6 +37,7 @@ class FolderTreeView extends StatefulWidget {
this.onRefresh, this.onRefresh,
this.firstItemFocusNode, this.firstItemFocusNode,
this.onNavigateUp, this.onNavigateUp,
this.onNavigateLeft,
}); });
@override @override
@@ -57,6 +59,11 @@ class FolderTreeViewState extends State<FolderTreeView> {
bool _isLoadingRoot = false; bool _isLoadingRoot = false;
String? _errorMessage; String? _errorMessage;
/// Generation counter for in-flight loads. Jellyfin folder fetches render
/// page-by-page via `onPage`; a root reload or deletion refresh bumps the
/// epoch so superseded pagination callbacks are dropped.
int _loadEpoch = 0;
/// Resolve the Plex folder key from a [MediaItem]'s `raw` map. The key is /// Resolve the Plex folder key from a [MediaItem]'s `raw` map. The key is
/// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used to /// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used to
/// recursively fetch children from [PlexClient.fetchFolderChildren]. /// recursively fetch children from [PlexClient.fetchFolderChildren].
@@ -76,7 +83,20 @@ class FolderTreeViewState extends State<FolderTreeView> {
_loadRootFolders(); _loadRootFolders();
} }
/// Invalidate in-flight loads (epoch bump) and drop their partial results
/// so superseded pagination can't leave truncated listings behind.
int _supersedeInFlightLoads() {
final epoch = ++_loadEpoch;
for (final id in _loadingFolders) {
_childrenCache.remove(id);
_expandedFolders.remove(id);
}
_loadingFolders.clear();
return epoch;
}
Future<void> _loadRootFolders() async { Future<void> _loadRootFolders() async {
final epoch = _supersedeInFlightLoads();
setState(() { setState(() {
_isLoadingRoot = true; _isLoadingRoot = true;
_errorMessage = null; _errorMessage = null;
@@ -84,9 +104,18 @@ class FolderTreeViewState extends State<FolderTreeView> {
try { try {
final client = context.getMediaClientForServer(ServerId(widget.serverId!)); final client = context.getMediaClientForServer(ServerId(widget.serverId!));
final folders = await _fetchRootFolders(client); final folders = await _fetchRootFolders(
client,
onPage: (items) {
if (!mounted || epoch != _loadEpoch) return;
setState(() {
_rootFolders = items;
_isLoadingRoot = false;
});
},
);
if (!mounted) return; if (!mounted || epoch != _loadEpoch) return;
setState(() { setState(() {
_rootFolders = folders; _rootFolders = folders;
@@ -95,7 +124,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
appLogger.d('Loaded ${folders.length} root folders'); appLogger.d('Loaded ${folders.length} root folders');
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted || epoch != _loadEpoch) return;
setState(() { setState(() {
_errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); _errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
@@ -108,8 +137,16 @@ class FolderTreeViewState extends State<FolderTreeView> {
final folderIdentity = _folderIdentity(folder); final folderIdentity = _folderIdentity(folder);
if (folderIdentity == null) return; if (folderIdentity == null) return;
// Already loading this folder // Already loading this folder — re-expand if partial pages are showing
if (_loadingFolders.contains(folderIdentity)) return; // (the user collapsed and re-expanded mid-pagination).
if (_loadingFolders.contains(folderIdentity)) {
if (_childrenCache.containsKey(folderIdentity)) {
setState(() {
_expandedFolders.add(folderIdentity);
});
}
return;
}
// Already loaded and cached // Already loaded and cached
if (_childrenCache.containsKey(folderIdentity)) { if (_childrenCache.containsKey(folderIdentity)) {
@@ -119,29 +156,47 @@ class FolderTreeViewState extends State<FolderTreeView> {
return; return;
} }
final epoch = _loadEpoch;
var pageDelivered = false;
setState(() { setState(() {
_loadingFolders.add(folderIdentity); _loadingFolders.add(folderIdentity);
}); });
try { try {
final client = context.getMediaClientForServer(ServerId(widget.serverId!)); final client = context.getMediaClientForServer(ServerId(widget.serverId!));
final children = await _fetchFolderChildren(client, folder); final children = await _fetchFolderChildren(
client,
folder,
onPage: (items) {
if (!mounted || epoch != _loadEpoch) return;
pageDelivered = true;
setState(() {
_childrenCache[folderIdentity] = items;
_expandedFolders.add(folderIdentity);
});
},
);
if (!mounted) return; if (!mounted || epoch != _loadEpoch) return;
setState(() { setState(() {
_childrenCache[folderIdentity] = children; _childrenCache[folderIdentity] = children;
_expandedFolders.add(folderIdentity); // Pages already expanded the folder; don't override a mid-load collapse.
if (!pageDelivered) _expandedFolders.add(folderIdentity);
_loadingFolders.remove(folderIdentity); _loadingFolders.remove(folderIdentity);
}); });
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted || epoch != _loadEpoch) return;
final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
setState(() { setState(() {
_loadingFolders.remove(folderIdentity); _loadingFolders.remove(folderIdentity);
// Drop partial pages so a retry refetches instead of leaving a
// truncated listing that looks complete.
_childrenCache.remove(folderIdentity);
_expandedFolders.remove(folderIdentity);
}); });
if (mounted) { if (mounted) {
@@ -162,6 +217,23 @@ class FolderTreeViewState extends State<FolderTreeView> {
} }
} }
/// Refetch the listing containing a deleted item: the whole tree for root
/// items, otherwise just the enclosing folder.
void _refreshAfterDeletion(MediaItem? parent) {
if (parent == null) {
_loadRootFolders();
return;
}
_supersedeInFlightLoads();
final folderIdentity = _folderIdentity(parent);
if (folderIdentity != null) {
setState(() {
_childrenCache.remove(folderIdentity);
});
}
_loadFolderChildren(parent);
}
Future<void> _handleItemTap(MediaItem item) async { Future<void> _handleItemTap(MediaItem item) async {
final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
if (!context.mounted) return; if (!context.mounted) return;
@@ -239,13 +311,20 @@ class FolderTreeViewState extends State<FolderTreeView> {
return false; return false;
} }
Future<List<MediaItem>> _fetchRootFolders(MediaServerClient client) { Future<List<MediaItem>> _fetchRootFolders(
MediaServerClient client, {
void Function(List<MediaItem> itemsSoFar)? onPage,
}) {
if (client is PlexClient) return client.fetchLibraryFolders(widget.libraryKey); if (client is PlexClient) return client.fetchLibraryFolders(widget.libraryKey);
if (client is JellyfinClient) return client.fetchLibraryFolders(widget.libraryKey); if (client is JellyfinClient) return client.fetchLibraryFolders(widget.libraryKey, onPage: onPage);
throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}'); throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}');
} }
Future<List<MediaItem>> _fetchFolderChildren(MediaServerClient client, MediaItem folder) { Future<List<MediaItem>> _fetchFolderChildren(
MediaServerClient client,
MediaItem folder, {
void Function(List<MediaItem> itemsSoFar)? onPage,
}) {
if (client is PlexClient) { if (client is PlexClient) {
final folderKey = _folderKey(folder); final folderKey = _folderKey(folder);
if (folderKey == null) return Future.value(const <MediaItem>[]); if (folderKey == null) return Future.value(const <MediaItem>[]);
@@ -253,30 +332,32 @@ class FolderTreeViewState extends State<FolderTreeView> {
} }
if (client is JellyfinClient) { if (client is JellyfinClient) {
if (_isJellyfinMediaContainer(folder)) return client.fetchChildren(folder.id); if (_isJellyfinMediaContainer(folder)) return client.fetchChildren(folder.id);
return client.fetchFolderChildren(folder.id); return client.fetchFolderChildren(folder.id, onPage: onPage);
} }
throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}'); throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}');
} }
/// Flatten the visible tree into a list of (item, depth, path) tuples so /// Flatten the visible tree into a list of (item, depth, path, parent)
/// `ListView.builder` can lazy-build only the rows currently on screen. /// tuples so `ListView.builder` can lazy-build only the rows currently on
/// screen. [parent] is the enclosing folder (null for root items).
void _flattenTreeItems( void _flattenTreeItems(
List<MediaItem> items, List<MediaItem> items,
int depth, int depth,
String parentPath, String parentPath,
List<({MediaItem item, int depth, String path})> out, MediaItem? parent,
List<({MediaItem item, int depth, String path, MediaItem? parent})> out,
) { ) {
for (int i = 0; i < items.length; i++) { for (int i = 0; i < items.length; i++) {
final item = items[i]; final item = items[i];
final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i'; final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i';
out.add((item: item, depth: depth, path: itemPath)); out.add((item: item, depth: depth, path: itemPath, parent: parent));
final folderKey = _folderIdentity(item); final folderKey = _folderIdentity(item);
if (_isFolder(item) && if (_isFolder(item) &&
folderKey != null && folderKey != null &&
_expandedFolders.contains(folderKey) && _expandedFolders.contains(folderKey) &&
_childrenCache.containsKey(folderKey)) { _childrenCache.containsKey(folderKey)) {
_flattenTreeItems(_childrenCache[folderKey]!, depth + 1, itemPath, out); _flattenTreeItems(_childrenCache[folderKey]!, depth + 1, itemPath, item, out);
} }
} }
} }
@@ -306,8 +387,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
); );
} }
final flattened = <({MediaItem item, int depth, String path})>[]; final flattened = <({MediaItem item, int depth, String path, MediaItem? parent})>[];
_flattenTreeItems(_rootFolders, 0, '', flattened); _flattenTreeItems(_rootFolders, 0, '', null, flattened);
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -324,7 +405,9 @@ class FolderTreeViewState extends State<FolderTreeView> {
final canPlayFolder = isFolder && _canPlayFolder(item); final canPlayFolder = isFolder && _canPlayFolder(item);
return FolderTreeItem( return FolderTreeItem(
key: ValueKey(entry.path), // Path alone isn't unique enough as identity (the same Plex item
// can appear under two folders), so combine it with the item id.
key: ValueKey('${entry.path}:${item.id}'),
item: item, item: item,
depth: entry.depth, depth: entry.depth,
isFolder: isFolder, isFolder: isFolder,
@@ -337,6 +420,9 @@ class FolderTreeViewState extends State<FolderTreeView> {
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
onNavigateLeft: widget.onNavigateLeft,
onRefresh: widget.onRefresh,
onListRefresh: () => _refreshAfterDeletion(entry.parent),
); );
}, },
), ),
@@ -1610,6 +1610,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
onRefresh: updateItem, onRefresh: updateItem,
firstItemFocusNode: firstItemFocusNode, firstItemFocusNode: firstItemFocusNode,
onNavigateUp: _navigateToChips, onNavigateUp: _navigateToChips,
onNavigateLeft: _navigateToSidebar,
), ),
]; ];
} }
+28 -4
View File
@@ -33,6 +33,15 @@ const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,Origi
/// queries because it is the heaviest item field Jellyfin returns. /// queries because it is the heaviest item field Jellyfin returns.
const _episodeRowFields = '$_browseFields,MediaSources'; const _episodeRowFields = '$_browseFields,MediaSources';
/// Folder-tree field set. The tree renders title/thumb/watch state plus
/// default dto fields (year, runtime, ratings); it deliberately skips
/// `RecursiveItemCount`/`ChildCount` — per-item COUNT queries the server
/// runs for every folder/series row, which made large folder listings very
/// slow — and `Overview`, which the tree never shows. Jellyfin web's folder
/// view requests none of them either. The unwatched badge survives via
/// `UserData.UnplayedItemCount` ([MediaItem.unwatchedCount] fallback).
const _folderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName';
/// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode /// only need title, thumbnail (`ImageTags['Primary']`), season/episode
/// index, and watched state. Title + indices come back without any /// index, and watched state. Title + indices come back without any
@@ -609,13 +618,23 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
/// direct children of the library/folder with `Recursive=false`. This is /// direct children of the library/folder with `Recursive=false`. This is
/// distinct from [fetchLibraryContent], which intentionally recurses through /// distinct from [fetchLibraryContent], which intentionally recurses through
/// a library to show metadata groupings like albums, artists, shows, etc. /// a library to show metadata groupings like albums, artists, shows, etc.
Future<List<MediaItem>> fetchLibraryFolders(String libraryId) => _fetchFolderChildren(libraryId); Future<List<MediaItem>> fetchLibraryFolders(String libraryId, {void Function(List<MediaItem> itemsSoFar)? onPage}) =>
_fetchFolderChildren(libraryId, onPage: onPage);
/// Contents of a Jellyfin folder. Kept separate from [fetchChildren] so the /// Contents of a Jellyfin folder. Kept separate from [fetchChildren] so the
/// folder tree can use direct-child semantics even for music libraries. /// folder tree can use direct-child semantics even for music libraries.
Future<List<MediaItem>> fetchFolderChildren(String folderId) => _fetchFolderChildren(folderId); ///
/// [onPage] surfaces the accumulated items (server order) after each
/// intermediate page so callers can render while pagination continues; it is
/// never called for single-page listings or the final page (the returned
/// list covers those).
Future<List<MediaItem>> fetchFolderChildren(String folderId, {void Function(List<MediaItem> itemsSoFar)? onPage}) =>
_fetchFolderChildren(folderId, onPage: onPage);
Future<List<MediaItem>> _fetchFolderChildren(String parentId) async { Future<List<MediaItem>> _fetchFolderChildren(
String parentId, {
void Function(List<MediaItem> itemsSoFar)? onPage,
}) async {
final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}';
if (isOfflineMode) { if (isOfflineMode) {
final cached = await cache.get(ServerId(cacheServerId), cacheKey); final cached = await cache.get(ServerId(cacheServerId), cacheKey);
@@ -623,6 +642,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
} }
final allRaw = <Map<String, dynamic>>[]; final allRaw = <Map<String, dynamic>>[];
final mappedSoFar = onPage == null ? null : <MediaItem>[];
var startIndex = 0; var startIndex = 0;
int? totalRecordCount; int? totalRecordCount;
while (totalRecordCount == null || startIndex < totalRecordCount) { while (totalRecordCount == null || startIndex < totalRecordCount) {
@@ -637,7 +657,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'EnableTotalRecordCount': 'true', 'EnableTotalRecordCount': 'true',
'SortBy': 'IsFolder,SortName', 'SortBy': 'IsFolder,SortName',
'SortOrder': 'Ascending', 'SortOrder': 'Ascending',
'Fields': _browseFields, 'Fields': _folderBrowseFields,
...jellyfinImageQueryParameters, ...jellyfinImageQueryParameters,
}, },
); );
@@ -645,12 +665,16 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
final data = response.data; final data = response.data;
final page = _itemsArray(data); final page = _itemsArray(data);
allRaw.addAll(page); allRaw.addAll(page);
mappedSoFar?.addAll(_mapItems(page));
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final rawTotal = data['TotalRecordCount']; final rawTotal = data['TotalRecordCount'];
if (rawTotal is int) totalRecordCount = rawTotal; if (rawTotal is int) totalRecordCount = rawTotal;
} }
if (page.isEmpty || page.length < _childrenPageSize) break; if (page.isEmpty || page.length < _childrenPageSize) break;
startIndex += page.length; startIndex += page.length;
if (mappedSoFar != null && (totalRecordCount == null || startIndex < totalRecordCount)) {
onPage!(List<MediaItem>.unmodifiable(mappedSoFar));
}
} }
allRaw.sort((a, b) { allRaw.sort((a, b) {
+13 -1
View File
@@ -1672,6 +1672,12 @@ void main() {
expect(captured!.queryParameters['SortBy'], 'IsFolder,SortName'); expect(captured!.queryParameters['SortBy'], 'IsFolder,SortName');
expect(captured!.queryParameters['SortOrder'], 'Ascending'); expect(captured!.queryParameters['SortOrder'], 'Ascending');
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources'))); expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
// Folder listings use the slim field set: the per-item count fields are
// expensive server-side and Overview is never rendered in the tree.
expect(captured!.queryParameters['Fields'], isNot(contains('RecursiveItemCount')));
expect(captured!.queryParameters['Fields'], isNot(contains('ChildCount')));
expect(captured!.queryParameters['Fields'], isNot(contains('Overview')));
expect(captured!.queryParameters['Fields'], contains('UserData'));
expect(items.map((item) => item.id), ['folder-z', 'series-a', 'movie-m', 'track-z']); expect(items.map((item) => item.id), ['folder-z', 'series-a', 'movie-m', 'track-z']);
expect(items.first.kind, MediaKind.unknown); expect(items.first.kind, MediaKind.unknown);
expect(items.first.raw?['IsFolder'], isTrue); expect(items.first.raw?['IsFolder'], isTrue);
@@ -1680,6 +1686,7 @@ void main() {
test('fetchFolderChildren pages direct folder contents', () async { test('fetchFolderChildren pages direct folder contents', () async {
final starts = <String?>[]; final starts = <String?>[];
final pages = <List<MediaItem>>[];
final scoped = JellyfinClient.forTesting( final scoped = JellyfinClient.forTesting(
connection: _conn(), connection: _conn(),
httpClient: MockClient((req) async { httpClient: MockClient((req) async {
@@ -1702,10 +1709,15 @@ void main() {
); );
addTearDown(scoped.close); addTearDown(scoped.close);
final items = await scoped.fetchFolderChildren('folder-1'); final items = await scoped.fetchFolderChildren('folder-1', onPage: pages.add);
expect(starts, ['0', '500']); expect(starts, ['0', '500']);
expect(items, hasLength(501)); expect(items, hasLength(501));
// onPage surfaces accumulated items after intermediate pages only; the
// final page is covered by the returned list.
expect(pages, hasLength(1));
expect(pages.single, hasLength(500));
expect(pages.single.first.id, 'child-0');
}); });
test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async { test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async {