fix(folders): row context menus, dpad left to sidebar, faster Jellyfin listings
This commit is contained in:
@@ -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<String, dynamic> ? 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) {
|
||||
|
||||
@@ -12,6 +12,10 @@ mixin ContextMenuTapMixin<T extends StatefulWidget> on State<T> {
|
||||
_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).
|
||||
|
||||
@@ -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<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() {
|
||||
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<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() {
|
||||
final item = widget.item;
|
||||
if (item.isEpisode) {
|
||||
final parts = <String>[];
|
||||
if (item.parentIndex != null && item.index != null) {
|
||||
@@ -104,6 +206,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _buildMetadataLine() {
|
||||
final item = widget.item;
|
||||
final parts = <String>[];
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<FolderTreeView> {
|
||||
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<FolderTreeView> {
|
||||
_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 {
|
||||
final epoch = _supersedeInFlightLoads();
|
||||
setState(() {
|
||||
_isLoadingRoot = true;
|
||||
_errorMessage = null;
|
||||
@@ -84,9 +104,18 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
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<FolderTreeView> {
|
||||
|
||||
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<FolderTreeView> {
|
||||
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<FolderTreeView> {
|
||||
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<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 {
|
||||
final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
|
||||
if (!context.mounted) return;
|
||||
@@ -239,13 +311,20 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
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 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<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) {
|
||||
final folderKey = _folderKey(folder);
|
||||
if (folderKey == null) return Future.value(const <MediaItem>[]);
|
||||
@@ -253,30 +332,32 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
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<MediaItem> 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<FolderTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
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<FolderTreeView> {
|
||||
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<FolderTreeView> {
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1610,6 +1610,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
onRefresh: updateItem,
|
||||
firstItemFocusNode: firstItemFocusNode,
|
||||
onNavigateUp: _navigateToChips,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,Origi
|
||||
/// queries because it is the heaviest item field Jellyfin returns.
|
||||
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
|
||||
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode
|
||||
/// 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
|
||||
/// distinct from [fetchLibraryContent], which intentionally recurses through
|
||||
/// 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
|
||||
/// 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}';
|
||||
if (isOfflineMode) {
|
||||
final cached = await cache.get(ServerId(cacheServerId), cacheKey);
|
||||
@@ -623,6 +642,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
}
|
||||
|
||||
final allRaw = <Map<String, dynamic>>[];
|
||||
final mappedSoFar = onPage == null ? null : <MediaItem>[];
|
||||
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<String, dynamic>) {
|
||||
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<MediaItem>.unmodifiable(mappedSoFar));
|
||||
}
|
||||
}
|
||||
|
||||
allRaw.sort((a, b) {
|
||||
|
||||
@@ -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 = <String?>[];
|
||||
final pages = <List<MediaItem>>[];
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user