Redesign All view as stacked bands with horizontal scrolling sheet on top
This commit is contained in:
@@ -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)",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
|
||||
|
||||
@@ -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<number, PIXI.Container>();
|
||||
private keys = new Map<number, PIXI.Graphics>();
|
||||
private keyLabels = new Map<number, PIXI.Text>();
|
||||
private fingerLabels = new Map<number, PIXI.Text>();
|
||||
private keyStates = new Map<number, KeyState>();
|
||||
private keys = new Map<number, PIXI.Graphics>();
|
||||
private keyLabels = new Map<number, PIXI.Text>();
|
||||
private fingerLabels = new Map<number, PIXI.Text>();
|
||||
private keyStates = new Map<number, KeyState>();
|
||||
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<PianoKeyboardOptions>) {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<number | null>(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<HTMLElement>(".at-cursor-beat") ??
|
||||
innerEl.querySelector<HTMLElement>(".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 (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto"
|
||||
className="flex-1"
|
||||
style={{
|
||||
background: scrollBg,
|
||||
minHeight: 0,
|
||||
// Top border sits against the header in every mode (matches the other
|
||||
// views' header separator). In "all" this is the TOP of the sheet band,
|
||||
// not the seam with the falling-notes pane (that seam stays borderless).
|
||||
borderTop: "1px solid var(--color-notes-border)",
|
||||
// Horizontal strip scrolls left<->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.
|
||||
*/}
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: horizontalSheet ? "none" : 900,
|
||||
margin: horizontalSheet ? 0 : "0 auto",
|
||||
padding: "8px 16px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
|
||||
|
||||
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,29 +268,54 @@ export function PracticeView() {
|
||||
</header>
|
||||
|
||||
{/* ── 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. */}
|
||||
<main className="flex flex-1 overflow-hidden min-h-0 relative">
|
||||
{/* Optional sheet sidebar (only in "all" mode) */}
|
||||
{layoutMode === "all" && (
|
||||
<div
|
||||
className="flex flex-col flex-shrink-0 min-h-0 overflow-hidden relative"
|
||||
style={{ flex: "0 0 42%", minWidth: 260, maxWidth: 440 }}
|
||||
>
|
||||
<SheetMusicView />
|
||||
</div>
|
||||
)}
|
||||
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 */}
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{layoutMode === "piano" && <PianoModeBackground />}
|
||||
{(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView />}
|
||||
{layoutMode === "sheet" && <SheetMusicView />}
|
||||
<PianoKeyboardView />
|
||||
"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). */}
|
||||
<main className="flex flex-1 overflow-hidden min-h-0 relative">
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
style={{
|
||||
// "all": one continuous glow on the whole column (sheet + falling
|
||||
// read transparently over it), so there's no seam and the overall
|
||||
// tone matches the other views (whose panes carry the same glow).
|
||||
// Anchored just above the piano (calc(100% - keyboard height)) so the
|
||||
// glow's brightest point lands where the other views' do - otherwise
|
||||
// it sits behind the opaque keybed and reads slightly dimmer (visible
|
||||
// in dark mode, where the purple contrasts more).
|
||||
background:
|
||||
layoutMode === "all"
|
||||
? "radial-gradient(ellipse at 50% calc(100% - 160px), rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{/* Piano-only mode: decorative backdrop above the keyboard */}
|
||||
{layoutMode === "piano" && <PianoModeBackground key="piano-bg" />}
|
||||
|
||||
{/* Dedicated Sheet mode: full-height vertical page */}
|
||||
{layoutMode === "sheet" && <SheetMusicView key="sheet-full" />}
|
||||
|
||||
{/* All mode: sheet as a full-width horizontal band at the top */}
|
||||
{layoutMode === "all" && (
|
||||
<div
|
||||
key="sheet-band"
|
||||
className="flex flex-col min-h-0 overflow-hidden"
|
||||
style={{ flex: "0 0 32%", minHeight: 130 }}
|
||||
>
|
||||
<SheetMusicView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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") && <FallingNotesView key="falling" />}
|
||||
|
||||
{/* Piano keyboard: always the bottom singleton (keyed). */}
|
||||
<PianoKeyboardView key="kbd" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user