|
|
@@ -4,18 +4,34 @@ import { syncEngine } from "../../engine/SyncEngine";
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(s: number): string {
|
|
|
|
function formatTime(s: number): string {
|
|
|
|
const m = Math.floor(s / 60);
|
|
|
|
const m = Math.floor(s / 60);
|
|
|
|
return `${m}:${Math.floor(s % 60).toString().padStart(2, "0")}`;
|
|
|
|
return `${m}:${Math.floor(s % 60)
|
|
|
|
|
|
|
|
.toString()
|
|
|
|
|
|
|
|
.padStart(2, "0")}`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function TransportControls() {
|
|
|
|
export function TransportControls() {
|
|
|
|
const {
|
|
|
|
const {
|
|
|
|
status, currentSeconds, tempoMultiplier,
|
|
|
|
status,
|
|
|
|
loopStart, loopEnd, waitMode,
|
|
|
|
currentSeconds,
|
|
|
|
|
|
|
|
tempoMultiplier,
|
|
|
|
|
|
|
|
loopStart,
|
|
|
|
|
|
|
|
loopEnd,
|
|
|
|
|
|
|
|
waitMode,
|
|
|
|
document: doc,
|
|
|
|
document: doc,
|
|
|
|
play, pause, stop, seek,
|
|
|
|
play,
|
|
|
|
setTempoMultiplier, setLoopPoints, setWaitMode, setMetronome,
|
|
|
|
pause,
|
|
|
|
settings, loadMidiFile, loadMusicXmlFile, loadPdfFile,
|
|
|
|
stop,
|
|
|
|
isLoadingDocument, loadingMessage,
|
|
|
|
seek,
|
|
|
|
|
|
|
|
setTempoMultiplier,
|
|
|
|
|
|
|
|
setLoopPoints,
|
|
|
|
|
|
|
|
setWaitMode,
|
|
|
|
|
|
|
|
setMetronome,
|
|
|
|
|
|
|
|
settings,
|
|
|
|
|
|
|
|
loadMidiFile,
|
|
|
|
|
|
|
|
loadMusicXmlFile,
|
|
|
|
|
|
|
|
loadPdfFile,
|
|
|
|
|
|
|
|
isLoadingDocument,
|
|
|
|
|
|
|
|
loadingMessage,
|
|
|
|
} = useAppStore();
|
|
|
|
} = useAppStore();
|
|
|
|
|
|
|
|
|
|
|
|
const [speedOpen, setSpeedOpen] = useState(false);
|
|
|
|
const [speedOpen, setSpeedOpen] = useState(false);
|
|
|
@@ -42,17 +58,17 @@ export function TransportControls() {
|
|
|
|
}, [speedOpen]);
|
|
|
|
}, [speedOpen]);
|
|
|
|
|
|
|
|
|
|
|
|
const openFile = (file: File) => {
|
|
|
|
const openFile = (file: File) => {
|
|
|
|
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
|
|
|
|
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
|
|
|
|
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
|
|
|
|
else if (file.name.match(/\.(xml|mxl)$/i)) loadMusicXmlFile(file);
|
|
|
|
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
|
|
|
|
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const total = doc?.totalDuration ?? 0;
|
|
|
|
const total = doc?.totalDuration ?? 0;
|
|
|
|
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
|
|
|
|
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
|
|
|
|
const loopActive = loopStart !== null && loopEnd !== null;
|
|
|
|
const loopActive = loopStart !== null && loopEnd !== null;
|
|
|
|
const isPlaying = status === "playing";
|
|
|
|
const isPlaying = status === "playing";
|
|
|
|
const isPaused = status === "paused";
|
|
|
|
const isPaused = status === "paused";
|
|
|
|
const isStopped = status === "stopped";
|
|
|
|
const isStopped = status === "stopped";
|
|
|
|
|
|
|
|
|
|
|
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
if (!total) return;
|
|
|
|
if (!total) return;
|
|
|
@@ -62,24 +78,34 @@ export function TransportControls() {
|
|
|
|
|
|
|
|
|
|
|
|
const toggleHand = (hand: "left" | "right") => {
|
|
|
|
const toggleHand = (hand: "left" | "right") => {
|
|
|
|
const next = new Set(settings.activeHands);
|
|
|
|
const next = new Set(settings.activeHands);
|
|
|
|
if (next.has(hand)) { if (next.size > 1) next.delete(hand); }
|
|
|
|
if (next.has(hand)) {
|
|
|
|
else next.add(hand);
|
|
|
|
if (next.size > 1) next.delete(hand);
|
|
|
|
|
|
|
|
} else next.add(hand);
|
|
|
|
useAppStore.getState().setActiveHands(next);
|
|
|
|
useAppStore.getState().setActiveHands(next);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const startLoop = () => {
|
|
|
|
const startLoop = () => {
|
|
|
|
const start = currentSeconds;
|
|
|
|
const start = currentSeconds;
|
|
|
|
const end = Math.min(total, start + 30);
|
|
|
|
const end = Math.min(total, start + 30);
|
|
|
|
setLoopPoints(start, end > start ? end : total);
|
|
|
|
setLoopPoints(start, end > start ? end : total);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
const clearLoop = () => setLoopPoints(null, null);
|
|
|
|
const clearLoop = () => setLoopPoints(null, null);
|
|
|
|
const setLoopA = () => { if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd); };
|
|
|
|
const setLoopA = () => {
|
|
|
|
const setLoopB = () => { if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1)); };
|
|
|
|
if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
const setLoopB = () => {
|
|
|
|
|
|
|
|
if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1));
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
<div
|
|
|
|
className="flex flex-col gap-1.5 pt-2 pb-2 flex-shrink-0 select-none"
|
|
|
|
className="flex flex-col gap-1.5 pt-2 pb-2 flex-shrink-0 select-none"
|
|
|
|
style={{ borderTop: "1px solid var(--color-border)", background: "var(--color-surface)", paddingLeft: 12, paddingRight: 12 }}
|
|
|
|
style={{
|
|
|
|
|
|
|
|
borderTop: "1px solid var(--color-border)",
|
|
|
|
|
|
|
|
background: "var(--color-surface)",
|
|
|
|
|
|
|
|
paddingLeft: 12,
|
|
|
|
|
|
|
|
paddingRight: 12,
|
|
|
|
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{/* ── Progress bar ──────────────────────────────────────────────────── */}
|
|
|
|
{/* ── Progress bar ──────────────────────────────────────────────────── */}
|
|
|
|
<div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}>
|
|
|
|
<div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}>
|
|
|
@@ -102,13 +128,16 @@ export function TransportControls() {
|
|
|
|
aria-valuemin={0}
|
|
|
|
aria-valuemin={0}
|
|
|
|
aria-valuemax={100}
|
|
|
|
aria-valuemax={100}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<div className="relative rounded-full" style={{ height: 6, background: "var(--color-surface-3)" }}>
|
|
|
|
<div
|
|
|
|
|
|
|
|
className="relative rounded-full"
|
|
|
|
|
|
|
|
style={{ height: 6, background: "var(--color-surface-3)" }}
|
|
|
|
|
|
|
|
>
|
|
|
|
{/* Loop region */}
|
|
|
|
{/* Loop region */}
|
|
|
|
{loopActive && total > 0 && (
|
|
|
|
{loopActive && total > 0 && (
|
|
|
|
<div
|
|
|
|
<div
|
|
|
|
className="absolute top-0 h-full"
|
|
|
|
className="absolute top-0 h-full"
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
left: `${(loopStart! / total) * 100}%`,
|
|
|
|
left: `${(loopStart! / total) * 100}%`,
|
|
|
|
width: `${((loopEnd! - loopStart!) / total) * 100}%`,
|
|
|
|
width: `${((loopEnd! - loopStart!) / total) * 100}%`,
|
|
|
|
background: "var(--color-accent)",
|
|
|
|
background: "var(--color-accent)",
|
|
|
|
opacity: 0.25,
|
|
|
|
opacity: 0.25,
|
|
|
@@ -148,36 +177,53 @@ export function TransportControls() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Controls row ──────────────────────────────────────────────────── */}
|
|
|
|
{/* ── Controls row ──────────────────────────────────────────────────── */}
|
|
|
|
<div className="flex items-center min-w-0" style={{ gap: 6, overflowX: "auto", scrollbarWidth: "none" }}>
|
|
|
|
<div
|
|
|
|
|
|
|
|
className="flex items-center min-w-0"
|
|
|
|
|
|
|
|
style={{ gap: 6, overflowX: "auto", scrollbarWidth: "none" }}
|
|
|
|
|
|
|
|
>
|
|
|
|
{/* ── Zone 1: File import - accent-styled to stand out ── */}
|
|
|
|
{/* ── Zone 1: File import - accent-styled to stand out ── */}
|
|
|
|
<label
|
|
|
|
<label
|
|
|
|
title="Open MIDI, MusicXML, or PDF sheet music"
|
|
|
|
title="Open MIDI, MusicXML, or PDF sheet music"
|
|
|
|
aria-label="Import MIDI, MusicXML, or PDF file"
|
|
|
|
aria-label="Import MIDI, MusicXML, or PDF file"
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
display: "inline-flex", alignItems: "center", gap: 5,
|
|
|
|
display: "inline-flex",
|
|
|
|
height: 32, padding: "0 12px",
|
|
|
|
alignItems: "center",
|
|
|
|
borderRadius: 7, cursor: "pointer",
|
|
|
|
gap: 5,
|
|
|
|
fontSize: 12, fontWeight: 600, fontFamily: "inherit",
|
|
|
|
height: 32,
|
|
|
|
|
|
|
|
padding: "0 12px",
|
|
|
|
|
|
|
|
borderRadius: 7,
|
|
|
|
|
|
|
|
cursor: "pointer",
|
|
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
|
|
fontFamily: "inherit",
|
|
|
|
flexShrink: 0,
|
|
|
|
flexShrink: 0,
|
|
|
|
background: "rgba(147,51,234,0.14)",
|
|
|
|
background: "rgba(147,51,234,0.14)",
|
|
|
|
border: "1px solid rgba(147,51,234,0.35)",
|
|
|
|
border: "1px solid rgba(147,51,234,0.35)",
|
|
|
|
color: "var(--color-accent-text)",
|
|
|
|
color: "var(--color-accent-text)",
|
|
|
|
transition: "background 0.12s",
|
|
|
|
transition: "background 0.12s",
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)")}
|
|
|
|
onMouseEnter={(e) =>
|
|
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)")}
|
|
|
|
((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
onMouseLeave={(e) =>
|
|
|
|
|
|
|
|
((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)")
|
|
|
|
|
|
|
|
}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{isLoadingDocument ? <SpinnerIcon /> : <UploadIcon />}
|
|
|
|
{isLoadingDocument ? <SpinnerIcon /> : <UploadIcon />}
|
|
|
|
{isLoadingDocument && loadingMessage
|
|
|
|
{isLoadingDocument && loadingMessage ? (
|
|
|
|
? <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
|
|
|
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
|
|
|
|
: <span>Import</span>
|
|
|
|
) : (
|
|
|
|
}
|
|
|
|
<span>Import</span>
|
|
|
|
|
|
|
|
)}
|
|
|
|
<input
|
|
|
|
<input
|
|
|
|
type="file"
|
|
|
|
type="file"
|
|
|
|
accept=".mid,.midi,.xml,.mxl,.pdf"
|
|
|
|
accept=".mid,.midi,.xml,.mxl,.pdf"
|
|
|
|
className="hidden"
|
|
|
|
className="hidden"
|
|
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) openFile(f); e.target.value = ""; }}
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
|
|
const f = e.target.files?.[0];
|
|
|
|
|
|
|
|
if (f) openFile(f);
|
|
|
|
|
|
|
|
e.target.value = "";
|
|
|
|
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</label>
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
@@ -185,10 +231,20 @@ export function TransportControls() {
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Zone 2: Playback controls ───────────────────────── */}
|
|
|
|
{/* ── Zone 2: Playback controls ───────────────────────── */}
|
|
|
|
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
|
|
|
|
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
|
|
|
|
<Btn onClick={stop} title="Stop and return to start" aria-label="Stop" disabled={isStopped}>
|
|
|
|
<Btn
|
|
|
|
|
|
|
|
onClick={stop}
|
|
|
|
|
|
|
|
title="Stop and return to start"
|
|
|
|
|
|
|
|
aria-label="Stop"
|
|
|
|
|
|
|
|
disabled={isStopped}
|
|
|
|
|
|
|
|
>
|
|
|
|
<StopIcon />
|
|
|
|
<StopIcon />
|
|
|
|
</Btn>
|
|
|
|
</Btn>
|
|
|
|
<Btn onClick={() => syncEngine.skipBy(-10)} title="Skip back 10 seconds" aria-label="Skip back 10 seconds" disabled={!doc}>
|
|
|
|
<Btn
|
|
|
|
|
|
|
|
onClick={() => syncEngine.skipBy(-10)}
|
|
|
|
|
|
|
|
title="Skip back 10 seconds"
|
|
|
|
|
|
|
|
aria-label="Skip back 10 seconds"
|
|
|
|
|
|
|
|
disabled={!doc}
|
|
|
|
|
|
|
|
>
|
|
|
|
<SkipBackIcon />
|
|
|
|
<SkipBackIcon />
|
|
|
|
</Btn>
|
|
|
|
</Btn>
|
|
|
|
<Btn
|
|
|
|
<Btn
|
|
|
@@ -200,7 +256,12 @@ export function TransportControls() {
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
|
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
|
|
</Btn>
|
|
|
|
</Btn>
|
|
|
|
<Btn onClick={() => syncEngine.skipBy(10)} title="Skip forward 10 seconds" aria-label="Skip forward 10 seconds" disabled={!doc}>
|
|
|
|
<Btn
|
|
|
|
|
|
|
|
onClick={() => syncEngine.skipBy(10)}
|
|
|
|
|
|
|
|
title="Skip forward 10 seconds"
|
|
|
|
|
|
|
|
aria-label="Skip forward 10 seconds"
|
|
|
|
|
|
|
|
disabled={!doc}
|
|
|
|
|
|
|
|
>
|
|
|
|
<SkipFwdIcon />
|
|
|
|
<SkipFwdIcon />
|
|
|
|
</Btn>
|
|
|
|
</Btn>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
@@ -221,21 +282,28 @@ export function TransportControls() {
|
|
|
|
aria-pressed={active}
|
|
|
|
aria-pressed={active}
|
|
|
|
className="rounded text-xs font-bold transition-all"
|
|
|
|
className="rounded text-xs font-bold transition-all"
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
width: 28, height: 28,
|
|
|
|
width: 28,
|
|
|
|
|
|
|
|
height: 28,
|
|
|
|
cursor: "pointer",
|
|
|
|
cursor: "pointer",
|
|
|
|
fontFamily: "inherit",
|
|
|
|
fontFamily: "inherit",
|
|
|
|
...(active ? {
|
|
|
|
...(active
|
|
|
|
background: isLeft ? "var(--color-left-hand-subtle)" : "var(--color-right-hand-subtle)",
|
|
|
|
? {
|
|
|
|
color: isLeft ? "var(--color-left-hand-text)" : "var(--color-right-hand-text)",
|
|
|
|
background: isLeft
|
|
|
|
border: isLeft
|
|
|
|
? "var(--color-left-hand-subtle)"
|
|
|
|
? "1px solid var(--color-left-hand-subtle-border)"
|
|
|
|
: "var(--color-right-hand-subtle)",
|
|
|
|
: "1px solid var(--color-right-hand-subtle-border)",
|
|
|
|
color: isLeft
|
|
|
|
} : {
|
|
|
|
? "var(--color-left-hand-text)"
|
|
|
|
background: "var(--color-surface-2)",
|
|
|
|
: "var(--color-right-hand-text)",
|
|
|
|
color: "var(--color-text-muted)",
|
|
|
|
border: isLeft
|
|
|
|
border: "1px solid var(--color-border)",
|
|
|
|
? "1px solid var(--color-left-hand-subtle-border)"
|
|
|
|
opacity: 0.5,
|
|
|
|
: "1px solid var(--color-right-hand-subtle-border)",
|
|
|
|
}),
|
|
|
|
}
|
|
|
|
|
|
|
|
: {
|
|
|
|
|
|
|
|
background: "var(--color-surface-2)",
|
|
|
|
|
|
|
|
color: "var(--color-text-muted)",
|
|
|
|
|
|
|
|
border: "1px solid var(--color-border)",
|
|
|
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
|
|
|
}),
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{isLeft ? "L" : "R"}
|
|
|
|
{isLeft ? "L" : "R"}
|
|
|
@@ -249,8 +317,13 @@ export function TransportControls() {
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Zone 5: Practice tools ──────────────────────────── */}
|
|
|
|
{/* ── Zone 5: Practice tools ──────────────────────────── */}
|
|
|
|
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
|
|
|
|
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
|
|
|
|
|
|
|
|
<ChipBtn
|
|
|
|
<ChipBtn active={waitMode} onClick={() => setWaitMode(!waitMode)} title="Pause until you play the correct note" aria-label="Wait mode" aria-pressed={waitMode}>
|
|
|
|
active={waitMode}
|
|
|
|
|
|
|
|
onClick={() => setWaitMode(!waitMode)}
|
|
|
|
|
|
|
|
title="Pause until you play the correct note"
|
|
|
|
|
|
|
|
aria-label="Wait mode"
|
|
|
|
|
|
|
|
aria-pressed={waitMode}
|
|
|
|
|
|
|
|
>
|
|
|
|
<WaitIcon /> Wait
|
|
|
|
<WaitIcon /> Wait
|
|
|
|
</ChipBtn>
|
|
|
|
</ChipBtn>
|
|
|
|
|
|
|
|
|
|
|
@@ -271,12 +344,15 @@ export function TransportControls() {
|
|
|
|
title="Set loop start (A) to current position"
|
|
|
|
title="Set loop start (A) to current position"
|
|
|
|
aria-label="Set loop start to current position"
|
|
|
|
aria-label="Set loop start to current position"
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
height: 28, padding: "0 8px",
|
|
|
|
height: 28,
|
|
|
|
|
|
|
|
padding: "0 8px",
|
|
|
|
background: "transparent",
|
|
|
|
background: "transparent",
|
|
|
|
border: "none",
|
|
|
|
border: "none",
|
|
|
|
borderRight: "1px solid var(--color-border)",
|
|
|
|
borderRight: "1px solid var(--color-border)",
|
|
|
|
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
|
|
|
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
|
|
|
fontSize: 11, fontWeight: 700, fontFamily: "inherit",
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
|
|
fontFamily: "inherit",
|
|
|
|
cursor: loopActive ? "pointer" : "default",
|
|
|
|
cursor: loopActive ? "pointer" : "default",
|
|
|
|
opacity: loopActive ? 1 : 0.5,
|
|
|
|
opacity: loopActive ? 1 : 0.5,
|
|
|
|
transition: "all 0.12s",
|
|
|
|
transition: "all 0.12s",
|
|
|
@@ -293,13 +369,18 @@ export function TransportControls() {
|
|
|
|
aria-label={loopActive ? "Clear loop" : "Start loop"}
|
|
|
|
aria-label={loopActive ? "Clear loop" : "Start loop"}
|
|
|
|
aria-pressed={loopActive}
|
|
|
|
aria-pressed={loopActive}
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
height: 28, padding: "0 9px",
|
|
|
|
height: 28,
|
|
|
|
|
|
|
|
padding: "0 9px",
|
|
|
|
background: loopActive ? "var(--color-accent-subtle)" : "transparent",
|
|
|
|
background: loopActive ? "var(--color-accent-subtle)" : "transparent",
|
|
|
|
border: "none",
|
|
|
|
border: "none",
|
|
|
|
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
|
|
|
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
|
|
|
fontSize: 11, fontWeight: 500, fontFamily: "inherit",
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
|
|
|
fontFamily: "inherit",
|
|
|
|
cursor: doc ? "pointer" : "default",
|
|
|
|
cursor: doc ? "pointer" : "default",
|
|
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
|
|
display: "flex",
|
|
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
|
|
gap: 4,
|
|
|
|
transition: "all 0.12s",
|
|
|
|
transition: "all 0.12s",
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
>
|
|
|
@@ -314,12 +395,15 @@ export function TransportControls() {
|
|
|
|
title="Set loop end (B) to current position"
|
|
|
|
title="Set loop end (B) to current position"
|
|
|
|
aria-label="Set loop end to current position"
|
|
|
|
aria-label="Set loop end to current position"
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
height: 28, padding: "0 8px",
|
|
|
|
height: 28,
|
|
|
|
|
|
|
|
padding: "0 8px",
|
|
|
|
background: "transparent",
|
|
|
|
background: "transparent",
|
|
|
|
border: "none",
|
|
|
|
border: "none",
|
|
|
|
borderLeft: "1px solid var(--color-border)",
|
|
|
|
borderLeft: "1px solid var(--color-border)",
|
|
|
|
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
|
|
|
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
|
|
|
fontSize: 11, fontWeight: 700, fontFamily: "inherit",
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
|
|
fontFamily: "inherit",
|
|
|
|
cursor: loopActive ? "pointer" : "default",
|
|
|
|
cursor: loopActive ? "pointer" : "default",
|
|
|
|
opacity: loopActive ? 1 : 0.5,
|
|
|
|
opacity: loopActive ? 1 : 0.5,
|
|
|
|
transition: "all 0.12s",
|
|
|
|
transition: "all 0.12s",
|
|
|
@@ -367,20 +451,47 @@ export function TransportControls() {
|
|
|
|
padding: "12px 14px",
|
|
|
|
padding: "12px 14px",
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
|
|
|
|
<div
|
|
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text)" }}>Playback Speed</span>
|
|
|
|
style={{
|
|
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-accent-text)", fontVariantNumeric: "tabular-nums" }}>
|
|
|
|
display: "flex",
|
|
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
|
|
justifyContent: "space-between",
|
|
|
|
|
|
|
|
marginBottom: 10,
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text)" }}>
|
|
|
|
|
|
|
|
Playback Speed
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
<span
|
|
|
|
|
|
|
|
style={{
|
|
|
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
|
|
color: "var(--color-accent-text)",
|
|
|
|
|
|
|
|
fontVariantNumeric: "tabular-nums",
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
{Math.round(tempoMultiplier * 100)}%
|
|
|
|
{Math.round(tempoMultiplier * 100)}%
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<input
|
|
|
|
<input
|
|
|
|
type="range" min={25} max={200} step={5}
|
|
|
|
type="range"
|
|
|
|
|
|
|
|
min={25}
|
|
|
|
|
|
|
|
max={200}
|
|
|
|
|
|
|
|
step={5}
|
|
|
|
value={Math.round(tempoMultiplier * 100)}
|
|
|
|
value={Math.round(tempoMultiplier * 100)}
|
|
|
|
onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)}
|
|
|
|
onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)}
|
|
|
|
aria-label="Playback speed"
|
|
|
|
aria-label="Playback speed"
|
|
|
|
style={{ width: "100%", accentColor: "var(--color-accent)" }}
|
|
|
|
style={{ width: "100%", accentColor: "var(--color-accent)" }}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "var(--color-text-muted)", marginTop: 4 }}>
|
|
|
|
<div
|
|
|
|
|
|
|
|
style={{
|
|
|
|
|
|
|
|
display: "flex",
|
|
|
|
|
|
|
|
justifyContent: "space-between",
|
|
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
|
|
color: "var(--color-text-muted)",
|
|
|
|
|
|
|
|
marginTop: 4,
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
<span>25%</span>
|
|
|
|
<span>25%</span>
|
|
|
|
<span>100%</span>
|
|
|
|
<span>100%</span>
|
|
|
|
<span>200%</span>
|
|
|
|
<span>200%</span>
|
|
|
@@ -389,12 +500,15 @@ export function TransportControls() {
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
onClick={() => setTempoMultiplier(1.0)}
|
|
|
|
onClick={() => setTempoMultiplier(1.0)}
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
marginTop: 10, width: "100%",
|
|
|
|
marginTop: 10,
|
|
|
|
|
|
|
|
width: "100%",
|
|
|
|
background: "var(--color-surface-2)",
|
|
|
|
background: "var(--color-surface-2)",
|
|
|
|
border: "1px solid var(--color-border)",
|
|
|
|
border: "1px solid var(--color-border)",
|
|
|
|
borderRadius: 6,
|
|
|
|
borderRadius: 6,
|
|
|
|
color: "var(--color-accent-text)",
|
|
|
|
color: "var(--color-accent-text)",
|
|
|
|
fontSize: 11, fontWeight: 600, fontFamily: "inherit",
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
|
|
fontFamily: "inherit",
|
|
|
|
padding: "5px 0",
|
|
|
|
padding: "5px 0",
|
|
|
|
cursor: "pointer",
|
|
|
|
cursor: "pointer",
|
|
|
|
}}
|
|
|
|
}}
|
|
|
@@ -405,7 +519,6 @@ export function TransportControls() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Waiting status pill */}
|
|
|
|
{/* Waiting status pill */}
|
|
|
@@ -419,7 +532,6 @@ export function TransportControls() {
|
|
|
|
● Waiting…
|
|
|
|
● Waiting…
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
);
|
|
|
@@ -427,7 +539,14 @@ export function TransportControls() {
|
|
|
|
|
|
|
|
|
|
|
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
|
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disabled }: {
|
|
|
|
function Btn({
|
|
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
|
|
title,
|
|
|
|
|
|
|
|
"aria-label": ariaLabel,
|
|
|
|
|
|
|
|
children,
|
|
|
|
|
|
|
|
primary,
|
|
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
|
|
}: {
|
|
|
|
onClick: () => void;
|
|
|
|
onClick: () => void;
|
|
|
|
title?: string;
|
|
|
|
title?: string;
|
|
|
|
"aria-label"?: string;
|
|
|
|
"aria-label"?: string;
|
|
|
@@ -448,7 +567,15 @@ function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disab
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-pressed": ariaPressed, children, disabled }: {
|
|
|
|
function ChipBtn({
|
|
|
|
|
|
|
|
active,
|
|
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
|
|
title,
|
|
|
|
|
|
|
|
"aria-label": ariaLabel,
|
|
|
|
|
|
|
|
"aria-pressed": ariaPressed,
|
|
|
|
|
|
|
|
children,
|
|
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
|
|
}: {
|
|
|
|
active: boolean;
|
|
|
|
active: boolean;
|
|
|
|
onClick: () => void;
|
|
|
|
onClick: () => void;
|
|
|
|
title?: string;
|
|
|
|
title?: string;
|
|
|
@@ -466,12 +593,21 @@ function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-presse
|
|
|
|
disabled={disabled}
|
|
|
|
disabled={disabled}
|
|
|
|
className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0"
|
|
|
|
className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0"
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
height: 28, padding: "0 8px",
|
|
|
|
height: 28,
|
|
|
|
|
|
|
|
padding: "0 8px",
|
|
|
|
cursor: disabled ? "default" : "pointer",
|
|
|
|
cursor: disabled ? "default" : "pointer",
|
|
|
|
fontFamily: "inherit",
|
|
|
|
fontFamily: "inherit",
|
|
|
|
...(active
|
|
|
|
...(active
|
|
|
|
? { background: "var(--color-accent-subtle)", color: "var(--color-accent-text)", border: "1px solid var(--color-accent-subtle-border)" }
|
|
|
|
? {
|
|
|
|
: { background: "var(--color-surface-2)", color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }),
|
|
|
|
background: "var(--color-accent-subtle)",
|
|
|
|
|
|
|
|
color: "var(--color-accent-text)",
|
|
|
|
|
|
|
|
border: "1px solid var(--color-accent-subtle-border)",
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
: {
|
|
|
|
|
|
|
|
background: "var(--color-surface-2)",
|
|
|
|
|
|
|
|
color: "var(--color-text-muted)",
|
|
|
|
|
|
|
|
border: "1px solid var(--color-border)",
|
|
|
|
|
|
|
|
}),
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{children}
|
|
|
|
{children}
|
|
|
@@ -480,53 +616,97 @@ function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-presse
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Divider() {
|
|
|
|
function Divider() {
|
|
|
|
return <div className="shrink-0" style={{ width: 1, height: 20, background: "var(--color-border)", margin: "0 2px" }} />;
|
|
|
|
return (
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
|
|
|
style={{ width: 1, height: 20, background: "var(--color-border)", margin: "0 2px" }}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Icons ─────────────────────────────────────────────────────────────────────
|
|
|
|
// ── Icons ─────────────────────────────────────────────────────────────────────
|
|
|
|
const PlayIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>;
|
|
|
|
const PlayIcon = () => (
|
|
|
|
const PauseIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>;
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
const StopIcon = () => <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="1"/></svg>;
|
|
|
|
<path d="M8 5v14l11-7z" />
|
|
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const PauseIcon = () => (
|
|
|
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
|
|
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const StopIcon = () => (
|
|
|
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
|
|
|
|
<rect x="5" y="5" width="14" height="14" rx="1" />
|
|
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
);
|
|
|
|
const SkipBackIcon = () => (
|
|
|
|
const SkipBackIcon = () => (
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
|
|
|
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" />
|
|
|
|
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
|
|
|
|
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">
|
|
|
|
|
|
|
|
10
|
|
|
|
|
|
|
|
</text>
|
|
|
|
</svg>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
const SkipFwdIcon = () => (
|
|
|
|
const SkipFwdIcon = () => (
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
|
|
|
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
|
|
|
|
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
|
|
|
|
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">
|
|
|
|
|
|
|
|
10
|
|
|
|
|
|
|
|
</text>
|
|
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const UploadIcon = () => (
|
|
|
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
|
|
|
|
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" />
|
|
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const SpinnerIcon = () => (
|
|
|
|
|
|
|
|
<svg
|
|
|
|
|
|
|
|
width="12"
|
|
|
|
|
|
|
|
height="12"
|
|
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
|
|
style={{ animation: "spin 1s linear infinite" }}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z" />
|
|
|
|
</svg>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
const UploadIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/></svg>;
|
|
|
|
|
|
|
|
const SpinnerIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ animation: "spin 1s linear infinite" }}><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const WaitIcon = () => (
|
|
|
|
const WaitIcon = () => (
|
|
|
|
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
|
|
|
|
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
|
|
|
|
<rect x="3.5" y="10" width="13" height="7" rx="1.5"/>
|
|
|
|
<rect x="3.5" y="10" width="13" height="7" rx="1.5" />
|
|
|
|
<rect x="4" y="2.5" width="2.5" height="9" rx="1.25"/>
|
|
|
|
<rect x="4" y="2.5" width="2.5" height="9" rx="1.25" />
|
|
|
|
<rect x="7.5" y="1" width="2.5" height="10.5" rx="1.25"/>
|
|
|
|
<rect x="7.5" y="1" width="2.5" height="10.5" rx="1.25" />
|
|
|
|
<rect x="11" y="1.5" width="2.5" height="10" rx="1.25"/>
|
|
|
|
<rect x="11" y="1.5" width="2.5" height="10" rx="1.25" />
|
|
|
|
<rect x="14" y="4" width="2.5" height="7.5" rx="1.25"/>
|
|
|
|
<rect x="14" y="4" width="2.5" height="7.5" rx="1.25" />
|
|
|
|
</svg>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const LoopIcon = () => (
|
|
|
|
const LoopIcon = () => (
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.68 14.72 20 13.41 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.32 9.28 4 10.59 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
|
|
|
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.68 14.72 20 13.41 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.32 9.28 4 10.59 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" />
|
|
|
|
</svg>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const SpeedIcon = () => (
|
|
|
|
const SpeedIcon = () => (
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<path d="M7 2v11h3v9l7-12h-4l4-8z"/>
|
|
|
|
<path d="M7 2v11h3v9l7-12h-4l4-8z" />
|
|
|
|
</svg>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const MetronomeIcon = () => (
|
|
|
|
const MetronomeIcon = () => (
|
|
|
|
<svg width="10" height="10" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
<svg
|
|
|
|
<path d="M10 2L2 18h16L10 2z"/>
|
|
|
|
width="10"
|
|
|
|
<line x1="10" y1="15" x2="13.5" y2="6"/>
|
|
|
|
height="10"
|
|
|
|
|
|
|
|
viewBox="0 0 20 20"
|
|
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
|
|
strokeWidth="1.6"
|
|
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<path d="M10 2L2 18h16L10 2z" />
|
|
|
|
|
|
|
|
<line x1="10" y1="15" x2="13.5" y2="6" />
|
|
|
|
</svg>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
);
|
|
|
|