Files
plezy/lib/screens/libraries/folder_tree_view.dart
T
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00

236 lines
7.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../models/plex_metadata.dart';
import '../../services/play_queue_launcher.dart';
import '../../utils/app_logger.dart';
import '../../utils/media_navigation_helper.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../i18n/strings.g.dart';
import 'folder_tree_item.dart';
import 'state_messages.dart';
/// Expandable tree view for browsing library folders
/// Shows a hierarchical file/folder structure
class FolderTreeView extends StatefulWidget {
final String libraryKey;
final String? serverId; // Server this library belongs to
final void Function(String)? onRefresh;
final FocusNode? firstItemFocusNode;
final VoidCallback? onNavigateUp;
const FolderTreeView({
super.key,
required this.libraryKey,
this.serverId,
this.onRefresh,
this.firstItemFocusNode,
this.onNavigateUp,
});
@override
State<FolderTreeView> createState() => _FolderTreeViewState();
}
class _FolderTreeViewState extends State<FolderTreeView> {
List<PlexMetadata> _rootFolders = [];
final Map<String, List<PlexMetadata>> _childrenCache = {};
final Set<String> _expandedFolders = {};
final Set<String> _loadingFolders = {};
bool _isLoadingRoot = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadRootFolders();
}
Future<void> _loadRootFolders() async {
setState(() {
_isLoadingRoot = true;
_errorMessage = null;
});
try {
final client = context.getClientForServer(widget.serverId!);
final folders = await client.getLibraryFolders(widget.libraryKey);
if (!mounted) return;
final taggedFolders = folders
.map(
(folder) => folder.copyWith(
serverId: widget.serverId!,
serverName: null, // server name not required for folders listing
),
)
.toList();
setState(() {
_rootFolders = taggedFolders;
_isLoadingRoot = false;
});
appLogger.d('Loaded ${folders.length} root folders');
} catch (e) {
if (!mounted) return;
appLogger.e('Failed to load root folders', error: e);
setState(() {
_errorMessage = t.errors.failedToLoad(context: t.libraries.folders, error: e.toString());
_isLoadingRoot = false;
});
}
}
Future<void> _loadFolderChildren(PlexMetadata folder) async {
// Already loading this folder
if (_loadingFolders.contains(folder.key!)) return;
// Already loaded and cached
if (_childrenCache.containsKey(folder.key!)) {
setState(() {
_expandedFolders.add(folder.key!);
});
return;
}
setState(() {
_loadingFolders.add(folder.key!);
});
try {
final client = context.getClientForServer(widget.serverId!);
// Items are automatically tagged with server info by PlexClient
final children = await client.getFolderChildren(folder.key!);
if (!mounted) return;
setState(() {
_childrenCache[folder.key!] = children;
_expandedFolders.add(folder.key!);
_loadingFolders.remove(folder.key!);
});
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
} catch (e) {
if (!mounted) return;
appLogger.e('Failed to load folder children', error: e);
setState(() {
_loadingFolders.remove(folder.key!);
});
if (mounted) {
showErrorSnackBar(context, t.errors.failedToLoad(context: t.libraries.folders, error: e.toString()));
}
}
}
void _toggleFolder(PlexMetadata folder) {
if (_expandedFolders.contains(folder.key!)) {
setState(() {
_expandedFolders.remove(folder.key!);
});
} else {
_loadFolderChildren(folder);
}
}
Future<void> _handleItemTap(PlexMetadata item) async {
await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
}
Future<void> _handleFolderPlay(PlexMetadata folder) async {
final client = context.getClientForServer(widget.serverId!);
final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(folderKey: folder.key!, shuffle: false);
}
Future<void> _handleFolderShuffle(PlexMetadata folder) async {
final client = context.getClientForServer(widget.serverId!);
final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(folderKey: folder.key!, shuffle: true);
}
bool _isFolder(PlexMetadata item) {
// Folders typically don't have a specific type or might have special indicators
// Check for common folder indicators
return item.key?.contains('/folder') == true || item.type == null || item.type!.isEmpty || item.mediaType == PlexMediaType.unknown;
}
List<Widget> _buildTreeItems(List<PlexMetadata> items, int depth, [String parentPath = '']) {
final List<Widget> widgets = [];
for (int i = 0; i < items.length; i++) {
final item = items[i];
final isFolder = _isFolder(item);
final isExpanded = _expandedFolders.contains(item.key);
final isLoading = _loadingFolders.contains(item.key);
// Create a unique key path that includes parent hierarchy and index
final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i';
// First root item gets the external focus node and navigate-up callback
final isFirstRootItem = depth == 0 && i == 0;
// Add the item itself
widgets.add(
FolderTreeItem(
key: ValueKey(itemPath),
item: item,
depth: depth,
isFolder: isFolder,
isExpanded: isExpanded,
isLoading: isLoading,
onExpand: isFolder ? () => _toggleFolder(item) : null,
onTap: !isFolder ? () => _handleItemTap(item) : null,
onPlayAll: isFolder ? () => _handleFolderPlay(item) : null,
onShuffle: isFolder ? () => _handleFolderShuffle(item) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
),
);
// Add children if folder is expanded
if (isFolder && isExpanded && _childrenCache.containsKey(item.key)) {
final children = _childrenCache[item.key]!;
widgets.addAll(_buildTreeItems(children, depth + 1, itemPath));
}
}
return widgets;
}
@override
Widget build(BuildContext context) {
if (_isLoadingRoot) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadRootFolders,
retryLabel: t.common.retry,
);
}
if (_rootFolders.isEmpty) {
return EmptyStateWidget(message: t.libraries.noFoldersFound, icon: Symbols.folder_open_rounded);
}
return RefreshIndicator(
onRefresh: _loadRootFolders,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: _buildTreeItems(_rootFolders, 0),
),
);
}
}