feat(jellyfin): add folder browsing and playback

This commit is contained in:
edde746
2026-05-26 01:39:39 +02:00
parent 8e53232961
commit 1958f8ca7e
13 changed files with 625 additions and 75 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ enum MediaKind {
'track' || 'audio' => MediaKind.track,
'collection' || 'boxset' => MediaKind.collection,
'playlist' => MediaKind.playlist,
'clip' || 'trailer' => MediaKind.clip,
'clip' || 'trailer' || 'video' || 'musicvideo' => MediaKind.clip,
'photo' => MediaKind.photo,
_ => MediaKind.unknown,
};
+4 -3
View File
@@ -102,9 +102,9 @@ class ServerCapabilities {
/// gates whether the player attempts the load at all.
final bool scrubThumbnails;
/// Library section exposes a folder hierarchy (Plex
/// `/library/sections/{id}/folders`). Jellyfin has no equivalent endpoint,
/// so the "Folders" grouping option is hidden when this is false.
/// Library section exposes a folder hierarchy. Plex uses
/// `/library/sections/{id}/folders`; Jellyfin uses direct-child
/// `/Items?ParentId=...&Recursive=false` queries.
final bool folderGrouping;
const ServerCapabilities({
@@ -179,6 +179,7 @@ class ServerCapabilities {
richMetadataEdit: false,
alphaBar: AlphaBarMode.nameStartsWithFilter,
scrubThumbnails: true,
folderGrouping: true,
);
ServerCapabilities copyWith({
+27 -14
View File
@@ -65,6 +65,18 @@ class FolderTreeItem extends StatelessWidget {
};
}
String _rowTitle() {
final title = item.title?.trim();
if (title != null && title.isNotEmpty) return title;
return item.displayTitle;
}
String? _dedupeSubtitle(String? subtitle) {
final value = subtitle?.trim();
if (value == null || value.isEmpty || value == _rowTitle()) return null;
return value;
}
void _handleTap() {
if (isFolder) {
onExpand?.call();
@@ -79,15 +91,12 @@ class FolderTreeItem extends StatelessWidget {
if (item.parentIndex != null && item.index != null) {
parts.add('S${item.parentIndex} E${item.index}');
}
if (item.title != null && item.title!.isNotEmpty) {
parts.add(item.title!);
}
return parts.isNotEmpty ? parts.join(' · ') : null;
return parts.isNotEmpty ? parts.join(' · ') : _dedupeSubtitle(item.displaySubtitle);
}
if (item.isSeason) {
return item.displaySubtitle;
return _dedupeSubtitle(item.displaySubtitle);
}
return item.displaySubtitle;
return _dedupeSubtitle(item.displaySubtitle);
}
String _buildMetadataLine() {
@@ -126,7 +135,7 @@ class FolderTreeItem extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
item.displayTitle,
_rowTitle(),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -181,7 +190,7 @@ class FolderTreeItem extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.displayTitle,
_rowTitle(),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, height: 1.2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -336,6 +345,8 @@ class FolderTreeItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final playAll = onPlayAll;
final shuffle = onShuffle;
final rowContent = isFolder
? _buildFolderRow(context)
: SettingsBuilder(
@@ -362,13 +373,13 @@ class FolderTreeItem extends StatelessWidget {
),
),
// Play/Shuffle buttons for folders
if (isFolder) ...[
// Play/Shuffle buttons for folders when the backend supports them.
if (isFolder && playAll != null) ...[
FocusableButton(
useBackgroundFocus: true,
onPressed: onPlayAll,
onPressed: playAll,
child: IconButton(
onPressed: onPlayAll,
onPressed: playAll,
icon: AppIcon(
Symbols.play_arrow_rounded,
fill: 1,
@@ -382,11 +393,13 @@ class FolderTreeItem extends StatelessWidget {
visualDensity: VisualDensity.compact,
),
),
],
if (isFolder && shuffle != null) ...[
FocusableButton(
useBackgroundFocus: true,
onPressed: onShuffle,
onPressed: shuffle,
child: IconButton(
onPressed: onShuffle,
onPressed: shuffle,
icon: AppIcon(
Symbols.shuffle_rounded,
fill: 1,
+106 -42
View File
@@ -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,
);
@@ -689,10 +689,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
List<String> _getGroupingOptions() {
final type = widget.library.kind.id.toLowerCase();
// Folder browsing relies on a section folder API
// (Plex `/library/sections/{id}/folders`); gated by capability so any
// future backend that exposes the same can opt in without touching
// this method.
// Folder browsing is gated by backend capability: Plex uses its section
// folder API, while Jellyfin uses direct non-recursive Items queries.
final canFolder = context.tryGetMediaClientForServer(widget.library.serverId)?.capabilities.folderGrouping ?? false;
if (type == 'show') {
return ['shows', 'seasons', 'episodes', if (canFolder) 'folders'];
@@ -1491,6 +1489,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
key: _folderTreeKey,
libraryKey: widget.library.id,
serverId: widget.library.serverId,
libraryKind: widget.library.kind,
onRefresh: updateItem,
firstItemFocusNode: firstItemFocusNode,
onNavigateUp: _navigateToChips,
+109 -3
View File
@@ -42,6 +42,18 @@ const _episodeQueuePageSize = 200;
const _childrenPageSize = 500;
const _pagedListPageSize = 200;
const _playableDescendantTypes = 'Movie,Episode';
const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo';
bool _isJellyfinFolderDto(Map<String, dynamic> item) {
final type = (item['Type'] as String?)?.toLowerCase();
return type == 'folder' || type == 'collectionfolder' || (type == null && item['IsFolder'] == true);
}
String _jellyfinFolderSortName(Map<String, dynamic> item) {
final raw = item['SortName'] as String? ?? item['Name'] as String? ?? '';
return raw.toLowerCase();
}
int _fallbackPageTotal({required int offset, required int itemCount, int? requestedSize}) {
if (requestedSize == null || requestedSize <= 0 || itemCount < requestedSize) {
@@ -506,6 +518,68 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
return _mapItems(allRaw);
}
/// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query
/// 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);
/// 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);
Future<List<MediaItem>> _fetchFolderChildren(String parentId) async {
final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}';
if (isOfflineMode) {
final cached = await cache.get(cacheServerId, cacheKey);
return cached == null ? const [] : _mapItems(_itemsArray(cached));
}
final allRaw = <Map<String, dynamic>>[];
var startIndex = 0;
int? totalRecordCount;
while (totalRecordCount == null || startIndex < totalRecordCount) {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': parentId,
'Recursive': 'false',
'StartIndex': '$startIndex',
'Limit': '$_childrenPageSize',
'EnableTotalRecordCount': 'true',
'SortBy': 'IsFolder,SortName',
'SortOrder': 'Ascending',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
final page = _itemsArray(data);
allRaw.addAll(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;
}
allRaw.sort((a, b) {
final folderRank = (_isJellyfinFolderDto(a) ? 0 : 1).compareTo(_isJellyfinFolderDto(b) ? 0 : 1);
if (folderRank != 0) return folderRank;
return _jellyfinFolderSortName(a).compareTo(_jellyfinFolderSortName(b));
});
try {
await cache.put(cacheServerId, cacheKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length});
} catch (e, st) {
appLogger.w('JellyfinClient.fetchFolderChildren cache write failed', error: e, stackTrace: st);
}
return _mapItems(allRaw);
}
/// All directly-playable descendants of [parentId] (Movies + Episodes),
/// recursively expanded. Used by the playback launcher so a collection
/// containing a Series plays its episodes instead of the unplayable
@@ -514,11 +588,27 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
/// since those preserve the container shape (Series rows, PlaylistItemId).
///
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) {
return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes);
}
/// Playable video descendants for a folder browse row. This includes
/// Jellyfin's generic `Video` / `MusicVideo` kinds for home-video libraries,
/// but deliberately excludes `Audio` so folder playback never starts music.
Future<List<MediaItem>> fetchPlayableFolderDescendants(String parentId) {
return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes);
}
Future<List<MediaItem>> _fetchAllPlayableDescendants(String parentId, {required String includeItemTypes}) async {
final all = <MediaItem>[];
var start = 0;
while (true) {
final page = await fetchPlayableDescendantsPage(parentId, start: start, size: _pagedListPageSize);
final page = await _fetchPlayableDescendantsPage(
parentId,
start: start,
size: _pagedListPageSize,
includeItemTypes: includeItemTypes,
);
if (page.items.isEmpty) break;
all.addAll(page.items);
start += page.items.length;
@@ -533,6 +623,22 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
int? start,
int? size,
AbortController? abort,
}) {
return _fetchPlayableDescendantsPage(
parentId,
start: start,
size: size,
abort: abort,
includeItemTypes: _playableDescendantTypes,
);
}
Future<LibraryPage<MediaItem>> _fetchPlayableDescendantsPage(
String parentId, {
int? start,
int? size,
AbortController? abort,
required String includeItemTypes,
}) async {
final offset = start ?? 0;
final pageSize = size ?? _pagedListPageSize;
@@ -542,7 +648,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'userId': connection.userId,
'ParentId': parentId,
'Recursive': 'true',
'IncludeItemTypes': 'Movie,Episode',
'IncludeItemTypes': includeItemTypes,
'StartIndex': offset.toString(),
'Limit': pageSize.toString(),
'Fields': _browseFields,
+70 -1
View File
@@ -11,10 +11,11 @@ import '../media/play_queue.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../utils/snackbar_helper.dart';
import 'jellyfin_client.dart';
import 'media_list_playback_launcher.dart';
import 'playlist_items_loader.dart';
/// Backend-neutral launcher for Jellyfin collections and playlists.
/// Backend-neutral launcher for Jellyfin collections, playlists, and folders.
///
/// Jellyfin has no server-side queue resource — the client fetches
/// children (collection) or playlist items, applies shuffle locally,
@@ -123,6 +124,74 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
);
}
/// Launch playback from a Jellyfin folder row. Jellyfin has no server-side
/// queue resource, so folders use the same local queue path as collections.
/// The client query is video-only; music-only folders return [PlayQueueEmpty].
Future<PlayQueueResult> launchFromFolder({
required MediaItem folder,
required bool shuffle,
bool showLoadingIndicator = true,
}) async {
final serverId = folder.serverId;
if (serverId == null) {
return PlayQueueError(Exception('Item is missing serverId'));
}
return executeWithLoading(
context: context,
showLoading: showLoadingIndicator,
actionLabel: shuffle ? t.common.shuffle : t.common.play,
execute: (dismissLoading) async {
final client = clientForTesting ?? _resolveClient(serverId);
if (client == null) {
await dismissLoading();
if (context.mounted) {
showErrorSnackBar(context, t.errors.noClientAvailable);
}
return PlayQueueError(Exception('No client for server $serverId'));
}
final fetched = client is JellyfinClient
? await client.fetchPlayableFolderDescendants(folder.id)
: await client.fetchPlayableDescendants(folder.id);
var items = fetched.where((item) => item.kind.isVideo).map((item) {
return item.copyWith(
serverId: item.serverId ?? serverId,
serverName: item.serverName ?? folder.serverName,
libraryId: item.libraryId ?? folder.libraryId,
libraryTitle: item.libraryTitle ?? folder.libraryTitle,
);
}).toList();
if (items.isEmpty) return const PlayQueueEmpty();
if (shuffle) {
items = List.of(items)..shuffle(Random());
}
await dismissLoading();
if (!context.mounted && navigateForTesting == null) {
return const PlayQueueError('Context not mounted');
}
final playbackState = playbackStateForTesting ?? context.read<PlaybackStateProvider>();
return launchLocalQueuePlayback(
context: context,
playbackState: playbackState,
queue: LocalPlayQueue(
id: 'jellyfin:folder:${folder.id}',
items: items,
currentIndex: 0,
shuffled: shuffle,
backendId: client.backend.id,
),
contextKey: folder.id,
navigateForTesting: navigateForTesting,
);
},
);
}
@override
Future<PlayQueueResult> launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async {
final kind = metadata.kind;
@@ -38,10 +38,10 @@ class PlayQueueError extends PlayQueueResult {
/// Backend-neutral playback launcher for collections and playlists.
///
/// Plex uses server-side `/playQueues` (one round trip, server tracks
/// queue state). Jellyfin has no equivalent — the client builds an
/// in-memory queue from `fetchChildren` (collection) or
/// `fetchPlaylistItems` (playlist). [MediaListPlaybackLauncher.forItem]
/// picks the implementation by inspecting the item's backend.
/// queue state). Jellyfin has no equivalent — the concrete Jellyfin launcher
/// builds an in-memory queue from playable descendants or playlist items.
/// [MediaListPlaybackLauncher.forItem] picks the implementation by inspecting
/// the item's backend.
abstract class MediaListPlaybackLauncher {
/// Launch playback from a collection (a [MediaItem] with
/// `kind == MediaKind.collection`) or a [MediaPlaylist].
+3 -3
View File
@@ -26,9 +26,9 @@ export 'media_list_playback_launcher.dart' show PlayQueueResult, PlayQueueSucces
/// 4. Handling errors with appropriate feedback
///
/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for
/// the backend-neutral entry point. Plex-only flows
/// ([launchFromPlaylistItem], [launchShuffledShow], [launchFromFolder]) live
/// directly on this class because they have no Jellyfin equivalent.
/// the backend-neutral entry point. Plex-only flows such as
/// [launchFromPlaylistItem] live directly on this class because they have no
/// Jellyfin equivalent.
class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
final BuildContext context;
final PlexClient client;
@@ -1529,6 +1529,75 @@ void main() {
expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
});
test('fetchLibraryFolders uses direct non-recursive Items query and orders folders first', () async {
Uri? captured;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
captured = req.url;
return http.Response(
jsonEncode({
'Items': [
{'Id': 'track-z', 'Type': 'Audio', 'Name': 'Z Track', 'IsFolder': false},
{'Id': 'series-a', 'Type': 'Series', 'Name': 'A Show', 'IsFolder': true},
{'Id': 'folder-z', 'Type': 'Folder', 'Name': 'Z Folder', 'IsFolder': true},
{'Id': 'movie-m', 'Type': 'Movie', 'Name': 'Movie', 'IsFolder': false},
],
'TotalRecordCount': 4,
}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchLibraryFolders('lib-1');
expect(captured, isNotNull);
expect(captured!.path, '/Items');
expect(captured!.queryParameters['ParentId'], 'lib-1');
expect(captured!.queryParameters['Recursive'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['SortBy'], 'IsFolder,SortName');
expect(captured!.queryParameters['SortOrder'], 'Ascending');
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
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);
expect(items[1].kind, MediaKind.show);
});
test('fetchFolderChildren pages direct folder contents', () async {
final starts = <String?>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
starts.add(req.url.queryParameters['StartIndex']);
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
const total = 501;
final end = start == 0 ? 500 : total;
return http.Response(
jsonEncode({
'Items': [
for (var i = start; i < end; i++)
{'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false},
],
'TotalRecordCount': total,
}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchFolderChildren('folder-1');
expect(starts, ['0', '500']);
expect(items, hasLength(501));
});
test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async {
final starts = <String?>[];
final pagedClient = JellyfinClient.forTesting(
@@ -2481,6 +2550,37 @@ void main() {
expect(requestUri!.queryParameters['Limit'], '10');
});
test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async {
Uri? requestUri;
final mock = MockClient((req) async {
if (req.url.path == '/Items') {
requestUri = req.url;
return http.Response(
jsonEncode({
'Items': [
{'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'},
],
'TotalRecordCount': 1,
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('not found', 404);
});
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
addTearDown(client.close);
final items = await client.fetchPlayableFolderDescendants('folder-1');
expect(items.single.kind, MediaKind.clip);
expect(requestUri, isNotNull);
expect(requestUri!.queryParameters['ParentId'], 'folder-1');
expect(requestUri!.queryParameters['Recursive'], 'true');
expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo');
expect(requestUri!.queryParameters['IncludeItemTypes'], isNot(contains('Audio')));
});
test('fetchChildren walks generic children pages', () async {
final itemRequests = <Uri>[];
final mock = MockClient((req) async {
+18
View File
@@ -94,6 +94,24 @@ void main() {
expect(item.isWatched, isFalse);
});
test('maps generic Jellyfin video types to playable clips', () {
final video = JellyfinMappers.mediaItem(
{'Id': 'home-video', 'Name': 'Home Video', 'Type': 'Video'},
serverId: _serverId,
absolutizer: null,
)!;
final musicVideo = JellyfinMappers.mediaItem(
{'Id': 'music-video', 'Name': 'Music Video', 'Type': 'MusicVideo'},
serverId: _serverId,
absolutizer: null,
)!;
expect(video.kind, MediaKind.clip);
expect(musicVideo.kind, MediaKind.clip);
expect(video.kind.isVideo, isTrue);
expect(musicVideo.kind.isVideo, isTrue);
});
test('episode preserves series/season hierarchy', () {
final json = {
'Id': 'ep1',
@@ -19,14 +19,17 @@ import 'package:plezy/utils/media_server_http_client.dart';
/// `implements JellyfinClient` so existing tests stay backend-tagged.
class _RecordingJellyfinClient implements JellyfinClient {
final List<MediaItem> playableDescendantsResponse;
final List<MediaItem> playableFolderDescendantsResponse;
final List<MediaItem> seriesEpisodesResponse;
final List<MediaItem> playlistItemsResponse;
final List<String> fetchPlayableDescendantsCalls = [];
final List<String> fetchPlayableFolderDescendantsCalls = [];
final List<String> fetchSeriesEpisodesCalls = [];
final List<({String id, int offset, int limit})> fetchPlaylistItemsCalls = [];
_RecordingJellyfinClient({
this.playableDescendantsResponse = const [],
this.playableFolderDescendantsResponse = const [],
this.seriesEpisodesResponse = const [],
this.playlistItemsResponse = const [],
});
@@ -37,6 +40,12 @@ class _RecordingJellyfinClient implements JellyfinClient {
return playableDescendantsResponse;
}
@override
Future<List<MediaItem>> fetchPlayableFolderDescendants(String parentId) async {
fetchPlayableFolderDescendantsCalls.add(parentId);
return playableFolderDescendantsResponse;
}
@override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
fetchSeriesEpisodesCalls.add(seriesId);
@@ -80,6 +89,15 @@ MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem(
serverId: serverId,
);
MediaItem _movie(String id, {String? serverId = 'srv-jf'}) =>
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, title: 'Movie $id', serverId: serverId);
MediaItem _clip(String id, {String? serverId = 'srv-jf'}) =>
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.clip, title: 'Video $id', serverId: serverId);
MediaItem _track(String id, {String? serverId = 'srv-jf'}) =>
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.track, title: 'Track $id', serverId: serverId);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -384,6 +402,105 @@ void main() {
expect(navigated.single.id, 'a');
});
testWidgets('folder path seeds a video-only local queue', (tester) async {
final ctx = await pumpContext(tester);
final fakeClient = _RecordingJellyfinClient(
playableFolderDescendantsResponse: [_track('song'), _movie('movie', serverId: null), _clip('video')],
);
final playback = PlaybackStateProvider();
final navigated = <MediaItem>[];
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (m) async => navigated.add(m),
);
final folder = MediaItem(
id: 'folder-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.unknown,
title: 'Folder',
serverId: 'srv-jf',
serverName: 'Home Jellyfin',
libraryId: 'lib-1',
libraryTitle: 'Videos',
);
final result = await launcher.launchFromFolder(folder: folder, shuffle: false, showLoadingIndicator: false);
expect(result, isA<PlayQueueSuccess>());
expect(fakeClient.fetchPlayableFolderDescendantsCalls, ['folder-1']);
expect(fakeClient.fetchPlayableDescendantsCalls, isEmpty);
expect(playback.loadedItems.map((m) => m.id).toList(), ['movie', 'video']);
expect(playback.loadedItems.any((m) => m.kind == MediaKind.track), isFalse);
expect(playback.loadedItems.first.serverId, 'srv-jf');
expect(playback.loadedItems.first.libraryId, 'lib-1');
expect(playback.isQueueActive, isTrue);
expect(playback.isShuffleActive, isFalse);
expect(navigated.single.id, 'movie');
});
testWidgets('folder shuffle reorders the video queue', (tester) async {
final ctx = await pumpContext(tester);
final originalIds = List.generate(50, (i) => 'v$i');
final fakeClient = _RecordingJellyfinClient(playableFolderDescendantsResponse: originalIds.map(_clip).toList());
final playback = PlaybackStateProvider();
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {},
);
final folder = MediaItem(
id: 'folder-shuffle',
backend: MediaBackend.jellyfin,
kind: MediaKind.unknown,
serverId: 'srv-jf',
);
final result = await launcher.launchFromFolder(folder: folder, shuffle: true, showLoadingIndicator: false);
expect(result, isA<PlayQueueSuccess>());
final shuffledIds = playback.loadedItems.map((m) => m.id).toList();
expect(shuffledIds.toSet(), originalIds.toSet());
expect(shuffledIds.length, originalIds.length);
expect(shuffledIds, isNot(equals(originalIds)));
expect(playback.isShuffleActive, isTrue);
});
testWidgets('music-only folder returns PlayQueueEmpty', (tester) async {
final ctx = await pumpContext(tester);
final fakeClient = _RecordingJellyfinClient(playableFolderDescendantsResponse: [_track('a'), _track('b')]);
final playback = PlaybackStateProvider();
var didNavigate = false;
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {
didNavigate = true;
},
);
final folder = MediaItem(
id: 'music-folder',
backend: MediaBackend.jellyfin,
kind: MediaKind.unknown,
serverId: 'srv-jf',
);
final result = await launcher.launchFromFolder(folder: folder, shuffle: false, showLoadingIndicator: false);
expect(result, isA<PlayQueueEmpty>());
expect(playback.isQueueActive, isFalse);
expect(didNavigate, isFalse);
});
testWidgets('launchShuffledShow rejects non-show/season kinds', (tester) async {
final ctx = await pumpContext(tester);
final launcher = JellyfinSequentialLauncher(context: ctx);
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/screens/libraries/folder_tree_item.dart';
void main() {
testWidgets('folder rows use the item title for seasons', (tester) async {
final item = MediaItem(
id: 'season-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
title: 'Season 1',
grandparentTitle: 'TV Show',
raw: const {'Type': 'Season', 'IsFolder': true},
);
await tester.pumpWidget(
MaterialApp(
home: Material(child: FolderTreeItem(item: item, depth: 1, isFolder: true)),
),
);
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('TV Show'), findsNothing);
expect(find.byType(IconButton), findsNothing);
});
testWidgets('folder rows show play and shuffle buttons when callbacks are supplied', (tester) async {
final item = MediaItem(
id: 'folder-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.unknown,
title: 'Videos',
raw: const {'Type': 'Folder', 'IsFolder': true},
);
var played = false;
var shuffled = false;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: FolderTreeItem(
item: item,
depth: 0,
isFolder: true,
onPlayAll: () => played = true,
onShuffle: () => shuffled = true,
),
),
),
);
expect(find.byType(IconButton), findsNWidgets(2));
await tester.tap(find.byType(IconButton).first);
await tester.tap(find.byType(IconButton).last);
expect(played, isTrue);
expect(shuffled, isTrue);
});
}