feat(jellyfin): add folder browsing and playback
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user