Files
Ivan Fontosh 278eb78637 feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

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

154 lines
4.3 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../ui/controls';
import styles from './PlaylistPlayerBar.module.css';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function clampGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
export type PlaylistPlayerBarProps = {
title: string;
subtitle?: string;
audioEl: HTMLAudioElement | null;
playing: boolean;
gain: number;
onGainChange: (gain: number) => void;
onTogglePlay: () => void;
onPrev: () => void;
onNext: () => void;
playLabel: string;
pauseLabel: string;
prevLabel: string;
nextLabel: string;
volumeLabel: string;
scrubLabel: string;
emptyLabel: string;
};
export function PlaylistPlayerBar({
title,
subtitle,
audioEl,
playing,
gain,
onGainChange,
onTogglePlay,
onPrev,
onNext,
playLabel,
pauseLabel,
prevLabel,
nextLabel,
volumeLabel,
scrubLabel,
emptyLabel,
}: PlaylistPlayerBarProps) {
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const scrubbing = useRef(false);
useEffect(() => {
if (!audioEl) {
setCurrent(0);
setDuration(0);
return;
}
const sync = () => {
if (!scrubbing.current) setCurrent(audioEl.currentTime);
setDuration(Number.isFinite(audioEl.duration) ? audioEl.duration : 0);
};
sync();
audioEl.addEventListener('timeupdate', sync);
audioEl.addEventListener('loadedmetadata', sync);
audioEl.addEventListener('durationchange', sync);
return () => {
audioEl.removeEventListener('timeupdate', sync);
audioEl.removeEventListener('loadedmetadata', sync);
audioEl.removeEventListener('durationchange', sync);
};
}, [audioEl]);
useEffect(() => {
if (audioEl) audioEl.volume = clampGain(gain);
}, [audioEl, gain]);
const hasTrack = Boolean(audioEl);
return (
<div className={styles.bar} data-testid="playlist-player-bar">
<div className={styles.meta}>
<div className={styles.title}>{hasTrack ? title : emptyLabel}</div>
{hasTrack && subtitle ? <div className={styles.subtitle}>{subtitle}</div> : null}
</div>
<div className={styles.transport}>
<Button variant="ghost" iconOnly title={prevLabel} ariaLabel={prevLabel} disabled={!hasTrack} onClick={onPrev}>
⏮
</Button>
<Button
variant="primary"
iconOnly
title={playing ? pauseLabel : playLabel}
ariaLabel={playing ? pauseLabel : playLabel}
disabled={!hasTrack}
onClick={onTogglePlay}
data-testid="playlist-play-pause"
>
{playing ? '⏸' : '▶'}
</Button>
<Button variant="ghost" iconOnly title={nextLabel} ariaLabel={nextLabel} disabled={!hasTrack} onClick={onNext}>
⏭
</Button>
</div>
<div className={styles.scrubRow}>
<span className={styles.time}>{formatTime(current)}</span>
<input
type="range"
className={styles.scrub}
min={0}
max={Math.max(duration, 0.001)}
step={0.01}
value={Math.min(current, duration || 0)}
disabled={!hasTrack || !duration}
aria-label={scrubLabel}
onMouseDown={() => {
scrubbing.current = true;
}}
onMouseUp={(e) => {
scrubbing.current = false;
if (audioEl) audioEl.currentTime = Number(e.currentTarget.value);
}}
onChange={(e) => {
const v = Number(e.currentTarget.value);
setCurrent(v);
if (audioEl && !scrubbing.current) audioEl.currentTime = v;
}}
/>
<span className={styles.time}>{formatTime(duration)}</span>
</div>
<label className={styles.volume}>
<span className={styles.volumeLabel}>{volumeLabel}</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={clampGain(gain)}
aria-label={volumeLabel}
onChange={(e) => onGainChange(clampGain(Number(e.currentTarget.value)))}
/>
</label>
</div>
);
}