mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
Phrase mode: App integration (mode toggle, keyboard, zen), Controls tweaks, styles
This commit is contained in:
119
src/App.css
119
src/App.css
@@ -980,4 +980,121 @@ button {
|
||||
/* Ensure FretboardHint container handles flexible width generic */
|
||||
.fretboard-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
/* ------------------------- Phrase Mode ------------------------- */
|
||||
|
||||
.phrase-card {
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.phrase-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.phrase-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.phrase-range {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.phrase-status {
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
.phrase-transport {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phrase-error {
|
||||
padding: 24px 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.phrase-warning {
|
||||
font-size: 12px;
|
||||
color: var(--color-warning);
|
||||
text-align: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.phrase-note {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin: 6px 0 0 0;
|
||||
}
|
||||
|
||||
.phrase-setup {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.phrase-setup summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.phrase-setup summary:hover {
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.phrase-setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.phrase-setup-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.phrase-setup-grid select,
|
||||
.phrase-setup-grid input[type='number'] {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-main);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.phrase-check {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--color-text-main) !important;
|
||||
align-self: end;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.phrase-sheet {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
387
src/App.tsx
387
src/App.tsx
@@ -1,41 +1,30 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Logo } from './components/Logo';
|
||||
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
|
||||
|
||||
|
||||
import { SheetMusic } from './components/SheetMusic';
|
||||
import { Controls } from './components/Controls';
|
||||
import { SettingsModal } from './components/SettingsModal';
|
||||
import { OpenSourceModal } from './components/OpenSourceModal';
|
||||
import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNoteTrainer';
|
||||
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
|
||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
||||
import { useSettings } from './context/useSettings';
|
||||
import { useGameLogic } from './hooks/useGameLogic';
|
||||
import { audioEngine } from './audio/AudioEngine';
|
||||
import { getNoteDetails, midiToFrequency } from './music/NoteUtils';
|
||||
import {
|
||||
TUNINGS,
|
||||
getFretboardPositions,
|
||||
} from './music/Tunings';
|
||||
import { midiToFrequency } from './music/NoteUtils';
|
||||
import { TUNINGS } from './music/Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
|
||||
import { Fretboard } from './components/Fretboard';
|
||||
import { PianoKeys } from './components/PianoKeys';
|
||||
|
||||
import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings, Maximize, Minimize } from 'lucide-react';
|
||||
import { Mic, MicOff, HelpCircle, X, Settings, Maximize, Minimize } from 'lucide-react';
|
||||
import './App.css';
|
||||
import './styles/skip-button.css';
|
||||
|
||||
|
||||
|
||||
|
||||
function App() {
|
||||
const [listening, setListening] = useState(false);
|
||||
const { playNote } = useAudioPlayer();
|
||||
|
||||
const { settings, updateSettings } = useSettings();
|
||||
// Alias to keep existing code working with minimal changes
|
||||
const setSettings = updateSettings;
|
||||
|
||||
const isPhrase = settings.gameMode === 'phrase';
|
||||
|
||||
const currentTuning = TUNINGS[settings.tuningId];
|
||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||
|
||||
@@ -59,26 +48,15 @@ function App() {
|
||||
midiToFrequency(instrumentMinMidi - 1)
|
||||
);
|
||||
|
||||
// Game Logic Hook (matching uses the raw detection; display is gated below)
|
||||
const {
|
||||
targetMidi,
|
||||
feedbackMessage,
|
||||
revealed,
|
||||
virtualNote,
|
||||
generateNewNote,
|
||||
handleVirtualInstrumentPlay
|
||||
} = useGameLogic(pitchData);
|
||||
|
||||
// Live feedback shows only what the USER plays. While the app itself makes
|
||||
// sound (ear-training reference note, Play Note, virtual instruments), the
|
||||
// mic picks up the speaker — displaying that would leak the target note in
|
||||
// ear training. The moment playback ends (plus decay tail), the note the
|
||||
// user is playing appears again. Re-evaluated on every detection frame.
|
||||
// Live feedback shows only what the USER plays, gated while the app's own
|
||||
// speaker output is audible (see SingleNoteTrainer / PhraseTrainer).
|
||||
const displayedPitch = audioEngine.isAudible() ? null : pitchData;
|
||||
|
||||
const singleNoteRef = useRef<SingleNoteHandle>(null);
|
||||
const phraseRef = useRef<PhraseHandle>(null);
|
||||
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
||||
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
|
||||
@@ -92,11 +70,8 @@ function App() {
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Theme support — MUST be a layout effect: children (e.g. SheetMusic) read the
|
||||
// resolved theme colors from getComputedStyle inside their own passive
|
||||
// effects. Layout effects run before any passive effects, so the
|
||||
// data-theme attribute is applied by the time they read it — otherwise the
|
||||
// rendered notation lags one theme switch behind.
|
||||
// Theme support — MUST be a layout effect: children read the resolved theme
|
||||
// colors from getComputedStyle inside their own passive effects.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const theme = settings.theme || 'auto';
|
||||
@@ -127,28 +102,77 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Resolve overrides
|
||||
const currentRangeDef = useMemo(() => {
|
||||
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
||||
}, [currentInstrumentDef, settings.difficulty]);
|
||||
|
||||
// Determine active clef/transpose for rendering
|
||||
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
||||
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
||||
|
||||
/* Keyboard Shortcuts */
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
// Opening dialogs pauses a running phrase run (contract E1/lifecycle).
|
||||
const openSettings = useCallback(() => {
|
||||
phraseRef.current?.pause();
|
||||
setIsSettingsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openHelp = useCallback(() => {
|
||||
phraseRef.current?.pause();
|
||||
setShowHelp(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const onInteractive =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLButtonElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
!!target?.isContentEditable;
|
||||
|
||||
// prevent default for space to stop scrolling
|
||||
if (e.code === 'Escape') {
|
||||
if (showHelp) setShowHelp(false);
|
||||
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
|
||||
return;
|
||||
}
|
||||
// Focused buttons handle their own keys (native activation);
|
||||
// global shortcuts must not double-fire or block them.
|
||||
if (onInteractive) return;
|
||||
|
||||
// Prevent default for space to stop scrolling
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (isPhrase) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'h':
|
||||
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
||||
break;
|
||||
case 'z':
|
||||
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
|
||||
break;
|
||||
case 'r':
|
||||
phraseRef.current?.retry();
|
||||
break;
|
||||
case 'p':
|
||||
phraseRef.current?.previewToggle();
|
||||
break;
|
||||
case 'n':
|
||||
phraseRef.current?.next();
|
||||
break;
|
||||
case 's':
|
||||
phraseRef.current?.skip();
|
||||
break;
|
||||
case 'v':
|
||||
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
|
||||
break;
|
||||
case 'l':
|
||||
setListening(l => !l);
|
||||
break;
|
||||
}
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
phraseRef.current?.pauseToggle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'h':
|
||||
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
||||
@@ -158,9 +182,9 @@ function App() {
|
||||
break;
|
||||
case 'r': // Replay
|
||||
case 'p': // Play
|
||||
playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5);
|
||||
singleNoteRef.current?.playCurrent();
|
||||
break;
|
||||
case 'm': // Toggle Game Mode
|
||||
case 'm': // Cycle game mode
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading'
|
||||
@@ -174,46 +198,31 @@ function App() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check non-character keys via code to avoid layout issues for function keys
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
generateNewNote();
|
||||
}
|
||||
|
||||
if (e.code === 'Escape') {
|
||||
if (showHelp) setShowHelp(false);
|
||||
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
|
||||
singleNoteRef.current?.skipNote();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
// `settings` (not individual fields): the handler reads several settings
|
||||
// (gameMode, referenceNoteDuration, ...) and re-attaching a listener is cheap.
|
||||
}, [settings, showHelp, generateNewNote, playNote, targetMidi, setSettings]);
|
||||
}, [settings, showHelp, isPhrase, setSettings]);
|
||||
|
||||
// Hint text construction
|
||||
const hintPositions = useMemo(() => {
|
||||
// If showing full hint OR detecting via fretboard, we need positions?
|
||||
// Actually, showHint=true renders the red dots.
|
||||
// If showFretboard=true but showHint=false, we render empty board (interactive).
|
||||
// 'm' cycles all three modes from phrase mode too.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() !== 'm') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return;
|
||||
if (settings.gameMode === 'phrase') {
|
||||
setSettings(s => ({ ...s, gameMode: 'sight_reading' }));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [settings.gameMode, setSettings]);
|
||||
|
||||
if (!settings.showHint && !settings.showFretboard) return [];
|
||||
if (!currentInstrumentDef.showTuning || !currentTuning) return [];
|
||||
|
||||
// If hint is hidden but board is explicit, we still need data if we want to support 'hint-on-hover' or similar later.
|
||||
// But for now, if settings.showHint is FALSE, we pass EMPTY positions to Fretboard so it doesn't draw dots,
|
||||
// UNLESS we want to decouple 'positions' prop from 'showHint' prop in the component.
|
||||
// It's cleaner to pass the positions regardless and let the component decide based on a prop,
|
||||
// OR filter here.
|
||||
// The request says: "If hints are enabled simultaneously, they can be shown on the same fretboard"
|
||||
// So if showHint is true -> pass positions. If false -> pass empty.
|
||||
|
||||
if (!settings.showHint) return [];
|
||||
|
||||
return getFretboardPositions(targetMidi, currentTuning);
|
||||
}, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]);
|
||||
|
||||
// Auto-enable mic when tuner is turned on (rAF defers the state update
|
||||
// Auto-enable mic when the tuner is turned on (rAF defers the state update
|
||||
// out of the effect phase to avoid cascading renders)
|
||||
useEffect(() => {
|
||||
if (settings.showTuningMeter && !listening) {
|
||||
@@ -245,6 +254,13 @@ function App() {
|
||||
>
|
||||
Ear Training
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-option ${settings.gameMode === 'phrase' ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, gameMode: 'phrase' }))}
|
||||
title="Play short phrases and melodies"
|
||||
>
|
||||
Phrases
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -263,14 +279,14 @@ function App() {
|
||||
</button>
|
||||
<button
|
||||
className="icon-button help-btn"
|
||||
onClick={() => setShowHelp(true)}
|
||||
onClick={openHelp}
|
||||
title="Shortcuts Help"
|
||||
>
|
||||
<HelpCircle size={24} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings size={24} />
|
||||
@@ -280,132 +296,23 @@ function App() {
|
||||
)}
|
||||
|
||||
<main className="main-stage">
|
||||
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
|
||||
<div className="sheet-music-container">
|
||||
<SheetMusic
|
||||
targetMidi={targetMidi}
|
||||
// Virtual fretboard taps are explicit user input and always
|
||||
// show; the mic preview is gated during speaker playback.
|
||||
playedMidi={virtualNote ?? displayedPitch?.midi}
|
||||
keySignature={settings.keySignature}
|
||||
clef={activeClef}
|
||||
transpose={activeTranspose}
|
||||
width={Math.min(windowWidth - 40, 500)}
|
||||
height={
|
||||
activeClef === 'grand'
|
||||
? (windowHeight < 500 ? 190 : 260) // Compact Grand Staff if short screen
|
||||
: (windowHeight < 500 ? 120 : 180) // Compact Single Staff
|
||||
}
|
||||
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
||||
hoverMidi={settings.showHint ? hoveredMidi : null}
|
||||
theme={settings.theme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!settings.zenMode && (
|
||||
<div className="feedback-area">
|
||||
{feedbackMessage ? (
|
||||
<div key={feedbackMessage} className="success-message animate-pop">
|
||||
{feedbackMessage.startsWith("Good! ") ? (
|
||||
<>
|
||||
<div className="success-prefix">Good!</div>
|
||||
<div className="success-note">{feedbackMessage.replace("Good! ", "")}</div>
|
||||
</>
|
||||
) : (
|
||||
feedbackMessage
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="instruction-text">
|
||||
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{(settings.showHint || settings.showFretboard) && (
|
||||
<div className="hint-card">
|
||||
{settings.showHint && (
|
||||
<div className="hint-note landscape-hint-note">
|
||||
{getNoteDetails(targetMidi + activeTranspose).scientific}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Instrument Display */}
|
||||
{currentInstrumentDef.showTuning && (
|
||||
currentInstrumentDef.id === 'piano' ? (
|
||||
<PianoKeys
|
||||
minMidi={36} // C2
|
||||
maxMidi={84} // C6
|
||||
markedNotes={settings.showHint ? [targetMidi] : []}
|
||||
interactive={settings.showFretboard || settings.showHint}
|
||||
showTooltips={settings.showHint}
|
||||
displayTranspose={activeTranspose}
|
||||
onPlayNote={handleVirtualInstrumentPlay}
|
||||
onHover={setHoveredMidi}
|
||||
/>
|
||||
) : (
|
||||
currentTuning && (
|
||||
<Fretboard
|
||||
tuning={currentTuning}
|
||||
positions={hintPositions}
|
||||
interactive={settings.showFretboard || settings.showHint}
|
||||
showTooltips={settings.showHint}
|
||||
displayTranspose={activeTranspose}
|
||||
onPlayNote={handleVirtualInstrumentPlay}
|
||||
onHover={setHoveredMidi}
|
||||
showHints={settings.showHint}
|
||||
maxFrets={15}
|
||||
/>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{!settings.zenMode && (
|
||||
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
|
||||
{currentInstrumentDef.showTuning && (
|
||||
<button
|
||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||
>
|
||||
<Guitar size={18} />
|
||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||
title="Keyboard Shortcut: H"
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="hint-button"
|
||||
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
||||
title="Keyboard Shortcut: P or R"
|
||||
>
|
||||
<Volume2 size={18} />
|
||||
Play Note
|
||||
</button>
|
||||
|
||||
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
|
||||
<SkipForward size={18} />
|
||||
Skip Note
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPhrase ? (
|
||||
<PhraseTrainer
|
||||
ref={phraseRef}
|
||||
listening={listening}
|
||||
micError={error}
|
||||
windowWidth={windowWidth}
|
||||
/>
|
||||
) : (
|
||||
<SingleNoteTrainer
|
||||
ref={singleNoteRef}
|
||||
pitchData={pitchData}
|
||||
windowWidth={windowWidth}
|
||||
windowHeight={windowHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{
|
||||
!settings.zenMode && (
|
||||
@@ -422,7 +329,7 @@ function App() {
|
||||
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
||||
{displayedPitch ? (
|
||||
<>
|
||||
<span className={`detected-note ${displayedPitch.midi === targetMidi ? 'match' : ''}`}>
|
||||
<span className="detected-note">
|
||||
{displayedPitch.note}
|
||||
</span>
|
||||
<span className="detected-hz">{Math.round(displayedPitch.frequency)} Hz</span>
|
||||
@@ -471,38 +378,26 @@ function App() {
|
||||
<button className="help-close" onClick={() => setShowHelp(false)}><X size={20} /></button>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
|
||||
|
||||
<div className="help-item">
|
||||
<span>Skip Note</span>
|
||||
<span className="shortcut-key">Space</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Toggle Hint</span>
|
||||
<span className="shortcut-key">H</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Toggle Virtual Instrument</span>
|
||||
<span className="shortcut-key">V</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Toggle Mic</span>
|
||||
<span className="shortcut-key">L</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Toggle Zen Mode</span>
|
||||
<span className="shortcut-key">Z</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Replay Note</span>
|
||||
<span className="shortcut-key">R</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Toggle Game Mode</span>
|
||||
<span className="shortcut-key">M</span>
|
||||
</div>
|
||||
<div className="help-item">
|
||||
<span>Close Help / Exit Zen</span>
|
||||
<span className="shortcut-key">Esc</span>
|
||||
</div>
|
||||
{isPhrase ? (
|
||||
<>
|
||||
<div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div>
|
||||
<div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div>
|
||||
<div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div>
|
||||
<div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div>
|
||||
<div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="help-item"><span>Skip Note</span><span className="shortcut-key">Space</span></div>
|
||||
<div className="help-item"><span>Replay Note</span><span className="shortcut-key">R</span></div>
|
||||
<div className="help-item"><span>Toggle Game Mode</span><span className="shortcut-key">M</span></div>
|
||||
</>
|
||||
)}
|
||||
<div className="help-item"><span>Toggle Hint</span><span className="shortcut-key">H</span></div>
|
||||
<div className="help-item"><span>Toggle Virtual Instrument</span><span className="shortcut-key">V</span></div>
|
||||
<div className="help-item"><span>Toggle Mic</span><span className="shortcut-key">L</span></div>
|
||||
<div className="help-item"><span>Toggle Zen Mode</span><span className="shortcut-key">Z</span></div>
|
||||
<div className="help-item"><span>Close Help / Exit Zen</span><span className="shortcut-key">Esc</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -58,6 +58,10 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
|
||||
|
||||
|
||||
// Phrase Mode brings its own key/tempo machinery; the single-note
|
||||
// key-signature select and metronome tool would be competing controls.
|
||||
const inPhraseMode = settings.gameMode === 'phrase';
|
||||
|
||||
return (
|
||||
<div className="controls-container">
|
||||
{/* Top Section: Settings Grid (2 Columns) */}
|
||||
@@ -124,6 +128,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
</div>
|
||||
|
||||
{/* Row 2, Col 2 */}
|
||||
{!inPhraseMode && (
|
||||
<div className="control-group">
|
||||
<label className="control-label">
|
||||
<span>Key signature</span>
|
||||
@@ -150,6 +155,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: Fret Range (Full Width) */}
|
||||
{currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
|
||||
@@ -188,7 +194,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
||||
<div className="tools-grid">
|
||||
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
||||
{/* Tool 1: Tuner */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -214,7 +220,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool 2: Metronome */}
|
||||
{/* Tool 2: Metronome (single-note modes only) */}
|
||||
{!inPhraseMode && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label className="control-label" style={{ marginBottom: 0 }}>
|
||||
@@ -326,7 +333,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool 3: Auto-play */}
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user