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, Equalizer } from '../entities/catalog'; import { AddToPlaylist } from '../entities/playlists'; import { usePlayer, usePlayerState } from '../player/context'; import { formatDuration } from '../shared/lib/format'; import { trackShareUrl } from '@pipu/core'; import { desktopHandle } from '../desktop'; import { useMusicShare } from '../features/social/Social'; 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 openShare = useMusicShare(); const st = usePlayerState(); const locked = st.listening?.locked === true; const narrow = useNarrow(); const [sheetOpen, setSheetOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false); 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 = () => { openShare({ attachment: { kind: 'track', id: track.id, title: track.title }, url: trackShareUrl(desktopHandle()?.server ?? window.location.origin, track.id) }); }; 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 (
{ if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true); } : undefined } > {narrow ? ( ) : ( )}
{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} />
{sheetOpen && narrow && ( setSheetOpen(false)} share={share} /> )} {queueOpen && }
); } function useNarrow() { const [narrow, setNarrow] = useState(false); useEffect(() => { const mq = window.matchMedia?.('(max-width: 767px)'); if (!mq) return; const sync = () => setNarrow(mq.matches); sync(); mq.addEventListener?.('change', sync); return () => mq.removeEventListener?.('change', sync); }, []); return narrow; } function NowPlayingSheet({ variant, onClose, share, }: { variant: 'bar' | 'island'; onClose: () => void; share: () => void; }) { const { t } = useTranslation(); const player = usePlayer(); const st = usePlayerState(); const locked = st.listening?.locked === true; const track = st.track; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; }, [onClose]); if (!track) return null; const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const repeatLabel = t(`player.repeat_${st.repeat}`); return createPortal(
e.stopPropagation()} >
{t('player.now_playing')}
{track.title}
{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)}
player.setVolume(Number(e.target.value))} style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties} />
, document.body, ); } /** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) { const { t } = useTranslation(); const player = usePlayer(); const st = usePlayerState(); const locked = st.listening?.locked === true; 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, ); }