diff --git a/packages/ui/src/player/controls/scrubber.tsx b/packages/ui/src/player/controls/scrubber.tsx index 9648a9561..ca7f9f874 100644 --- a/packages/ui/src/player/controls/scrubber.tsx +++ b/packages/ui/src/player/controls/scrubber.tsx @@ -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, 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 = ({ totalBars = 64, @@ -31,32 +71,109 @@ export const Scrubber: React.FC = ({ onSeek, onSeekBar, }) => { - const W = compact ? 400 : 600; + const wrapperRef = React.useRef(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( + , + ); } - } - const wrapperRef = React.useRef(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} + , + ); + } + + // 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( + + + + + ♪ + + + = {bpm} + + + , + ); + } + } + return { ticks, labels, tempos }; + }, [W, safeTotal, compact, tempoMarkers, tempoH, trackY, labelY, labelFontSize]); + const [drag, setDrag] = React.useState(false); const seekAtClientX = React.useCallback( @@ -125,38 +242,14 @@ export const Scrubber: React.FC = ({ style={{ width: '100%', cursor: onSeek ? 'pointer' : undefined, touchAction: 'none' }} {...slider} > + {/* The viewBox matches the measured width; only the fallback width before measuring stretches. */}