mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 18:13:31 +00:00
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:
parent
6a236c803a
commit
7a12e4657f
22 changed files with 1237 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.21.2",
|
"version": "0.22.0",
|
||||||
"description": "A modern self-hosted music player.",
|
"description": "A modern self-hosted music player.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"subsonic",
|
"subsonic",
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,10 @@
|
||||||
"visualizer": "visualizer",
|
"visualizer": "visualizer",
|
||||||
"noLyrics": "no lyrics found"
|
"noLyrics": "no lyrics found"
|
||||||
},
|
},
|
||||||
|
"folderList": {
|
||||||
|
"title": "Folders",
|
||||||
|
"description": "Browse music by folder structure"
|
||||||
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
|
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
|
||||||
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,20 @@ export const controller: GeneralController = {
|
||||||
server.type,
|
server.type,
|
||||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
)?.({ ...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) {
|
getGenreList(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||||
|
|
||||||
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
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) => {
|
getGenreList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|
@ -1085,6 +1090,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||||
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePlaylist: async (args) => {
|
updatePlaylist: async (args) => {
|
||||||
const { apiClientProps, body, query } = args;
|
const { apiClientProps, body, query } = args;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,11 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
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) => {
|
getGenreList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|
@ -716,6 +721,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||||
id: res.body.data.id,
|
id: res.body.data.id,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePlaylist: async (args) => {
|
updatePlaylist: async (args) => {
|
||||||
const { apiClientProps, body, query } = args;
|
const { apiClientProps, body, query } = args;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
AlbumDetailQuery,
|
AlbumDetailQuery,
|
||||||
AlbumListQuery,
|
AlbumListQuery,
|
||||||
ArtistListQuery,
|
ArtistListQuery,
|
||||||
|
FolderListQuery,
|
||||||
GenreListQuery,
|
GenreListQuery,
|
||||||
LyricSearchQuery,
|
LyricSearchQuery,
|
||||||
LyricsQuery,
|
LyricsQuery,
|
||||||
|
|
@ -158,6 +159,13 @@ export const queryKeys: Record<
|
||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
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: {
|
genres: {
|
||||||
list: (serverId: string, query?: GenreListQuery) => {
|
list: (serverId: string, query?: GenreListQuery) => {
|
||||||
const { filter, pagination } = splitPaginatedQuery(query);
|
const { filter, pagination } = splitPaginatedQuery(query);
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,22 @@ export const contract = c.router({
|
||||||
200: ssType._response.getGenres,
|
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: {
|
getMusicFolderList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getMusicFolders.view',
|
path: 'getMusicFolders.view',
|
||||||
|
|
|
||||||
|
|
@ -620,6 +620,77 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||||
'&c=Feishin'
|
'&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 }) => {
|
getGenreList: async ({ apiClientProps, query }) => {
|
||||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,14 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ divider: true, id: 'showDetails' },
|
{ 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 = [
|
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
Artist,
|
Artist,
|
||||||
|
FolderItem,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
QueueSong,
|
QueueSong,
|
||||||
Song,
|
Song,
|
||||||
|
|
@ -72,7 +73,7 @@ export const useHandleGeneralContextMenu = (
|
||||||
) => {
|
) => {
|
||||||
const handleContextMenu = (
|
const handleContextMenu = (
|
||||||
e: any,
|
e: any,
|
||||||
data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[],
|
data: Album[] | AlbumArtist[] | Artist[] | FolderItem[] | QueueSong[] | Song[],
|
||||||
) => {
|
) => {
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
const clickEvent = e as MouseEvent;
|
const clickEvent = e as MouseEvent;
|
||||||
|
|
|
||||||
32
src/renderer/features/folders/api/folder-api.ts
Normal file
32
src/renderer/features/folders/api/folder-api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
712
src/renderer/features/folders/components/folder-list-content.tsx
Normal file
712
src/renderer/features/folders/components/folder-list-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
32
src/renderer/features/folders/routes/folder-list-route.tsx
Normal file
32
src/renderer/features/folders/routes/folder-list-route.tsx
Normal 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;
|
||||||
|
|
@ -49,6 +49,7 @@ export const Sidebar = () => {
|
||||||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||||
|
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ const DummyAlbumDetailRoute = lazy(
|
||||||
|
|
||||||
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
|
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 SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
|
||||||
|
|
||||||
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
|
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
|
||||||
|
|
@ -161,6 +163,11 @@ export const AppRouter = () => {
|
||||||
errorElement={<RouteErrorBoundary />}
|
errorElement={<RouteErrorBoundary />}
|
||||||
path={AppRoute.LIBRARY_SONGS}
|
path={AppRoute.LIBRARY_SONGS}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
element={<FolderListRoute />}
|
||||||
|
errorElement={<RouteErrorBoundary />}
|
||||||
|
path={AppRoute.LIBRARY_FOLDERS}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={<PlaylistListRoute />}
|
element={<PlaylistListRoute />}
|
||||||
errorElement={<RouteErrorBoundary />}
|
errorElement={<RouteErrorBoundary />}
|
||||||
|
|
|
||||||
70
src/renderer/store/folder.store.ts
Normal file
70
src/renderer/store/folder.store.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
|
@ -475,6 +475,12 @@ export const sidebarItems: SidebarItemType[] = [
|
||||||
label: i18n.t('page.sidebar.genres'),
|
label: i18n.t('page.sidebar.genres'),
|
||||||
route: AppRoute.LIBRARY_GENRES,
|
route: AppRoute.LIBRARY_GENRES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
id: 'Folders',
|
||||||
|
label: i18n.t('page.sidebar.folders'),
|
||||||
|
route: AppRoute.LIBRARY_FOLDERS,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
disabled: true,
|
disabled: true,
|
||||||
id: 'Playlists',
|
id: 'Playlists',
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const ssNormalize = {
|
||||||
album: normalizeAlbum,
|
album: normalizeAlbum,
|
||||||
albumArtist: normalizeAlbumArtist,
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
folderItem: normalizeFolderItem,
|
||||||
genre: normalizeGenre,
|
genre: normalizeGenre,
|
||||||
playlist: normalizePlaylist,
|
playlist: normalizePlaylist,
|
||||||
song: normalizeSong,
|
song: normalizeSong,
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const ssType = {
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumInfo: albumInfoParameters,
|
albumInfo: albumInfoParameters,
|
||||||
|
|
@ -563,6 +630,8 @@ export const ssType = {
|
||||||
getArtists: getArtistsParameters,
|
getArtists: getArtistsParameters,
|
||||||
getGenre: getGenresParameters,
|
getGenre: getGenresParameters,
|
||||||
getGenres: getGenresParameters,
|
getGenres: getGenresParameters,
|
||||||
|
getIndexes: getIndexesParameters,
|
||||||
|
getMusicDirectory: getMusicDirectoryParameters,
|
||||||
getPlaylist: getPlaylistParameters,
|
getPlaylist: getPlaylistParameters,
|
||||||
getPlaylists: getPlaylistsParameters,
|
getPlaylists: getPlaylistsParameters,
|
||||||
getSong: getSongParameters,
|
getSong: getSongParameters,
|
||||||
|
|
@ -591,12 +660,16 @@ export const ssType = {
|
||||||
baseResponse,
|
baseResponse,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
directory,
|
||||||
|
directoryChild,
|
||||||
genre,
|
genre,
|
||||||
getAlbum,
|
getAlbum,
|
||||||
getAlbumList2,
|
getAlbumList2,
|
||||||
getArtist,
|
getArtist,
|
||||||
getArtists,
|
getArtists,
|
||||||
getGenres,
|
getGenres,
|
||||||
|
getIndexes,
|
||||||
|
getMusicDirectory,
|
||||||
getPlaylist,
|
getPlaylist,
|
||||||
getPlaylists,
|
getPlaylists,
|
||||||
getSong,
|
getSong,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export enum LibraryItem {
|
||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
ALBUM_ARTIST = 'albumArtist',
|
ALBUM_ARTIST = 'albumArtist',
|
||||||
ARTIST = 'artist',
|
ARTIST = 'artist',
|
||||||
|
FOLDER = 'folder',
|
||||||
GENRE = 'genre',
|
GENRE = 'genre',
|
||||||
PLAYLIST = 'playlist',
|
PLAYLIST = 'playlist',
|
||||||
SONG = 'song',
|
SONG = 'song',
|
||||||
|
|
@ -255,6 +256,47 @@ export type EndpointDetails = {
|
||||||
server: ServerListItem;
|
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 = {
|
export type GainInfo = {
|
||||||
album?: number;
|
album?: number;
|
||||||
track?: number;
|
track?: number;
|
||||||
|
|
@ -1236,6 +1278,7 @@ export type ControllerEndpoint = {
|
||||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||||
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
||||||
getDownloadUrl: (args: DownloadArgs) => string;
|
getDownloadUrl: (args: DownloadArgs) => string;
|
||||||
|
getFolderList: (args: FolderListArgs) => Promise<FolderListResponse>;
|
||||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||||
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||||
|
|
@ -1314,6 +1357,7 @@ export type InternalControllerEndpoint = {
|
||||||
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
||||||
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
||||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||||
|
getFolderList: (args: ReplaceApiClientProps<FolderListArgs>) => Promise<FolderListResponse>;
|
||||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||||
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
||||||
getMusicFolderList: (
|
getMusicFolderList: (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue