mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
@@ -42,6 +42,7 @@ import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
|||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||||
import {
|
import {
|
||||||
Album,
|
Album,
|
||||||
@@ -439,6 +440,7 @@ interface AlbumDetailSongsTableProps {
|
|||||||
const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table);
|
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table);
|
||||||
|
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
@@ -452,11 +454,11 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
|||||||
|
|
||||||
const filteredSongs = useMemo(() => {
|
const filteredSongs = useMemo(() => {
|
||||||
return sortSongList(
|
return sortSongList(
|
||||||
searchLibraryItems(songs, searchTerm, LibraryItem.SONG),
|
searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG),
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
);
|
);
|
||||||
}, [songs, searchTerm, sortBy, sortOrder]);
|
}, [songs, debouncedSearchTerm, sortBy, sortOrder]);
|
||||||
|
|
||||||
const { handleColumnReordered } = useItemListColumnReorder({
|
const { handleColumnReordered } = useItemListColumnReorder({
|
||||||
itemListKey: ItemListKey.ALBUM_DETAIL,
|
itemListKey: ItemListKey.ALBUM_DETAIL,
|
||||||
@@ -504,7 +506,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
|||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
// Remove groups when filtering
|
// Remove groups when filtering
|
||||||
if (searchTerm.trim()) {
|
if (debouncedSearchTerm.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +592,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
|||||||
},
|
},
|
||||||
rowHeight: 40,
|
rowHeight: 40,
|
||||||
}));
|
}));
|
||||||
}, [searchTerm, sortBy, discGroups, t]);
|
}, [debouncedSearchTerm, sortBy, discGroups, t]);
|
||||||
|
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,31 @@
|
|||||||
gap: var(--theme-spacing-2xl);
|
gap: var(--theme-spacing-2xl);
|
||||||
padding: 1rem 2rem 5rem;
|
padding: 1rem 2rem 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.album-section-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--theme-spacing-4xl);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-section-title {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--theme-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-section-divider-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-section-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,47 +1,61 @@
|
|||||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { Suspense, useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createSearchParams, generatePath, Link, useParams } from 'react-router';
|
import { createSearchParams, generatePath, Link, useParams } from 'react-router';
|
||||||
|
|
||||||
import styles from './album-artist-detail-content.module.css';
|
import styles from './album-artist-detail-content.module.css';
|
||||||
|
|
||||||
|
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
|
||||||
|
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||||
|
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
|
||||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
|
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
|
import {
|
||||||
|
CLIENT_SIDE_ALBUM_FILTERS,
|
||||||
|
ListSortByDropdownControlled,
|
||||||
|
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
|
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||||
import { useContainerQuery } from '/@/renderer/hooks';
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { ArtistItem, useCurrentServer, usePlayerSong } from '/@/renderer/store';
|
import {
|
||||||
|
ArtistItem,
|
||||||
|
useAppStore,
|
||||||
|
useCurrentServer,
|
||||||
|
useCurrentServerId,
|
||||||
|
usePlayerSong,
|
||||||
|
} from '/@/renderer/store';
|
||||||
import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store';
|
import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||||
|
import { sortAlbumList } from '/@/shared/api/utils';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
import { Grid } from '/@/shared/components/grid/grid';
|
import { Grid } from '/@/shared/components/grid/grid';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
|
||||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
|
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||||
import {
|
import {
|
||||||
AlbumArtist,
|
Album,
|
||||||
|
AlbumArtistDetailResponse,
|
||||||
AlbumListSort,
|
AlbumListSort,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
ServerType,
|
|
||||||
Song,
|
Song,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
TopSongListResponse,
|
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -141,7 +155,7 @@ const AlbumArtistMetadataBiography = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: '1280px' }}>
|
<section style={{ maxWidth: '1280px' }}>
|
||||||
<TextTitle fw={700} order={2}>
|
<TextTitle fw={700} order={3}>
|
||||||
{t('page.albumArtistDetail.about', {
|
{t('page.albumArtistDetail.about', {
|
||||||
artist: artistName,
|
artist: artistName,
|
||||||
})}
|
})}
|
||||||
@@ -154,20 +168,29 @@ const AlbumArtistMetadataBiography = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface AlbumArtistMetadataTopSongsProps {
|
interface AlbumArtistMetadataTopSongsProps {
|
||||||
|
detailQuery: ReturnType<typeof useSuspenseQuery<AlbumArtistDetailResponse>>;
|
||||||
routeId: string;
|
routeId: string;
|
||||||
topSongsQuery: ReturnType<typeof useQuery<TopSongListResponse>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AlbumArtistMetadataTopSongs = ({
|
const AlbumArtistMetadataTopSongs = ({
|
||||||
|
detailQuery,
|
||||||
routeId,
|
routeId,
|
||||||
topSongsQuery,
|
|
||||||
}: AlbumArtistMetadataTopSongsProps) => {
|
}: AlbumArtistMetadataTopSongsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
const topSongsQuery = useQuery(
|
||||||
|
artistsQueries.topSongs({
|
||||||
|
query: { artist: detailQuery.data?.name || '', artistId: routeId },
|
||||||
|
serverId: serverId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const songs = useMemo(() => topSongsQuery?.data?.items || [], [topSongsQuery?.data?.items]);
|
const songs = useMemo(() => topSongsQuery?.data?.items || [], [topSongsQuery?.data?.items]);
|
||||||
|
|
||||||
@@ -176,13 +199,13 @@ const AlbumArtistMetadataTopSongs = ({
|
|||||||
}, [tableConfig?.columns]);
|
}, [tableConfig?.columns]);
|
||||||
|
|
||||||
const filteredSongs = useMemo(() => {
|
const filteredSongs = useMemo(() => {
|
||||||
const filtered = searchLibraryItems(songs, searchTerm, LibraryItem.SONG);
|
const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
||||||
// When searching, show all results. Otherwise, limit to 5 if not showing all
|
// When searching, show all results. Otherwise, limit to 5 if not showing all
|
||||||
if (searchTerm.trim() || showAll) {
|
if (debouncedSearchTerm.trim() || showAll) {
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
return filtered.slice(0, 5);
|
return filtered.slice(0, 5);
|
||||||
}, [songs, searchTerm, showAll]);
|
}, [songs, debouncedSearchTerm, showAll]);
|
||||||
|
|
||||||
const { handleColumnReordered } = useItemListColumnReorder({
|
const { handleColumnReordered } = useItemListColumnReorder({
|
||||||
itemListKey: ItemListKey.SONG,
|
itemListKey: ItemListKey.SONG,
|
||||||
@@ -216,7 +239,7 @@ const AlbumArtistMetadataTopSongs = ({
|
|||||||
<section>
|
<section>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="flex-end" wrap="nowrap">
|
<Group align="flex-end" wrap="nowrap">
|
||||||
<TextTitle fw={700} order={2}>
|
<TextTitle fw={700} order={3}>
|
||||||
{t('page.albumArtistDetail.topSongs', {
|
{t('page.albumArtistDetail.topSongs', {
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})}
|
})}
|
||||||
@@ -247,7 +270,7 @@ const AlbumArtistMetadataTopSongs = ({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="flex-end" wrap="nowrap">
|
<Group align="flex-end" wrap="nowrap">
|
||||||
<TextTitle fw={700} order={2}>
|
<TextTitle fw={700} order={3}>
|
||||||
{t('page.albumArtistDetail.topSongs', {
|
{t('page.albumArtistDetail.topSongs', {
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})}
|
})}
|
||||||
@@ -404,14 +427,12 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AlbumArtistDetailContent = () => {
|
export const AlbumArtistDetailContent = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { artistItems, externalLinks, lastFM, musicBrainz } = useGeneralSettings();
|
const { artistItems, externalLinks, lastFM, musicBrainz } = useGeneralSettings();
|
||||||
const { albumArtistId, artistId } = useParams() as {
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
albumArtistId?: string;
|
albumArtistId?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
};
|
};
|
||||||
const routeId = (artistId || albumArtistId) as string;
|
const routeId = (artistId || albumArtistId) as string;
|
||||||
const { ref, ...cq } = useContainerQuery();
|
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
|
||||||
const [enabledItem, itemOrder] = useMemo(() => {
|
const [enabledItem, itemOrder] = useMemo(() => {
|
||||||
@@ -433,105 +454,27 @@ export const AlbumArtistDetailContent = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const artistDiscographyLink = `${generatePath(
|
const artistDiscographyLink = useMemo(
|
||||||
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY,
|
() =>
|
||||||
{
|
`${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
|
||||||
albumArtistId: routeId,
|
albumArtistId: routeId,
|
||||||
},
|
})}?${createSearchParams({
|
||||||
)}?${createSearchParams({
|
|
||||||
artistId: routeId,
|
|
||||||
artistName: detailQuery.data?.name || '',
|
|
||||||
})}`;
|
|
||||||
|
|
||||||
const artistSongsLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {
|
|
||||||
albumArtistId: routeId,
|
|
||||||
})}?${createSearchParams({
|
|
||||||
artistId: routeId,
|
|
||||||
artistName: detailQuery.data?.name || '',
|
|
||||||
})}`;
|
|
||||||
|
|
||||||
const topSongsQuery = useQuery(
|
|
||||||
artistsQueries.topSongs({
|
|
||||||
options: {
|
|
||||||
enabled: !!detailQuery.data?.name && enabledItem.topSongs,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
artist: detailQuery.data?.name || '',
|
|
||||||
artistId: routeId,
|
artistId: routeId,
|
||||||
},
|
artistName: detailQuery.data?.name || '',
|
||||||
serverId: server?.id,
|
})}`,
|
||||||
}),
|
[routeId, detailQuery.data?.name],
|
||||||
);
|
);
|
||||||
|
|
||||||
const carousels = useMemo(() => {
|
const artistSongsLink = useMemo(
|
||||||
return [
|
() =>
|
||||||
{
|
`${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {
|
||||||
isHidden: !enabledItem.recentAlbums || !routeId,
|
albumArtistId: routeId,
|
||||||
itemType: LibraryItem.ALBUM,
|
})}?${createSearchParams({
|
||||||
order: itemOrder.recentAlbums,
|
artistId: routeId,
|
||||||
query: {
|
artistName: detailQuery.data?.name || '',
|
||||||
artistIds: routeId ? [routeId] : undefined,
|
})}`,
|
||||||
compilation: false,
|
[routeId, detailQuery.data?.name],
|
||||||
},
|
);
|
||||||
rowCount: 2,
|
|
||||||
sortBy: AlbumListSort.RELEASE_DATE,
|
|
||||||
sortOrder: SortOrder.DESC,
|
|
||||||
title: (
|
|
||||||
<TextTitle fw={700} order={2}>
|
|
||||||
{t('page.albumArtistDetail.recentReleases', {
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
})}
|
|
||||||
</TextTitle>
|
|
||||||
),
|
|
||||||
uniqueId: 'recentReleases',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isHidden:
|
|
||||||
!enabledItem.compilations || server?.type === ServerType.SUBSONIC || !routeId,
|
|
||||||
itemType: LibraryItem.ALBUM,
|
|
||||||
order: itemOrder.compilations,
|
|
||||||
query: {
|
|
||||||
artistIds: routeId ? [routeId] : undefined,
|
|
||||||
compilation: true,
|
|
||||||
},
|
|
||||||
rowCount: 1,
|
|
||||||
sortBy: AlbumListSort.RELEASE_DATE,
|
|
||||||
sortOrder: SortOrder.DESC,
|
|
||||||
title: (
|
|
||||||
<TextTitle fw={700} order={2}>
|
|
||||||
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
|
|
||||||
</TextTitle>
|
|
||||||
),
|
|
||||||
uniqueId: 'compilationAlbums',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
data: (detailQuery.data?.similarArtists || []) as AlbumArtist[],
|
|
||||||
isHidden: !detailQuery.data?.similarArtists || !enabledItem.similarArtists,
|
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
|
||||||
order: itemOrder.similarArtists,
|
|
||||||
rowCount: 1,
|
|
||||||
title: (
|
|
||||||
<TextTitle fw={700} order={2}>
|
|
||||||
{t('page.albumArtistDetail.relatedArtists', {
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
})}
|
|
||||||
</TextTitle>
|
|
||||||
),
|
|
||||||
uniqueId: 'similarArtists',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
detailQuery.data?.similarArtists,
|
|
||||||
enabledItem.compilations,
|
|
||||||
enabledItem.recentAlbums,
|
|
||||||
enabledItem.similarArtists,
|
|
||||||
itemOrder.compilations,
|
|
||||||
itemOrder.recentAlbums,
|
|
||||||
itemOrder.similarArtists,
|
|
||||||
routeId,
|
|
||||||
server?.type,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const biography =
|
const biography =
|
||||||
detailQuery.data?.biography && enabledItem.biography ? detailQuery.data.biography : null;
|
detailQuery.data?.biography && enabledItem.biography ? detailQuery.data.biography : null;
|
||||||
@@ -544,7 +487,7 @@ export const AlbumArtistDetailContent = () => {
|
|||||||
const externalLinksOrder = 0.5;
|
const externalLinksOrder = 0.5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.contentContainer} ref={ref}>
|
<div className={styles.contentContainer}>
|
||||||
<div className={styles.detailContainer}>
|
<div className={styles.detailContainer}>
|
||||||
<AlbumArtistActionButtons
|
<AlbumArtistActionButtons
|
||||||
artistDiscographyLink={artistDiscographyLink}
|
artistDiscographyLink={artistDiscographyLink}
|
||||||
@@ -575,52 +518,264 @@ export const AlbumArtistDetailContent = () => {
|
|||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
)}
|
)}
|
||||||
{Boolean(topSongsQuery?.data?.items?.length) && enabledItem.topSongs && (
|
<Grid.Col order={itemOrder.recentAlbums} span={12}>
|
||||||
|
<ArtistAlbums />
|
||||||
|
</Grid.Col>
|
||||||
|
{enabledItem.topSongs && (
|
||||||
<Grid.Col order={itemOrder.topSongs} span={12}>
|
<Grid.Col order={itemOrder.topSongs} span={12}>
|
||||||
<AlbumArtistMetadataTopSongs
|
<AlbumArtistMetadataTopSongs
|
||||||
|
detailQuery={detailQuery}
|
||||||
routeId={routeId}
|
routeId={routeId}
|
||||||
topSongsQuery={topSongsQuery}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
)}
|
)}
|
||||||
{cq.height || cq.width
|
|
||||||
? carousels
|
|
||||||
.filter((c) => !c.isHidden)
|
|
||||||
.map((carousel) => (
|
|
||||||
<Grid.Col
|
|
||||||
key={`carousel-${carousel.uniqueId}`}
|
|
||||||
order={carousel.order}
|
|
||||||
span={12}
|
|
||||||
>
|
|
||||||
<Suspense fallback={<Spinner container />}>
|
|
||||||
{carousel.itemType === LibraryItem.ALBUM ? (
|
|
||||||
'query' in carousel &&
|
|
||||||
carousel.query &&
|
|
||||||
carousel.sortBy &&
|
|
||||||
carousel.sortOrder ? (
|
|
||||||
<AlbumInfiniteCarousel
|
|
||||||
query={carousel.query}
|
|
||||||
rowCount={carousel.rowCount}
|
|
||||||
sortBy={carousel.sortBy}
|
|
||||||
sortOrder={carousel.sortOrder}
|
|
||||||
title={carousel.title}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
) : carousel.itemType === LibraryItem.ALBUM_ARTIST ? (
|
|
||||||
'data' in carousel && carousel.data ? (
|
|
||||||
<AlbumArtistGridCarousel
|
|
||||||
data={carousel.data}
|
|
||||||
rowCount={carousel.rowCount}
|
|
||||||
title={carousel.title}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
) : null}
|
|
||||||
</Suspense>
|
|
||||||
</Grid.Col>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AlbumSectionProps {
|
||||||
|
albums: Album[];
|
||||||
|
controls: ItemControls;
|
||||||
|
cq: ReturnType<typeof useContainerQuery>;
|
||||||
|
rows: DataRow[] | undefined;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlbumSection = ({ albums, controls, cq, rows, title }: AlbumSectionProps) => {
|
||||||
|
const span = cq.isXl ? 3 : cq.isLg ? 4 : cq.isMd ? 6 : cq.isSm ? 8 : cq.isXs ? 12 : 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<div className={styles.albumSectionTitle}>
|
||||||
|
<TextTitle fw={700} order={3}>
|
||||||
|
{title}
|
||||||
|
</TextTitle>
|
||||||
|
<div className={styles.albumSectionDividerContainer}>
|
||||||
|
<div className={styles.albumSectionDivider} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Grid columns={24} gutter="md" type="container">
|
||||||
|
{albums.map((album) => (
|
||||||
|
<Grid.Col key={album.id} span={span}>
|
||||||
|
<MemoizedItemCard
|
||||||
|
controls={controls}
|
||||||
|
data={album}
|
||||||
|
enableDrag
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
rows={rows}
|
||||||
|
type="poster"
|
||||||
|
withControls
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArtistAlbums = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||||
|
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
||||||
|
const setAlbumArtistDetailSort = useAppStore((state) => state.actions.setAlbumArtistDetailSort);
|
||||||
|
const sortBy = albumArtistDetailSort.sortBy;
|
||||||
|
const sortOrder = albumArtistDetailSort.sortOrder;
|
||||||
|
|
||||||
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
|
albumArtistId?: string;
|
||||||
|
artistId?: string;
|
||||||
|
};
|
||||||
|
const routeId = (artistId || albumArtistId) as string;
|
||||||
|
|
||||||
|
const albumsQuery = useSuspenseQuery(
|
||||||
|
albumQueries.list({
|
||||||
|
query: {
|
||||||
|
artistIds: [routeId],
|
||||||
|
limit: -1,
|
||||||
|
sortBy: AlbumListSort.RELEASE_DATE,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
startIndex: 0,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
|
||||||
|
const controls = useDefaultItemListControls();
|
||||||
|
|
||||||
|
const filteredAndSortedAlbums = useMemo(() => {
|
||||||
|
const albums = albumsQuery.data?.items || [];
|
||||||
|
const searched = searchLibraryItems(albums, debouncedSearchTerm, LibraryItem.ALBUM);
|
||||||
|
return sortAlbumList(searched, sortBy, sortOrder);
|
||||||
|
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const albumsByReleaseType = useMemo(() => {
|
||||||
|
const albums = filteredAndSortedAlbums;
|
||||||
|
|
||||||
|
const grouped = albums.reduce(
|
||||||
|
(acc, album) => {
|
||||||
|
// Priority 1: Appears on - artist is not an album artist
|
||||||
|
const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId);
|
||||||
|
if (!isAlbumArtist) {
|
||||||
|
const appearsOnKey = 'appears-on';
|
||||||
|
if (!acc[appearsOnKey]) {
|
||||||
|
acc[appearsOnKey] = [];
|
||||||
|
}
|
||||||
|
acc[appearsOnKey].push(album);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Compilations
|
||||||
|
if (album.isCompilation) {
|
||||||
|
const compilationKey = 'compilation';
|
||||||
|
if (!acc[compilationKey]) {
|
||||||
|
acc[compilationKey] = [];
|
||||||
|
}
|
||||||
|
acc[compilationKey].push(album);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Single (includes EP and other non-album types)
|
||||||
|
const hasAlbumType = album.releaseTypes?.some(
|
||||||
|
(type) => type.toLowerCase() === 'album',
|
||||||
|
);
|
||||||
|
if (!hasAlbumType) {
|
||||||
|
const singleKey = 'single';
|
||||||
|
if (!acc[singleKey]) {
|
||||||
|
acc[singleKey] = [];
|
||||||
|
}
|
||||||
|
acc[singleKey].push(album);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Album
|
||||||
|
const albumKey = 'album';
|
||||||
|
if (!acc[albumKey]) {
|
||||||
|
acc[albumKey] = [];
|
||||||
|
}
|
||||||
|
acc[albumKey].push(album);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, Album[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}, [filteredAndSortedAlbums, routeId]);
|
||||||
|
|
||||||
|
const releaseTypeEntries = useMemo(() => {
|
||||||
|
const priorityOrder = ['album', 'single', 'compilation', 'appears-on'];
|
||||||
|
const getPriority = (releaseType: string) => {
|
||||||
|
const index = priorityOrder.indexOf(releaseType);
|
||||||
|
return index === -1 ? 999 : index;
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(albumsByReleaseType)
|
||||||
|
.map(([releaseType, albums]) => {
|
||||||
|
let displayName: string;
|
||||||
|
switch (releaseType) {
|
||||||
|
case 'album':
|
||||||
|
displayName = t('releaseType.primary.album', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'appears-on':
|
||||||
|
displayName = t('page.albumArtistDetail.appearsOn', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'compilation':
|
||||||
|
displayName = t('releaseType.secondary.compilation', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'single':
|
||||||
|
displayName = t('releaseType.primary.single', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
displayName = releaseType;
|
||||||
|
}
|
||||||
|
return { albums, displayName, releaseType };
|
||||||
|
})
|
||||||
|
.sort((a, b) => getPriority(a.releaseType) - getPriority(b.releaseType));
|
||||||
|
}, [albumsByReleaseType, t]);
|
||||||
|
|
||||||
|
const cq = useContainerQuery();
|
||||||
|
|
||||||
|
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useHotkeys([
|
||||||
|
[
|
||||||
|
binding.hotkey,
|
||||||
|
() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (releaseTypeEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm" w="100%">
|
||||||
|
<TextInput
|
||||||
|
flex={1}
|
||||||
|
leftSection={<Icon icon="search" />}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder={t('common.search', { postProcess: 'sentenceCase' })}
|
||||||
|
radius="xl"
|
||||||
|
ref={searchInputRef}
|
||||||
|
rightSection={
|
||||||
|
searchTerm ? (
|
||||||
|
<ActionIcon
|
||||||
|
icon="x"
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
size="sm"
|
||||||
|
variant="transparent"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={searchTerm}
|
||||||
|
/>
|
||||||
|
<ListSortByDropdownControlled
|
||||||
|
filters={CLIENT_SIDE_ALBUM_FILTERS}
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
setSortBy={(value) =>
|
||||||
|
setAlbumArtistDetailSort(value as AlbumListSort, sortOrder)
|
||||||
|
}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
<ListSortOrderToggleButtonControlled
|
||||||
|
setSortOrder={(value) => setAlbumArtistDetailSort(sortBy, value as SortOrder)}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<div className={styles.albumSectionContainer} ref={cq.ref}>
|
||||||
|
{releaseTypeEntries.map(({ albums, displayName, releaseType }) => (
|
||||||
|
<AlbumSection
|
||||||
|
albums={albums}
|
||||||
|
controls={controls}
|
||||||
|
cq={cq}
|
||||||
|
key={releaseType}
|
||||||
|
rows={rows}
|
||||||
|
title={displayName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { forwardRef, Fragment, Ref } from 'react';
|
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
@@ -34,16 +34,16 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
const routeId = (artistId || albumArtistId) as string;
|
const routeId = (artistId || albumArtistId) as string;
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const detailQuery = useQuery(
|
const detailQuery = useSuspenseQuery(
|
||||||
artistsQueries.albumArtistDetail({
|
artistsQueries.albumArtistDetail({
|
||||||
query: { id: routeId },
|
query: { id: routeId },
|
||||||
serverId: server?.id,
|
serverId: server?.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const albumCount = detailQuery?.data?.albumCount;
|
const albumCount = detailQuery.data?.albumCount;
|
||||||
const songCount = detailQuery?.data?.songCount;
|
const songCount = detailQuery.data?.songCount;
|
||||||
const duration = detailQuery?.data?.duration;
|
const duration = detailQuery.data?.duration;
|
||||||
const durationEnabled = duration !== null && duration !== undefined;
|
const durationEnabled = duration !== null && duration !== undefined;
|
||||||
|
|
||||||
const metadataItems = [
|
const metadataItems = [
|
||||||
@@ -93,68 +93,81 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = (type?: Play) => {
|
const handlePlay = useCallback(
|
||||||
if (!server?.id || !routeId) return;
|
(type?: Play) => {
|
||||||
addToQueueByFetch(
|
if (!server?.id || !routeId) return;
|
||||||
server.id,
|
addToQueueByFetch(
|
||||||
[routeId],
|
server.id,
|
||||||
LibraryItem.ALBUM_ARTIST,
|
[routeId],
|
||||||
type || playButtonBehavior,
|
LibraryItem.ALBUM_ARTIST,
|
||||||
);
|
type || playButtonBehavior,
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[addToQueueByFetch, playButtonBehavior, routeId, server.id],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFavorite = () => {
|
const handleFavorite = useCallback(() => {
|
||||||
if (!detailQuery?.data) return;
|
if (!detailQuery.data) return;
|
||||||
setFavorite(
|
setFavorite(
|
||||||
detailQuery.data._serverId,
|
detailQuery.data._serverId,
|
||||||
[detailQuery.data.id],
|
[detailQuery.data.id],
|
||||||
LibraryItem.ALBUM_ARTIST,
|
LibraryItem.ALBUM_ARTIST,
|
||||||
!detailQuery.data.userFavorite,
|
!detailQuery.data.userFavorite,
|
||||||
);
|
);
|
||||||
};
|
}, [detailQuery.data, setFavorite]);
|
||||||
|
|
||||||
const handleUpdateRating = (rating: number) => {
|
const handleUpdateRating = useCallback(
|
||||||
if (!detailQuery?.data) return;
|
(rating: number) => {
|
||||||
|
if (!detailQuery.data) return;
|
||||||
|
|
||||||
|
if (detailQuery.data.userRating === rating) {
|
||||||
|
return setRating(
|
||||||
|
detailQuery.data._serverId,
|
||||||
|
[detailQuery.data.id],
|
||||||
|
LibraryItem.ALBUM_ARTIST,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (detailQuery.data.userRating === rating) {
|
|
||||||
return setRating(
|
return setRating(
|
||||||
detailQuery.data._serverId,
|
detailQuery.data._serverId,
|
||||||
[detailQuery.data.id],
|
[detailQuery.data.id],
|
||||||
LibraryItem.ALBUM_ARTIST,
|
LibraryItem.ALBUM_ARTIST,
|
||||||
0,
|
rating,
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
[detailQuery.data, setRating],
|
||||||
|
);
|
||||||
|
|
||||||
return setRating(
|
const handleMoreOptions = useCallback(
|
||||||
detailQuery.data._serverId,
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
[detailQuery.data.id],
|
if (!detailQuery.data) return;
|
||||||
LibraryItem.ALBUM_ARTIST,
|
ContextMenuController.call({
|
||||||
rating,
|
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST },
|
||||||
);
|
event: e,
|
||||||
};
|
});
|
||||||
|
},
|
||||||
|
[detailQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const showRating = detailQuery.data?._serverType === ServerType.NAVIDROME;
|
||||||
if (!detailQuery?.data) return;
|
|
||||||
ContextMenuController.call({
|
|
||||||
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST },
|
|
||||||
event: e,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME;
|
|
||||||
|
|
||||||
const imageUrl = useItemImageUrl({
|
const imageUrl = useItemImageUrl({
|
||||||
id: detailQuery?.data?.imageId || undefined,
|
id: detailQuery.data?.imageId || undefined,
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedImageUrl = useMemo(() => {
|
||||||
|
return detailQuery.data?.imageUrl || imageUrl;
|
||||||
|
}, [detailQuery.data?.imageUrl, imageUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
imageUrl={imageUrl}
|
imageUrl={selectedImageUrl}
|
||||||
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
|
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title={detailQuery?.data?.name || ''}
|
title={detailQuery.data?.name || ''}
|
||||||
>
|
>
|
||||||
<Stack gap="md" w="100%">
|
<Stack gap="md" w="100%">
|
||||||
<Group className={styles.metadataGroup}>
|
<Group className={styles.metadataGroup}>
|
||||||
@@ -168,14 +181,14 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
<LibraryHeaderMenu
|
<LibraryHeaderMenu
|
||||||
favorite={detailQuery?.data?.userFavorite}
|
favorite={detailQuery.data?.userFavorite}
|
||||||
onArtistRadio={handleArtistRadio}
|
onArtistRadio={handleArtistRadio}
|
||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
onMore={handleMoreOptions}
|
onMore={handleMoreOptions}
|
||||||
onPlay={(type) => handlePlay(type)}
|
onPlay={(type) => handlePlay(type)}
|
||||||
onRating={showRating ? handleUpdateRating : undefined}
|
onRating={showRating ? handleUpdateRating : undefined}
|
||||||
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
||||||
rating={detailQuery?.data?.userRating || 0}
|
rating={detailQuery.data?.userRating || 0}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</LibraryHeader>
|
</LibraryHeader>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { useRef } from 'react';
|
import { Suspense, useRef } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router';
|
import { useLocation, useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
|
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
|
||||||
@@ -16,6 +17,7 @@ import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library
|
|||||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||||
import { useFastAverageColor, useWaitForColorCalculation } from '/@/renderer/hooks';
|
import { useFastAverageColor, useWaitForColorCalculation } from '/@/renderer/hooks';
|
||||||
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
const AlbumArtistDetailRoute = () => {
|
const AlbumArtistDetailRoute = () => {
|
||||||
@@ -39,9 +41,17 @@ const AlbumArtistDetailRoute = () => {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imageUrl = useItemImageUrl({
|
||||||
|
id: detailQuery?.data?.imageId || undefined,
|
||||||
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
|
type: 'header',
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedImageUrl = imageUrl || detailQuery.data?.imageUrl;
|
||||||
|
|
||||||
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
|
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
|
||||||
id: artistId,
|
id: artistId,
|
||||||
src: detailQuery.data?.imageUrl,
|
src: selectedImageUrl,
|
||||||
srcLoaded: !detailQuery.isLoading,
|
srcLoaded: !detailQuery.isLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +60,7 @@ const AlbumArtistDetailRoute = () => {
|
|||||||
const showBlurredImage = artistBackground;
|
const showBlurredImage = artistBackground;
|
||||||
|
|
||||||
const { isReady } = useWaitForColorCalculation({
|
const { isReady } = useWaitForColorCalculation({
|
||||||
hasImage: !!detailQuery.data?.imageUrl,
|
hasImage: !!selectedImageUrl,
|
||||||
isLoading: isColorLoading,
|
isLoading: isColorLoading,
|
||||||
routeId,
|
routeId,
|
||||||
showBlurredImage,
|
showBlurredImage,
|
||||||
@@ -86,7 +96,7 @@ const AlbumArtistDetailRoute = () => {
|
|||||||
<LibraryBackgroundImage
|
<LibraryBackgroundImage
|
||||||
blur={artistBackgroundBlur}
|
blur={artistBackgroundBlur}
|
||||||
headerRef={headerRef}
|
headerRef={headerRef}
|
||||||
imageUrl={detailQuery.data?.imageUrl || ''}
|
imageUrl={selectedImageUrl || ''}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />
|
<LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />
|
||||||
@@ -103,7 +113,9 @@ const AlbumArtistDetailRoute = () => {
|
|||||||
const AlbumArtistDetailRouteWithBoundary = () => {
|
const AlbumArtistDetailRouteWithBoundary = () => {
|
||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<AlbumArtistDetailRoute />
|
<Suspense fallback={<Spinner container />}>
|
||||||
|
<AlbumArtistDetailRoute />
|
||||||
|
</Suspense>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const ListSortByDropdown = ({
|
|||||||
|
|
||||||
interface ListSortByDropdownControlledProps {
|
interface ListSortByDropdownControlledProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
filters?: Array<{ defaultOrder: SortOrder; name: string; value: string }>;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
setSortBy: Dispatch<SetStateAction<string>>;
|
setSortBy: Dispatch<SetStateAction<string>>;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
@@ -86,6 +87,7 @@ interface ListSortByDropdownControlledProps {
|
|||||||
|
|
||||||
export const ListSortByDropdownControlled = ({
|
export const ListSortByDropdownControlled = ({
|
||||||
disabled,
|
disabled,
|
||||||
|
filters,
|
||||||
itemType,
|
itemType,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
sortBy,
|
sortBy,
|
||||||
@@ -93,8 +95,9 @@ export const ListSortByDropdownControlled = ({
|
|||||||
}: ListSortByDropdownControlledProps) => {
|
}: ListSortByDropdownControlledProps) => {
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
|
||||||
const sortByLabel =
|
const availableFilters = filters || (itemType && FILTERS[itemType]?.[server.type]) || [];
|
||||||
(itemType && FILTERS[itemType][server.type].find((f) => f.value === sortBy)?.name) || '—';
|
|
||||||
|
const sortByLabel = availableFilters.find((f) => f.value === sortBy)?.name || '—';
|
||||||
|
|
||||||
const handleSortByChange = (sortBy: string) => {
|
const handleSortByChange = (sortBy: string) => {
|
||||||
setSortBy(sortBy);
|
setSortBy(sortBy);
|
||||||
@@ -112,7 +115,7 @@ export const ListSortByDropdownControlled = ({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenu.Target>
|
</DropdownMenu.Target>
|
||||||
<DropdownMenu.Dropdown>
|
<DropdownMenu.Dropdown>
|
||||||
{FILTERS[itemType][server.type].map((f) => (
|
{availableFilters.map((f) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
isSelected={f.value === sortBy}
|
isSelected={f.value === sortBy}
|
||||||
key={`filter-${f.name}`}
|
key={`filter-${f.name}`}
|
||||||
@@ -210,6 +213,69 @@ const CLIENT_SIDE_SONG_FILTERS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const CLIENT_SIDE_ALBUM_FILTERS = [
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.ASC,
|
||||||
|
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.ALBUM_ARTIST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.DESC,
|
||||||
|
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.DURATION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.DESC,
|
||||||
|
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.FAVORITED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.ASC,
|
||||||
|
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.DESC,
|
||||||
|
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.PLAY_COUNT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.ASC,
|
||||||
|
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.RANDOM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.DESC,
|
||||||
|
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.RATING,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.releaseDate', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.RELEASE_DATE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.DESC,
|
||||||
|
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.YEAR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.DESC,
|
||||||
|
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
|
||||||
|
value: AlbumListSort.SONG_COUNT,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const ALBUM_LIST_FILTERS: Partial<
|
const ALBUM_LIST_FILTERS: Partial<
|
||||||
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
|
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
|
||||||
> = {
|
> = {
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export const useContainerQuery = (props?: UseContainerQueryProps) => {
|
|||||||
const isXs = width >= (xs || 360);
|
const isXs = width >= (xs || 360);
|
||||||
const isSm = width >= (sm || 600);
|
const isSm = width >= (sm || 600);
|
||||||
const isMd = width >= (md || 768);
|
const isMd = width >= (md || 768);
|
||||||
const isLg = width >= (lg || 1200);
|
const isLg = width >= (lg || 960);
|
||||||
const isXl = width >= (xl || 1500);
|
const isXl = width >= (xl || 1200);
|
||||||
const is2xl = width >= (xxl || 1920);
|
const is2xl = width >= (xxl || 1440);
|
||||||
const is3xl = width >= (xxxl || 2560);
|
const is3xl = width >= (xxxl || 1920);
|
||||||
|
|
||||||
const isCalculated = width !== 0;
|
const isCalculated = width !== 0;
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { devtools, persist } from 'zustand/middleware';
|
|||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
|
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { Platform } from '/@/shared/types/types';
|
import { Platform } from '/@/shared/types/types';
|
||||||
|
|
||||||
export interface AppSlice extends AppState {
|
export interface AppSlice extends AppState {
|
||||||
actions: {
|
actions: {
|
||||||
|
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
|
||||||
setAppStore: (data: Partial<AppSlice>) => void;
|
setAppStore: (data: Partial<AppSlice>) => void;
|
||||||
setPageSidebar: (key: string, value: boolean) => void;
|
setPageSidebar: (key: string, value: boolean) => void;
|
||||||
setPrivateMode: (enabled: boolean) => void;
|
setPrivateMode: (enabled: boolean) => void;
|
||||||
@@ -17,6 +19,10 @@ export interface AppSlice extends AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
albumArtistDetailSort: {
|
||||||
|
sortBy: AlbumListSort;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
};
|
||||||
commandPalette: CommandPaletteProps;
|
commandPalette: CommandPaletteProps;
|
||||||
isReorderingQueue: boolean;
|
isReorderingQueue: boolean;
|
||||||
pageSidebar: Record<string, boolean>;
|
pageSidebar: Record<string, boolean>;
|
||||||
@@ -53,6 +59,11 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
devtools(
|
devtools(
|
||||||
immer((set, get) => ({
|
immer((set, get) => ({
|
||||||
actions: {
|
actions: {
|
||||||
|
setAlbumArtistDetailSort: (sortBy, sortOrder) => {
|
||||||
|
set((state) => {
|
||||||
|
state.albumArtistDetailSort = { sortBy, sortOrder };
|
||||||
|
});
|
||||||
|
},
|
||||||
setAppStore: (data) => {
|
setAppStore: (data) => {
|
||||||
set({ ...get(), ...data });
|
set({ ...get(), ...data });
|
||||||
},
|
},
|
||||||
@@ -86,6 +97,10 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
albumArtistDetailSort: {
|
||||||
|
sortBy: AlbumListSort.RELEASE_DATE,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
},
|
||||||
commandPalette: {
|
commandPalette: {
|
||||||
close: () => {
|
close: () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TFunction } from 'react-i18next';
|
import { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { titleCase } from '/@/renderer/utils/title-case';
|
import { titleCase } from '/@/renderer/utils/title-case';
|
||||||
|
|
||||||
@@ -55,3 +55,42 @@ export const normalizeReleaseTypes = (types: string[], t: TFunction) => {
|
|||||||
|
|
||||||
return primary.concat(secondary, unknown);
|
return primary.concat(secondary, unknown);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeToPrimaryReleaseTypes = (types: string[], t: TFunction) => {
|
||||||
|
const primary: string[] = [];
|
||||||
|
for (const type of types) {
|
||||||
|
const lower = type.toLocaleLowerCase();
|
||||||
|
if (lower in PRIMARY_MAPPING) {
|
||||||
|
primary.push(
|
||||||
|
t(`releaseType.primary.${PRIMARY_MAPPING[lower]}`, { postProcess: 'sentenceCase' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no primary types found, use "other" category
|
||||||
|
if (primary.length === 0) {
|
||||||
|
primary.push(
|
||||||
|
t(`releaseType.primary.${PRIMARY_MAPPING.other}`, { postProcess: 'sentenceCase' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return primary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeToSecondaryReleaseTypes = (types: string[], t: TFunction) => {
|
||||||
|
const secondary: string[] = [];
|
||||||
|
for (const type of types) {
|
||||||
|
const lower = type.toLocaleLowerCase();
|
||||||
|
if (lower in SECONDARY_MAPPING) {
|
||||||
|
secondary.push(
|
||||||
|
t(`releaseType.secondary.${SECONDARY_MAPPING[lower]}`, {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
secondary.sort();
|
||||||
|
|
||||||
|
return secondary;
|
||||||
|
};
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ const normalizeAlbum = (
|
|||||||
originalDate: item.originalDate ? new Date(item.originalDate).toISOString() : null,
|
originalDate: item.originalDate ? new Date(item.originalDate).toISOString() : null,
|
||||||
playCount: item.playCount || 0,
|
playCount: item.playCount || 0,
|
||||||
releaseDate: item.releaseDate ? new Date(item.releaseDate).toISOString() : null,
|
releaseDate: item.releaseDate ? new Date(item.releaseDate).toISOString() : null,
|
||||||
releaseYear: item.minYear || null,
|
releaseYear: item.maxYear || null,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
songCount: item.songCount,
|
songCount: item.songCount,
|
||||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
|
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
|
||||||
@@ -285,7 +285,7 @@ const normalizeAlbumArtist = (
|
|||||||
},
|
},
|
||||||
server?: null | ServerListItem,
|
server?: null | ServerListItem,
|
||||||
): AlbumArtist => {
|
): AlbumArtist => {
|
||||||
const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
|
const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
|
||||||
|
|
||||||
let albumCount: number;
|
let albumCount: number;
|
||||||
let songCount: number;
|
let songCount: number;
|
||||||
|
|||||||
@@ -414,6 +414,25 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder:
|
|||||||
case AlbumListSort.RECENTLY_PLAYED:
|
case AlbumListSort.RECENTLY_PLAYED:
|
||||||
results = orderBy(results, ['lastPlayedAt'], [order]);
|
results = orderBy(results, ['lastPlayedAt'], [order]);
|
||||||
break;
|
break;
|
||||||
|
case AlbumListSort.RELEASE_DATE:
|
||||||
|
results = orderBy(
|
||||||
|
results,
|
||||||
|
[
|
||||||
|
(v) => {
|
||||||
|
if (v.releaseDate) {
|
||||||
|
return new Date(v.releaseDate).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the first day of the release year
|
||||||
|
if (v.releaseYear) {
|
||||||
|
return new Date(v.releaseYear, 0, 1).getTime();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[order],
|
||||||
|
);
|
||||||
|
break;
|
||||||
case AlbumListSort.SONG_COUNT:
|
case AlbumListSort.SONG_COUNT:
|
||||||
results = orderBy(results, ['songCount'], [order]);
|
results = orderBy(results, ['songCount'], [order]);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user