import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { ArtistLinks, Cover } from '../entities/catalog'; import { usePlayer, usePlayerState } from '../player/context'; import { formatDuration } from '../shared/lib/format'; import { shareTrack } from '../shared/lib/share'; import { albumIcon, closeIcon, dislikeFillIcon, dislikeIcon, likeFillIcon, likeIcon, musicIcon, nextIcon, pauseIcon, playIcon, prevIcon, queueIcon, repeatIcon, repeatOneIcon, shuffleIcon, shareIcon, volumeIcon, volumeOffIcon, waveIcon, } from '../shared/ui/icons'; import { cx, Icon } from '../shared/ui/ui'; import s from './PlayerBar.module.css'; /** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) { const { t } = useTranslation(); const player = usePlayer(); const st = usePlayerState(); const [queueOpen, setQueueOpen] = useState(false); const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null); const [sharing, setSharing] = useState(false); useEffect(() => { setShareStatus(null); }, [st.track?.id]); useEffect(() => { if (!shareStatus) return; const timer = setTimeout(() => setShareStatus(null), 4000); return () => clearTimeout(timer); }, [shareStatus]); const queueButton = useRef(null); const queueId = useId(); const closeQueue = useCallback(() => { setQueueOpen(false); queueButton.current?.focus(); // фокус — туда, откуда открыли }, []); const track = st.track; if (!track) { return ( ); } const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const share = async () => { setSharing(true); setShareStatus(null); try { const result = await shareTrack(track.id, track.title); if (result === 'copied') setShareStatus('copied'); } catch { setShareStatus('error'); } finally { setSharing(false); } }; const repeatLabel = t(`player.repeat_${st.repeat}`); const ctx = st.context; const tr = t as unknown as (k: string, o?: Record) => string; const source = !ctx ? null : ctx.type === 'wave' ? ctx.label ? t('player.source_wave_seed', { label: ctx.label }) : t('player.source_wave') : ctx.type === 'collection' ? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`) : tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' }); return (
{track.title} {source && (ctx?.type === 'album' && ctx.id ? ( {source} ) : ( {source} ))}
{formatDuration(st.positionMs)} player.seek(Number(e.target.value))} style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties} /> {formatDuration(st.durationMs || track.duration_ms)}
{st.error && ( {t(st.error as 'player.error')} )}
player.setVolume(Number(e.target.value))} style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties} />
{queueOpen && } {shareStatus && {t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}}
); } /** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) { const { t } = useTranslation(); const player = usePlayer(); const st = usePlayerState(); const upcoming = st.queue.slice(st.index, st.index + 40); const close = useRef(null); // Клавиатура: при открытии фокус в панель, Escape закрывает. useEffect(() => { close.current?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose]); // Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе. return createPortal( , document.body, ); }