Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 139 additions & 77 deletions packages/ui/src/player/controls/scrubber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,50 @@ export interface ScrubberProps {
/** Bars a Page Up or Page Down press moves. */
const PAGE_BARS = 4;

/** Layout width until the scrubber is measured, e.g. while rendered on the server. */
const FALLBACK_WIDTH = 600;
const FALLBACK_WIDTH_COMPACT = 400;

/** Distances in CSS pixels; the drawing is never scaled. */
const MIN_TICK_GAP = 4;
const MIN_LABEL_GAP = 48;
const LABEL_PADDING = 16;
const MIN_TEMPO_LABEL_GAP = 8;
const TEMPO_LABEL_OFFSET = 4;
const TEMPO_BPM_X = 6;
/** Advance of a digit in the monospace font, relative to the font size. */
const MONO_ADVANCE = 0.6;

/** The smallest power of two that is at least `value`. */
function powerOfTwoAtLeast(value: number): number {
return 2 ** Math.max(0, Math.ceil(Math.log2(Math.max(1, value))));
}

function useWidth(ref: React.RefObject<HTMLElement | null>, fallback: number): number {
const [width, setWidth] = React.useState(fallback);
React.useLayoutEffect(() => {
const node = ref.current;
if (!node) { return; }
const update = () => {
const w = node.getBoundingClientRect().width;
if (w > 0) { setWidth(w); }
};
update();
if (typeof ResizeObserver === 'undefined') { return; }
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, [ref]);
return width;
}

/**
* The song timeline with bar ticks, tempo markers and the loop range. With `onSeek` it is a slider:
* pointer drags seek continuously; the arrow keys move by a bar, Page Up and Page Down by four,
* Home and End to the first and last bar.
*
* It draws at its rendered width: tick and bar-number density follow the space a bar gets, labels
* show one-based bar numbers at the start of their bar.
*/
export const Scrubber: React.FC<ScrubberProps> = ({
totalBars = 64,
Expand All @@ -31,32 +71,109 @@ export const Scrubber: React.FC<ScrubberProps> = ({
onSeek,
onSeekBar,
}) => {
const W = compact ? 400 : 600;
const wrapperRef = React.useRef<HTMLDivElement>(null);
const W = useWidth(wrapperRef, compact ? FALLBACK_WIDTH_COMPACT : FALLBACK_WIDTH);
const tempoH = compact ? 0 : 12;
const trackY = compact ? 8 : 22;
const labelY = compact ? 16 : 32;
const labelY = compact ? 21 : 36;
const labelFontSize = compact ? 7 : 8;
const H = compact ? 22 : 38;
const safeTotal = Math.max(1, totalBars);
const xOf = (bar: number) => (Math.max(0, Math.min(bar, safeTotal)) / safeTotal) * W;
const cursorX = xOf(Math.max(0, Math.min(currentBar, safeTotal)));
const labelStops = [0, 16, 32, 48, 64].filter((b) => b > 0 && b < safeTotal);
// Drop tempo markers whose label would collide with the previously kept one.
// Scores with many close-spaced tempo automations would otherwise stack
// their labels into unreadable overlaps.
const MIN_TEMPO_LABEL_GAP = 60;
const tempos: TempoMarker[] = [];
if (tempoMarkers) {
let lastX = -Infinity;
for (const t of tempoMarkers) {
if (t.atBar >= safeTotal) { continue; }
const x = xOf(t.atBar);
if (x - lastX < MIN_TEMPO_LABEL_GAP) { continue; }
tempos.push(t);
lastX = x;

// The ruler only changes with the size, not with the playback position.
const ruler = React.useMemo(() => {
const barWidth = W / safeTotal;
const digitWidth = labelFontSize * MONO_ADVANCE;
const widestLabel = String(safeTotal).length * digitWidth;
const labelStep = powerOfTwoAtLeast(Math.max(MIN_LABEL_GAP, widestLabel + LABEL_PADDING) / barWidth);
const tickStep = Math.min(labelStep, powerOfTwoAtLeast(MIN_TICK_GAP / barWidth));
const midStep = Math.max(tickStep, labelStep / 4);
const x = (bar: number) => (bar / safeTotal) * W;

const ticks: React.ReactNode[] = [];
for (let b = 0; b <= safeTotal; b += tickStep) {
const major = b % labelStep === 0;
const mid = !major && b % midStep === 0;
const h = major ? 5 : mid ? 3 : 1.5;
const op = major ? 0.8 : mid ? 0.5 : 0.28;
ticks.push(
<line
key={`t${b}`}
x1={x(b)}
x2={x(b)}
y1={trackY + 2}
y2={trackY + 2 + h}
stroke="var(--at-muted)"
strokeWidth={1}
opacity={op}
/>,
);
}
}

const wrapperRef = React.useRef<HTMLDivElement>(null);
// Labels center under their tick but stay inside the scrubber at both ends.
const labels: React.ReactNode[] = [];
for (let b = 0; b < safeTotal; b += labelStep) {
const text = String(b + 1);
const half = (text.length * digitWidth) / 2;
labels.push(
<text
key={`l${b}`}
x={Math.max(half, Math.min(x(b), W - half))}
y={labelY}
fontFamily="var(--at-font-mono)"
fontSize={labelFontSize}
fill="var(--at-muted)"
textAnchor="middle"
>
{text}
</text>,
);
}

// A tempo label sits right of its marker, or left of it where it would leave the scrubber.
// Markers whose label would collide with the previously kept one are dropped: scores with
// many close-spaced tempo automations would otherwise stack their labels into unreadable overlaps.
const tempos: React.ReactNode[] = [];
if (!compact && tempoMarkers) {
let lastRight = -Infinity;
for (const t of tempoMarkers) {
if (t.atBar >= safeTotal) { continue; }
const bpm = Math.round(t.bpm);
const mx = x(Math.max(0, t.atBar));
const labelWidth = TEMPO_BPM_X + `= ${bpm}`.length * 8 * MONO_ADVANCE;
const flip = mx + TEMPO_LABEL_OFFSET + labelWidth > W;
const left = flip ? mx - TEMPO_LABEL_OFFSET - labelWidth : mx + TEMPO_LABEL_OFFSET;
if (left < 0 || left - lastRight < MIN_TEMPO_LABEL_GAP) { continue; }
lastRight = left + labelWidth;
tempos.push(
<g key={`m${t.atBar}`}>
<line
x1={mx}
x2={mx}
y1={tempoH - 2}
y2={trackY - 1}
stroke="var(--at-muted)"
strokeWidth={0.6}
opacity={0.5}
strokeDasharray="1 2"
/>
<g transform={`translate(${left}, ${tempoH - 4})`}>
<text fontFamily="Georgia, serif" fontSize="9" fill="var(--at-muted)">
♪
</text>
<text x={TEMPO_BPM_X} fontFamily="var(--at-font-mono)" fontSize="8" fill="var(--at-muted)">
= {bpm}
</text>
</g>
</g>,
);
}
}
return { ticks, labels, tempos };
}, [W, safeTotal, compact, tempoMarkers, tempoH, trackY, labelY, labelFontSize]);

const [drag, setDrag] = React.useState(false);

const seekAtClientX = React.useCallback(
Expand Down Expand Up @@ -125,38 +242,14 @@ export const Scrubber: React.FC<ScrubberProps> = ({
style={{ width: '100%', cursor: onSeek ? 'pointer' : undefined, touchAction: 'none' }}
{...slider}
>
{/* The viewBox matches the measured width; only the fallback width before measuring stretches. */}
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ display: 'block', width: '100%', height: `${H}px`, overflow: 'visible' }}
aria-hidden="true"
>
{!compact &&
tempos.map((tm, i) => {
const x = xOf(tm.atBar);
return (
<g key={i}>
<line
x1={x}
x2={x}
y1={tempoH - 2}
y2={trackY - 1}
stroke="var(--at-muted)"
strokeWidth={0.6}
opacity={0.5}
strokeDasharray="1 2"
/>
<g transform={`translate(${x + 4}, ${tempoH - 4})`}>
<text fontFamily="Georgia, serif" fontSize="9" fill="var(--at-muted)">
♪
</text>
<text x={6} fontFamily="var(--at-font-mono)" fontSize="8" fill="var(--at-muted)">
= {Math.round(tm.bpm)}
</text>
</g>
</g>
);
})}
{ruler.tempos}

{loopRange && (
<>
Expand Down Expand Up @@ -203,39 +296,8 @@ export const Scrubber: React.FC<ScrubberProps> = ({
strokeLinecap="round"
/>

{Array.from({ length: safeTotal + 1 }).map((_, b) => {
const x = xOf(b);
const major = b % 16 === 0;
const mid = b % 4 === 0;
const h = major ? 5 : mid ? 3 : 1.5;
const op = major ? 0.8 : mid ? 0.5 : 0.28;
return (
<line
key={b}
x1={x}
x2={x}
y1={trackY + 2}
y2={trackY + 2 + h}
stroke="var(--at-muted)"
strokeWidth={0.7}
opacity={op}
/>
);
})}

{labelStops.map((b) => (
<text
key={b}
x={xOf(b)}
y={labelY + 3}
fontFamily="var(--at-font-mono)"
fontSize="7"
fill="var(--at-muted)"
textAnchor="middle"
>
{b}
</text>
))}
{ruler.ticks}
{ruler.labels}

<circle cx={cursorX} cy={trackY} r={5} fill="var(--at-accent)" opacity={0.18} />
<line
Expand Down
Loading
Loading