mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Subsonic 2, general rework (#758)
This commit is contained in:
parent
31492fa9ef
commit
8cddbef701
69 changed files with 4625 additions and 3566 deletions
|
|
@ -11,7 +11,13 @@ import { generatePath, useParams } from 'react-router';
|
|||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, Popover, Spoiler } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import {
|
||||
|
|
@ -164,13 +170,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
|||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
||||
ExcludeItemIds: detailQuery?.data?.id,
|
||||
},
|
||||
navidrome: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
},
|
||||
artistIds: detailQuery?.data?.albumArtists.length
|
||||
? [detailQuery?.data?.albumArtists[0].id]
|
||||
: undefined,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
|
|
@ -179,15 +184,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
|||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const relatedAlbumGenresRequest = {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
GenreIds: detailQuery?.data?.genres?.[0]?.id,
|
||||
},
|
||||
navidrome: {
|
||||
genre_id: detailQuery?.data?.genres?.[0]?.id,
|
||||
},
|
||||
},
|
||||
const relatedAlbumGenresRequest: AlbumListQuery = {
|
||||
genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
|||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { pageKey, customFilters, id } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -162,9 +162,9 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
|||
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
...customFilters,
|
||||
startIndex: skip,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query, id);
|
||||
|
|
|
|||
|
|
@ -15,13 +15,20 @@ import {
|
|||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
|
|
@ -139,26 +146,74 @@ const FILTERS = {
|
|||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AlbumListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
|
||||
export const AlbumListHeaderFilters = ({
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: AlbumListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters, handlePlay } = useListContext();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({
|
||||
const { display, filter, table, grid } = useListStoreByKey<AlbumListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
});
|
||||
|
|
@ -191,27 +246,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
|||
);
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
let FilterComponent;
|
||||
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
FilterComponent = NavidromeAlbumFilters;
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
FilterComponent = JellyfinAlbumFilters;
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
FilterComponent = SubsonicAlbumFilters;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!FilterComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinAlbumFilters
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<FilterComponent
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
),
|
||||
title: 'Album Filters',
|
||||
});
|
||||
|
|
@ -347,8 +410,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
|||
filter?._custom?.jellyfin &&
|
||||
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
|
||||
const isSubsonicFilterApplied =
|
||||
server?.type === ServerType.SUBSONIC &&
|
||||
(filter.maxYear || filter.minYear || filter.genres?.length || filter.favorite);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
|
||||
}, [
|
||||
filter?._custom?.jellyfin,
|
||||
filter?._custom?.navidrome,
|
||||
filter.favorite,
|
||||
filter.genres?.length,
|
||||
filter.maxYear,
|
||||
filter.minYear,
|
||||
server?.type,
|
||||
]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
|
|
@ -33,8 +33,9 @@ export const AlbumListHeader = ({
|
|||
const cq = useContainerQuery();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const genreRef = useRef<string>();
|
||||
const { filter, handlePlay, refresh, search } = useDisplayRefresh({
|
||||
const { filter, handlePlay, refresh, search } = useDisplayRefresh<AlbumListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
tableRef,
|
||||
|
|
@ -90,6 +91,7 @@ export const AlbumListHeader = ({
|
|||
<FilterBar>
|
||||
<AlbumListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import { Divider, Group, Stack } from '@mantine/core';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListFilterByKey } from '../../../store/list.store';
|
||||
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
AlbumListQuery,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
|
|
@ -25,7 +31,7 @@ export const JellyfinAlbumFilters = ({
|
|||
serverId,
|
||||
}: JellyfinAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const filter = useListFilterByKey({ key: pageKey });
|
||||
const filter = useListFilterByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
|
|
@ -47,10 +53,6 @@ export const JellyfinAlbumFilters = ({
|
|||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.GenreIds?.split(',');
|
||||
}, [filter?._custom?.jellyfin?.GenreIds]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
|
|
@ -58,20 +60,15 @@ export const JellyfinAlbumFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IsFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
favorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter?._custom?.jellyfin?.IsFavorite,
|
||||
value: filter?.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -80,13 +77,8 @@ export const JellyfinAlbumFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
|
|
@ -99,13 +91,8 @@ export const JellyfinAlbumFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
|
|
@ -114,17 +101,11 @@ export const JellyfinAlbumFilters = ({
|
|||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
GenreIds: genreFilterString,
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
genres: e,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
|
|
@ -157,17 +138,11 @@ export const JellyfinAlbumFilters = ({
|
|||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string[] | null) => {
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
AlbumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
artistIds: e || undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
|
|
@ -193,21 +168,21 @@ export const JellyfinAlbumFilters = ({
|
|||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.minYear}
|
||||
defaultValue={filter?.minYear}
|
||||
hideControls={false}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?._custom?.jellyfin?.maxYear}
|
||||
required={!!filter?.maxYear}
|
||||
onChange={(e) => handleMinYearFilter(e)}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.maxYear}
|
||||
defaultValue={filter?.maxYear}
|
||||
hideControls={false}
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?._custom?.jellyfin?.minYear}
|
||||
required={!!filter?.minYear}
|
||||
onChange={(e) => handleMaxYearFilter(e)}
|
||||
/>
|
||||
</Group>
|
||||
|
|
@ -216,7 +191,7 @@ export const JellyfinAlbumFilters = ({
|
|||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
defaultValue={filter.genres}
|
||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
AlbumListQuery,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
|
|
@ -24,7 +30,7 @@ export const NavidromeAlbumFilters = ({
|
|||
serverId,
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
|
|
@ -48,13 +54,8 @@ export const NavidromeAlbumFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
genres: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
|
|
@ -90,20 +91,15 @@ export const NavidromeAlbumFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
starred: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
favorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
value: filter.favorite,
|
||||
},
|
||||
{
|
||||
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
|
||||
|
|
@ -111,20 +107,15 @@ export const NavidromeAlbumFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.compilation,
|
||||
value: filter.compilation,
|
||||
},
|
||||
{
|
||||
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
|
||||
interface SubsonicAlbumFiltersProps {
|
||||
onFilterChange: (filters: AlbumListFilter) => void;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const SubsonicAlbumFilters = ({
|
||||
onFilterChange,
|
||||
pageKey,
|
||||
serverId,
|
||||
}: SubsonicAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
genres: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
favorite: e.target.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
|
||||
let data: Partial<AlbumListQuery> = {};
|
||||
|
||||
if (type === 'min') {
|
||||
data = {
|
||||
minYear: e ? Number(e) : undefined,
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
maxYear: e ? Number(e) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
data,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter.minYear}
|
||||
disabled={filter.genres?.length !== undefined}
|
||||
hideControls={false}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e, 'min')}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter.maxYear}
|
||||
disabled={filter.genres?.length !== undefined}
|
||||
hideControls={false}
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e, 'max')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.genres?.length ? filter.genres[0] : undefined}
|
||||
disabled={Boolean(filter.minYear || filter.maxYear)}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAlbumListCount = (args: QueryHookArgs<AlbumListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albums.count(
|
||||
serverId || '',
|
||||
Object.keys(query).length === 0 ? undefined : query,
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
|
|
@ -18,6 +17,7 @@ import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
|||
import { Play } from '/@/renderer/types';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -33,14 +33,7 @@ const AlbumListRoute = () => {
|
|||
const value = {
|
||||
...(albumArtistId && { artistIds: [albumArtistId] }),
|
||||
...(genreId && {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
GenreIds: genreId,
|
||||
},
|
||||
navidrome: {
|
||||
genre_id: genreId,
|
||||
},
|
||||
},
|
||||
genres: [genreId],
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -51,7 +44,7 @@ const AlbumListRoute = () => {
|
|||
return value;
|
||||
}, [albumArtistId, genreId]);
|
||||
|
||||
const albumListFilter = useListFilterByKey({
|
||||
const albumListFilter = useListFilterByKey<AlbumListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
|
|
@ -78,32 +71,27 @@ const AlbumListRoute = () => {
|
|||
return genre?.name;
|
||||
}, [genreId, genreList.data]);
|
||||
|
||||
const itemCountCheck = useAlbumList({
|
||||
const itemCountCheck = useAlbumListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumListFilter,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
async (args: { initialSongId?: string; playType: Play }) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
const { playType } = args;
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...albumListFilter,
|
||||
...customFilters,
|
||||
startIndex: 0,
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
|
|
|
|||
|
|
@ -111,18 +111,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
enabled: enabledItem.recentAlbums,
|
||||
},
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...(server?.type === ServerType.JELLYFIN
|
||||
? { AlbumArtistIds: albumArtistId }
|
||||
: undefined),
|
||||
},
|
||||
navidrome: {
|
||||
...(server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: false }
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
artistIds: [albumArtistId],
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
|
|
@ -133,21 +122,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
|
||||
const compilationAlbumsQuery = useAlbumList({
|
||||
options: {
|
||||
enabled: enabledItem.compilations,
|
||||
enabled: enabledItem.compilations && server?.type !== ServerType.SUBSONIC,
|
||||
},
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...(server?.type === ServerType.JELLYFIN
|
||||
? { ContributingArtistIds: albumArtistId }
|
||||
: undefined),
|
||||
},
|
||||
navidrome: {
|
||||
...(server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: true }
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
artistIds: [albumArtistId],
|
||||
compilation: true,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
|
|
@ -254,7 +233,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
},
|
||||
{
|
||||
data: compilationAlbumsQuery?.data?.items,
|
||||
isHidden: !compilationAlbumsQuery?.data?.items?.length || !enabledItem.compilations,
|
||||
isHidden:
|
||||
!compilationAlbumsQuery?.data?.items?.length ||
|
||||
!enabledItem.compilations ||
|
||||
server?.type === ServerType.SUBSONIC,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
|
||||
order: itemOrder.compilations,
|
||||
|
|
@ -301,6 +283,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
recentAlbumsQuery?.data?.items,
|
||||
recentAlbumsQuery.isFetching,
|
||||
recentAlbumsQuery?.isLoading,
|
||||
server?.type,
|
||||
t,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,16 +26,19 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||
|
||||
const metadataItems = [
|
||||
{
|
||||
enabled: detailQuery?.data?.albumCount,
|
||||
id: 'albumCount',
|
||||
secondary: false,
|
||||
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery?.data?.songCount,
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery.data?.duration,
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
|
|
@ -70,7 +73,7 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||
<Stack>
|
||||
<Group>
|
||||
{metadataItems
|
||||
.filter((i) => i.value)
|
||||
.filter((i) => i.enabled)
|
||||
.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
AlbumArtistListQuery,
|
||||
AlbumArtistListResponse,
|
||||
AlbumArtistListSort,
|
||||
ArtistListQuery,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
|
||||
|
|
@ -34,7 +33,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
|||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const { pageKey } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<AlbumArtistListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
|
|
@ -73,7 +72,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
|||
|
||||
const fetch = useCallback(
|
||||
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
|
||||
const query: ArtistListQuery = {
|
||||
const query: AlbumArtistListQuery = {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
|
|
@ -91,7 +90,6 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
|||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
|
|||
import { useListContext } from '../../../context/list-context';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListQuery,
|
||||
AlbumArtistListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
|
@ -85,6 +91,28 @@ const FILTERS = {
|
|||
value: AlbumArtistListSort.SONG_COUNT,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.ALBUM_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.RATING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AlbumArtistListHeaderFiltersProps {
|
||||
|
|
@ -100,7 +128,9 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
const { display, table, grid, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { display, table, grid, filter } = useListStoreByKey<AlbumArtistListQuery>({
|
||||
key: pageKey,
|
||||
});
|
||||
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
|
||||
useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Flex, Group, Stack } from '@mantine/core';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FilterBar } from '../../shared/components/filter-bar';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
|
|
@ -28,8 +28,9 @@ export const AlbumArtistListHeader = ({
|
|||
const server = useCurrentServer();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh<AlbumArtistListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
server,
|
||||
tableRef,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListQuery } from '/@/renderer/api/types';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAlbumArtistListCount = (args: QueryHookArgs<AlbumArtistListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumArtistListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.count(
|
||||
serverId || '',
|
||||
Object.keys(query).length === 0 ? undefined : query,
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -13,7 +13,7 @@ export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
|
|||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
|
||||
return api.controller.getTopSongs({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
import { useMemo, useRef } from 'react';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
import { useListFilterByKey } from '../../../store/list.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
|
||||
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query';
|
||||
|
||||
const AlbumArtistListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
|
|
@ -16,25 +16,18 @@ const AlbumArtistListRoute = () => {
|
|||
const pageKey = LibraryItem.ALBUM_ARTIST;
|
||||
const server = useCurrentServer();
|
||||
|
||||
const albumArtistListFilter = useListFilterByKey({ key: pageKey });
|
||||
const albumArtistListFilter = useListFilterByKey<AlbumArtistListQuery>({ key: pageKey });
|
||||
|
||||
const itemCountCheck = useAlbumArtistList({
|
||||
const itemCountCheck = useAlbumArtistListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumArtistListFilter,
|
||||
},
|
||||
query: albumArtistListFilter,
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -494,17 +494,24 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
const removeFromPlaylistMutation = useRemoveFromPlaylist();
|
||||
|
||||
const handleRemoveFromPlaylist = useCallback(() => {
|
||||
const songId =
|
||||
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
|
||||
? ctx.dataNodes?.map((node) => node.data.playlistItemId)
|
||||
: ctx.dataNodes?.map((node) => node.data.id)) || [];
|
||||
let songId: string[] | undefined;
|
||||
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
case ServerType.JELLYFIN:
|
||||
songId = ctx.dataNodes?.map((node) => node.data.playlistItemId);
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
songId = ctx.dataNodes?.map((node) => node.rowIndex!.toString());
|
||||
break;
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
removeFromPlaylistMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: ctx.context.playlistId,
|
||||
songId,
|
||||
songId: songId || [],
|
||||
},
|
||||
serverId: ctx.data?.[0]?.serverId,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
|
|||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { pageKey, id } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const genrePath = useGenreRoute();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ import {
|
|||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
GenreListQuery,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
|
@ -47,25 +53,38 @@ const FILTERS = {
|
|||
value: GenreListSort.NAME,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: GenreListSort.NAME,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface GenreListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
|
||||
export const GenreListHeaderFilters = ({
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: GenreListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters } = useListContext();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
|
||||
const { display, filter, table, grid } = useListStoreByKey<GenreListQuery>({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
const { genreTarget } = useGeneralSettings();
|
||||
const { setGenreBehavior } = useSettingsStoreActions();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ChangeEvent, MutableRefObject } from 'react';
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { GenreListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
|
||||
|
|
@ -22,7 +22,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
|||
const { t } = useTranslation();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh<GenreListQuery>({
|
||||
gridRef,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
|
|
@ -66,6 +66,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
|||
<FilterBar>
|
||||
<GenreListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
|
|
|||
|
|
@ -8,19 +8,20 @@ import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-que
|
|||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { GenreListQuery } from '/@/renderer/api/types';
|
||||
|
||||
const GenreListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
const pageKey = 'genre';
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
|
||||
|
||||
const itemCountCheck = useGenreList({
|
||||
query: {
|
||||
...filter,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
SongListSort,
|
||||
SortOrder,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
export const getPlaylistSongsById = async (args: {
|
||||
|
|
@ -103,18 +102,7 @@ export const getGenreSongsById = async (args: {
|
|||
};
|
||||
for (const genreId of id) {
|
||||
const queryFilter: SongListQuery = {
|
||||
_custom: {
|
||||
...(server?.type === ServerType.JELLYFIN && {
|
||||
jellyfin: {
|
||||
GenreIds: genreId,
|
||||
},
|
||||
}),
|
||||
...(server?.type === ServerType.NAVIDROME && {
|
||||
navidrome: {
|
||||
genre_id: genreId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
genreIds: [genreId],
|
||||
sortBy: SongListSort.GENRE,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
|
|
@ -140,7 +128,9 @@ export const getGenreSongsById = async (args: {
|
|||
);
|
||||
|
||||
data.items.push(...res!.items);
|
||||
data.totalRecordCount += res!.totalRecordCount;
|
||||
if (data.totalRecordCount) {
|
||||
data.totalRecordCount += res!.totalRecordCount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
|
|
@ -202,14 +192,15 @@ export const getSongsByQuery = async (args: {
|
|||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
async ({ signal }) => {
|
||||
return api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
|
|
|
|||
|
|
@ -151,7 +151,12 @@ export const AddToPlaylistContextModal = ({
|
|||
server,
|
||||
signal,
|
||||
},
|
||||
query: { id: playlistId, startIndex: 0 },
|
||||
query: {
|
||||
id: playlistId,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
import { MutableRefObject, useMemo, useRef } from 'react';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
|
||||
options: {
|
||||
cacheTime: 0,
|
||||
keepPreviousData: false,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 50,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const handleLoadMore = () => {
|
||||
playlistSongsQueryInfinite.fetchNextPage();
|
||||
};
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs(table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
[table.columns],
|
||||
);
|
||||
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (detailQuery?.data?.rules) {
|
||||
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}
|
||||
|
||||
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}, [detailQuery?.data?.rules]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
|
||||
playlistId,
|
||||
});
|
||||
|
||||
const playlistSongData = useMemo(
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
|
||||
[playlistSongsQueryInfinite.data?.pages],
|
||||
);
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = () => {
|
||||
deletePlaylistMutation.mutate(
|
||||
{ query: { id: playlistId }, serverId: server?.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
closeAllModals();
|
||||
navigate(AppRoute.PLAYLISTS);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openDeletePlaylist = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
loading={deletePlaylistMutation.isLoading}
|
||||
onConfirm={handleDeletePlaylist}
|
||||
>
|
||||
Are you sure you want to delete this playlist?
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Group
|
||||
p="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map(
|
||||
(type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={() => handlePlay(type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
),
|
||||
)}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
if (!detailQuery.data || !server) return;
|
||||
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
|
||||
}}
|
||||
>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={openDeletePlaylist}>
|
||||
Delete playlist
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
|
||||
variant="subtle"
|
||||
>
|
||||
View full playlist
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
shouldUpdateSong
|
||||
stickyHeader
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => `${data.data.uniqueId}-${data.data.pageIndex}`}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={playlistSongData}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
<MotionGroup
|
||||
p="2rem"
|
||||
position="center"
|
||||
onViewportEnter={handleLoadMore}
|
||||
>
|
||||
<Button
|
||||
ref={loadMoreRef}
|
||||
compact
|
||||
disabled={!playlistSongsQueryInfinite.hasNextPage}
|
||||
loading={playlistSongsQueryInfinite.isFetchingNextPage}
|
||||
variant="subtle"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
|
||||
</Button>
|
||||
</MotionGroup>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useParams } from 'react-router';
|
||||
import { Badge, Text } from '/@/renderer/components';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { LibraryHeader } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
background: string;
|
||||
imagePlaceholderUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export const PlaylistDetailHeader = forwardRef(
|
||||
(
|
||||
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount || 0} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imagePlaceholderUrl={imagePlaceholderUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack>
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{isSmartPlaylist && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Badge
|
||||
radius="sm"
|
||||
size="md"
|
||||
>
|
||||
Smart Playlist
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -44,15 +44,16 @@ import {
|
|||
useSetPlaylistDetailTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { ListDisplayType, ServerType } from '/@/renderer/types';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import { toast } from '/@/renderer/components';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
songs?: Song[];
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const status = useCurrentStatus();
|
||||
|
|
@ -85,7 +86,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
|||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const iSClientSide = server?.type === ServerType.SUBSONIC;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
options: {
|
||||
enabled: !iSClientSide,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
|
|
@ -101,44 +107,51 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
|||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
if (!iSClientSide) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: PlaylistSongListQuery = {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
};
|
||||
const query: PlaylistSongListQuery = {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
query,
|
||||
);
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
query,
|
||||
}),
|
||||
);
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
songsRes?.items || [],
|
||||
songsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
}
|
||||
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
[filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
|
|
@ -270,6 +283,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
|||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const canDrag =
|
||||
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide;
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
|
|
@ -289,16 +305,17 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
|||
status,
|
||||
}}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
infiniteInitialRowCount={
|
||||
iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100
|
||||
}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={pagination.itemsPerPage || 100}
|
||||
rowClassRules={rowClassRules}
|
||||
rowDragEntireRow={
|
||||
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules
|
||||
}
|
||||
rowData={songs}
|
||||
rowDragEntireRow={canDrag}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowModelType={iSClientSide ? 'clientSide' : 'infinite'}
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,68 @@ const FILTERS = {
|
|||
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.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
|
|
@ -241,43 +303,55 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
if (server?.type !== ServerType.SUBSONIC) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
{
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
params.successCallback(
|
||||
songsRes?.items || [],
|
||||
songsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
} else {
|
||||
tableRef.current?.api.redrawRows();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
}
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from '/@/renderer/components/virtual-grid';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store';
|
||||
import { useCurrentServer, useListStoreByKey } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
|
||||
|
||||
|
|
@ -35,15 +35,12 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
|||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { display, grid, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { display, grid, filter } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Playlist>[] = defaultFullPlaylist
|
||||
? [PLAYLIST_CARD_ROWS.nameFull]
|
||||
: [PLAYLIST_CARD_ROWS.name];
|
||||
const rows: CardRow<Playlist>[] = [PLAYLIST_CARD_ROWS.nameFull];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case PlaylistListSort.DURATION:
|
||||
|
|
@ -66,7 +63,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
|||
}
|
||||
|
||||
return rows;
|
||||
}, [defaultFullPlaylist, filter.sortBy]);
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
|
|
@ -116,9 +113,9 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
|||
|
||||
const query: PlaylistListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
_custom: {},
|
||||
startIndex: skip,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.list(server?.id || '', query);
|
||||
|
|
@ -160,9 +157,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
|||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: defaultFullPlaylist
|
||||
? AppRoute.PLAYLISTS_DETAIL_SONGS
|
||||
: AppRoute.PLAYLISTS_DETAIL,
|
||||
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
}}
|
||||
width={width}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,38 @@ const FILTERS = {
|
|||
value: PlaylistListSort.UPDATED_AT,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.owner', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.OWNER,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.PUBLIC,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.UPDATED_AT,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface PlaylistListHeaderFiltersProps {
|
||||
|
|
@ -86,7 +118,7 @@ export const PlaylistListHeaderFilters = ({
|
|||
const server = useCurrentServer();
|
||||
const { setFilter, setTable, setTablePagination, setGrid, setDisplayType } =
|
||||
useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
|
||||
const { display, filter, table, grid } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFileAddFill } from 'react-icons/ri';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { LibraryItem, PlaylistListQuery, ServerType } from '/@/renderer/api/types';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
|
||||
interface PlaylistListHeaderProps {
|
||||
|
|
@ -37,8 +37,9 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
|||
});
|
||||
};
|
||||
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh<PlaylistListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
server,
|
||||
tableRef,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { VirtualTable } from '/@/renderer/components/virtual-table';
|
|||
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
|
||||
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface PlaylistListTableViewProps {
|
||||
itemCount?: number;
|
||||
|
|
@ -18,16 +18,11 @@ interface PlaylistListTableViewProps {
|
|||
export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
const pageKey = 'playlist';
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
if (!e.data) return;
|
||||
if (defaultFullPlaylist) {
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
|
||||
} else {
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
}
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
|
||||
};
|
||||
|
||||
const tableProps = useVirtualTable({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types';
|
||||
import type { PlaylistSongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
|
@ -22,32 +22,3 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
|
|||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled: !!server,
|
||||
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return pages?.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query: {
|
||||
...query,
|
||||
limit: query.limit || 50,
|
||||
startIndex: pageParam * (query.limit || 50),
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { NativeScrollArea, Spinner } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content';
|
||||
import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
const PlaylistDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const { color: background, colorId } = useFastAverageColor({
|
||||
algorithm: 'sqrt',
|
||||
id: playlistId,
|
||||
src: detailQuery?.data?.imageUrl,
|
||||
srcLoaded: !detailQuery?.isLoading,
|
||||
});
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
if (!background || colorId !== playlistId) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-${playlistId}`}>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>
|
||||
{detailQuery?.data?.name}
|
||||
</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
offset: 200,
|
||||
target: headerRef,
|
||||
}}
|
||||
>
|
||||
<PlaylistDetailHeader
|
||||
ref={headerRef}
|
||||
background={background}
|
||||
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
/>
|
||||
<PlaylistDetailContent tableRef={tableRef} />
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistDetailRoute;
|
||||
|
|
@ -144,10 +144,6 @@ const PlaylistDetailSongListRoute = () => {
|
|||
};
|
||||
|
||||
const itemCountCheck = usePlaylistSongList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
|
|
@ -157,10 +153,7 @@ const PlaylistDetailSongListRoute = () => {
|
|||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data?.totalRecordCount || itemCountCheck.data?.items.length;
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
|
|
@ -207,7 +200,12 @@ const PlaylistDetailSongListRoute = () => {
|
|||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
<PlaylistDetailSongListContent
|
||||
songs={
|
||||
server?.type === ServerType.SUBSONIC ? itemCountCheck.data?.items : undefined
|
||||
}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { PlaylistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { PlaylistListSort, PlaylistSongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content';
|
||||
|
|
@ -16,7 +16,7 @@ const PlaylistListRoute = () => {
|
|||
const server = useCurrentServer();
|
||||
const { playlistId } = useParams();
|
||||
const pageKey = 'playlist';
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { filter } = useListStoreByKey<PlaylistSongListQuery>({ key: pageKey });
|
||||
|
||||
const itemCountCheck = usePlaylistList({
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import debounce from 'lodash/debounce';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListQuery,
|
||||
AlbumListQuery,
|
||||
LibraryItem,
|
||||
SongListQuery,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
|
|
@ -24,7 +29,9 @@ export const SearchHeader = ({ tableRef, navigationId }: SearchHeaderProps) => {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const { filter } = useListStoreByKey({ key: itemType });
|
||||
const { filter } = useListStoreByKey<AlbumListQuery | AlbumArtistListQuery | SongListQuery>({
|
||||
key: itemType,
|
||||
});
|
||||
|
||||
const { handleRefreshTable } = useListFilterRefresh({
|
||||
itemType,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
|
|||
const SERVER_TYPES = [
|
||||
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
|
||||
{ label: 'Navidrome', value: ServerType.NAVIDROME },
|
||||
// { label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||
{ label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||
];
|
||||
|
||||
interface AddServerFormProps {
|
||||
|
|
|
|||
|
|
@ -375,28 +375,6 @@ export const ControlSettings = () => {
|
|||
isHidden: !isElectron(),
|
||||
title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Go to playlist songs page by default"
|
||||
defaultChecked={settings.defaultFullPlaylist}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
defaultFullPlaylist: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: t('setting.skipPlaylistPage', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: false,
|
||||
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const useSetRating = (args: MutationHookArgs) => {
|
|||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updateRating({ ...args, apiClientProps: { server } });
|
||||
return api.controller.setRating({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LibraryItem, Playlist } from '/@/renderer/api/types';
|
||||
import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists';
|
||||
|
|
@ -24,10 +24,6 @@ import { useCurrentServer, useGeneralSettings, useSettingsStoreActions } from '/
|
|||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
|
||||
interface SidebarPlaylistListProps {
|
||||
data: ReturnType<typeof usePlaylistList>['data'];
|
||||
}
|
||||
|
||||
const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -66,11 +62,7 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
|
|||
}
|
||||
|
||||
const path = data?.items[index].id
|
||||
? data.defaultFullPlaylist
|
||||
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
|
||||
: generatePath(AppRoute.PLAYLISTS_DETAIL, {
|
||||
playlistId: data?.items[index].id,
|
||||
})
|
||||
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
|
@ -181,12 +173,21 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
||||
export const SidebarPlaylistList = () => {
|
||||
const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0);
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { defaultFullPlaylist, sidebarCollapseShared } = useGeneralSettings();
|
||||
const { sidebarCollapseShared } = useGeneralSettings();
|
||||
const { toggleSidebarCollapseShare } = useSettingsStoreActions();
|
||||
const { type, username } = useCurrentServer() || {};
|
||||
const server = useCurrentServer();
|
||||
|
||||
const playlistsQuery = usePlaylistList({
|
||||
query: {
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const [rect, setRect] = useState({
|
||||
height: 0,
|
||||
|
|
@ -208,10 +209,12 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
|||
[handlePlayQueueAdd],
|
||||
);
|
||||
|
||||
const memoizedItemData = useMemo(() => {
|
||||
const base = { defaultFullPlaylist, handlePlay: handlePlayPlaylist };
|
||||
const data = playlistsQuery.data;
|
||||
|
||||
if (!type || !username || !data?.items) {
|
||||
const memoizedItemData = useMemo(() => {
|
||||
const base = { handlePlay: handlePlayPlaylist };
|
||||
|
||||
if (!server?.type || !server?.username || !data?.items) {
|
||||
return { ...base, items: data?.items };
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +222,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
|||
const shared: Playlist[] = [];
|
||||
|
||||
for (const playlist of data.items) {
|
||||
if (playlist.owner && playlist.owner !== username) {
|
||||
if (playlist.owner && playlist.owner !== server.username) {
|
||||
shared.push(playlist);
|
||||
} else {
|
||||
owned.push(playlist);
|
||||
|
|
@ -234,12 +237,11 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
|||
|
||||
return { ...base, items: final };
|
||||
}, [
|
||||
sidebarCollapseShared,
|
||||
data?.items,
|
||||
defaultFullPlaylist,
|
||||
handlePlayPlaylist,
|
||||
type,
|
||||
username,
|
||||
server?.type,
|
||||
server?.username,
|
||||
sidebarCollapseShared,
|
||||
toggleSidebarCollapseShare,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import {
|
|||
useGeneralSettings,
|
||||
useWindowSettings,
|
||||
} from '../../../store/settings.store';
|
||||
import { PlaylistListSort, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, MotionStack, Spinner, Tooltip } from '/@/renderer/components';
|
||||
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists';
|
||||
import { ServerType } from '/@/renderer/api/types';
|
||||
import { Button, MotionStack, Tooltip } from '/@/renderer/components';
|
||||
import { CreatePlaylistForm } from '/@/renderer/features/playlists';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
|
|
@ -110,15 +110,6 @@ export const Sidebar = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const playlistsQuery = usePlaylistList({
|
||||
query: {
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const expandFullScreenPlayer = () => {
|
||||
|
|
@ -198,7 +189,6 @@ export const Sidebar = () => {
|
|||
>
|
||||
{t('page.sidebar.playlists', { postProcess: 'titleCase' })}
|
||||
</Box>
|
||||
{playlistsQuery.isLoading && <Spinner />}
|
||||
</Group>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
|
|
@ -233,7 +223,7 @@ export const Sidebar = () => {
|
|||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<SidebarPlaylistList data={playlistsQuery.data} />
|
||||
<SidebarPlaylistList />
|
||||
</>
|
||||
)}
|
||||
</MotionStack>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
|
|
@ -22,7 +22,7 @@ export const JellyfinSongFilters = ({
|
|||
}: JellyfinSongFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useListFilterByKey({ key: pageKey });
|
||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||
|
||||
const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined;
|
||||
|
||||
|
|
@ -61,16 +61,16 @@ export const JellyfinSongFilters = ({
|
|||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
favorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter?._custom?.jellyfin?.IsFavorite,
|
||||
value: filter.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -84,9 +84,9 @@ export const JellyfinSongFilters = ({
|
|||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
|
|
@ -104,9 +104,9 @@ export const JellyfinSongFilters = ({
|
|||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
|
|
@ -115,7 +115,6 @@ export const JellyfinSongFilters = ({
|
|||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
|
|
@ -123,10 +122,10 @@ export const JellyfinSongFilters = ({
|
|||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
GenreIds: genreFilterString,
|
||||
IncludeItemTypes: 'Audio',
|
||||
},
|
||||
},
|
||||
genreIds: e,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
|
|
@ -151,18 +150,19 @@ export const JellyfinSongFilters = ({
|
|||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
required
|
||||
defaultValue={filter?._custom?.jellyfin?.minYear}
|
||||
defaultValue={filter?.minYear}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?.minYear}
|
||||
onChange={handleMinYearFilter}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.maxYear}
|
||||
defaultValue={filter?.maxYear}
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?.minYear}
|
||||
onChange={handleMaxYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
|
|
@ -22,9 +22,9 @@ export const NavidromeSongFilters = ({
|
|||
}: NavidromeSongFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useListFilterByKey({ key: pageKey });
|
||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||
|
||||
const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== undefined;
|
||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
query: {
|
||||
|
|
@ -47,12 +47,8 @@ export const NavidromeSongFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
genreIds: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
|
|
@ -68,12 +64,8 @@ export const NavidromeSongFilters = ({
|
|||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
starred: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
favorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
|
|
@ -81,7 +73,7 @@ export const NavidromeSongFilters = ({
|
|||
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
value: filter.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -133,7 +125,7 @@ export const NavidromeSongFilters = ({
|
|||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter._custom?.navidrome?.genre_id}
|
||||
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
width={150}
|
||||
onChange={handleGenresFilter}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps)
|
|||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { pageKey, customFilters, id } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<SongListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -174,9 +174,9 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps)
|
|||
const query: SongListQuery = {
|
||||
imageSize: 250,
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
...customFilters,
|
||||
startIndex: skip,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query, id);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ import {
|
|||
} from 'react-icons/ri';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { LibraryItem, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
|
@ -29,6 +35,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
|
|||
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filter';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
|
@ -165,25 +172,39 @@ const FILTERS = {
|
|||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface SongListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => {
|
||||
export const SongListHeaderFilters = ({
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: SongListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey, handlePlay, customFilters } = useListContext();
|
||||
const { display, table, filter, grid } = useListStoreByKey({
|
||||
const { display, table, filter, grid } = useListStoreByKey<SongListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
|
||||
const { setFilter, setGrid, setTable, setTablePagination, setDisplayType } =
|
||||
useListStoreActions();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
});
|
||||
|
|
@ -392,25 +413,32 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
|||
};
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
let FilterComponent;
|
||||
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
FilterComponent = NavidromeSongFilters;
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
FilterComponent = JellyfinSongFilters;
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
FilterComponent = SubsonicSongFilters;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!FilterComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeSongFilters
|
||||
customFilters={customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinSongFilters
|
||||
customFilters={customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<FilterComponent
|
||||
customFilters={customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
),
|
||||
title: 'Song Filters',
|
||||
});
|
||||
|
|
@ -429,8 +457,16 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
|||
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
|
||||
.some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
|
||||
const isGenericFilterApplied = filter?.favorite || filter?.genreIds?.length;
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied;
|
||||
}, [
|
||||
filter._custom?.jellyfin,
|
||||
filter._custom?.navidrome,
|
||||
filter?.favorite,
|
||||
filter?.genreIds?.length,
|
||||
server?.type,
|
||||
]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
|
|
@ -467,11 +503,15 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
|||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Divider orientation="vertical" />
|
||||
<OrderToggleButton
|
||||
sortOrder={filter.sortOrder}
|
||||
onToggle={handleToggleSortOrder}
|
||||
/>
|
||||
{server?.type !== ServerType.SUBSONIC && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<OrderToggleButton
|
||||
sortOrder={filter.sortOrder}
|
||||
onToggle={handleToggleSortOrder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { LibraryItem, SongListQuery } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';
|
||||
|
|
@ -33,12 +33,15 @@ export const SongListHeader = ({
|
|||
const cq = useContainerQuery();
|
||||
const genreRef = useRef<string>();
|
||||
|
||||
const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh({
|
||||
gridRef,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
tableRef,
|
||||
});
|
||||
const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh<SongListQuery>(
|
||||
{
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
tableRef,
|
||||
},
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = search(e) as SongListFilter;
|
||||
|
|
@ -96,6 +99,7 @@ export const SongListHeader = ({
|
|||
<FilterBar>
|
||||
<SongListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
|
|
|||
112
src/renderer/features/songs/components/subsonic-song-filter.tsx
Normal file
112
src/renderer/features/songs/components/subsonic-song-filter.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||
import { Select, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SubsonicSongFiltersProps {
|
||||
customFilters?: Partial<SongListFilter>;
|
||||
onFilterChange: (filters: SongListFilter) => void;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const SubsonicSongFilters = ({
|
||||
customFilters,
|
||||
onFilterChange,
|
||||
pageKey,
|
||||
serverId,
|
||||
}: SubsonicSongFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||
|
||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
genreIds: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
disabled: filter.genreIds !== undefined || isGenrePage || !!filter.searchTerm,
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
favorite: e.target.checked,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`ss-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
disabled={filter.disabled}
|
||||
size="xs"
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
{!isGenrePage && (
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
|
||||
disabled={!!filter.searchTerm}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
width={150}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
30
src/renderer/features/songs/queries/song-list-count-query.ts
Normal file
30
src/renderer/features/songs/queries/song-list-count-query.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { SongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useSongListCount = (args: QueryHookArgs<SongListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getSongListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.songs.count(
|
||||
serverId || '',
|
||||
Object.keys(query).length === 0 ? undefined : query,
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -10,11 +10,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
|
|||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
|
||||
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
|
||||
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query';
|
||||
|
||||
const TrackListRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -30,14 +30,7 @@ const TrackListRoute = () => {
|
|||
const value = {
|
||||
...(albumArtistId && { artistIds: [albumArtistId] }),
|
||||
...(genreId && {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
GenreIds: genreId,
|
||||
},
|
||||
navidrome: {
|
||||
genre_id: genreId,
|
||||
},
|
||||
},
|
||||
genreIds: [genreId],
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -76,29 +69,22 @@ const TrackListRoute = () => {
|
|||
return genre?.name;
|
||||
}, [genreId, genreList.data]);
|
||||
|
||||
const itemCountCheck = useSongList({
|
||||
const itemCountCheck = useSongListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...songListFilter,
|
||||
},
|
||||
query: songListFilter,
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
async (args: { initialSongId?: string; playType: Play }) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
const { initialSongId, playType } = args;
|
||||
const query: SongListQuery = { startIndex: 0, ...songListFilter };
|
||||
const query: SongListQuery = { ...songListFilter, limit: itemCount, startIndex: 0 };
|
||||
|
||||
if (albumArtistId) {
|
||||
handlePlayQueueAdd?.({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue