mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-04 03:13:29 +02:00
add folder browsing support (#315)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
getAlbumSongsById,
|
||||
getGenreSongsById,
|
||||
getPlaylistSongsById,
|
||||
getSongsByFolder,
|
||||
} from '/@/renderer/features/player/utils';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
|
||||
@@ -97,49 +98,64 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
|
||||
const getSongsByAlbum = useCallback(
|
||||
async (albumId: string) => {
|
||||
if (!server) return null;
|
||||
return getAlbumSongsById({
|
||||
id: [albumId],
|
||||
queryClient,
|
||||
server,
|
||||
serverId,
|
||||
});
|
||||
},
|
||||
[queryClient, server],
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
const getSongsByArtist = useCallback(
|
||||
async (artistId: string) => {
|
||||
if (!server) return null;
|
||||
return getAlbumArtistSongsById({
|
||||
id: [artistId],
|
||||
queryClient,
|
||||
server,
|
||||
serverId,
|
||||
});
|
||||
},
|
||||
[queryClient, server],
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
const getSongsByGenre = useCallback(
|
||||
async (genreIds: string[]) => {
|
||||
if (!server) return null;
|
||||
return getGenreSongsById({
|
||||
id: genreIds,
|
||||
queryClient,
|
||||
server,
|
||||
serverId,
|
||||
});
|
||||
},
|
||||
[queryClient, server],
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
const getSongsByPlaylist = useCallback(
|
||||
async (playlistId: string) => {
|
||||
if (!server) return null;
|
||||
return getPlaylistSongsById({
|
||||
id: playlistId,
|
||||
queryClient,
|
||||
server,
|
||||
serverId,
|
||||
});
|
||||
},
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
const getSongsByFolderLocal = useCallback(
|
||||
async (folderId: string) => {
|
||||
if (!server) return null;
|
||||
|
||||
const songsResponse = await getSongsByFolder({
|
||||
id: [folderId],
|
||||
queryClient,
|
||||
serverId: server.id,
|
||||
});
|
||||
|
||||
return {
|
||||
items: songsResponse.items.map((song) => song.id),
|
||||
startIndex: 0,
|
||||
totalRecordCount: songsResponse.items.length,
|
||||
};
|
||||
},
|
||||
[queryClient, server],
|
||||
);
|
||||
|
||||
@@ -173,6 +189,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
const songs = await getSongsByPlaylist(id);
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
} else if (itemType === LibraryItem.FOLDER) {
|
||||
for (const id of items) {
|
||||
const songs = await getSongsByFolderLocal(id);
|
||||
allSongIds.push(...(songs?.items || []));
|
||||
}
|
||||
}
|
||||
|
||||
if (allSongIds.length === 0) {
|
||||
@@ -213,6 +234,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
addToPlaylistMutation,
|
||||
getSongsByAlbum,
|
||||
getSongsByArtist,
|
||||
getSongsByFolderLocal,
|
||||
getSongsByGenre,
|
||||
getSongsByPlaylist,
|
||||
itemType,
|
||||
@@ -226,6 +248,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
const modalProps: {
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
folderId?: string[];
|
||||
genreId?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
playlistId?: string[];
|
||||
@@ -240,6 +263,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
case LibraryItem.ARTIST:
|
||||
modalProps.artistId = items;
|
||||
break;
|
||||
case LibraryItem.FOLDER:
|
||||
modalProps.folderId = items;
|
||||
break;
|
||||
case LibraryItem.GENRE:
|
||||
modalProps.genreId = items;
|
||||
break;
|
||||
|
||||
@@ -19,6 +19,8 @@ export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
|
||||
return 'album';
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
return 'albumArtist';
|
||||
case LibraryItem.FOLDER:
|
||||
return 'folder';
|
||||
case LibraryItem.PLAYLIST:
|
||||
return 'playlist';
|
||||
case LibraryItem.SONG:
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useParams } from 'react-router';
|
||||
import { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';
|
||||
import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu';
|
||||
import { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu';
|
||||
import { FolderContextMenu } from '/@/renderer/features/context-menu/menus/folder-context-menu';
|
||||
import { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';
|
||||
import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu';
|
||||
import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
Folder,
|
||||
Genre,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
@@ -82,6 +84,7 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
|
||||
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
|
||||
@@ -95,6 +98,7 @@ export type ContextMenuCommand =
|
||||
| AlbumArtistContextMenuProps
|
||||
| AlbumContextMenuProps
|
||||
| ArtistContextMenuProps
|
||||
| FolderContextMenuProps
|
||||
| GenreContextMenuProps
|
||||
| PlaylistContextMenuProps
|
||||
| PlaylistSongContextMenuProps
|
||||
@@ -116,6 +120,11 @@ type ArtistContextMenuProps = {
|
||||
type: LibraryItem.ARTIST;
|
||||
};
|
||||
|
||||
type FolderContextMenuProps = {
|
||||
items: Folder[];
|
||||
type: LibraryItem.FOLDER;
|
||||
};
|
||||
|
||||
type GenreContextMenuProps = {
|
||||
items: Genre[];
|
||||
type: LibraryItem.GENRE;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { ContextMenuPreview } from '/@/shared/components/context-menu/context-menu-preview';
|
||||
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface FolderContextMenuProps {
|
||||
items: Folder[];
|
||||
type: LibraryItem.FOLDER;
|
||||
}
|
||||
|
||||
export const FolderContextMenu = ({ items, type }: FolderContextMenuProps) => {
|
||||
const { ids } = useMemo(() => {
|
||||
const ids = items.map((item) => item.id);
|
||||
return { ids };
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Content
|
||||
bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}
|
||||
>
|
||||
<PlayAction ids={ids} itemType={LibraryItem.FOLDER} />
|
||||
<ContextMenu.Divider />
|
||||
<AddToPlaylistAction items={ids} itemType={LibraryItem.FOLDER} />
|
||||
<ContextMenu.Divider />
|
||||
<DownloadAction ids={ids} />
|
||||
<ShareAction ids={ids} itemType={LibraryItem.FOLDER} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
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 { FolderQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const folderQueries = {
|
||||
folder: (args: QueryHookArgs<FolderQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getFolder({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.folders.folder(args.serverId, args.query),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Suspense, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { DefaultItemControlProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
|
||||
import { FolderTreeBrowser } from '/@/renderer/features/folders/components/folder-tree-browser';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { useCurrentServerId, useListSettings, usePlayerSong } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Folder, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||
|
||||
export const FolderListContent = () => {
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<FolderListInnerContent />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const FolderListInnerContent = () => {
|
||||
const serverId = useCurrentServerId();
|
||||
const queryClient = useQueryClient();
|
||||
const { currentFolderId, query } = useFolderListFilters();
|
||||
|
||||
const getFolderQueryOptions = useCallback(
|
||||
(folderId: string) => {
|
||||
return folderQueries.folder({
|
||||
query: {
|
||||
id: folderId,
|
||||
searchTerm: query[FILTER_KEYS.SHARED.SEARCH_TERM] as string | undefined,
|
||||
sortBy:
|
||||
(query[FILTER_KEYS.SHARED.SORT_BY] as SongListSort) || SongListSort.NAME,
|
||||
sortOrder: (query[FILTER_KEYS.SHARED.SORT_ORDER] as SortOrder) || SortOrder.ASC,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
},
|
||||
[serverId, query],
|
||||
);
|
||||
|
||||
const rootFolderQuery = useQuery({
|
||||
...getFolderQueryOptions('0'),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const currentFolderQuery = useSuspenseQuery({
|
||||
...getFolderQueryOptions(currentFolderId),
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
|
||||
const fetchFolder = useCallback(
|
||||
async (folderId: string) => {
|
||||
const queryOptions = getFolderQueryOptions(folderId);
|
||||
return queryClient.fetchQuery({
|
||||
...queryOptions,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
},
|
||||
[getFolderQueryOptions, queryClient],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWithSidebarContainer.SidebarPortal>
|
||||
<FolderTreeBrowser fetchFolder={fetchFolder} rootFolderQuery={rootFolderQuery} />
|
||||
</ListWithSidebarContainer.SidebarPortal>
|
||||
<FolderListView folderQuery={currentFolderQuery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface FolderListViewProps {
|
||||
folderQuery: ReturnType<typeof useSuspenseQuery<Folder>>;
|
||||
}
|
||||
|
||||
export const FolderListView = ({ folderQuery }: FolderListViewProps) => {
|
||||
const { display, table } = useListSettings(ItemListKey.SONG);
|
||||
const { setItemCount } = useListContext();
|
||||
const { navigateToFolder } = useFolderListFilters();
|
||||
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
if (!folderQuery.data?.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { folders = [], songs = [] } = folderQuery.data.children;
|
||||
return [...folders, ...songs];
|
||||
}, [folderQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemCount?.(allItems.length);
|
||||
}, [allItems.length, setItemCount]);
|
||||
|
||||
const player = usePlayer();
|
||||
|
||||
const overrideControls = useMemo(() => {
|
||||
return {
|
||||
onDoubleClick: ({ index, internalState, item }: DefaultItemControlProps) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((item as unknown as Folder)._itemType === LibraryItem.FOLDER) {
|
||||
const folder = item as unknown as Folder;
|
||||
return navigateToFolder(folder.id, folder.name);
|
||||
}
|
||||
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
const songCount = items.filter(
|
||||
(item) => item._itemType === LibraryItem.SONG,
|
||||
).length;
|
||||
|
||||
const indexesToSkip = items.length - songCount;
|
||||
|
||||
const startIndex = indexesToSkip + (index ?? 0);
|
||||
player.addToQueueByData(items, Play.NOW);
|
||||
player.mediaPlayByIndex(startIndex);
|
||||
},
|
||||
};
|
||||
}, [navigateToFolder, player]);
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
switch (display) {
|
||||
// case ListDisplayType.GRID: {
|
||||
// return (
|
||||
// <ItemGridList
|
||||
// data={allItems}
|
||||
// gap={grid.itemGap}
|
||||
// initialTop={{
|
||||
// to: scrollOffset ?? 0,
|
||||
// type: 'offset',
|
||||
// }}
|
||||
// itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
|
||||
// itemType={LibraryItem.FOLDER}
|
||||
// onScrollEnd={handleOnScrollEnd}
|
||||
// overrideControls={overrideControls}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
case ListDisplayType.TABLE: {
|
||||
return (
|
||||
<ItemTableList
|
||||
activeRowId={currentSong?.id}
|
||||
autoFitColumns={table.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={table.columns}
|
||||
data={allItems}
|
||||
enableAlternateRowColors={table.enableAlternateRowColors}
|
||||
enableDrag={true}
|
||||
enableExpansion={false}
|
||||
enableHorizontalBorders={table.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={table.enableRowHoverHighlight}
|
||||
enableVerticalBorders={table.enableVerticalBorders}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
}}
|
||||
itemType={LibraryItem.FOLDER}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
overrideControls={overrideControls}
|
||||
size={table.size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { truncateMiddle } from '/@/renderer/utils';
|
||||
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const MAX_BREADCRUMB_TEXT_LENGTH = 26;
|
||||
|
||||
export const FolderListHeaderFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
const { folderPath, navigateToPathIndex, setFolderPath } = useFolderListFilters();
|
||||
const {
|
||||
is2xl,
|
||||
isLg,
|
||||
isMd,
|
||||
isSm,
|
||||
isXl,
|
||||
isXs,
|
||||
ref: breadcrumbContainerRef,
|
||||
} = useContainerQuery();
|
||||
|
||||
const maxItems = useMemo(() => {
|
||||
if (is2xl) return 8;
|
||||
if (isXl) return 6;
|
||||
if (isLg) return 4;
|
||||
if (isMd) return 3;
|
||||
if (isSm) return 2;
|
||||
if (isXs) return 2;
|
||||
return 1;
|
||||
}, [is2xl, isLg, isMd, isSm, isXl, isXs]);
|
||||
|
||||
const allBreadcrumbItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
fullLabel: string;
|
||||
id: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}> = [];
|
||||
|
||||
const homeLabel = t('common.home', { postProcess: 'titleCase' });
|
||||
items.push({
|
||||
fullLabel: homeLabel,
|
||||
id: 'folder-root',
|
||||
label: homeLabel,
|
||||
onClick: () => {
|
||||
setFolderPath([]);
|
||||
},
|
||||
});
|
||||
|
||||
folderPath.forEach((folder, index) => {
|
||||
items.push({
|
||||
fullLabel: folder.name,
|
||||
id: `folder-${folder.id}`,
|
||||
label: truncateMiddle(folder.name, MAX_BREADCRUMB_TEXT_LENGTH),
|
||||
onClick: () => navigateToPathIndex(index),
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [folderPath, navigateToPathIndex, setFolderPath, t]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const firstItem = allBreadcrumbItems[0];
|
||||
|
||||
if (maxItems === 1) {
|
||||
return [firstItem];
|
||||
}
|
||||
|
||||
if (allBreadcrumbItems.length <= maxItems) {
|
||||
return allBreadcrumbItems;
|
||||
}
|
||||
|
||||
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
|
||||
const middleItems = allBreadcrumbItems.slice(1, -1);
|
||||
const availableSlots = maxItems - 2;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
return [firstItem, lastItem];
|
||||
}
|
||||
|
||||
if (middleItems.length <= availableSlots) {
|
||||
return [firstItem, ...middleItems, lastItem];
|
||||
}
|
||||
|
||||
const startCount = Math.floor(availableSlots / 2);
|
||||
const endCount = availableSlots - startCount;
|
||||
const startMiddle = middleItems.slice(0, startCount);
|
||||
const endMiddle = middleItems.slice(-endCount);
|
||||
|
||||
return [firstItem, ...startMiddle, ...endMiddle, lastItem];
|
||||
}, [allBreadcrumbItems, maxItems]);
|
||||
|
||||
const collapsedItems = useMemo(() => {
|
||||
if (maxItems === 1) {
|
||||
return allBreadcrumbItems.slice(1);
|
||||
}
|
||||
|
||||
if (allBreadcrumbItems.length <= maxItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const middleItems = allBreadcrumbItems.slice(1, -1);
|
||||
const availableSlots = maxItems - 2;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
return middleItems;
|
||||
}
|
||||
|
||||
if (middleItems.length <= availableSlots) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const startCount = Math.floor(availableSlots / 2);
|
||||
const endCount = availableSlots - startCount;
|
||||
const visibleStart = middleItems.slice(0, startCount);
|
||||
const visibleEnd = middleItems.slice(-endCount);
|
||||
|
||||
return middleItems.filter(
|
||||
(item) => !visibleStart.includes(item) && !visibleEnd.includes(item),
|
||||
);
|
||||
}, [allBreadcrumbItems, maxItems]);
|
||||
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
const items: React.ReactNode[] = [];
|
||||
const firstItem = allBreadcrumbItems[0];
|
||||
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
|
||||
const hasCollapsedItems = collapsedItems.length > 0;
|
||||
|
||||
const renderDropdown = () => (
|
||||
<DropdownMenu key="breadcrumb-dropdown" position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button size="compact-sm" variant="subtle">
|
||||
<Icon icon="ellipsisHorizontal" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{collapsedItems.map((collapsedItem) => (
|
||||
<DropdownMenu.Item key={collapsedItem.id} onClick={collapsedItem.onClick}>
|
||||
{collapsedItem.fullLabel}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (hasCollapsedItems && maxItems === 1) {
|
||||
items.push(
|
||||
<Button
|
||||
key={firstItem.id}
|
||||
onClick={firstItem.onClick}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{firstItem.label}
|
||||
</Button>,
|
||||
);
|
||||
items.push(renderDropdown());
|
||||
return items;
|
||||
}
|
||||
|
||||
if (hasCollapsedItems) {
|
||||
const middleItems = allBreadcrumbItems.slice(1, -1);
|
||||
const availableSlots = maxItems - 2;
|
||||
const startCount = Math.floor(availableSlots / 2);
|
||||
const visibleStartMiddle = middleItems.slice(0, startCount);
|
||||
const visibleEndMiddle = middleItems.slice(-(availableSlots - startCount));
|
||||
|
||||
visibleItems.forEach((item, index) => {
|
||||
items.push(
|
||||
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
|
||||
{item.label}
|
||||
</Button>,
|
||||
);
|
||||
|
||||
if (index < visibleItems.length - 1) {
|
||||
const nextItem = visibleItems[index + 1];
|
||||
const isFirstItem = item.id === firstItem.id;
|
||||
const isLastStartMiddle =
|
||||
item.id !== firstItem.id &&
|
||||
item.id !== lastItem.id &&
|
||||
visibleStartMiddle.length > 0 &&
|
||||
item.id === visibleStartMiddle[visibleStartMiddle.length - 1].id;
|
||||
|
||||
const shouldInsertDropdown =
|
||||
(isFirstItem && nextItem.id === lastItem.id) ||
|
||||
(isLastStartMiddle &&
|
||||
(nextItem.id === lastItem.id ||
|
||||
(visibleEndMiddle.length > 0 &&
|
||||
nextItem.id === visibleEndMiddle[0].id)));
|
||||
|
||||
if (shouldInsertDropdown) {
|
||||
items.push(renderDropdown());
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
visibleItems.forEach((item) => {
|
||||
items.push(
|
||||
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
|
||||
{item.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [visibleItems, collapsedItems, allBreadcrumbItems, maxItems]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Flex justify="space-between">
|
||||
<Group gap="sm" w="100%">
|
||||
<ListSortByDropdown
|
||||
defaultSortByValue={SongListSort.ID}
|
||||
itemType={LibraryItem.FOLDER}
|
||||
listKey={ItemListKey.SONG}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<ListSortOrderToggleButton
|
||||
defaultSortOrder={SortOrder.ASC}
|
||||
listKey={ItemListKey.SONG}
|
||||
/>
|
||||
<ListRefreshButton listKey={ItemListKey.SONG} />
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ListConfigMenu
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
grid: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
table: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
}}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
<div ref={breadcrumbContainerRef}>
|
||||
<Breadcrumb separator={<Icon icon="arrowRight" />}>{breadcrumbItems}</Breadcrumb>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { FolderListHeaderFilters } from '/@/renderer/features/folders/components/folder-list-header-filters';
|
||||
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
interface FolderListHeaderProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FolderListHeader = ({ title }: FolderListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { itemCount } = useListContext();
|
||||
const pageTitle = title || t('page.folderList.title', { postProcess: 'titleCase' });
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar ignoreMaxWidth>
|
||||
<Stack>
|
||||
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
|
||||
</Stack>
|
||||
<LibraryHeaderBar.Badge isLoading={itemCount === undefined}>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<ListSearchInput />
|
||||
</Group>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<FolderListHeaderFilters />
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--theme-spacing-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--theme-radius-md);
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
.row.active {
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.row.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.expand-icon-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--theme-colors-foreground);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.expand-icon-placeholder {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.folder-icon-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-colors-foreground);
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--theme-font-size-md);
|
||||
color: var(--theme-colors-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row.active .folder-name {
|
||||
font-weight: 500;
|
||||
color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md) var(--theme-spacing-sm) 0;
|
||||
font-size: var(--theme-font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { type UseQueryResult } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { List, RowComponentProps } from 'react-window-v2';
|
||||
|
||||
import styles from './folder-tree-browser.module.css';
|
||||
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
|
||||
interface FlattenedNode {
|
||||
depth: number;
|
||||
folder: Folder;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
path: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
childrenLoaded: boolean;
|
||||
depth: number;
|
||||
folder: Folder;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 32;
|
||||
const INDENT_SIZE = 16;
|
||||
|
||||
interface FolderTreeBrowserProps {
|
||||
fetchFolder: (folderId: string) => Promise<Folder>;
|
||||
rootFolderQuery: UseQueryResult<Folder, Error>;
|
||||
}
|
||||
|
||||
export const FolderTreeBrowser = ({ fetchFolder, rootFolderQuery }: FolderTreeBrowserProps) => {
|
||||
const { currentFolderId, setFolderPath } = useFolderListFilters();
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [loadedNodes, setLoadedNodes] = useState<Map<string, Folder[]>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initialize root folder children when data is loaded
|
||||
useEffect(() => {
|
||||
if (rootFolderQuery.data?.children?.folders && !loadedNodes.has('0')) {
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set('0', rootFolderQuery.data?.children?.folders || []);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [rootFolderQuery.data, loadedNodes]);
|
||||
|
||||
// Fetch folder when expanding a node
|
||||
const fetchFolderChildren = useCallback(
|
||||
async (folderId: string) => {
|
||||
if (loadedNodes.has(folderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchFolder(folderId);
|
||||
|
||||
if (result?.children?.folders) {
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const folders = result?.children?.folders || [];
|
||||
newMap.set(folderId, folders);
|
||||
return newMap;
|
||||
});
|
||||
} else {
|
||||
// Even if no children, mark as loaded to avoid refetching
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(folderId, []);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setLoadedNodes((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(folderId, []);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchFolder, loadedNodes],
|
||||
);
|
||||
|
||||
// Get children for a folder
|
||||
const getFolderChildren = useCallback(
|
||||
(folder: Folder): Folder[] => {
|
||||
// First check if we have explicitly loaded children in loadedNodes
|
||||
const loaded = loadedNodes.get(folder.id);
|
||||
if (loaded !== undefined) {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// Otherwise, use children from the folder object itself (if available)
|
||||
// This handles cases where children came with the parent folder's response
|
||||
return folder.children?.folders || [];
|
||||
},
|
||||
[loadedNodes],
|
||||
);
|
||||
|
||||
// Build tree structure from root
|
||||
const buildTree = useCallback(
|
||||
(folder: Folder, depth: number = 0): TreeNode => {
|
||||
const folderId = folder.id;
|
||||
const isExpanded = expandedNodes.has(folderId);
|
||||
const children = getFolderChildren(folder);
|
||||
const hasChildren = children.length > 0;
|
||||
const childrenLoaded =
|
||||
loadedNodes.has(folderId) || (folder.children?.folders?.length ?? 0) > 0;
|
||||
|
||||
return {
|
||||
childrenLoaded,
|
||||
depth,
|
||||
folder,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
};
|
||||
},
|
||||
[expandedNodes, loadedNodes, getFolderChildren],
|
||||
);
|
||||
|
||||
// Flatten tree to list for virtualization
|
||||
const flattenedNodes = useMemo((): FlattenedNode[] => {
|
||||
if (!rootFolderQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: FlattenedNode[] = [];
|
||||
const rootFolder = rootFolderQuery.data;
|
||||
|
||||
const traverse = (
|
||||
folder: Folder,
|
||||
depth: number,
|
||||
path: Array<{ id: string; name: string }> = [],
|
||||
) => {
|
||||
const node = buildTree(folder, depth);
|
||||
const currentPath = [...path, { id: folder.id, name: folder.name }];
|
||||
const isRoot = folder.id === '0';
|
||||
|
||||
// Skip the root folder (id: '0')
|
||||
if (!isRoot) {
|
||||
result.push({
|
||||
depth: node.depth - 1,
|
||||
folder: node.folder,
|
||||
hasChildren: node.hasChildren,
|
||||
isExpanded: node.isExpanded,
|
||||
path: currentPath,
|
||||
});
|
||||
}
|
||||
|
||||
// For root folder, always traverse children
|
||||
const shouldTraverseChildren = isRoot
|
||||
? node.hasChildren
|
||||
: node.isExpanded && node.hasChildren;
|
||||
|
||||
if (shouldTraverseChildren) {
|
||||
const children = getFolderChildren(folder);
|
||||
// Recursively traverse each child - this supports infinite nesting
|
||||
children.forEach((child) => {
|
||||
traverse(child, depth + 1, currentPath);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
traverse(rootFolder, 0);
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rootFolderQuery.data, expandedNodes, loadedNodes, buildTree, getFolderChildren]);
|
||||
|
||||
const toggleNode = useCallback(
|
||||
(folderId: string, hasChildren: boolean, folder?: Folder) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
// Fetch children if not loaded and has children
|
||||
// Check both loadedNodes and folder.children to determine if we need to fetch
|
||||
const needsFetch =
|
||||
hasChildren &&
|
||||
!loadedNodes.has(folderId) &&
|
||||
!(folder?.children?.folders && folder.children.folders.length > 0);
|
||||
if (needsFetch) {
|
||||
fetchFolderChildren(folderId);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[fetchFolderChildren, loadedNodes],
|
||||
);
|
||||
|
||||
// Expand a node (doesn't collapse if already expanded)
|
||||
const expandNode = useCallback(
|
||||
(folderId: string, hasChildren: boolean, folder?: Folder) => {
|
||||
setExpandedNodes((prev) => {
|
||||
if (prev.has(folderId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Expand the node
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(folderId);
|
||||
|
||||
// Fetch children if not loaded and has children
|
||||
const needsFetch =
|
||||
hasChildren &&
|
||||
!loadedNodes.has(folderId) &&
|
||||
!(folder?.children?.folders && folder.children.folders.length > 0);
|
||||
if (needsFetch) {
|
||||
fetchFolderChildren(folderId);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[fetchFolderChildren, loadedNodes],
|
||||
);
|
||||
|
||||
// Handle node click - toggle expand/collapse and set current folder
|
||||
const handleNodeClick = useCallback(
|
||||
(
|
||||
folder: Folder,
|
||||
path: Array<{ id: string; name: string }>,
|
||||
isExpanded: boolean,
|
||||
isCurrentFolder: boolean,
|
||||
) => {
|
||||
// Only toggle close if the node is expanded AND it's the current folder
|
||||
if (isExpanded && isCurrentFolder) {
|
||||
toggleNode(folder.id, true, folder);
|
||||
} else if (!isExpanded) {
|
||||
// Node is not expanded - check if we should expand it
|
||||
const childrenLoaded = loadedNodes.has(folder.id);
|
||||
const hasChildrenFromFolder = (folder.children?.folders?.length ?? 0) > 0;
|
||||
|
||||
// Determine if we should expand:
|
||||
// - If children are loaded and empty, don't expand (we know it has no children)
|
||||
// - Otherwise, try to expand/fetch (either has children or we don't know yet)
|
||||
let shouldExpand = false;
|
||||
let mightHaveChildren = false;
|
||||
|
||||
if (childrenLoaded) {
|
||||
// Children are loaded - check if there are any
|
||||
const loadedChildren = loadedNodes.get(folder.id) || [];
|
||||
shouldExpand = loadedChildren.length > 0;
|
||||
mightHaveChildren = loadedChildren.length > 0;
|
||||
} else {
|
||||
// Children not loaded yet - assume it might have children and try to expand
|
||||
shouldExpand = true;
|
||||
mightHaveChildren = true;
|
||||
}
|
||||
|
||||
// Override with folder's children if available (from parent response)
|
||||
if (hasChildrenFromFolder) {
|
||||
shouldExpand = true;
|
||||
mightHaveChildren = true;
|
||||
}
|
||||
|
||||
if (shouldExpand) {
|
||||
expandNode(folder.id, mightHaveChildren, folder);
|
||||
}
|
||||
}
|
||||
|
||||
// Set current folder path (full path from root to clicked folder)
|
||||
// Skip the root folder (id: '0') from the path
|
||||
const pathWithoutRoot = path.filter((item) => item.id !== '0');
|
||||
setFolderPath(pathWithoutRoot);
|
||||
},
|
||||
[expandNode, loadedNodes, setFolderPath, toggleNode],
|
||||
);
|
||||
|
||||
const rowProps = useMemo(
|
||||
() => ({
|
||||
currentFolderId,
|
||||
data: flattenedNodes,
|
||||
handleNodeClick,
|
||||
toggleNode,
|
||||
}),
|
||||
[currentFolderId, flattenedNodes, handleNodeClick, toggleNode],
|
||||
);
|
||||
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: false,
|
||||
events: {
|
||||
initialized(osInstance) {
|
||||
const { viewport } = osInstance.elements();
|
||||
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
|
||||
},
|
||||
},
|
||||
options: {
|
||||
overflow: { x: 'hidden', y: 'scroll' },
|
||||
paddingAbsolute: true,
|
||||
scrollbars: {
|
||||
autoHide: 'leave',
|
||||
autoHideDelay: 500,
|
||||
pointers: ['mouse', 'pen', 'touch'],
|
||||
theme: 'feishin-os-scrollbar',
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container || !container.firstElementChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = container.firstElementChild as HTMLElement;
|
||||
|
||||
initialize({
|
||||
elements: { viewport },
|
||||
target: container,
|
||||
});
|
||||
|
||||
return () => osInstance()?.destroy();
|
||||
}, [initialize, osInstance]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<List
|
||||
rowComponent={RowComponent}
|
||||
rowCount={flattenedNodes.length}
|
||||
rowHeight={ITEM_HEIGHT}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RowComponent = ({
|
||||
currentFolderId,
|
||||
data,
|
||||
handleNodeClick,
|
||||
index,
|
||||
style,
|
||||
toggleNode,
|
||||
}: RowComponentProps<{
|
||||
currentFolderId: null | string;
|
||||
data: FlattenedNode[];
|
||||
handleNodeClick: (
|
||||
folder: Folder,
|
||||
path: Array<{ id: string; name: string }>,
|
||||
isExpanded: boolean,
|
||||
isCurrentFolder: boolean,
|
||||
) => void;
|
||||
toggleNode: (folderId: string, hasChildren: boolean, folder?: Folder) => void;
|
||||
}>) => {
|
||||
const item = data[index];
|
||||
const folderNameRef = useRef<HTMLSpanElement>(null);
|
||||
const folderIconRef = useRef<HTMLDivElement>(null);
|
||||
const expandIconRef = useRef<HTMLDivElement | null>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltipOffset, setTooltipOffset] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const calculateOffset = () => {
|
||||
if (rowRef.current && folderIconRef.current && expandIconRef.current) {
|
||||
const width = rowRef.current.offsetWidth;
|
||||
const paddingLeft = item.depth * INDENT_SIZE;
|
||||
const folderIconWidth = folderIconRef.current.offsetWidth;
|
||||
const expandIconWidth = expandIconRef.current.offsetWidth;
|
||||
const itemPadding = 8;
|
||||
setTooltipOffset(
|
||||
-width + paddingLeft + folderIconWidth + expandIconWidth + itemPadding,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
calculateOffset();
|
||||
|
||||
const handleResize = () => {
|
||||
calculateOffset();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [item]);
|
||||
|
||||
const { isDragging, ref: dragRef } = useDragDrop<HTMLDivElement>({
|
||||
drag: {
|
||||
getId: () => (item ? [item.folder.id] : []),
|
||||
getItem: () => (item ? [item.folder] : []),
|
||||
itemType: LibraryItem.FOLDER,
|
||||
operation: [DragOperation.ADD],
|
||||
target: DragTarget.FOLDER,
|
||||
},
|
||||
isEnabled: !!item,
|
||||
});
|
||||
|
||||
// Use dragRef for the element and also update rowRef for tooltip calculations
|
||||
useEffect(() => {
|
||||
if (dragRef && 'current' in dragRef && dragRef.current) {
|
||||
rowRef.current = dragRef.current;
|
||||
}
|
||||
}, [dragRef]);
|
||||
|
||||
if (!item) {
|
||||
return <div style={style} />;
|
||||
}
|
||||
|
||||
const isActive = currentFolderId === item.folder.id;
|
||||
const paddingLeft = item.depth * INDENT_SIZE;
|
||||
|
||||
const handleExpandClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleNode(item.folder.id, item.hasChildren, item.folder);
|
||||
};
|
||||
|
||||
const handleRowClick = () => {
|
||||
handleNodeClick(item.folder, item.path, item.isExpanded, isActive);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ContextMenuController.call({
|
||||
cmd: {
|
||||
items: [item.folder],
|
||||
type: LibraryItem.FOLDER,
|
||||
},
|
||||
event: e,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
classNames={{
|
||||
tooltip: styles.tooltip,
|
||||
}}
|
||||
label={item.folder.name}
|
||||
offset={tooltipOffset}
|
||||
openDelay={0}
|
||||
position="right"
|
||||
withArrow={false}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.row, {
|
||||
[styles.active]: isActive,
|
||||
[styles.dragging]: isDragging,
|
||||
})}
|
||||
onClick={handleRowClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
ref={dragRef}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.rowContent}>
|
||||
{item.hasChildren ? (
|
||||
<div className={styles.expandIconContainer} ref={expandIconRef}>
|
||||
<Icon
|
||||
className={clsx(styles.expandIcon, {
|
||||
[styles.expanded]: item.isExpanded,
|
||||
})}
|
||||
icon="arrowRightS"
|
||||
onClick={handleExpandClick}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.expandIconPlaceholder} ref={expandIconRef} />
|
||||
)}
|
||||
<div className={styles.folderIconContainer} ref={folderIconRef}>
|
||||
<Icon className={styles.folderIcon} icon="folder" size="md" />
|
||||
</div>
|
||||
<span className={styles.folderName} ref={folderNameRef}>
|
||||
{item.folder.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
|
||||
import { SongListSort } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export type FolderPathItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const useFolderListFilters = () => {
|
||||
const { sortBy } = useSortByFilter<SongListSort>(null, ItemListKey.SONG);
|
||||
|
||||
const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG);
|
||||
|
||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const folderPath = useMemo(() => {
|
||||
const path = parseJsonParam<FolderPathItem[]>(searchParams, FILTER_KEYS.FOLDER.FOLDER_PATH);
|
||||
return path || [];
|
||||
}, [searchParams]);
|
||||
|
||||
const setFolderPath = (path: FolderPathItem[]) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);
|
||||
return newParams;
|
||||
},
|
||||
{ replace: false },
|
||||
);
|
||||
};
|
||||
|
||||
// Navigate to a folder (adds to path)
|
||||
const navigateToFolder = (folderId: string, folderName: string) => {
|
||||
setFolderPath([...folderPath, { id: folderId, name: folderName }]);
|
||||
};
|
||||
|
||||
// Navigate back to a specific folder in the path (truncates path)
|
||||
const navigateToPathIndex = (index: number) => {
|
||||
setFolderPath(folderPath.slice(0, index + 1));
|
||||
};
|
||||
|
||||
// Get current folder ID (last item in path, or '0' for root)
|
||||
const currentFolderId = useMemo(() => {
|
||||
return folderPath.length > 0 ? folderPath[folderPath.length - 1].id : '0';
|
||||
}, [folderPath]);
|
||||
|
||||
const query = {
|
||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
currentFolderId,
|
||||
folderPath,
|
||||
navigateToFolder,
|
||||
navigateToPathIndex,
|
||||
query,
|
||||
setFolderPath,
|
||||
setSearchTerm,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
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';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const FolderListRoute = () => {
|
||||
const pageKey = ItemListKey.SONG;
|
||||
|
||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
id: undefined,
|
||||
itemCount,
|
||||
pageKey,
|
||||
setItemCount,
|
||||
};
|
||||
}, [itemCount, pageKey, setItemCount]);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<FolderListHeader />
|
||||
<ListWithSidebarContainer>
|
||||
<FolderListContent />
|
||||
</ListWithSidebarContainer>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderListRouteWithBoundary = () => {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<FolderListRoute />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderListRouteWithBoundary;
|
||||
@@ -29,7 +29,7 @@ import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
||||
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
||||
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
import { DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
import { ItemListKey, Play, PlayerQueueType } from '/@/shared/types/types';
|
||||
|
||||
@@ -248,6 +248,45 @@ const EmptyQueueDropZone = () => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DragTarget.FOLDER: {
|
||||
const items = args.source.item;
|
||||
|
||||
const { folders, songs } = (items || []).reduce<{
|
||||
folders: Folder[];
|
||||
songs: Song[];
|
||||
}>(
|
||||
(acc, item) => {
|
||||
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
|
||||
acc.songs.push(item as unknown as Song);
|
||||
} else if (
|
||||
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
|
||||
) {
|
||||
acc.folders.push(item as unknown as Folder);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ folders: [], songs: [] },
|
||||
);
|
||||
|
||||
const folderIds = folders.map((folder) => folder.id);
|
||||
|
||||
// Handle folders: fetch and add to queue
|
||||
if (folderIds.length > 0) {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
folderIds,
|
||||
LibraryItem.FOLDER,
|
||||
Play.NOW,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle songs: add directly to queue
|
||||
if (songs.length > 0) {
|
||||
playerContext.addToQueueByData(songs, Play.NOW);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case DragTarget.GENRE: {
|
||||
if (sourceServerId) {
|
||||
playerContext.addToQueueByFetch(
|
||||
|
||||
@@ -7,6 +7,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import {
|
||||
getAlbumArtistSongsById,
|
||||
getAlbumSongsById,
|
||||
getGenreSongsById,
|
||||
getPlaylistSongsById,
|
||||
getSongsByFolder,
|
||||
} from '/@/renderer/features/player/utils';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||
@@ -28,9 +35,6 @@ import {
|
||||
PlaylistSongListResponse,
|
||||
QueueSong,
|
||||
Song,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
|
||||
|
||||
@@ -911,88 +915,57 @@ export async function fetchSongsByItemType(
|
||||
|
||||
switch (args.itemType) {
|
||||
case LibraryItem.ALBUM: {
|
||||
const promises: Promise<SongListResponse>[] = [];
|
||||
|
||||
for (const id of args.id) {
|
||||
promises.push(
|
||||
queryClient.fetchQuery({
|
||||
...songsQueries.list({
|
||||
query: {
|
||||
albumIds: [id],
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...args.params,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
songs.push(...results.flatMap((r) => r.items));
|
||||
|
||||
const albumSongsResponse = await getAlbumSongsById({
|
||||
id: args.id,
|
||||
query: args.params,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
songs.push(...albumSongsResponse.items);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryItem.ALBUM_ARTIST: {
|
||||
const albumArtistSongsResponse = await getAlbumArtistSongsById({
|
||||
id: args.id,
|
||||
query: args.params,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
songs.push(...albumArtistSongsResponse.items);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
case LibraryItem.ARTIST: {
|
||||
const promises: Promise<SongListResponse>[] = [];
|
||||
|
||||
for (const id of args.id) {
|
||||
promises.push(
|
||||
queryClient.fetchQuery({
|
||||
...songsQueries.list({
|
||||
query: {
|
||||
albumArtistIds: [id],
|
||||
limit: -1,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...args.params,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
songs.push(...results.flatMap((r) => r.items));
|
||||
const artistSongsResponse = await getAlbumArtistSongsById({
|
||||
id: args.id,
|
||||
query: args.params,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
songs.push(...artistSongsResponse.items);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryItem.FOLDER: {
|
||||
const folderSongsResponse = await getSongsByFolder({
|
||||
id: args.id,
|
||||
query: args.params,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
songs.push(...folderSongsResponse.items);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryItem.GENRE: {
|
||||
const promises: Promise<SongListResponse>[] = [];
|
||||
|
||||
for (const id of args.id) {
|
||||
promises.push(
|
||||
queryClient.fetchQuery({
|
||||
...songsQueries.list({
|
||||
query: {
|
||||
genreIds: [id],
|
||||
limit: -1,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...args.params,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
songs.push(...results.flatMap((r) => r.items));
|
||||
const genreSongsResponse = await getGenreSongsById({
|
||||
id: args.id,
|
||||
query: args.params,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
songs.push(...genreSongsResponse.items);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1001,22 +974,16 @@ export async function fetchSongsByItemType(
|
||||
|
||||
for (const id of args.id) {
|
||||
promises.push(
|
||||
queryClient.fetchQuery({
|
||||
...playlistsQueries.songList({
|
||||
query: {
|
||||
id: id,
|
||||
...args.params,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
getPlaylistSongsById({
|
||||
id,
|
||||
query: args.params,
|
||||
queryClient,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
songs.push(...results.flatMap((r) => r.items));
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
|
||||
import { sortSongList } from '/@/shared/api/utils';
|
||||
import {
|
||||
PlaylistSongListQuery,
|
||||
PlaylistSongListQueryClientSide,
|
||||
ServerListItem,
|
||||
Song,
|
||||
SongDetailQuery,
|
||||
SongListQuery,
|
||||
SongListResponse,
|
||||
@@ -18,22 +19,22 @@ export const getPlaylistSongsById = async (args: {
|
||||
id: string;
|
||||
query?: Partial<PlaylistSongListQueryClientSide>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, query, queryClient, server } = args;
|
||||
const { id, query, queryClient, serverId } = args;
|
||||
|
||||
const queryFilter: PlaylistSongListQuery = {
|
||||
id,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id, id);
|
||||
const queryKey = queryKeys.playlists.songList(serverId, id);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
@@ -58,9 +59,9 @@ export const getAlbumSongsById = async (args: {
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, query, queryClient, server } = args;
|
||||
const { id, query, queryClient, serverId } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
albumIds: id,
|
||||
@@ -70,14 +71,14 @@ export const getAlbumSongsById = async (args: {
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
@@ -94,9 +95,9 @@ export const getGenreSongsById = async (args: {
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: null | ServerListItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, query, queryClient, server } = args;
|
||||
const { id, query, queryClient, serverId } = args;
|
||||
|
||||
const data: SongListResponse = {
|
||||
items: [],
|
||||
@@ -112,14 +113,14 @@ export const getGenreSongsById = async (args: {
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
@@ -142,9 +143,9 @@ export const getAlbumArtistSongsById = async (args: {
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, query, queryClient, server } = args;
|
||||
const { id, query, queryClient, serverId } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
albumArtistIds: id || [],
|
||||
@@ -154,14 +155,14 @@ export const getAlbumArtistSongsById = async (args: {
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
@@ -177,9 +178,9 @@ export const getArtistSongsById = async (args: {
|
||||
id: string[];
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, query, queryClient, server } = args;
|
||||
const { id, query, queryClient, serverId } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
artistIds: id,
|
||||
@@ -189,14 +190,14 @@ export const getArtistSongsById = async (args: {
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
@@ -211,9 +212,9 @@ export const getArtistSongsById = async (args: {
|
||||
export const getSongsByQuery = async (args: {
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { query, queryClient, server } = args;
|
||||
const { query, queryClient, serverId } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
sortBy: SongListSort.ALBUM,
|
||||
@@ -222,14 +223,14 @@ export const getSongsByQuery = async (args: {
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) => {
|
||||
return api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
@@ -242,23 +243,77 @@ export const getSongsByQuery = async (args: {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getSongsByFolder = async (args: {
|
||||
id: string[];
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, queryClient, serverId } = args;
|
||||
|
||||
const collectSongsFromFolder = async (folderId: string): Promise<Song[]> => {
|
||||
const folderSongs: Song[] = [];
|
||||
const folder = await queryClient.fetchQuery({
|
||||
...folderQueries.folder({
|
||||
query: {
|
||||
id: folderId,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (folder.children?.songs) {
|
||||
folderSongs.push(...folder.children.songs);
|
||||
}
|
||||
|
||||
if (folder.children?.folders) {
|
||||
for (const subFolder of folder.children.folders) {
|
||||
const subFolderSongs = await collectSongsFromFolder(subFolder.id);
|
||||
folderSongs.push(...subFolderSongs);
|
||||
}
|
||||
}
|
||||
|
||||
return folderSongs;
|
||||
};
|
||||
|
||||
const data: SongListResponse = {
|
||||
items: [],
|
||||
startIndex: 0,
|
||||
totalRecordCount: 0,
|
||||
};
|
||||
|
||||
// Process folders sequentially to maintain order
|
||||
for (const folderId of id) {
|
||||
const folderSongs = await collectSongsFromFolder(folderId);
|
||||
data.items.push(...folderSongs);
|
||||
data.totalRecordCount = (data.totalRecordCount || 0) + folderSongs.length;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSongById = async (args: {
|
||||
id: string;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
serverId: string;
|
||||
}): Promise<SongListResponse> => {
|
||||
const { id, queryClient, server } = args;
|
||||
const { id, queryClient, serverId } = args;
|
||||
|
||||
const queryFilter: SongDetailQuery = { id };
|
||||
|
||||
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.detail(serverId, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: async ({ signal }) =>
|
||||
api.controller.getSongDetail({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
|
||||
@@ -7,11 +7,17 @@ import styles from './add-to-playlist-context-modal.module.css';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { getGenreSongsById } from '/@/renderer/features/player/utils';
|
||||
import {
|
||||
getAlbumSongsById,
|
||||
getArtistSongsById,
|
||||
getGenreSongsById,
|
||||
getPlaylistSongsById,
|
||||
getSongsByFolder,
|
||||
} from '/@/renderer/features/player/utils';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useCurrentServerId } from '/@/renderer/store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
@@ -31,13 +37,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import {
|
||||
Playlist,
|
||||
PlaylistListSort,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
export const AddToPlaylistContextModal = ({
|
||||
id,
|
||||
@@ -45,14 +45,16 @@ export const AddToPlaylistContextModal = ({
|
||||
}: ContextModalProps<{
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
folderId?: string[];
|
||||
genreId?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
playlistId?: string[];
|
||||
songId?: string[];
|
||||
}>) => {
|
||||
const { t } = useTranslation();
|
||||
const { albumId, artistId, genreId, initialSelectedIds, playlistId, songId } = innerProps;
|
||||
const server = useCurrentServer();
|
||||
const { albumId, artistId, folderId, genreId, initialSelectedIds, playlistId, songId } =
|
||||
innerProps;
|
||||
const serverId = useCurrentServerId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);
|
||||
@@ -81,7 +83,7 @@ export const AddToPlaylistContextModal = ({
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -109,78 +111,35 @@ export const AddToPlaylistContextModal = ({
|
||||
|
||||
const getSongsByAlbum = useCallback(
|
||||
async (albumId: string) => {
|
||||
const query: SongListQuery = {
|
||||
albumIds: [albumId],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({
|
||||
apiClientProps: { serverId: server?.id || '', signal },
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey,
|
||||
return getAlbumSongsById({
|
||||
id: [albumId],
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return songsRes;
|
||||
},
|
||||
[server],
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const getSongsByArtist = useCallback(
|
||||
async (artistId: string) => {
|
||||
const query: SongListQuery = {
|
||||
artistIds: [artistId],
|
||||
sortBy: SongListSort.ARTIST,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({
|
||||
apiClientProps: { serverId: server?.id || '', signal },
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey,
|
||||
return getArtistSongsById({
|
||||
id: [artistId],
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return songsRes;
|
||||
},
|
||||
[server],
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const getSongsByPlaylist = useCallback(
|
||||
async (playlistId: string) => {
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { serverId: server?.id || '', signal },
|
||||
query: {
|
||||
id: playlistId,
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey,
|
||||
return getPlaylistSongsById({
|
||||
id: playlistId,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return songsRes;
|
||||
},
|
||||
[server],
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
@@ -211,12 +170,21 @@ export const AddToPlaylistContextModal = ({
|
||||
const songs = await getGenreSongsById({
|
||||
id: genreId,
|
||||
queryClient,
|
||||
server,
|
||||
serverId,
|
||||
});
|
||||
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
|
||||
if (folderId && folderId.length > 0) {
|
||||
const songs = await getSongsByFolder({
|
||||
id: folderId,
|
||||
queryClient,
|
||||
serverId,
|
||||
});
|
||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||
}
|
||||
|
||||
if (playlistId && playlistId.length > 0) {
|
||||
for (const id of playlistId) {
|
||||
const songs = await getSongsByPlaylist(id);
|
||||
@@ -234,7 +202,7 @@ export const AddToPlaylistContextModal = ({
|
||||
for (const playlist of values.newPlaylists) {
|
||||
try {
|
||||
const response = await api.controller.createPlaylist({
|
||||
apiClientProps: { serverId: server?.id || '' },
|
||||
apiClientProps: { serverId },
|
||||
body: {
|
||||
name: playlist,
|
||||
public: false,
|
||||
@@ -257,19 +225,13 @@ export const AddToPlaylistContextModal = ({
|
||||
const uniqueSongIds: string[] = [];
|
||||
|
||||
if (values.skipDuplicates) {
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
||||
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
|
||||
|
||||
const playlistSongsRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server)
|
||||
throw new Error(
|
||||
t('error.serverNotSelectedError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
);
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
serverId,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
@@ -291,13 +253,9 @@ export const AddToPlaylistContextModal = ({
|
||||
}
|
||||
|
||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||
if (!server) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
addToPlaylistMutation.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: server.id },
|
||||
apiClientProps: { serverId },
|
||||
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||
query: { id: playlistId },
|
||||
},
|
||||
|
||||
@@ -444,6 +444,35 @@ const SONG_LIST_FILTERS: Partial<
|
||||
],
|
||||
};
|
||||
|
||||
const FOLDER_LIST_FILTERS: Partial<
|
||||
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
|
||||
> = {
|
||||
[ServerType.JELLYFIN]: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ID,
|
||||
},
|
||||
...(SONG_LIST_FILTERS[ServerType.JELLYFIN] || []),
|
||||
],
|
||||
[ServerType.NAVIDROME]: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ID,
|
||||
},
|
||||
...(SONG_LIST_FILTERS[ServerType.NAVIDROME] || []),
|
||||
],
|
||||
[ServerType.SUBSONIC]: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ID,
|
||||
},
|
||||
...(SONG_LIST_FILTERS[ServerType.SUBSONIC] || []),
|
||||
],
|
||||
};
|
||||
|
||||
const PLAYLIST_SONG_LIST_FILTERS: Partial<
|
||||
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
|
||||
> = {
|
||||
@@ -715,6 +744,7 @@ const FILTERS: Partial<Record<LibraryItem, any>> = {
|
||||
[LibraryItem.ALBUM]: ALBUM_LIST_FILTERS,
|
||||
[LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS,
|
||||
[LibraryItem.ARTIST]: ARTIST_LIST_FILTERS,
|
||||
[LibraryItem.FOLDER]: FOLDER_LIST_FILTERS,
|
||||
[LibraryItem.GENRE]: GENRE_LIST_FILTERS,
|
||||
[LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS,
|
||||
[LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS,
|
||||
|
||||
@@ -77,9 +77,14 @@ enum PlaylistFilterKeys {
|
||||
CUSTOM = '_custom',
|
||||
}
|
||||
|
||||
enum FolderFilterKeys {
|
||||
FOLDER_PATH = 'folderPath',
|
||||
}
|
||||
|
||||
export const FILTER_KEYS = {
|
||||
ALBUM: AlbumFilterKeys,
|
||||
ARTIST: ArtistFilterKeys,
|
||||
FOLDER: FolderFilterKeys,
|
||||
PAGINATION: PaginationFilterKeys,
|
||||
PLAYLIST: PlaylistFilterKeys,
|
||||
SHARED: SharedFilterKeys,
|
||||
|
||||
@@ -92,6 +92,7 @@ const PlaylistRowButton = ({ item, name, onContextMenu, onPlay, to }: PlaylistRo
|
||||
const modalProps: {
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
folderId?: string[];
|
||||
genreId?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
playlistId?: string[];
|
||||
@@ -108,6 +109,9 @@ const PlaylistRowButton = ({ item, name, onContextMenu, onPlay, to }: PlaylistRo
|
||||
case LibraryItem.ARTIST:
|
||||
modalProps.artistId = sourceIds;
|
||||
break;
|
||||
case LibraryItem.FOLDER:
|
||||
modalProps.folderId = sourceIds;
|
||||
break;
|
||||
case LibraryItem.GENRE:
|
||||
modalProps.genreId = sourceIds;
|
||||
break;
|
||||
|
||||
@@ -47,6 +47,7 @@ export const Sidebar = () => {
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Favorites: t('page.sidebar.favorites', { 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' }),
|
||||
|
||||
Reference in New Issue
Block a user