diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 07733377a..ffe73bddb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 3116c87c5..e23614b47 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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); diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 38a05cda7..6ae6d85b3 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -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', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 24571c4be..8e9533fda 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -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, + 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).MediaSources, + ) + .map((item) => + jfNormalize.song( + item as unknown as z.infer, + 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; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index b0a1fb93d..4b7e3caa2 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -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; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 4a029cf5c..d3ea701c4 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -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); diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 4057f879c..e23f759ab 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -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', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index bc2489c6d..30b1ffa92 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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'; diff --git a/src/renderer/components/item-list/helpers/get-dragged-items.ts b/src/renderer/components/item-list/helpers/get-dragged-items.ts index 4c6fac94a..ee752a3cb 100644 --- a/src/renderer/components/item-list/helpers/get-dragged-items.ts +++ b/src/renderer/components/item-list/helpers/get-dragged-items.ts @@ -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[] => { diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index 3d059153b..4f8faff38 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -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, }); }, diff --git a/src/renderer/components/item-list/item-table-list/columns/image-column.module.css b/src/renderer/components/item-list/item-table-list/columns/image-column.module.css index d722e1ef7..1cd7eecfb 100644 --- a/src/renderer/components/item-list/item-table-list/columns/image-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/image-column.module.css @@ -54,3 +54,8 @@ width: 24px; height: 24px; } + +.folder-icon { + color: black; + fill: rgb(255 215 100); +} diff --git a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx index c5e09f20a..f55340c2a 100644 --- a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx @@ -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 ( + + + + ); + } + return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx index 7ff822a53..1a399e2e9 100644 --- a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx @@ -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: diff --git a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx index 1fd0113c0..a10ccaff6 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx @@ -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: diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css index fd9cd746f..57608c1c2 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css @@ -32,3 +32,8 @@ white-space: nowrap; user-select: none; } + +.folder-icon { + color: black; + fill: rgb(255 215 100); +} diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx index 5c241d1cf..f94802044 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx @@ -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 ( + + + + {title} + + + ); + } + if (row === null) { return ; } @@ -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: diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index fb3baadbe..74d540f82 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -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 ; + + case TableColumn.ALBUM: + return ; + + case TableColumn.ALBUM_ARTIST: + return ( + + ); + + case TableColumn.ALBUM_COUNT: + case TableColumn.PLAY_COUNT: + case TableColumn.SONG_COUNT: + return ; + + case TableColumn.ARTIST: + return ; + + case TableColumn.BIOGRAPHY: + case TableColumn.COMMENT: + return ; + + case TableColumn.BIT_RATE: + case TableColumn.BPM: + case TableColumn.CHANNELS: + case TableColumn.DISC_NUMBER: + case TableColumn.TRACK_NUMBER: + case TableColumn.YEAR: + return ; + + case TableColumn.DATE_ADDED: + case TableColumn.RELEASE_DATE: + return ; + + case TableColumn.DURATION: + return ; + + case TableColumn.GENRE: + return ; + + case TableColumn.GENRE_BADGE: + return ( + + ); + + case TableColumn.IMAGE: + return ; + + case TableColumn.LAST_PLAYED: + return ( + + ); + + case TableColumn.PATH: + return ; + + case TableColumn.ROW_INDEX: + return ; + + case TableColumn.SIZE: + return ; + + case TableColumn.TITLE: + return ; + + case TableColumn.TITLE_COMBINED: + return ( + + ); + + case TableColumn.USER_FAVORITE: + return ; + + case TableColumn.USER_RATING: + return ; + + default: + return ; + } + } + switch (type) { case TableColumn.ACTIONS: - case TableColumn.SKIP: return ; - case TableColumn.ALBUM: - return ; - - case TableColumn.ALBUM_ARTIST: - return ; - - case TableColumn.ALBUM_COUNT: - case TableColumn.PLAY_COUNT: - case TableColumn.SONG_COUNT: - return ; - - case TableColumn.ARTIST: - return ; - - case TableColumn.BIOGRAPHY: - case TableColumn.COMMENT: - return ; - - case TableColumn.BIT_RATE: - case TableColumn.BPM: - case TableColumn.CHANNELS: - case TableColumn.DISC_NUMBER: - case TableColumn.TRACK_NUMBER: - case TableColumn.YEAR: - return ; - - case TableColumn.DATE_ADDED: - case TableColumn.RELEASE_DATE: - return ; - - case TableColumn.DURATION: - return ; - - case TableColumn.GENRE: - return ; - - case TableColumn.GENRE_BADGE: - return ; - case TableColumn.IMAGE: return ; - case TableColumn.LAST_PLAYED: - return ; - - case TableColumn.PATH: - return ; - case TableColumn.ROW_INDEX: return ; - case TableColumn.SIZE: - return ; - case TableColumn.TITLE: return ; @@ -433,14 +517,8 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { ); - case TableColumn.USER_FAVORITE: - return ; - - case TableColumn.USER_RATING: - return ; - default: - return ; + return ; } }; diff --git a/src/renderer/components/item-list/types.ts b/src/renderer/components/item-list/types.ts index f80de8f83..711d2ecf8 100644 --- a/src/renderer/components/item-list/types.ts +++ b/src/renderer/components/item-list/types.ts @@ -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 extends ItemListComponentProps { autoFitColumns?: boolean; diff --git a/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx b/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx index 312a941ad..08d13669d 100644 --- a/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx +++ b/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx @@ -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; diff --git a/src/renderer/features/context-menu/actions/share-action.tsx b/src/renderer/features/context-menu/actions/share-action.tsx index d81f47322..f04d37f73 100644 --- a/src/renderer/features/context-menu/actions/share-action.tsx +++ b/src/renderer/features/context-menu/actions/share-action.tsx @@ -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: diff --git a/src/renderer/features/context-menu/context-menu-controller.tsx b/src/renderer/features/context-menu/context-menu-controller.tsx index 60a5f6782..31ec9bbd7 100644 --- a/src/renderer/features/context-menu/context-menu-controller.tsx +++ b/src/renderer/features/context-menu/context-menu-controller.tsx @@ -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} {cmd.type === LibraryItem.ALBUM_ARTIST && } {cmd.type === LibraryItem.ARTIST && } + {cmd.type === LibraryItem.FOLDER && } {cmd.type === LibraryItem.GENRE && } {cmd.type === LibraryItem.PLAYLIST && } {cmd.type === LibraryItem.PLAYLIST_SONG && } @@ -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; diff --git a/src/renderer/features/context-menu/menus/folder-context-menu.tsx b/src/renderer/features/context-menu/menus/folder-context-menu.tsx new file mode 100644 index 000000000..cca3c9dee --- /dev/null +++ b/src/renderer/features/context-menu/menus/folder-context-menu.tsx @@ -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 ( + } + > + + + + + + + + ); +}; diff --git a/src/renderer/features/folders/api/folder-api.ts b/src/renderer/features/folders/api/folder-api.ts new file mode 100644 index 000000000..5e7d7d6ed --- /dev/null +++ b/src/renderer/features/folders/api/folder-api.ts @@ -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) => { + 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, + }); + }, +}; diff --git a/src/renderer/features/folders/components/folder-list-content.tsx b/src/renderer/features/folders/components/folder-list-content.tsx new file mode 100644 index 000000000..19d57f393 --- /dev/null +++ b/src/renderer/features/folders/components/folder-list-content.tsx @@ -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 ( + }> + + + ); +}; + +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 ( + <> + + + + + + ); +}; + +interface FolderListViewProps { + folderQuery: ReturnType>; +} + +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 ( + // + // ); + // } + case ListDisplayType.TABLE: { + return ( + + ); + } + default: + return null; + } +}; diff --git a/src/renderer/features/folders/components/folder-list-header-filters.tsx b/src/renderer/features/folders/components/folder-list-header-filters.tsx new file mode 100644 index 000000000..79da6c51f --- /dev/null +++ b/src/renderer/features/folders/components/folder-list-header-filters.tsx @@ -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 = () => ( + + + + + + {collapsedItems.map((collapsedItem) => ( + + {collapsedItem.fullLabel} + + ))} + + + ); + + if (hasCollapsedItems && maxItems === 1) { + items.push( + , + ); + 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( + , + ); + + 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( + , + ); + }); + } + + return items; + }, [visibleItems, collapsedItems, allBreadcrumbItems, maxItems]); + + return ( + + + + + + + + + + + + +
+ }>{breadcrumbItems} +
+
+ ); +}; diff --git a/src/renderer/features/folders/components/folder-list-header.tsx b/src/renderer/features/folders/components/folder-list-header.tsx new file mode 100644 index 000000000..41a69ae76 --- /dev/null +++ b/src/renderer/features/folders/components/folder-list-header.tsx @@ -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 ( + + + + + {pageTitle} + + + {itemCount} + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/folders/components/folder-tree-browser.module.css b/src/renderer/features/folders/components/folder-tree-browser.module.css new file mode 100644 index 000000000..6fc498ae7 --- /dev/null +++ b/src/renderer/features/folders/components/folder-tree-browser.module.css @@ -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; +} diff --git a/src/renderer/features/folders/components/folder-tree-browser.tsx b/src/renderer/features/folders/components/folder-tree-browser.tsx new file mode 100644 index 000000000..36194b14c --- /dev/null +++ b/src/renderer/features/folders/components/folder-tree-browser.tsx @@ -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; + rootFolderQuery: UseQueryResult; +} + +export const FolderTreeBrowser = ({ fetchFolder, rootFolderQuery }: FolderTreeBrowserProps) => { + const { currentFolderId, setFolderPath } = useFolderListFilters(); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [loadedNodes, setLoadedNodes] = useState>(new Map()); + const containerRef = useRef(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 ( +
+ +
+ ); +}; + +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(null); + const folderIconRef = useRef(null); + const expandIconRef = useRef(null); + const rowRef = useRef(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({ + 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
; + } + + 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) => { + e.preventDefault(); + e.stopPropagation(); + ContextMenuController.call({ + cmd: { + items: [item.folder], + type: LibraryItem.FOLDER, + }, + event: e, + }); + }; + + return ( + +
+
+ {item.hasChildren ? ( +
+ +
+ ) : ( +
+ )} +
+ +
+ + {item.folder.name} + +
+
+ + ); +}; diff --git a/src/renderer/features/folders/hooks/use-folder-list-filters.ts b/src/renderer/features/folders/hooks/use-folder-list-filters.ts new file mode 100644 index 000000000..8dd48abc8 --- /dev/null +++ b/src/renderer/features/folders/hooks/use-folder-list-filters.ts @@ -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(null, ItemListKey.SONG); + + const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG); + + const { searchTerm, setSearchTerm } = useSearchTermFilter(''); + + const [searchParams, setSearchParams] = useSearchParams(); + + const folderPath = useMemo(() => { + const path = parseJsonParam(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, + }; +}; diff --git a/src/renderer/features/folders/routes/folder-list-route.tsx b/src/renderer/features/folders/routes/folder-list-route.tsx new file mode 100644 index 000000000..62acf5a53 --- /dev/null +++ b/src/renderer/features/folders/routes/folder-list-route.tsx @@ -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(undefined); + + const providerValue = useMemo(() => { + return { + id: undefined, + itemCount, + pageKey, + setItemCount, + }; + }, [itemCount, pageKey, setItemCount]); + + return ( + + + + + + + + + ); +}; + +const FolderListRouteWithBoundary = () => { + return ( + + + + ); +}; + +export default FolderListRouteWithBoundary; diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index a6fce437b..4ecda0495 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -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( diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index 6ad203acb..b15c375ff 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -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[] = []; - - 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[] = []; - - 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[] = []; - - 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; } diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 46201bd69..43a60680f 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -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; 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; 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; 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; 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; 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; 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; + queryClient: QueryClient; + serverId: string; +}) => { + const { id, queryClient, serverId } = args; + + const collectSongsFromFolder = async (folderId: string): Promise => { + 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 => { - 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, diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index 4b3c4cc85..4a572184c 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -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(''); const [focusedRowIndex, setFocusedRowIndex] = useState(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 }, }, diff --git a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx index a5e87d965..5c501a74d 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -444,6 +444,35 @@ const SONG_LIST_FILTERS: Partial< ], }; +const FOLDER_LIST_FILTERS: Partial< + Record> +> = { + [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> > = { @@ -715,6 +744,7 @@ const FILTERS: Partial> = { [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, diff --git a/src/renderer/features/shared/utils.ts b/src/renderer/features/shared/utils.ts index d54c4ce28..0fd428d30 100644 --- a/src/renderer/features/shared/utils.ts +++ b/src/renderer/features/shared/utils.ts @@ -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, diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 07e88b4c3..5ab24d740 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -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; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index e885cf568..c4a3b05ba 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -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' }), diff --git a/src/renderer/hooks/use-container-query.ts b/src/renderer/hooks/use-container-query.ts index cc5b61b5e..e450d0eab 100644 --- a/src/renderer/hooks/use-container-query.ts +++ b/src/renderer/hooks/use-container-query.ts @@ -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); diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index e7719d92b..afea66d16 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -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={} path={AppRoute.LIBRARY_SONGS} /> + } + path={AppRoute.LIBRARY_FOLDERS} + /> } path={AppRoute.PLAYLISTS} diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index b77fb2737..7bf55bc90 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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()( 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, }, ), ); diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index d9eebe6f4..f68b655dd 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -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'; diff --git a/src/renderer/utils/truncate-middle.ts b/src/renderer/utils/truncate-middle.ts new file mode 100644 index 000000000..2ef3389b9 --- /dev/null +++ b/src/renderer/utils/truncate-middle.ts @@ -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}`; +}; diff --git a/src/shared/api/jellyfin/jellyfin-normalize.ts b/src/shared/api/jellyfin/jellyfin-normalize.ts index 9aea04b54..09aa0b5da 100644 --- a/src/shared/api/jellyfin/jellyfin-normalize.ts +++ b/src/shared/api/jellyfin/jellyfin-normalize.ts @@ -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, + 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, diff --git a/src/shared/api/jellyfin/jellyfin-types.ts b/src/shared/api/jellyfin/jellyfin-types.ts index d6b2d4ee4..494075ad9 100644 --- a/src/shared/api/jellyfin/jellyfin-types.ts +++ b/src/shared/api/jellyfin/jellyfin-types.ts @@ -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, diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index 8993a55f9..fbc1e61d9 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -5,6 +5,7 @@ import { Album, AlbumArtist, ExplicitStatus, + Folder, Genre, LibraryItem, Playlist, @@ -342,9 +343,48 @@ const normalizeGenre = ( }; }; +const normalizeFolder = ( + item: z.infer, + 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, diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 4be3577cb..7a8394118 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -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, diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index 3b407b496..10998456d 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -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(); diff --git a/src/shared/components/breadcrumb/breadcrumb.tsx b/src/shared/components/breadcrumb/breadcrumb.tsx new file mode 100644 index 000000000..a1c63a4b9 --- /dev/null +++ b/src/shared/components/breadcrumb/breadcrumb.tsx @@ -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 {children}; +}; diff --git a/src/shared/components/context-menu/context-menu-preview.tsx b/src/shared/components/context-menu/context-menu-preview.tsx index ad4fec319..b258c08be 100644 --- a/src/shared/components/context-menu/context-menu-preview.tsx +++ b/src/shared/components/context-menu/context-menu-preview.tsx @@ -67,6 +67,7 @@ export const ContextMenuPreview = memo(({ items, itemType }: ContextMenuPreviewP )} {itemType === LibraryItem.GENRE && } + {itemType === LibraryItem.FOLDER && } {!itemType && }
)} diff --git a/src/shared/components/tooltip/tooltip.tsx b/src/shared/components/tooltip/tooltip.tsx index f15b0675e..77bc4703f 100644 --- a/src/shared/components/tooltip/tooltip.tsx +++ b/src/shared/components/tooltip/tooltip.tsx @@ -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 = ({ { + 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; getArtistListCount: (args: ArtistListCountArgs) => Promise; getDownloadUrl: (args: DownloadArgs) => string; + getFolder: (args: FolderArgs) => Promise; getGenreList: (args: GenreListArgs) => Promise; getLyrics?: (args: LyricsArgs) => Promise; getMusicFolderList: (args: MusicFolderListArgs) => Promise; @@ -1309,6 +1334,7 @@ export type InternalControllerEndpoint = { getArtistList: (args: ReplaceApiClientProps) => Promise; getArtistListCount: (args: ReplaceApiClientProps) => Promise; getDownloadUrl: (args: ReplaceApiClientProps) => string; + getFolder: (args: ReplaceApiClientProps) => Promise; getGenreList: (args: ReplaceApiClientProps) => Promise; getLyrics?: (args: ReplaceApiClientProps) => Promise; getMusicFolderList: ( diff --git a/src/shared/types/drag-and-drop.ts b/src/shared/types/drag-and-drop.ts index e9485b79b..fb2076a24 100644 --- a/src/shared/types/drag-and-drop.ts +++ b/src/shared/types/drag-and-drop.ts @@ -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,