ci / ts / ts (packages + web) (push) Successful in 4m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
import { useEffect, useState, type CSSProperties } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
|
|
import { AddToPlaylist } from '../entities/playlists';
|
|
import { usePlayer, usePlayerState } from '../player/context';
|
|
import { formatDuration } from '../shared/lib/format';
|
|
import {
|
|
chevronDownIcon,
|
|
likeFillIcon,
|
|
likeIcon,
|
|
moreIcon,
|
|
nextIcon,
|
|
pauseIcon,
|
|
playIcon,
|
|
prevIcon,
|
|
queueIcon,
|
|
repeatIcon,
|
|
repeatOneIcon,
|
|
shareIcon,
|
|
} from '../shared/ui/icons';
|
|
import { cx, Icon } from '../shared/ui/ui';
|
|
import s from './FullPlayer.module.css';
|
|
|
|
/** Полноэкранный плеер: обложка с кнопками поверх неё, название и прогресс. Открывается кликом по обложке в плеере. */
|
|
export function FullPlayer({ onClose, onQueue, share }: { onClose: () => void; onQueue: () => void; share: () => void }) {
|
|
const { t } = useTranslation();
|
|
const player = usePlayer();
|
|
const st = usePlayerState();
|
|
const [menu, setMenu] = useState(false);
|
|
const locked = st.listening?.locked === true;
|
|
const track = st.track;
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Escape') return;
|
|
if (menu) setMenu(false);
|
|
else 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, menu]);
|
|
if (!track) return null;
|
|
const duration = st.durationMs || track.duration_ms;
|
|
const liked = track.reaction === 'like';
|
|
return createPortal(
|
|
<div className={s.root} role="dialog" aria-modal="true" aria-label={t('player.now_playing')}>
|
|
{track.album.cover_url && (
|
|
<span className={s.bg} aria-hidden>
|
|
<img src={coverSrc(track.album.cover_url, 300)} alt="" />
|
|
</span>
|
|
)}
|
|
<button type="button" className={s.close} aria-label={t('form.close')} onClick={onClose}>
|
|
<Icon src={chevronDownIcon} size={26} />
|
|
</button>
|
|
<div className={s.stage}>
|
|
<div className={cx(s.card, menu && s.cardOpen)}>
|
|
<Cover album={track.album} size="100%" className={s.cover} />
|
|
<div className={s.shade} aria-hidden />
|
|
<button type="button" className={cx(s.round, s.topRight)} aria-label={t('player.queue')} onClick={onQueue}>
|
|
<Icon src={queueIcon} size={22} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cx(s.round, s.topLeft, st.repeat !== 'off' && s.accent)}
|
|
aria-label={t(`player.repeat_${st.repeat}`)}
|
|
disabled={locked}
|
|
onClick={() => player.cycleRepeat()}
|
|
>
|
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
|
</button>
|
|
<div className={s.controls}>
|
|
<button type="button" className={s.round} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
|
<Icon src={prevIcon} size={22} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={s.play}
|
|
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
|
aria-busy={st.loading || undefined}
|
|
disabled={locked}
|
|
onClick={() => player.toggle()}
|
|
>
|
|
<Icon src={st.playing ? pauseIcon : playIcon} size={30} />
|
|
</button>
|
|
<button type="button" className={s.round} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
|
<Icon src={nextIcon} size={22} />
|
|
</button>
|
|
</div>
|
|
<div className={s.moreWrap}>
|
|
<button type="button" className={s.round} aria-label={t('player.more')} aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
|
|
<Icon src={moreIcon} size={26} />
|
|
</button>
|
|
{menu && (
|
|
<div className={s.menu}>
|
|
<AddToPlaylist trackIds={[track.id]} className={s.menuItem} />
|
|
<button type="button" className={s.menuItem} onClick={() => { setMenu(false); share(); }}>
|
|
<Icon src={shareIcon} size={18} />
|
|
{t('player.share')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={cx(s.round, s.bottomRight, liked && s.accent)}
|
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
|
aria-pressed={liked}
|
|
onClick={() => void player.react(track, liked ? null : 'like').catch(() => undefined)}
|
|
>
|
|
<Icon src={liked ? likeFillIcon : likeIcon} size={24} />
|
|
</button>
|
|
</div>
|
|
<div className={s.meta}>
|
|
<Link to="/track/$id" params={{ id: track.id }} className={s.title} onClick={onClose}>
|
|
{track.title}
|
|
</Link>
|
|
<span className={s.artists}>
|
|
<ArtistLinks artists={track.artists} hideUrlPreview />
|
|
</span>
|
|
</div>
|
|
<div className={s.progress}>
|
|
<input
|
|
type="range"
|
|
className={s.range}
|
|
min={0}
|
|
max={duration}
|
|
step={1000}
|
|
value={Math.min(st.positionMs, duration)}
|
|
aria-label={t('player.progress')}
|
|
aria-valuetext={formatDuration(st.positionMs)}
|
|
disabled={locked}
|
|
onChange={(e) => player.seek(Number(e.target.value))}
|
|
style={{ '--pct': `${(st.positionMs / duration) * 100}%` } as CSSProperties}
|
|
/>
|
|
<span className={s.times}>
|
|
<span>{formatDuration(st.positionMs)}</span>
|
|
<span>{formatDuration(duration)}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|