278eb78637
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|