From 092a9c3f1910d12c4690a0185d6499c078b296e4 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 27 Nov 2025 14:04:33 -0800 Subject: [PATCH] add initial playlist reimplementation --- src/main/features/core/lyrics/shared.ts | 4 +- src/renderer/api/query-keys.ts | 5 +- .../api/subsonic/subsonic-controller.ts | 4 +- .../player/context/player-context.tsx | 2 +- src/renderer/features/player/utils.ts | 2 +- .../playlist-detail-song-list-content.tsx | 67 +++ ...aylist-detail-song-list-header-filters.tsx | 472 ++++-------------- .../playlist-detail-song-list-header.tsx | 27 +- .../playlist-detail-song-list-table.tsx | 127 +++++ .../hooks/use-playlist-song-list-filters.ts | 74 +++ .../playlist-detail-song-list-route.tsx | 143 +++--- .../components/list-sort-by-dropdown.tsx | 92 ++++ .../shared/hooks/use-sort-order-filter.ts | 2 +- .../mutations/favorite-optimistic-updates.ts | 37 +- src/renderer/features/shared/utils.ts | 7 - src/shared/api/utils.ts | 285 ++++++++++- src/shared/types/domain-types.ts | 274 ---------- src/shared/types/drag-and-drop.ts | 1 + 18 files changed, 878 insertions(+), 747 deletions(-) create mode 100644 src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx create mode 100644 src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx create mode 100644 src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts diff --git a/src/main/features/core/lyrics/shared.ts b/src/main/features/core/lyrics/shared.ts index 190aabc28..11a976bf6 100644 --- a/src/main/features/core/lyrics/shared.ts +++ b/src/main/features/core/lyrics/shared.ts @@ -1,4 +1,4 @@ -import Fuse from 'fuse.js'; +import Fuse, { IFuseOptions } from 'fuse.js'; import { InternetProviderLyricSearchResponse, @@ -11,7 +11,7 @@ export const orderSearchResults = (args: { }) => { const { params, results } = args; - const options: Fuse.IFuseOptions = { + const options: IFuseOptions = { fieldNormWeight: 1, includeScore: true, keys: [ diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 98301f27d..4a029cf5c 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -301,7 +301,10 @@ export const queryKeys: Record< }, root: (serverId: string) => [serverId, 'playlists'] as const, songList: (serverId: string, id?: string) => { - if (id) return [serverId, 'playlists', id, 'songList'] as const; + if (id) { + return [serverId, 'playlists', 'songList', id] as const; + } + return [serverId, 'playlists', 'songList'] as const; }, }, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 11524041c..77833299f 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -14,6 +14,7 @@ import { ssType, SubsonicExtensions, } from '/@/shared/api/subsonic/subsonic-types'; +import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils'; import { AlbumListSort, GenreListSort, @@ -21,10 +22,7 @@ import { LibraryItem, PlaylistListSort, Song, - sortAlbumArtistList, - sortAlbumList, SortOrder, - sortSongList, } from '/@/shared/types/domain-types'; import { ServerFeatures } from '/@/shared/types/features-types'; diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index 452db0011..ad0691448 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -15,6 +15,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { AddToQueueType, usePlayerActions } from '/@/renderer/store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { logMsg } from '/@/renderer/utils/logger-message'; +import { sortSongsByFetchedOrder } from '/@/shared/api/utils'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { ConfirmModal } from '/@/shared/components/modal/modal'; import { Stack } from '/@/shared/components/stack/stack'; @@ -30,7 +31,6 @@ import { SongListResponse, SongListSort, SortOrder, - sortSongsByFetchedOrder, } from '/@/shared/types/domain-types'; import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types'; diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index f470b20f4..46201bd69 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { sortSongList } from '/@/shared/api/utils'; import { PlaylistSongListQuery, PlaylistSongListQueryClientSide, @@ -11,7 +12,6 @@ import { SongListResponse, SongListSort, SortOrder, - sortSongList, } from '/@/shared/types/domain-types'; export const getPlaylistSongsById = async (args: { diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx new file mode 100644 index 000000000..e8a6162da --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -0,0 +1,67 @@ +import { lazy, Suspense } from 'react'; +import { useParams } from 'react-router'; + +import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { PlaylistSongListQuery } from '/@/shared/types/domain-types'; +import { ItemListKey, ListDisplayType } from '/@/shared/types/types'; + +const PlaylistDetailSongListTable = lazy(() => + import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then( + (module) => ({ + default: module.PlaylistDetailSongListTable, + }), + ), +); + +export const PlaylistDetailSongListContent = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings( + ItemListKey.PLAYLIST_SONG, + ); + const { playlistId } = useParams() as { playlistId: string }; + + return ( + }> + + + ); +}; + +export type OverridePlaylistSongListQuery = Omit, 'id'>; + +export const PlaylistDetailSongListView = ({ + display, + playlistId, + table, +}: ItemListSettings & { + playlistId: string; +}) => { + const server = useCurrentServer(); + + switch (display) { + case ListDisplayType.TABLE: { + return ( + + ); + } + default: + return null; + } +}; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index b9c073835..119cfd2ae 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -1,299 +1,42 @@ import { closeAllModals, openModal } from '@mantine/modals'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; -import { ChangeEvent, MouseEvent, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router'; import i18n from '/@/i18n/i18n'; -import { queryKeys } from '/@/renderer/api/query-keys'; +import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; -import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; -import { MoreButton } from '/@/renderer/features/shared/components/more-button'; -import { OrderToggleButton } from '/@/renderer/features/shared/components/order-toggle-button'; -import { SearchInput } from '/@/renderer/features/shared/components/search-input'; +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 { AppRoute } from '/@/renderer/router/routes'; -import { - useCurrentServer, - usePlaylistDetailStore, - useSetPlaylistDetailFilters, - useSetPlaylistStore, -} from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; +import { useCurrentServerId } from '/@/renderer/store'; 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 { ConfirmModal } from '/@/shared/components/modal/modal'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; -import { ServerType, SongListSort, SortOrder } from '/@/shared/types/domain-types'; -import { ListDisplayType, Play } from '/@/shared/types/types'; +import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey, Play } from '/@/shared/types/types'; -const FILTERS = { - jellyfin: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.id', { postProcess: 'titleCase' }), - value: SongListSort.ID, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.album', { postProcess: 'titleCase' }), - value: SongListSort.ALBUM, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), - value: SongListSort.ALBUM_ARTIST, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.artist', { postProcess: 'titleCase' }), - value: SongListSort.ARTIST, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: SongListSort.DURATION, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), - value: SongListSort.PLAY_COUNT, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: SongListSort.NAME, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.random', { postProcess: 'titleCase' }), - value: SongListSort.RANDOM, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), - value: SongListSort.RECENTLY_ADDED, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), - value: SongListSort.RECENTLY_PLAYED, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }), - value: SongListSort.RELEASE_DATE, - }, - ], - navidrome: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.id', { postProcess: 'titleCase' }), - value: SongListSort.ID, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.album', { postProcess: 'titleCase' }), - value: SongListSort.ALBUM, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), - value: SongListSort.ALBUM_ARTIST, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.artist', { postProcess: 'titleCase' }), - value: SongListSort.ARTIST, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.bpm', { postProcess: 'titleCase' }), - value: SongListSort.BPM, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('common.channel', { count: 2, postProcess: 'titleCase' }), - value: SongListSort.CHANNELS, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.comment', { postProcess: 'titleCase' }), - value: SongListSort.COMMENT, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: SongListSort.DURATION, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), - value: SongListSort.FAVORITED, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.genre', { postProcess: 'titleCase' }), - value: SongListSort.GENRE, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: SongListSort.NAME, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), - value: SongListSort.PLAY_COUNT, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.rating', { postProcess: 'titleCase' }), - value: SongListSort.RATING, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), - value: SongListSort.RECENTLY_ADDED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), - value: SongListSort.RECENTLY_PLAYED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), - value: SongListSort.YEAR, - }, - ], - subsonic: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.id', { postProcess: 'titleCase' }), - value: SongListSort.ID, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.album', { postProcess: 'titleCase' }), - value: SongListSort.ALBUM, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), - value: SongListSort.ALBUM_ARTIST, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.artist', { postProcess: 'titleCase' }), - value: SongListSort.ARTIST, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: SongListSort.DURATION, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), - value: SongListSort.FAVORITED, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.genre', { postProcess: 'titleCase' }), - value: SongListSort.GENRE, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: SongListSort.NAME, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.rating', { postProcess: 'titleCase' }), - value: SongListSort.RATING, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), - value: SongListSort.RECENTLY_ADDED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), - value: SongListSort.YEAR, - }, - ], -}; - -interface PlaylistDetailSongListHeaderFiltersProps { - handlePlay: (playType: Play) => void; - handleToggleShowQueryBuilder: () => void; - tableRef: any; -} - -export const PlaylistDetailSongListHeaderFilters = ({ - handlePlay, - handleToggleShowQueryBuilder, - tableRef, -}: PlaylistDetailSongListHeaderFiltersProps) => { +export const PlaylistDetailSongListHeaderFilters = () => { const { t } = useTranslation(); const { playlistId } = useParams() as { playlistId: string }; - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const server = useCurrentServer(); - const setPage = useSetPlaylistStore(); - const setFilter = useSetPlaylistDetailFilters(); - const page = usePlaylistDetailStore(); - const searchTerm = page?.table.id[playlistId]?.filter?.searchTerm; - const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID; - const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC; + const serverId = useCurrentServerId(); + + const navigate = useNavigate(); + + const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId })); - const detailQuery = useQuery( - playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }), - ); const isSmartPlaylist = detailQuery.data?.rules; const { ref, ...cq } = useContainerQuery(); - const sortByLabel = - (server?.type && - FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === sortBy)?.name) || - 'Unknown'; - - const handleFilterChange = useCallback(async () => { - // tableRef.current?.api.redrawRows(); - // tableRef.current?.api.ensureIndexVisible(0, 'top'); - // if (page.display === ListDisplayType.TABLE) { - // setPagination({ data: { currentPage: 0 } }); - // } - }, []); - - const handleRefresh = () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.playlists.songList(server?.id || '', playlistId), - }); - }; - - const handleSetSortBy = useCallback((e: MouseEvent) => {}, []); - - const handleToggleSortOrder = useCallback(() => {}, [ - sortOrder, - handleFilterChange, - playlistId, - setFilter, - ]); - - const handleSearch = debounce((e: ChangeEvent) => {}, 500); - - const handleSetViewType = useCallback((displayType: ListDisplayType) => {}, [page, setPage]); - const deletePlaylistMutation = useDeletePlaylist({}); const handleDeletePlaylist = useCallback(() => { @@ -332,99 +75,100 @@ export const PlaylistDetailSongListHeaderFilters = ({ return ( - - - - - - {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( - - {filter.name} - - ))} - - - - - + + + + + + - - - - - - } - onClick={() => handlePlay(Play.NOW)} - > - {t('player.play', { postProcess: 'sentenceCase' })} - - } - onClick={() => handlePlay(Play.LAST)} - > - {t('player.addLast', { postProcess: 'sentenceCase' })} - - } - onClick={() => handlePlay(Play.NEXT)} - > - {t('player.addNext', { postProcess: 'sentenceCase' })} - - - } - onClick={() => - openUpdatePlaylistModal({ - playlist: detailQuery.data!, - server: server!, - }) - } - > - {t('action.editPlaylist', { postProcess: 'sentenceCase' })} - - } - onClick={openDeletePlaylistModal} - > - {t('action.deletePlaylist', { postProcess: 'sentenceCase' })} - - - } - onClick={handleRefresh} - > - {t('action.refresh', { postProcess: 'sentenceCase' })} - - {server?.type === ServerType.NAVIDROME && !isSmartPlaylist && ( - <> - - - {t('action.toggleSmartPlaylistEditor', { - postProcess: 'sentenceCase', - })} - - - )} - - - - ); }; + +// const GenreFilterSelection = () => { +// const { t } = useTranslation(); +// const { playlistId } = useParams() as { playlistId: string }; +// const serverId = useCurrentServerId(); + +// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId })); + +// const genres = useMemo(() => { +// const uniqueGenres = new Map(); + +// data?.items.forEach((song) => { +// song.genres.forEach((genre) => { +// if (genre.id) { +// uniqueGenres.set(genre.id, genre.name); +// } +// }); +// }); + +// return Array.from(uniqueGenres.entries()).map(([id, name]) => ({ +// label: name, +// value: id, +// })); +// }, [data?.items]); + +// return ( +// +// {t('filter.genre', { postProcess: 'titleCase' })} +// +//
    +// {genres.map((genre) => ( +//
  • {genre.label}
  • +// ))} +//
+//
+//
+// ); +// }; + +// const ArtistFilterSelection = () => { +// const { t } = useTranslation(); +// const { playlistId } = useParams() as { playlistId: string }; +// const serverId = useCurrentServerId(); + +// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId })); + +// const artists = useMemo(() => { +// const uniqueArtists = new Map(); + +// data?.items.forEach((song) => { +// song.artists.forEach((artist) => { +// if (artist.id) { +// uniqueArtists.set(artist.id, artist.name); +// } +// }); +// }); + +// return Array.from(uniqueArtists.entries()).map(([id, name]) => ({ +// label: name, +// value: id, +// })); +// }, [data?.items]); + +// return ( +// +// {t('filter.artist', { postProcess: 'titleCase' })} +// +//
    +// {artists.map((artist) => ( +//
  • {artist.label}
  • +// ))} +//
+//
+//
+// ); +// }; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index f140099ee..e1c6b0032 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -1,37 +1,25 @@ import { useQuery } from '@tanstack/react-query'; -import { MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; -import { ItemListHandle } from '/@/renderer/components/item-list/types'; import { PageHeader } from '/@/renderer/components/page-header/page-header'; +import { useListContext } from '/@/renderer/context/list-context'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-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 { useCurrentServer } from '/@/renderer/store'; import { formatDurationString } from '/@/renderer/utils'; import { Badge } from '/@/shared/components/badge/badge'; import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { LibraryItem } from '/@/shared/types/domain-types'; -import { Play } from '/@/shared/types/types'; -interface PlaylistDetailHeaderProps { - handlePlay: (playType: Play) => void; - handleToggleShowQueryBuilder: () => void; - itemCount?: number; - tableRef: MutableRefObject; -} - -export const PlaylistDetailSongListHeader = ({ - handlePlay, - handleToggleShowQueryBuilder, - itemCount, - tableRef, -}: PlaylistDetailHeaderProps) => { +export const PlaylistDetailSongListHeader = () => { const { t } = useTranslation(); const { playlistId } = useParams() as { playlistId: string }; + const { itemCount } = useListContext(); const server = useCurrentServer(); const detailQuery = useQuery( playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }), @@ -60,13 +48,10 @@ export const PlaylistDetailSongListHeader = ({ {isSmartPlaylist && {t('entity.smartPlaylist')}} + - + ); diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx new file mode 100644 index 000000000..78bc1b838 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx @@ -0,0 +1,127 @@ +import { useQuery } from '@tanstack/react-query'; +import { forwardRef, 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 { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; +import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; +import { searchLibraryItems } from '/@/renderer/features/shared/utils'; +import { usePlayerSong } from '/@/renderer/store'; +import { sortSongList } from '/@/shared/api/utils'; +import { LibraryItem, PlaylistSongListQuery, Song } from '/@/shared/types/domain-types'; +import { ItemListKey, Play } from '/@/shared/types/types'; + +interface PlaylistDetailSongListTableProps + extends Omit, 'query'> { + playlistId: string; +} + +export const PlaylistDetailSongListTable = forwardRef( + ( + { + autoFitColumns = false, + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableSelection = true, + enableVerticalBorders = false, + playlistId, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const playlistSongs = useQuery( + playlistsQueries.songList({ + query: { id: playlistId }, + serverId: serverId, + }), + ); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.PLAYLIST_SONG, + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.PLAYLIST_SONG, + }); + + const { searchTerm } = useSearchTermFilter(); + const { query } = usePlaylistSongListFilters(); + + const filterSortedSongs = useMemo(() => { + let items = playlistSongs.data?.items || []; + + if (searchTerm) { + if (searchTerm) { + items = searchLibraryItems(items, searchTerm, LibraryItem.SONG); + } + + return sortSongList(items, query.sortBy, query.sortOrder); + } + + return sortSongList(items, query.sortBy, query.sortOrder); + }, [playlistSongs.data?.items, searchTerm, query.sortBy, query.sortOrder]); + + const player = usePlayer(); + + const currentSong = usePlayerSong(); + + const overrideControls: Partial = useMemo(() => { + return { + onDoubleClick: ({ index, internalState, item }) => { + if (!item) { + return; + } + + const items = internalState?.getData() as Song[]; + + if (index !== undefined) { + player.addToQueueByData(items, Play.NOW); + player.mediaPlayByIndex(index); + } + }, + }; + }, [player]); + + return ( + + ); + }, +); diff --git a/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts b/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts new file mode 100644 index 000000000..1350d3cb6 --- /dev/null +++ b/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts @@ -0,0 +1,74 @@ +import { + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsJson, + parseAsString, + useQueryState, +} from 'nuqs'; + +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 { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +export const usePlaylistSongListFilters = () => { + const { sortBy } = useSortByFilter(SongListSort.ID, ItemListKey.PLAYLIST_SONG); + + const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG); + + const { searchTerm, setSearchTerm } = useSearchTermFilter(''); + + const [albumIds, setAlbumIds] = useQueryState( + FILTER_KEYS.SONG.ALBUM_IDS, + parseAsArrayOf(parseAsString), + ); + + const [genreId, setGenreId] = useQueryState( + FILTER_KEYS.SONG.GENRE_ID, + parseAsArrayOf(parseAsString), + ); + + const [artistIds, setArtistIds] = useQueryState( + FILTER_KEYS.SONG.ARTIST_IDS, + parseAsArrayOf(parseAsString), + ); + + const [minYear, setMinYear] = useQueryState(FILTER_KEYS.SONG.MIN_YEAR, parseAsInteger); + + const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.SONG.MAX_YEAR, parseAsInteger); + + const [favorite, setFavorite] = useQueryState(FILTER_KEYS.SONG.FAVORITE, parseAsBoolean); + + const [custom, setCustom] = useQueryState( + FILTER_KEYS.SONG._CUSTOM, + parseAsJson(customFiltersSchema), + ); + + const query = { + [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, + [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, + [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, + [FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined, + [FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined, + [FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined, + [FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined, + [FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined, + [FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined, + [FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined, + }; + + return { + query, + setAlbumIds, + setArtistIds, + setCustom, + setFavorite, + setGenreId, + setMaxYear, + setMinYear, + setSearchTerm, + }; +}; diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index b6b017fb1..38306e1e4 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -1,12 +1,14 @@ import { closeAllModals, openModal } from '@mantine/modals'; import { useQuery } from '@tanstack/react-query'; import { motion } from 'motion/react'; -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, useNavigate, useParams } from 'react-router'; import { ItemListHandle } from '/@/renderer/components/item-list/types'; +import { ListContext } from '/@/renderer/context/list-context'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content'; import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header'; import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder'; import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form'; @@ -16,14 +18,14 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, usePlaylistDetailStore } from '/@/renderer/store'; +import { useCurrentServer } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Box } from '/@/shared/components/box/box'; import { Group } from '/@/shared/components/group/group'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; -import { ServerType, SongListSort, SortOrder, sortSongList } from '/@/shared/types/domain-types'; -import { Play } from '/@/shared/types/types'; +import { ServerType, SongListSort } from '/@/shared/types/domain-types'; +import { ItemListKey, Play } from '/@/shared/types/types'; const PlaylistDetailSongListRoute = () => { const { t } = useTranslation(); @@ -145,8 +147,6 @@ const PlaylistDetailSongListRoute = () => { setIsQueryBuilderExpanded(true); }; - const page = usePlaylistDetailStore(); - const playlistSongs = useQuery( playlistsQueries.songList({ query: { @@ -156,80 +156,83 @@ const PlaylistDetailSongListRoute = () => { }), ); - const filterSortedSongs = useMemo(() => { - const items = playlistSongs.data?.items; + const [itemCount, setItemCount] = useState(undefined); - if (items) { - const searchTerm = page?.table.id[playlistId]?.filter?.searchTerm; - - if (searchTerm) { - // items = searchSongs(items, searchTerm); - } - - const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID; - const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC; - return sortSongList(items, sortBy, sortOrder); - } else { - return []; - } - }, [playlistSongs.data?.items, page?.table.id, playlistId]); - - const itemCount = - typeof playlistSongs.data?.totalRecordCount === 'number' - ? filterSortedSongs.length - : undefined; - - const handlePlay = (play: Play) => { + const handlePlay = (_play: Play) => { // handlePlayQueueAdd?.({ // byData: filterSortedSongs, // playType: play, // }); }; + const providerValue = useMemo(() => { + return { + customFilters: undefined, + id: playlistId, + itemCount, + pageKey: ItemListKey.PLAYLIST_SONG, + setItemCount, + }; + }, [playlistId, itemCount]); + + // Update item count when playlist songs are loaded + useEffect(() => { + if ( + playlistSongs.data?.totalRecordCount !== undefined && + playlistSongs.data.totalRecordCount !== null + ) { + setItemCount(playlistSongs.data.totalRecordCount); + } + }, [playlistSongs.data?.totalRecordCount]); + return ( - - + + + - {(isSmartPlaylist || showQueryBuilder) && ( - - - - - - {t('form.queryEditor.title', { postProcess: 'titleCase' })} - - - {isQueryBuilderExpanded && ( - - )} - - - )} - {/* */} - + {(isSmartPlaylist || showQueryBuilder) && ( + + + + + + {t('form.queryEditor.title', { postProcess: 'titleCase' })} + + + {isQueryBuilderExpanded && ( + + )} + + + )} + + + ); }; 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 a672a56cc..a5e87d965 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -64,6 +64,89 @@ export const ListSortByDropdown = ({ ); }; +const CLIENT_SIDE_SONG_FILTERS = [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.id', { postProcess: 'titleCase' }), + value: SongListSort.ID, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.album', { postProcess: 'titleCase' }), + value: SongListSort.ALBUM, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), + value: SongListSort.ALBUM_ARTIST, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.artist', { postProcess: 'titleCase' }), + value: SongListSort.ARTIST, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.bpm', { postProcess: 'titleCase' }), + value: SongListSort.BPM, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('common.channel', { count: 2, postProcess: 'titleCase' }), + value: SongListSort.CHANNELS, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.comment', { postProcess: 'titleCase' }), + value: SongListSort.COMMENT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: SongListSort.DURATION, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), + value: SongListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.genre', { postProcess: 'titleCase' }), + value: SongListSort.GENRE, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: SongListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), + value: SongListSort.PLAY_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.rating', { postProcess: 'titleCase' }), + value: SongListSort.RATING, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), + value: SongListSort.RECENTLY_ADDED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), + value: SongListSort.RECENTLY_PLAYED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), + value: SongListSort.YEAR, + }, +]; + const ALBUM_LIST_FILTERS: Partial< Record> > = { @@ -361,6 +444,14 @@ const SONG_LIST_FILTERS: Partial< ], }; +const PLAYLIST_SONG_LIST_FILTERS: Partial< + Record> +> = { + [ServerType.JELLYFIN]: CLIENT_SIDE_SONG_FILTERS, + [ServerType.NAVIDROME]: CLIENT_SIDE_SONG_FILTERS, + [ServerType.SUBSONIC]: CLIENT_SIDE_SONG_FILTERS, +}; + const ALBUM_ARTIST_LIST_FILTERS: Partial< Record> > = { @@ -626,5 +717,6 @@ const FILTERS: Partial> = { [LibraryItem.ARTIST]: ARTIST_LIST_FILTERS, [LibraryItem.GENRE]: GENRE_LIST_FILTERS, [LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS, + [LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS, [LibraryItem.SONG]: SONG_LIST_FILTERS, }; diff --git a/src/renderer/features/shared/hooks/use-sort-order-filter.ts b/src/renderer/features/shared/hooks/use-sort-order-filter.ts index c114536f7..27fd0f5cf 100644 --- a/src/renderer/features/shared/hooks/use-sort-order-filter.ts +++ b/src/renderer/features/shared/hooks/use-sort-order-filter.ts @@ -1,7 +1,7 @@ import { parseAsString, useQueryState } from 'nuqs'; -import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useCurrentServer } from '/@/renderer/store'; import { SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; diff --git a/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts b/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts index 8bd11392d..edf96562a 100644 --- a/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts +++ b/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts @@ -12,6 +12,7 @@ import { ArtistListResponse, FavoriteArgs, LibraryItem, + PlaylistSongListResponse, Song, SongDetailResponse, } from '/@/shared/types/domain-types'; @@ -551,13 +552,47 @@ export const applyFavoriteOptimisticUpdates = ( } }); } + + const playlistSongListQueryKey = queryKeys.playlists.songList( + variables.apiClientProps.serverId, + ); + + const playlistSongListQueries = queryClient.getQueriesData({ + exact: false, + queryKey: playlistSongListQueryKey, + }); + + if (playlistSongListQueries.length) { + playlistSongListQueries.forEach(([queryKey, data]) => { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + (prev: PlaylistSongListResponse | undefined) => { + if (prev) { + return { + ...prev, + items: prev.items.map((item: Song) => + itemIdSet.has(item.id) + ? { ...item, userFavorite: isFavorite } + : item, + ), + }; + } + + return prev; + }, + ); + } + }); + } + break; } } return previousQueries; }; - export const restoreFavoriteQueryData = ( queryClient: QueryClient, previousQueries: PreviousQueryData[], diff --git a/src/renderer/features/shared/utils.ts b/src/renderer/features/shared/utils.ts index 07356d154..15eeccbd1 100644 --- a/src/renderer/features/shared/utils.ts +++ b/src/renderer/features/shared/utils.ts @@ -192,13 +192,6 @@ export const createFuseForLibraryItem = ( }, name: 'albumArtists', }, - { - getFn: (item) => { - const s = item as QueueSong | Song; - return s.genres?.map((genre) => genre.name).join(' ') || ''; - }, - name: 'genres', - }, ); break; } diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index 9bbcac1eb..3b407b496 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -1,10 +1,24 @@ import { AxiosHeaders } from 'axios'; import isElectron from 'is-electron'; +import orderBy from 'lodash/orderBy'; +import reverse from 'lodash/reverse'; +import shuffle from 'lodash/shuffle'; import semverCoerce from 'semver/functions/coerce'; import semverGte from 'semver/functions/gte'; import { z } from 'zod'; -import { ServerListItem } from '/@/shared/types/domain-types'; +import { + Album, + AlbumArtist, + AlbumArtistListSort, + AlbumListSort, + ArtistListSort, + LibraryItem, + ServerListItem, + Song, + SongListSort, + SortOrder, +} from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; // Since ts-rest client returns a strict response type, we need to add the headers to the body object @@ -113,3 +127,272 @@ export const getClientType = (): string => { }; export const SEPARATOR_STRING = ' ยท '; + +export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: SortOrder) => { + let results = songs; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case SongListSort.ALBUM: + results = orderBy( + results, + [(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], + [order, 'asc', 'asc'], + ); + break; + + case SongListSort.ALBUM_ARTIST: + results = orderBy( + results, + [(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'], + [order, order, 'asc', 'asc'], + ); + break; + + case SongListSort.ARTIST: + results = orderBy( + results, + [(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'], + [order, order, 'asc', 'asc'], + ); + break; + + case SongListSort.BPM: + results = orderBy(results, ['bpm'], [order]); + break; + + case SongListSort.CHANNELS: + results = orderBy(results, ['channels'], [order]); + break; + + case SongListSort.COMMENT: + results = orderBy(results, ['comment'], [order]); + break; + + case SongListSort.DURATION: + results = orderBy(results, ['duration'], [order]); + break; + + case SongListSort.FAVORITED: + results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]); + break; + + case SongListSort.GENRE: + results = orderBy( + results, + [ + (v) => v.genres?.[0]?.name.toLowerCase(), + (v) => v.album?.toLowerCase(), + 'discNumber', + 'trackNumber', + ], + [order, order, 'asc', 'asc'], + ); + break; + + case SongListSort.ID: + if (order === 'desc') { + results = reverse(results as any); + } + break; + + case SongListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + + case SongListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [order]); + break; + + case SongListSort.RANDOM: + results = shuffle(results); + break; + + case SongListSort.RATING: + results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]); + break; + + case SongListSort.RECENTLY_ADDED: + results = orderBy(results, ['createdAt'], [order]); + break; + + case SongListSort.RECENTLY_PLAYED: + results = orderBy(results, ['lastPlayedAt'], [order]); + break; + + case SongListSort.RELEASE_DATE: + results = orderBy(results, ['releaseDate'], [order]); + break; + + case SongListSort.YEAR: + results = orderBy( + results, + ['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [order, 'asc', 'asc', 'asc'], + ); + break; + + default: + break; + } + + return results; +}; + +export const sortSongsByFetchedOrder = ( + songs: Song[], + fetchedIds: string[], + itemType: LibraryItem, +): Song[] => { + // Group songs by the fetched ID they belong to + const songsByFetchedId = new Map(); + + for (const song of songs) { + let matchedId: string | undefined; + + switch (itemType) { + case LibraryItem.ALBUM: + matchedId = fetchedIds.find((id) => song.albumId === id); + break; + case LibraryItem.ALBUM_ARTIST: + matchedId = fetchedIds.find((id) => + song.albumArtists.some((artist) => artist.id === id), + ); + break; + case LibraryItem.ARTIST: + matchedId = fetchedIds.find((id) => + song.artists.some((artist) => artist.id === id), + ); + break; + case LibraryItem.GENRE: + matchedId = fetchedIds.find((id) => song.genres.some((genre) => genre.id === id)); + break; + case LibraryItem.PLAYLIST: + // For playlists, we might need to track which playlist each song came from + // This is a simplified approach - you may need to adjust based on your data structure + matchedId = fetchedIds.find((id) => song.playlistItemId === id); + break; + default: + break; + } + + if (matchedId) { + if (!songsByFetchedId.has(matchedId)) { + songsByFetchedId.set(matchedId, []); + } + songsByFetchedId.get(matchedId)!.push(song); + } + } + + // Sort each group by discNumber and trackNumber + for (const [fetchedId, groupSongs] of songsByFetchedId.entries()) { + const sortedGroup = orderBy(groupSongs, ['discNumber', 'trackNumber'], ['asc', 'asc']); + songsByFetchedId.set(fetchedId, sortedGroup); + } + + // Combine groups in the order of fetchedIds + const result: Song[] = []; + for (const fetchedId of fetchedIds) { + const groupSongs = songsByFetchedId.get(fetchedId); + if (groupSongs) { + result.push(...groupSongs); + } + } + + // Add any songs that didn't match any fetched ID at the end + const matchedIds = new Set(result.map((s) => s.id)); + const unmatchedSongs = songs.filter((s) => !matchedIds.has(s.id)); + if (unmatchedSongs.length > 0) { + const sortedUnmatched = orderBy( + unmatchedSongs, + ['discNumber', 'trackNumber'], + ['asc', 'asc'], + ); + result.push(...sortedUnmatched); + } + + return result; +}; + +export const sortAlbumArtistList = ( + artists: AlbumArtist[], + sortBy: AlbumArtistListSort | ArtistListSort, + sortOrder: SortOrder, +) => { + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + let results = artists; + + switch (sortBy) { + case AlbumArtistListSort.ALBUM_COUNT: + results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']); + break; + + case AlbumArtistListSort.FAVORITED: + results = orderBy(artists, ['starred'], [order]); + break; + + case AlbumArtistListSort.NAME: + results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]); + break; + + case AlbumArtistListSort.RATING: + results = orderBy(artists, ['userRating'], [order]); + break; + + default: + break; + } + + return results; +}; +export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => { + let results = albums; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case AlbumListSort.ALBUM_ARTIST: + results = orderBy( + results, + ['albumArtist', (v) => v.name.toLowerCase()], + [order, 'asc'], + ); + break; + case AlbumListSort.DURATION: + results = orderBy(results, ['duration'], [order]); + break; + case AlbumListSort.FAVORITED: + results = orderBy(results, ['starred'], [order]); + break; + case AlbumListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + case AlbumListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [order]); + break; + case AlbumListSort.RANDOM: + results = shuffle(results); + break; + case AlbumListSort.RATING: + results = orderBy(results, ['userRating'], [order]); + break; + case AlbumListSort.RECENTLY_ADDED: + results = orderBy(results, ['createdAt'], [order]); + break; + case AlbumListSort.RECENTLY_PLAYED: + results = orderBy(results, ['lastPlayedAt'], [order]); + break; + case AlbumListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [order]); + break; + case AlbumListSort.YEAR: + results = orderBy(results, ['releaseYear'], [order]); + break; + default: + break; + } + + return results; +}; diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 8a8701b22..c8842cb98 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1,7 +1,3 @@ -import orderBy from 'lodash/orderBy'; -import reverse from 'lodash/reverse'; -import shuffle from 'lodash/shuffle'; - import { JFAlbumArtistListSort, JFAlbumListSort, @@ -1434,273 +1430,3 @@ type BaseEndpointArgsWithServer = { signal?: AbortSignal; }; }; - -export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => { - let results = albums; - - const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; - - switch (sortBy) { - case AlbumListSort.ALBUM_ARTIST: - results = orderBy( - results, - ['albumArtist', (v) => v.name.toLowerCase()], - [order, 'asc'], - ); - break; - case AlbumListSort.DURATION: - results = orderBy(results, ['duration'], [order]); - break; - case AlbumListSort.FAVORITED: - results = orderBy(results, ['starred'], [order]); - break; - case AlbumListSort.NAME: - results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); - break; - case AlbumListSort.PLAY_COUNT: - results = orderBy(results, ['playCount'], [order]); - break; - case AlbumListSort.RANDOM: - results = shuffle(results); - break; - case AlbumListSort.RATING: - results = orderBy(results, ['userRating'], [order]); - break; - case AlbumListSort.RECENTLY_ADDED: - results = orderBy(results, ['createdAt'], [order]); - break; - case AlbumListSort.RECENTLY_PLAYED: - results = orderBy(results, ['lastPlayedAt'], [order]); - break; - case AlbumListSort.SONG_COUNT: - results = orderBy(results, ['songCount'], [order]); - break; - case AlbumListSort.YEAR: - results = orderBy(results, ['releaseYear'], [order]); - break; - default: - break; - } - - return results; -}; - -export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: SortOrder) => { - let results = songs; - - const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; - - switch (sortBy) { - case SongListSort.ALBUM: - results = orderBy( - results, - [(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], - [order, 'asc', 'asc'], - ); - break; - - case SongListSort.ALBUM_ARTIST: - results = orderBy( - results, - [(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'], - [order, order, 'asc', 'asc'], - ); - break; - - case SongListSort.ARTIST: - results = orderBy( - results, - [(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'], - [order, order, 'asc', 'asc'], - ); - break; - - case SongListSort.BPM: - results = orderBy(results, ['bpm'], [order]); - break; - - case SongListSort.CHANNELS: - results = orderBy(results, ['channels'], [order]); - break; - - case SongListSort.COMMENT: - results = orderBy(results, ['comment'], [order]); - break; - - case SongListSort.DURATION: - results = orderBy(results, ['duration'], [order]); - break; - - case SongListSort.FAVORITED: - results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]); - break; - - case SongListSort.GENRE: - results = orderBy( - results, - [ - (v) => v.genres?.[0]?.name.toLowerCase(), - (v) => v.album?.toLowerCase(), - 'discNumber', - 'trackNumber', - ], - [order, order, 'asc', 'asc'], - ); - break; - - case SongListSort.ID: - if (order === 'desc') { - results = reverse(results as any); - } - break; - - case SongListSort.NAME: - results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); - break; - - case SongListSort.PLAY_COUNT: - results = orderBy(results, ['playCount'], [order]); - break; - - case SongListSort.RANDOM: - results = shuffle(results); - break; - - case SongListSort.RATING: - results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]); - break; - - case SongListSort.RECENTLY_ADDED: - results = orderBy(results, ['createdAt'], [order]); - break; - - case SongListSort.RECENTLY_PLAYED: - results = orderBy(results, ['lastPlayedAt'], [order]); - break; - - case SongListSort.RELEASE_DATE: - results = orderBy(results, ['releaseDate'], [order]); - break; - - case SongListSort.YEAR: - results = orderBy( - results, - ['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], - [order, 'asc', 'asc', 'asc'], - ); - break; - - default: - break; - } - - return results; -}; - -export const sortSongsByFetchedOrder = ( - songs: Song[], - fetchedIds: string[], - itemType: LibraryItem, -): Song[] => { - // Group songs by the fetched ID they belong to - const songsByFetchedId = new Map(); - - for (const song of songs) { - let matchedId: string | undefined; - - switch (itemType) { - case LibraryItem.ALBUM: - matchedId = fetchedIds.find((id) => song.albumId === id); - break; - case LibraryItem.ALBUM_ARTIST: - matchedId = fetchedIds.find((id) => - song.albumArtists.some((artist) => artist.id === id), - ); - break; - case LibraryItem.ARTIST: - matchedId = fetchedIds.find((id) => - song.artists.some((artist) => artist.id === id), - ); - break; - case LibraryItem.GENRE: - matchedId = fetchedIds.find((id) => song.genres.some((genre) => genre.id === id)); - break; - case LibraryItem.PLAYLIST: - // For playlists, we might need to track which playlist each song came from - // This is a simplified approach - you may need to adjust based on your data structure - matchedId = fetchedIds.find((id) => song.playlistItemId === id); - break; - default: - break; - } - - if (matchedId) { - if (!songsByFetchedId.has(matchedId)) { - songsByFetchedId.set(matchedId, []); - } - songsByFetchedId.get(matchedId)!.push(song); - } - } - - // Sort each group by discNumber and trackNumber - for (const [fetchedId, groupSongs] of songsByFetchedId.entries()) { - const sortedGroup = orderBy(groupSongs, ['discNumber', 'trackNumber'], ['asc', 'asc']); - songsByFetchedId.set(fetchedId, sortedGroup); - } - - // Combine groups in the order of fetchedIds - const result: Song[] = []; - for (const fetchedId of fetchedIds) { - const groupSongs = songsByFetchedId.get(fetchedId); - if (groupSongs) { - result.push(...groupSongs); - } - } - - // Add any songs that didn't match any fetched ID at the end - const matchedIds = new Set(result.map((s) => s.id)); - const unmatchedSongs = songs.filter((s) => !matchedIds.has(s.id)); - if (unmatchedSongs.length > 0) { - const sortedUnmatched = orderBy( - unmatchedSongs, - ['discNumber', 'trackNumber'], - ['asc', 'asc'], - ); - result.push(...sortedUnmatched); - } - - return result; -}; - -export const sortAlbumArtistList = ( - artists: AlbumArtist[], - sortBy: AlbumArtistListSort | ArtistListSort, - sortOrder: SortOrder, -) => { - const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; - - let results = artists; - - switch (sortBy) { - case AlbumArtistListSort.ALBUM_COUNT: - results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']); - break; - - case AlbumArtistListSort.FAVORITED: - results = orderBy(artists, ['starred'], [order]); - break; - - case AlbumArtistListSort.NAME: - results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]); - break; - - case AlbumArtistListSort.RATING: - results = orderBy(artists, ['userRating'], [order]); - break; - - default: - break; - } - - return results; -}; diff --git a/src/shared/types/drag-and-drop.ts b/src/shared/types/drag-and-drop.ts index 66a4ab521..e9485b79b 100644 --- a/src/shared/types/drag-and-drop.ts +++ b/src/shared/types/drag-and-drop.ts @@ -21,6 +21,7 @@ export const DragTargetMap = { [LibraryItem.ARTIST]: DragTarget.ARTIST, [LibraryItem.GENRE]: DragTarget.GENRE, [LibraryItem.PLAYLIST]: DragTarget.PLAYLIST, + [LibraryItem.PLAYLIST_SONG]: DragTarget.SONG, [LibraryItem.QUEUE_SONG]: DragTarget.QUEUE_SONG, [LibraryItem.SONG]: DragTarget.SONG, };