Files
pipu-music/apps/web/src/widgets/PlayerBar.tsx
T
mrleo1nidandCursor 72567acd28
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
feat(web): polish catalog motion, empty states, and mobile now-playing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00

448 lines
18 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, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
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 narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
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}
onClick={
narrow
? (e) => {
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
}
: undefined
}
>
{narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button>
) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)}
<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}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<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>
{sheetOpen && narrow && (
<NowPlayingSheet
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{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 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,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
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(
<div className={s.sheetScrim} onClick={onClose}>
<div
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
role="dialog"
aria-modal="true"
aria-label={t('player.now_playing')}
onClick={(e) => e.stopPropagation()}
>
<div className={s.sheetHead}>
<span>{t('player.now_playing')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
<div className={s.sheetMeta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</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')}
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>
<div className={s.sheetControls}>
<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={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</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.sheetExtra}>
<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={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} 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={22} />
</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>
</div>
</div>,
document.body,
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
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(
<>
<div className={s.queueScrim} onClick={onClose} />
<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,
);
}