import type { components } from '@pipu/api-client'; import { Link } from '@tanstack/react-router'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Fragment, useRef, type ReactNode } from 'react'; import { AddToPlaylist } from './playlists'; 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 ( {album.cover_url ? ( ) : ( )} ); } /** Исполнители через запятую, каждый — ссылка. */ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) { return ( <> {artists.map((a, i) => ( {i > 0 && ', '} {a.name} ))} ); } export function AlbumCard({ album }: { album: AlbumSummary }) { return ( {album.title} {[album.artist.name, album.year].filter(Boolean).join(' · ')} ); } export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) { return (
{albums.map((a) => ( ))}
); } /** * Список треков. 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(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 (
{ 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 && {isCurrent ? : lead(tr, i)}} {numbered ? (tr.track_no ?? i + 1) : } {tr.available && ( )} {tr.title} {meta && · {meta}} {showAlbum && ( {tr.album.title} )} {actions ? actions(tr, i) : } {disliked ? ( ) : ( )} {formatDuration(tr.duration_ms)}
); }; let lastDisc: number | null = null; return (
{virtual ? virtualizer.getVirtualItems().map((vi) => (
{row(tracks[vi.index]!, vi.index)}
)) : tracks.map((tr, i) => { const disc = tr.disc_no ?? 1; const header = multiDisc && disc !== lastDisc; lastDisc = disc; return ( {header &&
{t('catalog.disc', { n: disc })}
} {row(tr, i)}
); })}
); } /** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */ export function Equalizer({ playing }: { playing: boolean }) { return ( ); } export function ArtistChip({ artist }: { artist: ArtistRef }) { const { t } = useTranslation(); return ( {artist.name} {t('search.artist_kind')} ); }