Files
sheetmusictrainer/src/components/SheetMusic.tsx
Jens Mohrmann 6e60ced477 Dark mode contrast, tuner display and misc UI fixes
- color-scheme per theme so native controls (selects, checkboxes, number
  inputs, sliders) render dark-native instead of black-on-black.
- Themed .control-select text color; new .control-input class replaces
  hardcoded white-on-black inline input styles.
- Tuner widget stays mounted when enabled: idle state (dimmed, needle parked,
  '—') instead of appearing/disappearing. Fully theme-var based (was
  hardcoded white — invisible in light mode).
- Remove CSS transitions on per-frame values (tuner needle, level meter):
  transitions interrupted 60x/sec are left stuck by WebKit/Safari; the
  display EMA from the detection loop replaces them.
- VexFlow notation: resolve CSS var() to concrete colors (SVG attributes
  can't resolve var(), silently fell back to black) and override VexFlow's
  hardcoded #444 ledger-line default; re-render on theme changes
  (useLayoutEffect so children read the updated attribute in time).
- Themed switch toggles (were hardcoded #ddd / white — glowed in dark mode).
- Fixed-height pitch readout so the layout stops jumping when a note starts
  or stops sounding.
- Split SettingsContext into context/Provider/hook files (fast-refresh);
  memoized provider functions; typed webkitAudioContext; prefer-const;
  removed 'as any' casts. ESLint now passes clean.
2026-09-13 18:52:27 +02:00

278 lines
11 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector, GhostNote, Annotation } from 'vexflow';
import { getNoteInKey } from '../music/NoteUtils';
interface SheetMusicProps {
targetMidi: number;
playedMidi?: number | null;
clef?: 'treble' | 'bass' | 'grand';
width?: number;
height?: number;
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
keySignature?: string;
hideTargetNote?: boolean;
hoverMidi?: number | null;
theme?: 'light' | 'dark' | 'auto';
}
export const SheetMusic: React.FC<SheetMusicProps> = ({
targetMidi,
playedMidi,
clef = 'treble',
width = 300,
height = 250, // Increased default height for Grand Staff
transpose = 12, // Default to +1 octave (Guitar Notation)
keySignature = 'C',
hideTargetNote = false,
hoverMidi = null,
theme = 'auto'
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// SVG presentation attributes cannot resolve CSS var() (VexFlow writes
// colors as `fill="..."` attributes), so the actual color is read from
// getComputedStyle at draw time. Re-render when the theme changes —
// including system scheme flips while theme === 'auto'.
const [schemeVersion, setSchemeVersion] = useState(0);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setSchemeVersion(v => v + 1);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
if (!containerRef.current) return;
// Clear previous render
containerRef.current.innerHTML = '';
const renderer = new Renderer(containerRef.current, Renderer.Backends.SVG);
renderer.resize(width, height);
const context = renderer.getContext();
// IMPORTANT: resolve the theme color to a concrete value — SVG
// attributes don't support var(). Without this the notation falls
// back to black, which is unreadable in dark mode.
const resolvedColor =
getComputedStyle(document.documentElement)
.getPropertyValue('--color-text-main')
.trim() || '#333333';
// Set global styles for the context to ensure everything draws with correct color
context.setFillStyle(resolvedColor);
context.setStrokeStyle(resolvedColor);
// VexFlow hardcodes ledger lines ("the extra lines" above/below the
// staff) to a dark gray default that is unreadable in dark mode.
// getDefaultLedgerLineStyle() merges this default OVER the stave style,
// so it must be overridden explicitly on every stave we create.
const ledgerStyle = { strokeStyle: resolvedColor, lineWidth: 2 };
const applyLedgerStyle = (stave: Stave) => stave.setDefaultLedgerLineStyle(ledgerStyle);
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
const getGrandStaffClef = (midi: number): 'treble' | 'bass' => {
return midi >= 60 ? 'treble' : 'bass';
};
// --- Measure Calculation ---
const numMeasures = (playedMidi || hoverMidi) ? 2 : 1;
// If 2 measures, split total width.
// We want a bit of padding. width is total width.
// Let's reserve 10px on Left/Right.
// Measure 1 Start: 10
// Measure 1 Width: (width - 20) / numMeasures
// Measure 2 Start: 10 + Width
const availableWidth = width - 20;
const measureWidth = availableWidth / numMeasures;
const startX = 10;
let stavesMeasure1: Record<string, Stave> = {};
let stavesMeasure2: Record<string, Stave> = {};
const isCompact = height < 200;
const trebleY = isCompact ? 10 : 20;
const bassY = isCompact ? 85 : 110;
// Gap reduced from 90 (110-20) to 75 (85-10) in compact mode
// --- Create Staves for Measure 1 ---
if (clef === 'grand') {
// Measure 1: Treble
const m1Treble = new Stave(startX, trebleY, measureWidth);
applyLedgerStyle(m1Treble);
m1Treble.addClef('treble').addKeySignature(keySignature);
m1Treble.setContext(context).draw();
// Measure 1: Bass
const m1Bass = new Stave(startX, bassY, measureWidth);
applyLedgerStyle(m1Bass);
m1Bass.addClef('bass').addKeySignature(keySignature);
m1Bass.setContext(context).draw();
stavesMeasure1 = { treble: m1Treble, bass: m1Bass };
// Connectors (Start of Measure 1 only)
const brace = new StaveConnector(m1Treble, m1Bass);
brace.setType(StaveConnector.type.BRACE);
brace.setContext(context).draw();
const leftLine = new StaveConnector(m1Treble, m1Bass);
leftLine.setType(StaveConnector.type.SINGLE_LEFT);
leftLine.setContext(context).draw();
// Right line for Measure 1 if it's the only measure, or end of system
// Actually, we usually want a barline at end of M1 if M2 exists.
// VexFlow Stave draws end barline by default or setBegBarType/setEndBarType.
// By default it draws a single bar line at end.
if (numMeasures === 2) {
// Measure 2: Treble
const m2Treble = new Stave(startX + measureWidth, trebleY, measureWidth);
applyLedgerStyle(m2Treble);
// No clef/key sig repeated typically for just next measure in same system,
// UNLESS it's a new system. Here it's same system.
// But VexFlow might require setting context context.
m2Treble.setContext(context).draw();
// Measure 2: Bass
const m2Bass = new Stave(startX + measureWidth, bassY, measureWidth);
applyLedgerStyle(m2Bass);
m2Bass.setContext(context).draw();
stavesMeasure2 = { treble: m2Treble, bass: m2Bass };
// Connect line between M1 and M2?
// Visual continuity is handled by them abiding.
// We might want a SINGLE_RIGHT connector at the very end of M2?
const rightLineEnd = new StaveConnector(m2Treble, m2Bass);
rightLineEnd.setType(StaveConnector.type.SINGLE_RIGHT); // Or BOLD_DOUBLE_RIGHT for end
rightLineEnd.setContext(context).draw();
} else {
const rightLineEnd = new StaveConnector(m1Treble, m1Bass);
rightLineEnd.setType(StaveConnector.type.SINGLE_RIGHT);
rightLineEnd.setContext(context).draw();
}
} else {
// Single Stave
const staveY = isCompact ? 10 : 30;
const m1Stave = new Stave(startX, staveY, measureWidth);
applyLedgerStyle(m1Stave);
m1Stave.addClef(clef).addKeySignature(keySignature);
m1Stave.setContext(context).draw();
stavesMeasure1 = { [clef]: m1Stave };
if (numMeasures === 2) {
const m2Stave = new Stave(startX + measureWidth, staveY, measureWidth);
applyLedgerStyle(m2Stave);
m2Stave.setContext(context).draw();
stavesMeasure2 = { [clef]: m2Stave };
}
}
// --- Helper: Create VexFlow Note ---
const createStaveNote = (midi: number, duration: string, type: 'target' | 'played') => {
const visualMidi = midi + transpose;
const data = getNoteInKey(visualMidi, keySignature);
// Determine Clef for THIS note
let noteClef = clef;
if (clef === 'grand') {
noteClef = getGrandStaffClef(visualMidi);
}
const staveNote = new StaveNote({
keys: data.keys,
duration: duration,
clef: noteClef as 'treble' | 'bass'
});
// Set styles
staveNote.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
if (data.accidental) {
const accidental = new Accidental(data.accidental);
staveNote.addModifier(accidental);
}
if (type === 'played') {
// Always use default style (text color), no green/red distinction
staveNote.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
}
return { note: staveNote, clef: noteClef };
};
const voicesToDraw: { stave: Stave, voice: Voice }[] = [];
// --- Render Target Note (Measure 1) ---
// Always Whole Note
let targetNoteObj: { note: StaveNote | GhostNote, clef: string };
if (hideTargetNote) {
const visualMidi = targetMidi + transpose;
let noteClef = clef;
if (clef === 'grand') noteClef = getGrandStaffClef(visualMidi);
const data = getNoteInKey(visualMidi, keySignature);
const ghost = new GhostNote({
keys: data.keys,
duration: "w"
});
ghost.addModifier(new Annotation("?").setVerticalJustification(Annotation.VerticalJustify.CENTER));
targetNoteObj = { note: ghost, clef: noteClef };
} else {
targetNoteObj = createStaveNote(targetMidi, "w", 'target');
}
// Add Target to Measure 1 Stave
const m1StaveForKey = stavesMeasure1[targetNoteObj.clef];
if (m1StaveForKey) {
const voice = new Voice({ numBeats: 4, beatValue: 4 });
voice.addTickables([targetNoteObj.note]);
new Formatter().joinVoices([voice]).format([voice], measureWidth - 50); // Less width for padding
voicesToDraw.push({ stave: m1StaveForKey, voice });
}
// --- Render Played Note OR Hover Preview (Measure 2) ---
// If playedMidi exists, it takes precedence.
// If not, we show hoverMidi as a preview (ghost/grey).
const noteToShowMidi = playedMidi ?? (hoverMidi || null);
const isPreview = !playedMidi && hoverMidi;
if (noteToShowMidi && numMeasures === 2) {
// We reuse 'played' logic for creation but handle style manually if preview
const noteObj = createStaveNote(noteToShowMidi, "w", 'played');
if (isPreview) {
// Apply grey style for preview
noteObj.note.setStyle({ fillStyle: "#888888", strokeStyle: "#888888" });
}
// Add Played/Preview to Measure 2 Stave
const m2StaveForKey = stavesMeasure2[noteObj.clef];
if (m2StaveForKey) {
const voice = new Voice({ numBeats: 4, beatValue: 4 });
voice.addTickables([noteObj.note]);
new Formatter().joinVoices([voice]).format([voice], measureWidth - 50);
voicesToDraw.push({ stave: m2StaveForKey, voice });
}
}
// Draw all voices
voicesToDraw.forEach(({ stave, voice }) => {
voice.draw(context, stave);
});
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote, theme, schemeVersion]);
return <div ref={containerRef} className="sheet-music-container" />;
};