mroctopus / app /src /components /Controls.jsx
Ewan
Fix piano roll quality, drum sounds, add original audio playback, fix speed
b5c16f8
import { useState, useEffect, useRef } from 'react';
import OctopusLogo from './OctopusLogo';
function formatTime(s) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
export default function Controls({
isPlaying,
togglePlayPause,
tempo,
setTempo,
currentTimeRef,
totalDuration,
seekTo,
fileName,
onNewSong,
loopStart,
loopEnd,
isLooping,
onSetLoopA,
onSetLoopB,
onClearLoop,
originalAudioOn,
setOriginalAudioOn,
originalVolume,
setOriginalVolume,
}) {
const [displayTime, setDisplayTime] = useState(0);
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setDisplayTime(currentTimeRef.current);
}, 50);
return () => clearInterval(intervalRef.current);
}, [currentTimeRef]);
const progress = totalDuration > 0 ? (displayTime / totalDuration) * 100 : 0;
// Loop region markers for the timeline
const loopStartPct = loopStart !== null && totalDuration > 0
? (loopStart / totalDuration) * 100 : null;
const loopEndPct = loopEnd !== null && totalDuration > 0
? (loopEnd / totalDuration) * 100 : null;
// Build timeline background with loop region
let timelineBg;
if (loopStartPct !== null && loopEndPct !== null) {
timelineBg = `linear-gradient(to right,
var(--border) ${loopStartPct}%,
rgba(139, 92, 246, 0.3) ${loopStartPct}%,
var(--primary) ${Math.min(progress, loopEndPct)}%,
rgba(139, 92, 246, 0.3) ${Math.min(progress, loopEndPct)}%,
rgba(139, 92, 246, 0.3) ${loopEndPct}%,
var(--border) ${loopEndPct}%)`;
} else {
timelineBg = `linear-gradient(to right, var(--primary) ${progress}%, var(--border) ${progress}%)`;
}
return (
<div className="controls">
{/* Main controls row */}
<div className="controls-main">
<div className="controls-left">
<div className="brand-mark">
<OctopusLogo size={32} />
<span className="brand-name">Mr. Octopus</span>
</div>
{fileName && (
<span className="file-name">{fileName.replace(/\.[^.]+$/, '')}</span>
)}
</div>
<div className="controls-center">
<button
className="transport-btn"
onClick={() => seekTo(Math.max(0, displayTime - 5))}
title="Back 5s"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
</svg>
<span className="transport-label">5s</span>
</button>
<button className="play-btn" onClick={togglePlayPause}>
{isPlaying ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
className="transport-btn"
onClick={() => seekTo(Math.min(totalDuration, displayTime + 5))}
title="Forward 5s"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
</svg>
<span className="transport-label">5s</span>
</button>
</div>
<div className="controls-right">
{/* Loop controls */}
<div className="loop-controls">
{isLooping ? (
<>
<span className="loop-badge">
Looping {formatTime(loopStart)} — {formatTime(loopEnd)}
</span>
<button className="btn btn-loop-clear" onClick={onClearLoop} title="Clear loop">
&times;
</button>
</>
) : loopStart !== null ? (
<>
<span className="loop-step">Start: {formatTime(loopStart)}</span>
<span className="loop-arrow"></span>
<button className="btn btn-loop-action" onClick={onSetLoopB} title="Set loop end to current position">
Set End
</button>
</>
) : (
<button className="btn btn-loop-action" onClick={onSetLoopA} title="Set loop start to current position">
Loop from here
</button>
)}
</div>
{/* Original audio toggle + volume */}
{setOriginalAudioOn && (
<div className="original-audio-control">
<button
className={`btn btn-original ${originalAudioOn ? 'active' : ''}`}
onClick={() => setOriginalAudioOn(!originalAudioOn)}
title={originalAudioOn ? 'Mute original audio' : 'Play original audio'}
>
{originalAudioOn ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
)}
<span>Original</span>
</button>
{originalAudioOn && (
<input
type="range"
className="original-volume"
min={0}
max={100}
value={originalVolume}
onChange={(e) => setOriginalVolume(Number(e.target.value))}
title={`Original volume: ${originalVolume}%`}
/>
)}
</div>
)}
<div className="tempo-control">
<span className="tempo-label">Speed</span>
<input
type="range"
min={50}
max={200}
value={tempo}
onChange={(e) => setTempo(Number(e.target.value))}
/>
<span className="tempo-value">{tempo}%</span>
</div>
{onNewSong && (
<button className="btn btn-new" onClick={onNewSong}>
+ New Song
</button>
)}
</div>
</div>
{/* Timeline row */}
<div className="timeline">
<span className="timeline-time">{formatTime(displayTime)}</span>
<div className="timeline-track">
<input
type="range"
min={0}
max={totalDuration || 1}
step={0.1}
value={displayTime}
onChange={(e) => seekTo(Number(e.target.value))}
style={{ background: timelineBg }}
/>
</div>
<span className="timeline-time">{formatTime(totalDuration)}</span>
</div>
</div>
);
}