mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
redesign album detail page
This commit is contained in:
@@ -70,6 +70,7 @@
|
||||
"edit": "edit",
|
||||
"enable": "enable",
|
||||
"expand": "expand",
|
||||
"externalLinks": "external links",
|
||||
"favorite": "favorite",
|
||||
"filter_one": "filter",
|
||||
"filter_other": "filters",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.content-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
@@ -8,5 +9,59 @@
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-lg);
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-layout {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--theme-spacing-lg);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metadata-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.songs-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@container (min-width: $mantine-breakpoint-lg) {
|
||||
.content-layout {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: var(--theme-spacing-xl);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.songs-column {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
/* Prevent sticky headers from extending into the right margins */
|
||||
.songs-column :global(.fs-item-table-list-module-sticky-header),
|
||||
.songs-column :global(.fs-item-table-list-module-sticky-group-row) {
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.metadata-column {
|
||||
position: sticky;
|
||||
top: calc(var(--theme-spacing-lg) + 4rem);
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
align-self: start;
|
||||
width: 300px;
|
||||
max-height: calc(100vh - 90px - var(--theme-spacing-lg) - 4rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import { ReactNode, Suspense, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
|
||||
import styles from './album-detail-content.module.css';
|
||||
|
||||
@@ -12,36 +12,310 @@ import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useGeneralSettings,
|
||||
usePlayButtonBehavior,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
formatDateAbsoluteUTC,
|
||||
formatDurationString,
|
||||
formatSizeString,
|
||||
titleCase,
|
||||
} from '/@/renderer/utils';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Pill, PillLink } from '/@/shared/components/pill/pill';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { AlbumListSort, LibraryItem, Song, SortOrder } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
Album,
|
||||
AlbumListSort,
|
||||
ExplicitStatus,
|
||||
LibraryItem,
|
||||
Song,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
interface AlbumDetailContentProps {
|
||||
background?: string;
|
||||
}
|
||||
|
||||
interface AlbumMetadataTagsProps {
|
||||
album: Album | undefined;
|
||||
}
|
||||
|
||||
const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metadataItems = useMemo(() => {
|
||||
if (!album) return [];
|
||||
|
||||
const originalDifferentFromRelease =
|
||||
album.originalDate && album.originalDate !== album.releaseDate;
|
||||
|
||||
const releasePrefix = originalDifferentFromRelease
|
||||
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
|
||||
: '♫';
|
||||
|
||||
const releaseTypes = normalizeReleaseTypes(album.releaseTypes ?? [], t).map((type) => ({
|
||||
id: type,
|
||||
value: titleCase(type),
|
||||
}));
|
||||
|
||||
const items: Array<{ id: string; value: ReactNode | string | undefined }> = [];
|
||||
|
||||
if (originalDifferentFromRelease && album.originalDate) {
|
||||
items.push({
|
||||
id: 'originalDate',
|
||||
value: `♫ ${formatDateAbsoluteUTC(album.originalDate)}`,
|
||||
});
|
||||
}
|
||||
|
||||
items.push(...releaseTypes);
|
||||
|
||||
items.push(
|
||||
{
|
||||
id: 'releaseDate',
|
||||
value: album.releaseDate
|
||||
? `${releasePrefix} ${formatDateAbsoluteUTC(album.releaseDate)}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'releaseYear',
|
||||
value: album.releaseYear?.toString(),
|
||||
},
|
||||
{
|
||||
id: 'songCount',
|
||||
value: album.songCount
|
||||
? t('entity.trackWithCount', {
|
||||
count: album.songCount,
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
value: album.duration ? (
|
||||
<Flex align="center" gap="xs">
|
||||
<Icon icon="duration" size="md" /> {formatDurationString(album.duration)}
|
||||
</Flex>
|
||||
) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
value: album.size ? formatSizeString(album.size) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'playCount',
|
||||
value:
|
||||
typeof album.playCount === 'number'
|
||||
? t('entity.play', {
|
||||
count: album.playCount,
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'explicitStatus',
|
||||
value:
|
||||
album.explicitStatus === ExplicitStatus.EXPLICIT
|
||||
? t('common.explicit', { postProcess: 'sentenceCase' })
|
||||
: album.explicitStatus === ExplicitStatus.CLEAN
|
||||
? t('common.clean', { postProcess: 'sentenceCase' })
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'isCompilation',
|
||||
value:
|
||||
album.isCompilation !== null
|
||||
? t('filter.isCompilation', { postProcess: 'sentenceCase' })
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'recordLabels',
|
||||
value:
|
||||
album.recordLabels && album.recordLabels.length > 0
|
||||
? album.recordLabels.join(', ')
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'version',
|
||||
value: album.version || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return items.filter((item) => item.value);
|
||||
}, [album, t]);
|
||||
|
||||
if (metadataItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} isNoSelect size="sm" tt="uppercase">
|
||||
{t('common.tags', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<Pill.Group>
|
||||
{metadataItems.map((item, index) => (
|
||||
<Pill key={`item-${item.id}-${index}`} size="md">
|
||||
{item.value}
|
||||
</Pill>
|
||||
))}
|
||||
</Pill.Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlbumMetadataGenresProps {
|
||||
genres?: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const AlbumMetadataGenres = ({ genres }: AlbumMetadataGenresProps) => {
|
||||
const { t } = useTranslation();
|
||||
const genreRoute = useGenreRoute();
|
||||
|
||||
if (!genres || genres.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} isNoSelect size="sm" tt="uppercase">
|
||||
{t('entity.genre', {
|
||||
count: genres.length,
|
||||
})}
|
||||
</Text>
|
||||
<Pill.Group>
|
||||
{genres.map((genre) => (
|
||||
<PillLink
|
||||
key={`genre-${genre.id}`}
|
||||
size="md"
|
||||
to={generatePath(genreRoute, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
>
|
||||
{genre.name}
|
||||
</PillLink>
|
||||
))}
|
||||
</Pill.Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlbumMetadataArtistsProps {
|
||||
artists?: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const AlbumMetadataArtists = ({ artists }: AlbumMetadataArtistsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!artists || artists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} isNoSelect size="sm" tt="uppercase">
|
||||
{t('entity.albumArtist', {
|
||||
count: artists.length,
|
||||
})}
|
||||
</Text>
|
||||
<Pill.Group>
|
||||
{artists.map((artist) => (
|
||||
<PillLink
|
||||
key={`artist-${artist.id}`}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</PillLink>
|
||||
))}
|
||||
</Pill.Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlbumMetadataExternalLinksProps {
|
||||
albumArtist?: string;
|
||||
albumName?: string;
|
||||
externalLinks: boolean;
|
||||
lastFM: boolean;
|
||||
mbzId?: null | string;
|
||||
musicBrainz: boolean;
|
||||
}
|
||||
|
||||
const AlbumMetadataExternalLinks = ({
|
||||
albumArtist,
|
||||
albumName,
|
||||
externalLinks,
|
||||
lastFM,
|
||||
mbzId,
|
||||
musicBrainz,
|
||||
}: AlbumMetadataExternalLinksProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!externalLinks || (!lastFM && !musicBrainz)) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} isNoSelect size="sm" tt="uppercase">
|
||||
{t('common.externalLinks', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
{lastFM && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
albumArtist || '',
|
||||
)}/${encodeURIComponent(albumName || '')}`}
|
||||
icon="brandLastfm"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
{mbzId && musicBrainz ? (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||
icon="brandMusicBrainz"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
@@ -89,22 +363,6 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
||||
uniqueId: 'relatedGenres',
|
||||
},
|
||||
];
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const { addToQueueByFetch } = usePlayer();
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!server?.id) return;
|
||||
addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, playButtonBehavior);
|
||||
};
|
||||
|
||||
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!detailQuery?.data) return;
|
||||
ContextMenuController.call({
|
||||
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM },
|
||||
event: e,
|
||||
});
|
||||
};
|
||||
|
||||
const comment = detailQuery?.data?.comment;
|
||||
|
||||
@@ -114,75 +372,29 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
||||
<div className={styles.contentContainer} ref={ref}>
|
||||
<LibraryBackgroundOverlay backgroundColor={background} />
|
||||
<div className={styles.detailContainer}>
|
||||
<section>
|
||||
<Group gap="sm" justify="space-between">
|
||||
<Group>
|
||||
<PlayButton onClick={handlePlay} />
|
||||
<ActionIcon
|
||||
icon="ellipsisHorizontal"
|
||||
onClick={handleMoreOptions}
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
{comment && <Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>}
|
||||
<div className={styles.contentLayout}>
|
||||
<div className={styles.metadataColumn}>
|
||||
<Stack gap="2xl">
|
||||
<AlbumMetadataArtists artists={detailQuery?.data?.albumArtists} />
|
||||
<AlbumMetadataGenres genres={detailQuery?.data?.genres} />
|
||||
<AlbumMetadataTags album={detailQuery?.data} />
|
||||
<AlbumMetadataExternalLinks
|
||||
albumArtist={detailQuery?.data?.albumArtist}
|
||||
albumName={detailQuery?.data?.name}
|
||||
externalLinks={externalLinks}
|
||||
lastFM={lastFM}
|
||||
mbzId={mbzId || undefined}
|
||||
musicBrainz={musicBrainz}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</section>
|
||||
{externalLinks && (lastFM || musicBrainz) ? (
|
||||
<section>
|
||||
<Group gap="sm">
|
||||
{lastFM && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.albumArtist || '',
|
||||
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
|
||||
icon="brandLastfm"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
{mbzId && musicBrainz ? (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||
icon="brandMusicBrainz"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</section>
|
||||
) : null}
|
||||
{comment && (
|
||||
<section>
|
||||
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && (
|
||||
<section>
|
||||
<AlbumDetailSongsTable songs={detailQuery.data.songs} />
|
||||
</section>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
{detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && (
|
||||
<div className={styles.songsColumn}>
|
||||
<AlbumDetailSongsTable songs={detailQuery.data.songs} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack gap="lg" mt="3rem">
|
||||
{cq.height || cq.width ? (
|
||||
@@ -338,18 +550,13 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
align="center"
|
||||
h="100%"
|
||||
px="md"
|
||||
style={{ background: 'var(--theme-colors-background)' }}
|
||||
w="100%"
|
||||
>
|
||||
<Group align="center" h="100%" px="md" w="100%">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
id={`disc-${discGroup.discNumber}`}
|
||||
indeterminate={isSomeSelected}
|
||||
label={
|
||||
<Text size="sm">
|
||||
<Text component="label" size="sm">
|
||||
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
|
||||
{discGroup.discNumber}
|
||||
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, Link, useParams } from 'react-router';
|
||||
import { forwardRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { LibraryHeader } from '/@/renderer/features/shared/components/library-header';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
import {
|
||||
LibraryHeader,
|
||||
LibraryHeaderMenu,
|
||||
} from '/@/renderer/features/shared/components/library-header';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { formatDateAbsoluteUTC, formatDurationString, titleCase } from '/@/renderer/utils';
|
||||
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Pill, PillLink } from '/@/shared/components/pill/pill';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
background: {
|
||||
@@ -34,75 +31,13 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
||||
const detailQuery = useQuery(
|
||||
albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const genreRoute = useGenreRoute();
|
||||
|
||||
const showRating =
|
||||
detailQuery?.data?._serverType === ServerType.NAVIDROME ||
|
||||
detailQuery?.data?._serverType === ServerType.SUBSONIC;
|
||||
|
||||
const showGenres = detailQuery?.data?.genres
|
||||
? detailQuery?.data?.genres.length !== 0
|
||||
: false;
|
||||
|
||||
const originalDifferentFromRelease =
|
||||
detailQuery.data?.originalDate &&
|
||||
detailQuery.data.originalDate !== detailQuery.data.releaseDate;
|
||||
|
||||
const releasePrefix = originalDifferentFromRelease
|
||||
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
|
||||
: '♫';
|
||||
|
||||
const releaseTypes = useMemo(
|
||||
() =>
|
||||
normalizeReleaseTypes(detailQuery.data?.releaseTypes ?? [], t).map((type) => ({
|
||||
id: type,
|
||||
value: titleCase(type),
|
||||
})) || [],
|
||||
[detailQuery.data?.releaseTypes, t],
|
||||
);
|
||||
|
||||
const metadataItems = releaseTypes.concat([
|
||||
{
|
||||
id: 'releaseDate',
|
||||
value:
|
||||
detailQuery?.data?.releaseDate &&
|
||||
`${releasePrefix} ${formatDateAbsoluteUTC(detailQuery?.data?.releaseDate)}`,
|
||||
},
|
||||
{
|
||||
id: 'songCount',
|
||||
value: t('entity.trackWithCount', {
|
||||
count: detailQuery?.data?.songCount as number,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
value:
|
||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
{
|
||||
id: 'playCount',
|
||||
value:
|
||||
typeof detailQuery?.data?.playCount === 'number' &&
|
||||
t('entity.play', {
|
||||
count: detailQuery?.data?.playCount,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'version',
|
||||
value: detailQuery.data?.version,
|
||||
},
|
||||
]);
|
||||
|
||||
if (originalDifferentFromRelease) {
|
||||
const formatted = `♫ ${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`;
|
||||
metadataItems.splice(0, 0, {
|
||||
id: 'originalDate',
|
||||
value: formatted,
|
||||
});
|
||||
}
|
||||
|
||||
const { setFavorite, setRating } = usePlayer();
|
||||
const { addToQueueByFetch, setFavorite, setRating } = usePlayer();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
@@ -114,24 +49,39 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
const handleUpdateRating = showRating
|
||||
? (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
if (detailQuery.data.userRating === rating) {
|
||||
return setRating(
|
||||
detailQuery.data._serverId,
|
||||
[detailQuery.data.id],
|
||||
LibraryItem.ALBUM,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return setRating(
|
||||
detailQuery.data._serverId,
|
||||
[detailQuery.data.id],
|
||||
LibraryItem.ALBUM,
|
||||
rating,
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handlePlay = (type?: Play) => {
|
||||
if (!server?.id || !albumId) return;
|
||||
addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
|
||||
};
|
||||
|
||||
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
if (detailQuery.data.userRating === rating) {
|
||||
return setRating(
|
||||
detailQuery.data._serverId,
|
||||
[detailQuery.data.id],
|
||||
LibraryItem.ALBUM,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return setRating(
|
||||
detailQuery.data._serverId,
|
||||
[detailQuery.data.id],
|
||||
LibraryItem.ALBUM,
|
||||
rating,
|
||||
);
|
||||
ContextMenuController.call({
|
||||
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM },
|
||||
event: e,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -142,80 +92,15 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
||||
title={detailQuery?.data?.name || ''}
|
||||
{...background}
|
||||
>
|
||||
<Pill.Group>
|
||||
{metadataItems.map(
|
||||
(item, index) =>
|
||||
item.value && (
|
||||
<Pill key={`item-${item.id}-${index}`} size="md">
|
||||
{item.value}
|
||||
</Pill>
|
||||
),
|
||||
)}
|
||||
</Pill.Group>
|
||||
{showGenres && (
|
||||
<Pill.Group>
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<PillLink
|
||||
key={`genre-${genre.id}`}
|
||||
size="md"
|
||||
to={generatePath(genreRoute, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
>
|
||||
{genre.name}
|
||||
</PillLink>
|
||||
))}
|
||||
</Pill.Group>
|
||||
)}
|
||||
<Group align="center" gap="xs">
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: detailQuery?.data?.userFavorite ? 'primary' : undefined,
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={handleFavorite}
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
/>
|
||||
{showRating && (
|
||||
<Rating
|
||||
onChange={handleUpdateRating}
|
||||
readOnly={detailQuery?.isFetching}
|
||||
size="md"
|
||||
styles={{
|
||||
input: {
|
||||
background: 'transparent',
|
||||
},
|
||||
}}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
gap="md"
|
||||
mah="4rem"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Text
|
||||
component={Link}
|
||||
fw={600}
|
||||
isLink
|
||||
key={`artist-${artist.id}`}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
variant="subtle"
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
<LibraryHeaderMenu
|
||||
favorite={detailQuery?.data?.userFavorite}
|
||||
onFavorite={handleFavorite}
|
||||
onMore={handleMoreOptions}
|
||||
onPlay={() => handlePlay(Play.NOW)}
|
||||
onRating={handleUpdateRating}
|
||||
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
||||
rating={detailQuery?.data?.userRating || 0}
|
||||
/>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -45,5 +45,11 @@ export const AlbumListHeader = ({ title }: AlbumListHeaderProps) => {
|
||||
const PlayButton = () => {
|
||||
const { query } = useAlbumListFilters();
|
||||
|
||||
return <LibraryHeaderBar.PlayButton itemType={LibraryItem.ALBUM} listQuery={query} />;
|
||||
return (
|
||||
<LibraryHeaderBar.PlayButton
|
||||
itemType={LibraryItem.ALBUM}
|
||||
listQuery={query}
|
||||
variant="filled"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ const AlbumDetailRoute = () => {
|
||||
<LibraryHeaderBar.PlayButton
|
||||
ids={[albumId]}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
variant="default"
|
||||
/>
|
||||
<LibraryHeaderBar.Title>
|
||||
{detailQuery?.data?.name}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface HeaderPlayButtonProps {
|
||||
itemType: LibraryItem;
|
||||
listQuery?: Record<string, any>;
|
||||
songs?: Song[];
|
||||
variant?: 'default' | 'filled';
|
||||
}
|
||||
|
||||
interface TitleProps {
|
||||
@@ -40,6 +41,7 @@ const HeaderPlayButton = ({
|
||||
itemType,
|
||||
listQuery,
|
||||
songs,
|
||||
variant = 'filled',
|
||||
...props
|
||||
}: HeaderPlayButtonProps) => {
|
||||
const serverId = useCurrentServerId();
|
||||
@@ -73,14 +75,19 @@ const HeaderPlayButton = ({
|
||||
|
||||
return (
|
||||
<div className={styles.playButtonContainer}>
|
||||
<PlayButton className={className} onClick={openPlayTypeModal} {...props} />
|
||||
<PlayButton
|
||||
className={className}
|
||||
onClick={openPlayTypeModal}
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Title = ({ children }: TitleProps) => {
|
||||
return (
|
||||
<TextTitle fw={700} order={1} overflow="hidden">
|
||||
<TextTitle fw={700} order={2} overflow="hidden">
|
||||
{children}
|
||||
</TextTitle>
|
||||
);
|
||||
|
||||
@@ -22,10 +22,8 @@
|
||||
width: 250px !important;
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 768px) {
|
||||
.library-header {
|
||||
@container (min-width: $mantine-breakpoint-sm) {
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 225px minmax(0, 1fr);
|
||||
@@ -45,10 +43,8 @@
|
||||
height: 225px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 1200px) {
|
||||
.library-header {
|
||||
@container (min-width: $mantine-breakpoint-lg) {
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
|
||||
.image {
|
||||
@@ -70,6 +66,10 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 0 8px rgb(0 0 0 / 50%));
|
||||
|
||||
@container (min-width: $mantine-breakpoint-sm) {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
@@ -81,25 +81,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
|
||||
& > div {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 768px) {
|
||||
.image-section {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.metadata-section,
|
||||
.metadata-section > div:first-of-type,
|
||||
.metadata-section > div:last-of-type {
|
||||
@container (min-width: $mantine-breakpoint-sm) {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -146,3 +133,18 @@
|
||||
color: var(--theme-colors-foreground);
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.library-header-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-sm);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
@container (min-width: $mantine-breakpoint-sm) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@ import { Link } from 'react-router';
|
||||
|
||||
import styles from './library-header.module.css';
|
||||
|
||||
import {
|
||||
WidePlayButton,
|
||||
WideShuffleButton,
|
||||
} from '/@/renderer/features/shared/components/play-button';
|
||||
import { useGeneralSettings } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
@@ -117,6 +124,7 @@ export const LibraryHeader = forwardRef(
|
||||
{title && (
|
||||
<div className={styles.metadataSection}>
|
||||
<Text
|
||||
className={styles.itemType}
|
||||
component={Link}
|
||||
fw={600}
|
||||
isLink
|
||||
@@ -127,7 +135,7 @@ export const LibraryHeader = forwardRef(
|
||||
{itemTypeString()}
|
||||
</Text>
|
||||
<h1 className={styles.title}>
|
||||
<AutoTextSize maxFontSizePx={80} minFontSizePx={36} mode="box">
|
||||
<AutoTextSize maxFontSizePx={80} minFontSizePx={32} mode="box">
|
||||
{title}
|
||||
</AutoTextSize>
|
||||
</h1>
|
||||
@@ -138,3 +146,54 @@ export const LibraryHeader = forwardRef(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface LibraryHeaderMenuProps {
|
||||
favorite?: boolean;
|
||||
onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onMore?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onPlay?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onRating?: (rating: number) => void;
|
||||
onShuffle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export const LibraryHeaderMenu = ({
|
||||
favorite,
|
||||
onFavorite,
|
||||
onMore,
|
||||
onPlay,
|
||||
onRating,
|
||||
onShuffle,
|
||||
rating,
|
||||
}: LibraryHeaderMenuProps) => {
|
||||
return (
|
||||
<div className={styles.libraryHeaderMenu}>
|
||||
<Group wrap="nowrap">
|
||||
{onPlay && <WidePlayButton onClick={onPlay} />}
|
||||
{onShuffle && <WideShuffleButton onClick={onShuffle} />}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{onRating && <Rating onChange={onRating} size="lg" value={rating || 0} />}
|
||||
{onFavorite && (
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: favorite ? 'primary' : undefined,
|
||||
}}
|
||||
onClick={onFavorite}
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
)}
|
||||
{onMore && (
|
||||
<ActionIcon
|
||||
icon="ellipsisHorizontal"
|
||||
onClick={onMore}
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,3 +14,80 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.button.unthemed {
|
||||
@mixin light {
|
||||
color: white;
|
||||
background: black;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: lighten(black, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
color: black;
|
||||
background: white;
|
||||
|
||||
svg {
|
||||
color: black;
|
||||
fill: black;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: darken(white, 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wide-button {
|
||||
padding-right: var(--theme-spacing-xl);
|
||||
padding-left: var(--theme-spacing-xl);
|
||||
background: white;
|
||||
border-radius: var(--theme-radius-xl);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.wide-button.unthemed {
|
||||
@mixin light {
|
||||
background: black;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: lighten(black, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: white;
|
||||
|
||||
svg {
|
||||
color: black;
|
||||
fill: black;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: darken(white, 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wide-button-label {
|
||||
font-size: var(--theme-font-size-md);
|
||||
font-weight: 600;
|
||||
color: black;
|
||||
|
||||
svg {
|
||||
color: black;
|
||||
fill: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,69 @@
|
||||
import clsx from 'clsx';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import styles from './play-button.module.css';
|
||||
|
||||
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button, ButtonProps } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
|
||||
export interface PlayButtonProps extends ActionIconProps {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export const PlayButton = ({ className, ...props }: PlayButtonProps) => {
|
||||
export const PlayButton = ({ className, variant = 'filled', ...props }: PlayButtonProps) => {
|
||||
return (
|
||||
<ActionIcon
|
||||
className={clsx(styles.button, className)}
|
||||
className={clsx(styles.button, className, {
|
||||
[styles.unthemed]: variant !== 'filled',
|
||||
})}
|
||||
icon="mediaPlay"
|
||||
iconProps={{
|
||||
size: 'xl',
|
||||
}}
|
||||
variant="filled"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface WidePlayButtonProps extends ButtonProps {}
|
||||
|
||||
export const WidePlayButton = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: WidePlayButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
className={clsx(styles.wideButton, className, {
|
||||
[styles.unthemed]: variant !== 'filled',
|
||||
})}
|
||||
classNames={{
|
||||
label: styles.wideButtonLabel,
|
||||
root: styles.wideButton,
|
||||
}}
|
||||
variant="subtle"
|
||||
{...props}
|
||||
>
|
||||
{props.children || (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Icon fill="default" icon="mediaPlay" size="lg" />
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</Group>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const WideShuffleButton = ({ ...props }: WidePlayButtonProps) => {
|
||||
return (
|
||||
<WidePlayButton {...props}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Icon fill="default" icon="mediaShuffle" size="lg" />
|
||||
{t('action.shuffle', { postProcess: 'sentenceCase' })}
|
||||
</Group>
|
||||
</WidePlayButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
user-select: auto;
|
||||
background: alpha(var(--theme-colors-background), 0.5);
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&[data-variant='outline'] {
|
||||
background: transparent;
|
||||
@@ -10,6 +11,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.root.link:hover {
|
||||
background: alpha(var(--theme-colors-surface), 0.5);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--theme-content-font-family);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const PillLink = forwardRef<HTMLDivElement, PillLinkProps>(({ children, .
|
||||
[styles.xs]: size === 'xs',
|
||||
}),
|
||||
remove: styles.remove,
|
||||
root: styles.root,
|
||||
root: clsx(styles.root, styles.link),
|
||||
...classNames,
|
||||
}}
|
||||
component={Link}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
.root.xs {
|
||||
--rating-size: var(--theme-font-size-xs) !important;
|
||||
|
||||
padding: 0 var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.root.sm {
|
||||
--rating-size: var(--theme-font-size-sm) !important;
|
||||
|
||||
padding: 0 var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.root.md {
|
||||
--rating-size: var(--theme-font-size-md) !important;
|
||||
|
||||
padding: 0 var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.root.lg {
|
||||
--rating-size: var(--theme-font-size-lg) !important;
|
||||
|
||||
padding: 0 var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.root.xl {
|
||||
--rating-size: var(--theme-font-size-xl) !important;
|
||||
|
||||
padding: 0 var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.symbol-body {
|
||||
svg {
|
||||
stroke: alpha(var(--theme-colors-foreground), 0.7);
|
||||
stroke: var(--theme-colors-foreground);
|
||||
stroke-width: 2px;
|
||||
|
||||
&:not([data-filled='true']) {
|
||||
|
||||
Reference in New Issue
Block a user