Spaces:
Sleeping
Sleeping
| 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"> | |
| × | |
| </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> | |
| ); | |
| } | |