mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-04 19:33:32 +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)",
|
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
||||||
"title": "$t(entity.genre_other)"
|
"title": "$t(entity.genre_other)"
|
||||||
},
|
},
|
||||||
|
"folderList": {
|
||||||
|
"title": "$t(entity.folder_other)"
|
||||||
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
"commands": {
|
"commands": {
|
||||||
"goToPage": "go to page",
|
"goToPage": "go to page",
|
||||||
@@ -689,8 +692,6 @@
|
|||||||
"gaplessAudio_description": "sets the gapless audio setting for mpv",
|
"gaplessAudio_description": "sets the gapless audio setting for mpv",
|
||||||
"gaplessAudio_optionWeak": "weak (recommended)",
|
"gaplessAudio_optionWeak": "weak (recommended)",
|
||||||
"gaplessAudio": "gapless audio",
|
"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_description": "enable or disable the usage of your system media hotkeys to control playback",
|
||||||
"globalMediaHotkeys": "global media hotkeys",
|
"globalMediaHotkeys": "global media hotkeys",
|
||||||
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
|
"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,
|
server.type,
|
||||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
)?.({ ...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) {
|
getGenreList(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
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: {
|
getGenreList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'musicgenres',
|
path: 'musicgenres',
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import chunk from 'lodash/chunk';
|
import chunk from 'lodash/chunk';
|
||||||
|
import filter from 'lodash/filter';
|
||||||
|
import orderBy from 'lodash/orderBy';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
||||||
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
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 {
|
import {
|
||||||
albumArtistListSortMap,
|
albumArtistListSortMap,
|
||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
|
Folder,
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Played,
|
Played,
|
||||||
playlistListSortMap,
|
playlistListSortMap,
|
||||||
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
|
SongListSort,
|
||||||
songListSortMap,
|
songListSortMap,
|
||||||
|
SortOrder,
|
||||||
sortOrderMap,
|
sortOrderMap,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-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}`;
|
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) => {
|
getGenreList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
getDownloadUrl: SubsonicController.getDownloadUrl,
|
||||||
|
getFolder: SubsonicController.getFolder,
|
||||||
getGenreList: async (args) => {
|
getGenreList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AlbumDetailQuery,
|
AlbumDetailQuery,
|
||||||
AlbumListQuery,
|
AlbumListQuery,
|
||||||
ArtistListQuery,
|
ArtistListQuery,
|
||||||
|
FolderQuery,
|
||||||
GenreListQuery,
|
GenreListQuery,
|
||||||
LyricSearchQuery,
|
LyricSearchQuery,
|
||||||
LyricsQuery,
|
LyricsQuery,
|
||||||
@@ -224,6 +225,15 @@ export const queryKeys: Record<
|
|||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
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: {
|
genres: {
|
||||||
count: (serverId: string, query?: GenreListQuery) => {
|
count: (serverId: string, query?: GenreListQuery) => {
|
||||||
const { filter, pagination } = splitPaginatedQuery(query);
|
const { filter, pagination } = splitPaginatedQuery(query);
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ export const contract = c.router({
|
|||||||
200: ssType._response.getGenres,
|
200: ssType._response.getGenres,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getIndexes: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getIndexes.view',
|
||||||
|
query: ssType._parameters.getIndexes,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getIndexes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getMusicDirectory: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getMusicDirectory.view',
|
||||||
|
query: ssType._parameters.getMusicDirectory,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getMusicDirectory,
|
||||||
|
},
|
||||||
|
},
|
||||||
getMusicFolderList: {
|
getMusicFolderList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getMusicFolders.view',
|
path: 'getMusicFolders.view',
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
PlaylistListSort,
|
PlaylistListSort,
|
||||||
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
|
SongListSort,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeatures } from '/@/shared/types/features-types';
|
import { ServerFeatures } from '/@/shared/types/features-types';
|
||||||
@@ -650,6 +652,106 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
'&c=Feishin'
|
'&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 }) => {
|
getGenreList: async ({ apiClientProps, query }) => {
|
||||||
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
ItemListStateActions,
|
ItemListStateActions,
|
||||||
ItemListStateItemWithRequiredProperties,
|
ItemListStateItemWithRequiredProperties,
|
||||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
} 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
|
* 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.
|
* Otherwise, select and drag only the current item.
|
||||||
* If internalState is not provided, returns the single item wrapped in an array.
|
* 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 internalState - The item list state actions (optional)
|
||||||
* @param updateSelection - Whether to update the selection state (default: true)
|
* @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)
|
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
|
||||||
*/
|
*/
|
||||||
export const getDraggedItems = (
|
export const getDraggedItems = (
|
||||||
data: Album | AlbumArtist | Artist | Playlist | Song | undefined,
|
data: Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined,
|
||||||
internalState?: ItemListStateActions,
|
internalState?: ItemListStateActions,
|
||||||
updateSelection: boolean = true,
|
updateSelection: boolean = true,
|
||||||
): ItemListStateItemWithRequiredProperties[] => {
|
): ItemListStateItemWithRequiredProperties[] => {
|
||||||
|
|||||||
@@ -299,10 +299,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
return;
|
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 no internalState, call ContextMenuController directly
|
||||||
if (!internalState) {
|
if (!internalState) {
|
||||||
return ContextMenuController.call({
|
return ContextMenuController.call({
|
||||||
cmd: { items: [item] as any[], type: itemType as any },
|
cmd: { items: [item] as any[], type: actualItemType as any },
|
||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -315,7 +320,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
if (internalState.getSelected().length === 0) {
|
if (internalState.getSelected().length === 0) {
|
||||||
internalState.setSelected([item]);
|
internalState.setSelected([item]);
|
||||||
return ContextMenuController.call({
|
return ContextMenuController.call({
|
||||||
cmd: { items: [item] as any[], type: itemType as any },
|
cmd: { items: [item] as any[], type: actualItemType as any },
|
||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,15 +328,21 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
else if (!internalState.isSelected(rowId)) {
|
else if (!internalState.isSelected(rowId)) {
|
||||||
internalState.setSelected([item]);
|
internalState.setSelected([item]);
|
||||||
return ContextMenuController.call({
|
return ContextMenuController.call({
|
||||||
cmd: { items: [item] as any[], type: itemType as any },
|
cmd: { items: [item] as any[], type: actualItemType as any },
|
||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedItems = internalState.getSelected();
|
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({
|
return ContextMenuController.call({
|
||||||
cmd: { items: selectedItems as any[], type: itemType as any },
|
cmd: { items: selectedItems as any[], type: selectedItemType as any },
|
||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,3 +54,8 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 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 { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||||
import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';
|
import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Image } from '/@/shared/components/image/image';
|
import { Image } from '/@/shared/components/image/image';
|
||||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
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';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const ImageColumn = (props: ItemTableListInnerColumn) => {
|
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 (
|
return (
|
||||||
<TableColumnContainer {...props}>
|
<TableColumnContainer {...props}>
|
||||||
<Skeleton containerClassName={styles.skeleton} />
|
<Skeleton containerClassName={styles.skeleton} />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
const { itemType } = props;
|
const { itemType } = props;
|
||||||
|
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
|
case LibraryItem.FOLDER:
|
||||||
case LibraryItem.PLAYLIST_SONG:
|
case LibraryItem.PLAYLIST_SONG:
|
||||||
case LibraryItem.QUEUE_SONG:
|
case LibraryItem.QUEUE_SONG:
|
||||||
case LibraryItem.SONG:
|
case LibraryItem.SONG:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
const { itemType } = props;
|
const { itemType } = props;
|
||||||
|
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
|
case LibraryItem.FOLDER:
|
||||||
case LibraryItem.PLAYLIST_SONG:
|
case LibraryItem.PLAYLIST_SONG:
|
||||||
case LibraryItem.QUEUE_SONG:
|
case LibraryItem.QUEUE_SONG:
|
||||||
case LibraryItem.SONG:
|
case LibraryItem.SONG:
|
||||||
|
|||||||
+5
@@ -32,3 +32,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-icon {
|
||||||
|
color: black;
|
||||||
|
fill: rgb(255 215 100);
|
||||||
|
}
|
||||||
|
|||||||
+41
-1
@@ -12,9 +12,10 @@ import {
|
|||||||
TableColumnContainer,
|
TableColumnContainer,
|
||||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Image } from '/@/shared/components/image/image';
|
import { Image } from '/@/shared/components/image/image';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
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) => {
|
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||||
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
|
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) {
|
if (row === null) {
|
||||||
return <ColumnNullFallback {...props} />;
|
return <ColumnNullFallback {...props} />;
|
||||||
}
|
}
|
||||||
@@ -177,6 +216,7 @@ export const TitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
const { itemType } = props;
|
const { itemType } = props;
|
||||||
|
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
|
case LibraryItem.FOLDER:
|
||||||
case LibraryItem.PLAYLIST_SONG:
|
case LibraryItem.PLAYLIST_SONG:
|
||||||
case LibraryItem.QUEUE_SONG:
|
case LibraryItem.QUEUE_SONG:
|
||||||
case LibraryItem.SONG:
|
case LibraryItem.SONG:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
|||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
|
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
|
||||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
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 {
|
import {
|
||||||
dndUtils,
|
dndUtils,
|
||||||
DragData,
|
DragData,
|
||||||
@@ -80,6 +80,7 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
|||||||
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
|
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
|
||||||
const item = isDataRow ? props.data[props.rowIndex] : null;
|
const item = isDataRow ? props.data[props.rowIndex] : null;
|
||||||
const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item;
|
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)
|
// 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)
|
// 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;
|
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: {
|
case DragTarget.GENRE: {
|
||||||
props.playerContext.addToQueueByFetch(
|
props.playerContext.addToQueueByFetch(
|
||||||
sourceServerId,
|
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) {
|
switch (type) {
|
||||||
case TableColumn.ACTIONS:
|
case TableColumn.ACTIONS:
|
||||||
case TableColumn.SKIP:
|
|
||||||
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
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:
|
case TableColumn.IMAGE:
|
||||||
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
|
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:
|
case TableColumn.ROW_INDEX:
|
||||||
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||||
|
|
||||||
case TableColumn.SIZE:
|
|
||||||
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
||||||
|
|
||||||
case TableColumn.TITLE:
|
case TableColumn.TITLE:
|
||||||
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
|
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||||
|
|
||||||
@@ -433,14 +517,8 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
|||||||
<TitleCombinedColumn {...props} {...dragProps} controls={controls} type={type} />
|
<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:
|
default:
|
||||||
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
|
return <ColumnNullFallback {...props} {...dragProps} controls={controls} type={type} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
Artist,
|
Artist,
|
||||||
|
Folder,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Playlist,
|
Playlist,
|
||||||
Song,
|
Song,
|
||||||
@@ -75,7 +76,7 @@ export interface ItemListHandle {
|
|||||||
scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
|
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> {
|
export interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> {
|
||||||
autoFitColumns?: boolean;
|
autoFitColumns?: boolean;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getAlbumSongsById,
|
getAlbumSongsById,
|
||||||
getGenreSongsById,
|
getGenreSongsById,
|
||||||
getPlaylistSongsById,
|
getPlaylistSongsById,
|
||||||
|
getSongsByFolder,
|
||||||
} from '/@/renderer/features/player/utils';
|
} from '/@/renderer/features/player/utils';
|
||||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
|
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
|
||||||
@@ -97,49 +98,64 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
|||||||
|
|
||||||
const getSongsByAlbum = useCallback(
|
const getSongsByAlbum = useCallback(
|
||||||
async (albumId: string) => {
|
async (albumId: string) => {
|
||||||
if (!server) return null;
|
|
||||||
return getAlbumSongsById({
|
return getAlbumSongsById({
|
||||||
id: [albumId],
|
id: [albumId],
|
||||||
queryClient,
|
queryClient,
|
||||||
server,
|
serverId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[queryClient, server],
|
[queryClient, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSongsByArtist = useCallback(
|
const getSongsByArtist = useCallback(
|
||||||
async (artistId: string) => {
|
async (artistId: string) => {
|
||||||
if (!server) return null;
|
|
||||||
return getAlbumArtistSongsById({
|
return getAlbumArtistSongsById({
|
||||||
id: [artistId],
|
id: [artistId],
|
||||||
queryClient,
|
queryClient,
|
||||||
server,
|
serverId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[queryClient, server],
|
[queryClient, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSongsByGenre = useCallback(
|
const getSongsByGenre = useCallback(
|
||||||
async (genreIds: string[]) => {
|
async (genreIds: string[]) => {
|
||||||
if (!server) return null;
|
|
||||||
return getGenreSongsById({
|
return getGenreSongsById({
|
||||||
id: genreIds,
|
id: genreIds,
|
||||||
queryClient,
|
queryClient,
|
||||||
server,
|
serverId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[queryClient, server],
|
[queryClient, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSongsByPlaylist = useCallback(
|
const getSongsByPlaylist = useCallback(
|
||||||
async (playlistId: string) => {
|
async (playlistId: string) => {
|
||||||
if (!server) return null;
|
|
||||||
return getPlaylistSongsById({
|
return getPlaylistSongsById({
|
||||||
id: playlistId,
|
id: playlistId,
|
||||||
queryClient,
|
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],
|
[queryClient, server],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -173,6 +189,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
|||||||
const songs = await getSongsByPlaylist(id);
|
const songs = await getSongsByPlaylist(id);
|
||||||
allSongIds.push(...(songs?.items?.map((song) => song.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) {
|
if (allSongIds.length === 0) {
|
||||||
@@ -213,6 +234,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
|||||||
addToPlaylistMutation,
|
addToPlaylistMutation,
|
||||||
getSongsByAlbum,
|
getSongsByAlbum,
|
||||||
getSongsByArtist,
|
getSongsByArtist,
|
||||||
|
getSongsByFolderLocal,
|
||||||
getSongsByGenre,
|
getSongsByGenre,
|
||||||
getSongsByPlaylist,
|
getSongsByPlaylist,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -226,6 +248,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
|||||||
const modalProps: {
|
const modalProps: {
|
||||||
albumId?: string[];
|
albumId?: string[];
|
||||||
artistId?: string[];
|
artistId?: string[];
|
||||||
|
folderId?: string[];
|
||||||
genreId?: string[];
|
genreId?: string[];
|
||||||
initialSelectedIds?: string[];
|
initialSelectedIds?: string[];
|
||||||
playlistId?: string[];
|
playlistId?: string[];
|
||||||
@@ -240,6 +263,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
|||||||
case LibraryItem.ARTIST:
|
case LibraryItem.ARTIST:
|
||||||
modalProps.artistId = items;
|
modalProps.artistId = items;
|
||||||
break;
|
break;
|
||||||
|
case LibraryItem.FOLDER:
|
||||||
|
modalProps.folderId = items;
|
||||||
|
break;
|
||||||
case LibraryItem.GENRE:
|
case LibraryItem.GENRE:
|
||||||
modalProps.genreId = items;
|
modalProps.genreId = items;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
|
|||||||
return 'album';
|
return 'album';
|
||||||
case LibraryItem.ALBUM_ARTIST:
|
case LibraryItem.ALBUM_ARTIST:
|
||||||
return 'albumArtist';
|
return 'albumArtist';
|
||||||
|
case LibraryItem.FOLDER:
|
||||||
|
return 'folder';
|
||||||
case LibraryItem.PLAYLIST:
|
case LibraryItem.PLAYLIST:
|
||||||
return 'playlist';
|
return 'playlist';
|
||||||
case LibraryItem.SONG:
|
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 { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';
|
||||||
import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-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 { 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 { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';
|
||||||
import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-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';
|
import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
Artist,
|
Artist,
|
||||||
|
Folder,
|
||||||
Genre,
|
Genre,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Playlist,
|
Playlist,
|
||||||
@@ -82,6 +84,7 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
|
|||||||
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
|
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
|
||||||
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
|
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
|
||||||
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
|
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
|
||||||
|
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
|
||||||
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
|
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
|
||||||
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
|
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
|
||||||
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
|
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
|
||||||
@@ -95,6 +98,7 @@ export type ContextMenuCommand =
|
|||||||
| AlbumArtistContextMenuProps
|
| AlbumArtistContextMenuProps
|
||||||
| AlbumContextMenuProps
|
| AlbumContextMenuProps
|
||||||
| ArtistContextMenuProps
|
| ArtistContextMenuProps
|
||||||
|
| FolderContextMenuProps
|
||||||
| GenreContextMenuProps
|
| GenreContextMenuProps
|
||||||
| PlaylistContextMenuProps
|
| PlaylistContextMenuProps
|
||||||
| PlaylistSongContextMenuProps
|
| PlaylistSongContextMenuProps
|
||||||
@@ -116,6 +120,11 @@ type ArtistContextMenuProps = {
|
|||||||
type: LibraryItem.ARTIST;
|
type: LibraryItem.ARTIST;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FolderContextMenuProps = {
|
||||||
|
items: Folder[];
|
||||||
|
type: LibraryItem.FOLDER;
|
||||||
|
};
|
||||||
|
|
||||||
type GenreContextMenuProps = {
|
type GenreContextMenuProps = {
|
||||||
items: Genre[];
|
items: Genre[];
|
||||||
type: LibraryItem.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 { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
||||||
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
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 { DragTarget } from '/@/shared/types/drag-and-drop';
|
||||||
import { ItemListKey, Play, PlayerQueueType } from '/@/shared/types/types';
|
import { ItemListKey, Play, PlayerQueueType } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -248,6 +248,45 @@ const EmptyQueueDropZone = () => {
|
|||||||
}
|
}
|
||||||
break;
|
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: {
|
case DragTarget.GENRE: {
|
||||||
if (sourceServerId) {
|
if (sourceServerId) {
|
||||||
playerContext.addToQueueByFetch(
|
playerContext.addToQueueByFetch(
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-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 { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
@@ -28,9 +35,6 @@ import {
|
|||||||
PlaylistSongListResponse,
|
PlaylistSongListResponse,
|
||||||
QueueSong,
|
QueueSong,
|
||||||
Song,
|
Song,
|
||||||
SongListResponse,
|
|
||||||
SongListSort,
|
|
||||||
SortOrder,
|
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
|
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -911,88 +915,57 @@ export async function fetchSongsByItemType(
|
|||||||
|
|
||||||
switch (args.itemType) {
|
switch (args.itemType) {
|
||||||
case LibraryItem.ALBUM: {
|
case LibraryItem.ALBUM: {
|
||||||
const promises: Promise<SongListResponse>[] = [];
|
const albumSongsResponse = await getAlbumSongsById({
|
||||||
|
id: args.id,
|
||||||
for (const id of args.id) {
|
query: args.params,
|
||||||
promises.push(
|
queryClient,
|
||||||
queryClient.fetchQuery({
|
serverId,
|
||||||
...songsQueries.list({
|
});
|
||||||
query: {
|
songs.push(...albumSongsResponse.items);
|
||||||
albumIds: [id],
|
break;
|
||||||
sortBy: SongListSort.ID,
|
}
|
||||||
sortOrder: SortOrder.ASC,
|
|
||||||
startIndex: 0,
|
case LibraryItem.ALBUM_ARTIST: {
|
||||||
...args.params,
|
const albumArtistSongsResponse = await getAlbumArtistSongsById({
|
||||||
},
|
id: args.id,
|
||||||
serverId: serverId,
|
query: args.params,
|
||||||
}),
|
queryClient,
|
||||||
gcTime: 0,
|
serverId,
|
||||||
staleTime: 0,
|
});
|
||||||
}),
|
songs.push(...albumArtistSongsResponse.items);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
songs.push(...results.flatMap((r) => r.items));
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case LibraryItem.ALBUM_ARTIST:
|
|
||||||
case LibraryItem.ARTIST: {
|
case LibraryItem.ARTIST: {
|
||||||
const promises: Promise<SongListResponse>[] = [];
|
const artistSongsResponse = await getAlbumArtistSongsById({
|
||||||
|
id: args.id,
|
||||||
for (const id of args.id) {
|
query: args.params,
|
||||||
promises.push(
|
queryClient,
|
||||||
queryClient.fetchQuery({
|
serverId,
|
||||||
...songsQueries.list({
|
});
|
||||||
query: {
|
songs.push(...artistSongsResponse.items);
|
||||||
albumArtistIds: [id],
|
break;
|
||||||
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));
|
|
||||||
|
|
||||||
|
case LibraryItem.FOLDER: {
|
||||||
|
const folderSongsResponse = await getSongsByFolder({
|
||||||
|
id: args.id,
|
||||||
|
query: args.params,
|
||||||
|
queryClient,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
songs.push(...folderSongsResponse.items);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case LibraryItem.GENRE: {
|
case LibraryItem.GENRE: {
|
||||||
const promises: Promise<SongListResponse>[] = [];
|
const genreSongsResponse = await getGenreSongsById({
|
||||||
|
id: args.id,
|
||||||
for (const id of args.id) {
|
query: args.params,
|
||||||
promises.push(
|
queryClient,
|
||||||
queryClient.fetchQuery({
|
serverId,
|
||||||
...songsQueries.list({
|
});
|
||||||
query: {
|
songs.push(...genreSongsResponse.items);
|
||||||
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));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,22 +974,16 @@ export async function fetchSongsByItemType(
|
|||||||
|
|
||||||
for (const id of args.id) {
|
for (const id of args.id) {
|
||||||
promises.push(
|
promises.push(
|
||||||
queryClient.fetchQuery({
|
getPlaylistSongsById({
|
||||||
...playlistsQueries.songList({
|
id,
|
||||||
query: {
|
query: args.params,
|
||||||
id: id,
|
queryClient,
|
||||||
...args.params,
|
serverId,
|
||||||
},
|
|
||||||
serverId: serverId,
|
|
||||||
}),
|
|
||||||
gcTime: 0,
|
|
||||||
staleTime: 0,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
songs.push(...results.flatMap((r) => r.items));
|
songs.push(...results.flatMap((r) => r.items));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { QueryClient } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
|
||||||
import { sortSongList } from '/@/shared/api/utils';
|
import { sortSongList } from '/@/shared/api/utils';
|
||||||
import {
|
import {
|
||||||
PlaylistSongListQuery,
|
PlaylistSongListQuery,
|
||||||
PlaylistSongListQueryClientSide,
|
PlaylistSongListQueryClientSide,
|
||||||
ServerListItem,
|
Song,
|
||||||
SongDetailQuery,
|
SongDetailQuery,
|
||||||
SongListQuery,
|
SongListQuery,
|
||||||
SongListResponse,
|
SongListResponse,
|
||||||
@@ -18,22 +19,22 @@ export const getPlaylistSongsById = async (args: {
|
|||||||
id: string;
|
id: string;
|
||||||
query?: Partial<PlaylistSongListQueryClientSide>;
|
query?: Partial<PlaylistSongListQueryClientSide>;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: ServerListItem;
|
serverId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { id, query, queryClient, server } = args;
|
const { id, query, queryClient, serverId } = args;
|
||||||
|
|
||||||
const queryFilter: PlaylistSongListQuery = {
|
const queryFilter: PlaylistSongListQuery = {
|
||||||
id,
|
id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.playlists.songList(server?.id, id);
|
const queryKey = queryKeys.playlists.songList(serverId, id);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
api.controller.getPlaylistSongList({
|
api.controller.getPlaylistSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
@@ -58,9 +59,9 @@ export const getAlbumSongsById = async (args: {
|
|||||||
orderByIds?: boolean;
|
orderByIds?: boolean;
|
||||||
query?: Partial<SongListQuery>;
|
query?: Partial<SongListQuery>;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: ServerListItem;
|
serverId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { id, query, queryClient, server } = args;
|
const { id, query, queryClient, serverId } = args;
|
||||||
|
|
||||||
const queryFilter: SongListQuery = {
|
const queryFilter: SongListQuery = {
|
||||||
albumIds: id,
|
albumIds: id,
|
||||||
@@ -70,14 +71,14 @@ export const getAlbumSongsById = async (args: {
|
|||||||
...query,
|
...query,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
api.controller.getSongList({
|
api.controller.getSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
@@ -94,9 +95,9 @@ export const getGenreSongsById = async (args: {
|
|||||||
orderByIds?: boolean;
|
orderByIds?: boolean;
|
||||||
query?: Partial<SongListQuery>;
|
query?: Partial<SongListQuery>;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: null | ServerListItem;
|
serverId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { id, query, queryClient, server } = args;
|
const { id, query, queryClient, serverId } = args;
|
||||||
|
|
||||||
const data: SongListResponse = {
|
const data: SongListResponse = {
|
||||||
items: [],
|
items: [],
|
||||||
@@ -112,14 +113,14 @@ export const getGenreSongsById = async (args: {
|
|||||||
...query,
|
...query,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
api.controller.getSongList({
|
api.controller.getSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
@@ -142,9 +143,9 @@ export const getAlbumArtistSongsById = async (args: {
|
|||||||
orderByIds?: boolean;
|
orderByIds?: boolean;
|
||||||
query?: Partial<SongListQuery>;
|
query?: Partial<SongListQuery>;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: ServerListItem;
|
serverId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { id, query, queryClient, server } = args;
|
const { id, query, queryClient, serverId } = args;
|
||||||
|
|
||||||
const queryFilter: SongListQuery = {
|
const queryFilter: SongListQuery = {
|
||||||
albumArtistIds: id || [],
|
albumArtistIds: id || [],
|
||||||
@@ -154,14 +155,14 @@ export const getAlbumArtistSongsById = async (args: {
|
|||||||
...query,
|
...query,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
api.controller.getSongList({
|
api.controller.getSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
@@ -177,9 +178,9 @@ export const getArtistSongsById = async (args: {
|
|||||||
id: string[];
|
id: string[];
|
||||||
query?: Partial<SongListQuery>;
|
query?: Partial<SongListQuery>;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: ServerListItem;
|
serverId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { id, query, queryClient, server } = args;
|
const { id, query, queryClient, serverId } = args;
|
||||||
|
|
||||||
const queryFilter: SongListQuery = {
|
const queryFilter: SongListQuery = {
|
||||||
artistIds: id,
|
artistIds: id,
|
||||||
@@ -189,14 +190,14 @@ export const getArtistSongsById = async (args: {
|
|||||||
...query,
|
...query,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
api.controller.getSongList({
|
api.controller.getSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
@@ -211,9 +212,9 @@ export const getArtistSongsById = async (args: {
|
|||||||
export const getSongsByQuery = async (args: {
|
export const getSongsByQuery = async (args: {
|
||||||
query?: Partial<SongListQuery>;
|
query?: Partial<SongListQuery>;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: ServerListItem;
|
serverId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { query, queryClient, server } = args;
|
const { query, queryClient, serverId } = args;
|
||||||
|
|
||||||
const queryFilter: SongListQuery = {
|
const queryFilter: SongListQuery = {
|
||||||
sortBy: SongListSort.ALBUM,
|
sortBy: SongListSort.ALBUM,
|
||||||
@@ -222,14 +223,14 @@ export const getSongsByQuery = async (args: {
|
|||||||
...query,
|
...query,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
const queryKey = queryKeys.songs.list(serverId, queryFilter);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
return api.controller.getSongList({
|
return api.controller.getSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
@@ -242,23 +243,77 @@ export const getSongsByQuery = async (args: {
|
|||||||
return res;
|
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: {
|
export const getSongById = async (args: {
|
||||||
id: string;
|
id: string;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
server: ServerListItem;
|
serverId: string;
|
||||||
}): Promise<SongListResponse> => {
|
}): Promise<SongListResponse> => {
|
||||||
const { id, queryClient, server } = args;
|
const { id, queryClient, serverId } = args;
|
||||||
|
|
||||||
const queryFilter: SongDetailQuery = { id };
|
const queryFilter: SongDetailQuery = { id };
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
|
const queryKey = queryKeys.songs.detail(serverId, queryFilter);
|
||||||
|
|
||||||
const res = await queryClient.fetchQuery({
|
const res = await queryClient.fetchQuery({
|
||||||
gcTime: 1000 * 60,
|
gcTime: 1000 * 60,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
api.controller.getSongDetail({
|
api.controller.getSongDetail({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: queryFilter,
|
query: queryFilter,
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import styles from './add-to-playlist-context-modal.module.css';
|
|||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
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 { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
import { Box } from '/@/shared/components/box/box';
|
import { Box } from '/@/shared/components/box/box';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
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 { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { useForm } from '/@/shared/hooks/use-form';
|
import { useForm } from '/@/shared/hooks/use-form';
|
||||||
import {
|
import { Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
Playlist,
|
|
||||||
PlaylistListSort,
|
|
||||||
SongListQuery,
|
|
||||||
SongListSort,
|
|
||||||
SortOrder,
|
|
||||||
} from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export const AddToPlaylistContextModal = ({
|
export const AddToPlaylistContextModal = ({
|
||||||
id,
|
id,
|
||||||
@@ -45,14 +45,16 @@ export const AddToPlaylistContextModal = ({
|
|||||||
}: ContextModalProps<{
|
}: ContextModalProps<{
|
||||||
albumId?: string[];
|
albumId?: string[];
|
||||||
artistId?: string[];
|
artistId?: string[];
|
||||||
|
folderId?: string[];
|
||||||
genreId?: string[];
|
genreId?: string[];
|
||||||
initialSelectedIds?: string[];
|
initialSelectedIds?: string[];
|
||||||
playlistId?: string[];
|
playlistId?: string[];
|
||||||
songId?: string[];
|
songId?: string[];
|
||||||
}>) => {
|
}>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { albumId, artistId, genreId, initialSelectedIds, playlistId, songId } = innerProps;
|
const { albumId, artistId, folderId, genreId, initialSelectedIds, playlistId, songId } =
|
||||||
const server = useCurrentServer();
|
innerProps;
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [search, setSearch] = useState<string>('');
|
const [search, setSearch] = useState<string>('');
|
||||||
const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);
|
const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);
|
||||||
@@ -81,7 +83,7 @@ export const AddToPlaylistContextModal = ({
|
|||||||
sortOrder: SortOrder.ASC,
|
sortOrder: SortOrder.ASC,
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
},
|
},
|
||||||
serverId: server?.id,
|
serverId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,78 +111,35 @@ export const AddToPlaylistContextModal = ({
|
|||||||
|
|
||||||
const getSongsByAlbum = useCallback(
|
const getSongsByAlbum = useCallback(
|
||||||
async (albumId: string) => {
|
async (albumId: string) => {
|
||||||
const query: SongListQuery = {
|
return getAlbumSongsById({
|
||||||
albumIds: [albumId],
|
id: [albumId],
|
||||||
sortBy: SongListSort.ALBUM,
|
queryClient,
|
||||||
sortOrder: SortOrder.ASC,
|
serverId,
|
||||||
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 songsRes;
|
|
||||||
},
|
},
|
||||||
[server],
|
[serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSongsByArtist = useCallback(
|
const getSongsByArtist = useCallback(
|
||||||
async (artistId: string) => {
|
async (artistId: string) => {
|
||||||
const query: SongListQuery = {
|
return getArtistSongsById({
|
||||||
artistIds: [artistId],
|
id: [artistId],
|
||||||
sortBy: SongListSort.ARTIST,
|
queryClient,
|
||||||
sortOrder: SortOrder.ASC,
|
serverId,
|
||||||
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 songsRes;
|
|
||||||
},
|
},
|
||||||
[server],
|
[serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSongsByPlaylist = useCallback(
|
const getSongsByPlaylist = useCallback(
|
||||||
async (playlistId: string) => {
|
async (playlistId: string) => {
|
||||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
return getPlaylistSongsById({
|
||||||
|
id: playlistId,
|
||||||
const songsRes = await queryClient.fetchQuery({
|
queryClient,
|
||||||
queryFn: ({ signal }) => {
|
serverId,
|
||||||
if (!server) throw new Error('No server');
|
|
||||||
return api.controller.getPlaylistSongList({
|
|
||||||
apiClientProps: { serverId: server?.id || '', signal },
|
|
||||||
query: {
|
|
||||||
id: playlistId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
queryKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return songsRes;
|
|
||||||
},
|
},
|
||||||
[server],
|
[serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit(async (values) => {
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
@@ -211,12 +170,21 @@ export const AddToPlaylistContextModal = ({
|
|||||||
const songs = await getGenreSongsById({
|
const songs = await getGenreSongsById({
|
||||||
id: genreId,
|
id: genreId,
|
||||||
queryClient,
|
queryClient,
|
||||||
server,
|
serverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
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) {
|
if (playlistId && playlistId.length > 0) {
|
||||||
for (const id of playlistId) {
|
for (const id of playlistId) {
|
||||||
const songs = await getSongsByPlaylist(id);
|
const songs = await getSongsByPlaylist(id);
|
||||||
@@ -234,7 +202,7 @@ export const AddToPlaylistContextModal = ({
|
|||||||
for (const playlist of values.newPlaylists) {
|
for (const playlist of values.newPlaylists) {
|
||||||
try {
|
try {
|
||||||
const response = await api.controller.createPlaylist({
|
const response = await api.controller.createPlaylist({
|
||||||
apiClientProps: { serverId: server?.id || '' },
|
apiClientProps: { serverId },
|
||||||
body: {
|
body: {
|
||||||
name: playlist,
|
name: playlist,
|
||||||
public: false,
|
public: false,
|
||||||
@@ -257,19 +225,13 @@ export const AddToPlaylistContextModal = ({
|
|||||||
const uniqueSongIds: string[] = [];
|
const uniqueSongIds: string[] = [];
|
||||||
|
|
||||||
if (values.skipDuplicates) {
|
if (values.skipDuplicates) {
|
||||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
|
||||||
|
|
||||||
const playlistSongsRes = await queryClient.fetchQuery({
|
const playlistSongsRes = await queryClient.fetchQuery({
|
||||||
queryFn: ({ signal }) => {
|
queryFn: ({ signal }) => {
|
||||||
if (!server)
|
|
||||||
throw new Error(
|
|
||||||
t('error.serverNotSelectedError', {
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return api.controller.getPlaylistSongList({
|
return api.controller.getPlaylistSongList({
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
serverId: server?.id || '',
|
serverId,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
@@ -291,13 +253,9 @@ export const AddToPlaylistContextModal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||||
if (!server) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addToPlaylistMutation.mutate(
|
addToPlaylistMutation.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: server.id },
|
apiClientProps: { serverId },
|
||||||
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||||
query: { id: playlistId },
|
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<
|
const PLAYLIST_SONG_LIST_FILTERS: Partial<
|
||||||
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
|
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]: ALBUM_LIST_FILTERS,
|
||||||
[LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS,
|
[LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS,
|
||||||
[LibraryItem.ARTIST]: ARTIST_LIST_FILTERS,
|
[LibraryItem.ARTIST]: ARTIST_LIST_FILTERS,
|
||||||
|
[LibraryItem.FOLDER]: FOLDER_LIST_FILTERS,
|
||||||
[LibraryItem.GENRE]: GENRE_LIST_FILTERS,
|
[LibraryItem.GENRE]: GENRE_LIST_FILTERS,
|
||||||
[LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS,
|
[LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS,
|
||||||
[LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS,
|
[LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS,
|
||||||
|
|||||||
@@ -77,9 +77,14 @@ enum PlaylistFilterKeys {
|
|||||||
CUSTOM = '_custom',
|
CUSTOM = '_custom',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FolderFilterKeys {
|
||||||
|
FOLDER_PATH = 'folderPath',
|
||||||
|
}
|
||||||
|
|
||||||
export const FILTER_KEYS = {
|
export const FILTER_KEYS = {
|
||||||
ALBUM: AlbumFilterKeys,
|
ALBUM: AlbumFilterKeys,
|
||||||
ARTIST: ArtistFilterKeys,
|
ARTIST: ArtistFilterKeys,
|
||||||
|
FOLDER: FolderFilterKeys,
|
||||||
PAGINATION: PaginationFilterKeys,
|
PAGINATION: PaginationFilterKeys,
|
||||||
PLAYLIST: PlaylistFilterKeys,
|
PLAYLIST: PlaylistFilterKeys,
|
||||||
SHARED: SharedFilterKeys,
|
SHARED: SharedFilterKeys,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const PlaylistRowButton = ({ item, name, onContextMenu, onPlay, to }: PlaylistRo
|
|||||||
const modalProps: {
|
const modalProps: {
|
||||||
albumId?: string[];
|
albumId?: string[];
|
||||||
artistId?: string[];
|
artistId?: string[];
|
||||||
|
folderId?: string[];
|
||||||
genreId?: string[];
|
genreId?: string[];
|
||||||
initialSelectedIds?: string[];
|
initialSelectedIds?: string[];
|
||||||
playlistId?: string[];
|
playlistId?: string[];
|
||||||
@@ -108,6 +109,9 @@ const PlaylistRowButton = ({ item, name, onContextMenu, onPlay, to }: PlaylistRo
|
|||||||
case LibraryItem.ARTIST:
|
case LibraryItem.ARTIST:
|
||||||
modalProps.artistId = sourceIds;
|
modalProps.artistId = sourceIds;
|
||||||
break;
|
break;
|
||||||
|
case LibraryItem.FOLDER:
|
||||||
|
modalProps.folderId = sourceIds;
|
||||||
|
break;
|
||||||
case LibraryItem.GENRE:
|
case LibraryItem.GENRE:
|
||||||
modalProps.genreId = sourceIds;
|
modalProps.genreId = sourceIds;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const Sidebar = () => {
|
|||||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||||
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
|
||||||
|
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ interface UseContainerQueryProps {
|
|||||||
md?: number;
|
md?: number;
|
||||||
sm?: number;
|
sm?: number;
|
||||||
xl?: number;
|
xl?: number;
|
||||||
|
xs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useContainerQuery = (props?: UseContainerQueryProps) => {
|
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 { height, ref, width } = useElementSize();
|
||||||
|
|
||||||
const isXs = width >= 0;
|
const isXs = width >= (xs || 360);
|
||||||
const isSm = width >= (sm || 600);
|
const isSm = width >= (sm || 600);
|
||||||
const isMd = width >= (md || 768);
|
const isMd = width >= (md || 768);
|
||||||
const isLg = width >= (lg || 1200);
|
const isLg = width >= (lg || 1200);
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const GenreDetailRoute = lazy(
|
|||||||
() => import('/@/renderer/features/genres/routes/genre-detail-route'),
|
() => 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 SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
|
||||||
|
|
||||||
const FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route'));
|
const FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route'));
|
||||||
@@ -142,6 +146,10 @@ export const AppRouter = () => {
|
|||||||
element={<SongListRoute />}
|
element={<SongListRoute />}
|
||||||
path={AppRoute.LIBRARY_SONGS}
|
path={AppRoute.LIBRARY_SONGS}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
element={<FolderListRoute />}
|
||||||
|
path={AppRoute.LIBRARY_FOLDERS}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={<PlaylistListRoute />}
|
element={<PlaylistListRoute />}
|
||||||
path={AppRoute.PLAYLISTS}
|
path={AppRoute.PLAYLISTS}
|
||||||
|
|||||||
@@ -565,6 +565,12 @@ export const sidebarItems: SidebarItemType[] = [
|
|||||||
label: i18n.t('page.sidebar.genres'),
|
label: i18n.t('page.sidebar.genres'),
|
||||||
route: AppRoute.LIBRARY_GENRES,
|
route: AppRoute.LIBRARY_GENRES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
id: 'Folders',
|
||||||
|
label: i18n.t('page.sidebar.folders'),
|
||||||
|
route: AppRoute.LIBRARY_FOLDERS,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
disabled: true,
|
disabled: true,
|
||||||
id: 'Playlists',
|
id: 'Playlists',
|
||||||
@@ -1333,10 +1339,19 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version <= 12) {
|
||||||
|
state.general.sidebarItems.push({
|
||||||
|
disabled: false,
|
||||||
|
id: 'Folders',
|
||||||
|
label: i18n.t('page.sidebar.folders'),
|
||||||
|
route: AppRoute.LIBRARY_FOLDERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return persistedState;
|
return persistedState;
|
||||||
},
|
},
|
||||||
name: 'store_settings',
|
name: 'store_settings',
|
||||||
version: 12,
|
version: 13,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from './rgb-to-rgba';
|
|||||||
export * from './sentence-case';
|
export * from './sentence-case';
|
||||||
export * from './set-local-storage-setttings';
|
export * from './set-local-storage-setttings';
|
||||||
export * from './title-case';
|
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 {
|
import {
|
||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
|
Folder,
|
||||||
Genre,
|
Genre,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
MusicFolder,
|
MusicFolder,
|
||||||
@@ -496,17 +497,20 @@ const normalizeGenre = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// const normalizeFolder = (item: any) => {
|
const normalizeFolder = (
|
||||||
// return {
|
item: z.infer<typeof jfType._response.folder>,
|
||||||
// created: item.DateCreated,
|
server: null | ServerListItem,
|
||||||
// id: item.Id,
|
): Folder => {
|
||||||
// image: getCoverArtUrl(item, 150),
|
return {
|
||||||
// isDir: true,
|
_itemType: LibraryItem.FOLDER,
|
||||||
// title: item.Name,
|
_serverId: server?.id || 'unknown',
|
||||||
// type: Item.Folder,
|
_serverType: ServerType.JELLYFIN,
|
||||||
// uniqueId: nanoid(),
|
children: undefined,
|
||||||
// };
|
id: item.Id,
|
||||||
// };
|
name: item.Name || 'Unknown folder',
|
||||||
|
parentId: item.ParentId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// const normalizeScanStatus = () => {
|
// const normalizeScanStatus = () => {
|
||||||
// return {
|
// return {
|
||||||
@@ -518,6 +522,7 @@ const normalizeGenre = (
|
|||||||
export const jfNormalize = {
|
export const jfNormalize = {
|
||||||
album: normalizeAlbum,
|
album: normalizeAlbum,
|
||||||
albumArtist: normalizeAlbumArtist,
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
folder: normalizeFolder,
|
||||||
genre: normalizeGenre,
|
genre: normalizeGenre,
|
||||||
musicFolder: normalizeMusicFolder,
|
musicFolder: normalizeMusicFolder,
|
||||||
playlist: normalizePlaylist,
|
playlist: normalizePlaylist,
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ const song = z.object({
|
|||||||
MediaType: z.string(),
|
MediaType: z.string(),
|
||||||
Name: z.string(),
|
Name: z.string(),
|
||||||
NormalizationGain: z.number().optional(),
|
NormalizationGain: z.number().optional(),
|
||||||
|
ParentId: z.string().optional(),
|
||||||
ParentIndexNumber: z.number(),
|
ParentIndexNumber: z.number(),
|
||||||
People: participant.array().optional(),
|
People: participant.array().optional(),
|
||||||
PlaylistItemId: z.string().optional(),
|
PlaylistItemId: z.string().optional(),
|
||||||
@@ -495,7 +496,7 @@ const song = z.object({
|
|||||||
ProviderIds: providerIds.optional(),
|
ProviderIds: providerIds.optional(),
|
||||||
RunTimeTicks: z.number(),
|
RunTimeTicks: z.number(),
|
||||||
ServerId: z.string(),
|
ServerId: z.string(),
|
||||||
SortName: z.string(),
|
SortName: z.string().optional(),
|
||||||
Tags: z.string().array().optional(),
|
Tags: z.string().array().optional(),
|
||||||
Type: z.string(),
|
Type: z.string(),
|
||||||
UserData: userData.optional(),
|
UserData: userData.optional(),
|
||||||
@@ -772,6 +773,34 @@ const filters = z.object({
|
|||||||
Years: z.number().array().optional(),
|
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 = {
|
export const jfType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: albumArtistListSort,
|
albumArtistList: albumArtistListSort,
|
||||||
@@ -794,6 +823,7 @@ export const jfType = {
|
|||||||
deletePlaylist: deletePlaylistParameters,
|
deletePlaylist: deletePlaylistParameters,
|
||||||
favorite: favoriteParameters,
|
favorite: favoriteParameters,
|
||||||
filterList: filterListParameters,
|
filterList: filterListParameters,
|
||||||
|
folder: folderParameters,
|
||||||
genreList: genreListParameters,
|
genreList: genreListParameters,
|
||||||
musicFolderList: musicFolderListParameters,
|
musicFolderList: musicFolderListParameters,
|
||||||
playlistDetail: playlistDetailParameters,
|
playlistDetail: playlistDetailParameters,
|
||||||
@@ -819,6 +849,8 @@ export const jfType = {
|
|||||||
error,
|
error,
|
||||||
favorite,
|
favorite,
|
||||||
filters,
|
filters,
|
||||||
|
folder,
|
||||||
|
folderList,
|
||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
lyrics,
|
lyrics,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
ExplicitStatus,
|
ExplicitStatus,
|
||||||
|
Folder,
|
||||||
Genre,
|
Genre,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Playlist,
|
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 = {
|
export const ssNormalize = {
|
||||||
album: normalizeAlbum,
|
album: normalizeAlbum,
|
||||||
albumArtist: normalizeAlbumArtist,
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
folder: normalizeFolder,
|
||||||
genre: normalizeGenre,
|
genre: normalizeGenre,
|
||||||
playlist: normalizePlaylist,
|
playlist: normalizePlaylist,
|
||||||
song: normalizeSong,
|
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 = {
|
export const ssType = {
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumInfo: albumInfoParameters,
|
albumInfo: albumInfoParameters,
|
||||||
@@ -563,6 +607,8 @@ export const ssType = {
|
|||||||
getArtists: getArtistsParameters,
|
getArtists: getArtistsParameters,
|
||||||
getGenre: getGenresParameters,
|
getGenre: getGenresParameters,
|
||||||
getGenres: getGenresParameters,
|
getGenres: getGenresParameters,
|
||||||
|
getIndexes: getIndexesParameters,
|
||||||
|
getMusicDirectory: getMusicDirectoryParameters,
|
||||||
getPlaylist: getPlaylistParameters,
|
getPlaylist: getPlaylistParameters,
|
||||||
getPlaylists: getPlaylistsParameters,
|
getPlaylists: getPlaylistsParameters,
|
||||||
getSong: getSongParameters,
|
getSong: getSongParameters,
|
||||||
@@ -591,12 +637,15 @@ export const ssType = {
|
|||||||
baseResponse,
|
baseResponse,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
directory,
|
||||||
genre,
|
genre,
|
||||||
getAlbum,
|
getAlbum,
|
||||||
getAlbumList2,
|
getAlbumList2,
|
||||||
getArtist,
|
getArtist,
|
||||||
getArtists,
|
getArtists,
|
||||||
getGenres,
|
getGenres,
|
||||||
|
getIndexes,
|
||||||
|
getMusicDirectory,
|
||||||
getPlaylist,
|
getPlaylist,
|
||||||
getPlaylists,
|
getPlaylists,
|
||||||
getSong,
|
getSong,
|
||||||
|
|||||||
@@ -245,6 +245,11 @@ export const sortSongsByFetchedOrder = (
|
|||||||
fetchedIds: string[],
|
fetchedIds: string[],
|
||||||
itemType: LibraryItem,
|
itemType: LibraryItem,
|
||||||
): Song[] => {
|
): 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
|
// Group songs by the fetched ID they belong to
|
||||||
const songsByFetchedId = new Map<string, Song[]>();
|
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" />
|
<Icon icon="playlist" size="md" />
|
||||||
)}
|
)}
|
||||||
{itemType === LibraryItem.GENRE && <Icon icon="genre" size="md" />}
|
{itemType === LibraryItem.GENRE && <Icon icon="genre" size="md" />}
|
||||||
|
{itemType === LibraryItem.FOLDER && <Icon icon="folder" size="md" />}
|
||||||
{!itemType && <Icon icon="library" size="md" />}
|
{!itemType && <Icon icon="library" size="md" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
|
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import styles from './tooltip.module.css';
|
import styles from './tooltip.module.css';
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ export interface TooltipProps extends MantineTooltipProps {}
|
|||||||
|
|
||||||
export const Tooltip = ({
|
export const Tooltip = ({
|
||||||
children,
|
children,
|
||||||
|
classNames,
|
||||||
openDelay = 500,
|
openDelay = 500,
|
||||||
transitionProps = {
|
transitionProps = {
|
||||||
duration: 250,
|
duration: 250,
|
||||||
@@ -18,7 +20,8 @@ export const Tooltip = ({
|
|||||||
<MantineTooltip
|
<MantineTooltip
|
||||||
arrowSize={10}
|
arrowSize={10}
|
||||||
classNames={{
|
classNames={{
|
||||||
tooltip: styles.tooltip,
|
...classNames,
|
||||||
|
tooltip: clsx(styles.tooltip, classNames?.['tooltip']),
|
||||||
}}
|
}}
|
||||||
multiline
|
multiline
|
||||||
openDelay={openDelay}
|
openDelay={openDelay}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export enum LibraryItem {
|
|||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
ALBUM_ARTIST = 'albumArtist',
|
ALBUM_ARTIST = 'albumArtist',
|
||||||
ARTIST = 'artist',
|
ARTIST = 'artist',
|
||||||
|
FOLDER = 'folder',
|
||||||
GENRE = 'genre',
|
GENRE = 'genre',
|
||||||
PLAYLIST = 'playlist',
|
PLAYLIST = 'playlist',
|
||||||
PLAYLIST_SONG = 'playlistSong',
|
PLAYLIST_SONG = 'playlistSong',
|
||||||
@@ -257,6 +258,29 @@ export type EndpointDetails = {
|
|||||||
server: ServerListItem;
|
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 = {
|
export type GainInfo = {
|
||||||
album?: number;
|
album?: number;
|
||||||
track?: number;
|
track?: number;
|
||||||
@@ -1231,6 +1255,7 @@ export type ControllerEndpoint = {
|
|||||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||||
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
||||||
getDownloadUrl: (args: DownloadArgs) => string;
|
getDownloadUrl: (args: DownloadArgs) => string;
|
||||||
|
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
||||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||||
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||||
@@ -1309,6 +1334,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
||||||
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
||||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||||
|
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
||||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||||
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
||||||
getMusicFolderList: (
|
getMusicFolderList: (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export enum DragTarget {
|
|||||||
ALBUM = LibraryItem.ALBUM,
|
ALBUM = LibraryItem.ALBUM,
|
||||||
ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,
|
ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,
|
||||||
ARTIST = LibraryItem.ARTIST,
|
ARTIST = LibraryItem.ARTIST,
|
||||||
|
FOLDER = LibraryItem.FOLDER,
|
||||||
GENERIC = 'generic',
|
GENERIC = 'generic',
|
||||||
GENRE = LibraryItem.GENRE,
|
GENRE = LibraryItem.GENRE,
|
||||||
GRID_ROW = 'gridRow',
|
GRID_ROW = 'gridRow',
|
||||||
@@ -19,6 +20,7 @@ export const DragTargetMap = {
|
|||||||
[LibraryItem.ALBUM]: DragTarget.ALBUM,
|
[LibraryItem.ALBUM]: DragTarget.ALBUM,
|
||||||
[LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST,
|
[LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST,
|
||||||
[LibraryItem.ARTIST]: DragTarget.ARTIST,
|
[LibraryItem.ARTIST]: DragTarget.ARTIST,
|
||||||
|
[LibraryItem.FOLDER]: DragTarget.FOLDER,
|
||||||
[LibraryItem.GENRE]: DragTarget.GENRE,
|
[LibraryItem.GENRE]: DragTarget.GENRE,
|
||||||
[LibraryItem.PLAYLIST]: DragTarget.PLAYLIST,
|
[LibraryItem.PLAYLIST]: DragTarget.PLAYLIST,
|
||||||
[LibraryItem.PLAYLIST_SONG]: DragTarget.SONG,
|
[LibraryItem.PLAYLIST_SONG]: DragTarget.SONG,
|
||||||
|
|||||||
Reference in New Issue
Block a user