Files
pipu-music/apps/web/src/widgets/PlayerBar.tsx
T
mrleo1nidandCursor fa79394869
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s
feat: add personal playlists and shareable track links
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00

287 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}