Files
pipu-music/apps/web/src/widgets/PlayerBar.tsx
T
mrleo1nidandClaude Sonnet 5.5 8a007be036
ci / ts / ts (packages + web) (push) Successful in 3m15s
feat(web): show spinner on play button while track is loading
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00

431 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 { 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<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 = () => {
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, 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={44} 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} title={track.title}>{track.title}</span>
<span className={s.artists}>
<ArtistLinks artists={track.artists} hideUrlPreview />
</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')} disabled={locked} 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}
disabled={locked} onClick={() => player.toggle()}
>
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={28} />}
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} 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)}
disabled={locked} 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')} 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}
disabled={locked} 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}
disabled={locked} 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}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</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,
}: {
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(
<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} hideUrlPreview />
</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')}
disabled={locked} 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')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</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()}>
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} 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} disabled={locked} onClick={() => player.toggleShuffle()}>
<Icon src={shuffleIcon} size={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} disabled={locked} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} 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 locked = st.listening?.locked === true;
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}
disabled={st.listening?.locked} 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,
);
}