import { memo, useRef, useState } from "react";
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y } from "./timelineLayout";
export const BEAT_BAND_H = 14; // dark band height at top of track
const BEAT_HIT_W = 12; // grab width per beat (px)
/** Hide both layers when beats are packed tighter than this (px) โ too dense to read. */
function beatsTooDense(beatTimes: number[], pps: number): boolean {
if (beatTimes.length < 2) return true;
const avgInterval = (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1);
return avgInterval * pps < 5;
}
/**
* Faint full-height beat lines painted into a track lane's background. Rendered
* behind the clips so they only show through the empty track area (the dots in
* BeatStrip mark beats on the clips themselves). Brightness scales with beat
* loudness. Drawn on every track lane for a global beat grid.
*/
export const BeatBackgroundLines = memo(function BeatBackgroundLines({
beatTimes,
beatStrengths,
pps,
highlightTime,
}: {
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
/** Beat time a dragged clip will snap to โ drawn as a bright neon line. */
highlightTime?: number | null;
}) {
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
return (
{beatTimes.map((t, i) => {
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
return (
);
})}
);
});
/**
* Green beat dots on the music track's row. Drag a dot to move its beat,
* double-click to delete; both scrub the audio. Dot size/brightness scale with
* beat loudness (gamma-curved for contrast).
*/
export const BeatStrip = memo(function BeatStrip({
beatTimes,
beatStrengths,
pps,
}: {
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
}) {
// Active drag: which beat and how far (px) it's been dragged.
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
const cy = BEAT_BAND_H / 2;
return (
{beatTimes.map((t, i) => {
// Louder beats โ larger, brighter dot. Gamma curve widens the contrast.
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
const r = 1.5 + strength * 2.5;
const opacity = 0.25 + strength * 0.75;
const dxPx = drag?.index === i ? drag.dx : 0;
const x = t * pps + dxPx;
return (
{
// preventDefault stops the browser starting a native text/drag
// selection (which otherwise "selects" the whole panel mid-drag).
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { index: i, startX: e.clientX, origTime: t };
setDrag({ index: i, dx: 0 });
usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.index !== i) return;
e.preventDefault();
const dx = e.clientX - d.startX;
setDrag({ index: i, dx });
// Scrub the audio (and move the playhead) to follow the dragged beat.
usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
}}
onPointerUp={(e) => {
const d = dragRef.current;
dragRef.current = null;
setDrag(null);
usePlayerStore.getState().setBeatDragging(false);
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
if (!d || d.index !== i) return;
const dx = e.clientX - d.startX;
if (Math.abs(dx) > 2) {
const newTime = Math.max(0, d.origTime + dx / pps);
moveBeatCompositionTime(d.origTime, newTime);
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
deleteBeatAtCompositionTime(t);
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
}}
>
);
})}
);
});