mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
redesign album detail page
This commit is contained in:
@@ -70,6 +70,7 @@
|
|||||||
"edit": "edit",
|
"edit": "edit",
|
||||||
"enable": "enable",
|
"enable": "enable",
|
||||||
"expand": "expand",
|
"expand": "expand",
|
||||||
|
"externalLinks": "external links",
|
||||||
"favorite": "favorite",
|
"favorite": "favorite",
|
||||||
"filter_one": "filter",
|
"filter_one": "filter",
|
||||||
"filter_other": "filters",
|
"filter_other": "filters",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.content-container {
|
.content-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-container {
|
.detail-container {
|
||||||
@@ -8,5 +9,59 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--theme-spacing-lg);
|
gap: var(--theme-spacing-lg);
|
||||||
padding: 1rem 2rem 5rem;
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { Suspense, useMemo, useState } from 'react';
|
import { ReactNode, Suspense, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router';
|
import { generatePath, useParams } from 'react-router';
|
||||||
|
|
||||||
import styles from './album-detail-content.module.css';
|
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 { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
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 { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
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 { 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 { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
import {
|
import {
|
||||||
useGeneralSettings,
|
formatDateAbsoluteUTC,
|
||||||
usePlayButtonBehavior,
|
formatDurationString,
|
||||||
useSettingsStore,
|
formatSizeString,
|
||||||
} from '/@/renderer/store/settings.store';
|
titleCase,
|
||||||
|
} from '/@/renderer/utils';
|
||||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||||
|
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
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 { Pill, PillLink } from '/@/shared/components/pill/pill';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
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 { Text } from '/@/shared/components/text/text';
|
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';
|
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface AlbumDetailContentProps {
|
interface AlbumDetailContentProps {
|
||||||
background?: string;
|
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) => {
|
export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { albumId } = useParams() as { albumId: string };
|
const { albumId } = useParams() as { albumId: string };
|
||||||
@@ -89,22 +363,6 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
uniqueId: 'relatedGenres',
|
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;
|
const comment = detailQuery?.data?.comment;
|
||||||
|
|
||||||
@@ -114,75 +372,29 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
<div className={styles.contentContainer} ref={ref}>
|
<div className={styles.contentContainer} ref={ref}>
|
||||||
<LibraryBackgroundOverlay backgroundColor={background} />
|
<LibraryBackgroundOverlay backgroundColor={background} />
|
||||||
<div className={styles.detailContainer}>
|
<div className={styles.detailContainer}>
|
||||||
<section>
|
{comment && <Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>}
|
||||||
<Group gap="sm" justify="space-between">
|
<div className={styles.contentLayout}>
|
||||||
<Group>
|
<div className={styles.metadataColumn}>
|
||||||
<PlayButton onClick={handlePlay} />
|
<Stack gap="2xl">
|
||||||
<ActionIcon
|
<AlbumMetadataArtists artists={detailQuery?.data?.albumArtists} />
|
||||||
icon="ellipsisHorizontal"
|
<AlbumMetadataGenres genres={detailQuery?.data?.genres} />
|
||||||
onClick={handleMoreOptions}
|
<AlbumMetadataTags album={detailQuery?.data} />
|
||||||
size="lg"
|
<AlbumMetadataExternalLinks
|
||||||
variant="transparent"
|
albumArtist={detailQuery?.data?.albumArtist}
|
||||||
|
albumName={detailQuery?.data?.name}
|
||||||
|
externalLinks={externalLinks}
|
||||||
|
lastFM={lastFM}
|
||||||
|
mbzId={mbzId || undefined}
|
||||||
|
musicBrainz={musicBrainz}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Stack>
|
||||||
</Group>
|
</div>
|
||||||
</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 && (
|
{detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && (
|
||||||
<section>
|
<div className={styles.songsColumn}>
|
||||||
<AlbumDetailSongsTable songs={detailQuery.data.songs} />
|
<AlbumDetailSongsTable songs={detailQuery.data.songs} />
|
||||||
</section>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Stack gap="lg" mt="3rem">
|
<Stack gap="lg" mt="3rem">
|
||||||
{cq.height || cq.width ? (
|
{cq.height || cq.width ? (
|
||||||
@@ -338,18 +550,13 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group align="center" h="100%" px="md" w="100%">
|
||||||
align="center"
|
|
||||||
h="100%"
|
|
||||||
px="md"
|
|
||||||
style={{ background: 'var(--theme-colors-background)' }}
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
|
id={`disc-${discGroup.discNumber}`}
|
||||||
indeterminate={isSomeSelected}
|
indeterminate={isSomeSelected}
|
||||||
label={
|
label={
|
||||||
<Text size="sm">
|
<Text component="label" size="sm">
|
||||||
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
|
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
|
||||||
{discGroup.discNumber}
|
{discGroup.discNumber}
|
||||||
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
|
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { forwardRef, useMemo } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useParams } from 'react-router';
|
||||||
import { generatePath, Link, useParams } from 'react-router';
|
|
||||||
|
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { LibraryHeader } from '/@/renderer/features/shared/components/library-header';
|
import {
|
||||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
LibraryHeader,
|
||||||
|
LibraryHeaderMenu,
|
||||||
|
} from '/@/renderer/features/shared/components/library-header';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { formatDateAbsoluteUTC, formatDurationString, titleCase } from '/@/renderer/utils';
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
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 { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
|
||||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||||
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface AlbumDetailHeaderProps {
|
interface AlbumDetailHeaderProps {
|
||||||
background: {
|
background: {
|
||||||
@@ -34,75 +31,13 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
|||||||
const detailQuery = useQuery(
|
const detailQuery = useQuery(
|
||||||
albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
|
albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
|
||||||
);
|
);
|
||||||
const { t } = useTranslation();
|
|
||||||
const genreRoute = useGenreRoute();
|
|
||||||
|
|
||||||
const showRating =
|
const showRating =
|
||||||
detailQuery?.data?._serverType === ServerType.NAVIDROME ||
|
detailQuery?.data?._serverType === ServerType.NAVIDROME ||
|
||||||
detailQuery?.data?._serverType === ServerType.SUBSONIC;
|
detailQuery?.data?._serverType === ServerType.SUBSONIC;
|
||||||
|
|
||||||
const showGenres = detailQuery?.data?.genres
|
const { addToQueueByFetch, setFavorite, setRating } = usePlayer();
|
||||||
? detailQuery?.data?.genres.length !== 0
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
: 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 handleFavorite = () => {
|
const handleFavorite = () => {
|
||||||
if (!detailQuery?.data) return;
|
if (!detailQuery?.data) return;
|
||||||
@@ -114,7 +49,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateRating = (rating: number) => {
|
const handleUpdateRating = showRating
|
||||||
|
? (rating: number) => {
|
||||||
if (!detailQuery?.data) return;
|
if (!detailQuery?.data) return;
|
||||||
|
|
||||||
if (detailQuery.data.userRating === rating) {
|
if (detailQuery.data.userRating === rating) {
|
||||||
@@ -132,6 +68,20 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
|||||||
LibraryItem.ALBUM,
|
LibraryItem.ALBUM,
|
||||||
rating,
|
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;
|
||||||
|
ContextMenuController.call({
|
||||||
|
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM },
|
||||||
|
event: e,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,80 +92,15 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderPro
|
|||||||
title={detailQuery?.data?.name || ''}
|
title={detailQuery?.data?.name || ''}
|
||||||
{...background}
|
{...background}
|
||||||
>
|
>
|
||||||
<Pill.Group>
|
<LibraryHeaderMenu
|
||||||
{metadataItems.map(
|
favorite={detailQuery?.data?.userFavorite}
|
||||||
(item, index) =>
|
onFavorite={handleFavorite}
|
||||||
item.value && (
|
onMore={handleMoreOptions}
|
||||||
<Pill key={`item-${item.id}-${index}`} size="md">
|
onPlay={() => handlePlay(Play.NOW)}
|
||||||
{item.value}
|
onRating={handleUpdateRating}
|
||||||
</Pill>
|
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
||||||
),
|
rating={detailQuery?.data?.userRating || 0}
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</LibraryHeader>
|
</LibraryHeader>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,5 +45,11 @@ export const AlbumListHeader = ({ title }: AlbumListHeaderProps) => {
|
|||||||
const PlayButton = () => {
|
const PlayButton = () => {
|
||||||
const { query } = useAlbumListFilters();
|
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
|
<LibraryHeaderBar.PlayButton
|
||||||
ids={[albumId]}
|
ids={[albumId]}
|
||||||
itemType={LibraryItem.ALBUM}
|
itemType={LibraryItem.ALBUM}
|
||||||
|
variant="default"
|
||||||
/>
|
/>
|
||||||
<LibraryHeaderBar.Title>
|
<LibraryHeaderBar.Title>
|
||||||
{detailQuery?.data?.name}
|
{detailQuery?.data?.name}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface HeaderPlayButtonProps {
|
|||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
listQuery?: Record<string, any>;
|
listQuery?: Record<string, any>;
|
||||||
songs?: Song[];
|
songs?: Song[];
|
||||||
|
variant?: 'default' | 'filled';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TitleProps {
|
interface TitleProps {
|
||||||
@@ -40,6 +41,7 @@ const HeaderPlayButton = ({
|
|||||||
itemType,
|
itemType,
|
||||||
listQuery,
|
listQuery,
|
||||||
songs,
|
songs,
|
||||||
|
variant = 'filled',
|
||||||
...props
|
...props
|
||||||
}: HeaderPlayButtonProps) => {
|
}: HeaderPlayButtonProps) => {
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
@@ -73,14 +75,19 @@ const HeaderPlayButton = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.playButtonContainer}>
|
<div className={styles.playButtonContainer}>
|
||||||
<PlayButton className={className} onClick={openPlayTypeModal} {...props} />
|
<PlayButton
|
||||||
|
className={className}
|
||||||
|
onClick={openPlayTypeModal}
|
||||||
|
variant={variant}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Title = ({ children }: TitleProps) => {
|
const Title = ({ children }: TitleProps) => {
|
||||||
return (
|
return (
|
||||||
<TextTitle fw={700} order={1} overflow="hidden">
|
<TextTitle fw={700} order={2} overflow="hidden">
|
||||||
{children}
|
{children}
|
||||||
</TextTitle>
|
</TextTitle>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,10 +22,8 @@
|
|||||||
width: 250px !important;
|
width: 250px !important;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@container (min-width: 768px) {
|
@container (min-width: $mantine-breakpoint-sm) {
|
||||||
.library-header {
|
|
||||||
grid-template-areas: 'image info';
|
grid-template-areas: 'image info';
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
grid-template-columns: 225px minmax(0, 1fr);
|
grid-template-columns: 225px minmax(0, 1fr);
|
||||||
@@ -45,10 +43,8 @@
|
|||||||
height: 225px;
|
height: 225px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@container (min-width: 1200px) {
|
@container (min-width: $mantine-breakpoint-lg) {
|
||||||
.library-header {
|
|
||||||
grid-template-columns: 250px minmax(0, 1fr);
|
grid-template-columns: 250px minmax(0, 1fr);
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
@@ -70,6 +66,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
filter: drop-shadow(0 0 8px rgb(0 0 0 / 50%));
|
filter: drop-shadow(0 0 8px rgb(0 0 0 / 50%));
|
||||||
|
|
||||||
|
@container (min-width: $mantine-breakpoint-sm) {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-section {
|
.metadata-section {
|
||||||
@@ -81,25 +81,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
& > div {
|
@container (min-width: $mantine-breakpoint-sm) {
|
||||||
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 {
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,3 +133,18 @@
|
|||||||
color: var(--theme-colors-foreground);
|
color: var(--theme-colors-foreground);
|
||||||
-webkit-box-orient: vertical;
|
-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 styles from './library-header.module.css';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WidePlayButton,
|
||||||
|
WideShuffleButton,
|
||||||
|
} from '/@/renderer/features/shared/components/play-button';
|
||||||
import { useGeneralSettings } from '/@/renderer/store';
|
import { useGeneralSettings } from '/@/renderer/store';
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Center } from '/@/shared/components/center/center';
|
import { Center } from '/@/shared/components/center/center';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Image } from '/@/shared/components/image/image';
|
import { Image } from '/@/shared/components/image/image';
|
||||||
|
import { Rating } from '/@/shared/components/rating/rating';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
@@ -117,6 +124,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
{title && (
|
{title && (
|
||||||
<div className={styles.metadataSection}>
|
<div className={styles.metadataSection}>
|
||||||
<Text
|
<Text
|
||||||
|
className={styles.itemType}
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={600}
|
fw={600}
|
||||||
isLink
|
isLink
|
||||||
@@ -127,7 +135,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
{itemTypeString()}
|
{itemTypeString()}
|
||||||
</Text>
|
</Text>
|
||||||
<h1 className={styles.title}>
|
<h1 className={styles.title}>
|
||||||
<AutoTextSize maxFontSizePx={80} minFontSizePx={36} mode="box">
|
<AutoTextSize maxFontSizePx={80} minFontSizePx={32} mode="box">
|
||||||
{title}
|
{title}
|
||||||
</AutoTextSize>
|
</AutoTextSize>
|
||||||
</h1>
|
</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;
|
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 clsx from 'clsx';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
|
||||||
import styles from './play-button.module.css';
|
import styles from './play-button.module.css';
|
||||||
|
|
||||||
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
|
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 {
|
export interface PlayButtonProps extends ActionIconProps {
|
||||||
size?: number | string;
|
size?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayButton = ({ className, ...props }: PlayButtonProps) => {
|
export const PlayButton = ({ className, variant = 'filled', ...props }: PlayButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
className={clsx(styles.button, className)}
|
className={clsx(styles.button, className, {
|
||||||
|
[styles.unthemed]: variant !== 'filled',
|
||||||
|
})}
|
||||||
icon="mediaPlay"
|
icon="mediaPlay"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
}}
|
}}
|
||||||
variant="filled"
|
variant={variant}
|
||||||
{...props}
|
{...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;
|
user-select: auto;
|
||||||
background: alpha(var(--theme-colors-background), 0.5);
|
background: alpha(var(--theme-colors-background), 0.5);
|
||||||
border: 1px solid var(--theme-colors-border);
|
border: 1px solid var(--theme-colors-border);
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
|
||||||
&[data-variant='outline'] {
|
&[data-variant='outline'] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -10,6 +11,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root.link:hover {
|
||||||
|
background: alpha(var(--theme-colors-surface), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-family: var(--theme-content-font-family);
|
font-family: var(--theme-content-font-family);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const PillLink = forwardRef<HTMLDivElement, PillLinkProps>(({ children, .
|
|||||||
[styles.xs]: size === 'xs',
|
[styles.xs]: size === 'xs',
|
||||||
}),
|
}),
|
||||||
remove: styles.remove,
|
remove: styles.remove,
|
||||||
root: styles.root,
|
root: clsx(styles.root, styles.link),
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
.root.xs {
|
.root.xs {
|
||||||
--rating-size: var(--theme-font-size-xs) !important;
|
--rating-size: var(--theme-font-size-xs) !important;
|
||||||
|
|
||||||
|
padding: 0 var(--theme-spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root.sm {
|
.root.sm {
|
||||||
--rating-size: var(--theme-font-size-sm) !important;
|
--rating-size: var(--theme-font-size-sm) !important;
|
||||||
|
|
||||||
|
padding: 0 var(--theme-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root.md {
|
.root.md {
|
||||||
--rating-size: var(--theme-font-size-md) !important;
|
--rating-size: var(--theme-font-size-md) !important;
|
||||||
|
|
||||||
|
padding: 0 var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root.lg {
|
.root.lg {
|
||||||
--rating-size: var(--theme-font-size-lg) !important;
|
--rating-size: var(--theme-font-size-lg) !important;
|
||||||
|
|
||||||
|
padding: 0 var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root.xl {
|
.root.xl {
|
||||||
--rating-size: var(--theme-font-size-xl) !important;
|
--rating-size: var(--theme-font-size-xl) !important;
|
||||||
|
|
||||||
|
padding: 0 var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.symbol-body {
|
.symbol-body {
|
||||||
svg {
|
svg {
|
||||||
stroke: alpha(var(--theme-colors-foreground), 0.7);
|
stroke: var(--theme-colors-foreground);
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
|
|
||||||
&:not([data-filled='true']) {
|
&:not([data-filled='true']) {
|
||||||
|
|||||||
Reference in New Issue
Block a user