310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { unwrap } from '@pipu/core';
|
|
import { Link, useNavigate } from '@tanstack/react-router';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
|
import { AddToPlaylist } from './playlists';
|
|
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../app/session';
|
|
import { formatDuration } from '../shared/lib/format';
|
|
import { usePlayer, usePlayerState } from '../player/context';
|
|
import type { PlayContext } from '../player/player';
|
|
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
|
|
import { cx, Icon } from '../shared/ui/ui';
|
|
import s from './catalog.module.css';
|
|
|
|
export type Track = components['schemas']['Track'];
|
|
export type AlbumSummary = components['schemas']['AlbumSummary'];
|
|
export type ArtistRef = components['schemas']['ArtistRef'];
|
|
|
|
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
|
|
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
|
|
function fallbackColors(id: string): string[] {
|
|
let h = 0;
|
|
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
const base = h % 360;
|
|
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
|
|
}
|
|
|
|
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
|
export function coverSrc(url: string, cssPx: number): string {
|
|
if (!url.includes('/api/v1/covers/')) return url;
|
|
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
|
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
|
return `${url}&size=${size}`;
|
|
}
|
|
|
|
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
|
|
export function Cover({
|
|
album,
|
|
size = 48,
|
|
round,
|
|
className,
|
|
}: {
|
|
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
|
size?: number | string;
|
|
round?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
|
|
return (
|
|
<span
|
|
className={cx(s.cover, round && s.round, className)}
|
|
style={{
|
|
width: size,
|
|
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
|
|
height: typeof size === 'number' ? size : undefined,
|
|
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
|
|
}}
|
|
aria-hidden
|
|
>
|
|
{album.cover_url ? (
|
|
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
|
|
) : (
|
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Исполнители через запятую, каждый — ссылка. */
|
|
export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<>
|
|
{artists.map((a, i) => (
|
|
<Fragment key={a.id}>
|
|
{i > 0 && ', '}
|
|
{hideUrlPreview ? <button type="button" role="link" className={s.link}
|
|
onClick={() => void navigate({ to: '/artist/$id', params: { id: a.id } })}>
|
|
{a.name}
|
|
</button> : <Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
|
{a.name}
|
|
</Link>}
|
|
</Fragment>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CoverImg({ src }: { src: string }) {
|
|
const [ready, setReady] = useState(false);
|
|
return (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
loading="lazy"
|
|
className={cx(s.coverImg, ready && s.coverImgOn)}
|
|
onLoad={() => setReady(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const player = usePlayer();
|
|
const play = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
void (async () => {
|
|
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
|
|
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
|
|
})();
|
|
};
|
|
return (
|
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
|
<span className={s.cardCoverWrap}>
|
|
<Cover album={album} size="100%" className={s.cardCover} />
|
|
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
|
|
<Icon src={playIcon} size={22} />
|
|
</button>
|
|
</span>
|
|
<span className={s.cardTitle}>{album.title}</span>
|
|
<span className={s.cardMeta}>
|
|
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
|
|
return (
|
|
<div className={s.grid}>
|
|
{albums.map((a) => (
|
|
<AlbumCard key={a.id} album={a} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
|
|
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
|
|
*/
|
|
export function TrackList({
|
|
tracks,
|
|
showAlbum,
|
|
numbered,
|
|
context,
|
|
rowMeta,
|
|
lead,
|
|
disliked,
|
|
actions,
|
|
}: {
|
|
tracks: Track[];
|
|
showAlbum?: boolean;
|
|
numbered?: boolean;
|
|
context: PlayContext;
|
|
/** Дополнительный текст под исполнителями. */
|
|
rowMeta?: (t: Track, i: number) => string | undefined;
|
|
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
|
|
lead?: (t: Track, i: number) => string;
|
|
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
|
disliked?: boolean;
|
|
actions?: (track: Track, index: number) => ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const player = usePlayer();
|
|
const { track: current, playing } = usePlayerState();
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
|
const multiDisc = Boolean(numbered && discs.size > 1);
|
|
const virtual = tracks.length > 30 && !multiDisc;
|
|
const virtualizer = useVirtualizer({
|
|
count: virtual ? tracks.length : 0,
|
|
getScrollElement: () => listRef.current?.closest('main') ?? null,
|
|
estimateSize: () => 68,
|
|
overscan: 8,
|
|
});
|
|
|
|
const row = (tr: Track, i: number) => {
|
|
const isCurrent = current?.id === tr.id;
|
|
const play = () => tr.available && player.playList(tracks, i, context);
|
|
const liked = tr.reaction === 'like';
|
|
const meta = rowMeta?.(tr, i);
|
|
return (
|
|
<div
|
|
role="listitem"
|
|
tabIndex={tr.available ? 0 : -1}
|
|
aria-current={isCurrent || undefined}
|
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
|
|
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
|
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
|
onClick={(e) => {
|
|
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
|
}}
|
|
>
|
|
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
|
<span className={cx(s.num, disliked && s.dimmed)}>
|
|
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
|
{tr.available && (
|
|
<span className={s.playHint} aria-hidden>
|
|
<Icon src={playIcon} size={20} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
|
<span className={s.trackTitle}>{tr.title}</span>
|
|
<span className={s.trackArtists}>
|
|
<ArtistLinks artists={tr.artists} />
|
|
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
|
</span>
|
|
</span>
|
|
{showAlbum && (
|
|
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
|
{tr.album.title}
|
|
</Link>
|
|
)}
|
|
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
|
{!actions && <SaveOfflineButton track={tr} compact />}
|
|
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
|
{disliked ? (
|
|
<button
|
|
type="button"
|
|
className={cx(s.like, s.liked)}
|
|
aria-label={t('player.undislike')}
|
|
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
|
>
|
|
<Icon src={undoIcon} size={18} />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={cx(s.like, liked && s.liked)}
|
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
|
aria-pressed={liked}
|
|
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
|
>
|
|
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
|
</button>
|
|
)}
|
|
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
let lastDisc: number | null = null;
|
|
return (
|
|
<div
|
|
ref={listRef}
|
|
className={s.tracks}
|
|
role="list"
|
|
aria-label={t('search.tracks')}
|
|
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
|
|
>
|
|
{virtual
|
|
? virtualizer.getVirtualItems().map((vi) => (
|
|
<div
|
|
key={vi.key}
|
|
data-index={vi.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
|
>
|
|
{row(tracks[vi.index]!, vi.index)}
|
|
</div>
|
|
))
|
|
: tracks.map((tr, i) => {
|
|
const disc = tr.disc_no ?? 1;
|
|
const header = multiDisc && disc !== lastDisc;
|
|
lastDisc = disc;
|
|
return (
|
|
<Fragment key={`${tr.id}-${i}`}>
|
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
|
{row(tr, i)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */
|
|
export function Equalizer({ playing }: { playing: boolean }) {
|
|
return (
|
|
<span className={cx(s.eq, !playing && s.eqPaused)} aria-hidden>
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ArtistChip({ artist }: { artist: ArtistRef }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
|
|
<Cover album={{ id: artist.id }} size={88} round />
|
|
<span className={s.cardTitle}>{artist.name}</span>
|
|
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
|
|
</Link>
|
|
);
|
|
}
|