mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 06:18:31 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73159a76a7 | ||
|
|
4e0e3c5417 | ||
|
|
8ef97708ef |
@@ -46,6 +46,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
carry their gig log; instruments their gig count.
|
carry their gig log; instruments their gig count.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Folder library renders only the songs on screen** (#965) — a song list used to
|
||||||
|
render *every* song it held. On a flat 50,944-song library that was one `<div>`
|
||||||
|
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
|
||||||
|
built even while another screen was showing. A document that size also punishes
|
||||||
|
unrelated code: any `document.querySelector` that misses has to walk the whole
|
||||||
|
tree — which is how the song-preview menu check ended up eating ~50% of the
|
||||||
|
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
|
||||||
|
windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged.
|
||||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||||
|
|||||||
@@ -878,6 +878,146 @@ function createFolderSurface(cfg) {
|
|||||||
var _dragRafId = null;
|
var _dragRafId = null;
|
||||||
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
||||||
|
|
||||||
|
// ── Windowed song lists ─────────────────────────────────────────────
|
||||||
|
// A song list used to render EVERY song it held. On a flat 50,944-song
|
||||||
|
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
|
||||||
|
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
|
||||||
|
// even be looking at. It also poisons unrelated code: any
|
||||||
|
// `document.querySelector` miss anywhere in the app must walk that whole
|
||||||
|
// tree, which is how song_preview's per-frame menu check ended up eating
|
||||||
|
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
|
||||||
|
//
|
||||||
|
// So render only what is on screen. Rows are uniform height (and grid cards
|
||||||
|
// uniform size), so the window is pure arithmetic — no per-row observers.
|
||||||
|
// Off-window rows are represented by padding on the list itself rather than
|
||||||
|
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
|
||||||
|
// shift the columns, whereas padding works identically for both layouts.
|
||||||
|
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
|
||||||
|
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
|
||||||
|
var _virtualCleanups = [];
|
||||||
|
var _virtualLists = []; // repaint fns, one per live windowed list
|
||||||
|
|
||||||
|
// Which slice of the list is on screen. Pure arithmetic — kept separate from
|
||||||
|
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
|
||||||
|
//
|
||||||
|
// top : list's offset relative to the scroller viewport's top. NEGATIVE
|
||||||
|
// once the user has scrolled the list's start above the fold.
|
||||||
|
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
|
||||||
|
//
|
||||||
|
// Returns the song index range [start, end) to render, plus how many ROWS of
|
||||||
|
// padding stand in for the songs above and below it.
|
||||||
|
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
|
||||||
|
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
|
||||||
|
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
|
||||||
|
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
|
||||||
|
// Scrolled entirely past the list (either direction): keep one row alive
|
||||||
|
// rather than emptying it, so the padding math stays anchored.
|
||||||
|
if (lastRow <= firstRow) {
|
||||||
|
firstRow = Math.min(firstRow, rows - 1);
|
||||||
|
lastRow = firstRow + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: firstRow * perRow,
|
||||||
|
end: Math.min(total, lastRow * perRow),
|
||||||
|
padRowsTop: firstRow,
|
||||||
|
padRowsBottom: Math.max(0, rows - lastRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearVirtualLists() {
|
||||||
|
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||||
|
_virtualCleanups = [];
|
||||||
|
_virtualLists = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill `list` with `songs`, windowed when the list is big enough to matter.
|
||||||
|
// `make(song)` builds one row/card.
|
||||||
|
function _fillSongList(list, songs, make) {
|
||||||
|
var sorted = _sortSongs(songs);
|
||||||
|
if (sorted.length <= VIRTUAL_MIN) {
|
||||||
|
sorted.forEach(function (s) { list.appendChild(make(s)); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scroller = _getScrollEl();
|
||||||
|
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
|
||||||
|
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
|
||||||
|
|
||||||
|
// Measure one real row once — no hardcoded row height to drift out of
|
||||||
|
// sync with the CSS. (The list is shown before it is populated, so this
|
||||||
|
// measures a laid-out row, not a zero-height one.)
|
||||||
|
var probe = make(sorted[0]);
|
||||||
|
probe.style.visibility = 'hidden';
|
||||||
|
list.appendChild(probe);
|
||||||
|
var probeRect = probe.getBoundingClientRect();
|
||||||
|
var rowH = probeRect.height || 44;
|
||||||
|
var cardW = probeRect.width || 150;
|
||||||
|
list.removeChild(probe);
|
||||||
|
|
||||||
|
var GRID_GAP = 12; // matches the grid's `gap:12px`
|
||||||
|
var raf = 0, lastStart = -1, lastEnd = -1;
|
||||||
|
|
||||||
|
// Recomputed on EVERY paint, not captured once: a window resize changes
|
||||||
|
// the grid's column count, and therefore the row count and the height of
|
||||||
|
// the padding standing in for off-window rows. paint() runs on resize, so
|
||||||
|
// stale metrics would slice the wrong songs and mis-size the list.
|
||||||
|
function metrics() {
|
||||||
|
var perRow = 1, itemH = rowH;
|
||||||
|
if (_view === 'grid') {
|
||||||
|
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
|
||||||
|
itemH = rowH + GRID_GAP;
|
||||||
|
}
|
||||||
|
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
raf = 0;
|
||||||
|
// Collapsed (display:none) or detached: nothing to paint, and don't
|
||||||
|
// pay for layout on every scroll tick of a section nobody can see.
|
||||||
|
// Forget the last window so re-showing repaints from scratch against
|
||||||
|
// the new position rather than short-circuiting on a stale memo.
|
||||||
|
if (!list.isConnected || list.offsetParent === null) {
|
||||||
|
lastStart = -1; lastEnd = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var m = metrics();
|
||||||
|
// Where the list sits relative to the scroller's viewport.
|
||||||
|
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||||
|
var vh = scroller.clientHeight || window.innerHeight;
|
||||||
|
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
|
||||||
|
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
|
||||||
|
lastStart = w.start; lastEnd = w.end;
|
||||||
|
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
|
||||||
|
list.textContent = '';
|
||||||
|
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
|
||||||
|
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
|
||||||
|
list.appendChild(frag);
|
||||||
|
}
|
||||||
|
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
|
||||||
|
|
||||||
|
scroller.addEventListener('scroll', schedule, { passive: true });
|
||||||
|
window.addEventListener('resize', schedule);
|
||||||
|
// Expanding or collapsing ANY section moves every list below it. Those
|
||||||
|
// lists' windows are computed from their position, so they must repaint
|
||||||
|
// too — otherwise they keep the window from their old position and show
|
||||||
|
// blank padding where songs should be until the user happens to scroll.
|
||||||
|
_virtualLists.push(schedule);
|
||||||
|
_virtualCleanups.push(function () {
|
||||||
|
scroller.removeEventListener('scroll', schedule);
|
||||||
|
window.removeEventListener('resize', schedule);
|
||||||
|
if (raf) window.cancelAnimationFrame(raf);
|
||||||
|
});
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-window every live list — call after anything that can move them
|
||||||
|
// vertically (a folder expanding/collapsing, a section being shown).
|
||||||
|
function _repaintVirtualLists() {
|
||||||
|
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||||
|
}
|
||||||
|
|
||||||
function _getScrollEl() {
|
function _getScrollEl() {
|
||||||
var el = _treeEl();
|
var el = _treeEl();
|
||||||
while (el && el !== document.documentElement) {
|
while (el && el !== document.documentElement) {
|
||||||
@@ -1159,8 +1299,8 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
var _listPopulated = open;
|
var _listPopulated = open;
|
||||||
function _populateList() {
|
function _populateList() {
|
||||||
_sortSongs(folder.songs).forEach(function (s) {
|
_fillSongList(list, folder.songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||||
});
|
});
|
||||||
(folder.children || []).forEach(function (child) {
|
(folder.children || []).forEach(function (child) {
|
||||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||||
@@ -1195,12 +1335,18 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
var nowOpen = content.style.display === 'none';
|
var nowOpen = content.style.display === 'none';
|
||||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
// Show BEFORE populating: a windowed list measures a real row and the
|
||||||
|
// scroller viewport, and both are zero while display:none.
|
||||||
content.style.display = nowOpen ? '' : 'none';
|
content.style.display = nowOpen ? '' : 'none';
|
||||||
|
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||||
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
||||||
if (nowOpen) _openFolders.add(folder.path);
|
if (nowOpen) _openFolders.add(folder.path);
|
||||||
else _openFolders.delete(folder.path);
|
else _openFolders.delete(folder.path);
|
||||||
_storeJSON('open', [..._openFolders]);
|
_storeJSON('open', [..._openFolders]);
|
||||||
|
// This toggle moved everything below it — re-window the other lists,
|
||||||
|
// and re-window THIS one if it was already populated (its saved
|
||||||
|
// window was computed at its old position).
|
||||||
|
_repaintVirtualLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(hdr); wrap.appendChild(content);
|
wrap.appendChild(hdr); wrap.appendChild(content);
|
||||||
@@ -1245,8 +1391,8 @@ function createFolderSurface(cfg) {
|
|||||||
}
|
}
|
||||||
var _populated = _unsortedOpen;
|
var _populated = _unsortedOpen;
|
||||||
function _populate() {
|
function _populate() {
|
||||||
_sortSongs(songs).forEach(function (s) {
|
_fillSongList(list, songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||||
@@ -1255,10 +1401,12 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
_unsortedOpen = list.style.display === 'none';
|
_unsortedOpen = list.style.display === 'none';
|
||||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
// Show BEFORE populating — see the folder toggle above.
|
||||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||||
|
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||||
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
||||||
_store(cfg.unsortedKey, String(_unsortedOpen));
|
_store(cfg.unsortedKey, String(_unsortedOpen));
|
||||||
|
_repaintVirtualLists(); // this toggle moved every list below it
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(hdr); wrap.appendChild(list);
|
wrap.appendChild(hdr); wrap.appendChild(list);
|
||||||
@@ -1340,6 +1488,10 @@ function createFolderSurface(cfg) {
|
|||||||
// ── Render ──────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────
|
||||||
function _render() {
|
function _render() {
|
||||||
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
||||||
|
// Drop the scroll listeners of the previous render's windowed lists —
|
||||||
|
// their `list` nodes are about to be detached, and a surviving listener
|
||||||
|
// would keep painting into orphaned DOM (and leak on every re-render).
|
||||||
|
_clearVirtualLists();
|
||||||
var treeEl = _treeEl();
|
var treeEl = _treeEl();
|
||||||
if (!treeEl) return;
|
if (!treeEl) return;
|
||||||
var data = _filtered();
|
var data = _filtered();
|
||||||
@@ -1451,6 +1603,7 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||||
function _unload() {
|
function _unload() {
|
||||||
|
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||||
if (!cfg.searchInputId) return;
|
if (!cfg.searchInputId) return;
|
||||||
var el = _el(cfg.searchInputId);
|
var el = _el(cfg.searchInputId);
|
||||||
if (el) el.style.maxWidth = '';
|
if (el) el.style.maxWidth = '';
|
||||||
@@ -1554,6 +1707,8 @@ function createFolderSurface(cfg) {
|
|||||||
init: _init,
|
init: _init,
|
||||||
onScreenChanged: _onScreenChanged,
|
onScreenChanged: _onScreenChanged,
|
||||||
render: _render,
|
render: _render,
|
||||||
|
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||||
|
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,6 +1811,7 @@ if (!window.__folderLibraryLib) {
|
|||||||
window.folderLibrary = {
|
window.folderLibrary = {
|
||||||
load: function (force) { return _lib.load(force); },
|
load: function (force) { return _lib.load(force); },
|
||||||
unload: function () { _lib.unload(); },
|
unload: function () { _lib.unload(); },
|
||||||
|
__test: _lib.__test,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-load if folder view was already active when this script was injected.
|
// Auto-load if folder view was already active when this script was injected.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Windowed song lists (feedBack#965).
|
||||||
|
//
|
||||||
|
// A song list used to render EVERY song. On a flat 50,944-song library that is
|
||||||
|
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
|
||||||
|
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
|
||||||
|
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
|
||||||
|
// the app had to walk that whole tree.
|
||||||
|
//
|
||||||
|
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
|
||||||
|
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
|
||||||
|
// place, so it is tested directly — the DOM glue around it is not the risky bit.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const window = {
|
||||||
|
console,
|
||||||
|
document: {
|
||||||
|
readyState: 'complete',
|
||||||
|
addEventListener() {},
|
||||||
|
getElementById() { return null; },
|
||||||
|
querySelector() { return null; },
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
localStorage: { getItem() { return null; }, setItem() {} },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
setInterval() { return 0; },
|
||||||
|
clearInterval() {},
|
||||||
|
requestAnimationFrame() { return 0; },
|
||||||
|
cancelAnimationFrame() {},
|
||||||
|
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||||
|
innerHeight: 800,
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
window.globalThis = window;
|
||||||
|
const ctx = vm.createContext(window);
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||||
|
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||||
|
return window.folderLibrary.__test;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
|
||||||
|
|
||||||
|
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
|
||||||
|
const ROW = 44;
|
||||||
|
const VH = 800;
|
||||||
|
const TOTAL = 50938;
|
||||||
|
|
||||||
|
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
const rendered = w.end - w.start;
|
||||||
|
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
|
||||||
|
// ~18 rows fit in 800px, plus buffer above and below.
|
||||||
|
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, TOTAL - w.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
|
||||||
|
const scrolled = 10000 * ROW; // row 10,000 at the fold
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
|
||||||
|
assert.ok(w.end > w.start);
|
||||||
|
// The invariant that keeps the scrollbar honest: padding rows + rendered
|
||||||
|
// rows must account for every song, or the list changes height as you scroll.
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the very bottom: no bottom padding, end lands on the last song', () => {
|
||||||
|
const rows = TOTAL;
|
||||||
|
const scrolled = rows * ROW - VH; // scrolled to the end
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid view: perRow songs collapse into one row', () => {
|
||||||
|
const perRow = 6;
|
||||||
|
const rows = Math.ceil(TOTAL / perRow);
|
||||||
|
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
|
||||||
|
assert.ok(w.end <= TOTAL);
|
||||||
|
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled far past the list: keeps one row, never a negative window', () => {
|
||||||
|
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.ok(w.end > w.start, 'window must never invert');
|
||||||
|
assert.ok(w.start >= 0 && w.end <= TOTAL);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
|
||||||
|
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.ok(w.end > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
|
||||||
|
// Measured height of 0 (e.g. list still display:none) must not divide by zero
|
||||||
|
// and must not silently render an empty list.
|
||||||
|
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('small lists are below the virtualization threshold', () => {
|
||||||
|
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
|
||||||
|
// perRow and rows were originally captured once at fill time. paint() also runs
|
||||||
|
// on resize, so a narrower/wider window changed the column count while the
|
||||||
|
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
|
||||||
|
// the padding. These pin that the geometry is a function of perRow, so a stale
|
||||||
|
// perRow cannot silently survive.
|
||||||
|
|
||||||
|
test('resizing the grid to fewer columns re-windows against the new row count', () => {
|
||||||
|
const total = 10000;
|
||||||
|
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
|
||||||
|
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
|
||||||
|
|
||||||
|
// Same viewport, half the columns -> about half as many songs on screen.
|
||||||
|
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
|
||||||
|
// ...and the total must still add up, or the scrollbar lies after a resize.
|
||||||
|
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
|
||||||
|
const rows = Math.ceil(total / perRow);
|
||||||
|
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
|
||||||
|
`rows must account for every song at perRow=${perRow}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale perRow would break the total-height invariant (the bug)', () => {
|
||||||
|
const total = 10000;
|
||||||
|
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
|
||||||
|
// the row count no longer matches the geometry, and the padding is wrong.
|
||||||
|
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
|
||||||
|
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
|
||||||
|
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
|
||||||
|
assert.notEqual(accounted, actualRows,
|
||||||
|
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
|
||||||
|
'metrics() recomputes both together on every paint so this cannot happen in practice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled grid window always starts on a row boundary', () => {
|
||||||
|
const total = 10000, perRow = 4;
|
||||||
|
const rows = Math.ceil(total / perRow);
|
||||||
|
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
|
||||||
|
});
|
||||||
@@ -15388,6 +15388,41 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The host throttles paused frames to ~10 fps, on the assumption
|
||||||
|
// that a paused chart is a static picture and re-rendering it is
|
||||||
|
// pure waste (highway-constants._PAUSED_FRAME_INTERVAL_MS).
|
||||||
|
//
|
||||||
|
// That stopped being true when the venue landed. The venue backdrop
|
||||||
|
// is a PLAYING VIDEO and the crowd reacts on its own clock, and they
|
||||||
|
// are drawn into this same canvas as the highway — so throttling the
|
||||||
|
// highway throttled the whole room. Pausing the song dropped the
|
||||||
|
// venue, the crowd and the stage to 10 fps.
|
||||||
|
//
|
||||||
|
// Two independent sources of motion, and BOTH must keep their frames:
|
||||||
|
//
|
||||||
|
// • a crowd video rolling on its own clock (career venue pack), and
|
||||||
|
// • the venue scene's own fake-depth motion — the backdrop breathes,
|
||||||
|
// the haze drifts, warmth pulses, the shimmer moves. That is
|
||||||
|
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
|
||||||
|
// so it only moves while we are actually given frames, and it runs
|
||||||
|
// with NO pack at all.
|
||||||
|
//
|
||||||
|
// The throttle fires whenever the CHART CLOCK is stalled — which is
|
||||||
|
// not just a pause. A count-in and the credits/author overlay stall it
|
||||||
|
// exactly the same way, so the venue was stuttering there too.
|
||||||
|
//
|
||||||
|
// With no venue at all (plain 3D highway) the paused scene really is a
|
||||||
|
// still picture: motion mode reads 'off', we claim nothing, and the
|
||||||
|
// throttle still saves the GPU as #654 intended.
|
||||||
|
needsContinuousFrames() {
|
||||||
|
if (!_isReady || _ctxLost) return false;
|
||||||
|
for (const v of _venueCrowdVideos) {
|
||||||
|
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
|
||||||
|
}
|
||||||
|
// 'off' also covers prefers-reduced-motion and "no venue scene".
|
||||||
|
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
|
||||||
|
},
|
||||||
|
|
||||||
draw(bundle) {
|
draw(bundle) {
|
||||||
if (!_isReady) return;
|
if (!_isReady) return;
|
||||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||||
|
|||||||
+36
-1
@@ -986,6 +986,22 @@ function createHighway() {
|
|||||||
// inline arrow function.
|
// inline arrow function.
|
||||||
function _handleAsyncInitFailure(e) {
|
function _handleAsyncInitFailure(e) {
|
||||||
if (hwState._renderer !== _installedRenderer) return;
|
if (hwState._renderer !== _installedRenderer) return;
|
||||||
|
// ...and ignore a rejection from a SUPERSEDED init cycle.
|
||||||
|
//
|
||||||
|
// A renderer mints a fresh readyPromise on every init(), and
|
||||||
|
// rejects the previous one ("superseded") when a newer init
|
||||||
|
// starts. The renderer object is unchanged, so the identity
|
||||||
|
// check above does not catch it — and we would tear down a
|
||||||
|
// perfectly healthy renderer that is merely re-initialising.
|
||||||
|
//
|
||||||
|
// This is exactly what starting a gig did: setViz('venue')
|
||||||
|
// installed the 3D renderer, then the queue's playSong()
|
||||||
|
// re-initialised it a tick later; init #1's promise rejected,
|
||||||
|
// and the gig dropped to the fallback 2D highway with the
|
||||||
|
// venue gone. A superseded init is not a failed init — the
|
||||||
|
// NEW cycle owns the outcome, and its own promise is what we
|
||||||
|
// must judge.
|
||||||
|
if (_installedRenderer.readyPromise !== rp) return;
|
||||||
console.error('renderer async init failure:', e);
|
console.error('renderer async init failure:', e);
|
||||||
_destroyCurrentIfInited();
|
_destroyCurrentIfInited();
|
||||||
hwState._renderer = _defaultRenderer;
|
hwState._renderer = _defaultRenderer;
|
||||||
@@ -1159,6 +1175,17 @@ function createHighway() {
|
|||||||
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
|
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional renderer capability: "my picture keeps moving even when the chart
|
||||||
|
// clock is stopped". Anything a renderer animates on its own clock (the 3D
|
||||||
|
// highway's venue video + crowd) has to opt out of the paused-frame throttle
|
||||||
|
// or it renders at 10 fps while the song is paused. Absent / throwing =
|
||||||
|
// false, so every existing renderer keeps the throttle unchanged.
|
||||||
|
function _rendererNeedsContinuousFrames() {
|
||||||
|
const r = hwState._renderer;
|
||||||
|
if (!r || typeof r.needsContinuousFrames !== 'function') return false;
|
||||||
|
try { return r.needsContinuousFrames() === true; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
hwState.animFrame = requestAnimationFrame(draw);
|
hwState.animFrame = requestAnimationFrame(draw);
|
||||||
if (!hwState.canvas || !hwState._renderer) return;
|
if (!hwState.canvas || !hwState._renderer) return;
|
||||||
@@ -1223,7 +1250,15 @@ function createHighway() {
|
|||||||
const _nowP = performance.now();
|
const _nowP = performance.now();
|
||||||
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
||||||
_paused = true;
|
_paused = true;
|
||||||
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
// ...unless the renderer says its picture is NOT static while
|
||||||
|
// paused. The throttle assumes a paused chart is a still frame,
|
||||||
|
// but a renderer can own content on a clock of its own — the 3D
|
||||||
|
// highway draws the venue's video backdrop and its reactive crowd
|
||||||
|
// into this same canvas, so throttling the highway throttled the
|
||||||
|
// whole room to 10 fps whenever the song was paused. Optional
|
||||||
|
// method: renderers that don't implement it keep the throttle.
|
||||||
|
if (!_rendererNeedsContinuousFrames()
|
||||||
|
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||||
hwState._lastPausedDrawAt = _nowP;
|
hwState._lastPausedDrawAt = _nowP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,11 @@
|
|||||||
let _lastStingerAt = -Infinity;
|
let _lastStingerAt = -Infinity;
|
||||||
let _prevStreak = 0;
|
let _prevStreak = 0;
|
||||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
||||||
|
// Filename of the song song:loaded last reported. An arrangement switch
|
||||||
|
// re-emits song:loaded for the SAME file (changeArrangement reloads through
|
||||||
|
// the normal load path), and that must not be mistaken for arriving at the
|
||||||
|
// venue with a new song — see onSongLoaded.
|
||||||
|
let _lastSongFile = '';
|
||||||
let _bound = false;
|
let _bound = false;
|
||||||
|
|
||||||
function now() { return Date.now(); }
|
function now() { return Date.now(); }
|
||||||
@@ -478,10 +483,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSongLoaded() {
|
// song:loaded for the SAME file is an arrangement switch, not an arrival at
|
||||||
|
// the venue. changeArrangement() reloads through the normal load path, so
|
||||||
|
// the event is indistinguishable from a fresh load except by filename.
|
||||||
|
function isArrangementSwitch(prevFile, nextFile) {
|
||||||
|
return !!nextFile && nextFile === prevFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSongLoaded(song) {
|
||||||
|
const file = String((song && song.filename) || '');
|
||||||
|
const sameSong = isArrangementSwitch(_lastSongFile, file);
|
||||||
|
_lastSongFile = file;
|
||||||
|
|
||||||
machine.reset();
|
machine.reset();
|
||||||
_prevStreak = 0;
|
_prevStreak = 0;
|
||||||
_lastAccuracyPct = null;
|
_lastAccuracyPct = null;
|
||||||
|
|
||||||
|
// Switching arrangement is NOT arriving at the venue.
|
||||||
|
//
|
||||||
|
// changeArrangement() reloads the song through the same path as a fresh
|
||||||
|
// load, so highway.js emits song:loaded again — same filename, new
|
||||||
|
// arrangement. Treated as a new song, that replayed the arrival flyover:
|
||||||
|
// the camera flew in from the back of the room again mid-set, every time
|
||||||
|
// the player switched from lead to rhythm. The player is already on
|
||||||
|
// stage; the room should just carry on.
|
||||||
|
//
|
||||||
|
// So keep the video pipeline running and only re-sync the mood: the
|
||||||
|
// performance restarts, so the loop must follow the reset machine (a
|
||||||
|
// quiet crossfade), never the intro.
|
||||||
|
if (sameSong) {
|
||||||
|
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuinely different song — full teardown.
|
||||||
// Abort any stinger/pending state from the previous song: its ended
|
// Abort any stinger/pending state from the previous song: its ended
|
||||||
// handler must not fade back into the old song's layers.
|
// handler must not fade back into the old song's layers.
|
||||||
cancelFade();
|
cancelFade();
|
||||||
@@ -651,6 +686,7 @@
|
|||||||
bindRuntime,
|
bindRuntime,
|
||||||
getState,
|
getState,
|
||||||
celebrate,
|
celebrate,
|
||||||
|
isArrangementSwitch,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (root) root.v3VenueCrowd = api;
|
if (root) root.v3VenueCrowd = api;
|
||||||
|
|||||||
@@ -18,6 +18,30 @@
|
|||||||
let _lastMood = 'idle';
|
let _lastMood = 'idle';
|
||||||
let _bound = false;
|
let _bound = false;
|
||||||
|
|
||||||
|
// The venue belongs to the SONG player and nowhere else.
|
||||||
|
//
|
||||||
|
// isVenueViz() only answers "is Venue the selected visualization" — a global
|
||||||
|
// preference. It says nothing about what is on screen. Other surfaces borrow
|
||||||
|
// the same highway_3d renderer (Virtuoso runs its practice charts on it), so
|
||||||
|
// with Venue selected they inherited the venue backdrop: the crowd and the
|
||||||
|
// stage showed up behind a chromatic exercise. The viz picker is a
|
||||||
|
// preference for the player; it is not a licence to paint the venue over
|
||||||
|
// whatever else happens to be using the renderer.
|
||||||
|
//
|
||||||
|
// So gate on both: Venue selected AND the player screen is the one showing.
|
||||||
|
function isPlayerScreen() {
|
||||||
|
try {
|
||||||
|
const active = document.querySelector('.screen.active');
|
||||||
|
return !!active && active.id === 'player';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBeActive() {
|
||||||
|
return isVenueViz() && isPlayerScreen();
|
||||||
|
}
|
||||||
|
|
||||||
function isVenueViz() {
|
function isVenueViz() {
|
||||||
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
||||||
const sel = root.v3VenueViz.getSelectedVizId
|
const sel = root.v3VenueViz.getSelectedVizId
|
||||||
@@ -146,7 +170,8 @@
|
|||||||
|
|
||||||
function syncViz(vizId) {
|
function syncViz(vizId) {
|
||||||
const id = String(vizId || '');
|
const id = String(vizId || '');
|
||||||
if (id === 'venue') {
|
// Venue selected is necessary but not sufficient — see shouldBeActive.
|
||||||
|
if (id === 'venue' && isPlayerScreen()) {
|
||||||
activate();
|
activate();
|
||||||
} else {
|
} else {
|
||||||
deactivate();
|
deactivate();
|
||||||
@@ -192,12 +217,19 @@
|
|||||||
if (_active) syncInstrumentPov();
|
if (_active) syncInstrumentPov();
|
||||||
});
|
});
|
||||||
sm.on('viz:renderer:ready', () => {
|
sm.on('viz:renderer:ready', () => {
|
||||||
if (isVenueViz()) activate();
|
if (shouldBeActive()) activate();
|
||||||
else deactivate();
|
else deactivate();
|
||||||
});
|
});
|
||||||
sm.on('viz:reverted', () => deactivate());
|
sm.on('viz:reverted', () => deactivate());
|
||||||
|
// Leaving the player tears the venue down; coming back rebuilds it.
|
||||||
|
// Without this the backdrop followed the renderer onto every other
|
||||||
|
// surface that borrows it (Virtuoso's practice highway).
|
||||||
|
sm.on('screen:changed', () => {
|
||||||
|
if (shouldBeActive()) activate();
|
||||||
|
else deactivate();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (isVenueViz()) activate();
|
if (shouldBeActive()) activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState() {
|
function getState() {
|
||||||
@@ -234,6 +266,8 @@
|
|||||||
activate,
|
activate,
|
||||||
deactivate,
|
deactivate,
|
||||||
syncViz,
|
syncViz,
|
||||||
|
isPlayerScreen,
|
||||||
|
shouldBeActive,
|
||||||
onAssetsLoaded,
|
onAssetsLoaded,
|
||||||
onAssetsFailed,
|
onAssetsFailed,
|
||||||
onPerformanceState,
|
onPerformanceState,
|
||||||
|
|||||||
@@ -77,3 +77,89 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
|||||||
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
||||||
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── The throttle must not starve a renderer that animates on its own clock ──
|
||||||
|
//
|
||||||
|
// The throttle assumes a paused chart is a still picture, so re-rendering it is
|
||||||
|
// waste. That stopped being true when the venue landed: the 3D highway draws the
|
||||||
|
// venue's VIDEO backdrop and its reactive crowd into the same canvas as the
|
||||||
|
// notes, so capping paused frames capped the whole room — pausing the song
|
||||||
|
// dropped the venue to ~10 fps ("everything around the highway drops fps").
|
||||||
|
//
|
||||||
|
// Renderers now opt out via an optional needsContinuousFrames(). Absent or
|
||||||
|
// throwing must mean false, so every other renderer keeps the throttle.
|
||||||
|
|
||||||
|
test('paused throttle defers to a renderer that needs continuous frames', () => {
|
||||||
|
const src = highwaySources();
|
||||||
|
const fn = extractBlock(src, 'function draw()');
|
||||||
|
assert.match(fn, /_rendererNeedsContinuousFrames\s*\(\s*\)/,
|
||||||
|
'the paused throttle must consult the renderer capability');
|
||||||
|
// The capability must GATE the early-return, not merely be called near it:
|
||||||
|
// the throttle only applies when the renderer does NOT need every frame.
|
||||||
|
assert.match(
|
||||||
|
fn,
|
||||||
|
/!\s*_rendererNeedsContinuousFrames\s*\(\s*\)[\s\S]{0,160}_PAUSED_FRAME_INTERVAL_MS[\s\S]{0,40}return;/,
|
||||||
|
'throttle must be skipped when the renderer needs continuous frames',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the capability probe fails closed (absent / non-function / throwing)', () => {
|
||||||
|
const src = highwaySources();
|
||||||
|
const fn = extractBlock(src, 'function _rendererNeedsContinuousFrames()');
|
||||||
|
assert.match(fn, /typeof\s+r\.needsContinuousFrames\s*!==\s*'function'[\s\S]{0,40}return false/,
|
||||||
|
'a renderer without the method must keep the throttle');
|
||||||
|
assert.match(fn, /catch[\s\S]{0,40}return false/,
|
||||||
|
'a throwing renderer must keep the throttle, not crash the draw loop');
|
||||||
|
assert.match(fn, /===\s*true/,
|
||||||
|
'only an explicit true opts out — a truthy accident must not disable the throttle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3D highway claims continuous frames for BOTH sources of venue motion', () => {
|
||||||
|
const h3d = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||||
|
const fn = extractBlock(h3d, 'needsContinuousFrames()');
|
||||||
|
// (1) a crowd video rolling on its own clock (career venue pack)
|
||||||
|
assert.match(fn, /_venueCrowdVideos/, 'must key off the actual crowd video elements');
|
||||||
|
assert.match(fn, /\.paused/, 'a paused video is a still frame');
|
||||||
|
// (2) the venue scene's OWN fake-depth motion — backdrop breathe, haze drift,
|
||||||
|
// warmth pulse, shimmer. Math.sin(t) in the draw loop, so it only moves while
|
||||||
|
// we get frames, and it runs with NO pack at all. Missing this meant the venue
|
||||||
|
// still stuttered on pause / count-in / credits whenever no video was rolling.
|
||||||
|
assert.match(fn, /_venueEffectiveMotionMode\s*\(\s*\)\s*!==\s*'off'/,
|
||||||
|
'the venue scene animates without any video — it must claim frames too');
|
||||||
|
// ...and with no venue at all the paused scene IS static: the #654 GPU saving
|
||||||
|
// must survive, so the method has to be able to return false.
|
||||||
|
assert.match(fn, /return false;/, 'must fall through to false on a plain 3D highway');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── a SUPERSEDED init is not a FAILED init ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Starting a gig dropped the player onto the fallback 2D highway with no venue.
|
||||||
|
//
|
||||||
|
// setViz('venue') installs the 3D renderer, whose init is async; the gig then
|
||||||
|
// immediately starts its play queue, and playSong() re-initialises that same
|
||||||
|
// renderer a tick later. A renderer mints a fresh readyPromise per init() and
|
||||||
|
// rejects the previous one with "superseded" — but highway.js only checked that
|
||||||
|
// the RENDERER object was unchanged, which it is. So it treated a healthy
|
||||||
|
// re-initialising renderer as a failed one, tore it down, and reverted to 2D:
|
||||||
|
//
|
||||||
|
// renderer async init failure: Error: superseded
|
||||||
|
// viz picker: reverted to default renderer (async-init-failure)
|
||||||
|
//
|
||||||
|
// Reproduced and fixed against the real build (venue stays selected, scene
|
||||||
|
// active, no viz:reverted).
|
||||||
|
|
||||||
|
test('a superseded readyPromise must not revert the viz to 2D', () => {
|
||||||
|
const src = highwaySources();
|
||||||
|
const fn = extractBlock(src, 'function _handleAsyncInitFailure(e)');
|
||||||
|
assert.match(fn, /readyPromise\s*!==\s*rp[\s\S]{0,40}return/,
|
||||||
|
'a rejection from a STALE readyPromise (the renderer has since re-init\'d) must be ' +
|
||||||
|
'ignored — otherwise a re-initialising renderer is torn down as if it had failed');
|
||||||
|
// The renderer-identity check must survive too: a rejection belonging to a
|
||||||
|
// renderer that has since been REPLACED is also not our problem.
|
||||||
|
assert.match(fn, /hwState\._renderer\s*!==\s*_installedRenderer[\s\S]{0,20}return/,
|
||||||
|
'the renderer-identity guard must remain');
|
||||||
|
// ...and a genuine failure of the CURRENT init cycle must still revert.
|
||||||
|
assert.match(fn, /_emitVizReverted\s*\(\s*'async-init-failure'\s*\)/,
|
||||||
|
'a real async-init failure must still fall back to the default renderer');
|
||||||
|
});
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ test('index.html loads venue deps before venue-scene-3d', () => {
|
|||||||
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncViz activates only for venue visualization id', () => {
|
test('syncViz activates only for venue visualization id, and only on the player', () => {
|
||||||
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
||||||
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
||||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||||
@@ -216,7 +216,14 @@ test('syncViz activates only for venue visualization id', () => {
|
|||||||
global.v3VenueViz = venueViz;
|
global.v3VenueViz = venueViz;
|
||||||
global.v3VenueInstrumentPov = pov;
|
global.v3VenueInstrumentPov = pov;
|
||||||
global.feedBack = { on() {} };
|
global.feedBack = { on() {} };
|
||||||
|
// The venue is scoped to the song player: selecting Venue is a preference
|
||||||
|
// for THAT screen, not a licence to paint the venue over anything else that
|
||||||
|
// borrows the highway_3d renderer (Virtuoso's practice charts did exactly
|
||||||
|
// that). syncViz therefore needs to know which screen is showing.
|
||||||
|
const onScreen = (id) => { global.document = { querySelector: (s) => (s === '.screen.active' && id ? { id } : null) }; };
|
||||||
|
const prevDoc = global.document;
|
||||||
try {
|
try {
|
||||||
|
onScreen('player');
|
||||||
venueScene.deactivate();
|
venueScene.deactivate();
|
||||||
venueScene.syncViz('highway_3d');
|
venueScene.syncViz('highway_3d');
|
||||||
assert.equal(global._h3dActive, false);
|
assert.equal(global._h3dActive, false);
|
||||||
@@ -224,7 +231,16 @@ test('syncViz activates only for venue visualization id', () => {
|
|||||||
assert.equal(global._h3dActive, true);
|
assert.equal(global._h3dActive, true);
|
||||||
assert.equal(venueScene.getState().active, true);
|
assert.equal(venueScene.getState().active, true);
|
||||||
assert.equal(venueScene.getState().themeId, 'small-club');
|
assert.equal(venueScene.getState().themeId, 'small-club');
|
||||||
|
|
||||||
|
// ...and the same call OFF the player must not activate it.
|
||||||
|
venueScene.deactivate();
|
||||||
|
onScreen('virtuoso');
|
||||||
|
venueScene.syncViz('venue');
|
||||||
|
assert.equal(global._h3dActive, false,
|
||||||
|
'Venue selected must NOT paint the venue onto the Virtuoso highway');
|
||||||
|
assert.equal(venueScene.getState().active, false);
|
||||||
} finally {
|
} finally {
|
||||||
|
global.document = prevDoc;
|
||||||
venueScene.deactivate();
|
venueScene.deactivate();
|
||||||
delete global.h3dVenueSceneSetActive;
|
delete global.h3dVenueSceneSetActive;
|
||||||
delete global.h3dVenueSceneSetMood;
|
delete global.h3dVenueSceneSetMood;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Two venue bugs reported from a live career session.
|
||||||
|
//
|
||||||
|
// 1. Changing arrangement mid-song replayed the venue arrival flyover. The
|
||||||
|
// camera flew in from the back of the room again, every time the player
|
||||||
|
// switched lead -> rhythm. changeArrangement() reloads the song through the
|
||||||
|
// normal load path, so highway.js re-emits `song:loaded` — same filename,
|
||||||
|
// new arrangement — and the venue could not tell that from a fresh arrival.
|
||||||
|
// The player is already on stage; the room should just carry on.
|
||||||
|
//
|
||||||
|
// 2. With Venue selected, the venue backdrop showed up on the VIRTUOSO highway.
|
||||||
|
// The venue was gated purely on the viz selection, which is a global
|
||||||
|
// preference and says nothing about what is on screen. Virtuoso borrows the
|
||||||
|
// same highway_3d renderer for its practice charts, so it inherited the
|
||||||
|
// crowd and the stage behind a chromatic exercise. The venue belongs to the
|
||||||
|
// song player and nowhere else.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const crowd = require('../../static/v3/venue-crowd.js');
|
||||||
|
|
||||||
|
// ── 1. arrangement switch is not an arrival ────────────────────────────────
|
||||||
|
|
||||||
|
test('same filename = arrangement switch (no arrival flyover)', () => {
|
||||||
|
// changeArrangement() re-emits song:loaded for the song already on stage.
|
||||||
|
assert.equal(crowd.isArrangementSwitch('song.feedpak', 'song.feedpak'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different filename = a genuinely new song (flyover is correct)', () => {
|
||||||
|
assert.equal(crowd.isArrangementSwitch('a.feedpak', 'b.feedpak'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first load of the session is an arrival, not a switch', () => {
|
||||||
|
// No previous song -> the flyover must play.
|
||||||
|
assert.equal(crowd.isArrangementSwitch('', 'a.feedpak'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing filename is never treated as a switch', () => {
|
||||||
|
// Otherwise a malformed payload would silently suppress the flyover for the
|
||||||
|
// rest of the session.
|
||||||
|
assert.equal(crowd.isArrangementSwitch('a.feedpak', ''), false);
|
||||||
|
assert.equal(crowd.isArrangementSwitch('a.feedpak', undefined), false);
|
||||||
|
assert.equal(crowd.isArrangementSwitch('', ''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. the venue belongs to the player screen ──────────────────────────────
|
||||||
|
|
||||||
|
const scene = require('../../static/v3/venue-scene-3d.js');
|
||||||
|
|
||||||
|
// Venue MUST be the selected visualization for these to mean anything: if the
|
||||||
|
// viz were unset, shouldBeActive() would be false for the wrong reason and the
|
||||||
|
// virtuoso assertion below would pass vacuously. Force the viz on, so the only
|
||||||
|
// thing under test is the SCREEN gate.
|
||||||
|
function withScreen(id, fn) {
|
||||||
|
const prevDoc = global.document;
|
||||||
|
const prevViz = global.v3VenueViz;
|
||||||
|
global.v3VenueViz = {
|
||||||
|
isVenueVisualization: (v) => String(v) === 'venue',
|
||||||
|
getSelectedVizId: () => 'venue',
|
||||||
|
};
|
||||||
|
global.document = {
|
||||||
|
querySelector(sel) {
|
||||||
|
if (sel !== '.screen.active') return null;
|
||||||
|
return id ? { id } : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try { return fn(); } finally { global.document = prevDoc; global.v3VenueViz = prevViz; }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('guard: with Venue selected AND on the player, the venue IS active', () => {
|
||||||
|
// If this ever fails, every "not active" test below is vacuous.
|
||||||
|
withScreen('player', () => {
|
||||||
|
assert.equal(scene.shouldBeActive(), true,
|
||||||
|
'the screen gate must not break the normal case');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('venue is active on the player screen', () => {
|
||||||
|
withScreen('player', () => {
|
||||||
|
assert.equal(scene.isPlayerScreen(), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('venue is NOT active on the virtuoso screen (the bug)', () => {
|
||||||
|
withScreen('virtuoso', () => {
|
||||||
|
assert.equal(scene.isPlayerScreen(), false,
|
||||||
|
'Virtuoso borrows the same highway_3d renderer — the venue backdrop ' +
|
||||||
|
'must not follow it there');
|
||||||
|
assert.equal(scene.shouldBeActive(), false,
|
||||||
|
'selecting Venue is a preference for the PLAYER; it is not a licence ' +
|
||||||
|
'to paint the venue over whatever else is using the renderer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('venue is not active on any other screen either', () => {
|
||||||
|
for (const id of ['v3-home', 'plugin-folder_library', 'settings', 'career']) {
|
||||||
|
withScreen(id, () => {
|
||||||
|
assert.equal(scene.shouldBeActive(), false, `venue must not be active on ${id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no active screen at all is not the player', () => {
|
||||||
|
withScreen(null, () => {
|
||||||
|
assert.equal(scene.isPlayerScreen(), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a throwing document does not take the venue down with it', () => {
|
||||||
|
const prev = global.document;
|
||||||
|
global.document = { querySelector() { throw new Error('detached'); } };
|
||||||
|
try {
|
||||||
|
assert.equal(scene.isPlayerScreen(), false, 'must fail closed, not throw');
|
||||||
|
} finally {
|
||||||
|
global.document = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user