diff --git a/site/src/App.tsx b/site/src/App.tsx index 73af0fe61..fe4c52917 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import type { Config, + Cursor, HoverEvent, Note, PointerTarget, @@ -85,6 +86,68 @@ function CheckIcon() { ); } +// Heroicons (outline). Each value is the icon's path list — circle play/pause have two paths. +const ICON = { + play: [ + 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z', + ], + pause: ['M15.75 5.25v13.5m-7.5-13.5v13.5'], + prev: ['M15.75 19.5 8.25 12l7.5-7.5'], + next: ['m8.25 4.5 7.5 7.5-7.5 7.5'], +} as const; + +function PlayerIcon({ + d, + className = 'size-6', +}: { + d: readonly string[]; + className?: string; +}) { + return ( + + ); +} + +// ms → m:ss +function fmtTime(ms: number): string { + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +function Section({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ + {title} + + {action} +
+ {children} +
+ ); +} + function Or() { return (
@@ -117,17 +180,26 @@ export default function App() { ); const [cleared, setCleared] = useState(false); const [restored, setRestored] = useState(false); - const [showInfo, setShowInfo] = useState(true); + const [scrolled, setScrolled] = useState(false); const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string; } | null>(null); - // Read live inside the pointer handler so toggling the checkbox doesn't re-subscribe. - const showInfoRef = useRef(showInfo); - showInfoRef.current = showInfo; + // Scrub-bar tooltip: "measure i of N" at the pointer x (relative to the track), while + // hovering or dragging the seek slider. ponytail: pointer-only, add a value-driven tip if + // keyboard nav matters. + const [scrubTip, setScrubTip] = useState<{ x: number; text: string } | null>( + null, + ); // The note whose halo is currently lit, so the next move can turn it back off. const haloRef = useRef(null); + // Playback cursor: owned by the current Score (disposed with it). `change` keeps timeMs in sync, + // whether movement came from the play loop, next/previous, or seek. + const cursorRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [timeMs, setTimeMs] = useState(0); + const [durationMs, setDurationMs] = useState(0); const debounceRef = useRef | undefined>( undefined, ); @@ -138,7 +210,9 @@ export default function App() { const systemSpacing = config.systemSpacing ?? 30; const maxSystemFill = config.maxSystemFill ?? 0.9; const width = - config.layout?.type === 'standard' ? (config.layout.width ?? 900) : 900; + config.layout?.type === 'standard' + ? (config.layout.referenceWidth ?? 900) + : 900; const notationFont = config.fonts?.notation?.family ?? 'Bravura'; const resetKeys = [ 'noteSpacing', @@ -201,7 +275,7 @@ export default function App() { // window re-scales instantly without re-rendering. const layoutWidth = renderConfig.layout?.type === 'standard' - ? renderConfig.layout.width + ? renderConfig.layout.referenceWidth : undefined; let cancelled = false; // Turn off the lit halo and hide the tooltip; used to reset on teardown/re-render. @@ -215,7 +289,7 @@ export default function App() { let detach: (() => void) | undefined; render(input, container, { ...renderConfig, - layout: { type: 'standard', width: layoutWidth }, + layout: { type: 'standard', referenceWidth: layoutWidth }, }) .then((score) => { // The effect can re-run before this resolves; drop the late score so it @@ -229,6 +303,49 @@ export default function App() { setRenderMs(renderMsRef.current); setInitialized(true); + // Headless cursor + the built-in bar view; follow() scrolls it into view as it moves. + const cursor = score.addCursor(); + cursor.attach(score.createCursorView()); + cursor.follow(); + // The notes (and their tab frets) currently under the cursor. Cursor coloring and the + // hover halo share one color channel, so recolor() resolves both: hover wins while a + // note is hovered, otherwise the active color shows, otherwise it clears. Rests never + // enter `lit` (no pitch), so only sounding notes get the active color. + const lit = new Set(); + const recolor = (n: Note) => { + if (n === haloRef.current) { + n.color.on('#f4f800'); + } else if (lit.has(n)) { + n.color.on('#155dfc'); + } else { + n.color.off(); + } + }; + const paint = (active: readonly Note[]) => { + const sounding = active.filter((n) => n.getPitch() !== null); + for (const n of [...lit]) { + if (!sounding.includes(n)) { + lit.delete(n); + recolor(n); + } + } + for (const n of sounding) { + if (!lit.has(n)) { + lit.add(n); + recolor(n); + } + } + }; + cursor.addEventListener('change', (e) => { + setTimeMs(e.timeMs); + paint(e.active); + }); + paint(cursor.getActiveNotes()); + cursorRef.current = cursor; + setDurationMs(score.getDurationMs()); + setTimeMs(0); + setPlaying(false); + // A click/tap pins a target (toggle); hover is transient. The pinned one wins, so // hovering elsewhere — or scrolling it out from under the pointer — never clears the // pin. Clicking it again, or clicking empty space, unpins. @@ -243,15 +360,22 @@ export default function App() { ? target.getNote() : null; if (note !== haloRef.current) { - haloRef.current?.halo.off(); - haloRef.current?.color.off(); - note?.halo.on('rgba(255, 0, 105, 0.9)'); - note?.color.on('#f4f800'); + const prev = haloRef.current; haloRef.current = note; + prev?.halo.off(); + // recolor reads haloRef.current, so update it first: prev falls back to its + // active color (or clears), note picks up the hover color. + if (prev) { + recolor(prev); + } + note?.halo.on('rgba(255, 0, 105, 0.9)'); + if (note) { + recolor(note); + } } container.style.cursor = note ? 'pointer' : ''; // Only note-bearing targets get a tooltip; describe() is empty for a measure. - if (note && target && showInfoRef.current) { + if (note && target) { const r = target.getBoundingClientRect(); setTooltip({ x: r.left + r.width / 2, @@ -277,8 +401,26 @@ export default function App() { pinned = pinned === t ? null : t; apply(); }; + // Click or drag anywhere on the score scrubs the cursor to that position's time. + const seekTo = (point: { x: number; y: number }) => { + const t = score.getTimeAt(point); + if (t) { + setPlaying(false); + cursor.seekMs(t.ms); + } + }; + const onPointerDown = (e: PointerTargetEvent) => seekTo(e.point); + // buttons === 1 means the primary button is held, so this continues the scrub + // during a drag and ignores a plain hover (no manual drag-state flag needed). + const onPointerMove = (e: PointerTargetEvent) => { + if (e.native.buttons === 1) { + seekTo(e.point); + } + }; score.addEventListener('hover', onHover); score.addEventListener('click', onClick); + score.addEventListener('pointerdown', onPointerDown); + score.addEventListener('pointermove', onPointerMove); detach = clearHalo; }) .catch((e: unknown) => { @@ -289,13 +431,60 @@ export default function App() { }); return () => { cancelled = true; - // score.dispose() drops its own listeners; this only unbinds the DOM-level leave handler. + // score.dispose() drops its own listeners (and disposes the cursor); this only unbinds + // the DOM-level leave handler and stops the play loop. detach?.(); scoreRef.current?.dispose(); scoreRef.current = null; + cursorRef.current = null; + setPlaying(false); }; }, [input, renderConfig]); + // Advance the cursor in real time while playing. seekMs drives the bar (and any future audio); + // stops itself at the end. ponytail: wall-clock RAF, no audio yet — swap in an audio clock when sound lands. + useEffect(() => { + const cursor = cursorRef.current; + if (!playing || !cursor) { + return; + } + let raf = 0; + let last = performance.now(); + const tick = (now: number) => { + const next = cursor.getTimeMs() + (now - last); + last = now; + if (next >= durationMs) { + cursor.seekMs(durationMs); + setPlaying(false); + return; + } + cursor.seekMs(next); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [playing, durationMs]); + + const togglePlay = () => { + const cursor = cursorRef.current; + if (!cursor) { + return; + } + // Restart from the top if we're parked at the end. + if (!playing && cursor.isDone()) { + cursor.seekMs(0); + } + setPlaying((p) => !p); + }; + const stepPrev = () => { + setPlaying(false); + cursorRef.current?.previous(); + }; + const stepNext = () => { + setPlaying(false); + cursorRef.current?.next(); + }; + // Restore the last-edited MusicXML, or open with a random example. useEffect(() => { const saved = localStorage.getItem(STORAGE_KEY); @@ -434,386 +623,501 @@ export default function App() {
-