import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { FallingNotesView } from "../components/FallingNotes/FallingNotesView"; import { PianoKeyboardView } from "../components/PianoKeyboard/PianoKeyboardView"; import { SheetMusicView } from "../components/SheetMusic/SheetMusicView"; import { TransportControls } from "../components/Transport/TransportControls"; import { SettingsPanel } from "../components/Transport/SettingsPanel"; import { DevicePanel } from "../components/DevicePanel/DevicePanel"; import { useAppStore } from "../store/useAppStore"; import type { LayoutMode } from "../store/useAppStore"; import bus from "../engine/EventBus"; import { midiToPitch, keySignatureToLabel } from "@bach-to-basics/shared"; import { syncEngine } from "../engine/SyncEngine"; const TAB_LABELS: Record = { piano: "Piano", falling: "Notes", sheet: "Sheet", all: "All", }; const TAB_TITLES: Record = { piano: "Show only the piano keyboard view", falling: "Show only the falling notes view", sheet: "Show only the sheet music view", all: "Show all views at once", }; export function PracticeView() { const [settingsOpen, setSettingsOpen] = useState(false); const { loadMidiFile, loadMusicXmlFile, loadPdfFile, loadAudioFile, settings, updateSettings, status, play, pause, seek, loadError, clearLoadError, document: doc, isLoadingDocument, } = useAppStore(); // Pre-warm sample loading so piano keys respond with minimal latency. // Sample fetch (CDN) does NOT need a user gesture - just creates an // AudioContext (suspended) and downloads samples in the background. useEffect(() => { syncEngine.audio.load().catch(() => {}); const preWarm = () => syncEngine.audio.load().catch(() => {}); document.addEventListener("pointermove", preWarm, { once: true }); return () => document.removeEventListener("pointermove", preWarm); }, []); // Wake both audio contexts (Tone.js + smplr) on the FIRST real user gesture. // The pre-warm above creates contexts before any gesture, so browser autoplay // policy keeps them suspended. Without this, no sound ever plays. useEffect(() => { let woken = false; const wake = () => { if (woken) return; woken = true; syncEngine.wakeAudio(); }; window.addEventListener("pointerdown", wake); window.addEventListener("keydown", wake); return () => { window.removeEventListener("pointerdown", wake); window.removeEventListener("keydown", wake); }; }, []); // Apply theme to element useEffect(() => { document.documentElement.dataset.theme = settings.theme; }, [settings.theme]); // Spacebar: play / pause useEffect(() => { const handler = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement).tagName; if (e.code !== "Space" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; e.preventDefault(); if (status === "playing") pause(); else play(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [status, play, pause]); // ArrowLeft / ArrowRight: seek to prev / next note onset useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return; const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; e.preventDefault(); const { document: d, currentSeconds } = useAppStore.getState(); if (!d?.notes.length) return; const onsets = [...new Set(d.notes.map((n) => n.startSeconds))].sort((a, b) => a - b); if (e.code === "ArrowRight") { const next = onsets.find((t) => t > currentSeconds + 0.05); if (next !== undefined) seek(next); } else { const prev = [...onsets].reverse().find((t) => t < currentSeconds - 0.05); if (prev !== undefined) seek(prev); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [seek]); const loadFile = useCallback( (file: File) => { if (file.name.match(/\.midi?$/i)) loadMidiFile(file); else if (file.name.match(/\.(xml|mxl)$/i)) loadMusicXmlFile(file); else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); else if (file.name.match(/\.(mp3|wav|m4a|ogg|flac|aac)$/i)) loadAudioFile(file); }, [loadMidiFile, loadMusicXmlFile, loadPdfFile, loadAudioFile] ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) loadFile(file); }, [loadFile] ); const { layoutMode } = settings; const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode }); return (
e.preventDefault()} onDrop={handleDrop} > {/* ── Header ─────────────────────────────────────────────────────────── */} {/* Flex layout with absolutely-centered tabs: - Left group: flex, takes natural width (never truncates) - Center tabs: position:absolute left:50% transform:translateX(-50%) - true viewport center - Right group: marginLeft:auto, pushes to far right This pattern is immune to left/right asymmetry that breaks grid-based centering. */}
{/* Left: gradient logo + app name → links to GitHub repo */} {/* Typographic pun: "Bach" and "Basics" bold, "to" muted */} Bach to Basics on GitHub {/* Center: layout mode tabs - absolutely centered in the header */}
{(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => ( ))}
{/* Right: MIDI device chip + theme toggle + settings gear */}
{/* ── Main content - layout-aware ────────────────────────────────────── Single vertical column. The piano keyboard is a SINGLETON pinned at the bottom (keyed) so PixiJS never re-inits on a view switch. FallingNotesView is keyed too, so it stays mounted across the falling<->all toggle (the sheet band appears above it in "all" without remounting it). "all" mode = three full-width horizontal bands stacked vertically: sheet (top, horizontal strip) / falling notes (middle) / piano (bottom). The dedicated "sheet" mode keeps a full vertical page (better for reading). */}
{/* Piano-only mode: decorative backdrop above the keyboard */} {layoutMode === "piano" && } {/* Dedicated Sheet mode: full-height vertical page */} {layoutMode === "sheet" && } {/* All mode: sheet as a full-width horizontal band at the top */} {layoutMode === "all" && (
)} {/* Falling notes: middle (all) or full (falling). Keyed so it stays mounted across the falling<->all toggle (no PixiJS re-init). */} {(layoutMode === "falling" || layoutMode === "all") && } {/* Piano keyboard: always the bottom singleton (keyed). */}
{/* ── Empty state overlay ────────────────────────────────────────────── */} {!doc && !isLoadingDocument && } {/* ── Error toast ────────────────────────────────────────────────────── */} {loadError && } {/* ── Transport bar ──────────────────────────────────────────────────── */} {/* ── Status bar ─────────────────────────────────────────────────────── */} {/* ── Settings panel (managed here, gear is in header) ───────────────── */} setSettingsOpen(false)} />
); } // ── Empty state ─────────────────────────────────────────────────────────────── function EmptyState({ onImport }: { onImport: (file: File) => void }) { const fileInputRef = useRef(null); return (
{/* Illustration */}
No file loaded
Import a MIDI, MusicXML, PDF sheet music, or audio recording to start practicing
{/* Primary CTA */} or drag & drop anywhere { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
); } const UploadIconLg = () => ( ); // ── Piano-mode background ───────────────────────────────────────────────────── // Mirrors the grid drawn by FallingNotesRenderer so the piano-only view has the // same dark background + black-key lanes + octave separator lines. const MIDI_MIN_BG = 21; // A0 const MIDI_MAX_BG = 108; // C8 /** Precompute whiteKeyIndex (cumulative white-key counter per MIDI note). */ function buildWhiteKeyIndex() { const isBlk = (m: number) => [1, 3, 6, 8, 10].includes(m % 12); const idx = new Array(MIDI_MAX_BG + 1).fill(0); let wi = 0; for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) { idx[m] = wi; if (!isBlk(m)) wi++; } return { idx, totalWhite: wi }; } const { idx: wkIdx, totalWhite: TOTAL_WHITE_BG } = buildWhiteKeyIndex(); function drawPianoGrid(canvas: HTMLCanvasElement) { const { width, height } = canvas; if (width === 0 || height === 0) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const isBlk = (m: number) => [1, 3, 6, 8, 10].includes(m % 12); const wW = width / TOTAL_WHITE_BG; // Clear to transparent - the CSS background on the parent div provides the colour. ctx.clearRect(0, 0, width, height); // Faint octave separator lines at every C - neutral, works on dark and light ctx.fillStyle = "rgba(128,128,144,0.2)"; for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) { if (m % 12 === 0 && !isBlk(m)) { const x = wkIdx[m] * wW; ctx.fillRect(x, 0, 1, height); } } } function PianoModeBackground() { const canvasRef = useRef(null); const containerRef = useRef(null); const { settings } = useAppStore(); // Reusable draw callback const redraw = useMemo( () => () => { const canvas = canvasRef.current; if (!canvas) return; drawPianoGrid(canvas); }, [] ); useEffect(() => { const container = containerRef.current; const canvas = canvasRef.current; if (!container || !canvas) return; const ro = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; canvas.width = Math.round(width); canvas.height = Math.round(height); if (settings.showGrid) redraw(); }); ro.observe(container); // Initial size canvas.width = container.clientWidth; canvas.height = container.clientHeight; if (settings.showGrid) redraw(); return () => ro.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [redraw]); // Redraw when showGrid toggles useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; if (settings.showGrid) { redraw(); } else { const ctx = canvas.getContext("2d"); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); } }, [settings.showGrid, redraw]); return (
); } // ── Status bar ──────────────────────────────────────────────────────────────── function StatusBar() { const { document: doc, currentSeconds, tempoMultiplier, status } = useAppStore(); const [lastNote, setLastNote] = useState(""); const clearTimerRef = useRef | null>(null); useEffect(() => { const onNoteOn = ({ midi }: { midi: number; velocity: number }) => { setLastNote(midiToPitch(midi)); if (clearTimerRef.current) clearTimeout(clearTimerRef.current); clearTimerRef.current = setTimeout(() => setLastNote(""), 2000); }; bus.on("input:note:on", onNoteOn as never); return () => { bus.off("input:note:on", onNoteOn as never); if (clearTimerRef.current) clearTimeout(clearTimerRef.current); }; }, []); const bpm = doc?.tempoMap[0]?.bpm ?? null; const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null; const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60) .toString() .padStart(2, "0")}`; const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null; const isActive = status === "playing" || status === "paused"; return (
{doc ? ( <> {isActive && ( Playing )} {doc.title} {keySig && ( <> {keySig} )} {effectiveBpm !== null && ( <> {effectiveBpm} BPM )} {lastNote && ( <> {lastNote} )} ) : ( No file loaded - drag & drop or click ↑ Import )}
{doc && ( {fmt(currentSeconds)} / {fmt(doc.totalDuration)} )}
); } const Dot = () => ·; // ── Error toast ─────────────────────────────────────────────────────────────── // Shown whenever loadError is non-null. Dismissible with ✕ or auto-dismissed // after 12 s so it doesn't block the UI forever. function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () => void }) { useEffect(() => { // Longer timeout for multi-line messages (setup instructions, etc.) const delay = message.includes("\n") ? 30_000 : 12_000; const t = setTimeout(onDismiss, delay); return () => clearTimeout(t); }, [message, onDismiss]); return (
{/* Warning icon */} ⚠ {message.split("\n").map((line, i) => ( {line} {i < message.split("\n").length - 1 &&
}
))}
); } // ── Icons ───────────────────────────────────────────────────────────────────── const MusicNoteIcon = () => ( ); /** Piano keyboard icon: 5 white keys with 3 black keys (C-D-E-F-G pattern) */ const PianoKeysIcon = () => ( ); const SunIcon = () => ( ); const MoonIcon = () => ( ); const GearIcon = () => ( );