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 (
{album.cover_url ? (
) : (
)}
);
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
const navigate = useNavigate();
return (
<>
{artists.map((a, i) => (
{i > 0 && ', '}
{hideUrlPreview ? :
{a.name}
}
))}
>
);
}
function CoverImg({ src }: { src: string }) {
const [ready, setReady] = useState(false);
return (
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 (
{album.title}
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
);
}
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
return (
);
}
/**
* Список треков. 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 ? 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')}
);
}