feat(jellyfin): add folder browsing and playback
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../media/media_backend.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../services/jellyfin_client.dart';
|
||||
import '../../services/jellyfin_sequential_launcher.dart';
|
||||
import '../../services/play_queue_launcher.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
import '../../utils/media_navigation_helper.dart';
|
||||
@@ -17,6 +22,7 @@ import 'state_messages.dart';
|
||||
class FolderTreeView extends StatefulWidget {
|
||||
final String libraryKey;
|
||||
final String? serverId; // Server this library belongs to
|
||||
final MediaKind? libraryKind;
|
||||
final void Function(String)? onRefresh;
|
||||
final FocusNode? firstItemFocusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
@@ -25,6 +31,7 @@ class FolderTreeView extends StatefulWidget {
|
||||
super.key,
|
||||
required this.libraryKey,
|
||||
this.serverId,
|
||||
this.libraryKind,
|
||||
this.onRefresh,
|
||||
this.firstItemFocusNode,
|
||||
this.onNavigateUp,
|
||||
@@ -39,10 +46,9 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
/// Reload the root folders. Exposed for parent-driven pull-to-refresh.
|
||||
Future<void> refresh() => _loadRootFolders();
|
||||
|
||||
/// Folders/items returned by the Plex `/library/sections/{id}/folder`
|
||||
/// endpoint, mapped to neutral [MediaItem]s. The Plex `key` (folder URL)
|
||||
/// survives in [MediaItem.raw] under the `'key'` slot — see
|
||||
/// [_folderKey].
|
||||
/// Folders/items returned by the backend's folder API and mapped to neutral
|
||||
/// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key'];
|
||||
/// Jellyfin folders use the item id as their recursive parent id.
|
||||
List<MediaItem> _rootFolders = [];
|
||||
final Map<String, List<MediaItem>> _childrenCache = {};
|
||||
final Set<String> _expandedFolders = {};
|
||||
@@ -51,11 +57,18 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
String? _errorMessage;
|
||||
|
||||
/// 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 as
|
||||
/// the cache key and to recursively fetch children from
|
||||
/// [PlexClient.getFolderChildren].
|
||||
/// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used to
|
||||
/// recursively fetch children from [PlexClient.fetchFolderChildren].
|
||||
String? _folderKey(MediaItem item) => item.raw?['key'] as String?;
|
||||
|
||||
String? _itemType(MediaItem item) => (item.raw?['Type'] as String? ?? item.raw?['type'] as String?)?.toLowerCase();
|
||||
|
||||
String? _folderIdentity(MediaItem item) {
|
||||
if (item.backend == MediaBackend.plex) return _folderKey(item);
|
||||
if (_isFolder(item)) return item.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -69,11 +82,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
|
||||
// PlexClient.fetchLibraryFolders returns neutral [MediaItem]s; folders
|
||||
// come back already tagged with the client's serverId/serverName.
|
||||
final folders = await client.fetchLibraryFolders(widget.libraryKey);
|
||||
final client = context.getMediaClientForServer(widget.serverId!);
|
||||
final folders = await _fetchRootFolders(client);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -94,40 +104,34 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
Future<void> _loadFolderChildren(MediaItem folder) async {
|
||||
final folderKey = _folderKey(folder);
|
||||
if (folderKey == null) return;
|
||||
final folderIdentity = _folderIdentity(folder);
|
||||
if (folderIdentity == null) return;
|
||||
|
||||
// Already loading this folder
|
||||
if (_loadingFolders.contains(folderKey)) return;
|
||||
if (_loadingFolders.contains(folderIdentity)) return;
|
||||
|
||||
// Already loaded and cached
|
||||
if (_childrenCache.containsKey(folderKey)) {
|
||||
if (_childrenCache.containsKey(folderIdentity)) {
|
||||
setState(() {
|
||||
_expandedFolders.add(folderKey);
|
||||
_expandedFolders.add(folderIdentity);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingFolders.add(folderKey);
|
||||
_loadingFolders.add(folderIdentity);
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
|
||||
// Items are automatically tagged with server info by PlexClient.
|
||||
final children = await client.fetchFolderChildren(
|
||||
folderKey,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
final client = context.getMediaClientForServer(widget.serverId!);
|
||||
final children = await _fetchFolderChildren(client, folder);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_childrenCache[folderKey] = children;
|
||||
_expandedFolders.add(folderKey);
|
||||
_loadingFolders.remove(folderKey);
|
||||
_childrenCache[folderIdentity] = children;
|
||||
_expandedFolders.add(folderIdentity);
|
||||
_loadingFolders.remove(folderIdentity);
|
||||
});
|
||||
|
||||
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
|
||||
@@ -136,7 +140,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
|
||||
setState(() {
|
||||
_loadingFolders.remove(folderKey);
|
||||
_loadingFolders.remove(folderIdentity);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
@@ -146,11 +150,11 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
void _toggleFolder(MediaItem folder) {
|
||||
final folderKey = _folderKey(folder);
|
||||
if (folderKey == null) return;
|
||||
if (_expandedFolders.contains(folderKey)) {
|
||||
final folderIdentity = _folderIdentity(folder);
|
||||
if (folderIdentity == null) return;
|
||||
if (_expandedFolders.contains(folderIdentity)) {
|
||||
setState(() {
|
||||
_expandedFolders.remove(folderKey);
|
||||
_expandedFolders.remove(folderIdentity);
|
||||
});
|
||||
} else {
|
||||
_loadFolderChildren(folder);
|
||||
@@ -158,10 +162,26 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
Future<void> _handleItemTap(MediaItem item) async {
|
||||
await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
|
||||
final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
|
||||
if (!context.mounted) return;
|
||||
switch (result) {
|
||||
case MediaNavigationResult.unsupported:
|
||||
showAppSnackBar(context, t.messages.musicNotSupported);
|
||||
case MediaNavigationResult.listRefreshNeeded:
|
||||
widget.onRefresh?.call(item.id);
|
||||
case MediaNavigationResult.navigated:
|
||||
case MediaNavigationResult.librarySelected:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFolderPlay(MediaItem folder) async {
|
||||
if (folder.backend == MediaBackend.jellyfin) {
|
||||
final launcher = JellyfinSequentialLauncher(context: context);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: false);
|
||||
return;
|
||||
}
|
||||
|
||||
final folderKey = _folderKey(folder);
|
||||
if (folderKey == null) return;
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
@@ -175,6 +195,12 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
Future<void> _handleFolderShuffle(MediaItem folder) async {
|
||||
if (folder.backend == MediaBackend.jellyfin) {
|
||||
final launcher = JellyfinSequentialLauncher(context: context);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final folderKey = _folderKey(folder);
|
||||
if (folderKey == null) return;
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
@@ -188,10 +214,47 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
bool _isFolder(MediaItem item) {
|
||||
// Folders typically have no media kind (Plex returns `type: 'folder'`,
|
||||
// mapped to [MediaKind.unknown]) or expose `/folder` in their key.
|
||||
if (item.backend == MediaBackend.jellyfin) {
|
||||
return _isJellyfinFilesystemFolder(item) || _isJellyfinMediaContainer(item);
|
||||
}
|
||||
|
||||
// Plex folders typically have no media kind (mapped to [MediaKind.unknown])
|
||||
// or expose `/folder` in their key.
|
||||
final folderKey = _folderKey(item);
|
||||
return folderKey?.contains('/folder') == true || item.kind == MediaKind.unknown;
|
||||
final type = _itemType(item);
|
||||
return folderKey?.contains('/folder') == true || type == 'folder' || item.kind == MediaKind.unknown;
|
||||
}
|
||||
|
||||
bool _isJellyfinFilesystemFolder(MediaItem item) {
|
||||
final type = _itemType(item);
|
||||
return type == 'folder' || type == 'collectionfolder' || (type == null && item.raw?['IsFolder'] == true);
|
||||
}
|
||||
|
||||
bool _isJellyfinMediaContainer(MediaItem item) => item.kind == MediaKind.show || item.kind == MediaKind.season;
|
||||
|
||||
bool _canPlayFolder(MediaItem item) {
|
||||
if (item.backend == MediaBackend.plex) return true;
|
||||
if (item.backend == MediaBackend.jellyfin) return widget.libraryKind?.isMusic != true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<List<MediaItem>> _fetchRootFolders(MediaServerClient client) {
|
||||
if (client is PlexClient) return client.fetchLibraryFolders(widget.libraryKey);
|
||||
if (client is JellyfinClient) return client.fetchLibraryFolders(widget.libraryKey);
|
||||
throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}');
|
||||
}
|
||||
|
||||
Future<List<MediaItem>> _fetchFolderChildren(MediaServerClient client, MediaItem folder) {
|
||||
if (client is PlexClient) {
|
||||
final folderKey = _folderKey(folder);
|
||||
if (folderKey == null) return Future.value(const <MediaItem>[]);
|
||||
return client.fetchFolderChildren(folderKey, libraryId: folder.libraryId, libraryTitle: folder.libraryTitle);
|
||||
}
|
||||
if (client is JellyfinClient) {
|
||||
if (_isJellyfinMediaContainer(folder)) return client.fetchChildren(folder.id);
|
||||
return client.fetchFolderChildren(folder.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
|
||||
@@ -207,7 +270,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i';
|
||||
out.add((item: item, depth: depth, path: itemPath));
|
||||
|
||||
final folderKey = _folderKey(item);
|
||||
final folderKey = _folderIdentity(item);
|
||||
if (_isFolder(item) &&
|
||||
folderKey != null &&
|
||||
_expandedFolders.contains(folderKey) &&
|
||||
@@ -253,10 +316,11 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
final entry = flattened[index];
|
||||
final item = entry.item;
|
||||
final isFolder = _isFolder(item);
|
||||
final folderKey = _folderKey(item);
|
||||
final folderKey = _folderIdentity(item);
|
||||
final isExpanded = folderKey != null && _expandedFolders.contains(folderKey);
|
||||
final isLoading = folderKey != null && _loadingFolders.contains(folderKey);
|
||||
final isFirstRootItem = index == 0;
|
||||
final canPlayFolder = isFolder && _canPlayFolder(item);
|
||||
|
||||
return FolderTreeItem(
|
||||
key: ValueKey(entry.path),
|
||||
@@ -268,8 +332,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
serverId: widget.serverId,
|
||||
onExpand: isFolder ? () => _toggleFolder(item) : null,
|
||||
onTap: !isFolder ? () => _handleItemTap(item) : null,
|
||||
onPlayAll: isFolder ? () => _handleFolderPlay(item) : null,
|
||||
onShuffle: isFolder ? () => _handleFolderShuffle(item) : null,
|
||||
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
|
||||
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
|
||||
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
|
||||
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user