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}
|
onWheel={handleWheel}
|
||||||
style={{
|
style={{
|
||||||
minHeight: 200,
|
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:
|
background:
|
||||||
"radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
|
settings.layoutMode === "all"
|
||||||
borderTop: "1px solid var(--color-notes-border)",
|
? "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%" }} />
|
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import { lighten, hexToNum } from "../../utils/colorUtils";
|
|||||||
const MIDI_MIN = 21; // A0
|
const MIDI_MIN = 21; // A0
|
||||||
const MIDI_MAX = 108; // C8
|
const MIDI_MAX = 108; // C8
|
||||||
|
|
||||||
function isWhite(midi: number) { return !isBlackKey(midi); }
|
function isWhite(midi: number) {
|
||||||
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i)
|
return !isBlackKey(midi);
|
||||||
.filter(isWhite).length;
|
}
|
||||||
|
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i).filter(
|
||||||
|
isWhite
|
||||||
|
).length;
|
||||||
|
|
||||||
// ── Color tokens ──────────────────────────────────────────────────────────────
|
// ── Color tokens ──────────────────────────────────────────────────────────────
|
||||||
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
|
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
|
||||||
const C_WHITE_MID = 0xf5f5f0; // main key face
|
const C_WHITE_MID = 0xf5f5f0; // main key face
|
||||||
const C_WHITE_BTM = 0xe4e4dc; // front edge shadow
|
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_BORDER = 0x9a9a90;
|
const C_BORDER = 0x9a9a90;
|
||||||
const C_LABEL = 0x6b7280;
|
const C_LABEL = 0x6b7280;
|
||||||
@@ -52,17 +52,51 @@ function whiteKeyColors(ivory: boolean): { top: number; mid: number; btm: number
|
|||||||
const GRAD = {
|
const GRAD = {
|
||||||
whiteNormal: (ivory: boolean) => {
|
whiteNormal: (ivory: boolean) => {
|
||||||
const c = whiteKeyColors(ivory);
|
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]]),
|
// Horizontal overlay: soft shadow down each side, transparent in the middle.
|
||||||
activeWhite: (base: number) => lg([
|
// 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, 0x050508], // deep hinge shadow
|
||||||
[0.12, lighten(base, 0.58)],
|
[0.12, lighten(base, 0.58)],
|
||||||
[0.22, base],
|
[0.22, base],
|
||||||
[0.83, base],
|
[0.83, base],
|
||||||
[1, lighten(base, 1.45)], // bright front lip
|
[1, lighten(base, 1.45)], // bright front lip
|
||||||
]),
|
]),
|
||||||
activeBlack: (base: number) => lg([
|
activeBlack: (base: number) =>
|
||||||
|
lg([
|
||||||
[0, lighten(base, 0.6)],
|
[0, lighten(base, 0.6)],
|
||||||
[0.5, base],
|
[0.5, base],
|
||||||
[1, lighten(base, 1.5)],
|
[1, lighten(base, 1.5)],
|
||||||
@@ -151,7 +185,7 @@ export class PianoKeyboard {
|
|||||||
this.whiteW = width / WHITE_COUNT;
|
this.whiteW = width / WHITE_COUNT;
|
||||||
this.whiteH = height;
|
this.whiteH = height;
|
||||||
this.blackW = this.whiteW * 0.58;
|
this.blackW = this.whiteW * 0.58;
|
||||||
this.blackH = height * 0.60;
|
this.blackH = height * 0.6;
|
||||||
|
|
||||||
let wi = 0;
|
let wi = 0;
|
||||||
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
|
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
|
||||||
@@ -192,9 +226,7 @@ export class PianoKeyboard {
|
|||||||
// Note label (created for every key; text/visibility set by noteLabelMode)
|
// Note label (created for every key; text/visibility set by noteLabelMode)
|
||||||
{
|
{
|
||||||
const labelStr = this.noteLabel(midi);
|
const labelStr = this.noteLabel(midi);
|
||||||
const lFontSz = white
|
const lFontSz = white ? Math.max(7, this.whiteW * 0.42) : Math.max(6, this.blackW * 0.55);
|
||||||
? Math.max(7, this.whiteW * 0.42)
|
|
||||||
: Math.max(6, this.blackW * 0.55);
|
|
||||||
const label = new PIXI.Text({
|
const label = new PIXI.Text({
|
||||||
text: labelStr,
|
text: labelStr,
|
||||||
style: {
|
style: {
|
||||||
@@ -244,7 +276,10 @@ export class PianoKeyboard {
|
|||||||
}
|
}
|
||||||
// Hover highlight
|
// Hover highlight
|
||||||
const st = this.keyStates.get(midi);
|
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", () => {
|
container.on("pointerout", () => {
|
||||||
if (this.dragKeys.has(midi)) {
|
if (this.dragKeys.has(midi)) {
|
||||||
@@ -253,12 +288,21 @@ export class PianoKeyboard {
|
|||||||
}
|
}
|
||||||
// Remove hover highlight
|
// Remove hover highlight
|
||||||
const st = this.keyStates.get(midi);
|
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.keys.set(midi, g);
|
||||||
this.keyContainers.set(midi, container);
|
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);
|
if (white) whiteLayer.addChild(container);
|
||||||
else blackLayer.addChild(container);
|
else blackLayer.addChild(container);
|
||||||
@@ -272,18 +316,22 @@ export class PianoKeyboard {
|
|||||||
|
|
||||||
private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient {
|
private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient {
|
||||||
let g = this.gradCache.get(key);
|
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;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
private paintKey(
|
private paintKey(
|
||||||
g: PIXI.Graphics,
|
g: PIXI.Graphics,
|
||||||
w: number, h: number,
|
w: number,
|
||||||
|
h: number,
|
||||||
white: boolean,
|
white: boolean,
|
||||||
active: boolean,
|
active: boolean,
|
||||||
activeColor: number | null,
|
activeColor: number | null,
|
||||||
_hand: "left" | "right" | null,
|
_hand: "left" | "right" | null,
|
||||||
hovered = false,
|
hovered = false
|
||||||
) {
|
) {
|
||||||
g.clear();
|
g.clear();
|
||||||
const r = white ? 4 : 3;
|
const r = white ? 4 : 3;
|
||||||
@@ -311,12 +359,18 @@ export class PianoKeyboard {
|
|||||||
if (active && white) {
|
if (active && white) {
|
||||||
// Keybed shadow strip at bottom (felt bumper contact)
|
// Keybed shadow strip at bottom (felt bumper contact)
|
||||||
g.rect(1, h - 4, w - 2, 4);
|
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
|
// Side edge depth shadows
|
||||||
g.rect(0.5, h * 0.08, 2, h * 0.88);
|
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.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
|
// White highlight strip at top of active black keys
|
||||||
@@ -373,9 +427,14 @@ export class PianoKeyboard {
|
|||||||
if (active) {
|
if (active) {
|
||||||
const cc = this.opts.customColors;
|
const cc = this.opts.customColors;
|
||||||
if (this.opts.showHandColors && state.hand) {
|
if (this.opts.showHandColors && state.hand) {
|
||||||
color = state.hand === "left"
|
color =
|
||||||
? (cc ? hexToNum(cc.leftHand) : C_LEFT)
|
state.hand === "left"
|
||||||
: (cc ? hexToNum(cc.rightHand) : C_RIGHT);
|
? cc
|
||||||
|
? hexToNum(cc.leftHand)
|
||||||
|
: C_LEFT
|
||||||
|
: cc
|
||||||
|
? hexToNum(cc.rightHand)
|
||||||
|
: C_RIGHT;
|
||||||
} else if (state.inputActive) {
|
} else if (state.inputActive) {
|
||||||
color = C_INPUT;
|
color = C_INPUT;
|
||||||
} else {
|
} else {
|
||||||
@@ -414,7 +473,7 @@ export class PianoKeyboard {
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
if (source === "playback") {
|
if (source === "playback") {
|
||||||
state.playbackActive = true;
|
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;
|
state.finger = note.finger;
|
||||||
} else {
|
} else {
|
||||||
state.inputActive = true;
|
state.inputActive = true;
|
||||||
@@ -427,7 +486,10 @@ export class PianoKeyboard {
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
if (source === "playback") {
|
if (source === "playback") {
|
||||||
state.playbackActive = false;
|
state.playbackActive = false;
|
||||||
if (!state.inputActive) { state.hand = null; state.finger = null; }
|
if (!state.inputActive) {
|
||||||
|
state.hand = null;
|
||||||
|
state.finger = null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.inputActive = false;
|
state.inputActive = false;
|
||||||
}
|
}
|
||||||
@@ -463,11 +525,10 @@ export class PianoKeyboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOptions(opts: Partial<PianoKeyboardOptions>) {
|
setOptions(opts: Partial<PianoKeyboardOptions>) {
|
||||||
const needsFullRebuild = (
|
const needsFullRebuild =
|
||||||
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
|
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
|
||||||
("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) ||
|
("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);
|
Object.assign(this.opts, opts);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export function SheetMusicView() {
|
|||||||
const scoreLoaded = useRef(false); // true once score SVG is rendered
|
const scoreLoaded = useRef(false); // true once score SVG is rendered
|
||||||
const lastScrollTop = useRef(-1);
|
const lastScrollTop = useRef(-1);
|
||||||
const lastScrollMs = useRef(0);
|
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) ──────────────────────────────────
|
// ── AlphaTab init (runs once on mount) ──────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,11 +39,22 @@ export function SheetMusicView() {
|
|||||||
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
|
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
|
||||||
if (!containerRef.current) return;
|
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, {
|
const api = new AlphaTabApi(containerRef.current, {
|
||||||
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
|
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
|
||||||
display: {
|
display: {
|
||||||
layoutMode: 0, // page mode - vertical scroll
|
layoutMode: horizontal ? 1 : 0, // 1 = horizontal strip, 0 = page (vertical)
|
||||||
scale: 0.9,
|
// 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
|
// stretchForce 0 = notes keep their natural rhythmic proportions (like
|
||||||
// a printed score). A very small value (0.1) avoids a ragged right
|
// a printed score). A very small value (0.1) avoids a ragged right
|
||||||
// edge on the last system without visibly compressing the spacing.
|
// edge on the last system without visibly compressing the spacing.
|
||||||
@@ -102,6 +118,32 @@ export function SheetMusicView() {
|
|||||||
/* ignore */
|
/* 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 ─────────────────────────
|
// ── Transport tick: cursor position + scroll ─────────────────────────
|
||||||
// IMPORTANT: do NOT close over `doc` here - it would always be null
|
// IMPORTANT: do NOT close over `doc` here - it would always be null
|
||||||
// (stale closure from first render). Always read fresh state instead.
|
// (stale closure from first render). Always read fresh state instead.
|
||||||
@@ -119,11 +161,9 @@ export function SheetMusicView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll cursor into view - throttled to ~4 fps to avoid jitter
|
// Update scroll to follow the cursor. Horizontal mode sets a lerp target
|
||||||
const now = performance.now();
|
// (the rAF loop animates toward it for a continuous glide); page mode
|
||||||
if (now - lastScrollMs.current < 250) return;
|
// does a throttled jump with the browser's smooth-scroll.
|
||||||
lastScrollMs.current = now;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const scrollEl = scrollRef.current; // outer scroll container
|
const scrollEl = scrollRef.current; // outer scroll container
|
||||||
const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
|
const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
|
||||||
@@ -133,16 +173,27 @@ export function SheetMusicView() {
|
|||||||
innerEl.querySelector<HTMLElement>(".at-cursor-beat") ??
|
innerEl.querySelector<HTMLElement>(".at-cursor-beat") ??
|
||||||
innerEl.querySelector<HTMLElement>(".at-cursor-bar");
|
innerEl.querySelector<HTMLElement>(".at-cursor-bar");
|
||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
// cursor position relative to the scrollable content area
|
const cRect = cursor.getBoundingClientRect();
|
||||||
const cursorTop =
|
const sRect = scrollEl.getBoundingClientRect();
|
||||||
cursor.getBoundingClientRect().top -
|
|
||||||
scrollEl.getBoundingClientRect().top +
|
if (horizontal) {
|
||||||
scrollEl.scrollTop;
|
// 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);
|
const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3);
|
||||||
if (Math.abs(target - lastScrollTop.current) > 80) {
|
if (Math.abs(target - lastScrollTop.current) > 80) {
|
||||||
lastScrollTop.current = target;
|
lastScrollTop.current = target;
|
||||||
scrollEl.scrollTo({ top: target, behavior: "smooth" });
|
scrollEl.scrollTo({ top: target, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +203,10 @@ export function SheetMusicView() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanupBus?.();
|
cleanupBus?.();
|
||||||
|
if (rafScrollId.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScrollId.current);
|
||||||
|
rafScrollId.current = null;
|
||||||
|
}
|
||||||
playerReady.current = false;
|
playerReady.current = false;
|
||||||
scoreLoaded.current = false;
|
scoreLoaded.current = false;
|
||||||
if (apiRef.current) {
|
if (apiRef.current) {
|
||||||
@@ -168,15 +223,14 @@ export function SheetMusicView() {
|
|||||||
const isDark = settings.theme === "dark";
|
const isDark = settings.theme === "dark";
|
||||||
const keepWhite = isDark && settings.sheetMusicWhiteBackground;
|
const keepWhite = isDark && settings.sheetMusicWhiteBackground;
|
||||||
const invertSheet = 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 ────────────────────────────────────
|
// ── stretchForce ──────────────────────────────────────────────────────────
|
||||||
// "all" mode gives the sheet a narrow shared column, so we keep more
|
// Near-natural note spacing (0.1) for a printed-score look in both page mode
|
||||||
// stretching (0.5) to fill each line. In dedicated sheet mode we use
|
// and the horizontal "all" strip. (The old 0.5 was to fill the narrow "all"
|
||||||
// near-natural note spacing (0.1) for a printed-score look.
|
// sidebar, which no longer exists - "all" is now a full-width band.)
|
||||||
// The value is stored in a ref so the load effect below can read it fresh.
|
|
||||||
const stretchForceRef = useRef(0.1);
|
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(() => {
|
useEffect(() => {
|
||||||
// Re-render the current score when the user switches between layout modes.
|
// Re-render the current score when the user switches between layout modes.
|
||||||
@@ -214,32 +268,47 @@ export function SheetMusicView() {
|
|||||||
}, [doc?.musicXml, doc?.mxlBuffer]);
|
}, [doc?.musicXml, doc?.mxlBuffer]);
|
||||||
|
|
||||||
// Background logic:
|
// 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
|
// 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
|
// "all" (horizontal band): TRANSPARENT - the parent column carries one
|
||||||
// by FallingNotesView and PianoModeBackground, keeping all canvas areas visually consistent.
|
// 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
|
const scrollBg = keepWhite
|
||||||
? "#ffffff"
|
? "#ffffff"
|
||||||
|
: horizontalSheet
|
||||||
|
? "transparent"
|
||||||
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
|
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex-1 overflow-auto"
|
className="flex-1"
|
||||||
style={{
|
style={{
|
||||||
background: scrollBg,
|
background: scrollBg,
|
||||||
minHeight: 0,
|
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)",
|
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
|
Page mode: page-width wrapper centres the score and caps it at ~A4
|
||||||
(900 px). On wide monitors this creates margins on both sides so the
|
screen width (900 px). Horizontal mode: no width cap - the strip must
|
||||||
score never looks like a stretched banner; on narrow screens it fills
|
extend as wide as the score so it can scroll. position:relative keeps
|
||||||
the full width just like before. position:relative is needed so the
|
the key-signature badge anchored.
|
||||||
key-signature badge stays anchored inside the content column.
|
|
||||||
*/}
|
*/}
|
||||||
<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" : ""} />
|
<div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
|
||||||
|
|
||||||
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
|
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
|
||||||
|
|||||||
@@ -297,9 +297,10 @@ input[type="range"]::-moz-range-progress {
|
|||||||
border-radius: 1px;
|
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 {
|
.at-cursor-bar {
|
||||||
background: rgba(147, 51, 234, 0.12);
|
background: rgba(147, 51, 234, 0.16);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,29 +268,54 @@ export function PracticeView() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Main content - layout-aware ──────────────────────────────────────
|
{/* ── Main content - layout-aware ──────────────────────────────────────
|
||||||
PianoKeyboardView is a SINGLETON at the bottom of the main column so
|
Single vertical column. The piano keyboard is a SINGLETON pinned at the
|
||||||
PixiJS doesn't re-init on every view switch (skeleton flash + perf cost).
|
bottom (keyed) so PixiJS never re-inits on a view switch. FallingNotesView
|
||||||
Same for FallingNotesView in falling/all modes - kept mounted across
|
is keyed too, so it stays mounted across the falling<->all toggle (the
|
||||||
the two modes that show it. Only the conditional top-content elements
|
sheet band appears above it in "all" without remounting it).
|
||||||
(PianoModeBackground / SheetMusicView / sheet sidebar) mount and
|
|
||||||
unmount on layoutMode change. */}
|
"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">
|
<main className="flex flex-1 overflow-hidden min-h-0 relative">
|
||||||
{/* Optional sheet sidebar (only in "all" mode) */}
|
<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" && (
|
{layoutMode === "all" && (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col flex-shrink-0 min-h-0 overflow-hidden relative"
|
key="sheet-band"
|
||||||
style={{ flex: "0 0 42%", minWidth: 260, maxWidth: 440 }}
|
className="flex flex-col min-h-0 overflow-hidden"
|
||||||
|
style={{ flex: "0 0 32%", minHeight: 130 }}
|
||||||
>
|
>
|
||||||
<SheetMusicView />
|
<SheetMusicView />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main column: top content varies, piano persists across all modes */}
|
{/* Falling notes: middle (all) or full (falling). Keyed so it stays
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
mounted across the falling<->all toggle (no PixiJS re-init). */}
|
||||||
{layoutMode === "piano" && <PianoModeBackground />}
|
{(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView key="falling" />}
|
||||||
{(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView />}
|
|
||||||
{layoutMode === "sheet" && <SheetMusicView />}
|
{/* Piano keyboard: always the bottom singleton (keyed). */}
|
||||||
<PianoKeyboardView />
|
<PianoKeyboardView key="kbd" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user