An axe-core audit of every screen in both themes found low contrast on white text over the accent and on caption text, track rows using row roles without cells, and unlabelled progress bars. Accent fills now use a darker token, caption text meets 4.5:1, track lists are lists and progress bars are labelled. The queue drawer slides in from the right as designed, takes focus, closes on Escape and notes that the wave never ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
9.0 KiB
TypeScript
233 lines
9.0 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { Fragment } 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 discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
|
let lastDisc: number | null = null;
|
|
return (
|
|
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
|
{tracks.map((tr, i) => {
|
|
const disc = tr.disc_no ?? 1;
|
|
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
|
lastDisc = disc;
|
|
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 (
|
|
<Fragment key={`${tr.id}-${i}`}>
|
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
|
<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>
|
|
</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>
|
|
);
|
|
}
|