diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 5f03426a..a6566d83 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -389,6 +389,15 @@ sealed class MediaItem with _$MediaItem { 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 ? userData['UnplayedItemCount'] as int? : null; + } + /// Copy with the watched flag applied so [isWatched] reflects it for every /// kind: containers need their leaf counts patched, not just [viewCount]. MediaItem withWatchedFlag(bool isWatched) { diff --git a/lib/mixins/context_menu_tap_mixin.dart b/lib/mixins/context_menu_tap_mixin.dart index cb71abd4..c1ab8cfd 100644 --- a/lib/mixins/context_menu_tap_mixin.dart +++ b/lib/mixins/context_menu_tap_mixin.dart @@ -12,6 +12,10 @@ mixin ContextMenuTapMixin on State { _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; /// Show at the last tap position (long-press, mouse). diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index e601f2db..1180f04e 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:ui'; import '../../media/ids.dart'; @@ -10,19 +11,23 @@ import '../../focus/focusable_wrapper.dart'; import '../../media/media_item.dart'; import '../../media/media_item_types.dart'; import '../../media/media_kind.dart'; +import '../../mixins/context_menu_tap_mixin.dart'; import '../../services/settings_service.dart'; import '../../widgets/settings_builder.dart'; import '../../utils/formatters.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/optimized_media_image.dart'; +import '../../widgets/overlay_sheet.dart'; 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) -class FolderTreeItem extends StatelessWidget { +class FolderTreeItem extends StatefulWidget { final MediaItem item; final int depth; final bool isExpanded; @@ -34,6 +39,9 @@ class FolderTreeItem extends StatelessWidget { final bool isLoading; final FocusNode? focusNode; final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateLeft; + final void Function(String itemId)? onRefresh; + final VoidCallback? onListRefresh; final String? serverId; const FolderTreeItem({ @@ -49,15 +57,34 @@ class FolderTreeItem extends StatelessWidget { this.isLoading = false, this.focusNode, this.onNavigateUp, + this.onNavigateLeft, + this.onRefresh, + this.onListRefresh, this.serverId, }); + @override + State createState() => _FolderTreeItemState(); +} + +class _FolderTreeItemState extends State 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() { - if (isFolder) { + if (widget.isFolder) { return Symbols.folder_rounded; } - return switch (item.kind) { + return switch (widget.item.kind) { MediaKind.movie => Symbols.movie_rounded, MediaKind.show => Symbols.tv_rounded, MediaKind.season => Symbols.video_library_rounded, @@ -68,12 +95,12 @@ class FolderTreeItem extends StatelessWidget { } String _rowTitle() { - final title = item.title?.trim(); + final title = widget.item.title?.trim(); 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) { final value = subtitle?.trim(); @@ -82,14 +109,89 @@ class FolderTreeItem extends StatelessWidget { } void _handleTap() { - if (isFolder) { - onExpand?.call(); + if (widget.isFolder) { + widget.onExpand?.call(); } 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 _showFolderMenu() async { + final entries = >[ + if (widget.onPlayAll != null) + AppMenuItem(value: 'play', icon: Symbols.play_arrow_rounded, label: t.common.play), + if (widget.onShuffle != null) + AppMenuItem(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( + context, + showDragHandle: true, + builder: (context) => AppMenuSheet(title: _rowTitle(), entries: entries, focusFirstItem: fromKeyboard), + ); + } else { + selected = await showAppMenu( + 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() { + final item = widget.item; if (item.isEpisode) { final parts = []; if (item.parentIndex != null && item.index != null) { @@ -104,6 +206,7 @@ class FolderTreeItem extends StatelessWidget { } String _buildMetadataLine() { + final item = widget.item; final parts = []; if (item.contentRating != null && item.contentRating!.isNotEmpty) { @@ -123,8 +226,8 @@ class FolderTreeItem extends StatelessWidget { } Widget _buildFolderRow(BuildContext context) { - final indentation = depth * 24.0; - final expandIcon = isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded; + final indentation = widget.depth * 24.0; + final expandIcon = widget.isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded; return Container( padding: .only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), @@ -132,7 +235,7 @@ class FolderTreeItem extends StatelessWidget { children: [ SizedBox( 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), AppIcon(_getIcon(), fill: 1, size: 20, color: Theme.of(context).colorScheme.primary), @@ -151,13 +254,13 @@ class FolderTreeItem extends StatelessWidget { } Widget _buildMediaRow(BuildContext context) { - final indentation = depth * 24.0; + final indentation = widget.depth * 24.0; final svc = SettingsService.instance; final episodePosterMode = svc.read(SettingsService.episodePosterMode); final hideSpoilers = svc.read(SettingsService.hideSpoilers); 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 thumbHeight = isWide ? 73.0 : 80.0; @@ -240,9 +343,10 @@ class FolderTreeItem extends StatelessWidget { double width, double height, ) { + final item = widget.item; final posterUrl = item.posterThumb(mode: episodePosterMode); // 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 = 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. final item = _effectiveItem(context); final hasActiveProgress = item.hasActiveProgress; + final unwatchedCount = item.unwatchedCount; return Stack( children: [ @@ -299,7 +404,8 @@ class FolderTreeItem extends StatelessWidget { if (showUnwatchedCount && !item.isWatched && (item.kind == MediaKind.show || item.kind == MediaKind.season) && - (item.leafCount != null && item.leafCount! > 0 && item.viewedLeafCount != null)) + unwatchedCount != null && + unwatchedCount > 0) Positioned( top: 3, right: 3, @@ -313,7 +419,7 @@ class FolderTreeItem extends StatelessWidget { ), alignment: .center, child: Text( - '${item.leafCount! - item.viewedLeafCount!}', + '$unwatchedCount', style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: .bold), ), ), @@ -351,9 +457,10 @@ class FolderTreeItem extends StatelessWidget { @override Widget build(BuildContext context) { - final playAll = onPlayAll; - final shuffle = onShuffle; - final rowContent = isFolder + final playAll = widget.onPlayAll; + final shuffle = widget.onShuffle; + final hasMenu = _hasMenu; + final rowContent = widget.isFolder ? _buildFolderRow(context) : SettingsBuilder( prefs: const [ @@ -364,23 +471,46 @@ class FolderTreeItem extends StatelessWidget { 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( children: [ // Main item row Expanded( child: FocusableWrapper( - focusNode: focusNode, + focusNode: widget.focusNode, onSelect: _handleTap, - onNavigateUp: onNavigateUp, + enableLongPress: hasMenu, + onLongPress: hasMenu ? _showRowMenu : null, + onNavigateUp: widget.onNavigateUp, + onNavigateLeft: widget.onNavigateLeft, useBackgroundFocus: true, disableScale: true, descendantsAreFocusable: false, - child: GestureDetector(onTap: _handleTap, behavior: HitTestBehavior.opaque, child: rowContent), + child: gesture, ), ), // Play/Shuffle buttons for folders when the backend supports them. - if (isFolder && playAll != null) ...[ + if (widget.isFolder && playAll != null) ...[ FocusableButton( useBackgroundFocus: true, onPressed: playAll, @@ -400,7 +530,7 @@ class FolderTreeItem extends StatelessWidget { ), ), ], - if (isFolder && shuffle != null) ...[ + if (widget.isFolder && shuffle != null) ...[ FocusableButton( useBackgroundFocus: true, onPressed: shuffle, diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 165b9108..1c063dea 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -27,6 +27,7 @@ class FolderTreeView extends StatefulWidget { final void Function(String)? onRefresh; final FocusNode? firstItemFocusNode; final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateLeft; const FolderTreeView({ super.key, @@ -36,6 +37,7 @@ class FolderTreeView extends StatefulWidget { this.onRefresh, this.firstItemFocusNode, this.onNavigateUp, + this.onNavigateLeft, }); @override @@ -57,6 +59,11 @@ class FolderTreeViewState extends State { bool _isLoadingRoot = false; 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 /// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used to /// recursively fetch children from [PlexClient.fetchFolderChildren]. @@ -76,7 +83,20 @@ class FolderTreeViewState extends State { _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 _loadRootFolders() async { + final epoch = _supersedeInFlightLoads(); setState(() { _isLoadingRoot = true; _errorMessage = null; @@ -84,9 +104,18 @@ class FolderTreeViewState extends State { try { 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(() { _rootFolders = folders; @@ -95,7 +124,7 @@ class FolderTreeViewState extends State { appLogger.d('Loaded ${folders.length} root folders'); } catch (e) { - if (!mounted) return; + if (!mounted || epoch != _loadEpoch) return; setState(() { _errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); @@ -108,8 +137,16 @@ class FolderTreeViewState extends State { final folderIdentity = _folderIdentity(folder); if (folderIdentity == null) return; - // Already loading this folder - if (_loadingFolders.contains(folderIdentity)) return; + // Already loading this folder — re-expand if partial pages are showing + // (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 if (_childrenCache.containsKey(folderIdentity)) { @@ -119,29 +156,47 @@ class FolderTreeViewState extends State { return; } + final epoch = _loadEpoch; + var pageDelivered = false; setState(() { _loadingFolders.add(folderIdentity); }); try { 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(() { _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); }); appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); } catch (e) { - if (!mounted) return; + if (!mounted || epoch != _loadEpoch) return; final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); setState(() { _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) { @@ -162,6 +217,23 @@ class FolderTreeViewState extends State { } } + /// 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 _handleItemTap(MediaItem item) async { final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); if (!context.mounted) return; @@ -239,13 +311,20 @@ class FolderTreeViewState extends State { return false; } - Future> _fetchRootFolders(MediaServerClient client) { + Future> _fetchRootFolders( + MediaServerClient client, { + void Function(List itemsSoFar)? onPage, + }) { 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}'); } - Future> _fetchFolderChildren(MediaServerClient client, MediaItem folder) { + Future> _fetchFolderChildren( + MediaServerClient client, + MediaItem folder, { + void Function(List itemsSoFar)? onPage, + }) { if (client is PlexClient) { final folderKey = _folderKey(folder); if (folderKey == null) return Future.value(const []); @@ -253,30 +332,32 @@ class FolderTreeViewState extends State { } if (client is JellyfinClient) { 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}'); } - /// Flatten the visible tree into a list of (item, depth, path) tuples so - /// `ListView.builder` can lazy-build only the rows currently on screen. + /// Flatten the visible tree into a list of (item, depth, path, parent) + /// tuples so `ListView.builder` can lazy-build only the rows currently on + /// screen. [parent] is the enclosing folder (null for root items). void _flattenTreeItems( List items, int depth, 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++) { final item = items[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); if (_isFolder(item) && folderKey != null && _expandedFolders.contains(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 { ); } - final flattened = <({MediaItem item, int depth, String path})>[]; - _flattenTreeItems(_rootFolders, 0, '', flattened); + final flattened = <({MediaItem item, int depth, String path, MediaItem? parent})>[]; + _flattenTreeItems(_rootFolders, 0, '', null, flattened); return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -324,7 +405,9 @@ class FolderTreeViewState extends State { final canPlayFolder = isFolder && _canPlayFolder(item); 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, depth: entry.depth, isFolder: isFolder, @@ -337,6 +420,9 @@ class FolderTreeViewState extends State { onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, + onNavigateLeft: widget.onNavigateLeft, + onRefresh: widget.onRefresh, + onListRefresh: () => _refreshAfterDeletion(entry.parent), ); }, ), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index dc3ecad9..875999a8 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1610,6 +1610,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> fetchLibraryFolders(String libraryId) => _fetchFolderChildren(libraryId); + Future> fetchLibraryFolders(String libraryId, {void Function(List itemsSoFar)? onPage}) => + _fetchFolderChildren(libraryId, onPage: onPage); /// Contents of a Jellyfin folder. Kept separate from [fetchChildren] so the /// folder tree can use direct-child semantics even for music libraries. - Future> 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> fetchFolderChildren(String folderId, {void Function(List itemsSoFar)? onPage}) => + _fetchFolderChildren(folderId, onPage: onPage); - Future> _fetchFolderChildren(String parentId) async { + Future> _fetchFolderChildren( + String parentId, { + void Function(List itemsSoFar)? onPage, + }) async { final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; if (isOfflineMode) { final cached = await cache.get(ServerId(cacheServerId), cacheKey); @@ -623,6 +642,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } final allRaw = >[]; + final mappedSoFar = onPage == null ? null : []; var startIndex = 0; int? totalRecordCount; while (totalRecordCount == null || startIndex < totalRecordCount) { @@ -637,7 +657,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'EnableTotalRecordCount': 'true', 'SortBy': 'IsFolder,SortName', 'SortOrder': 'Ascending', - 'Fields': _browseFields, + 'Fields': _folderBrowseFields, ...jellyfinImageQueryParameters, }, ); @@ -645,12 +665,16 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final data = response.data; final page = _itemsArray(data); allRaw.addAll(page); + mappedSoFar?.addAll(_mapItems(page)); if (data is Map) { final rawTotal = data['TotalRecordCount']; if (rawTotal is int) totalRecordCount = rawTotal; } if (page.isEmpty || page.length < _childrenPageSize) break; startIndex += page.length; + if (mappedSoFar != null && (totalRecordCount == null || startIndex < totalRecordCount)) { + onPage!(List.unmodifiable(mappedSoFar)); + } } allRaw.sort((a, b) { diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 3ecdf72a..96c8ad28 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1672,6 +1672,12 @@ void main() { expect(captured!.queryParameters['SortBy'], 'IsFolder,SortName'); expect(captured!.queryParameters['SortOrder'], 'Ascending'); 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.first.kind, MediaKind.unknown); expect(items.first.raw?['IsFolder'], isTrue); @@ -1680,6 +1686,7 @@ void main() { test('fetchFolderChildren pages direct folder contents', () async { final starts = []; + final pages = >[]; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { @@ -1702,10 +1709,15 @@ void main() { ); 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(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 {