{/* Canvas area — vertically centered in remaining space */}
{/* Playback bar — stacked below canvas, never overlapping */}
setPlaying(p => !p)}
onReset={() => { setTime(0); }}
onSeek={(t) => setTime(t)}
onHover={(t) => setHoverTime(t)}
/>
);
}
// ── Playback bar ────────────────────────────────────────────────────────────
// Play/pause, return-to-begin, scrub track, time display.
// Uses fixed-width time fields so layout doesn't thrash.
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const timeFromEvent = React.useCallback((e) => {
const rect = trackRef.current.getBoundingClientRect();
const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
return x * duration;
}, [duration]);
const onTrackMove = (e) => {
if (!trackRef.current) return;
const t = timeFromEvent(e);
if (dragging) {
onSeek(t);
} else {
onHover(t);
}
};
const onTrackLeave = () => {
if (!dragging) onHover(null);
};
const onTrackDown = (e) => {
setDragging(true);
const t = timeFromEvent(e);
onSeek(t);
onHover(null);
};
React.useEffect(() => {
if (!dragging) return;
const onUp = () => setDragging(false);
const onMove = (e) => {
if (!trackRef.current) return;
const t = timeFromEvent(e);
onSeek(t);
};
window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
return () => {
window.removeEventListener('mouseup', onUp);
window.removeEventListener('mousemove', onMove);
};
}, [dragging, timeFromEvent, onSeek]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
const fmt = (t) => {
const total = Math.max(0, t);
const m = Math.floor(total / 60);
const s = Math.floor(total % 60);
const cs = Math.floor((total * 100) % 100);
return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
};
const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace';
return (