ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
9.7 KiB
TypeScript
267 lines
9.7 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { Fragment, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
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 {
|
|
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 ? (
|
|
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
|
) : (
|
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Исполнители через запятую, каждый — ссылка. */
|
|
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
|
return (
|
|
<>
|
|
{artists.map((a, i) => (
|
|
<Fragment key={a.id}>
|
|
{i > 0 && ', '}
|
|
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
|
{a.name}
|
|
</Link>
|
|
</Fragment>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
|
return (
|
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
|
<Cover album={album} size="100%" className={s.cardCover} />
|
|
<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,
|
|
}: {
|
|
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;
|
|
}) {
|
|
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)}
|
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
|
onClick={(e) => {
|
|
if (!(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}>
|
|
{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>
|
|
);
|
|
}
|