import { useAppStore } from "../../store/useAppStore"; import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, InstrumentId, ImpactStyle, } from "../../store/useAppStore"; import { COLOR_PRESET_VALUES, INSTRUMENT_LABELS } from "../../store/useAppStore"; import { useState, createContext, useContext } from "react"; /** Provides the Row's label to child Toggle buttons for aria-label. */ const RowLabelContext = createContext(""); const NOTE_FILTER_OPTIONS: { value: NoteFilter; label: string; title: string }[] = [ { value: "all", label: "All", title: "Show all notes" }, { value: "white", label: "Natural", title: "Show only natural (white) key notes" }, { value: "black", label: "Sharps", title: "Show only sharp/flat (black) key notes" }, { value: "c_only", label: "C only", title: "Show only C notes" }, ]; const COLOR_THEME_LABELS: Record = { violet: "Violet", classic: "Classic", ocean: "Ocean", forest: "Forest", cascade: "Cascade", custom: "Custom", }; const NOTE_LABEL_OPTIONS: { value: NoteLabelMode; label: string; title: string }[] = [ { value: "none", label: "None", title: "No note name labels" }, { value: "c_only", label: "C only", title: "Label C notes with octave number (C4, C5…)" }, { value: "white", label: "Natural", title: "Label all natural (white) keys" }, { value: "black", label: "Sharps", title: "Label all sharp/flat (black) keys" }, { value: "all", label: "All", title: "Label every key" }, ]; const INSTRUMENT_OPTIONS: { value: InstrumentId; label: string; title: string }[] = [ { value: "grand", label: INSTRUMENT_LABELS.grand, title: "Splendid Grand Piano - high-quality sampled concert grand", }, { value: "bright", label: INSTRUMENT_LABELS.bright, title: "Bright Acoustic Piano - brighter attack and tone", }, { value: "electric", label: INSTRUMENT_LABELS.electric, title: "CP80 Electric Grand Piano - vintage Yamaha electric grand", }, { value: "harpsichord", label: INSTRUMENT_LABELS.harpsichord, title: "Harpsichord - plucked strings, no velocity dynamic", }, { value: "honkytonk", label: INSTRUMENT_LABELS.honkytonk, title: "Honky-Tonk Piano - slightly out-of-tune saloon upright", }, ]; function formatTranspose(n: number): string { if (n === 0) return "original"; if (n > 0) return `+${n}♯`; return `${n}♭`; } // ── Sub-components ──────────────────────────────────────────────────────────── function Toggle({ active, onClick, title, "aria-label": ariaLabel, }: { active: boolean; onClick: () => void; title?: string; "aria-label"?: string; }) { const rowLabel = useContext(RowLabelContext); return ( ); } /** A row in the settings panel. * - `sublabel` renders a second, dimmer line under the main label. * - `stacked` puts the control beneath the label instead of inline (useful for wide controls). */ function Row({ label, sublabel, title, stacked = false, children, }: { label: string; sublabel?: string; title?: string; stacked?: boolean; children: React.ReactNode; }) { const contextLabel = title ?? label; if (stacked) { return (
{label} {sublabel && ( {sublabel} )}
{children}
); } return (
{label} {sublabel && ( {sublabel} )}
{children}
); } /** Segmented control - matches the layout-mode tabs in the header. * `fullWidth` stretches to fill its container, each button sharing equal space. */ function BtnGroup({ options, value, onChange, fullWidth = false, "aria-label": ariaLabel, }: { options: { value: T; label: string; title?: string }[]; value: T; onChange: (v: T) => void; fullWidth?: boolean; "aria-label"?: string; }) { return (
{options.map((opt) => { const active = opt.value === value; return ( ); })}
); } // ── Section header - always open, no accordion collapse ─────────────────────── function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
{/* Plain text label - no background, just spacing */}
{title}
{children}
); } /** Lightweight divider that names a sub-group within a Section. */ function SubHeader({ children }: { children: React.ReactNode }) { return (
{children}
); } // ── Reset button ───────────────────────────────────────────────────────────── function ResetButton() { const { resetSettings } = useAppStore(); const isDark = useAppStore((s) => s.settings.theme === "dark"); const [confirmed, setConfirmed] = useState(false); const [hovered, setHovered] = useState(false); const handleClick = () => { if (!confirmed) { setConfirmed(true); setTimeout(() => setConfirmed(false), 3000); } else { resetSettings(); setConfirmed(false); } }; return (
{/* Support link */} (e.currentTarget.style.opacity = "0.75")} onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")} > Buy me a metronome {/* Logo mark - links to GitHub repo */}
Bach to Basics on GitHub
); } // ── Main component ──────────────────────────────────────────────────────────── export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => void }) { const { settings, updateSettings } = useAppStore(); // Computes the inline style for range inputs. // Sets --fill-pct so the CSS gradient draws the correct filled/unfilled split. const rangeStyle = (value: number, min: number, max: number): React.CSSProperties => { const pct = `${((value - min) / (max - min)) * 100}%`; return { "--fill-pct": pct } as React.CSSProperties; }; const handleColorThemeChange = (t: ColorTheme) => { if (t === "custom") { const seed: CustomColors = settings.colorTheme !== "custom" ? COLOR_PRESET_VALUES[settings.colorTheme] : settings.customColors; updateSettings({ colorTheme: "custom", customColors: seed }); } else { updateSettings({ colorTheme: t }); } }; return ( <> {/* Backdrop */} {open && (
)} {/* Drawer */}
{/* Header */}
Settings
{/* Scrollable content */}
{/* ── Appearance ─────────────────────────────────────────────────── */}
{/* Color theme - swatch grid */}
Color theme
{/* Preset swatches - 5 in a row */}
{(["violet", "classic", "ocean", "forest", "cascade"] as const).map((theme) => { const colors = COLOR_PRESET_VALUES[theme]; const active = settings.colorTheme === theme; return ( ); })}
{/* Custom - separate row */} {/* Custom color pickers - shown inline when custom is active */} {settings.colorTheme === "custom" && (
{( [ { key: "leftHand" as keyof CustomColors, label: "Left hand" }, { key: "rightHand" as keyof CustomColors, label: "Right hand" }, { key: "unknown" as keyof CustomColors, label: "Other" }, ] as { key: keyof CustomColors; label: string }[] ).map(({ key, label }) => ( ))}
)} {/* Differentiate hands - toggle + live L/R legend */} {(() => { const activeColors = settings.colorTheme === "custom" ? settings.customColors : COLOR_PRESET_VALUES[settings.colorTheme as Exclude]; return (
Differentiate hands Works with two-track MIDI files
updateSettings({ showHandColors: !settings.showHandColors })} aria-label="Differentiate hands" />
{settings.showHandColors && (
{( [ { color: activeColors.leftHand, label: "Left hand" }, { color: activeColors.rightHand, label: "Right hand" }, ] as { color: string; label: string }[] ).map(({ color, label }) => (
{label}
))}
)}
); })()}
{/* ── Keyboard sub-group ──────────────────────────────────────── */} Keyboard {/* Key color - custom picker with mini piano-key previews */}
{( [ { value: "white" as const, label: "White", title: "Bright white piano keys", topColor: "#c4c4bc", midColor: "#f5f5f0", btmColor: "#e4e4dc", }, { value: "ivory" as const, label: "Ivory", title: "Warm cream-toned piano keys", topColor: "#cdc8a8", midColor: "#fff8e7", btmColor: "#e8e0c8", }, ] as { value: "white" | "ivory"; label: string; title: string; topColor: string; midColor: string; btmColor: string; }[] ).map(({ value, label, title, topColor, midColor, btmColor }) => { const active = settings.pianoTheme === value; return ( ); })}
updateSettings({ noteLabelMode: v })} fullWidth /> updateSettings({ useFlats: !settings.useFlats })} /> {/* ── Falling notes sub-group ─────────────────────────────────── */} Falling notes updateSettings({ fallingNotesLabelMode: v })} fullWidth /> updateSettings({ noteFilter: v })} fullWidth /> updateSettings({ showGrid: !settings.showGrid })} />
4 updateSettings({ minNoteHeight: Number(e.target.value) })} aria-label="Minimum note height in pixels" aria-valuetext={`${settings.minNoteHeight}px`} className="flex-1" style={rangeStyle(settings.minNoteHeight, 4, 24)} /> 24 {settings.minNoteHeight}px
0 updateSettings({ noteCornerRadius: Number(e.target.value) })} aria-label="Note corner radius" aria-valuetext={`${settings.noteCornerRadius}`} className="flex-1" style={rangeStyle(settings.noteCornerRadius, 0, 12)} /> 12 {settings.noteCornerRadius}px
updateSettings({ showNoteOutline: !settings.showNoteOutline })} /> updateSettings({ sheetMusicWhiteBackground: !settings.sheetMusicWhiteBackground }) } />
{/* ── Overlays ───────────────────────────────────────────────────── */}
updateSettings({ showMeasureNums: !settings.showMeasureNums })} /> updateSettings({ showBeatLines: !settings.showBeatLines })} /> updateSettings({ showSustainPedal: !settings.showSustainPedal })} /> updateSettings({ showSustainedNotes: !settings.showSustainedNotes })} /> updateSettings({ impactStyle: v as ImpactStyle })} />
{/* ── Playback ───────────────────────────────────────────────────── */}
2s updateSettings({ viewportSeconds: Number(e.target.value) })} aria-label="Note window - seconds of music visible" aria-valuetext={`${settings.viewportSeconds} seconds`} className="flex-1" style={rangeStyle(settings.viewportSeconds, 2, 10)} /> 10s {settings.viewportSeconds}s
-6♭ updateSettings({ transposeSemitones: Number(e.target.value) })} aria-label="Transpose in semitones" aria-valuetext={formatTranspose(settings.transposeSemitones)} className="flex-1" style={rangeStyle(settings.transposeSemitones, -6, 6)} /> +6♯ {formatTranspose(settings.transposeSemitones)}
-200 updateSettings({ renderOffset: Number(e.target.value) })} aria-label="Audio offset in milliseconds" aria-valuetext={`${settings.renderOffset}ms`} className="flex-1" style={rangeStyle(settings.renderOffset, -200, 200)} /> +200 {settings.renderOffset > 0 ? `+${settings.renderOffset}` : settings.renderOffset} ms
updateSettings({ scrollToSeek: !settings.scrollToSeek })} /> updateSettings({ instrument: v as InstrumentId })} fullWidth /> Practice updateSettings({ waitForHand: v as "left" | "right" | "both" })} fullWidth />
{(["left", "right"] as const).map((hand) => (
{hand === "left" ? "Left" : "Right"} updateSettings({ handVolume: { ...settings.handVolume, [hand]: Number(e.target.value) }, }) } aria-label={`${hand === "left" ? "Left" : "Right"} hand volume`} aria-valuetext={`${Math.round(settings.handVolume[hand] * 100)}%`} className="flex-1" style={rangeStyle(settings.handVolume[hand], 0, 1)} /> {Math.round(settings.handVolume[hand] * 100)}%
))}
updateSettings({ countInBars: v as 0 | 1 | 2 })} fullWidth /> updateSettings({ speedTrainer: { ...settings.speedTrainer, enabled: !settings.speedTrainer.enabled, }, }) } /> {/* Speed trainer sub-rows: number inputs to sliders with % units */} {settings.speedTrainer.enabled && ( <>
25% updateSettings({ speedTrainer: { ...settings.speedTrainer, startPct: Number(e.target.value), }, }) } aria-label="Speed trainer start percentage" aria-valuetext={`${settings.speedTrainer.startPct}%`} className="flex-1" style={rangeStyle(settings.speedTrainer.startPct, 25, 100)} /> 100% {settings.speedTrainer.startPct}%
50% updateSettings({ speedTrainer: { ...settings.speedTrainer, endPct: Number(e.target.value), }, }) } aria-label="Speed trainer end percentage" aria-valuetext={`${settings.speedTrainer.endPct}%`} className="flex-1" style={rangeStyle(settings.speedTrainer.endPct, 50, 200)} /> 200% {settings.speedTrainer.endPct}%
1% updateSettings({ speedTrainer: { ...settings.speedTrainer, stepPct: Number(e.target.value), }, }) } aria-label="Speed trainer step percentage" aria-valuetext={`${settings.speedTrainer.stepPct}%`} className="flex-1" style={rangeStyle(settings.speedTrainer.stepPct, 1, 20)} /> 20% {settings.speedTrainer.stepPct}%
)}
{/* ── Reset to defaults ─────────────────────────────────────────── */}
); } // ── Icons ───────────────────────────────────────────────────────────────────── const MetronomeIcon = () => ( {/* Trapezoid body - wider at base, tapers toward top */} {/* Pendulum rod - angled to suggest motion */} {/* Weight - small circle riding the rod at mid-height */} );