Skip to content

Commit 3a19972

Browse files
RuitingMaclaude
andcommitted
Score: leading phantom tile fills the left-of-playhead void
Tiled copies extended only to the right of copy 1, so dragging backward in a loop could push translate past 0 with nothing to fill the stage-left region — the reader saw blank space where the previous iteration's tail should be. Add an absolute-positioned clone of copy 1, left=-musicWidth, that's always in the pan. Its music region abuts copy 1's music seamlessly (last-bar glyph → barline → first-bar glyph), so: - Stage left always has content during pre-roll and after a drag-backward, whether translate is positive or negative - The wrap-left teleport in the render loop (`while translateX > 0`) is now visually seamless — what the wrap reveals on the left matches what the leader was already drawing - With that seamlessness established, drop the iter > 0 guard added in the previous loop-visual-bug fix; wrap fires as soon as ms ≥ 0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9656e67 commit 3a19972

1 file changed

Lines changed: 30 additions & 9 deletions

File tree

src/lib/score-player.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,26 @@ function applyLoopSpacing(panSvgs: SVGSVGElement[], headerWidth: number) {
11241124
}
11251125
}
11261126

1127+
/** Loop mode: attach an out-of-flow phantom tile positioned `-musicWidth`
1128+
* to the left of copy 1. It clones copy 1 (already header-stripped) so
1129+
* when the pan is translated right — either in the pre-roll lead-in
1130+
* pose, or when the reader drags backward past copy 1's start — the
1131+
* stage-left region shows the loop's tail rather than empty space. Also
1132+
* makes the wrap-left teleport (`while translateX > 0`) visually
1133+
* seamless: the content the wrap "reveals" on the left matches what the
1134+
* phantom was already drawing. */
1135+
function appendLoopLeader(pan: HTMLElement, source: SVGSVGElement, musicWidth: number): void {
1136+
const leader = source.cloneNode(true) as SVGSVGElement;
1137+
leader.style.position = 'absolute';
1138+
leader.style.left = `-${musicWidth}px`;
1139+
leader.style.top = '0';
1140+
leader.style.height = '100%';
1141+
leader.style.width = 'auto';
1142+
leader.style.display = 'block';
1143+
leader.setAttribute('aria-hidden', 'true');
1144+
pan.appendChild(leader);
1145+
}
1146+
11271147
/** Set `--mask-start` / `--mask-end` CSS vars on the stage for the pan
11281148
* mask gradient. 0 → mask-start is fully transparent (pan hidden where
11291149
* the frozen overlay sits), mask-start → mask-end fades from 0 → 1,
@@ -1241,14 +1261,12 @@ function setupRenderLoop(args: {
12411261
lastIterSeen = iter;
12421262
}
12431263
translateX = playheadPx - containerX + wrapOffset;
1244-
// Wrap only AFTER the first iteration is done. At iter=0 the
1245-
// initial translate is naturally positive — stage left of the
1246-
// playhead is simply the empty pre-music region. Wrapping there
1247-
// would teleport the pan to show the TAIL of the loop suddenly
1248-
// filling that empty region, which reads as "a duplicate score
1249-
// appearing" at first-note time. Once iter > 0 the copies are
1250-
// already tiled so the wrap is visually seamless.
1251-
if (iter > 0) {
1264+
// Keep translate in (-musicWidth, 0]. The phantom leader tile
1265+
// positioned at `-musicWidth` means either side of that range
1266+
// has identical content to what the wrap reveals — the snap
1267+
// is visually seamless. Skipping during pre-roll (ms<0) preserves
1268+
// the lead-in pose where translate is intentionally positive.
1269+
if (ms >= 0) {
12521270
while (translateX <= -musicWidth) { translateX += musicWidth; wrapOffset += musicWidth; }
12531271
while (translateX > 0) { translateX -= musicWidth; wrapOffset -= musicWidth; }
12541272
}
@@ -1408,7 +1426,10 @@ export async function mountScore(host: HTMLElement): Promise<MountedScore | null
14081426
if (frozenOverlay) stripStaffLinesFrom(frozenOverlay.host);
14091427
for (const s of panSvgs) stripHeadersFrom(s);
14101428

1411-
if (loop) applyLoopSpacing(panSvgs, actualHeaderWidth);
1429+
if (loop) {
1430+
applyLoopSpacing(panSvgs, actualHeaderWidth);
1431+
appendLoopLeader(pan, panSvgs[0], musicWidth);
1432+
}
14121433
setFadeMaskVars(stage, headerWidestWidth, playheadPx);
14131434

14141435
const player = new ScorePlayer(rendered, loop, K.PRE_ROLL_MS, K.TAIL_MS);

0 commit comments

Comments
 (0)