287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
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<HTMLButtonElement>(null);
|
||
const queueId = useId();
|
||
const closeQueue = useCallback(() => {
|
||
setQueueOpen(false);
|
||
queueButton.current?.focus(); // фокус — туда, откуда открыли
|
||
}, []);
|
||
const track = st.track;
|
||
|
||
if (!track) {
|
||
return (
|
||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||
<span className={s.empty}>
|
||
<span className={s.placeholder}>
|
||
<Icon src={musicIcon} size={22} />
|
||
</span>
|
||
{t('player.empty')}
|
||
</span>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
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, unknown>) => 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 (
|
||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||
<div className={s.now}>
|
||
<Cover album={track.album} size={52} />
|
||
<div className={s.meta}>
|
||
<span className={s.title}>{track.title}</span>
|
||
<span className={s.artists}>
|
||
<ArtistLinks artists={track.artists} />
|
||
</span>
|
||
{source &&
|
||
(ctx?.type === 'album' && ctx.id ? (
|
||
<Link to="/album/$id" params={{ id: ctx.id }} className={s.source}>
|
||
<Icon src={albumIcon} size={14} />
|
||
{source}
|
||
</Link>
|
||
) : (
|
||
<span className={cx(s.source, ctx?.type === 'wave' && s.sourceWave)}>
|
||
<Icon src={ctx?.type === 'wave' ? waveIcon : albumIcon} size={14} />
|
||
{source}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={s.center}>
|
||
<div className={s.controls}>
|
||
<button
|
||
type="button"
|
||
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
|
||
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
|
||
aria-pressed={track.reaction === 'dislike'}
|
||
onClick={() => react('dislike')}
|
||
>
|
||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||
</button>
|
||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||
<Icon src={prevIcon} size={26} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={s.play}
|
||
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||
aria-busy={st.loading || undefined}
|
||
onClick={() => player.toggle()}
|
||
>
|
||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||
</button>
|
||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||
<Icon src={nextIcon} size={26} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cx(s.icon, track.reaction === 'like' && s.accent)}
|
||
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
|
||
aria-pressed={track.reaction === 'like'}
|
||
onClick={() => react('like')}
|
||
>
|
||
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||
</button>
|
||
</div>
|
||
<div className={s.progress}>
|
||
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||
<input
|
||
type="range"
|
||
className={s.range}
|
||
min={0}
|
||
max={st.durationMs || track.duration_ms}
|
||
step={1000}
|
||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||
aria-label={t('player.progress')}
|
||
aria-valuetext={formatDuration(st.positionMs)}
|
||
onChange={(e) => player.seek(Number(e.target.value))}
|
||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||
/>
|
||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||
</div>
|
||
{st.error && (
|
||
<span className={s.error} role="status">
|
||
{t(st.error as 'player.error')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className={s.right}>
|
||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||
<Icon src={shareIcon} size={20} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cx(s.icon, st.shuffle && s.accent)}
|
||
aria-label={t('player.shuffle')}
|
||
aria-pressed={st.shuffle}
|
||
onClick={() => player.toggleShuffle()}
|
||
>
|
||
<Icon src={shuffleIcon} size={20} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||
aria-label={repeatLabel}
|
||
title={repeatLabel}
|
||
onClick={() => player.cycleRepeat()}
|
||
>
|
||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||
</button>
|
||
<button
|
||
ref={queueButton}
|
||
type="button"
|
||
className={cx(s.icon, s.queueButton, queueOpen && s.accent)}
|
||
aria-label={t('player.queue')}
|
||
aria-expanded={queueOpen}
|
||
aria-controls={queueOpen ? queueId : undefined}
|
||
onClick={() => setQueueOpen((v) => !v)}
|
||
>
|
||
<Icon src={queueIcon} size={20} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={s.icon}
|
||
aria-label={t(st.muted ? 'player.unmute' : 'player.mute')}
|
||
onClick={() => player.toggleMute()}
|
||
>
|
||
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={20} />
|
||
</button>
|
||
<input
|
||
type="range"
|
||
className={cx(s.range, s.volume)}
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
value={st.muted ? 0 : st.volume}
|
||
aria-label={t('player.volume')}
|
||
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||
/>
|
||
</div>
|
||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||
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<HTMLButtonElement>(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(
|
||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||
<div className={s.drawerHead}>
|
||
<span>{t('player.queue')}</span>
|
||
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||
<Icon src={closeIcon} size={20} />
|
||
</button>
|
||
</div>
|
||
<div className={s.drawerList}>
|
||
{upcoming.map((tr, k) => (
|
||
<button
|
||
key={`${tr.id}-${k}`}
|
||
type="button"
|
||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||
aria-current={k === 0 || undefined}
|
||
onClick={() => player.jump(st.index + k)}
|
||
>
|
||
<Cover album={tr.album} size={40} />
|
||
<span className={s.meta}>
|
||
<span className={s.title}>{tr.title}</span>
|
||
<span className={s.artists}>{tr.artists.map((a) => a.name).join(', ')}</span>
|
||
</span>
|
||
<span className={s.time}>{formatDuration(tr.duration_ms)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||
</aside>,
|
||
document.body,
|
||
);
|
||
}
|