feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user