feat: implement folder list view with navigation and playback

- Add folder browsing functionality with breadcrumb navigation
- Implement folder playback with recursive song scanning and progress notifications
- Add browser history support for back/forward button navigation
- Include empty folder detection and loading states
This commit is contained in:
Ante Budimir 2025-11-15 08:50:53 +02:00
parent 6a236c803a
commit 7a12e4657f
22 changed files with 1237 additions and 2 deletions

View file

@ -403,6 +403,10 @@
"visualizer": "visualizer",
"noLyrics": "no lyrics found"
},
"folderList": {
"title": "Folders",
"description": "Browse music by folder structure"
},
"genreList": {
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",

View file

@ -281,6 +281,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getFolderList(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolderList`,
);
}
return apiController(
'getFolderList',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getGenreList(args) {
const server = getServerById(args.apiClientProps.serverId);

View file

@ -386,6 +386,11 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
},
getFolderList: async () => {
// Jellyfin doesn't have folder-based navigation like Subsonic
// This would need to be implemented using Jellyfin's folder structure
throw new Error('Folder browsing not supported for Jellyfin servers');
},
getGenreList: async (args) => {
const { apiClientProps, query } = args;
@ -1085,6 +1090,7 @@ export const JellyfinController: InternalControllerEndpoint = {
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
};
},
updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args;

View file

@ -353,6 +353,11 @@ export const NavidromeController: InternalControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl,
getFolderList: async () => {
// Navidrome supports Subsonic API, so this should work
// But for now, delegate to Subsonic implementation
throw new Error('Use Subsonic API endpoint for folder browsing on Navidrome servers');
},
getGenreList: async (args) => {
const { apiClientProps, query } = args;
@ -716,6 +721,7 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args;

View file

@ -4,6 +4,7 @@ import type {
AlbumDetailQuery,
AlbumListQuery,
ArtistListQuery,
FolderListQuery,
GenreListQuery,
LyricSearchQuery,
LyricsQuery,
@ -158,6 +159,13 @@ export const queryKeys: Record<
},
root: (serverId: string) => [serverId, 'artists'] as const,
},
folders: {
list: (serverId: string, query?: FolderListQuery) => {
if (query) return [serverId, 'folders', 'list', query] as const;
return [serverId, 'folders', 'list'] as const;
},
root: (serverId: string) => [serverId, 'folders'] as const,
},
genres: {
list: (serverId: string, query?: GenreListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);

View file

@ -100,6 +100,22 @@ export const contract = c.router({
200: ssType._response.getGenres,
},
},
getIndexes: {
method: 'GET',
path: 'getIndexes.view',
query: ssType._parameters.getIndexes,
responses: {
200: ssType._response.getIndexes,
},
},
getMusicDirectory: {
method: 'GET',
path: 'getMusicDirectory.view',
query: ssType._parameters.getMusicDirectory,
responses: {
200: ssType._response.getMusicDirectory,
},
},
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',

View file

@ -620,6 +620,77 @@ export const SubsonicController: InternalControllerEndpoint = {
'&c=Feishin'
);
},
getFolderList: async (args) => {
const { apiClientProps, query } = args;
// Check if this is a root music folder ID (single digit like '0', '1', etc.)
// These IDs from getMusicFolders are NOT valid directory IDs
// We need to use getIndexes instead to get the top-level content
const isMusicFolderId = /^\d+$/.test(query.id);
if (isMusicFolderId) {
const res = await ssApiClient(apiClientProps).getIndexes({
query: {
musicFolderId: query.id,
},
});
if (res.status !== 200) {
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
}
// Convert index entries to folder items
const items: any[] = [];
res.body.indexes?.index?.forEach((idx) => {
idx.artist?.forEach((artist) => {
items.push({
id: artist.id.toString(),
imageUrl: null,
isDir: true,
itemType: 'folder' as const,
name: artist.name,
serverId: apiClientProps.server?.id || 'unknown',
serverType: 'subsonic' as const,
title: artist.name,
});
});
});
return {
id: query.id,
items,
name: 'Music',
parent: undefined,
};
}
// For actual directory IDs, use getMusicDirectory
const requestQuery = {
id: query.id,
};
const res = await ssApiClient(apiClientProps).getMusicDirectory({
query: requestQuery,
});
if (res.status !== 200) {
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
}
const directory = res.body.directory;
const result = {
id: directory.id.toString(),
items:
directory.child?.map((item) =>
ssNormalize.folderItem(item, apiClientProps.server),
) || [],
name: directory.name,
parent: directory.parent,
};
return result;
},
getGenreList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';

View file

@ -91,6 +91,14 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'showDetails' },
];
export const FOLDER_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ id: 'playNext' },
{ divider: true, id: 'playShuffled' },
{ divider: true, id: 'addToPlaylist' },
];
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },

View file

@ -6,6 +6,7 @@ import {
Album,
AlbumArtist,
Artist,
FolderItem,
LibraryItem,
QueueSong,
Song,
@ -72,7 +73,7 @@ export const useHandleGeneralContextMenu = (
) => {
const handleContextMenu = (
e: any,
data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[],
data: Album[] | AlbumArtist[] | Artist[] | FolderItem[] | QueueSong[] | Song[],
) => {
if (!e) return;
const clickEvent = e as MouseEvent;

View file

@ -0,0 +1,32 @@
import { queryOptions } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { FolderListQuery } from '/@/shared/types/domain-types';
export const folderQueries = {
list: (args: QueryHookArgs<FolderListQuery>) => {
return queryOptions({
queryFn: ({ signal }) => {
return api.controller.getFolderList({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
});
},
queryKey: queryKeys.folders.list(args.serverId, args.query),
...args.options,
});
},
musicFolders: (args: { options?: any; serverId: string }) => {
return queryOptions({
queryFn: ({ signal }) => {
return api.controller.getMusicFolderList({
apiClientProps: { serverId: args.serverId, signal },
});
},
queryKey: queryKeys.musicFolders.list(args.serverId),
...args.options,
});
},
};

View file

@ -0,0 +1,712 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { ActionIcon, Box, Breadcrumbs, Group, Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { RiArrowLeftLine, RiFolderLine, RiMusicLine, RiPlayFill } from 'react-icons/ri';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import styles from '../routes/folder-list-route.module.css';
import { api } from '/@/renderer/api';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
import { FOLDER_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import { useCurrentServer } from '/@/renderer/store';
import { useFolderPath, useFolderStoreActions } from '/@/renderer/store/folder.store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import {
FolderItem,
FolderListResponse,
LibraryItem,
MusicFolderListResponse,
ServerListItemWithCredential,
Song,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface FolderListContentProps {
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const FolderListContent = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
gridRef: _gridRef,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
itemCount: _itemCount,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
tableRef: _tableRef,
}: FolderListContentProps) => {
const server = useCurrentServer();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const folderId = searchParams.get('folderId');
const folderActions = useFolderStoreActions();
const { path } = useFolderPath();
const handlePlayQueueAdd = usePlayQueueAdd();
const queryClient = useQueryClient();
const [loadingFolderId, setLoadingFolderId] = useState<null | string>(null);
const [emptyFolders, setEmptyFolders] = useState<Set<string>>(new Set());
const [checkingEmptyFolders, setCheckingEmptyFolders] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Sync breadcrumb path with browser history state
useEffect(() => {
if (!folderId) {
// At root level, reset path
folderActions.resetPath();
} else {
// Try to restore path from history state
const historyState = location.state as any;
if (historyState?.breadcrumbPath) {
folderActions.setPath(historyState.breadcrumbPath);
} else {
// No history state, reset path to avoid incorrect breadcrumbs
folderActions.resetPath();
}
}
}, [folderId, folderActions, location.state]);
const handleFolderContextMenu = useHandleGeneralContextMenu(
LibraryItem.FOLDER,
FOLDER_CONTEXT_MENU_ITEMS,
);
const folderQuery = useQuery({
enabled: !!folderId,
queryFn: ({ signal }) =>
api.controller.getFolderList({
apiClientProps: {
server: server as ServerListItemWithCredential,
serverId: server?.id || '',
signal,
},
query: {
id: folderId || '',
},
}),
queryKey: ['folder', server?.id, folderId],
});
const musicFoldersQuery = useQuery({
enabled: !folderId,
queryFn: ({ signal }) =>
api.controller.getMusicFolderList({
apiClientProps: {
server: server as ServerListItemWithCredential,
serverId: server?.id || '',
signal,
},
}),
queryKey: ['music-folders', server?.id],
});
const items = useMemo(() => {
if (folderId && folderQuery.data) {
const data = folderQuery.data as FolderListResponse;
return data.items || [];
}
if (!folderId && musicFoldersQuery.data) {
const data = musicFoldersQuery.data as MusicFolderListResponse;
return (data.items || []).map((folder) => ({
id: folder.id,
imageUrl: null,
isDir: true,
itemType: 'folder' as const,
name: folder.name,
serverId: server?.id || '',
serverType: server?.type || 'subsonic',
title: folder.name,
}));
}
return [];
}, [folderId, folderQuery.data, musicFoldersQuery.data, server]);
// Proactively check which folders are empty
useEffect(() => {
const checkEmptyFolders = async () => {
if (!items || items.length === 0 || !folderId) return;
setCheckingEmptyFolders(true);
const emptyFolderIds: string[] = [];
for (const item of items) {
if (item.isDir && !emptyFolders.has(item.id)) {
try {
// Fetch folder contents to check if it has any items
const folderData = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getFolderList({
apiClientProps: {
server: server as ServerListItemWithCredential,
serverId: server?.id || '',
signal,
},
query: { id: item.id },
}),
queryKey: ['folder', server?.id, item.id],
staleTime: 1000 * 60 * 5, // 5 minutes
});
// If folder has no items, mark it as empty
if (!folderData.items || folderData.items.length === 0) {
emptyFolderIds.push(item.id);
}
} catch (error) {
// On error, don't mark as empty (assume it might have content)
console.warn(`Failed to check folder ${item.id}:`, error);
}
}
}
if (emptyFolderIds.length > 0) {
setEmptyFolders((prev) => {
const newSet = new Set(prev);
emptyFolderIds.forEach((id) => newSet.add(id));
return newSet;
});
}
setCheckingEmptyFolders(false);
};
checkEmptyFolders();
}, [items, folderId, server, queryClient, emptyFolders]);
const handleFolderClick = useCallback(
(item: FolderItem) => {
const newFolderId = item.id;
const folderName = item.name || item.title || 'Unknown Folder';
// Update the path in the store
folderActions.pushPath({ id: newFolderId, name: folderName });
// Navigate to the new folder with breadcrumb path in state
const currentPath = path || [];
navigate(`/library/folders?folderId=${newFolderId}`, {
state: { breadcrumbPath: [...currentPath, { id: newFolderId, name: folderName }] },
});
},
[navigate, folderActions, path],
);
const handleBack = useCallback(() => {
if (path && path.length > 0) {
// Navigate to the previous folder in the path
const previousFolder = path[path.length - 2]; // Second to last item
if (previousFolder) {
const newPath = path.slice(0, -1); // Remove last item
navigate(`/library/folders?folderId=${previousFolder.id}`, {
state: { breadcrumbPath: newPath },
});
folderActions.setPath(newPath);
} else {
// Go to root if no previous folder
navigate('/library/folders');
folderActions.resetPath();
}
} else {
// If no path, go to root
navigate('/library/folders');
folderActions.resetPath();
}
}, [navigate, folderActions, path]);
const handleBreadcrumbClick = useCallback(
(index: number) => {
const currentPath = path || [];
const targetPath = currentPath.slice(0, index + 1);
// Navigate to the selected folder
if (index === -1) {
// Root level
navigate('/library/folders');
folderActions.resetPath();
} else {
const targetFolder = targetPath[index];
navigate(`/library/folders?folderId=${targetFolder.id}`, {
state: { breadcrumbPath: targetPath },
});
folderActions.setPath(targetPath);
}
},
[navigate, folderActions, path],
);
const handlePlaySong = useCallback(
(item: FolderItem) => {
if (!item.isDir && item.id) {
// Play the song
handlePlayQueueAdd?.({
byItemType: {
id: [item.id],
type: LibraryItem.SONG,
},
playType: Play.NOW,
});
}
},
[handlePlayQueueAdd],
);
// Enhanced recursive function with caching, progress tracking, and cancellation
const collectAllSongIds = useCallback(
async (
folderId: string,
signal?: AbortSignal,
onProgress?: (current: number, total: number) => void,
): Promise<string[]> => {
// Check for cancellation
if (signal?.aborted) {
throw new Error('Operation cancelled');
}
// Try to get from cache first
const cacheKey = ['folder-songs', server?.id, folderId];
const cached = queryClient.getQueryData<string[]>(cacheKey);
if (cached) {
return cached;
}
// Use React Query's fetchQuery for better caching
const folderData = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getFolderList({
apiClientProps: {
server: server as ServerListItemWithCredential,
serverId: server?.id || '',
signal,
},
query: { id: folderId },
}),
queryKey: ['folder', server?.id, folderId],
staleTime: 1000 * 60 * 5, // 5 minutes
});
if (signal?.aborted) {
throw new Error('Operation cancelled');
}
const songIds: string[] = [];
const subfolders = folderData.items.filter((item) => item.isDir);
const songs = folderData.items.filter((item) => !item.isDir);
// Add immediate song IDs
songIds.push(...songs.map((song) => song.id));
// Process subfolders with controlled concurrency
const BATCH_SIZE = 5; // Process 5 folders at a time
let processedFolders = 0;
for (let i = 0; i < subfolders.length; i += BATCH_SIZE) {
if (signal?.aborted) {
throw new Error('Operation cancelled');
}
const batch = subfolders.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map(async (subfolder) => {
try {
return await collectAllSongIds(subfolder.id, signal, onProgress);
} catch (error) {
// Log error but continue with other folders
console.warn(`Failed to collect songs from folder ${subfolder.id}:`, error);
return [];
}
});
const batchResults = await Promise.all(batchPromises);
songIds.push(...batchResults.flat());
processedFolders += batch.length;
onProgress?.(processedFolders, subfolders.length);
}
// Cache the result
queryClient.setQueryData(cacheKey, songIds, {
updatedAt: Date.now(),
});
return songIds;
},
[server, queryClient],
);
const handlePlayFolder = useCallback(
async (e: React.MouseEvent, item: FolderItem | { id: string }) => {
e.stopPropagation();
// Cancel any existing operation
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
setLoadingFolderId(item.id);
const notificationId = notifications.show({
autoClose: false,
id: 'folder-play-progress',
loading: true,
message: 'Preparing to play all songs',
title: 'Scanning folder...',
withCloseButton: true,
});
try {
// Collect song IDs with progress tracking
const songIds = await collectAllSongIds(
item.id,
abortController.signal,
(current, total) => {
notifications.update({
id: notificationId,
loading: true,
message: `Processed ${current}/${total} folders`,
title: 'Scanning folders...',
});
},
);
if (abortController.signal.aborted) {
return;
}
if (songIds.length > 0) {
// Remove from empty folders set if it has songs
setEmptyFolders((prev) => {
const newSet = new Set(prev);
newSet.delete(item.id);
return newSet;
});
// Update notification for song fetching
notifications.update({
id: notificationId,
loading: true,
message: `Loading ${songIds.length} songs`,
title: 'Fetching song details...',
});
// Fetch song details in batches to avoid overwhelming the server
const BATCH_SIZE = 20;
const allSongs: Song[] = [];
for (let i = 0; i < songIds.length; i += BATCH_SIZE) {
if (abortController.signal.aborted) {
return;
}
const batch = songIds.slice(i, i + BATCH_SIZE);
const songPromises = batch.map((songId) =>
api.controller.getSongDetail({
apiClientProps: {
server: server as ServerListItemWithCredential,
serverId: server?.id || '',
signal: abortController.signal,
},
query: { id: songId },
}),
);
const batchSongs = await Promise.all(songPromises);
allSongs.push(...batchSongs);
// Update progress
const loadedSongs = Math.min(i + BATCH_SIZE, songIds.length);
notifications.update({
id: notificationId,
loading: true,
message: `Loaded ${loadedSongs}/${songIds.length} songs`,
title: 'Fetching song details...',
});
}
if (abortController.signal.aborted) {
return;
}
// Play the songs
handlePlayQueueAdd?.({
byData: allSongs,
playType: Play.NOW,
});
// Show success notification
notifications.update({
autoClose: 3000,
color: 'green',
id: notificationId,
loading: false,
message: `Started playing ${allSongs.length} songs`,
title: 'Playing folder',
});
} else {
// Mark folder as empty
setEmptyFolders((prev) => new Set(prev).add(item.id));
notifications.update({
autoClose: 3000,
color: 'yellow',
id: notificationId,
loading: false,
message: 'This folder contains no playable songs',
title: 'No songs found',
});
}
} catch (error) {
if (abortController.signal.aborted) {
notifications.update({
autoClose: 2000,
color: 'orange',
id: notificationId,
loading: false,
message: 'Folder playback was cancelled',
title: 'Operation cancelled',
});
} else {
notifications.update({
autoClose: 5000,
color: 'red',
id: notificationId,
loading: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
title: 'Failed to play folder',
});
}
} finally {
setLoadingFolderId(null);
abortControllerRef.current = null;
}
},
[collectAllSongIds, handlePlayQueueAdd, server],
);
const handleCancelPlayFolder = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setLoadingFolderId(null);
}
}, []);
return (
<Stack className={styles.container} gap="md">
{folderId && (
<Group p="md">
<Box
aria-label="Go back to previous folder"
className={styles['back-button']}
component="button"
onClick={handleBack}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleBack();
}
}}
role="button"
tabIndex={0}
>
<RiArrowLeftLine size={24} />
</Box>
<Breadcrumbs
className={styles.breadcrumbs}
separator={
<Text c="dimmed" size="sm">
/
</Text>
}
>
<Box
className={styles['breadcrumb-item']}
onClick={() => handleBreadcrumbClick(-1)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleBreadcrumbClick(-1);
}
}}
role="button"
tabIndex={0}
>
<Text
aria-label="Go to folders list"
c="blue"
className={styles['breadcrumb-text']}
size="sm"
>
Folders
</Text>
</Box>
{path?.map((pathItem, index) =>
index < path.length - 1 ? (
<Box
className={styles['breadcrumb-item']}
key={pathItem.id}
onClick={() => handleBreadcrumbClick(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleBreadcrumbClick(index);
}
}}
role="button"
tabIndex={0}
>
<Text
aria-label={`Navigate to: ${pathItem.name}`}
c="blue"
className={styles['breadcrumb-text']}
size="sm"
>
{pathItem.name}
</Text>
</Box>
) : (
<Text
aria-label={`Current folder: ${pathItem.name}`}
c="white"
className={styles['current-breadcrumb-text']}
key={pathItem.id}
role="text"
size="sm"
>
{pathItem.name}
</Text>
),
)}
</Breadcrumbs>
</Group>
)}
{!folderId && (
<Text fw={700} p="md" size="xl">
Folders
</Text>
)}
{folderQuery.isLoading && folderId && <Text p="md">Loading...</Text>}
{musicFoldersQuery.isLoading && !folderId && <Text p="md">Loading...</Text>}
{folderQuery.error && folderId && (
<Stack gap="xs" p="md">
<Text c="red">Error: {(folderQuery.error as Error)?.message}</Text>
<Text c="dimmed" size="sm">
Note: Browsing root folders requires using artist indexes. Try browsing from
Albums or Artists views instead, or navigate directly to a subfolder if you
know its ID.
</Text>
</Stack>
)}
{musicFoldersQuery.error && !folderId && (
<Text c="red" p="md">
Error: {(musicFoldersQuery.error as Error)?.message}
</Text>
)}
<Box aria-label="Folder contents" className={styles['folder-content']} role="main">
<Stack
aria-label={`${folderId ? 'Folder contents' : 'Folders'} list`}
gap="xs"
role="list"
>
{checkingEmptyFolders ? (
<Group justify="center" p="md">
<Spinner container />
<Text c="dimmed" size="sm">
Checking folder contents...
</Text>
</Group>
) : items.length === 0 &&
!folderQuery.isLoading &&
!musicFoldersQuery.isLoading ? (
<Text c="dimmed" p="md">
No items found
</Text>
) : null}
{!checkingEmptyFolders &&
items.map((item) => (
<Group
aria-label={`${item.isDir ? 'Folder' : 'Song'}: ${item.title || item.name}`}
className={styles['folder-item']}
key={item.id}
onClick={() =>
item.isDir ? handleFolderClick(item) : handlePlaySong(item)
}
onContextMenu={(e) => {
if (item.isDir) {
handleFolderContextMenu(e, [item]);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (item.isDir) {
handleFolderClick(item);
} else {
handlePlaySong(item);
}
}
}}
p="sm"
role="listitem"
tabIndex={0}
>
{item.isDir ? (
<RiFolderLine size={24} />
) : (
<RiMusicLine size={24} />
)}
{item.isDir && !emptyFolders.has(item.id) && folderId && (
<Tooltip
label={loadingFolderId === item.id ? 'Stop' : 'Play all'}
>
<ActionIcon
aria-label={
loadingFolderId === item.id
? 'Stop playing folder'
: `Play folder: ${item.title || item.name}`
}
color={loadingFolderId === item.id ? 'red' : 'blue'}
onClick={(e) => {
if (loadingFolderId === item.id) {
handleCancelPlayFolder();
} else {
handlePlayFolder(e, item);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (loadingFolderId === item.id) {
handleCancelPlayFolder();
} else {
handlePlayFolder(e as any, item);
}
}
}}
role="button"
size="lg"
tabIndex={0}
variant="subtle"
>
{loadingFolderId === item.id ? (
<Spinner size={20} />
) : (
<RiPlayFill size={20} />
)}
</ActionIcon>
</Tooltip>
)}
<Text className={styles['song-title']}>
{item.title || item.name}
</Text>
</Group>
))}
</Stack>
</Box>
</Stack>
);
};

View file

@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { useContainerQuery } from '/@/renderer/hooks';
import { Flex } from '/@/shared/components/flex/flex';
import { Stack } from '/@/shared/components/stack/stack';
export const FolderListHeader = (): JSX.Element => {
const { t } = useTranslation();
const cq = useContainerQuery();
return (
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('page.folderList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
</Flex>
</PageHeader>
</Stack>
);
};

View file

@ -0,0 +1,56 @@
.container {
height: 100vh;
overflow: hidden;
}
.back-button {
cursor: pointer;
background: none;
border: none;
}
.breadcrumbs {
flex: 1;
}
.breadcrumb-item {
position: relative;
display: inline-block;
cursor: pointer;
background-color: transparent;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.breadcrumb-item:hover {
background-color: rgb(255 255 255 / 5%);
}
.breadcrumb-text {
user-select: none;
}
.current-breadcrumb-text {
user-select: auto;
}
.folder-content {
flex: 1;
overflow-y: auto;
}
.folder-item {
position: relative;
cursor: pointer;
background-color: transparent;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.folder-item:hover {
background-color: rgb(255 255 255 / 5%);
}
.song-title {
flex: 1;
}

View file

@ -0,0 +1,32 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useMemo, useRef } from 'react';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
import { ListContext } from '/@/renderer/context/list-context';
import { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';
import { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
const FolderListRoute = () => {
const gridRef = useRef<null | VirtualInfiniteGridRef>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const pageKey = 'folder';
const providerValue = useMemo(() => {
return {
pageKey,
};
}, []);
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<FolderListHeader />
<FolderListContent gridRef={gridRef} itemCount={undefined} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);
};
export default FolderListRoute;

View file

@ -49,6 +49,7 @@ export const Sidebar = () => {
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),

View file

@ -59,6 +59,8 @@ const DummyAlbumDetailRoute = lazy(
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
const FolderListRoute = lazy(() => import('/@/renderer/features/folders/routes/folder-list-route'));
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
@ -161,6 +163,11 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_SONGS}
/>
<Route
element={<FolderListRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_FOLDERS}
/>
<Route
element={<PlaylistListRoute />}
errorElement={<RouteErrorBoundary />}

View file

@ -0,0 +1,70 @@
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { createWithEqualityFn } from 'zustand/traditional';
export interface FolderStoreSlice extends FolderStoreState {
actions: {
popPath: () => void;
pushPath: (path: { id: string; name: string }) => void;
resetPath: () => void;
setCurrentFolderId: (id: null | string) => void;
setPath: (path: Array<{ id: string; name: string }>) => void;
};
}
export interface FolderStoreState {
currentFolderId: null | string;
path: Array<{ id: string; name: string }>;
}
export const useFolderStore = createWithEqualityFn<FolderStoreSlice>()(
devtools(
immer((set) => ({
actions: {
popPath: () => {
set((state) => {
if (state.path.length > 0) {
state.path.pop();
state.currentFolderId =
state.path.length > 0 ? state.path[state.path.length - 1].id : null;
}
});
},
pushPath: (pathItem) => {
set((state) => {
state.path.push(pathItem);
state.currentFolderId = pathItem.id;
});
},
resetPath: () => {
set((state) => {
state.path = [];
state.currentFolderId = null;
});
},
setCurrentFolderId: (id) => {
set((state) => {
state.currentFolderId = id;
});
},
setPath: (path) => {
set((state) => {
state.path = path;
state.currentFolderId = path.length > 0 ? path[path.length - 1].id : null;
});
},
},
currentFolderId: null,
path: [],
})),
{ name: 'store_folder' },
),
);
export const useFolderStoreActions = () => useFolderStore((state) => state.actions);
export const useFolderPath = () =>
useFolderStore((state) => ({
currentFolderId: state.currentFolderId,
path: state.path,
}));

View file

@ -475,6 +475,12 @@ export const sidebarItems: SidebarItemType[] = [
label: i18n.t('page.sidebar.genres'),
route: AppRoute.LIBRARY_GENRES,
},
{
disabled: false,
id: 'Folders',
label: i18n.t('page.sidebar.folders'),
route: AppRoute.LIBRARY_FOLDERS,
},
{
disabled: true,
id: 'Playlists',

View file

@ -334,9 +334,51 @@ const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre =>
};
};
const normalizeFolderItem = (
item: z.infer<typeof ssType._response.directoryChild>,
server?: null | ServerListItemWithCredential,
): import('/@/shared/types/domain-types').FolderItem => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt?.toString(),
credential: server?.credential,
size: 300,
}) || null;
return {
album: item.album,
albumId: item.albumId?.toString(),
artist: item.artist,
artistId: item.artistId?.toString(),
coverArt: item.coverArt?.toString(),
created: item.created,
duration: item.duration,
genre: item.genre,
id: item.id.toString(),
imageUrl,
isDir: item.isDir,
itemType: LibraryItem.FOLDER,
name: item.title,
parent: item.parent,
path: item.path,
playCount: item.playCount,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: item.size,
starred: !!item.starred,
suffix: item.suffix,
title: item.title,
track: item.track,
userRating: item.userRating,
year: item.year,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
folderItem: normalizeFolderItem,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,

View file

@ -548,6 +548,73 @@ const albumInfo = z.object({
}),
});
const directoryChild = z.object({
album: z.string().optional(),
albumId: id.optional(),
artist: z.string().optional(),
artistId: id.optional(),
averageRating: z.number().optional(),
contentType: z.string().optional(),
coverArt: z.string().optional(),
created: z.string().optional(),
duration: z.number().optional(),
genre: z.string().optional(),
id,
isDir: z.boolean(),
isVideo: z.boolean().optional(),
parent: z.string().optional(),
path: z.string().optional(),
playCount: z.number().optional(),
size: z.number().optional(),
starred: z.string().optional(),
suffix: z.string().optional(),
title: z.string(),
track: z.number().optional(),
type: z.string().optional(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const directory = z.object({
averageRating: z.number().optional(),
child: z.array(directoryChild).optional(),
id,
name: z.string(),
parent: z.string().optional(),
playCount: z.number().optional(),
starred: z.string().optional(),
userRating: z.number().optional(),
});
const getMusicDirectoryParameters = z.object({
id: z.string(),
});
const getMusicDirectory = z.object({
directory,
});
const getIndexesParameters = z.object({
ifModifiedSince: z.number().optional(),
musicFolderId: z.string().optional(),
});
const getIndexes = z.object({
indexes: z.object({
ignoredArticles: z.string().optional(),
index: z
.array(
z.object({
artist: z.array(artistListEntry).optional(),
name: z.string(),
}),
)
.optional(),
lastModified: z.number(),
shortcut: z.array(artistListEntry).optional(),
}),
});
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
@ -563,6 +630,8 @@ export const ssType = {
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
getIndexes: getIndexesParameters,
getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
@ -591,12 +660,16 @@ export const ssType = {
baseResponse,
createFavorite,
createPlaylist,
directory,
directoryChild,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
getIndexes,
getMusicDirectory,
getPlaylist,
getPlaylists,
getSong,

View file

@ -31,6 +31,7 @@ export enum LibraryItem {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
FOLDER = 'folder',
GENRE = 'genre',
PLAYLIST = 'playlist',
SONG = 'song',
@ -255,6 +256,47 @@ export type EndpointDetails = {
server: ServerListItem;
};
export type FolderItem = {
album?: string;
albumId?: string;
artist?: string;
artistId?: string;
coverArt?: string;
created?: string;
duration?: number;
genre?: string;
id: string;
imageUrl: null | string;
isDir: boolean;
itemType: LibraryItem.FOLDER;
name: string;
parent?: string;
path?: string;
playCount?: number;
serverId: string;
serverType: ServerType;
size?: number;
starred?: boolean;
suffix?: string;
title: string;
track?: number;
userRating?: number;
year?: number;
};
export type FolderListArgs = BaseEndpointArgs & { query: FolderListQuery };
export interface FolderListQuery {
id: string;
}
export type FolderListResponse = {
id: string;
items: FolderItem[];
name: string;
parent?: string;
};
export type GainInfo = {
album?: number;
track?: number;
@ -1236,6 +1278,7 @@ export type ControllerEndpoint = {
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getDownloadUrl: (args: DownloadArgs) => string;
getFolderList: (args: FolderListArgs) => Promise<FolderListResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
@ -1314,6 +1357,7 @@ export type InternalControllerEndpoint = {
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFolderList: (args: ReplaceApiClientProps<FolderListArgs>) => Promise<FolderListResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
getMusicFolderList: (