Spaces:
Running
Running
Commit ·
13e8a7a
1
Parent(s): 723e020
enhanced score player.
Browse files- web/score-player.css +16 -1
- web/score-player.js +73 -7
web/score-player.css
CHANGED
|
@@ -27,14 +27,29 @@
|
|
| 27 |
.ls-status.ls-err { color: #c0392b; }
|
| 28 |
|
| 29 |
.ls-svg {
|
| 30 |
-
background: #
|
| 31 |
display: inline-block;
|
| 32 |
min-width: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
.ls-svg svg {
|
| 35 |
max-width: 100%;
|
| 36 |
height: auto;
|
| 37 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
/* Verovio draws everything with stroke|fill="currentColor", which resolves to the
|
| 39 |
computed `color`. We can't rely on inheritance from a container `color`: Gradio's
|
| 40 |
`.prose * { color: var(--body-text-color) }` sets color DIRECTLY on every
|
|
|
|
| 27 |
.ls-status.ls-err { color: #c0392b; }
|
| 28 |
|
| 29 |
.ls-svg {
|
| 30 |
+
background: #fff;
|
| 31 |
display: inline-block;
|
| 32 |
min-width: 100%;
|
| 33 |
+
position: relative; /* anchor for the absolutely-positioned playback cursor */
|
| 34 |
+
}
|
| 35 |
+
/* faint warm/yellow tint behind the score while generating */
|
| 36 |
+
.ls-svg.ls-generating-bg {
|
| 37 |
+
background: #fffdf2;
|
| 38 |
}
|
| 39 |
.ls-svg svg {
|
| 40 |
max-width: 100%;
|
| 41 |
height: auto;
|
| 42 |
}
|
| 43 |
+
/* Playback cursor: a vertical line tracking the currently-sounding note. */
|
| 44 |
+
.ls-cursor {
|
| 45 |
+
position: absolute;
|
| 46 |
+
width: 2px;
|
| 47 |
+
background: rgba(124, 92, 255, 0.85);
|
| 48 |
+
pointer-events: none;
|
| 49 |
+
z-index: 5;
|
| 50 |
+
display: none;
|
| 51 |
+
transition: left 0.06s linear;
|
| 52 |
+
}
|
| 53 |
/* Verovio draws everything with stroke|fill="currentColor", which resolves to the
|
| 54 |
computed `color`. We can't rely on inheritance from a container `color`: Gradio's
|
| 55 |
`.prose * { color: var(--body-text-color) }` sets color DIRECTLY on every
|
web/score-player.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
| 46 |
|
| 47 |
const HIGHLIGHT_THROTTLE_MS = 50;
|
| 48 |
let lastHighlightUpdate = 0;
|
|
|
|
| 49 |
|
| 50 |
function log (msg) { console.log('[LilyScore]', msg); }
|
| 51 |
|
|
@@ -99,6 +100,8 @@
|
|
| 99 |
if (!svg) return;
|
| 100 |
els.svg.innerHTML = '';
|
| 101 |
els.svg.appendChild(document.importNode(svg, true));
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
function setStatus (text, kind) {
|
|
@@ -275,12 +278,57 @@
|
|
| 275 |
if (!state.highlighted.has(id)) { const el = document.getElementById(id); if (el) el.classList.add('ls-hl'); }
|
| 276 |
});
|
| 277 |
state.highlighted = now;
|
|
|
|
|
|
|
|
|
|
| 278 |
} catch (e) { /* ignore */ }
|
| 279 |
}
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
function clearHighlights () {
|
| 282 |
state.highlighted.forEach(function (id) { const el = document.getElementById(id); if (el) el.classList.remove('ls-hl'); });
|
| 283 |
state.highlighted = new Set();
|
|
|
|
| 284 |
}
|
| 285 |
|
| 286 |
// ---- transport UI -------------------------------------------------------
|
|
@@ -323,6 +371,8 @@
|
|
| 323 |
const wrap = document.createElement('div'); wrap.className = 'ls-preview';
|
| 324 |
const status = document.createElement('div'); status.className = 'ls-status';
|
| 325 |
const svgBox = document.createElement('div'); svgBox.className = 'ls-svg';
|
|
|
|
|
|
|
| 326 |
wrap.appendChild(status); wrap.appendChild(svgBox);
|
| 327 |
|
| 328 |
// transport bar
|
|
@@ -336,7 +386,7 @@
|
|
| 336 |
|
| 337 |
root.appendChild(wrap); root.appendChild(player);
|
| 338 |
|
| 339 |
-
els.root = root; els.svg = svgBox; els.preview = wrap; els.status = status; els.player = player;
|
| 340 |
els.playBtn = player.querySelector('.ls-play');
|
| 341 |
els.pauseBtn = player.querySelector('.ls-pause');
|
| 342 |
els.stopBtn = player.querySelector('.ls-stop');
|
|
@@ -352,13 +402,27 @@
|
|
| 352 |
const r = els.progress.getBoundingClientRect();
|
| 353 |
seekTo(state.duration * ((e.clientX - r.left) / r.width));
|
| 354 |
});
|
| 355 |
-
// click
|
|
|
|
|
|
|
|
|
|
| 356 |
els.svg.addEventListener('click', function (e) {
|
| 357 |
-
if (!state.toolkit || state.generating) return;
|
| 358 |
-
const
|
| 359 |
-
if (!
|
| 360 |
-
const
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
});
|
| 363 |
|
| 364 |
initVerovio(); // warm up early
|
|
@@ -375,6 +439,8 @@
|
|
| 375 |
flag = !!flag;
|
| 376 |
if (flag === state.generating) return;
|
| 377 |
state.generating = flag;
|
|
|
|
|
|
|
| 378 |
if (flag) { stop(); }
|
| 379 |
updatePlayerVisibility();
|
| 380 |
},
|
|
|
|
| 46 |
|
| 47 |
const HIGHLIGHT_THROTTLE_MS = 50;
|
| 48 |
let lastHighlightUpdate = 0;
|
| 49 |
+
let lastAutoScroll = 0;
|
| 50 |
|
| 51 |
function log (msg) { console.log('[LilyScore]', msg); }
|
| 52 |
|
|
|
|
| 100 |
if (!svg) return;
|
| 101 |
els.svg.innerHTML = '';
|
| 102 |
els.svg.appendChild(document.importNode(svg, true));
|
| 103 |
+
// re-attach the playback cursor (innerHTML reset above removed it)
|
| 104 |
+
if (els.cursor) { els.cursor.style.display = 'none'; els.svg.appendChild(els.cursor); }
|
| 105 |
}
|
| 106 |
|
| 107 |
function setStatus (text, kind) {
|
|
|
|
| 278 |
if (!state.highlighted.has(id)) { const el = document.getElementById(id); if (el) el.classList.add('ls-hl'); }
|
| 279 |
});
|
| 280 |
state.highlighted = now;
|
| 281 |
+
// move the playback cursor to the first currently-sounding note
|
| 282 |
+
const ids = res.notes || [];
|
| 283 |
+
if (ids.length) updateCursor(ids[0]);
|
| 284 |
} catch (e) { /* ignore */ }
|
| 285 |
}
|
| 286 |
|
| 287 |
+
// Position the vertical cursor at a note element, spanning its system's height.
|
| 288 |
+
function updateCursor (noteId) {
|
| 289 |
+
if (!els.cursor || !els.svg) return;
|
| 290 |
+
const note = document.getElementById(noteId);
|
| 291 |
+
if (!note) { els.cursor.style.display = 'none'; return; }
|
| 292 |
+
const sysOrStaff = note.closest('.system') || note.closest('.staff');
|
| 293 |
+
const boxRect = els.svg.getBoundingClientRect();
|
| 294 |
+
const noteRect = note.getBoundingClientRect();
|
| 295 |
+
const x = noteRect.left + noteRect.width / 2 - boxRect.left;
|
| 296 |
+
let top = 0, height = boxRect.height;
|
| 297 |
+
if (sysOrStaff) {
|
| 298 |
+
const sr = sysOrStaff.getBoundingClientRect();
|
| 299 |
+
top = sr.top - boxRect.top;
|
| 300 |
+
height = sr.height;
|
| 301 |
+
}
|
| 302 |
+
els.cursor.style.left = x + 'px';
|
| 303 |
+
els.cursor.style.top = top + 'px';
|
| 304 |
+
els.cursor.style.height = height + 'px';
|
| 305 |
+
els.cursor.style.display = 'block';
|
| 306 |
+
// keep the cursor in view while playing
|
| 307 |
+
scrollCursorIntoView(noteRect, boxRect);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Auto-scroll the preview so the cursor stays visible (throttled, gentle).
|
| 311 |
+
function scrollCursorIntoView (noteRect, boxRect) {
|
| 312 |
+
if (!els.preview) return;
|
| 313 |
+
const now = performance.now();
|
| 314 |
+
if (now - lastAutoScroll < 300) return;
|
| 315 |
+
const pr = els.preview.getBoundingClientRect();
|
| 316 |
+
const above = noteRect.top < pr.top + 60;
|
| 317 |
+
const below = noteRect.bottom > pr.bottom - 60;
|
| 318 |
+
if (!above && !below) return;
|
| 319 |
+
lastAutoScroll = now;
|
| 320 |
+
const target = els.preview.scrollTop + (noteRect.top - pr.top) - els.preview.clientHeight * 0.35;
|
| 321 |
+
els.preview.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
function hideCursor () {
|
| 325 |
+
if (els.cursor) els.cursor.style.display = 'none';
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
function clearHighlights () {
|
| 329 |
state.highlighted.forEach(function (id) { const el = document.getElementById(id); if (el) el.classList.remove('ls-hl'); });
|
| 330 |
state.highlighted = new Set();
|
| 331 |
+
hideCursor();
|
| 332 |
}
|
| 333 |
|
| 334 |
// ---- transport UI -------------------------------------------------------
|
|
|
|
| 371 |
const wrap = document.createElement('div'); wrap.className = 'ls-preview';
|
| 372 |
const status = document.createElement('div'); status.className = 'ls-status';
|
| 373 |
const svgBox = document.createElement('div'); svgBox.className = 'ls-svg';
|
| 374 |
+
const cursor = document.createElement('div'); cursor.className = 'ls-cursor';
|
| 375 |
+
svgBox.appendChild(cursor);
|
| 376 |
wrap.appendChild(status); wrap.appendChild(svgBox);
|
| 377 |
|
| 378 |
// transport bar
|
|
|
|
| 386 |
|
| 387 |
root.appendChild(wrap); root.appendChild(player);
|
| 388 |
|
| 389 |
+
els.root = root; els.svg = svgBox; els.preview = wrap; els.status = status; els.player = player; els.cursor = cursor;
|
| 390 |
els.playBtn = player.querySelector('.ls-play');
|
| 391 |
els.pauseBtn = player.querySelector('.ls-pause');
|
| 392 |
els.stopBtn = player.querySelector('.ls-stop');
|
|
|
|
| 402 |
const r = els.progress.getBoundingClientRect();
|
| 403 |
seekTo(state.duration * ((e.clientX - r.left) / r.width));
|
| 404 |
});
|
| 405 |
+
// click in the score -> seek to the nearest note/rest. We don't rely on the
|
| 406 |
+
// click target (elementFromPoint often lands on the bare <svg> between
|
| 407 |
+
// noteheads, or on a staff line): instead find the .note/.chord/.rest whose
|
| 408 |
+
// on-screen box is closest to the click, then seek to its time.
|
| 409 |
els.svg.addEventListener('click', function (e) {
|
| 410 |
+
if (!state.toolkit || state.generating || !state.midiData) return;
|
| 411 |
+
const svg = els.svg.querySelector('svg');
|
| 412 |
+
if (!svg) return;
|
| 413 |
+
const cands = svg.querySelectorAll('.note, .chord, .rest, .mRest');
|
| 414 |
+
let best = null, bestD = Infinity;
|
| 415 |
+
for (var i = 0; i < cands.length; i++) {
|
| 416 |
+
if (!cands[i].id) continue;
|
| 417 |
+
const r = cands[i].getBoundingClientRect();
|
| 418 |
+
const dx = e.clientX - (r.left + r.width / 2);
|
| 419 |
+
const dy = e.clientY - (r.top + r.height / 2);
|
| 420 |
+
const d = dx * dx + dy * dy;
|
| 421 |
+
if (d < bestD) { bestD = d; best = cands[i]; }
|
| 422 |
+
}
|
| 423 |
+
if (!best) return;
|
| 424 |
+
const time = state.toolkit.getTimeForElement(best.id);
|
| 425 |
+
if (typeof time === 'number' && !isNaN(time) && time >= 0) seekTo(time);
|
| 426 |
});
|
| 427 |
|
| 428 |
initVerovio(); // warm up early
|
|
|
|
| 439 |
flag = !!flag;
|
| 440 |
if (flag === state.generating) return;
|
| 441 |
state.generating = flag;
|
| 442 |
+
// faint yellow score background only while generating
|
| 443 |
+
if (els.svg) els.svg.classList.toggle('ls-generating-bg', flag);
|
| 444 |
if (flag) { stop(); }
|
| 445 |
updatePlayerVisibility();
|
| 446 |
},
|