add initial playlist reimplementation

This commit is contained in:
jeffvli
2025-11-27 14:04:33 -08:00
parent ac5de29c71
commit 092a9c3f19
18 changed files with 878 additions and 747 deletions
+2 -2
View File
@@ -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<InternetProviderLyricSearchResponse> = {
const options: IFuseOptions<InternetProviderLyricSearchResponse> = {
fieldNormWeight: 1,
includeScore: true,
keys: [
+4 -1
View File
@@ -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;
},
},
@@ -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';
@@ -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';
+1 -1
View File
@@ -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: {
@@ -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 (
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListView
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
pagination={pagination}
playlistId={playlistId}
table={table}
/>
</Suspense>
);
};
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
export const PlaylistDetailSongListView = ({
display,
playlistId,
table,
}: ItemListSettings & {
playlistId: string;
}) => {
const server = useCurrentServer();
switch (display) {
case ListDisplayType.TABLE: {
return (
<PlaylistDetailSongListTable
autoFitColumns={table.autoFitColumns}
columns={table.columns}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
playlistId={playlistId}
serverId={server.id}
size={table.size}
/>
);
}
default:
return null;
}
};
@@ -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<HTMLButtonElement>) => {}, []);
const handleToggleSortOrder = useCallback(() => {}, [
sortOrder,
handleFilterChange,
playlistId,
setFilter,
]);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {}, 500);
const handleSetViewType = useCallback((displayType: ListDisplayType) => {}, [page, setPage]);
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
@@ -332,99 +75,100 @@ export const PlaylistDetailSongListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group gap="sm" ref={ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
tooltip={{
label: t('page.playlist.reorder', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
isSelected={filter.value === sortBy}
key={`filter-${filter.name}`}
onClick={handleSetSortBy}
value={filter.value}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={sortOrder || SortOrder.ASC}
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
itemType={LibraryItem.PLAYLIST_SONG}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<ListRefreshButton listKey={ItemListKey.PLAYLIST_SONG} />
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
listKey={ItemListKey.PLAYLIST_SONG}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<MoreButton />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
leftSection={<Icon icon="mediaPlay" />}
onClick={() => handlePlay(Play.NOW)}
>
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
leftSection={<Icon icon="mediaPlayLast" />}
onClick={() => handlePlay(Play.LAST)}
>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
leftSection={<Icon icon="mediaPlayNext" />}
onClick={() => handlePlay(Play.NEXT)}
>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
leftSection={<Icon icon="edit" />}
onClick={() =>
openUpdatePlaylistModal({
playlist: detailQuery.data!,
server: server!,
})
}
>
{t('action.editPlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
leftSection={<Icon icon="delete" />}
onClick={openDeletePlaylistModal}
>
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
leftSection={<Icon icon="refresh" />}
onClick={handleRefresh}
>
{t('action.refresh', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item isDanger onClick={handleToggleShowQueryBuilder}>
{t('action.toggleSmartPlaylistEditor', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<SearchInput defaultValue={searchTerm} onChange={handleSearch} />
</Group>
<Group></Group>
</Flex>
);
};
// 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<string, string>();
// 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 (
// <Stack p="md" style={{ background: 'var(--theme-colors-surface)', height: '12rem' }}>
// <Text>{t('filter.genre', { postProcess: 'titleCase' })}</Text>
// <ScrollArea>
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
// {genres.map((genre) => (
// <li key={genre.value}>{genre.label}</li>
// ))}
// </ul>
// </ScrollArea>
// </Stack>
// );
// };
// 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<string, string>();
// 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 (
// <Stack style={{ height: '12rem' }}>
// <Text>{t('filter.artist', { postProcess: 'titleCase' })}</Text>
// <ScrollArea>
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
// {artists.map((artist) => (
// <li key={artist.value}>{artist.label}</li>
// ))}
// </ul>
// </ScrollArea>
// </Stack>
// );
// };
@@ -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<ItemListHandle | null>;
}
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 = ({
</Badge>
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
</LibraryHeaderBar>
<ListSearchInput />
</PageHeader>
<FilterBar>
<PlaylistDetailSongListHeaderFilters
handlePlay={handlePlay}
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
tableRef={tableRef}
/>
<PlaylistDetailSongListHeaderFilters />
</FilterBar>
</Stack>
);
@@ -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<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
playlistId: string;
}
export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>(
(
{
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<ItemControls> = 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 (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filterSortedSongs}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getRowId="playlistItemId"
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.PLAYLIST_SONG}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={overrideControls}
ref={ref}
size={size}
/>
);
},
);
@@ -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>(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,
};
};
@@ -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<number | undefined>(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 (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<LibraryContainer>
<PlaylistDetailSongListHeader
handlePlay={handlePlay}
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
itemCount={itemCount}
tableRef={tableRef}
/>
<ListContext.Provider value={providerValue}>
<LibraryContainer>
<PlaylistDetailSongListHeader
handlePlay={handlePlay}
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
itemCount={itemCount}
tableRef={tableRef}
/>
{(isSmartPlaylist || showQueryBuilder) && (
<motion.div>
<Box h="100%" mah="35vh" p="md" w="100%">
<Group pb="md">
<ActionIcon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
iconProps={{
size: 'md',
}}
onClick={handleToggleExpand}
size="xs"
/>
<Text>
{t('form.queryEditor.title', { postProcess: 'titleCase' })}
</Text>
</Group>
{isQueryBuilderExpanded && (
<PlaylistQueryBuilder
isSaving={createPlaylistMutation?.isPending}
key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit}
onSave={handleSave}
onSaveAs={handleSaveAs}
playlistId={playlistId}
query={detailQuery?.data?.rules}
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
/>
)}
</Box>
</motion.div>
)}
{/* <PlaylistDetailSongListContent songs={filterSortedSongs} tableRef={tableRef} /> */}
</LibraryContainer>
{(isSmartPlaylist || showQueryBuilder) && (
<motion.div>
<Box h="100%" mah="35vh" p="md" w="100%">
<Group pb="md">
<ActionIcon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
iconProps={{
size: 'md',
}}
onClick={handleToggleExpand}
size="xs"
/>
<Text>
{t('form.queryEditor.title', { postProcess: 'titleCase' })}
</Text>
</Group>
{isQueryBuilderExpanded && (
<PlaylistQueryBuilder
isSaving={createPlaylistMutation?.isPending}
key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit}
onSave={handleSave}
onSaveAs={handleSaveAs}
playlistId={playlistId}
query={detailQuery?.data?.rules}
sortBy={
detailQuery?.data?.rules?.sort || SongListSort.ALBUM
}
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
/>
)}
</Box>
</motion.div>
)}
<PlaylistDetailSongListContent />
</LibraryContainer>
</ListContext.Provider>
</AnimatedPage>
);
};
@@ -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<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
> = {
@@ -361,6 +444,14 @@ const SONG_LIST_FILTERS: Partial<
],
};
const PLAYLIST_SONG_LIST_FILTERS: Partial<
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
> = {
[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<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
> = {
@@ -626,5 +717,6 @@ const FILTERS: Partial<Record<LibraryItem, any>> = {
[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,
};
@@ -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';
@@ -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[],
-7
View File
@@ -192,13 +192,6 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
},
name: 'albumArtists',
},
{
getFn: (item) => {
const s = item as QueueSong | Song;
return s.genres?.map((genre) => genre.name).join(' ') || '';
},
name: 'genres',
},
);
break;
}
+284 -1
View File
@@ -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<string, Song[]>();
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;
};
-274
View File
@@ -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<string, Song[]>();
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;
};
+1
View File
@@ -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,
};