From 6ba588aedb7e20fd7b423fdf63210b29b2516db4 Mon Sep 17 00:00:00 2001 From: Fabricio Giglio <58532944+gigliof@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:50:01 +0200 Subject: [PATCH] Redesign All view as stacked bands with horizontal scrolling sheet on top --- .../FallingNotes/FallingNotesView.tsx | 8 +- .../components/PianoKeyboard/PianoKeyboard.ts | 191 ++++++++++++------ .../components/SheetMusic/SheetMusicView.tsx | 137 +++++++++---- frontend/src/index.css | 5 +- frontend/src/views/PracticeView.tsx | 69 +++++-- 5 files changed, 285 insertions(+), 125 deletions(-) diff --git a/frontend/src/components/FallingNotes/FallingNotesView.tsx b/frontend/src/components/FallingNotes/FallingNotesView.tsx index fb07ade..7fae571 100644 --- a/frontend/src/components/FallingNotes/FallingNotesView.tsx +++ b/frontend/src/components/FallingNotes/FallingNotesView.tsx @@ -170,9 +170,13 @@ export function FallingNotesView() { onWheel={handleWheel} style={{ minHeight: 200, + // In "all" the parent column carries one continuous glow (so sheet + + // falling read as one surface, no seam). Other modes paint their own. background: - "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)", - borderTop: "1px solid var(--color-notes-border)", + settings.layoutMode === "all" + ? "transparent" + : "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)", + borderTop: settings.layoutMode === "all" ? "none" : "1px solid var(--color-notes-border)", }} > diff --git a/frontend/src/components/PianoKeyboard/PianoKeyboard.ts b/frontend/src/components/PianoKeyboard/PianoKeyboard.ts index f3849a1..f14715c 100644 --- a/frontend/src/components/PianoKeyboard/PianoKeyboard.ts +++ b/frontend/src/components/PianoKeyboard/PianoKeyboard.ts @@ -5,30 +5,30 @@ import type { CustomColors, NoteLabelMode } from "../../store/useAppStore"; import { lighten, hexToNum } from "../../utils/colorUtils"; // ── Layout constants ────────────────────────────────────────────────────────── -const MIDI_MIN = 21; // A0 +const MIDI_MIN = 21; // A0 const MIDI_MAX = 108; // C8 -function isWhite(midi: number) { return !isBlackKey(midi); } -const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i) - .filter(isWhite).length; +function isWhite(midi: number) { + return !isBlackKey(midi); +} +const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i).filter( + isWhite +).length; // ── Color tokens ────────────────────────────────────────────────────────────── -const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge -const C_WHITE_MID = 0xf5f5f0; // main key face -const C_WHITE_BTM = 0xe4e4dc; // front edge shadow -const C_BLACK_TOP = 0x2c2c38; // top of black key -const C_BLACK_MID = 0x181820; // main body -const C_BLACK_BTM = 0x363644; // slight gloss at bottom +const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge +const C_WHITE_MID = 0xf5f5f0; // main key face +const C_WHITE_BTM = 0xe4e4dc; // front edge shadow -const C_BORDER = 0x9a9a90; -const C_LABEL = 0x6b7280; -const C_FINGER = 0xffffff; +const C_BORDER = 0x9a9a90; +const C_LABEL = 0x6b7280; +const C_FINGER = 0xffffff; // Active state colors (hand-aware) -const C_PLAYBACK = 0x6366f1; -const C_INPUT = 0x22c55e; -const C_LEFT = 0x3b82f6; -const C_RIGHT = 0xef4444; +const C_PLAYBACK = 0x6366f1; +const C_INPUT = 0x22c55e; +const C_LEFT = 0x3b82f6; +const C_RIGHT = 0xef4444; // ── Gradient factory ────────────────────────────────────────────────────────── function lg(stops: Array<[number, number | string]>): PIXI.FillGradient { @@ -52,21 +52,55 @@ function whiteKeyColors(ivory: boolean): { top: number; mid: number; btm: number const GRAD = { whiteNormal: (ivory: boolean) => { const c = whiteKeyColors(ivory); - return lg([[0, c.top], [0.07, c.mid], [0.88, c.mid], [1, c.btm]]); + return lg([ + [0, c.top], + [0.07, c.mid], + [0.88, c.mid], + [1, c.btm], + ]); }, - blackNormal: () => lg([[0, C_BLACK_TOP], [0.35, C_BLACK_MID], [0.88, C_BLACK_MID], [1, C_BLACK_BTM]]), - activeWhite: (base: number) => lg([ - [0, 0x050508], // deep hinge shadow - [0.12, lighten(base, 0.58)], - [0.22, base], - [0.83, base], - [1, lighten(base, 1.45)], // bright front lip - ]), - activeBlack: (base: number) => lg([ - [0, lighten(base, 0.6)], - [0.5, base], - [1, lighten(base, 1.5)], - ]), + // Horizontal overlay: soft shadow down each side, transparent in the middle. + // Overlaid on the (vertical) white-key gradient, it makes each white key read + // as rounded / raised instead of a flat slab - the main "fuller, more real" + // cue (black keys sit between white keys and cast a soft shadow onto them). + whiteSideShade: () => + new PIXI.FillGradient({ + type: "linear", + start: { x: 0, y: 0 }, + end: { x: 1, y: 0 }, + textureSpace: "local", + colorStops: [ + { offset: 0, color: "rgba(0,0,0,0.10)" }, + { offset: 0.16, color: "rgba(0,0,0,0)" }, + { offset: 0.84, color: "rgba(0,0,0,0)" }, + { offset: 1, color: "rgba(0,0,0,0.10)" }, + ], + }), + // Subtle dimension via the gradient alone: a soft sheen at the top, dark + // body, and a slightly lighter "front lip" at the bottom so the key isn't a + // flat 100% black slab. (Kept restrained - stronger glossy/face attempts read + // as plasticky on our renderer.) + blackNormal: () => + lg([ + [0, 0x34343f], // top sheen - catches a bit of light + [0.18, 0x1c1c24], + [0.82, 0x141419], // darkest part of the body + [1, 0x3a3a46], // lighter front lip at the bottom + ]), + activeWhite: (base: number) => + lg([ + [0, 0x050508], // deep hinge shadow + [0.12, lighten(base, 0.58)], + [0.22, base], + [0.83, base], + [1, lighten(base, 1.45)], // bright front lip + ]), + activeBlack: (base: number) => + lg([ + [0, lighten(base, 0.6)], + [0.5, base], + [1, lighten(base, 1.5)], + ]), }; export interface PianoKeyboardOptions { @@ -96,10 +130,10 @@ export class PianoKeyboard { private _initialized = false; private keyContainers = new Map(); - private keys = new Map(); - private keyLabels = new Map(); - private fingerLabels = new Map(); - private keyStates = new Map(); + private keys = new Map(); + private keyLabels = new Map(); + private fingerLabels = new Map(); + private keyStates = new Map(); private opts: PianoKeyboardOptions; private whiteW = 0; @@ -151,7 +185,7 @@ export class PianoKeyboard { this.whiteW = width / WHITE_COUNT; this.whiteH = height; this.blackW = this.whiteW * 0.58; - this.blackH = height * 0.60; + this.blackH = height * 0.6; let wi = 0; for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) { @@ -169,7 +203,7 @@ export class PianoKeyboard { } private drawKeys() { - const root = new PIXI.Container(); + const root = new PIXI.Container(); const whiteLayer = new PIXI.Container(); const blackLayer = new PIXI.Container(); @@ -192,9 +226,7 @@ export class PianoKeyboard { // Note label (created for every key; text/visibility set by noteLabelMode) { const labelStr = this.noteLabel(midi); - const lFontSz = white - ? Math.max(7, this.whiteW * 0.42) - : Math.max(6, this.blackW * 0.55); + const lFontSz = white ? Math.max(7, this.whiteW * 0.42) : Math.max(6, this.blackW * 0.55); const label = new PIXI.Text({ text: labelStr, style: { @@ -244,7 +276,10 @@ export class PianoKeyboard { } // Hover highlight const st = this.keyStates.get(midi); - if (st && !st.hovered) { st.hovered = true; this.redrawKey(midi); } + if (st && !st.hovered) { + st.hovered = true; + this.redrawKey(midi); + } }); container.on("pointerout", () => { if (this.dragKeys.has(midi)) { @@ -253,15 +288,24 @@ export class PianoKeyboard { } // Remove hover highlight const st = this.keyStates.get(midi); - if (st && st.hovered) { st.hovered = false; this.redrawKey(midi); } + if (st && st.hovered) { + st.hovered = false; + this.redrawKey(midi); + } }); this.keys.set(midi, g); this.keyContainers.set(midi, container); - this.keyStates.set(midi, { playbackActive: false, inputActive: false, hand: null, finger: null, hovered: false }); + this.keyStates.set(midi, { + playbackActive: false, + inputActive: false, + hand: null, + finger: null, + hovered: false, + }); if (white) whiteLayer.addChild(container); - else blackLayer.addChild(container); + else blackLayer.addChild(container); } root.addChild(whiteLayer, blackLayer); @@ -272,18 +316,22 @@ export class PianoKeyboard { private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient { let g = this.gradCache.get(key); - if (!g) { g = factory(); this.gradCache.set(key, g); } + if (!g) { + g = factory(); + this.gradCache.set(key, g); + } return g; } private paintKey( g: PIXI.Graphics, - w: number, h: number, + w: number, + h: number, white: boolean, active: boolean, activeColor: number | null, _hand: "left" | "right" | null, - hovered = false, + hovered = false ) { g.clear(); const r = white ? 4 : 3; @@ -311,12 +359,18 @@ export class PianoKeyboard { if (active && white) { // Keybed shadow strip at bottom (felt bumper contact) g.rect(1, h - 4, w - 2, 4); - g.fill({ color: 0x000000, alpha: 0.40 }); + g.fill({ color: 0x000000, alpha: 0.4 }); // Side edge depth shadows g.rect(0.5, h * 0.08, 2, h * 0.88); - g.fill({ color: 0x000000, alpha: 0.10 }); + g.fill({ color: 0x000000, alpha: 0.1 }); g.rect(w - 2.5, h * 0.08, 2, h * 0.88); - g.fill({ color: 0x000000, alpha: 0.10 }); + g.fill({ color: 0x000000, alpha: 0.1 }); + } + + // ── Inactive white key: soft side shading for a rounded, fuller look ──── + if (white && !active) { + g.roundRect(0.5, 0.5, w - 1, h - 1, r); + g.fill(this.getGrad("wshade", GRAD.whiteSideShade)); } // White highlight strip at top of active black keys @@ -355,14 +409,14 @@ export class PianoKeyboard { } private redrawKey(midi: number) { - const g = this.keys.get(midi); + const g = this.keys.get(midi); const state = this.keyStates.get(midi); - const cont = this.keyContainers.get(midi); + const cont = this.keyContainers.get(midi); if (!g || !state || !cont) return; - const white = isWhite(midi); - const w = white ? this.whiteW : this.blackW; - const h = white ? this.whiteH : this.blackH; + const white = isWhite(midi); + const w = white ? this.whiteW : this.blackW; + const h = white ? this.whiteH : this.blackH; const active = state.playbackActive || state.inputActive; // 3D press: shift container down when active @@ -373,9 +427,14 @@ export class PianoKeyboard { if (active) { const cc = this.opts.customColors; if (this.opts.showHandColors && state.hand) { - color = state.hand === "left" - ? (cc ? hexToNum(cc.leftHand) : C_LEFT) - : (cc ? hexToNum(cc.rightHand) : C_RIGHT); + color = + state.hand === "left" + ? cc + ? hexToNum(cc.leftHand) + : C_LEFT + : cc + ? hexToNum(cc.rightHand) + : C_RIGHT; } else if (state.inputActive) { color = C_INPUT; } else { @@ -402,7 +461,7 @@ export class PianoKeyboard { return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : ""; } if (mode === "white" && !white) return ""; - if (mode === "black" && white) return ""; + if (mode === "black" && white) return ""; // "white", "black", "all": show note name class only (no octave) return midiToNoteName(midi, this.opts.useFlats); } @@ -414,7 +473,7 @@ export class PianoKeyboard { if (!state) return; if (source === "playback") { state.playbackActive = true; - state.hand = note.hand === "unknown" ? null : note.hand as "left" | "right"; + state.hand = note.hand === "unknown" ? null : (note.hand as "left" | "right"); state.finger = note.finger; } else { state.inputActive = true; @@ -427,7 +486,10 @@ export class PianoKeyboard { if (!state) return; if (source === "playback") { state.playbackActive = false; - if (!state.inputActive) { state.hand = null; state.finger = null; } + if (!state.inputActive) { + state.hand = null; + state.finger = null; + } } else { state.inputActive = false; } @@ -463,11 +525,10 @@ export class PianoKeyboard { } setOptions(opts: Partial) { - const needsFullRebuild = ( - ("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) || + const needsFullRebuild = + ("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) || ("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) || - ("useFlats" in opts && opts.useFlats !== this.opts.useFlats) - ); + ("useFlats" in opts && opts.useFlats !== this.opts.useFlats); Object.assign(this.opts, opts); diff --git a/frontend/src/components/SheetMusic/SheetMusicView.tsx b/frontend/src/components/SheetMusic/SheetMusicView.tsx index 0024b2d..ec5dd71 100644 --- a/frontend/src/components/SheetMusic/SheetMusicView.tsx +++ b/frontend/src/components/SheetMusic/SheetMusicView.tsx @@ -25,6 +25,11 @@ export function SheetMusicView() { const scoreLoaded = useRef(false); // true once score SVG is rendered const lastScrollTop = useRef(-1); const lastScrollMs = useRef(0); + // Horizontal "all" mode: a rAF lerp loop slides scrollLeft toward this target + // every frame for continuous, buttery score-following (instead of stepped + // bar-to-bar jumps). onTick updates the target; the loop animates toward it. + const scrollTargetRef = useRef(0); + const rafScrollId = useRef(null); // ── AlphaTab init (runs once on mount) ────────────────────────────────── useEffect(() => { @@ -34,11 +39,22 @@ export function SheetMusicView() { import("@coderline/alphatab").then(({ AlphaTabApi }) => { if (!containerRef.current) return; + // In the "all" view the sheet sits in a full-width band at the top and + // renders as a single horizontal strip (layoutMode 1) that scrolls + // left->right following playback. The dedicated "Sheet" mode keeps page + // mode (vertical, multi-line) which is better for reading a full score. + // SheetMusicView mounts fresh per layout mode (not a singleton), so reading + // the mode once at init is safe for this instance's lifetime. + const horizontal = useAppStore.getState().settings.layoutMode === "all"; + const api = new AlphaTabApi(containerRef.current, { core: { engine: "svg", enableLazyLoading: false, logLevel: 0 }, display: { - layoutMode: 0, // page mode - vertical scroll - scale: 0.9, + layoutMode: horizontal ? 1 : 0, // 1 = horizontal strip, 0 = page (vertical) + // Smaller scale in the horizontal "all" band so the full grand staff + // (both clefs) fits the short band height without the bass clef being + // clipped. Page mode keeps the larger, more readable 0.9. + scale: horizontal ? 0.62 : 0.9, // stretchForce 0 = notes keep their natural rhythmic proportions (like // a printed score). A very small value (0.1) avoids a ragged right // edge on the last system without visibly compressing the spacing. @@ -102,6 +118,32 @@ export function SheetMusicView() { /* ignore */ } + // ── Continuous horizontal scroll loop (lerp toward scrollTargetRef) ──── + // In horizontal mode the strip glides smoothly under the cursor instead of + // jumping bar to bar. The 0.12 factor is a gentle chase (~0.13s) that also + // averages out the ~8 fps cursor steps into continuous motion. Gated: the + // loop self-stops once paused AND settled; onTick (which only fires while + // playing, plus once on seek) restarts it via ensureScrollLoop(). + const lerpScroll = () => { + const scrollEl = scrollRef.current; + let settling = false; + if (scrollEl) { + const diff = scrollTargetRef.current - scrollEl.scrollLeft; + if (Math.abs(diff) > 0.5) { + scrollEl.scrollLeft += diff * 0.12; + settling = true; + } + } + if (useAppStore.getState().status === "playing" || settling) { + rafScrollId.current = requestAnimationFrame(lerpScroll); + } else { + rafScrollId.current = null; // idle - stop until the next tick kicks it + } + }; + const ensureScrollLoop = () => { + if (rafScrollId.current === null) rafScrollId.current = requestAnimationFrame(lerpScroll); + }; + // ── Transport tick: cursor position + scroll ───────────────────────── // IMPORTANT: do NOT close over `doc` here - it would always be null // (stale closure from first render). Always read fresh state instead. @@ -119,11 +161,9 @@ export function SheetMusicView() { return; } - // Scroll cursor into view - throttled to ~4 fps to avoid jitter - const now = performance.now(); - if (now - lastScrollMs.current < 250) return; - lastScrollMs.current = now; - + // Update scroll to follow the cursor. Horizontal mode sets a lerp target + // (the rAF loop animates toward it for a continuous glide); page mode + // does a throttled jump with the browser's smooth-scroll. requestAnimationFrame(() => { const scrollEl = scrollRef.current; // outer scroll container const innerEl = containerRef.current; // AlphaTab host (cursor lives here) @@ -133,15 +173,26 @@ export function SheetMusicView() { innerEl.querySelector(".at-cursor-beat") ?? innerEl.querySelector(".at-cursor-bar"); if (!cursor) return; - // cursor position relative to the scrollable content area - const cursorTop = - cursor.getBoundingClientRect().top - - scrollEl.getBoundingClientRect().top + - scrollEl.scrollTop; - const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3); - if (Math.abs(target - lastScrollTop.current) > 80) { - lastScrollTop.current = target; - scrollEl.scrollTo({ top: target, behavior: "smooth" }); + const cRect = cursor.getBoundingClientRect(); + const sRect = scrollEl.getBoundingClientRect(); + + if (horizontal) { + // Absolute content x of the cursor; keep it ~40% from the left so + // there's lookahead. The rAF loop slides scrollLeft toward this. + const cursorLeft = cRect.left - sRect.left + scrollEl.scrollLeft; + scrollTargetRef.current = Math.max(0, cursorLeft - scrollEl.clientWidth * 0.4); + ensureScrollLoop(); + } else { + // Page mode: throttled (~4 fps) jump, keep cursor at ~30% from top. + const now = performance.now(); + if (now - lastScrollMs.current < 250) return; + lastScrollMs.current = now; + const cursorTop = cRect.top - sRect.top + scrollEl.scrollTop; + const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3); + if (Math.abs(target - lastScrollTop.current) > 80) { + lastScrollTop.current = target; + scrollEl.scrollTo({ top: target, behavior: "smooth" }); + } } }); }; @@ -152,6 +203,10 @@ export function SheetMusicView() { return () => { cleanupBus?.(); + if (rafScrollId.current !== null) { + cancelAnimationFrame(rafScrollId.current); + rafScrollId.current = null; + } playerReady.current = false; scoreLoaded.current = false; if (apiRef.current) { @@ -168,15 +223,14 @@ export function SheetMusicView() { const isDark = settings.theme === "dark"; const keepWhite = isDark && settings.sheetMusicWhiteBackground; const invertSheet = isDark && !settings.sheetMusicWhiteBackground; + // The "all" view renders the sheet as a horizontal strip (see init effect). + const horizontalSheet = settings.layoutMode === "all"; - // ── Adapt stretchForce to layout mode ──────────────────────────────────── - // "all" mode gives the sheet a narrow shared column, so we keep more - // stretching (0.5) to fill each line. In dedicated sheet mode we use - // near-natural note spacing (0.1) for a printed-score look. - // The value is stored in a ref so the load effect below can read it fresh. + // ── stretchForce ────────────────────────────────────────────────────────── + // Near-natural note spacing (0.1) for a printed-score look in both page mode + // and the horizontal "all" strip. (The old 0.5 was to fill the narrow "all" + // sidebar, which no longer exists - "all" is now a full-width band.) const stretchForceRef = useRef(0.1); - // eslint-disable-next-line react-hooks/refs -- intentional: keeps ref in sync with derived state for effect to read fresh - stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1; useEffect(() => { // Re-render the current score when the user switches between layout modes. @@ -214,32 +268,47 @@ export function SheetMusicView() { }, [doc?.musicXml, doc?.mxlBuffer]); // Background logic: - // light / dark normal: radial gradient + var(--color-notes-bg), matching FallingNotesView // dark + keep white: explicit #fff so black notation stays readable on white - // The gradient (7% purple at bottom-center to transparent) is the same subtle glow used - // by FallingNotesView and PianoModeBackground, keeping all canvas areas visually consistent. + // "all" (horizontal band): TRANSPARENT - the parent column carries one + // continuous glow that both the sheet band and the falling-notes pane sit + // over, so there's no seam and the overall tone matches the other views. + // page mode: radial gradient + var(--color-notes-bg), matching FallingNotesView const scrollBg = keepWhite ? "#ffffff" - : "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"; + : horizontalSheet + ? "transparent" + : "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"; return (
right; page mode scrolls up/down. + overflowX: horizontalSheet ? "auto" : "hidden", + overflowY: horizontalSheet ? "hidden" : "auto", }} > {/* - Page-width wrapper - centres the score and caps it at ~A4 screen width - (900 px). On wide monitors this creates margins on both sides so the - score never looks like a stretched banner; on narrow screens it fills - the full width just like before. position:relative is needed so the - key-signature badge stays anchored inside the content column. + Page mode: page-width wrapper centres the score and caps it at ~A4 + screen width (900 px). Horizontal mode: no width cap - the strip must + extend as wide as the score so it can scroll. position:relative keeps + the key-signature badge anchored. */} -
+
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 339256b..0fa6f45 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -297,9 +297,10 @@ input[type="range"]::-moz-range-progress { border-radius: 1px; } -/* Bar cursor - wider semi-transparent highlight for the current measure */ +/* Bar cursor - the "spotlight" highlight on the current measure. Slightly more + prominent than a hairline so it's easy to track while following along. */ .at-cursor-bar { - background: rgba(147, 51, 234, 0.12); + background: rgba(147, 51, 234, 0.16); border-radius: 2px; } diff --git a/frontend/src/views/PracticeView.tsx b/frontend/src/views/PracticeView.tsx index 757ee83..e7ec178 100644 --- a/frontend/src/views/PracticeView.tsx +++ b/frontend/src/views/PracticeView.tsx @@ -268,29 +268,54 @@ export function PracticeView() { {/* ── Main content - layout-aware ────────────────────────────────────── - PianoKeyboardView is a SINGLETON at the bottom of the main column so - PixiJS doesn't re-init on every view switch (skeleton flash + perf cost). - Same for FallingNotesView in falling/all modes - kept mounted across - the two modes that show it. Only the conditional top-content elements - (PianoModeBackground / SheetMusicView / sheet sidebar) mount and - unmount on layoutMode change. */} -
- {/* Optional sheet sidebar (only in "all" mode) */} - {layoutMode === "all" && ( -
- -
- )} + 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). - {/* Main column: top content varies, piano persists across all modes */} -
- {layoutMode === "piano" && } - {(layoutMode === "falling" || layoutMode === "all") && } - {layoutMode === "sheet" && } - + "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). */} +