mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
add folder browsing support (#315)
This commit is contained in:
@@ -450,6 +450,9 @@
|
||||
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
||||
"title": "$t(entity.genre_other)"
|
||||
},
|
||||
"folderList": {
|
||||
"title": "$t(entity.folder_other)"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
"goToPage": "go to page",
|
||||
@@ -689,8 +692,6 @@
|
||||
"gaplessAudio_description": "sets the gapless audio setting for mpv",
|
||||
"gaplessAudio_optionWeak": "weak (recommended)",
|
||||
"gaplessAudio": "gapless audio",
|
||||
"genreBehavior_description": "determines whether clicking on a genre opens by default in track or album list",
|
||||
"genreBehavior": "genre page default behavior",
|
||||
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
|
||||
"globalMediaHotkeys": "global media hotkeys",
|
||||
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
|
||||
|
||||
@@ -306,6 +306,24 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
getFolder(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolder`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'getFolder',
|
||||
server.type,
|
||||
)?.({
|
||||
...args,
|
||||
apiClientProps: { ...args.apiClientProps, server },
|
||||
query: mergeMusicFolderId(args.query, server),
|
||||
});
|
||||
},
|
||||
getGenreList(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -116,6 +116,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getFolder: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.folder,
|
||||
responses: {
|
||||
200: jfType._response.folderList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'musicgenres',
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import chunk from 'lodash/chunk';
|
||||
import filter from 'lodash/filter';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
||||
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
||||
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
|
||||
import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';
|
||||
import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
Folder,
|
||||
genreListSortMap,
|
||||
InternalControllerEndpoint,
|
||||
LibraryItem,
|
||||
Played,
|
||||
playlistListSortMap,
|
||||
ServerType,
|
||||
Song,
|
||||
SongListSort,
|
||||
songListSortMap,
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
@@ -386,6 +392,213 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
||||
},
|
||||
getFolder: async ({ apiClientProps, query }) => {
|
||||
const userId = apiClientProps.server?.userId;
|
||||
|
||||
if (!userId) throw new Error('No userId found');
|
||||
|
||||
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
||||
const isRootFolderId = query.id === '0';
|
||||
|
||||
if (isRootFolderId) {
|
||||
if (query.musicFolderId) {
|
||||
// If music folder is provided, directly get the folder
|
||||
const musicFolderRes = await jfApiClient(apiClientProps).getFolder({
|
||||
params: {
|
||||
userId,
|
||||
},
|
||||
query: {
|
||||
ParentId: getLibraryId(query.musicFolderId)!,
|
||||
},
|
||||
});
|
||||
|
||||
if (musicFolderRes.status !== 200) {
|
||||
throw new Error('Failed to get music folder list');
|
||||
}
|
||||
|
||||
let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');
|
||||
|
||||
if (query.searchTerm) {
|
||||
items = filter(items, (item) => {
|
||||
return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
const folders = items
|
||||
.filter((item) => item.Type !== 'Audio')
|
||||
.map((item) => jfNormalize.folder(item, apiClientProps.server));
|
||||
|
||||
const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.FOLDER,
|
||||
_serverId: apiClientProps.server?.id || 'unknown',
|
||||
_serverType: ServerType.JELLYFIN,
|
||||
children: {
|
||||
folders: sortedFolders,
|
||||
songs: [],
|
||||
},
|
||||
id: query.id,
|
||||
name: '~',
|
||||
parentId: undefined,
|
||||
};
|
||||
} else {
|
||||
// Use the root music folder list if no music folder id is provided
|
||||
const musicFolderRes = await jfApiClient(apiClientProps).getMusicFolderList({
|
||||
params: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (musicFolderRes.status !== 200) {
|
||||
throw new Error('Failed to get music folder list');
|
||||
}
|
||||
|
||||
let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');
|
||||
|
||||
if (query.searchTerm) {
|
||||
items = filter(items, (item) => {
|
||||
return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
const folders = items
|
||||
.filter((item) => item.Type !== 'Audio')
|
||||
.map((item) =>
|
||||
jfNormalize.folder(
|
||||
item as unknown as z.infer<typeof jfType._response.folder>,
|
||||
apiClientProps.server,
|
||||
),
|
||||
);
|
||||
|
||||
const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.FOLDER,
|
||||
_serverId: apiClientProps.server?.id || 'unknown',
|
||||
_serverType: ServerType.JELLYFIN,
|
||||
children: {
|
||||
folders: sortedFolders,
|
||||
songs: [],
|
||||
},
|
||||
id: query.id,
|
||||
name: '~',
|
||||
parentId: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const folderDetailRes = await jfApiClient(apiClientProps).getFolder({
|
||||
params: {
|
||||
userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
ParentId: query.id,
|
||||
SortBy: query.sortBy
|
||||
? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'
|
||||
: 'SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder || SortOrder.ASC],
|
||||
},
|
||||
});
|
||||
|
||||
if (folderDetailRes.status !== 200) {
|
||||
throw new Error('Failed to get folder');
|
||||
}
|
||||
|
||||
// Get parent folder info - we'll use the first child's ParentId to infer the folder's parentId
|
||||
// The folder name will be inferred from the query.id or we can try to get it from a parent query
|
||||
let parentId: string | undefined;
|
||||
let folderName = 'Unknown folder';
|
||||
|
||||
if (folderDetailRes.body.Items?.length > 0) {
|
||||
const firstItem = folderDetailRes.body.Items[0];
|
||||
parentId = firstItem.ParentId;
|
||||
|
||||
// Try to get the folder name by querying its parent's children
|
||||
if (parentId) {
|
||||
const parentFolderRes = await jfApiClient(apiClientProps).getFolder({
|
||||
params: {
|
||||
userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
ParentId: parentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (parentFolderRes.status === 200) {
|
||||
const parentFolderItem = parentFolderRes.body.Items?.find(
|
||||
(item) => item.Id === query.id,
|
||||
);
|
||||
if (parentFolderItem) {
|
||||
folderName = parentFolderItem.Name || 'Unknown folder';
|
||||
parentId = parentFolderItem.ParentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = folderDetailRes.body.Items || [];
|
||||
|
||||
let filteredFolders = items
|
||||
.filter((item) => item.Type !== 'Audio')
|
||||
.map((item) => jfNormalize.folder(item, apiClientProps.server));
|
||||
let filteredSongs = items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.Type === 'Audio' &&
|
||||
(item as unknown as z.infer<typeof jfType._response.song>).MediaSources,
|
||||
)
|
||||
.map((item) =>
|
||||
jfNormalize.song(
|
||||
item as unknown as z.infer<typeof jfType._response.song>,
|
||||
apiClientProps.server,
|
||||
),
|
||||
);
|
||||
|
||||
if (query.searchTerm) {
|
||||
const searchTermLower = query.searchTerm.toLowerCase();
|
||||
filteredFolders = filter(filteredFolders, (f) =>
|
||||
f.name.toLowerCase().includes(searchTermLower),
|
||||
);
|
||||
filteredSongs = filter(filteredSongs, (s) => {
|
||||
const name = s.name?.toLowerCase() || '';
|
||||
const album = s.album?.toLowerCase() || '';
|
||||
const artist = s.artistName?.toLowerCase() || '';
|
||||
return (
|
||||
name.includes(searchTermLower) ||
|
||||
album.includes(searchTermLower) ||
|
||||
artist.includes(searchTermLower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);
|
||||
|
||||
if (filteredSongs.length > 0) {
|
||||
filteredSongs = sortSongList(
|
||||
filteredSongs,
|
||||
query.sortBy || SongListSort.NAME,
|
||||
query.sortOrder || SortOrder.ASC,
|
||||
);
|
||||
}
|
||||
|
||||
const folder: Folder = {
|
||||
_itemType: LibraryItem.FOLDER,
|
||||
_serverId: apiClientProps.server?.id || 'unknown',
|
||||
_serverType: ServerType.JELLYFIN,
|
||||
children: {
|
||||
folders: filteredFolders,
|
||||
songs: filteredSongs,
|
||||
},
|
||||
id: query.id,
|
||||
name: folderName,
|
||||
parentId,
|
||||
};
|
||||
|
||||
return folder;
|
||||
},
|
||||
getGenreList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
||||
getFolder: SubsonicController.getFolder,
|
||||
getGenreList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AlbumDetailQuery,
|
||||
AlbumListQuery,
|
||||
ArtistListQuery,
|
||||
FolderQuery,
|
||||
GenreListQuery,
|
||||
LyricSearchQuery,
|
||||
LyricsQuery,
|
||||
@@ -224,6 +225,15 @@ export const queryKeys: Record<
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||
},
|
||||
folders: {
|
||||
folder: (serverId: string, query?: FolderQuery) => {
|
||||
if (query) {
|
||||
return [serverId, 'folders', 'folder', query] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'folders', 'folder'] as const;
|
||||
},
|
||||
},
|
||||
genres: {
|
||||
count: (serverId: string, query?: GenreListQuery) => {
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
|
||||
@@ -100,6 +100,22 @@ export const contract = c.router({
|
||||
200: ssType._response.getGenres,
|
||||
},
|
||||
},
|
||||
getIndexes: {
|
||||
method: 'GET',
|
||||
path: 'getIndexes.view',
|
||||
query: ssType._parameters.getIndexes,
|
||||
responses: {
|
||||
200: ssType._response.getIndexes,
|
||||
},
|
||||
},
|
||||
getMusicDirectory: {
|
||||
method: 'GET',
|
||||
path: 'getMusicDirectory.view',
|
||||
query: ssType._parameters.getMusicDirectory,
|
||||
responses: {
|
||||
200: ssType._response.getMusicDirectory,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
InternalControllerEndpoint,
|
||||
LibraryItem,
|
||||
PlaylistListSort,
|
||||
ServerType,
|
||||
Song,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeatures } from '/@/shared/types/features-types';
|
||||
@@ -650,6 +652,106 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
'&c=Feishin'
|
||||
);
|
||||
},
|
||||
getFolder: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
||||
|
||||
const isRootFolderId = /^\d+$/.test(query.id);
|
||||
|
||||
if (isRootFolderId) {
|
||||
const res = await ssApiClient(apiClientProps).getIndexes({
|
||||
query: {
|
||||
musicFolderId: getLibraryId(query.musicFolderId),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
|
||||
let items =
|
||||
res.body.indexes?.index?.flatMap((idx) =>
|
||||
idx.artist.map((artist) => ({
|
||||
artist: artist.name,
|
||||
id: artist.id.toString(),
|
||||
isDir: true,
|
||||
title: artist.name,
|
||||
})),
|
||||
) || [];
|
||||
|
||||
if (query.searchTerm) {
|
||||
items = filter(items, (item) => {
|
||||
return item.title.toLowerCase().includes(query.searchTerm!.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
let folders = items.map((item) => ssNormalize.folder(item, apiClientProps.server));
|
||||
|
||||
folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.FOLDER,
|
||||
_serverId: apiClientProps.server?.id || 'unknown',
|
||||
_serverType: ServerType.SUBSONIC,
|
||||
children: {
|
||||
folders,
|
||||
songs: [],
|
||||
},
|
||||
id: query.id,
|
||||
name: '~',
|
||||
parentId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const directoryRes = await ssApiClient(apiClientProps).getMusicDirectory({
|
||||
query: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (directoryRes.status !== 200) {
|
||||
throw new Error('Failed to get folder');
|
||||
}
|
||||
|
||||
const folder = ssNormalize.folder(directoryRes.body.directory, apiClientProps.server);
|
||||
|
||||
let filteredFolders = folder.children?.folders || [];
|
||||
let filteredSongs = folder.children?.songs || [];
|
||||
|
||||
if (query.searchTerm) {
|
||||
const searchTermLower = query.searchTerm.toLowerCase();
|
||||
filteredFolders = filter(filteredFolders, (f) =>
|
||||
f.name.toLowerCase().includes(searchTermLower),
|
||||
);
|
||||
filteredSongs = filter(filteredSongs, (s) => {
|
||||
const name = s.name?.toLowerCase() || '';
|
||||
const album = s.album?.toLowerCase() || '';
|
||||
const artist = s.artistName?.toLowerCase() || '';
|
||||
return (
|
||||
name.includes(searchTermLower) ||
|
||||
album.includes(searchTermLower) ||
|
||||
artist.includes(searchTermLower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);
|
||||
|
||||
if (filteredSongs.length > 0) {
|
||||
filteredSongs = sortSongList(
|
||||
filteredSongs,
|
||||
query.sortBy || SongListSort.NAME,
|
||||
query.sortOrder || SortOrder.ASC,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...folder,
|
||||
children: {
|
||||
folders: filteredFolders,
|
||||
songs: filteredSongs,
|
||||
},
|
||||
};
|
||||
},
|
||||
getGenreList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ItemListStateActions,
|
||||
ItemListStateItemWithRequiredProperties,
|
||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/shared/types/domain-types';
|
||||
import { Album, AlbumArtist, Artist, Folder, Playlist, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
/**
|
||||
* Type guard to assert that an item has the required properties for dragging
|
||||
@@ -28,13 +28,13 @@ const hasRequiredDragProperties = (
|
||||
* Otherwise, select and drag only the current item.
|
||||
* If internalState is not provided, returns the single item wrapped in an array.
|
||||
*
|
||||
* @param data - The item data to drag (Album, AlbumArtist, Artist, Playlist, or Song)
|
||||
* @param data - The item data to drag (Album, AlbumArtist, Artist, Folder, Playlist, or Song)
|
||||
* @param internalState - The item list state actions (optional)
|
||||
* @param updateSelection - Whether to update the selection state (default: true)
|
||||
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
|
||||
*/
|
||||
export const getDraggedItems = (
|
||||
data: Album | AlbumArtist | Artist | Playlist | Song | undefined,
|
||||
data: Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined,
|
||||
internalState?: ItemListStateActions,
|
||||
updateSelection: boolean = true,
|
||||
): ItemListStateItemWithRequiredProperties[] => {
|
||||
|
||||
@@ -299,10 +299,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the item's _itemType if available, otherwise fall back to the prop itemType
|
||||
// This allows mixed lists (e.g., folders + songs) to show the correct context menu
|
||||
const actualItemType =
|
||||
(item as any)?._itemType || itemTypeMapping[itemType] || itemType;
|
||||
|
||||
// If no internalState, call ContextMenuController directly
|
||||
if (!internalState) {
|
||||
return ContextMenuController.call({
|
||||
cmd: { items: [item] as any[], type: itemType as any },
|
||||
cmd: { items: [item] as any[], type: actualItemType as any },
|
||||
event,
|
||||
});
|
||||
}
|
||||
@@ -315,7 +320,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
if (internalState.getSelected().length === 0) {
|
||||
internalState.setSelected([item]);
|
||||
return ContextMenuController.call({
|
||||
cmd: { items: [item] as any[], type: itemType as any },
|
||||
cmd: { items: [item] as any[], type: actualItemType as any },
|
||||
event,
|
||||
});
|
||||
}
|
||||
@@ -323,15 +328,21 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
else if (!internalState.isSelected(rowId)) {
|
||||
internalState.setSelected([item]);
|
||||
return ContextMenuController.call({
|
||||
cmd: { items: [item] as any[], type: itemType as any },
|
||||
cmd: { items: [item] as any[], type: actualItemType as any },
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedItems = internalState.getSelected();
|
||||
|
||||
// For multiple selected items, use the itemType prop (assumes all selected items are of the same type)
|
||||
const selectedItemType =
|
||||
selectedItems.length > 0 && (selectedItems[0] as any)?._itemType
|
||||
? (selectedItems[0] as any)._itemType
|
||||
: actualItemType;
|
||||
|
||||
return ContextMenuController.call({
|
||||
cmd: { items: selectedItems as any[], type: itemType as any },
|
||||
cmd: { items: selectedItems as any[], type: selectedItemType as any },
|
||||
event,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -54,3 +54,8 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: black;
|
||||
fill: rgb(255 215 100);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||
import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const ImageColumn = (props: ItemTableListInnerColumn) => {
|
||||
@@ -98,6 +99,14 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
|
||||
);
|
||||
}
|
||||
|
||||
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
|
||||
return (
|
||||
<TableColumnContainer {...props}>
|
||||
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
|
||||
</TableColumnContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableColumnContainer {...props}>
|
||||
<Skeleton containerClassName={styles.skeleton} />
|
||||
|
||||
@@ -21,6 +21,7 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { itemType } = props;
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.FOLDER:
|
||||
case LibraryItem.PLAYLIST_SONG:
|
||||
case LibraryItem.QUEUE_SONG:
|
||||
case LibraryItem.SONG:
|
||||
|
||||
@@ -17,6 +17,7 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { itemType } = props;
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.FOLDER:
|
||||
case LibraryItem.PLAYLIST_SONG:
|
||||
case LibraryItem.QUEUE_SONG:
|
||||
case LibraryItem.SONG:
|
||||
|
||||
+5
@@ -32,3 +32,8 @@
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: black;
|
||||
fill: rgb(255 215 100);
|
||||
}
|
||||
|
||||
+41
-1
@@ -12,9 +12,10 @@ import {
|
||||
TableColumnContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
|
||||
import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
|
||||
|
||||
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
|
||||
@@ -166,6 +167,44 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
);
|
||||
}
|
||||
|
||||
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
|
||||
const rowHeight = props.getRowHeight(props.rowIndex, props);
|
||||
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
|
||||
|
||||
const item = props.data[props.rowIndex] as any;
|
||||
const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};
|
||||
|
||||
const titleLinkProps = path
|
||||
? {
|
||||
component: Link,
|
||||
isLink: true,
|
||||
state: { item },
|
||||
to: path,
|
||||
}
|
||||
: {};
|
||||
|
||||
const title = (props.data[props.rowIndex] as unknown as Folder)?.name;
|
||||
|
||||
return (
|
||||
<TableColumnContainer
|
||||
className={styles.titleCombined}
|
||||
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
|
||||
{...props}
|
||||
>
|
||||
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
|
||||
<Text
|
||||
className={styles.title}
|
||||
isNoSelect
|
||||
size="md"
|
||||
{...titleLinkProps}
|
||||
style={textStyles}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</TableColumnContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (row === null) {
|
||||
return <ColumnNullFallback {...props} />;
|
||||
}
|
||||
@@ -177,6 +216,7 @@ export const TitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { itemType } = props;
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.FOLDER:
|
||||
case LibraryItem.PLAYLIST_SONG:
|
||||
case LibraryItem.QUEUE_SONG:
|
||||
case LibraryItem.SONG:
|
||||
|
||||
@@ -53,7 +53,7 @@ import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
|
||||
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 {
|
||||
dndUtils,
|
||||
DragData,
|
||||
@@ -80,6 +80,7 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
|
||||
const item = isDataRow ? props.data[props.rowIndex] : null;
|
||||
const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item;
|
||||
const itemType = (item as unknown as { _itemType?: LibraryItem })?._itemType || props.itemType;
|
||||
|
||||
// Check if this row should render a group header (must be before conditional returns)
|
||||
// Group headers need to be rendered consistently across all grids (pinned left, main, pinned right)
|
||||
@@ -239,6 +240,48 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
);
|
||||
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) {
|
||||
props.playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
folderIds,
|
||||
LibraryItem.FOLDER,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
}
|
||||
|
||||
// Handle songs: add directly to queue
|
||||
if (songs.length > 0) {
|
||||
props.playerContext.addToQueueByData(songs, {
|
||||
edge: args.edge,
|
||||
uniqueId: droppedOnUniqueId,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case DragTarget.GENRE: {
|
||||
props.playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
@@ -366,65 +409,106 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType !== LibraryItem.FOLDER) {
|
||||
switch (type) {
|
||||
case TableColumn.ACTIONS:
|
||||
case TableColumn.SKIP:
|
||||
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ALBUM:
|
||||
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ALBUM_ARTIST:
|
||||
return (
|
||||
<AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.ALBUM_COUNT:
|
||||
case TableColumn.PLAY_COUNT:
|
||||
case TableColumn.SONG_COUNT:
|
||||
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ARTIST:
|
||||
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.BIOGRAPHY:
|
||||
case TableColumn.COMMENT:
|
||||
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.BIT_RATE:
|
||||
case TableColumn.BPM:
|
||||
case TableColumn.CHANNELS:
|
||||
case TableColumn.DISC_NUMBER:
|
||||
case TableColumn.TRACK_NUMBER:
|
||||
case TableColumn.YEAR:
|
||||
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.DATE_ADDED:
|
||||
case TableColumn.RELEASE_DATE:
|
||||
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.DURATION:
|
||||
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.GENRE:
|
||||
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.GENRE_BADGE:
|
||||
return (
|
||||
<GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.IMAGE:
|
||||
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.LAST_PLAYED:
|
||||
return (
|
||||
<RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.PATH:
|
||||
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ROW_INDEX:
|
||||
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.SIZE:
|
||||
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.TITLE:
|
||||
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.TITLE_COMBINED:
|
||||
return (
|
||||
<TitleCombinedColumn
|
||||
{...props}
|
||||
{...dragProps}
|
||||
controls={controls}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
|
||||
case TableColumn.USER_FAVORITE:
|
||||
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.USER_RATING:
|
||||
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
default:
|
||||
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case TableColumn.ACTIONS:
|
||||
case TableColumn.SKIP:
|
||||
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ALBUM:
|
||||
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ALBUM_ARTIST:
|
||||
return <AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ALBUM_COUNT:
|
||||
case TableColumn.PLAY_COUNT:
|
||||
case TableColumn.SONG_COUNT:
|
||||
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ARTIST:
|
||||
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.BIOGRAPHY:
|
||||
case TableColumn.COMMENT:
|
||||
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.BIT_RATE:
|
||||
case TableColumn.BPM:
|
||||
case TableColumn.CHANNELS:
|
||||
case TableColumn.DISC_NUMBER:
|
||||
case TableColumn.TRACK_NUMBER:
|
||||
case TableColumn.YEAR:
|
||||
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.DATE_ADDED:
|
||||
case TableColumn.RELEASE_DATE:
|
||||
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.DURATION:
|
||||
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.GENRE:
|
||||
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.GENRE_BADGE:
|
||||
return <GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.IMAGE:
|
||||
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.LAST_PLAYED:
|
||||
return <RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.PATH:
|
||||
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ROW_INDEX:
|
||||
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.SIZE:
|
||||
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.TITLE:
|
||||
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
@@ -433,14 +517,8 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
<TitleCombinedColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.USER_FAVORITE:
|
||||
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.USER_RATING:
|
||||
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
default:
|
||||
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
return <ColumnNullFallback {...props} {...dragProps} controls={controls} type={type} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
Folder,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
Song,
|
||||
@@ -75,7 +76,7 @@ export interface ItemListHandle {
|
||||
scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
|
||||
}
|
||||
|
||||
export type ItemListItem = Album | AlbumArtist | Artist | Playlist | Song | undefined;
|
||||
export type ItemListItem = Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined;
|
||||
|
||||
export interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> {
|
||||
autoFitColumns?: boolean;
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -7,13 +7,14 @@ interface UseContainerQueryProps {
|
||||
md?: number;
|
||||
sm?: number;
|
||||
xl?: number;
|
||||
xs?: number;
|
||||
}
|
||||
|
||||
export const useContainerQuery = (props?: UseContainerQueryProps) => {
|
||||
const { '2xl': xxl, '3xl': xxxl, lg, md, sm, xl } = props || {};
|
||||
const { '2xl': xxl, '3xl': xxxl, lg, md, sm, xl, xs } = props || {};
|
||||
const { height, ref, width } = useElementSize();
|
||||
|
||||
const isXs = width >= 0;
|
||||
const isXs = width >= (xs || 360);
|
||||
const isSm = width >= (sm || 600);
|
||||
const isMd = width >= (md || 768);
|
||||
const isLg = width >= (lg || 1200);
|
||||
|
||||
@@ -68,6 +68,10 @@ const GenreDetailRoute = lazy(
|
||||
() => import('/@/renderer/features/genres/routes/genre-detail-route'),
|
||||
);
|
||||
|
||||
const FolderListRoute = lazy(
|
||||
() => import('/@/renderer/features/folders/routes/folder-list-route'),
|
||||
);
|
||||
|
||||
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
|
||||
|
||||
const FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route'));
|
||||
@@ -142,6 +146,10 @@ export const AppRouter = () => {
|
||||
element={<SongListRoute />}
|
||||
path={AppRoute.LIBRARY_SONGS}
|
||||
/>
|
||||
<Route
|
||||
element={<FolderListRoute />}
|
||||
path={AppRoute.LIBRARY_FOLDERS}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistListRoute />}
|
||||
path={AppRoute.PLAYLISTS}
|
||||
|
||||
@@ -565,6 +565,12 @@ export const sidebarItems: SidebarItemType[] = [
|
||||
label: i18n.t('page.sidebar.genres'),
|
||||
route: AppRoute.LIBRARY_GENRES,
|
||||
},
|
||||
{
|
||||
disabled: false,
|
||||
id: 'Folders',
|
||||
label: i18n.t('page.sidebar.folders'),
|
||||
route: AppRoute.LIBRARY_FOLDERS,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
id: 'Playlists',
|
||||
@@ -1333,10 +1339,19 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
||||
return {};
|
||||
}
|
||||
|
||||
if (version <= 12) {
|
||||
state.general.sidebarItems.push({
|
||||
disabled: false,
|
||||
id: 'Folders',
|
||||
label: i18n.t('page.sidebar.folders'),
|
||||
route: AppRoute.LIBRARY_FOLDERS,
|
||||
});
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 12,
|
||||
version: 13,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './rgb-to-rgba';
|
||||
export * from './sentence-case';
|
||||
export * from './set-local-storage-setttings';
|
||||
export * from './title-case';
|
||||
export * from './truncate-middle';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const truncateMiddle = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ellipsis = '…';
|
||||
const halfLength = Math.floor((maxLength - ellipsis.length) / 2);
|
||||
const start = text.substring(0, halfLength);
|
||||
const end = text.substring(text.length - halfLength);
|
||||
|
||||
return `${start}${ellipsis}${end}`;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Folder,
|
||||
Genre,
|
||||
LibraryItem,
|
||||
MusicFolder,
|
||||
@@ -496,17 +497,20 @@ const normalizeGenre = (
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeFolder = (item: any) => {
|
||||
// return {
|
||||
// created: item.DateCreated,
|
||||
// id: item.Id,
|
||||
// image: getCoverArtUrl(item, 150),
|
||||
// isDir: true,
|
||||
// title: item.Name,
|
||||
// type: Item.Folder,
|
||||
// uniqueId: nanoid(),
|
||||
// };
|
||||
// };
|
||||
const normalizeFolder = (
|
||||
item: z.infer<typeof jfType._response.folder>,
|
||||
server: null | ServerListItem,
|
||||
): Folder => {
|
||||
return {
|
||||
_itemType: LibraryItem.FOLDER,
|
||||
_serverId: server?.id || 'unknown',
|
||||
_serverType: ServerType.JELLYFIN,
|
||||
children: undefined,
|
||||
id: item.Id,
|
||||
name: item.Name || 'Unknown folder',
|
||||
parentId: item.ParentId,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeScanStatus = () => {
|
||||
// return {
|
||||
@@ -518,6 +522,7 @@ const normalizeGenre = (
|
||||
export const jfNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
folder: normalizeFolder,
|
||||
genre: normalizeGenre,
|
||||
musicFolder: normalizeMusicFolder,
|
||||
playlist: normalizePlaylist,
|
||||
|
||||
@@ -487,6 +487,7 @@ const song = z.object({
|
||||
MediaType: z.string(),
|
||||
Name: z.string(),
|
||||
NormalizationGain: z.number().optional(),
|
||||
ParentId: z.string().optional(),
|
||||
ParentIndexNumber: z.number(),
|
||||
People: participant.array().optional(),
|
||||
PlaylistItemId: z.string().optional(),
|
||||
@@ -495,7 +496,7 @@ const song = z.object({
|
||||
ProviderIds: providerIds.optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
SortName: z.string(),
|
||||
SortName: z.string().optional(),
|
||||
Tags: z.string().array().optional(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
@@ -772,6 +773,34 @@ const filters = z.object({
|
||||
Years: z.number().array().optional(),
|
||||
});
|
||||
|
||||
const folder = z.object({
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
CollectionType: z.string(),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
IsFolder: z.boolean(),
|
||||
LocationType: z.string(),
|
||||
MediaType: z.string(),
|
||||
Name: z.string(),
|
||||
ParentId: z.string().optional(),
|
||||
ServerId: z.string(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
const folderList = pagination.extend({
|
||||
Items: z.array(folder),
|
||||
});
|
||||
|
||||
const folderParameters = z.object({
|
||||
Fields: z.string().optional(),
|
||||
ParentId: z.string().optional(),
|
||||
SortBy: z.string().optional(),
|
||||
SortOrder: z.enum(sortOrderValues).optional(),
|
||||
});
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
albumArtistList: albumArtistListSort,
|
||||
@@ -794,6 +823,7 @@ export const jfType = {
|
||||
deletePlaylist: deletePlaylistParameters,
|
||||
favorite: favoriteParameters,
|
||||
filterList: filterListParameters,
|
||||
folder: folderParameters,
|
||||
genreList: genreListParameters,
|
||||
musicFolderList: musicFolderListParameters,
|
||||
playlistDetail: playlistDetailParameters,
|
||||
@@ -819,6 +849,8 @@ export const jfType = {
|
||||
error,
|
||||
favorite,
|
||||
filters,
|
||||
folder,
|
||||
folderList,
|
||||
genre,
|
||||
genreList,
|
||||
lyrics,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
ExplicitStatus,
|
||||
Folder,
|
||||
Genre,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
@@ -342,9 +343,48 @@ const normalizeGenre = (
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeFolder = (
|
||||
item: z.infer<typeof ssType._response.directory>,
|
||||
server?: null | ServerListItemWithCredential,
|
||||
): Folder => {
|
||||
const results = item.child?.reduce(
|
||||
(acc: { folders: Folder[]; songs: Song[] }, item) => {
|
||||
const isDirectory = item.isDir === true;
|
||||
|
||||
if (isDirectory) {
|
||||
const folder = normalizeFolder(item, server);
|
||||
acc.folders.push(folder);
|
||||
} else {
|
||||
const song = normalizeSong(item, server);
|
||||
acc.songs.push(song);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
folders: [],
|
||||
songs: [],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.FOLDER,
|
||||
_serverId: server?.id || 'unknown',
|
||||
_serverType: ServerType.SUBSONIC,
|
||||
children: {
|
||||
folders: results?.folders || [],
|
||||
songs: results?.songs || [],
|
||||
},
|
||||
id: item.id.toString(),
|
||||
name: item.title,
|
||||
parentId: item.parent,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
folder: normalizeFolder,
|
||||
genre: normalizeGenre,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
|
||||
@@ -548,6 +548,50 @@ const albumInfo = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const getMusicDirectoryParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const directory = z.object({
|
||||
artist: z.string().optional(),
|
||||
child: z.array(song).optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id,
|
||||
isDir: z.boolean(),
|
||||
parent: z.string().optional(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const getMusicDirectory = z.object({
|
||||
directory,
|
||||
});
|
||||
|
||||
const getIndexes = z.object({
|
||||
indexes: z.object({
|
||||
child: z.array(song),
|
||||
index: z
|
||||
.object({
|
||||
artist: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.array(),
|
||||
})
|
||||
.array(),
|
||||
shortcut: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.array(),
|
||||
}),
|
||||
});
|
||||
|
||||
const getIndexesParameters = z.object({
|
||||
musicFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumInfo: albumInfoParameters,
|
||||
@@ -563,6 +607,8 @@ export const ssType = {
|
||||
getArtists: getArtistsParameters,
|
||||
getGenre: getGenresParameters,
|
||||
getGenres: getGenresParameters,
|
||||
getIndexes: getIndexesParameters,
|
||||
getMusicDirectory: getMusicDirectoryParameters,
|
||||
getPlaylist: getPlaylistParameters,
|
||||
getPlaylists: getPlaylistsParameters,
|
||||
getSong: getSongParameters,
|
||||
@@ -591,12 +637,15 @@ export const ssType = {
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
directory,
|
||||
genre,
|
||||
getAlbum,
|
||||
getAlbumList2,
|
||||
getArtist,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getIndexes,
|
||||
getMusicDirectory,
|
||||
getPlaylist,
|
||||
getPlaylists,
|
||||
getSong,
|
||||
|
||||
@@ -245,6 +245,11 @@ export const sortSongsByFetchedOrder = (
|
||||
fetchedIds: string[],
|
||||
itemType: LibraryItem,
|
||||
): Song[] => {
|
||||
// For folders, songs are already in the correct order
|
||||
if (itemType === LibraryItem.FOLDER) {
|
||||
return songs;
|
||||
}
|
||||
|
||||
// Group songs by the fetched ID they belong to
|
||||
const songsByFetchedId = new Map<string, Song[]>();
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
Breadcrumbs as MantineBreadcrumbs,
|
||||
BreadcrumbsProps as MantineBreadcrumbsProps,
|
||||
} from '@mantine/core';
|
||||
|
||||
interface BreadcrumbProps extends MantineBreadcrumbsProps {}
|
||||
|
||||
export const Breadcrumb = ({ children, ...props }: BreadcrumbProps) => {
|
||||
return <MantineBreadcrumbs {...props}>{children}</MantineBreadcrumbs>;
|
||||
};
|
||||
@@ -67,6 +67,7 @@ export const ContextMenuPreview = memo(({ items, itemType }: ContextMenuPreviewP
|
||||
<Icon icon="playlist" size="md" />
|
||||
)}
|
||||
{itemType === LibraryItem.GENRE && <Icon icon="genre" size="md" />}
|
||||
{itemType === LibraryItem.FOLDER && <Icon icon="folder" size="md" />}
|
||||
{!itemType && <Icon icon="library" size="md" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './tooltip.module.css';
|
||||
|
||||
@@ -6,6 +7,7 @@ export interface TooltipProps extends MantineTooltipProps {}
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
classNames,
|
||||
openDelay = 500,
|
||||
transitionProps = {
|
||||
duration: 250,
|
||||
@@ -18,7 +20,8 @@ export const Tooltip = ({
|
||||
<MantineTooltip
|
||||
arrowSize={10}
|
||||
classNames={{
|
||||
tooltip: styles.tooltip,
|
||||
...classNames,
|
||||
tooltip: clsx(styles.tooltip, classNames?.['tooltip']),
|
||||
}}
|
||||
multiline
|
||||
openDelay={openDelay}
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum LibraryItem {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
FOLDER = 'folder',
|
||||
GENRE = 'genre',
|
||||
PLAYLIST = 'playlist',
|
||||
PLAYLIST_SONG = 'playlistSong',
|
||||
@@ -257,6 +258,29 @@ export type EndpointDetails = {
|
||||
server: ServerListItem;
|
||||
};
|
||||
|
||||
export type Folder = {
|
||||
_itemType: LibraryItem.FOLDER;
|
||||
_serverId: string;
|
||||
_serverType: ServerType;
|
||||
children?: {
|
||||
folders: Folder[];
|
||||
songs: Song[];
|
||||
};
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type FolderArgs = BaseEndpointArgs & { query: FolderQuery };
|
||||
|
||||
export interface FolderQuery extends BaseQuery<SongListSort> {
|
||||
id: string;
|
||||
musicFolderId?: string | string[];
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
export type FolderResponse = Folder;
|
||||
|
||||
export type GainInfo = {
|
||||
album?: number;
|
||||
track?: number;
|
||||
@@ -1231,6 +1255,7 @@ export type ControllerEndpoint = {
|
||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
||||
getDownloadUrl: (args: DownloadArgs) => string;
|
||||
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
@@ -1309,6 +1334,7 @@ export type InternalControllerEndpoint = {
|
||||
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
||||
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum DragTarget {
|
||||
ALBUM = LibraryItem.ALBUM,
|
||||
ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,
|
||||
ARTIST = LibraryItem.ARTIST,
|
||||
FOLDER = LibraryItem.FOLDER,
|
||||
GENERIC = 'generic',
|
||||
GENRE = LibraryItem.GENRE,
|
||||
GRID_ROW = 'gridRow',
|
||||
@@ -19,6 +20,7 @@ export const DragTargetMap = {
|
||||
[LibraryItem.ALBUM]: DragTarget.ALBUM,
|
||||
[LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST,
|
||||
[LibraryItem.ARTIST]: DragTarget.ARTIST,
|
||||
[LibraryItem.FOLDER]: DragTarget.FOLDER,
|
||||
[LibraryItem.GENRE]: DragTarget.GENRE,
|
||||
[LibraryItem.PLAYLIST]: DragTarget.PLAYLIST,
|
||||
[LibraryItem.PLAYLIST_SONG]: DragTarget.SONG,
|
||||
|
||||
Reference in New Issue
Block a user