add folder browsing support (#315)

This commit is contained in:
jeffvli
2025-12-02 21:30:44 -08:00
parent 355257104d
commit 917bf91583
53 changed files with 2382 additions and 299 deletions
+3 -2
View File
@@ -450,6 +450,9 @@
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
"title": "$t(entity.genre_other)"
},
"folderList": {
"title": "$t(entity.folder_other)"
},
"globalSearch": {
"commands": {
"goToPage": "go to page",
@@ -689,8 +692,6 @@
"gaplessAudio_description": "sets the gapless audio setting for mpv",
"gaplessAudio_optionWeak": "weak (recommended)",
"gaplessAudio": "gapless audio",
"genreBehavior_description": "determines whether clicking on a genre opens by default in track or album list",
"genreBehavior": "genre page default behavior",
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"globalMediaHotkeys": "global media hotkeys",
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
+18
View File
@@ -306,6 +306,24 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getFolder(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolder`,
);
}
return apiController(
'getFolder',
server.type,
)?.({
...args,
apiClientProps: { ...args.apiClientProps, server },
query: mergeMusicFolderId(args.query, server),
});
},
getGenreList(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -116,6 +116,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getFolder: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.folder,
responses: {
200: jfType._response.folderList,
400: jfType._response.error,
},
},
getGenreList: {
method: 'GET',
path: 'musicgenres',
@@ -1,20 +1,26 @@
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
import { z } from 'zod';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
albumListSortMap,
Folder,
genreListSortMap,
InternalControllerEndpoint,
LibraryItem,
Played,
playlistListSortMap,
ServerType,
Song,
SongListSort,
songListSortMap,
SortOrder,
sortOrderMap,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -386,6 +392,213 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
},
getFolder: async ({ apiClientProps, query }) => {
const userId = apiClientProps.server?.userId;
if (!userId) throw new Error('No userId found');
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = query.id === '0';
if (isRootFolderId) {
if (query.musicFolderId) {
// If music folder is provided, directly get the folder
const musicFolderRes = await jfApiClient(apiClientProps).getFolder({
params: {
userId,
},
query: {
ParentId: getLibraryId(query.musicFolderId)!,
},
});
if (musicFolderRes.status !== 200) {
throw new Error('Failed to get music folder list');
}
let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');
if (query.searchTerm) {
items = filter(items, (item) => {
return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
}
const folders = items
.filter((item) => item.Type !== 'Audio')
.map((item) => jfNormalize.folder(item, apiClientProps.server));
const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
return {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: {
folders: sortedFolders,
songs: [],
},
id: query.id,
name: '~',
parentId: undefined,
};
} else {
// Use the root music folder list if no music folder id is provided
const musicFolderRes = await jfApiClient(apiClientProps).getMusicFolderList({
params: {
userId,
},
});
if (musicFolderRes.status !== 200) {
throw new Error('Failed to get music folder list');
}
let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');
if (query.searchTerm) {
items = filter(items, (item) => {
return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
}
const folders = items
.filter((item) => item.Type !== 'Audio')
.map((item) =>
jfNormalize.folder(
item as unknown as z.infer<typeof jfType._response.folder>,
apiClientProps.server,
),
);
const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
return {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: {
folders: sortedFolders,
songs: [],
},
id: query.id,
name: '~',
parentId: undefined,
};
}
}
const folderDetailRes = await jfApiClient(apiClientProps).getFolder({
params: {
userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
ParentId: query.id,
SortBy: query.sortBy
? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'
: 'SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder || SortOrder.ASC],
},
});
if (folderDetailRes.status !== 200) {
throw new Error('Failed to get folder');
}
// Get parent folder info - we'll use the first child's ParentId to infer the folder's parentId
// The folder name will be inferred from the query.id or we can try to get it from a parent query
let parentId: string | undefined;
let folderName = 'Unknown folder';
if (folderDetailRes.body.Items?.length > 0) {
const firstItem = folderDetailRes.body.Items[0];
parentId = firstItem.ParentId;
// Try to get the folder name by querying its parent's children
if (parentId) {
const parentFolderRes = await jfApiClient(apiClientProps).getFolder({
params: {
userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
ParentId: parentId,
},
});
if (parentFolderRes.status === 200) {
const parentFolderItem = parentFolderRes.body.Items?.find(
(item) => item.Id === query.id,
);
if (parentFolderItem) {
folderName = parentFolderItem.Name || 'Unknown folder';
parentId = parentFolderItem.ParentId;
}
}
}
}
const items = folderDetailRes.body.Items || [];
let filteredFolders = items
.filter((item) => item.Type !== 'Audio')
.map((item) => jfNormalize.folder(item, apiClientProps.server));
let filteredSongs = items
.filter(
(item) =>
item.Type === 'Audio' &&
(item as unknown as z.infer<typeof jfType._response.song>).MediaSources,
)
.map((item) =>
jfNormalize.song(
item as unknown as z.infer<typeof jfType._response.song>,
apiClientProps.server,
),
);
if (query.searchTerm) {
const searchTermLower = query.searchTerm.toLowerCase();
filteredFolders = filter(filteredFolders, (f) =>
f.name.toLowerCase().includes(searchTermLower),
);
filteredSongs = filter(filteredSongs, (s) => {
const name = s.name?.toLowerCase() || '';
const album = s.album?.toLowerCase() || '';
const artist = s.artistName?.toLowerCase() || '';
return (
name.includes(searchTermLower) ||
album.includes(searchTermLower) ||
artist.includes(searchTermLower)
);
});
}
filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);
if (filteredSongs.length > 0) {
filteredSongs = sortSongList(
filteredSongs,
query.sortBy || SongListSort.NAME,
query.sortOrder || SortOrder.ASC,
);
}
const folder: Folder = {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: {
folders: filteredFolders,
songs: filteredSongs,
},
id: query.id,
name: folderName,
parentId,
};
return folder;
},
getGenreList: async (args) => {
const { apiClientProps, query } = args;
@@ -393,6 +393,7 @@ export const NavidromeController: InternalControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl,
getFolder: SubsonicController.getFolder,
getGenreList: async (args) => {
const { apiClientProps, query } = args;
+10
View File
@@ -4,6 +4,7 @@ import type {
AlbumDetailQuery,
AlbumListQuery,
ArtistListQuery,
FolderQuery,
GenreListQuery,
LyricSearchQuery,
LyricsQuery,
@@ -224,6 +225,15 @@ export const queryKeys: Record<
},
root: (serverId: string) => [serverId, 'artists'] as const,
},
folders: {
folder: (serverId: string, query?: FolderQuery) => {
if (query) {
return [serverId, 'folders', 'folder', query] as const;
}
return [serverId, 'folders', 'folder'] as const;
},
},
genres: {
count: (serverId: string, query?: GenreListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
+16
View File
@@ -100,6 +100,22 @@ export const contract = c.router({
200: ssType._response.getGenres,
},
},
getIndexes: {
method: 'GET',
path: 'getIndexes.view',
query: ssType._parameters.getIndexes,
responses: {
200: ssType._response.getIndexes,
},
},
getMusicDirectory: {
method: 'GET',
path: 'getMusicDirectory.view',
query: ssType._parameters.getMusicDirectory,
responses: {
200: ssType._response.getMusicDirectory,
},
},
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',
@@ -21,7 +21,9 @@ import {
InternalControllerEndpoint,
LibraryItem,
PlaylistListSort,
ServerType,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerFeatures } from '/@/shared/types/features-types';
@@ -650,6 +652,106 @@ export const SubsonicController: InternalControllerEndpoint = {
'&c=Feishin'
);
},
getFolder: async ({ apiClientProps, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = /^\d+$/.test(query.id);
if (isRootFolderId) {
const res = await ssApiClient(apiClientProps).getIndexes({
query: {
musicFolderId: getLibraryId(query.musicFolderId),
},
});
if (res.status !== 200) {
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
}
let items =
res.body.indexes?.index?.flatMap((idx) =>
idx.artist.map((artist) => ({
artist: artist.name,
id: artist.id.toString(),
isDir: true,
title: artist.name,
})),
) || [];
if (query.searchTerm) {
items = filter(items, (item) => {
return item.title.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
}
let folders = items.map((item) => ssNormalize.folder(item, apiClientProps.server));
folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
return {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
children: {
folders,
songs: [],
},
id: query.id,
name: '~',
parentId: undefined,
};
}
const directoryRes = await ssApiClient(apiClientProps).getMusicDirectory({
query: {
id: query.id,
},
});
if (directoryRes.status !== 200) {
throw new Error('Failed to get folder');
}
const folder = ssNormalize.folder(directoryRes.body.directory, apiClientProps.server);
let filteredFolders = folder.children?.folders || [];
let filteredSongs = folder.children?.songs || [];
if (query.searchTerm) {
const searchTermLower = query.searchTerm.toLowerCase();
filteredFolders = filter(filteredFolders, (f) =>
f.name.toLowerCase().includes(searchTermLower),
);
filteredSongs = filter(filteredSongs, (s) => {
const name = s.name?.toLowerCase() || '';
const album = s.album?.toLowerCase() || '';
const artist = s.artistName?.toLowerCase() || '';
return (
name.includes(searchTermLower) ||
album.includes(searchTermLower) ||
artist.includes(searchTermLower)
);
});
}
filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);
if (filteredSongs.length > 0) {
filteredSongs = sortSongList(
filteredSongs,
query.sortBy || SongListSort.NAME,
query.sortOrder || SortOrder.ASC,
);
}
return {
...folder,
children: {
folders: filteredFolders,
songs: filteredSongs,
},
};
},
getGenreList: async ({ apiClientProps, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
@@ -2,7 +2,7 @@ import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/shared/types/domain-types';
import { Album, AlbumArtist, Artist, Folder, Playlist, Song } from '/@/shared/types/domain-types';
/**
* Type guard to assert that an item has the required properties for dragging
@@ -28,13 +28,13 @@ const hasRequiredDragProperties = (
* Otherwise, select and drag only the current item.
* If internalState is not provided, returns the single item wrapped in an array.
*
* @param data - The item data to drag (Album, AlbumArtist, Artist, Playlist, or Song)
* @param data - The item data to drag (Album, AlbumArtist, Artist, Folder, Playlist, or Song)
* @param internalState - The item list state actions (optional)
* @param updateSelection - Whether to update the selection state (default: true)
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
*/
export const getDraggedItems = (
data: Album | AlbumArtist | Artist | Playlist | Song | undefined,
data: Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined,
internalState?: ItemListStateActions,
updateSelection: boolean = true,
): ItemListStateItemWithRequiredProperties[] => {
@@ -299,10 +299,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
// Use the item's _itemType if available, otherwise fall back to the prop itemType
// This allows mixed lists (e.g., folders + songs) to show the correct context menu
const actualItemType =
(item as any)?._itemType || itemTypeMapping[itemType] || itemType;
// If no internalState, call ContextMenuController directly
if (!internalState) {
return ContextMenuController.call({
cmd: { items: [item] as any[], type: itemType as any },
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
@@ -315,7 +320,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
if (internalState.getSelected().length === 0) {
internalState.setSelected([item]);
return ContextMenuController.call({
cmd: { items: [item] as any[], type: itemType as any },
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
@@ -323,15 +328,21 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
else if (!internalState.isSelected(rowId)) {
internalState.setSelected([item]);
return ContextMenuController.call({
cmd: { items: [item] as any[], type: itemType as any },
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
const selectedItems = internalState.getSelected();
// For multiple selected items, use the itemType prop (assumes all selected items are of the same type)
const selectedItemType =
selectedItems.length > 0 && (selectedItems[0] as any)?._itemType
? (selectedItems[0] as any)._itemType
: actualItemType;
return ContextMenuController.call({
cmd: { items: selectedItems as any[], type: itemType as any },
cmd: { items: selectedItems as any[], type: selectedItemType as any },
event,
});
},
@@ -54,3 +54,8 @@
width: 24px;
height: 24px;
}
.folder-icon {
color: black;
fill: rgb(255 215 100);
}
@@ -10,9 +10,10 @@ import {
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const ImageColumn = (props: ItemTableListInnerColumn) => {
@@ -98,6 +99,14 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
);
}
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
return (
<TableColumnContainer {...props}>
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
</TableColumnContainer>
);
}
return (
<TableColumnContainer {...props}>
<Skeleton containerClassName={styles.skeleton} />
@@ -21,6 +21,7 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
@@ -17,6 +17,7 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
@@ -32,3 +32,8 @@
white-space: nowrap;
user-select: none;
}
.folder-icon {
color: black;
fill: rgb(255 215 100);
}
@@ -12,9 +12,10 @@ import {
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { AppRoute } from '/@/renderer/router/routes';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
@@ -166,6 +167,44 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
);
}
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = props.data[props.rowIndex] as any;
const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};
const titleLinkProps = path
? {
component: Link,
isLink: true,
state: { item },
to: path,
}
: {};
const title = (props.data[props.rowIndex] as unknown as Folder)?.name;
return (
<TableColumnContainer
className={styles.titleCombined}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
<Text
className={styles.title}
isNoSelect
size="md"
{...titleLinkProps}
style={textStyles}
>
{title}
</Text>
</TableColumnContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
@@ -177,6 +216,7 @@ export const TitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
@@ -53,7 +53,7 @@ import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import {
dndUtils,
DragData,
@@ -80,6 +80,7 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
const item = isDataRow ? props.data[props.rowIndex] : null;
const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item;
const itemType = (item as unknown as { _itemType?: LibraryItem })?._itemType || props.itemType;
// Check if this row should render a group header (must be before conditional returns)
// Group headers need to be rendered consistently across all grids (pinned left, main, pinned right)
@@ -239,6 +240,48 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
);
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
props.playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
}
// Handle songs: add directly to queue
if (songs.length > 0) {
props.playerContext.addToQueueByData(songs, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
case DragTarget.GENRE: {
props.playerContext.addToQueueByFetch(
sourceServerId,
@@ -366,65 +409,106 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
);
}
if (itemType !== LibraryItem.FOLDER) {
switch (type) {
case TableColumn.ACTIONS:
case TableColumn.SKIP:
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM:
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM_ARTIST:
return (
<AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.ALBUM_COUNT:
case TableColumn.PLAY_COUNT:
case TableColumn.SONG_COUNT:
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ARTIST:
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIOGRAPHY:
case TableColumn.COMMENT:
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIT_RATE:
case TableColumn.BPM:
case TableColumn.CHANNELS:
case TableColumn.DISC_NUMBER:
case TableColumn.TRACK_NUMBER:
case TableColumn.YEAR:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DATE_ADDED:
case TableColumn.RELEASE_DATE:
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DURATION:
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE:
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE_BADGE:
return (
<GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.IMAGE:
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.LAST_PLAYED:
return (
<RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.PATH:
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ROW_INDEX:
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.SIZE:
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.TITLE:
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.TITLE_COMBINED:
return (
<TitleCombinedColumn
{...props}
{...dragProps}
controls={controls}
type={type}
/>
);
case TableColumn.USER_FAVORITE:
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.USER_RATING:
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
default:
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
}
}
switch (type) {
case TableColumn.ACTIONS:
case TableColumn.SKIP:
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM:
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM_ARTIST:
return <AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM_COUNT:
case TableColumn.PLAY_COUNT:
case TableColumn.SONG_COUNT:
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ARTIST:
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIOGRAPHY:
case TableColumn.COMMENT:
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIT_RATE:
case TableColumn.BPM:
case TableColumn.CHANNELS:
case TableColumn.DISC_NUMBER:
case TableColumn.TRACK_NUMBER:
case TableColumn.YEAR:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DATE_ADDED:
case TableColumn.RELEASE_DATE:
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DURATION:
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE:
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE_BADGE:
return <GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.IMAGE:
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.LAST_PLAYED:
return <RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.PATH:
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ROW_INDEX:
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.SIZE:
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.TITLE:
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -433,14 +517,8 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
<TitleCombinedColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.USER_FAVORITE:
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.USER_RATING:
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
default:
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
return <ColumnNullFallback {...props} {...dragProps} controls={controls} type={type} />;
}
};
+2 -1
View File
@@ -3,6 +3,7 @@ import {
Album,
AlbumArtist,
Artist,
Folder,
LibraryItem,
Playlist,
Song,
@@ -75,7 +76,7 @@ export interface ItemListHandle {
scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
}
export type ItemListItem = Album | AlbumArtist | Artist | Playlist | Song | undefined;
export type ItemListItem = Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined;
export interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> {
autoFitColumns?: boolean;
@@ -9,6 +9,7 @@ import {
getAlbumSongsById,
getGenreSongsById,
getPlaylistSongsById,
getSongsByFolder,
} from '/@/renderer/features/player/utils';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
@@ -97,49 +98,64 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
const getSongsByAlbum = useCallback(
async (albumId: string) => {
if (!server) return null;
return getAlbumSongsById({
id: [albumId],
queryClient,
server,
serverId,
});
},
[queryClient, server],
[queryClient, serverId],
);
const getSongsByArtist = useCallback(
async (artistId: string) => {
if (!server) return null;
return getAlbumArtistSongsById({
id: [artistId],
queryClient,
server,
serverId,
});
},
[queryClient, server],
[queryClient, serverId],
);
const getSongsByGenre = useCallback(
async (genreIds: string[]) => {
if (!server) return null;
return getGenreSongsById({
id: genreIds,
queryClient,
server,
serverId,
});
},
[queryClient, server],
[queryClient, serverId],
);
const getSongsByPlaylist = useCallback(
async (playlistId: string) => {
if (!server) return null;
return getPlaylistSongsById({
id: playlistId,
queryClient,
server,
serverId,
});
},
[queryClient, serverId],
);
const getSongsByFolderLocal = useCallback(
async (folderId: string) => {
if (!server) return null;
const songsResponse = await getSongsByFolder({
id: [folderId],
queryClient,
serverId: server.id,
});
return {
items: songsResponse.items.map((song) => song.id),
startIndex: 0,
totalRecordCount: songsResponse.items.length,
};
},
[queryClient, server],
);
@@ -173,6 +189,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
const songs = await getSongsByPlaylist(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
} else if (itemType === LibraryItem.FOLDER) {
for (const id of items) {
const songs = await getSongsByFolderLocal(id);
allSongIds.push(...(songs?.items || []));
}
}
if (allSongIds.length === 0) {
@@ -213,6 +234,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
addToPlaylistMutation,
getSongsByAlbum,
getSongsByArtist,
getSongsByFolderLocal,
getSongsByGenre,
getSongsByPlaylist,
itemType,
@@ -226,6 +248,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
const modalProps: {
albumId?: string[];
artistId?: string[];
folderId?: string[];
genreId?: string[];
initialSelectedIds?: string[];
playlistId?: string[];
@@ -240,6 +263,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
case LibraryItem.ARTIST:
modalProps.artistId = items;
break;
case LibraryItem.FOLDER:
modalProps.folderId = items;
break;
case LibraryItem.GENRE:
modalProps.genreId = items;
break;
@@ -19,6 +19,8 @@ export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
return 'album';
case LibraryItem.ALBUM_ARTIST:
return 'albumArtist';
case LibraryItem.FOLDER:
return 'folder';
case LibraryItem.PLAYLIST:
return 'playlist';
case LibraryItem.SONG:
@@ -6,6 +6,7 @@ import { useParams } from 'react-router';
import { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';
import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu';
import { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu';
import { FolderContextMenu } from '/@/renderer/features/context-menu/menus/folder-context-menu';
import { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';
import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu';
import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';
@@ -16,6 +17,7 @@ import {
Album,
AlbumArtist,
Artist,
Folder,
Genre,
LibraryItem,
Playlist,
@@ -82,6 +84,7 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
@@ -95,6 +98,7 @@ export type ContextMenuCommand =
| AlbumArtistContextMenuProps
| AlbumContextMenuProps
| ArtistContextMenuProps
| FolderContextMenuProps
| GenreContextMenuProps
| PlaylistContextMenuProps
| PlaylistSongContextMenuProps
@@ -116,6 +120,11 @@ type ArtistContextMenuProps = {
type: LibraryItem.ARTIST;
};
type FolderContextMenuProps = {
items: Folder[];
type: LibraryItem.FOLDER;
};
type GenreContextMenuProps = {
items: Genre[];
type: LibraryItem.GENRE;
@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { ContextMenuPreview } from '/@/shared/components/context-menu/context-menu-preview';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
interface FolderContextMenuProps {
items: Folder[];
type: LibraryItem.FOLDER;
}
export const FolderContextMenu = ({ items, type }: FolderContextMenuProps) => {
const { ids } = useMemo(() => {
const ids = items.map((item) => item.id);
return { ids };
}, [items]);
return (
<ContextMenu.Content
bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}
>
<PlayAction ids={ids} itemType={LibraryItem.FOLDER} />
<ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.FOLDER} />
<ContextMenu.Divider />
<DownloadAction ids={ids} />
<ShareAction ids={ids} itemType={LibraryItem.FOLDER} />
</ContextMenu.Content>
);
};
@@ -0,0 +1,21 @@
import { queryOptions } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { FolderQuery } from '/@/shared/types/domain-types';
export const folderQueries = {
folder: (args: QueryHookArgs<FolderQuery>) => {
return queryOptions({
queryFn: ({ signal }) => {
return api.controller.getFolder({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
});
},
queryKey: queryKeys.folders.folder(args.serverId, args.query),
...args.options,
});
},
};
@@ -0,0 +1,194 @@
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useCallback, useEffect, useMemo } from 'react';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { DefaultItemControlProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
import { FolderTreeBrowser } from '/@/renderer/features/folders/components/folder-tree-browser';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServerId, useListSettings, usePlayerSong } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Folder, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
export const FolderListContent = () => {
return (
<Suspense fallback={<Spinner container />}>
<FolderListInnerContent />
</Suspense>
);
};
export const FolderListInnerContent = () => {
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const { currentFolderId, query } = useFolderListFilters();
const getFolderQueryOptions = useCallback(
(folderId: string) => {
return folderQueries.folder({
query: {
id: folderId,
searchTerm: query[FILTER_KEYS.SHARED.SEARCH_TERM] as string | undefined,
sortBy:
(query[FILTER_KEYS.SHARED.SORT_BY] as SongListSort) || SongListSort.NAME,
sortOrder: (query[FILTER_KEYS.SHARED.SORT_ORDER] as SortOrder) || SortOrder.ASC,
},
serverId,
});
},
[serverId, query],
);
const rootFolderQuery = useQuery({
...getFolderQueryOptions('0'),
staleTime: 1000 * 60 * 5,
});
const currentFolderQuery = useSuspenseQuery({
...getFolderQueryOptions(currentFolderId),
staleTime: 1000 * 60,
});
const fetchFolder = useCallback(
async (folderId: string) => {
const queryOptions = getFolderQueryOptions(folderId);
return queryClient.fetchQuery({
...queryOptions,
staleTime: 1000 * 60 * 5,
});
},
[getFolderQueryOptions, queryClient],
);
return (
<>
<ListWithSidebarContainer.SidebarPortal>
<FolderTreeBrowser fetchFolder={fetchFolder} rootFolderQuery={rootFolderQuery} />
</ListWithSidebarContainer.SidebarPortal>
<FolderListView folderQuery={currentFolderQuery} />
</>
);
};
interface FolderListViewProps {
folderQuery: ReturnType<typeof useSuspenseQuery<Folder>>;
}
export const FolderListView = ({ folderQuery }: FolderListViewProps) => {
const { display, table } = useListSettings(ItemListKey.SONG);
const { setItemCount } = useListContext();
const { navigateToFolder } = useFolderListFilters();
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: true,
});
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.SONG,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.SONG,
});
const allItems = useMemo(() => {
if (!folderQuery.data?.children) {
return [];
}
const { folders = [], songs = [] } = folderQuery.data.children;
return [...folders, ...songs];
}, [folderQuery.data]);
useEffect(() => {
setItemCount?.(allItems.length);
}, [allItems.length, setItemCount]);
const player = usePlayer();
const overrideControls = useMemo(() => {
return {
onDoubleClick: ({ index, internalState, item }: DefaultItemControlProps) => {
if (!item) {
return;
}
if ((item as unknown as Folder)._itemType === LibraryItem.FOLDER) {
const folder = item as unknown as Folder;
return navigateToFolder(folder.id, folder.name);
}
const items = internalState?.getData() as Song[];
const songCount = items.filter(
(item) => item._itemType === LibraryItem.SONG,
).length;
const indexesToSkip = items.length - songCount;
const startIndex = indexesToSkip + (index ?? 0);
player.addToQueueByData(items, Play.NOW);
player.mediaPlayByIndex(startIndex);
},
};
}, [navigateToFolder, player]);
const currentSong = usePlayerSong();
switch (display) {
// case ListDisplayType.GRID: {
// return (
// <ItemGridList
// data={allItems}
// gap={grid.itemGap}
// initialTop={{
// to: scrollOffset ?? 0,
// type: 'offset',
// }}
// itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
// itemType={LibraryItem.FOLDER}
// onScrollEnd={handleOnScrollEnd}
// overrideControls={overrideControls}
// />
// );
// }
case ListDisplayType.TABLE: {
return (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={table.columns}
data={allItems}
enableAlternateRowColors={table.enableAlternateRowColors}
enableDrag={true}
enableExpansion={false}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.FOLDER}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={overrideControls}
size={table.size}
/>
);
}
default:
return null;
}
};
@@ -0,0 +1,264 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { useContainerQuery } from '/@/renderer/hooks';
import { truncateMiddle } from '/@/renderer/utils';
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
const MAX_BREADCRUMB_TEXT_LENGTH = 26;
export const FolderListHeaderFilters = () => {
const { t } = useTranslation();
const { folderPath, navigateToPathIndex, setFolderPath } = useFolderListFilters();
const {
is2xl,
isLg,
isMd,
isSm,
isXl,
isXs,
ref: breadcrumbContainerRef,
} = useContainerQuery();
const maxItems = useMemo(() => {
if (is2xl) return 8;
if (isXl) return 6;
if (isLg) return 4;
if (isMd) return 3;
if (isSm) return 2;
if (isXs) return 2;
return 1;
}, [is2xl, isLg, isMd, isSm, isXl, isXs]);
const allBreadcrumbItems = useMemo(() => {
const items: Array<{
fullLabel: string;
id: string;
label: string;
onClick: () => void;
}> = [];
const homeLabel = t('common.home', { postProcess: 'titleCase' });
items.push({
fullLabel: homeLabel,
id: 'folder-root',
label: homeLabel,
onClick: () => {
setFolderPath([]);
},
});
folderPath.forEach((folder, index) => {
items.push({
fullLabel: folder.name,
id: `folder-${folder.id}`,
label: truncateMiddle(folder.name, MAX_BREADCRUMB_TEXT_LENGTH),
onClick: () => navigateToPathIndex(index),
});
});
return items;
}, [folderPath, navigateToPathIndex, setFolderPath, t]);
const visibleItems = useMemo(() => {
const firstItem = allBreadcrumbItems[0];
if (maxItems === 1) {
return [firstItem];
}
if (allBreadcrumbItems.length <= maxItems) {
return allBreadcrumbItems;
}
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
const middleItems = allBreadcrumbItems.slice(1, -1);
const availableSlots = maxItems - 2;
if (availableSlots <= 0) {
return [firstItem, lastItem];
}
if (middleItems.length <= availableSlots) {
return [firstItem, ...middleItems, lastItem];
}
const startCount = Math.floor(availableSlots / 2);
const endCount = availableSlots - startCount;
const startMiddle = middleItems.slice(0, startCount);
const endMiddle = middleItems.slice(-endCount);
return [firstItem, ...startMiddle, ...endMiddle, lastItem];
}, [allBreadcrumbItems, maxItems]);
const collapsedItems = useMemo(() => {
if (maxItems === 1) {
return allBreadcrumbItems.slice(1);
}
if (allBreadcrumbItems.length <= maxItems) {
return [];
}
const middleItems = allBreadcrumbItems.slice(1, -1);
const availableSlots = maxItems - 2;
if (availableSlots <= 0) {
return middleItems;
}
if (middleItems.length <= availableSlots) {
return [];
}
const startCount = Math.floor(availableSlots / 2);
const endCount = availableSlots - startCount;
const visibleStart = middleItems.slice(0, startCount);
const visibleEnd = middleItems.slice(-endCount);
return middleItems.filter(
(item) => !visibleStart.includes(item) && !visibleEnd.includes(item),
);
}, [allBreadcrumbItems, maxItems]);
const breadcrumbItems = useMemo(() => {
const items: React.ReactNode[] = [];
const firstItem = allBreadcrumbItems[0];
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
const hasCollapsedItems = collapsedItems.length > 0;
const renderDropdown = () => (
<DropdownMenu key="breadcrumb-dropdown" position="bottom-start">
<DropdownMenu.Target>
<Button size="compact-sm" variant="subtle">
<Icon icon="ellipsisHorizontal" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{collapsedItems.map((collapsedItem) => (
<DropdownMenu.Item key={collapsedItem.id} onClick={collapsedItem.onClick}>
{collapsedItem.fullLabel}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
);
if (hasCollapsedItems && maxItems === 1) {
items.push(
<Button
key={firstItem.id}
onClick={firstItem.onClick}
size="compact-sm"
variant="subtle"
>
{firstItem.label}
</Button>,
);
items.push(renderDropdown());
return items;
}
if (hasCollapsedItems) {
const middleItems = allBreadcrumbItems.slice(1, -1);
const availableSlots = maxItems - 2;
const startCount = Math.floor(availableSlots / 2);
const visibleStartMiddle = middleItems.slice(0, startCount);
const visibleEndMiddle = middleItems.slice(-(availableSlots - startCount));
visibleItems.forEach((item, index) => {
items.push(
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
{item.label}
</Button>,
);
if (index < visibleItems.length - 1) {
const nextItem = visibleItems[index + 1];
const isFirstItem = item.id === firstItem.id;
const isLastStartMiddle =
item.id !== firstItem.id &&
item.id !== lastItem.id &&
visibleStartMiddle.length > 0 &&
item.id === visibleStartMiddle[visibleStartMiddle.length - 1].id;
const shouldInsertDropdown =
(isFirstItem && nextItem.id === lastItem.id) ||
(isLastStartMiddle &&
(nextItem.id === lastItem.id ||
(visibleEndMiddle.length > 0 &&
nextItem.id === visibleEndMiddle[0].id)));
if (shouldInsertDropdown) {
items.push(renderDropdown());
}
}
});
} else {
visibleItems.forEach((item) => {
items.push(
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
{item.label}
</Button>,
);
});
}
return items;
}, [visibleItems, collapsedItems, allBreadcrumbItems, maxItems]);
return (
<Stack>
<Flex justify="space-between">
<Group gap="sm" w="100%">
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
itemType={LibraryItem.FOLDER}
listKey={ItemListKey.SONG}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.SONG}
/>
<ListRefreshButton listKey={ItemListKey.SONG} />
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
grid: {
itemsPerPage: { hidden: true },
pagination: { hidden: true },
},
table: {
itemsPerPage: { hidden: true },
pagination: { hidden: true },
},
}}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
</Flex>
<div ref={breadcrumbContainerRef}>
<Breadcrumb separator={<Icon icon="arrowRight" />}>{breadcrumbItems}</Breadcrumb>
</div>
</Stack>
);
};
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useListContext } from '/@/renderer/context/list-context';
import { FolderListHeaderFilters } from '/@/renderer/features/folders/components/folder-list-header-filters';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
interface FolderListHeaderProps {
title?: string;
}
export const FolderListHeader = ({ title }: FolderListHeaderProps) => {
const { t } = useTranslation();
const { itemCount } = useListContext();
const pageTitle = title || t('page.folderList.title', { postProcess: 'titleCase' });
return (
<Stack gap={0}>
<PageHeader>
<LibraryHeaderBar ignoreMaxWidth>
<Stack>
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
</Stack>
<LibraryHeaderBar.Badge isLoading={itemCount === undefined}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<ListSearchInput />
</Group>
</PageHeader>
<FilterBar>
<FolderListHeaderFilters />
</FilterBar>
</Stack>
);
};
@@ -0,0 +1,96 @@
.container {
width: 100%;
height: 100%;
padding: var(--theme-spacing-sm);
}
.row {
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 0 var(--theme-spacing-sm);
cursor: pointer;
user-select: none;
border-radius: var(--theme-radius-md);
transition: background-color 0.15s ease-in-out;
}
.row:hover {
background-color: var(--theme-colors-surface);
}
.row.active {
color: var(--theme-colors-primary-filled);
}
.row.dragging {
opacity: 0.5;
}
.row-content {
display: flex;
gap: var(--theme-spacing-xs);
align-items: center;
width: 100%;
}
.expand-icon-container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.expand-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
color: var(--theme-colors-foreground);
transition: transform 0.2s ease-in-out;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
.expand-icon-placeholder {
display: flex;
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
.folder-icon-container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.folder-icon {
flex-shrink: 0;
color: var(--theme-colors-foreground);
}
.folder-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-md);
color: var(--theme-colors-foreground);
white-space: nowrap;
}
.row.active .folder-name {
font-weight: 500;
color: var(--theme-colors-primary-filled);
}
.tooltip {
padding: var(--theme-spacing-sm) var(--theme-spacing-md) var(--theme-spacing-sm) 0;
font-size: var(--theme-font-size-lg);
font-weight: 500;
}
@@ -0,0 +1,489 @@
import { type UseQueryResult } from '@tanstack/react-query';
import clsx from 'clsx';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { List, RowComponentProps } from 'react-window-v2';
import styles from './folder-tree-browser.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { Icon } from '/@/shared/components/icon/icon';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
interface FlattenedNode {
depth: number;
folder: Folder;
hasChildren: boolean;
isExpanded: boolean;
path: Array<{ id: string; name: string }>;
}
interface TreeNode {
childrenLoaded: boolean;
depth: number;
folder: Folder;
hasChildren: boolean;
isExpanded: boolean;
}
const ITEM_HEIGHT = 32;
const INDENT_SIZE = 16;
interface FolderTreeBrowserProps {
fetchFolder: (folderId: string) => Promise<Folder>;
rootFolderQuery: UseQueryResult<Folder, Error>;
}
export const FolderTreeBrowser = ({ fetchFolder, rootFolderQuery }: FolderTreeBrowserProps) => {
const { currentFolderId, setFolderPath } = useFolderListFilters();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loadedNodes, setLoadedNodes] = useState<Map<string, Folder[]>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
// Initialize root folder children when data is loaded
useEffect(() => {
if (rootFolderQuery.data?.children?.folders && !loadedNodes.has('0')) {
setLoadedNodes((prev) => {
const newMap = new Map(prev);
newMap.set('0', rootFolderQuery.data?.children?.folders || []);
return newMap;
});
}
}, [rootFolderQuery.data, loadedNodes]);
// Fetch folder when expanding a node
const fetchFolderChildren = useCallback(
async (folderId: string) => {
if (loadedNodes.has(folderId)) {
return;
}
try {
const result = await fetchFolder(folderId);
if (result?.children?.folders) {
setLoadedNodes((prev) => {
const newMap = new Map(prev);
const folders = result?.children?.folders || [];
newMap.set(folderId, folders);
return newMap;
});
} else {
// Even if no children, mark as loaded to avoid refetching
setLoadedNodes((prev) => {
const newMap = new Map(prev);
newMap.set(folderId, []);
return newMap;
});
}
} catch {
setLoadedNodes((prev) => {
const newMap = new Map(prev);
newMap.set(folderId, []);
return newMap;
});
}
},
[fetchFolder, loadedNodes],
);
// Get children for a folder
const getFolderChildren = useCallback(
(folder: Folder): Folder[] => {
// First check if we have explicitly loaded children in loadedNodes
const loaded = loadedNodes.get(folder.id);
if (loaded !== undefined) {
return loaded;
}
// Otherwise, use children from the folder object itself (if available)
// This handles cases where children came with the parent folder's response
return folder.children?.folders || [];
},
[loadedNodes],
);
// Build tree structure from root
const buildTree = useCallback(
(folder: Folder, depth: number = 0): TreeNode => {
const folderId = folder.id;
const isExpanded = expandedNodes.has(folderId);
const children = getFolderChildren(folder);
const hasChildren = children.length > 0;
const childrenLoaded =
loadedNodes.has(folderId) || (folder.children?.folders?.length ?? 0) > 0;
return {
childrenLoaded,
depth,
folder,
hasChildren,
isExpanded,
};
},
[expandedNodes, loadedNodes, getFolderChildren],
);
// Flatten tree to list for virtualization
const flattenedNodes = useMemo((): FlattenedNode[] => {
if (!rootFolderQuery.data) {
return [];
}
const result: FlattenedNode[] = [];
const rootFolder = rootFolderQuery.data;
const traverse = (
folder: Folder,
depth: number,
path: Array<{ id: string; name: string }> = [],
) => {
const node = buildTree(folder, depth);
const currentPath = [...path, { id: folder.id, name: folder.name }];
const isRoot = folder.id === '0';
// Skip the root folder (id: '0')
if (!isRoot) {
result.push({
depth: node.depth - 1,
folder: node.folder,
hasChildren: node.hasChildren,
isExpanded: node.isExpanded,
path: currentPath,
});
}
// For root folder, always traverse children
const shouldTraverseChildren = isRoot
? node.hasChildren
: node.isExpanded && node.hasChildren;
if (shouldTraverseChildren) {
const children = getFolderChildren(folder);
// Recursively traverse each child - this supports infinite nesting
children.forEach((child) => {
traverse(child, depth + 1, currentPath);
});
}
};
traverse(rootFolder, 0);
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootFolderQuery.data, expandedNodes, loadedNodes, buildTree, getFolderChildren]);
const toggleNode = useCallback(
(folderId: string, hasChildren: boolean, folder?: Folder) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(folderId)) {
newSet.delete(folderId);
} else {
newSet.add(folderId);
// Fetch children if not loaded and has children
// Check both loadedNodes and folder.children to determine if we need to fetch
const needsFetch =
hasChildren &&
!loadedNodes.has(folderId) &&
!(folder?.children?.folders && folder.children.folders.length > 0);
if (needsFetch) {
fetchFolderChildren(folderId);
}
}
return newSet;
});
},
[fetchFolderChildren, loadedNodes],
);
// Expand a node (doesn't collapse if already expanded)
const expandNode = useCallback(
(folderId: string, hasChildren: boolean, folder?: Folder) => {
setExpandedNodes((prev) => {
if (prev.has(folderId)) {
return prev;
}
// Expand the node
const newSet = new Set(prev);
newSet.add(folderId);
// Fetch children if not loaded and has children
const needsFetch =
hasChildren &&
!loadedNodes.has(folderId) &&
!(folder?.children?.folders && folder.children.folders.length > 0);
if (needsFetch) {
fetchFolderChildren(folderId);
}
return newSet;
});
},
[fetchFolderChildren, loadedNodes],
);
// Handle node click - toggle expand/collapse and set current folder
const handleNodeClick = useCallback(
(
folder: Folder,
path: Array<{ id: string; name: string }>,
isExpanded: boolean,
isCurrentFolder: boolean,
) => {
// Only toggle close if the node is expanded AND it's the current folder
if (isExpanded && isCurrentFolder) {
toggleNode(folder.id, true, folder);
} else if (!isExpanded) {
// Node is not expanded - check if we should expand it
const childrenLoaded = loadedNodes.has(folder.id);
const hasChildrenFromFolder = (folder.children?.folders?.length ?? 0) > 0;
// Determine if we should expand:
// - If children are loaded and empty, don't expand (we know it has no children)
// - Otherwise, try to expand/fetch (either has children or we don't know yet)
let shouldExpand = false;
let mightHaveChildren = false;
if (childrenLoaded) {
// Children are loaded - check if there are any
const loadedChildren = loadedNodes.get(folder.id) || [];
shouldExpand = loadedChildren.length > 0;
mightHaveChildren = loadedChildren.length > 0;
} else {
// Children not loaded yet - assume it might have children and try to expand
shouldExpand = true;
mightHaveChildren = true;
}
// Override with folder's children if available (from parent response)
if (hasChildrenFromFolder) {
shouldExpand = true;
mightHaveChildren = true;
}
if (shouldExpand) {
expandNode(folder.id, mightHaveChildren, folder);
}
}
// Set current folder path (full path from root to clicked folder)
// Skip the root folder (id: '0') from the path
const pathWithoutRoot = path.filter((item) => item.id !== '0');
setFolderPath(pathWithoutRoot);
},
[expandNode, loadedNodes, setFolderPath, toggleNode],
);
const rowProps = useMemo(
() => ({
currentFolderId,
data: flattenedNodes,
handleNodeClick,
toggleNode,
}),
[currentFolderId, flattenedNodes, handleNodeClick, toggleNode],
);
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
},
},
options: {
overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
visibility: 'visible',
},
},
});
useEffect(() => {
const { current: container } = containerRef;
if (!container || !container.firstElementChild) {
return;
}
const viewport = container.firstElementChild as HTMLElement;
initialize({
elements: { viewport },
target: container,
});
return () => osInstance()?.destroy();
}, [initialize, osInstance]);
return (
<div className={styles.container} ref={containerRef}>
<List
rowComponent={RowComponent}
rowCount={flattenedNodes.length}
rowHeight={ITEM_HEIGHT}
rowProps={rowProps}
/>
</div>
);
};
const RowComponent = ({
currentFolderId,
data,
handleNodeClick,
index,
style,
toggleNode,
}: RowComponentProps<{
currentFolderId: null | string;
data: FlattenedNode[];
handleNodeClick: (
folder: Folder,
path: Array<{ id: string; name: string }>,
isExpanded: boolean,
isCurrentFolder: boolean,
) => void;
toggleNode: (folderId: string, hasChildren: boolean, folder?: Folder) => void;
}>) => {
const item = data[index];
const folderNameRef = useRef<HTMLSpanElement>(null);
const folderIconRef = useRef<HTMLDivElement>(null);
const expandIconRef = useRef<HTMLDivElement | null>(null);
const rowRef = useRef<HTMLDivElement>(null);
const [tooltipOffset, setTooltipOffset] = useState(0);
useLayoutEffect(() => {
if (!item) {
return;
}
const calculateOffset = () => {
if (rowRef.current && folderIconRef.current && expandIconRef.current) {
const width = rowRef.current.offsetWidth;
const paddingLeft = item.depth * INDENT_SIZE;
const folderIconWidth = folderIconRef.current.offsetWidth;
const expandIconWidth = expandIconRef.current.offsetWidth;
const itemPadding = 8;
setTooltipOffset(
-width + paddingLeft + folderIconWidth + expandIconWidth + itemPadding,
);
}
};
calculateOffset();
const handleResize = () => {
calculateOffset();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [item]);
const { isDragging, ref: dragRef } = useDragDrop<HTMLDivElement>({
drag: {
getId: () => (item ? [item.folder.id] : []),
getItem: () => (item ? [item.folder] : []),
itemType: LibraryItem.FOLDER,
operation: [DragOperation.ADD],
target: DragTarget.FOLDER,
},
isEnabled: !!item,
});
// Use dragRef for the element and also update rowRef for tooltip calculations
useEffect(() => {
if (dragRef && 'current' in dragRef && dragRef.current) {
rowRef.current = dragRef.current;
}
}, [dragRef]);
if (!item) {
return <div style={style} />;
}
const isActive = currentFolderId === item.folder.id;
const paddingLeft = item.depth * INDENT_SIZE;
const handleExpandClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleNode(item.folder.id, item.hasChildren, item.folder);
};
const handleRowClick = () => {
handleNodeClick(item.folder, item.path, item.isExpanded, isActive);
};
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
ContextMenuController.call({
cmd: {
items: [item.folder],
type: LibraryItem.FOLDER,
},
event: e,
});
};
return (
<Tooltip
classNames={{
tooltip: styles.tooltip,
}}
label={item.folder.name}
offset={tooltipOffset}
openDelay={0}
position="right"
withArrow={false}
>
<div
className={clsx(styles.row, {
[styles.active]: isActive,
[styles.dragging]: isDragging,
})}
onClick={handleRowClick}
onContextMenu={handleContextMenu}
ref={dragRef}
style={{
...style,
paddingLeft: `${paddingLeft}px`,
}}
>
<div className={styles.rowContent}>
{item.hasChildren ? (
<div className={styles.expandIconContainer} ref={expandIconRef}>
<Icon
className={clsx(styles.expandIcon, {
[styles.expanded]: item.isExpanded,
})}
icon="arrowRightS"
onClick={handleExpandClick}
size="sm"
/>
</div>
) : (
<div className={styles.expandIconPlaceholder} ref={expandIconRef} />
)}
<div className={styles.folderIconContainer} ref={folderIconRef}>
<Icon className={styles.folderIcon} icon="folder" size="md" />
</div>
<span className={styles.folderName} ref={folderNameRef}>
{item.folder.name}
</span>
</div>
</div>
</Tooltip>
);
};
@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
import { SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export type FolderPathItem = {
id: string;
name: string;
};
export const useFolderListFilters = () => {
const { sortBy } = useSortByFilter<SongListSort>(null, ItemListKey.SONG);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
const [searchParams, setSearchParams] = useSearchParams();
const folderPath = useMemo(() => {
const path = parseJsonParam<FolderPathItem[]>(searchParams, FILTER_KEYS.FOLDER.FOLDER_PATH);
return path || [];
}, [searchParams]);
const setFolderPath = (path: FolderPathItem[]) => {
setSearchParams(
(prev) => {
const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);
return newParams;
},
{ replace: false },
);
};
// Navigate to a folder (adds to path)
const navigateToFolder = (folderId: string, folderName: string) => {
setFolderPath([...folderPath, { id: folderId, name: folderName }]);
};
// Navigate back to a specific folder in the path (truncates path)
const navigateToPathIndex = (index: number) => {
setFolderPath(folderPath.slice(0, index + 1));
};
// Get current folder ID (last item in path, or '0' for root)
const currentFolderId = useMemo(() => {
return folderPath.length > 0 ? folderPath[folderPath.length - 1].id : '0';
}, [folderPath]);
const query = {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
};
return {
currentFolderId,
folderPath,
navigateToFolder,
navigateToPathIndex,
query,
setFolderPath,
setSearchTerm,
};
};
@@ -0,0 +1,45 @@
import { useMemo, useState } from 'react';
import { ListContext } from '/@/renderer/context/list-context';
import { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';
import { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { ItemListKey } from '/@/shared/types/types';
const FolderListRoute = () => {
const pageKey = ItemListKey.SONG;
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const providerValue = useMemo(() => {
return {
id: undefined,
itemCount,
pageKey,
setItemCount,
};
}, [itemCount, pageKey, setItemCount]);
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<FolderListHeader />
<ListWithSidebarContainer>
<FolderListContent />
</ListWithSidebarContainer>
</ListContext.Provider>
</AnimatedPage>
);
};
const FolderListRouteWithBoundary = () => {
return (
<PageErrorBoundary>
<FolderListRoute />
</PageErrorBoundary>
);
};
export default FolderListRouteWithBoundary;
@@ -29,7 +29,7 @@ import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, Play, PlayerQueueType } from '/@/shared/types/types';
@@ -248,6 +248,45 @@ const EmptyQueueDropZone = () => {
}
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
Play.NOW,
);
}
// Handle songs: add directly to queue
if (songs.length > 0) {
playerContext.addToQueueByData(songs, Play.NOW);
}
break;
}
case DragTarget.GENRE: {
if (sourceServerId) {
playerContext.addToQueueByFetch(
@@ -7,6 +7,13 @@ import { useTranslation } from 'react-i18next';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import {
getAlbumArtistSongsById,
getAlbumSongsById,
getGenreSongsById,
getPlaylistSongsById,
getSongsByFolder,
} from '/@/renderer/features/player/utils';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
@@ -28,9 +35,6 @@ import {
PlaylistSongListResponse,
QueueSong,
Song,
SongListResponse,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
@@ -911,88 +915,57 @@ export async function fetchSongsByItemType(
switch (args.itemType) {
case LibraryItem.ALBUM: {
const promises: Promise<SongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery({
...songsQueries.list({
query: {
albumIds: [id],
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...args.params,
},
serverId: serverId,
}),
gcTime: 0,
staleTime: 0,
}),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
const albumSongsResponse = await getAlbumSongsById({
id: args.id,
query: args.params,
queryClient,
serverId,
});
songs.push(...albumSongsResponse.items);
break;
}
case LibraryItem.ALBUM_ARTIST: {
const albumArtistSongsResponse = await getAlbumArtistSongsById({
id: args.id,
query: args.params,
queryClient,
serverId,
});
songs.push(...albumArtistSongsResponse.items);
break;
}
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST: {
const promises: Promise<SongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [id],
limit: -1,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...args.params,
},
serverId: serverId,
}),
gcTime: 0,
staleTime: 0,
}),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
const artistSongsResponse = await getAlbumArtistSongsById({
id: args.id,
query: args.params,
queryClient,
serverId,
});
songs.push(...artistSongsResponse.items);
break;
}
case LibraryItem.FOLDER: {
const folderSongsResponse = await getSongsByFolder({
id: args.id,
query: args.params,
queryClient,
serverId,
});
songs.push(...folderSongsResponse.items);
break;
}
case LibraryItem.GENRE: {
const promises: Promise<SongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery({
...songsQueries.list({
query: {
genreIds: [id],
limit: -1,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...args.params,
},
serverId: serverId,
}),
gcTime: 0,
staleTime: 0,
}),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
const genreSongsResponse = await getGenreSongsById({
id: args.id,
query: args.params,
queryClient,
serverId,
});
songs.push(...genreSongsResponse.items);
break;
}
@@ -1001,22 +974,16 @@ export async function fetchSongsByItemType(
for (const id of args.id) {
promises.push(
queryClient.fetchQuery({
...playlistsQueries.songList({
query: {
id: id,
...args.params,
},
serverId: serverId,
}),
gcTime: 0,
staleTime: 0,
getPlaylistSongsById({
id,
query: args.params,
queryClient,
serverId,
}),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
break;
}
+84 -29
View File
@@ -2,11 +2,12 @@ import { QueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
import { sortSongList } from '/@/shared/api/utils';
import {
PlaylistSongListQuery,
PlaylistSongListQueryClientSide,
ServerListItem,
Song,
SongDetailQuery,
SongListQuery,
SongListResponse,
@@ -18,22 +19,22 @@ export const getPlaylistSongsById = async (args: {
id: string;
query?: Partial<PlaylistSongListQueryClientSide>;
queryClient: QueryClient;
server: ServerListItem;
serverId: string;
}) => {
const { id, query, queryClient, server } = args;
const { id, query, queryClient, serverId } = args;
const queryFilter: PlaylistSongListQuery = {
id,
};
const queryKey = queryKeys.playlists.songList(server?.id, id);
const queryKey = queryKeys.playlists.songList(serverId, id);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -58,9 +59,9 @@ export const getAlbumSongsById = async (args: {
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
serverId: string;
}) => {
const { id, query, queryClient, server } = args;
const { id, query, queryClient, serverId } = args;
const queryFilter: SongListQuery = {
albumIds: id,
@@ -70,14 +71,14 @@ export const getAlbumSongsById = async (args: {
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(serverId, queryFilter);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -94,9 +95,9 @@ export const getGenreSongsById = async (args: {
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: null | ServerListItem;
serverId: string;
}) => {
const { id, query, queryClient, server } = args;
const { id, query, queryClient, serverId } = args;
const data: SongListResponse = {
items: [],
@@ -112,14 +113,14 @@ export const getGenreSongsById = async (args: {
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(serverId, queryFilter);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -142,9 +143,9 @@ export const getAlbumArtistSongsById = async (args: {
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
serverId: string;
}) => {
const { id, query, queryClient, server } = args;
const { id, query, queryClient, serverId } = args;
const queryFilter: SongListQuery = {
albumArtistIds: id || [],
@@ -154,14 +155,14 @@ export const getAlbumArtistSongsById = async (args: {
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(serverId, queryFilter);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -177,9 +178,9 @@ export const getArtistSongsById = async (args: {
id: string[];
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
serverId: string;
}) => {
const { id, query, queryClient, server } = args;
const { id, query, queryClient, serverId } = args;
const queryFilter: SongListQuery = {
artistIds: id,
@@ -189,14 +190,14 @@ export const getArtistSongsById = async (args: {
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(serverId, queryFilter);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -211,9 +212,9 @@ export const getArtistSongsById = async (args: {
export const getSongsByQuery = async (args: {
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
serverId: string;
}) => {
const { query, queryClient, server } = args;
const { query, queryClient, serverId } = args;
const queryFilter: SongListQuery = {
sortBy: SongListSort.ALBUM,
@@ -222,14 +223,14 @@ export const getSongsByQuery = async (args: {
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(serverId, queryFilter);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) => {
return api.controller.getSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -242,23 +243,77 @@ export const getSongsByQuery = async (args: {
return res;
};
export const getSongsByFolder = async (args: {
id: string[];
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
serverId: string;
}) => {
const { id, queryClient, serverId } = args;
const collectSongsFromFolder = async (folderId: string): Promise<Song[]> => {
const folderSongs: Song[] = [];
const folder = await queryClient.fetchQuery({
...folderQueries.folder({
query: {
id: folderId,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
},
serverId,
}),
gcTime: 0,
staleTime: 0,
});
if (folder.children?.songs) {
folderSongs.push(...folder.children.songs);
}
if (folder.children?.folders) {
for (const subFolder of folder.children.folders) {
const subFolderSongs = await collectSongsFromFolder(subFolder.id);
folderSongs.push(...subFolderSongs);
}
}
return folderSongs;
};
const data: SongListResponse = {
items: [],
startIndex: 0,
totalRecordCount: 0,
};
// Process folders sequentially to maintain order
for (const folderId of id) {
const folderSongs = await collectSongsFromFolder(folderId);
data.items.push(...folderSongs);
data.totalRecordCount = (data.totalRecordCount || 0) + folderSongs.length;
}
return data;
};
export const getSongById = async (args: {
id: string;
queryClient: QueryClient;
server: ServerListItem;
serverId: string;
}): Promise<SongListResponse> => {
const { id, queryClient, server } = args;
const { id, queryClient, serverId } = args;
const queryFilter: SongDetailQuery = { id };
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
const queryKey = queryKeys.songs.detail(serverId, queryFilter);
const res = await queryClient.fetchQuery({
gcTime: 1000 * 60,
queryFn: async ({ signal }) =>
api.controller.getSongDetail({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: queryFilter,
@@ -7,11 +7,17 @@ import styles from './add-to-playlist-context-modal.module.css';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { getGenreSongsById } from '/@/renderer/features/player/utils';
import {
getAlbumSongsById,
getArtistSongsById,
getGenreSongsById,
getPlaylistSongsById,
getSongsByFolder,
} from '/@/renderer/features/player/utils';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { useCurrentServerId } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button';
@@ -31,13 +37,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import {
Playlist,
PlaylistListSort,
SongListQuery,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
export const AddToPlaylistContextModal = ({
id,
@@ -45,14 +45,16 @@ export const AddToPlaylistContextModal = ({
}: ContextModalProps<{
albumId?: string[];
artistId?: string[];
folderId?: string[];
genreId?: string[];
initialSelectedIds?: string[];
playlistId?: string[];
songId?: string[];
}>) => {
const { t } = useTranslation();
const { albumId, artistId, genreId, initialSelectedIds, playlistId, songId } = innerProps;
const server = useCurrentServer();
const { albumId, artistId, folderId, genreId, initialSelectedIds, playlistId, songId } =
innerProps;
const serverId = useCurrentServerId();
const [isLoading, setIsLoading] = useState(false);
const [search, setSearch] = useState<string>('');
const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);
@@ -81,7 +83,7 @@ export const AddToPlaylistContextModal = ({
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
serverId,
}),
);
@@ -109,78 +111,35 @@ export const AddToPlaylistContextModal = ({
const getSongsByAlbum = useCallback(
async (albumId: string) => {
const query: SongListQuery = {
albumIds: [albumId],
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getSongList({
apiClientProps: { serverId: server?.id || '', signal },
query,
});
},
queryKey,
return getAlbumSongsById({
id: [albumId],
queryClient,
serverId,
});
return songsRes;
},
[server],
[serverId],
);
const getSongsByArtist = useCallback(
async (artistId: string) => {
const query: SongListQuery = {
artistIds: [artistId],
sortBy: SongListSort.ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getSongList({
apiClientProps: { serverId: server?.id || '', signal },
query,
});
},
queryKey,
return getArtistSongsById({
id: [artistId],
queryClient,
serverId,
});
return songsRes;
},
[server],
[serverId],
);
const getSongsByPlaylist = useCallback(
async (playlistId: string) => {
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
const songsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getPlaylistSongList({
apiClientProps: { serverId: server?.id || '', signal },
query: {
id: playlistId,
},
});
},
queryKey,
return getPlaylistSongsById({
id: playlistId,
queryClient,
serverId,
});
return songsRes;
},
[server],
[serverId],
);
const handleSubmit = form.onSubmit(async (values) => {
@@ -211,12 +170,21 @@ export const AddToPlaylistContextModal = ({
const songs = await getGenreSongsById({
id: genreId,
queryClient,
server,
serverId,
});
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
if (folderId && folderId.length > 0) {
const songs = await getSongsByFolder({
id: folderId,
queryClient,
serverId,
});
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
if (playlistId && playlistId.length > 0) {
for (const id of playlistId) {
const songs = await getSongsByPlaylist(id);
@@ -234,7 +202,7 @@ export const AddToPlaylistContextModal = ({
for (const playlist of values.newPlaylists) {
try {
const response = await api.controller.createPlaylist({
apiClientProps: { serverId: server?.id || '' },
apiClientProps: { serverId },
body: {
name: playlist,
public: false,
@@ -257,19 +225,13 @@ export const AddToPlaylistContextModal = ({
const uniqueSongIds: string[] = [];
if (values.skipDuplicates) {
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
if (!server)
throw new Error(
t('error.serverNotSelectedError', {
postProcess: 'sentenceCase',
}),
);
return api.controller.getPlaylistSongList({
apiClientProps: {
serverId: server?.id || '',
serverId,
signal,
},
query: {
@@ -291,13 +253,9 @@ export const AddToPlaylistContextModal = ({
}
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
if (!server) {
setIsLoading(false);
return;
}
addToPlaylistMutation.mutate(
{
apiClientProps: { serverId: server.id },
apiClientProps: { serverId },
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
query: { id: playlistId },
},
@@ -444,6 +444,35 @@ const SONG_LIST_FILTERS: Partial<
],
};
const FOLDER_LIST_FILTERS: Partial<
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
> = {
[ServerType.JELLYFIN]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
...(SONG_LIST_FILTERS[ServerType.JELLYFIN] || []),
],
[ServerType.NAVIDROME]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
...(SONG_LIST_FILTERS[ServerType.NAVIDROME] || []),
],
[ServerType.SUBSONIC]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
...(SONG_LIST_FILTERS[ServerType.SUBSONIC] || []),
],
};
const PLAYLIST_SONG_LIST_FILTERS: Partial<
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
> = {
@@ -715,6 +744,7 @@ const FILTERS: Partial<Record<LibraryItem, any>> = {
[LibraryItem.ALBUM]: ALBUM_LIST_FILTERS,
[LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS,
[LibraryItem.ARTIST]: ARTIST_LIST_FILTERS,
[LibraryItem.FOLDER]: FOLDER_LIST_FILTERS,
[LibraryItem.GENRE]: GENRE_LIST_FILTERS,
[LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS,
[LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS,
+5
View File
@@ -77,9 +77,14 @@ enum PlaylistFilterKeys {
CUSTOM = '_custom',
}
enum FolderFilterKeys {
FOLDER_PATH = 'folderPath',
}
export const FILTER_KEYS = {
ALBUM: AlbumFilterKeys,
ARTIST: ArtistFilterKeys,
FOLDER: FolderFilterKeys,
PAGINATION: PaginationFilterKeys,
PLAYLIST: PlaylistFilterKeys,
SHARED: SharedFilterKeys,
@@ -92,6 +92,7 @@ const PlaylistRowButton = ({ item, name, onContextMenu, onPlay, to }: PlaylistRo
const modalProps: {
albumId?: string[];
artistId?: string[];
folderId?: string[];
genreId?: string[];
initialSelectedIds?: string[];
playlistId?: string[];
@@ -108,6 +109,9 @@ const PlaylistRowButton = ({ item, name, onContextMenu, onPlay, to }: PlaylistRo
case LibraryItem.ARTIST:
modalProps.artistId = sourceIds;
break;
case LibraryItem.FOLDER:
modalProps.folderId = sourceIds;
break;
case LibraryItem.GENRE:
modalProps.genreId = sourceIds;
break;
@@ -47,6 +47,7 @@ export const Sidebar = () => {
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
+3 -2
View File
@@ -7,13 +7,14 @@ interface UseContainerQueryProps {
md?: number;
sm?: number;
xl?: number;
xs?: number;
}
export const useContainerQuery = (props?: UseContainerQueryProps) => {
const { '2xl': xxl, '3xl': xxxl, lg, md, sm, xl } = props || {};
const { '2xl': xxl, '3xl': xxxl, lg, md, sm, xl, xs } = props || {};
const { height, ref, width } = useElementSize();
const isXs = width >= 0;
const isXs = width >= (xs || 360);
const isSm = width >= (sm || 600);
const isMd = width >= (md || 768);
const isLg = width >= (lg || 1200);
+8
View File
@@ -68,6 +68,10 @@ const GenreDetailRoute = lazy(
() => import('/@/renderer/features/genres/routes/genre-detail-route'),
);
const FolderListRoute = lazy(
() => import('/@/renderer/features/folders/routes/folder-list-route'),
);
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
const FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route'));
@@ -142,6 +146,10 @@ export const AppRouter = () => {
element={<SongListRoute />}
path={AppRoute.LIBRARY_SONGS}
/>
<Route
element={<FolderListRoute />}
path={AppRoute.LIBRARY_FOLDERS}
/>
<Route
element={<PlaylistListRoute />}
path={AppRoute.PLAYLISTS}
+16 -1
View File
@@ -565,6 +565,12 @@ export const sidebarItems: SidebarItemType[] = [
label: i18n.t('page.sidebar.genres'),
route: AppRoute.LIBRARY_GENRES,
},
{
disabled: false,
id: 'Folders',
label: i18n.t('page.sidebar.folders'),
route: AppRoute.LIBRARY_FOLDERS,
},
{
disabled: true,
id: 'Playlists',
@@ -1333,10 +1339,19 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
return {};
}
if (version <= 12) {
state.general.sidebarItems.push({
disabled: false,
id: 'Folders',
label: i18n.t('page.sidebar.folders'),
route: AppRoute.LIBRARY_FOLDERS,
});
}
return persistedState;
},
name: 'store_settings',
version: 12,
version: 13,
},
),
);
+1
View File
@@ -8,3 +8,4 @@ export * from './rgb-to-rgba';
export * from './sentence-case';
export * from './set-local-storage-setttings';
export * from './title-case';
export * from './truncate-middle';
+12
View File
@@ -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}`;
};
+16 -11
View File
@@ -4,6 +4,7 @@ import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import {
Album,
AlbumArtist,
Folder,
Genre,
LibraryItem,
MusicFolder,
@@ -496,17 +497,20 @@ const normalizeGenre = (
};
};
// const normalizeFolder = (item: any) => {
// return {
// created: item.DateCreated,
// id: item.Id,
// image: getCoverArtUrl(item, 150),
// isDir: true,
// title: item.Name,
// type: Item.Folder,
// uniqueId: nanoid(),
// };
// };
const normalizeFolder = (
item: z.infer<typeof jfType._response.folder>,
server: null | ServerListItem,
): Folder => {
return {
_itemType: LibraryItem.FOLDER,
_serverId: server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: undefined,
id: item.Id,
name: item.Name || 'Unknown folder',
parentId: item.ParentId,
};
};
// const normalizeScanStatus = () => {
// return {
@@ -518,6 +522,7 @@ const normalizeGenre = (
export const jfNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
folder: normalizeFolder,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
+33 -1
View File
@@ -487,6 +487,7 @@ const song = z.object({
MediaType: z.string(),
Name: z.string(),
NormalizationGain: z.number().optional(),
ParentId: z.string().optional(),
ParentIndexNumber: z.number(),
People: participant.array().optional(),
PlaylistItemId: z.string().optional(),
@@ -495,7 +496,7 @@ const song = z.object({
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SortName: z.string(),
SortName: z.string().optional(),
Tags: z.string().array().optional(),
Type: z.string(),
UserData: userData.optional(),
@@ -772,6 +773,34 @@ const filters = z.object({
Years: z.number().array().optional(),
});
const folder = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
CollectionType: z.string(),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
MediaType: z.string(),
Name: z.string(),
ParentId: z.string().optional(),
ServerId: z.string(),
Type: z.string(),
UserData: userData.optional(),
});
const folderList = pagination.extend({
Items: z.array(folder),
});
const folderParameters = z.object({
Fields: z.string().optional(),
ParentId: z.string().optional(),
SortBy: z.string().optional(),
SortOrder: z.enum(sortOrderValues).optional(),
});
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@@ -794,6 +823,7 @@ export const jfType = {
deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters,
filterList: filterListParameters,
folder: folderParameters,
genreList: genreListParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
@@ -819,6 +849,8 @@ export const jfType = {
error,
favorite,
filters,
folder,
folderList,
genre,
genreList,
lyrics,
@@ -5,6 +5,7 @@ import {
Album,
AlbumArtist,
ExplicitStatus,
Folder,
Genre,
LibraryItem,
Playlist,
@@ -342,9 +343,48 @@ const normalizeGenre = (
};
};
const normalizeFolder = (
item: z.infer<typeof ssType._response.directory>,
server?: null | ServerListItemWithCredential,
): Folder => {
const results = item.child?.reduce(
(acc: { folders: Folder[]; songs: Song[] }, item) => {
const isDirectory = item.isDir === true;
if (isDirectory) {
const folder = normalizeFolder(item, server);
acc.folders.push(folder);
} else {
const song = normalizeSong(item, server);
acc.songs.push(song);
}
return acc;
},
{
folders: [],
songs: [],
},
);
return {
_itemType: LibraryItem.FOLDER,
_serverId: server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
children: {
folders: results?.folders || [],
songs: results?.songs || [],
},
id: item.id.toString(),
name: item.title,
parentId: item.parent,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
folder: normalizeFolder,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,
+49
View File
@@ -548,6 +548,50 @@ const albumInfo = z.object({
}),
});
const getMusicDirectoryParameters = z.object({
id: z.string(),
});
const directory = z.object({
artist: z.string().optional(),
child: z.array(song).optional(),
coverArt: z.string().optional(),
id,
isDir: z.boolean(),
parent: z.string().optional(),
title: z.string(),
});
const getMusicDirectory = z.object({
directory,
});
const getIndexes = z.object({
indexes: z.object({
child: z.array(song),
index: z
.object({
artist: z
.object({
id: z.string(),
name: z.string(),
})
.array(),
})
.array(),
shortcut: z
.object({
id: z.string(),
name: z.string(),
})
.array(),
}),
});
const getIndexesParameters = z.object({
musicFolderId: z.string().optional(),
});
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
@@ -563,6 +607,8 @@ export const ssType = {
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
getIndexes: getIndexesParameters,
getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
@@ -591,12 +637,15 @@ export const ssType = {
baseResponse,
createFavorite,
createPlaylist,
directory,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
getIndexes,
getMusicDirectory,
getPlaylist,
getPlaylists,
getSong,
+5
View File
@@ -245,6 +245,11 @@ export const sortSongsByFetchedOrder = (
fetchedIds: string[],
itemType: LibraryItem,
): Song[] => {
// For folders, songs are already in the correct order
if (itemType === LibraryItem.FOLDER) {
return songs;
}
// Group songs by the fetched ID they belong to
const songsByFetchedId = new Map<string, Song[]>();
@@ -0,0 +1,10 @@
import {
Breadcrumbs as MantineBreadcrumbs,
BreadcrumbsProps as MantineBreadcrumbsProps,
} from '@mantine/core';
interface BreadcrumbProps extends MantineBreadcrumbsProps {}
export const Breadcrumb = ({ children, ...props }: BreadcrumbProps) => {
return <MantineBreadcrumbs {...props}>{children}</MantineBreadcrumbs>;
};
@@ -67,6 +67,7 @@ export const ContextMenuPreview = memo(({ items, itemType }: ContextMenuPreviewP
<Icon icon="playlist" size="md" />
)}
{itemType === LibraryItem.GENRE && <Icon icon="genre" size="md" />}
{itemType === LibraryItem.FOLDER && <Icon icon="folder" size="md" />}
{!itemType && <Icon icon="library" size="md" />}
</div>
)}
+4 -1
View File
@@ -1,4 +1,5 @@
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
import clsx from 'clsx';
import styles from './tooltip.module.css';
@@ -6,6 +7,7 @@ export interface TooltipProps extends MantineTooltipProps {}
export const Tooltip = ({
children,
classNames,
openDelay = 500,
transitionProps = {
duration: 250,
@@ -18,7 +20,8 @@ export const Tooltip = ({
<MantineTooltip
arrowSize={10}
classNames={{
tooltip: styles.tooltip,
...classNames,
tooltip: clsx(styles.tooltip, classNames?.['tooltip']),
}}
multiline
openDelay={openDelay}
+26
View File
@@ -24,6 +24,7 @@ export enum LibraryItem {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
FOLDER = 'folder',
GENRE = 'genre',
PLAYLIST = 'playlist',
PLAYLIST_SONG = 'playlistSong',
@@ -257,6 +258,29 @@ export type EndpointDetails = {
server: ServerListItem;
};
export type Folder = {
_itemType: LibraryItem.FOLDER;
_serverId: string;
_serverType: ServerType;
children?: {
folders: Folder[];
songs: Song[];
};
id: string;
name: string;
parentId?: string;
};
export type FolderArgs = BaseEndpointArgs & { query: FolderQuery };
export interface FolderQuery extends BaseQuery<SongListSort> {
id: string;
musicFolderId?: string | string[];
searchTerm?: string;
}
export type FolderResponse = Folder;
export type GainInfo = {
album?: number;
track?: number;
@@ -1231,6 +1255,7 @@ export type ControllerEndpoint = {
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getDownloadUrl: (args: DownloadArgs) => string;
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
@@ -1309,6 +1334,7 @@ export type InternalControllerEndpoint = {
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
getMusicFolderList: (
+2
View File
@@ -6,6 +6,7 @@ export enum DragTarget {
ALBUM = LibraryItem.ALBUM,
ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,
ARTIST = LibraryItem.ARTIST,
FOLDER = LibraryItem.FOLDER,
GENERIC = 'generic',
GENRE = LibraryItem.GENRE,
GRID_ROW = 'gridRow',
@@ -19,6 +20,7 @@ export const DragTargetMap = {
[LibraryItem.ALBUM]: DragTarget.ALBUM,
[LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST,
[LibraryItem.ARTIST]: DragTarget.ARTIST,
[LibraryItem.FOLDER]: DragTarget.FOLDER,
[LibraryItem.GENRE]: DragTarget.GENRE,
[LibraryItem.PLAYLIST]: DragTarget.PLAYLIST,
[LibraryItem.PLAYLIST_SONG]: DragTarget.SONG,