* [enhancement]: Support toggling Album/Track view for gneres The _primary_ purpose of this PR is to enable viewing tracks OR albums for genres. This has a few requirements: 1. Ability to set default route for genres, **except** when already on song/album page 2. Ability to toggle between album and genre view 3. Fixed refresh for genre ID Additionally, there was some refactoring: - Since the *-list-headers had very similar functions for search, export that as a hook instead * also use hook for album artist * support switching albumartist tracks/albums * remove toggle on song/album list, simplify logic
544 lines
22 KiB
TypeScript
544 lines
22 KiB
TypeScript
import { MutableRefObject, useCallback, useMemo } from 'react';
|
|
import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
|
|
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
|
import { Box, Group, Stack } from '@mantine/core';
|
|
import { useSetState } from '@mantine/hooks';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { FaLastfmSquare } from 'react-icons/fa';
|
|
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
|
|
import { SiMusicbrainz } from 'react-icons/si';
|
|
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 { Button, Popover, Spoiler } from '/@/renderer/components';
|
|
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
|
import {
|
|
TableConfigDropdown,
|
|
VirtualTable,
|
|
getColumnDefs,
|
|
} from '/@/renderer/components/virtual-table';
|
|
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
|
|
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
|
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
|
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
|
import {
|
|
useHandleGeneralContextMenu,
|
|
useHandleTableContextMenu,
|
|
} from '/@/renderer/features/context-menu';
|
|
import {
|
|
ALBUM_CONTEXT_MENU_ITEMS,
|
|
SONG_CONTEXT_MENU_ITEMS,
|
|
} from '/@/renderer/features/context-menu/context-menu-items';
|
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
|
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
|
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
|
import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
|
|
import { AppRoute } from '/@/renderer/router/routes';
|
|
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
|
|
import {
|
|
useGeneralSettings,
|
|
usePlayButtonBehavior,
|
|
useSettingsStoreActions,
|
|
useTableSettings,
|
|
} from '/@/renderer/store/settings.store';
|
|
import { Play } from '/@/renderer/types';
|
|
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
|
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
|
|
|
const isFullWidthRow = (node: RowNode) => {
|
|
return node.id?.startsWith('disc-');
|
|
};
|
|
|
|
const ContentContainer = styled.div`
|
|
position: relative;
|
|
z-index: 0;
|
|
`;
|
|
|
|
const DetailContainer = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2rem;
|
|
padding: 1rem 2rem 5rem;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
interface AlbumDetailContentProps {
|
|
background?: string;
|
|
tableRef: MutableRefObject<AgGridReactType | null>;
|
|
}
|
|
|
|
export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentProps) => {
|
|
const { t } = useTranslation();
|
|
const { albumId } = useParams() as { albumId: string };
|
|
const server = useCurrentServer();
|
|
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
|
const cq = useContainerQuery();
|
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
|
const tableConfig = useTableSettings('albumDetail');
|
|
const { setTable } = useSettingsStoreActions();
|
|
const status = useCurrentStatus();
|
|
const isFocused = useAppFocus();
|
|
const currentSong = useCurrentSong();
|
|
const { externalLinks } = useGeneralSettings();
|
|
const genreRoute = useGenreRoute();
|
|
|
|
const columnDefs = useMemo(
|
|
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
|
|
[tableConfig.columns],
|
|
);
|
|
|
|
const getRowHeight = useCallback(
|
|
(params: RowHeightParams) => {
|
|
if (isFullWidthRow(params.node)) {
|
|
return 45;
|
|
}
|
|
|
|
return tableConfig.rowHeight;
|
|
},
|
|
[tableConfig.rowHeight],
|
|
);
|
|
|
|
const songsRowData = useMemo(() => {
|
|
if (!detailQuery.data?.songs) {
|
|
return [];
|
|
}
|
|
|
|
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
|
|
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
|
|
|
for (const discNumber of uniqueDiscNumbers.values()) {
|
|
const songsByDiscNumber = detailQuery.data?.songs.filter(
|
|
(s) => s.discNumber === discNumber,
|
|
);
|
|
|
|
const discSubtitle = songsByDiscNumber?.[0]?.discSubtitle;
|
|
const discName = [`Disc ${discNumber}`.toLocaleUpperCase(), discSubtitle]
|
|
.filter(Boolean)
|
|
.join(': ');
|
|
|
|
rowData.push({
|
|
id: `disc-${discNumber}`,
|
|
name: discName,
|
|
});
|
|
rowData.push(...songsByDiscNumber);
|
|
}
|
|
|
|
return rowData;
|
|
}, [detailQuery.data?.songs]);
|
|
|
|
const [pagination, setPagination] = useSetState({
|
|
artist: 0,
|
|
});
|
|
|
|
const handleNextPage = useCallback(
|
|
(key: 'artist') => {
|
|
setPagination({
|
|
[key]: pagination[key as keyof typeof pagination] + 1,
|
|
});
|
|
},
|
|
[pagination, setPagination],
|
|
);
|
|
|
|
const handlePreviousPage = useCallback(
|
|
(key: 'artist') => {
|
|
setPagination({
|
|
[key]: pagination[key as keyof typeof pagination] - 1,
|
|
});
|
|
},
|
|
[pagination, setPagination],
|
|
);
|
|
|
|
const artistQuery = useAlbumList({
|
|
options: {
|
|
cacheTime: 1000 * 60,
|
|
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
|
|
keepPreviousData: true,
|
|
staleTime: 1000 * 60,
|
|
},
|
|
query: {
|
|
_custom: {
|
|
jellyfin: {
|
|
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
|
ExcludeItemIds: detailQuery?.data?.id,
|
|
},
|
|
navidrome: {
|
|
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
|
},
|
|
},
|
|
limit: 15,
|
|
sortBy: AlbumListSort.YEAR,
|
|
sortOrder: SortOrder.DESC,
|
|
startIndex: 0,
|
|
},
|
|
serverId: server?.id,
|
|
});
|
|
|
|
const relatedAlbumGenresRequest = {
|
|
_custom: {
|
|
jellyfin: {
|
|
GenreIds: detailQuery?.data?.genres?.[0]?.id,
|
|
},
|
|
navidrome: {
|
|
genre_id: detailQuery?.data?.genres?.[0]?.id,
|
|
},
|
|
},
|
|
limit: 15,
|
|
sortBy: AlbumListSort.RANDOM,
|
|
sortOrder: SortOrder.ASC,
|
|
startIndex: 0,
|
|
};
|
|
|
|
const relatedAlbumGenresQuery = useAlbumList({
|
|
options: {
|
|
cacheTime: 1000 * 60,
|
|
enabled: !!detailQuery?.data?.genres?.[0],
|
|
queryKey: queryKeys.albums.related(
|
|
server?.id || '',
|
|
albumId,
|
|
relatedAlbumGenresRequest,
|
|
),
|
|
staleTime: 1000 * 60,
|
|
},
|
|
query: relatedAlbumGenresRequest,
|
|
serverId: server?.id,
|
|
});
|
|
|
|
const carousels = [
|
|
{
|
|
data: artistQuery?.data?.items.filter((a) => a.id !== detailQuery?.data?.id),
|
|
isHidden: !artistQuery?.data?.items.filter((a) => a.id !== detailQuery?.data?.id)
|
|
.length,
|
|
loading: artistQuery?.isLoading || artistQuery.isFetching,
|
|
pagination: {
|
|
handleNextPage: () => handleNextPage('artist'),
|
|
handlePreviousPage: () => handlePreviousPage('artist'),
|
|
hasPreviousPage: pagination.artist > 0,
|
|
},
|
|
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
|
|
uniqueId: 'mostPlayed',
|
|
},
|
|
{
|
|
data: relatedAlbumGenresQuery?.data?.items.filter(
|
|
(a) => a.id !== detailQuery?.data?.id,
|
|
),
|
|
isHidden: !relatedAlbumGenresQuery?.data?.items.filter(
|
|
(a) => a.id !== detailQuery?.data?.id,
|
|
).length,
|
|
loading: relatedAlbumGenresQuery?.isLoading || relatedAlbumGenresQuery.isFetching,
|
|
title: t('page.albumDetail.moreFromGeneric', {
|
|
item: detailQuery?.data?.genres?.[0]?.name,
|
|
postProcess: 'sentenceCase',
|
|
}),
|
|
uniqueId: 'relatedGenres',
|
|
},
|
|
];
|
|
const playButtonBehavior = usePlayButtonBehavior();
|
|
|
|
const handlePlay = async (playType?: Play) => {
|
|
handlePlayQueueAdd?.({
|
|
byData: detailQuery?.data?.songs,
|
|
playType: playType || playButtonBehavior,
|
|
});
|
|
};
|
|
|
|
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
|
|
|
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
|
if (!e.data || e.node.isFullWidthCell()) return;
|
|
|
|
const rowData: QueueSong[] = [];
|
|
e.api.forEachNode((node) => {
|
|
if (!node.data || node.isFullWidthCell()) return;
|
|
rowData.push(node.data);
|
|
});
|
|
|
|
handlePlayQueueAdd?.({
|
|
byData: rowData,
|
|
initialSongId: e.data.id,
|
|
playType: playButtonBehavior,
|
|
});
|
|
};
|
|
|
|
const createFavoriteMutation = useCreateFavorite({});
|
|
const deleteFavoriteMutation = useDeleteFavorite({});
|
|
|
|
const handleFavorite = () => {
|
|
if (!detailQuery?.data) return;
|
|
|
|
if (detailQuery.data.userFavorite) {
|
|
deleteFavoriteMutation.mutate({
|
|
query: {
|
|
id: [detailQuery.data.id],
|
|
type: LibraryItem.ALBUM,
|
|
},
|
|
serverId: detailQuery.data.serverId,
|
|
});
|
|
} else {
|
|
createFavoriteMutation.mutate({
|
|
query: {
|
|
id: [detailQuery.data.id],
|
|
type: LibraryItem.ALBUM,
|
|
},
|
|
serverId: detailQuery.data.serverId,
|
|
});
|
|
}
|
|
};
|
|
|
|
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
|
const comment = detailQuery?.data?.comment;
|
|
|
|
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
|
LibraryItem.ALBUM,
|
|
ALBUM_CONTEXT_MENU_ITEMS,
|
|
);
|
|
|
|
const onColumnMoved = useCallback(() => {
|
|
const { columnApi } = tableRef?.current || {};
|
|
const columnsOrder = columnApi?.getAllGridColumns();
|
|
|
|
if (!columnsOrder) return;
|
|
|
|
const columnsInSettings = tableConfig.columns;
|
|
const updatedColumns = [];
|
|
for (const column of columnsOrder) {
|
|
const columnInSettings = columnsInSettings.find(
|
|
(c) => c.column === column.getColDef().colId,
|
|
);
|
|
|
|
if (columnInSettings) {
|
|
updatedColumns.push({
|
|
...columnInSettings,
|
|
...(!tableConfig.autoFit && {
|
|
width: column.getActualWidth(),
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
setTable('albumDetail', { ...tableConfig, columns: updatedColumns });
|
|
}, [setTable, tableConfig, tableRef]);
|
|
|
|
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
|
|
|
const mbzId = detailQuery?.data?.mbzId;
|
|
|
|
return (
|
|
<ContentContainer>
|
|
<LibraryBackgroundOverlay $backgroundColor={background} />
|
|
<DetailContainer>
|
|
<Box component="section">
|
|
<Group
|
|
position="apart"
|
|
spacing="sm"
|
|
>
|
|
<Group>
|
|
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
|
<Button
|
|
compact
|
|
loading={
|
|
createFavoriteMutation.isLoading ||
|
|
deleteFavoriteMutation.isLoading
|
|
}
|
|
variant="subtle"
|
|
onClick={handleFavorite}
|
|
>
|
|
{detailQuery?.data?.userFavorite ? (
|
|
<RiHeartFill
|
|
color="red"
|
|
size={20}
|
|
/>
|
|
) : (
|
|
<RiHeartLine size={20} />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
compact
|
|
variant="subtle"
|
|
onClick={(e) => {
|
|
if (!detailQuery?.data) return;
|
|
handleGeneralContextMenu(e, [detailQuery.data!]);
|
|
}}
|
|
>
|
|
<RiMoreFill size={20} />
|
|
</Button>
|
|
</Group>
|
|
|
|
<Popover position="bottom-end">
|
|
<Popover.Target>
|
|
<Button
|
|
compact
|
|
size="md"
|
|
variant="subtle"
|
|
>
|
|
<RiSettings2Fill size={20} />
|
|
</Button>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<TableConfigDropdown type="albumDetail" />
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
</Group>
|
|
</Box>
|
|
{showGenres && (
|
|
<Box component="section">
|
|
<Group spacing="sm">
|
|
{detailQuery?.data?.genres?.map((genre) => (
|
|
<Button
|
|
key={`genre-${genre.id}`}
|
|
compact
|
|
component={Link}
|
|
radius={0}
|
|
size="md"
|
|
to={generatePath(genreRoute, {
|
|
genreId: genre.id,
|
|
})}
|
|
variant="outline"
|
|
>
|
|
{genre.name}
|
|
</Button>
|
|
))}
|
|
</Group>
|
|
</Box>
|
|
)}
|
|
{externalLinks ? (
|
|
<Box component="section">
|
|
<Group spacing="sm">
|
|
<Button
|
|
compact
|
|
component="a"
|
|
href={`https://www.last.fm/music/${encodeURIComponent(
|
|
detailQuery?.data?.albumArtist || '',
|
|
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
|
|
radius="md"
|
|
rel="noopener noreferrer"
|
|
size="md"
|
|
target="_blank"
|
|
tooltip={{
|
|
label: t('action.openIn.lastfm'),
|
|
}}
|
|
variant="subtle"
|
|
>
|
|
<FaLastfmSquare size={25} />
|
|
</Button>
|
|
{mbzId ? (
|
|
<Button
|
|
compact
|
|
component="a"
|
|
href={`https://musicbrainz.org/release/${mbzId}`}
|
|
radius="md"
|
|
rel="noopener noreferrer"
|
|
size="md"
|
|
target="_blank"
|
|
tooltip={{
|
|
label: t('action.openIn.musicbrainz'),
|
|
}}
|
|
variant="subtle"
|
|
>
|
|
<SiMusicbrainz size={25} />
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Box>
|
|
) : null}
|
|
{comment && (
|
|
<Box component="section">
|
|
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
|
</Box>
|
|
)}
|
|
<Box style={{ minHeight: '300px' }}>
|
|
<VirtualTable
|
|
key={`table-${tableConfig.rowHeight}`}
|
|
ref={tableRef}
|
|
autoHeight
|
|
stickyHeader
|
|
suppressCellFocus
|
|
suppressLoadingOverlay
|
|
suppressRowDrag
|
|
autoFitColumns={tableConfig.autoFit}
|
|
columnDefs={columnDefs}
|
|
context={{
|
|
currentSong,
|
|
isFocused,
|
|
onCellContextMenu,
|
|
status,
|
|
}}
|
|
enableCellChangeFlash={false}
|
|
fullWidthCellRenderer={FullWidthDiscCell}
|
|
getRowHeight={getRowHeight}
|
|
getRowId={(data) => data.data.id}
|
|
isFullWidthRow={(data) => {
|
|
return isFullWidthRow(data.rowNode) || false;
|
|
}}
|
|
isRowSelectable={(data) => {
|
|
if (isFullWidthRow(data.data)) return false;
|
|
return true;
|
|
}}
|
|
rowClassRules={rowClassRules}
|
|
rowData={songsRowData}
|
|
rowSelection="multiple"
|
|
onCellContextMenu={onCellContextMenu}
|
|
onColumnMoved={onColumnMoved}
|
|
onRowDoubleClicked={handleRowDoubleClick}
|
|
/>
|
|
</Box>
|
|
<Stack
|
|
ref={cq.ref}
|
|
mt="3rem"
|
|
spacing="lg"
|
|
>
|
|
{cq.height || cq.width ? (
|
|
<>
|
|
{carousels
|
|
.filter((c) => !c.isHidden)
|
|
.map((carousel, index) => (
|
|
<MemoizedSwiperGridCarousel
|
|
key={`carousel-${carousel.uniqueId}-${index}`}
|
|
cardRows={[
|
|
{
|
|
property: 'name',
|
|
route: {
|
|
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
|
slugs: [
|
|
{
|
|
idProperty: 'id',
|
|
slugProperty: 'albumId',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
arrayProperty: 'name',
|
|
property: 'albumArtists',
|
|
route: {
|
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
|
slugs: [
|
|
{
|
|
idProperty: 'id',
|
|
slugProperty: 'albumArtistId',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]}
|
|
data={carousel.data}
|
|
isLoading={carousel.loading}
|
|
itemType={LibraryItem.ALBUM}
|
|
route={{
|
|
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
|
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
|
}}
|
|
title={{
|
|
label: carousel.title,
|
|
}}
|
|
uniqueId={carousel.uniqueId}
|
|
/>
|
|
))}
|
|
</>
|
|
) : null}
|
|
</Stack>
|
|
</DetailContainer>
|
|
</ContentContainer>
|
|
);
|
|
};
|