mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:54:29 +00:00
refactor(h3d-carve-5): move H-section player-chrome bg-control to src/bg-control.js
Extracted _pc* subsystem (420 lines) from screen.js IIFE into
src/bg-control.js using a factory DI pattern (createBgControl({...})).
Exports: createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe,
_bgUnsubscribe, getVenueSceneOverride })
→ { _pcAcquire, _pcRelease }
screen.js: const { _pcAcquire, _pcRelease } = createBgControl({...})
Beyond-subst (2):
1. Factory wrapper (IIFE-scope closure → DI params) — factory export pattern
required because DI values are IIFE-scope, not ES module imports.
2. _venueSceneOverride → getVenueSceneOverride() (live accessor, 1 call site
in _pcSync — mutable let at screen.js:1523 must be read per-call).
DI values all defined before the createBgControl call (screen.js):
- BG_STYLE_IDS: line 1435 | _bgReadGlobal: 1825
- _bgSubscribe/_bgUnsubscribe: 1917-18 | _venueSceneOverride: 1523
First _pcAcquire caller: init() in createFactory() (~line 14850 post-cut).
Construction order correct: createBgControl call before createFactory.
Surprise declared to god before commit (outbox/h3d-cut5-surprise.json):
No bc-panel.js dependency — §8's anticipation was wrong. The
_bcCreateController call at what was ~line 8082 is in _bcSyncMode
(P-section / factory scope), not the H-section. bg-control.js has ZERO
dependency on bc-panel.js.
Tests:
- tests/js/highway_3d_bg_control.test.js (new, 13 class-killers):
stranded-caller (test 13, factory-adapted from bc-panel test 12),
construction-order (tests 11-12), DI completeness (test 2),
live-accessor enforcement (test 3), lifecycle (tests 4-7).
- plugins/highway_3d/tests/background_control.test.js: retargeted from
screen.js slice → bg-control.js factory eval; all 20 existing behaviour
tests preserved (load() uses vm.createContext + augmented return getters).
- tests/js/highway_3d_panel_controls.test.js: createBgControl stub added.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
base 222/222 (f69c544) → tip 235/235 (+13: 13 new class-killers)
plugin.json: 3.40.0 → 3.41.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f69c544eea
commit
c4eebe1c9f
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.40.0",
|
||||
"version": "3.41.0",
|
||||
"type": "visualization",
|
||||
"scriptType": "module",
|
||||
"bundled": true,
|
||||
|
||||
+13
-417
@@ -10,6 +10,7 @@ import { geoFretX, dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, compute
|
||||
import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2
|
||||
import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount as _resolveStringCountBase, _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5, _BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8, _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning as _openStringPitchLabelsForTuningBase, _ssActive, _ssIsCanvasFocused } from './src/utils.js'; // h3d-carve-3
|
||||
import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4
|
||||
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -3055,424 +3056,19 @@ import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './
|
||||
let _nextInstanceId = 0;
|
||||
|
||||
/* ======================================================================
|
||||
* Player-chrome background control
|
||||
* ======================================================================
|
||||
* A Background picker mounted into the player's Plugins rail popover, so
|
||||
* the background can be switched MID-SONG without leaving for Settings.
|
||||
*
|
||||
* It writes through the SAME global setters settings.html uses
|
||||
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
|
||||
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
|
||||
* is persisted here, and the option list is generated from BG_STYLE_IDS —
|
||||
* add a style there and it shows up in both places automatically.
|
||||
*
|
||||
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
|
||||
* instances but these settings are global — a panel may set a per-panel
|
||||
* override, but this single shared control only ever reads/writes the
|
||||
* global slot (via _bgReadGlobal), so N copies would be N ways to set
|
||||
* one value. init() acquires, destroy() releases,
|
||||
* and the last release unmounts — so the control disappears when the user
|
||||
* switches to a non-3D renderer instead of lingering as a dead knob.
|
||||
*
|
||||
* Everything here is event-driven. No DOM work on a per-frame path.
|
||||
*/
|
||||
// Wording is kept verbatim in sync with settings.html's <option> text so
|
||||
// the same style is not named two different things in two UIs that sit
|
||||
// two clicks apart. An id with no entry here falls back to the raw id.
|
||||
const _PC_LABELS = {
|
||||
off: 'Off', particles: 'Particles (drifting)',
|
||||
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
|
||||
geometric: 'Geometric (rotating shapes)',
|
||||
butterchurn: 'Butterchurn (visualizer)',
|
||||
image: 'Custom image', video: 'Custom video',
|
||||
};
|
||||
// Which settings each background style actually consumes, so a control
|
||||
// that would do nothing is greyed out instead of lying.
|
||||
//
|
||||
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
|
||||
// build() reads settings.intensity, and uses `reactive` if its update()
|
||||
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
|
||||
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
|
||||
// drives its own audio tap and canvas opacity (only the fog-scenery half
|
||||
// falls through to BG_STYLES.off). So neither knob here reaches it - both
|
||||
// are false, and the tooltip points at Butterchurn's own controls.
|
||||
//
|
||||
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
|
||||
// and its row is not updated, the control stays greyed out and lies the
|
||||
// other way. An id missing from this table defaults to both-enabled, which
|
||||
// is the safe direction: a new style is assumed to use its settings.
|
||||
const _PC_USES = {
|
||||
off: { intensity: false, reactive: false, why: 'No background to adjust' },
|
||||
particles: { intensity: true, reactive: true },
|
||||
silhouettes: { intensity: true, reactive: true },
|
||||
lights: { intensity: true, reactive: true },
|
||||
geometric: { intensity: true, reactive: true },
|
||||
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
|
||||
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
|
||||
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
|
||||
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
|
||||
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
|
||||
// active it is the EFFECTIVE style, so both knobs drive nothing.
|
||||
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
|
||||
};
|
||||
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
|
||||
// Non-disabled wrappers around the two greyable controls. A native-disabled
|
||||
// <button>/<input> receives no pointer events, so its `title` tooltip never
|
||||
// shows on hover — the whole "greyed out, says why on hover" affordance
|
||||
// would be dead. The reason lives on these wrappers instead, and the
|
||||
// disabled control gets pointer-events:none so the hover reaches them.
|
||||
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
|
||||
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
|
||||
|
||||
// The player chrome exposes this slot once it has initialised. A host
|
||||
// that does not provide it gets no control (and no error) - the Settings
|
||||
// page remains the way in.
|
||||
function _pcSlot() {
|
||||
try {
|
||||
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
|
||||
// precedent). The playerControlSlot typeof check below already
|
||||
// covers the practical case - only v3 exposes it - but the
|
||||
// documented checklist asks plugins to detect v3 explicitly.
|
||||
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
|
||||
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
|
||||
return typeof fn === 'function' ? fn() : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
// Visual language: these controls sit in the player's Plugin Controls
|
||||
// popover alongside pills from other plugins (Invert, Split, Tuner, the
|
||||
// STEMS group...), so they follow the same look - small rounded pills,
|
||||
// dark fill, brighter on hover, tinted when active.
|
||||
//
|
||||
// Styled INLINE rather than with the Tailwind classes those plugins use
|
||||
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
|
||||
// compiled stylesheet and several of those utilities are not in it, so
|
||||
// using them would mean regenerating assets/plugin.css and bumping the
|
||||
// manifest version. The values below are the resolved tokens from
|
||||
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
|
||||
// #d1d5db), so the result matches without the build step.
|
||||
const _PC_C = {
|
||||
idle: '#181830', // bg-dark-600
|
||||
hover: '#1e1e3a', // bg-dark-500
|
||||
text: '#d1d5db', // text-gray-300
|
||||
textDim: '#6b7280', // text-gray-500 (inert controls)
|
||||
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
|
||||
onText: '#86efac', // text-green-300
|
||||
};
|
||||
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
|
||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||
+ 'transition:background-color .15s,color .15s;';
|
||||
function _pcPill(label, title) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
if (title) b.title = title;
|
||||
b.style.cssText = _PC_PILL;
|
||||
// Hover is a pseudo-class we cannot express inline; these two
|
||||
// listeners reproduce hover:bg-dark-500 for non-active pills only
|
||||
// (an active pill keeps its tint on hover, as the other plugins do).
|
||||
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
|
||||
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
|
||||
return b;
|
||||
}
|
||||
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
|
||||
// when the active background style ignores the setting entirely (see
|
||||
// _pcSync and _PC_USES) - the pill stays visible so the layout
|
||||
// does not jump, but it is inert and says why on hover.
|
||||
function _pcPaint(btn, on, disabled, reason) {
|
||||
btn._on = !!on && !disabled;
|
||||
btn.disabled = !!disabled;
|
||||
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
||||
// A toggle button must expose its state, not just its label.
|
||||
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
|
||||
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
|
||||
// which carries the reason a disabled button's own title can't show.
|
||||
btn.style.pointerEvents = disabled ? 'none' : '';
|
||||
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
|
||||
btn.style.opacity = disabled ? '.45' : '1';
|
||||
btn.title = reason || 'React to the audio';
|
||||
if (disabled) {
|
||||
btn.style.backgroundColor = _PC_C.idle;
|
||||
btn.style.color = _PC_C.textDim;
|
||||
return;
|
||||
}
|
||||
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
|
||||
btn.style.color = on ? _PC_C.onText : _PC_C.text;
|
||||
}
|
||||
function _pcGroupLabel(text) {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = text;
|
||||
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
|
||||
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
|
||||
return el;
|
||||
}
|
||||
// Pull every control back to what is actually stored. Runs on mount and
|
||||
// whenever the settings bus reports one of our keys changed, so editing
|
||||
// from the Settings page updates this control and vice-versa.
|
||||
function _pcSync() {
|
||||
// The active style is the EFFECTIVE one, not the stored one: while the
|
||||
// Venue scene override is on it is what's mounted, and it ignores the
|
||||
// whole Background group - picking a style writes `style` but
|
||||
// _bgMountStyle resolves back to venue, so the dropdown would look
|
||||
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
|
||||
// and the user exits Venue from the visualization picker where they
|
||||
// entered it. An unknown id enables everything rather than disabling
|
||||
// it, so a style added without a _PC_USES row is merely unhelpful.
|
||||
const venue = !!_venueSceneOverride;
|
||||
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
|
||||
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
|
||||
const why = uses.why || 'This background style ignores this setting';
|
||||
if (_pcReason) _pcReason.textContent = why;
|
||||
// Point a screen reader at the reason, but only while a control is
|
||||
// inert - cleared otherwise so an enabled control is not described by a
|
||||
// stale reason.
|
||||
const _pcDescribe = (el, inert) => {
|
||||
if (!el) return;
|
||||
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
|
||||
else el.removeAttribute('aria-describedby');
|
||||
};
|
||||
_pcDescribe(_pcSel, venue);
|
||||
_pcDescribe(_pcReactive, !uses.reactive);
|
||||
_pcDescribe(_pcIntensity, !uses.intensity);
|
||||
if (_pcSel) {
|
||||
// The custom slots stay unselectable until something is uploaded -
|
||||
// same rule settings.html applies.
|
||||
const img = _pcSel.querySelector('option[value="image"]');
|
||||
const vid = _pcSel.querySelector('option[value="video"]');
|
||||
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
|
||||
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
|
||||
_pcSel.value = _bgReadGlobal('style');
|
||||
// The dropdown still SHOWS the stored style (venue has no option),
|
||||
// but it's inert while Venue owns the scene.
|
||||
_pcSel.disabled = venue;
|
||||
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
|
||||
_pcSel.style.opacity = venue ? '.45' : '1';
|
||||
_pcSel.style.cursor = venue ? 'not-allowed' : '';
|
||||
// Restore the base tooltip when Venue exits — blanking it would
|
||||
// permanently drop the mount-time 'Background style' hint. Matches
|
||||
// how the intensity slider and Reactive pill restore theirs.
|
||||
_pcSel.title = venue ? why : 'Background style';
|
||||
}
|
||||
if (_pcReactive) {
|
||||
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
|
||||
uses.reactive ? 'React to the audio' : why);
|
||||
}
|
||||
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
|
||||
// enabled so the control's own title takes over.
|
||||
if (_pcReactiveWrap) {
|
||||
_pcReactiveWrap.title = uses.reactive ? '' : why;
|
||||
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
|
||||
}
|
||||
if (_pcIntensity) {
|
||||
_pcIntensity.value = String(_bgReadGlobal('intensity'));
|
||||
_pcIntensity.disabled = !uses.intensity;
|
||||
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
|
||||
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
|
||||
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
|
||||
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
|
||||
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
|
||||
}
|
||||
if (_pcIntensityWrap) {
|
||||
_pcIntensityWrap.title = uses.intensity ? '' : why;
|
||||
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
|
||||
}
|
||||
}
|
||||
// Mirror the current values into the Settings panel's controls when it's
|
||||
// in the DOM.
|
||||
//
|
||||
// settings.html hydrates ONCE from localStorage when the panel is injected
|
||||
// and never subscribes to the settings bus, so before this existed there
|
||||
// was only one writer and it could not go stale. Adding the in-player
|
||||
// picker made a second writer, and the panel had no way to hear about it —
|
||||
// change the style mid-song and Settings would still show the old value.
|
||||
//
|
||||
// Assigning .value / .checked programmatically does NOT fire a 'change'
|
||||
// event, so this cannot loop back into the setters.
|
||||
function _pcSyncSettingsPanel() {
|
||||
try {
|
||||
const st = document.getElementById('h3d-bg-style');
|
||||
if (st) st.value = _bgReadGlobal('style');
|
||||
const re = document.getElementById('h3d-bg-reactive');
|
||||
if (re) re.checked = !!_bgReadGlobal('reactive');
|
||||
const inten = _bgReadGlobal('intensity');
|
||||
const ie = document.getElementById('h3d-bg-intensity');
|
||||
if (ie) ie.value = String(inten);
|
||||
// The panel prints the numeric value beside the slider; keep its
|
||||
// formatting identical to settings.html's own hydration.
|
||||
const il = document.getElementById('h3d-bg-intensity-label');
|
||||
if (il) il.textContent = Number(inten).toFixed(2);
|
||||
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
|
||||
}
|
||||
function _pcMount() {
|
||||
// A screen change can swap the popover out from under us, orphaning
|
||||
// the control. Re-resolve only when the cached node is actually gone.
|
||||
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
|
||||
if (_pcEl) return true;
|
||||
const slot = _pcSlot();
|
||||
if (!slot) return false;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'h3d-pc';
|
||||
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
|
||||
// Visually-hidden text carrying the "why greyed out" reason to screen
|
||||
// readers; disabled controls point aria-describedby here. A title alone
|
||||
// is announced unreliably and never on touch. One span suffices - every
|
||||
// greyed control shares the same reason (derived from the single
|
||||
// effective style).
|
||||
_pcReason = document.createElement('span');
|
||||
_pcReason.id = 'h3d-pc-reason';
|
||||
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
|
||||
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
|
||||
box.appendChild(_pcReason);
|
||||
|
||||
box.appendChild(_pcGroupLabel('Background'));
|
||||
// A dropdown, not pills: the style list is 8 entries and growing, and
|
||||
// a pill per style dominated a popover whose other controls are single
|
||||
// toggles. Styled to match the surrounding pills rather than left as a
|
||||
// raw <select>.
|
||||
_pcSel = document.createElement('select');
|
||||
_pcSel.title = 'Background style';
|
||||
_pcSel.setAttribute('aria-label', 'Background style');
|
||||
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
|
||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
|
||||
for (const id of BG_STYLE_IDS) {
|
||||
const o = document.createElement('option');
|
||||
o.value = id;
|
||||
o.textContent = _PC_LABELS[id] || id;
|
||||
_pcSel.appendChild(o);
|
||||
}
|
||||
_pcSel.addEventListener('change', () => {
|
||||
if (_pcSel.disabled) return; // inert under the Venue override
|
||||
try { window.h3dBgSetStyle(_pcSel.value); }
|
||||
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
|
||||
* Player-chrome background control — moved to src/bg-control.js (h3d-carve-5)
|
||||
* _pcAcquire and _pcRelease are destructured from createBgControl() below.
|
||||
* DI: BG_STYLE_IDS (1435), _bgReadGlobal (1825), _bgSubscribe/_bgUnsubscribe (1917-18),
|
||||
* getVenueSceneOverride ← _venueSceneOverride (1523). All defined before this call.
|
||||
* First _pcAcquire caller: init() in createFactory() below (~line 15198 pre-cut).
|
||||
* ====================================================================== */
|
||||
const { _pcAcquire, _pcRelease } = createBgControl({
|
||||
BG_STYLE_IDS,
|
||||
_bgReadGlobal,
|
||||
_bgSubscribe,
|
||||
_bgUnsubscribe,
|
||||
getVenueSceneOverride: () => _venueSceneOverride,
|
||||
});
|
||||
box.appendChild(_pcSel);
|
||||
const optWrap = document.createElement('div');
|
||||
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
|
||||
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
|
||||
_pcReactive = _pcPill('Reactive', 'React to the audio');
|
||||
_pcReactive.addEventListener('click', () => {
|
||||
if (_pcReactive.disabled) return;
|
||||
try { window.h3dBgSetReactive(!_pcReactive._on); }
|
||||
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
|
||||
});
|
||||
optWrap.appendChild(_pcReactive);
|
||||
box.appendChild(optWrap);
|
||||
|
||||
box.appendChild(_pcGroupLabel('Intensity'));
|
||||
// Wrapper carries the reason on hover when the slider is disabled — a
|
||||
// native-disabled <input> shows no title of its own.
|
||||
_pcIntensityWrap = document.createElement('div');
|
||||
_pcIntensityWrap.style.cssText = 'width:100%;';
|
||||
_pcIntensity = document.createElement('input');
|
||||
_pcIntensity.type = 'range';
|
||||
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
|
||||
_pcIntensity.title = 'Background intensity';
|
||||
_pcIntensity.setAttribute('aria-label', 'Background intensity');
|
||||
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
|
||||
// 'change' (fires on release), NOT 'input'. Every write goes through
|
||||
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
|
||||
// background style down and re-runs build(). On 'input' a single drag
|
||||
// across the range would trigger ~20 full scene rebuilds on the main
|
||||
// thread mid-playback. settings.html's slider makes the same choice:
|
||||
// oninput only repaints its label, onchange calls the setter.
|
||||
_pcIntensity.addEventListener('change', () => {
|
||||
if (_pcIntensity.disabled) return;
|
||||
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
|
||||
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
|
||||
});
|
||||
_pcIntensityWrap.appendChild(_pcIntensity);
|
||||
box.appendChild(_pcIntensityWrap);
|
||||
slot.appendChild(box);
|
||||
_pcEl = box;
|
||||
_pcSync();
|
||||
_pcListener = (key) => {
|
||||
if (key === 'style' || key === 'reactive' || key === 'intensity'
|
||||
|| key === 'customImageDataUrl' || key === 'customVideoName'
|
||||
|| key === 'venueScene') {
|
||||
// 'venueScene' has no dropdown/settings widget of its own, but
|
||||
// toggling Venue changes the EFFECTIVE style, so the greying
|
||||
// must re-evaluate (see _pcSync's effectiveStyle).
|
||||
_pcSync();
|
||||
_pcSyncSettingsPanel();
|
||||
}
|
||||
};
|
||||
_bgSubscribe(_pcListener);
|
||||
return true;
|
||||
}
|
||||
function _pcTeardownDom() {
|
||||
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
|
||||
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
|
||||
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
|
||||
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
|
||||
}
|
||||
function _pcAcquire() {
|
||||
_pcRefs++;
|
||||
_pcBindScreenHook();
|
||||
if (_pcMount()) return;
|
||||
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
|
||||
// the renderer is viable inside the v3 player chrome, and player-chrome.js
|
||||
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
|
||||
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
|
||||
// spinning it out to the ~3s budget for a slot that will never appear.
|
||||
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
|
||||
// The rail popover may not be built yet on a cold load. Retry a few
|
||||
// times, then give up quietly — Settings still works.
|
||||
if (_pcRetryTimer) return;
|
||||
_pcRetry = 0;
|
||||
const tick = () => {
|
||||
_pcRetryTimer = 0;
|
||||
if (_pcRefs <= 0) return; // renderer went away mid-retry
|
||||
// Re-attempt the bus subscription too, not just the mount. On a cold
|
||||
// load the renderer can init before window.feedBack.on exists; the
|
||||
// first _pcBindScreenHook() then no-ops and, without this, the hook
|
||||
// never binds and the control goes permanently deaf to screen
|
||||
// changes. Idempotent via the _pcScreenHook guard.
|
||||
_pcBindScreenHook();
|
||||
if (_pcMount()) return;
|
||||
if (++_pcRetry > 12) return; // ~3s at 250ms
|
||||
_pcRetryTimer = setTimeout(tick, 250);
|
||||
};
|
||||
_pcRetryTimer = setTimeout(tick, 250);
|
||||
}
|
||||
// Re-mount after the player chrome is rebuilt.
|
||||
//
|
||||
// _pcMount's isConnected check can only run when something calls it, and
|
||||
// after the first successful mount nothing did - init() and the retry tick
|
||||
// are the only callers, and the tick stops on success. So a popover that
|
||||
// got swapped out left the control gone until the next song change. This
|
||||
// listener gives that check a real trigger.
|
||||
//
|
||||
// Event-driven and cheap: one _pcMount() call per screen change, and it
|
||||
// early-returns immediately when the cached node is still connected.
|
||||
let _pcScreenHook = null;
|
||||
function _pcBindScreenHook() {
|
||||
if (_pcScreenHook) return;
|
||||
const bus = window.feedBack;
|
||||
if (!bus || typeof bus.on !== 'function') return;
|
||||
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
|
||||
try { bus.on('screen:changed', _pcScreenHook); }
|
||||
catch (e) { _pcScreenHook = null; }
|
||||
}
|
||||
function _pcRelease() {
|
||||
_pcRefs = Math.max(0, _pcRefs - 1);
|
||||
if (_pcRefs > 0) return;
|
||||
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
|
||||
// Drop the screen:changed subscription too, not just the DOM. The
|
||||
// refcount guard inside the hook makes a stale one harmless, but the
|
||||
// listener and its closure would otherwise outlive the control for the
|
||||
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
|
||||
// again, binding another hook to the same bus while the old one stays.
|
||||
// _pcBindScreenHook re-binds on the next acquire.
|
||||
if (_pcScreenHook) {
|
||||
try {
|
||||
const bus = window.feedBack;
|
||||
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
|
||||
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
|
||||
_pcScreenHook = null;
|
||||
}
|
||||
_pcTeardownDom();
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Factory — feedBack#36 setRenderer contract
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
// h3d-carve-5: player-chrome background control
|
||||
//
|
||||
// Verbatim move of the H-section from screen.js (_pc* symbols, lines 3057-3476
|
||||
// pre-cut). IIFE-scope dependencies injected via factory DI so the module has
|
||||
// no side-effects at import time.
|
||||
//
|
||||
// Beyond-subst (2):
|
||||
// 1. Factory wrapper — closure vars become DI params.
|
||||
// 2. `_venueSceneOverride` → `getVenueSceneOverride()` (live accessor, 1 call
|
||||
// site in _pcSync at what was screen.js:3220).
|
||||
//
|
||||
// screen.js usage:
|
||||
// import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
||||
// const { _pcAcquire, _pcRelease } = createBgControl({
|
||||
// BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,
|
||||
// getVenueSceneOverride: () => _venueSceneOverride,
|
||||
// });
|
||||
|
||||
export function createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe, getVenueSceneOverride }) {
|
||||
/* ======================================================================
|
||||
* Player-chrome background control
|
||||
* ======================================================================
|
||||
* A Background picker mounted into the player's Plugins rail popover, so
|
||||
* the background can be switched MID-SONG without leaving for Settings.
|
||||
*
|
||||
* It writes through the SAME global setters settings.html uses
|
||||
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
|
||||
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
|
||||
* is persisted here, and the option list is generated from BG_STYLE_IDS —
|
||||
* add a style there and it shows up in both places automatically.
|
||||
*
|
||||
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
|
||||
* instances but these settings are global — a panel may set a per-panel
|
||||
* override, but this single shared control only ever reads/writes the
|
||||
* global slot (via _bgReadGlobal), so N copies would be N ways to set
|
||||
* one value. init() acquires, destroy() releases,
|
||||
* and the last release unmounts — so the control disappears when the user
|
||||
* switches to a non-3D renderer instead of lingering as a dead knob.
|
||||
*
|
||||
* Everything here is event-driven. No DOM work on a per-frame path.
|
||||
*/
|
||||
// Wording is kept verbatim in sync with settings.html's <option> text so
|
||||
// the same style is not named two different things in two UIs that sit
|
||||
// two clicks apart. An id with no entry here falls back to the raw id.
|
||||
const _PC_LABELS = {
|
||||
off: 'Off', particles: 'Particles (drifting)',
|
||||
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
|
||||
geometric: 'Geometric (rotating shapes)',
|
||||
butterchurn: 'Butterchurn (visualizer)',
|
||||
image: 'Custom image', video: 'Custom video',
|
||||
};
|
||||
// Which settings each background style actually consumes, so a control
|
||||
// that would do nothing is greyed out instead of lying.
|
||||
//
|
||||
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
|
||||
// build() reads settings.intensity, and uses `reactive` if its update()
|
||||
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
|
||||
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
|
||||
// drives its own audio tap and canvas opacity (only the fog-scenery half
|
||||
// falls through to BG_STYLES.off). So neither knob here reaches it - both
|
||||
// are false, and the tooltip points at Butterchurn's own controls.
|
||||
//
|
||||
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
|
||||
// and its row is not updated, the control stays greyed out and lies the
|
||||
// other way. An id missing from this table defaults to both-enabled, which
|
||||
// is the safe direction: a new style is assumed to use its settings.
|
||||
const _PC_USES = {
|
||||
off: { intensity: false, reactive: false, why: 'No background to adjust' },
|
||||
particles: { intensity: true, reactive: true },
|
||||
silhouettes: { intensity: true, reactive: true },
|
||||
lights: { intensity: true, reactive: true },
|
||||
geometric: { intensity: true, reactive: true },
|
||||
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
|
||||
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
|
||||
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
|
||||
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
|
||||
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
|
||||
// active it is the EFFECTIVE style, so both knobs drive nothing.
|
||||
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
|
||||
};
|
||||
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
|
||||
// Non-disabled wrappers around the two greyable controls. A native-disabled
|
||||
// <button>/<input> receives no pointer events, so its `title` tooltip never
|
||||
// shows on hover — the whole "greyed out, says why on hover" affordance
|
||||
// would be dead. The reason lives on these wrappers instead, and the
|
||||
// disabled control gets pointer-events:none so the hover reaches them.
|
||||
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
|
||||
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
|
||||
|
||||
// The player chrome exposes this slot once it has initialised. A host
|
||||
// that does not provide it gets no control (and no error) - the Settings
|
||||
// page remains the way in.
|
||||
function _pcSlot() {
|
||||
try {
|
||||
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
|
||||
// precedent). The playerControlSlot typeof check below already
|
||||
// covers the practical case - only v3 exposes it - but the
|
||||
// documented checklist asks plugins to detect v3 explicitly.
|
||||
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
|
||||
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
|
||||
return typeof fn === 'function' ? fn() : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
// Visual language: these controls sit in the player's Plugin Controls
|
||||
// popover alongside pills from other plugins (Invert, Split, Tuner, the
|
||||
// STEMS group...), so they follow the same look - small rounded pills,
|
||||
// dark fill, brighter on hover, tinted when active.
|
||||
//
|
||||
// Styled INLINE rather than with the Tailwind classes those plugins use
|
||||
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
|
||||
// compiled stylesheet and several of those utilities are not in it, so
|
||||
// using them would mean regenerating assets/plugin.css and bumping the
|
||||
// manifest version. The values below are the resolved tokens from
|
||||
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
|
||||
// #d1d5db), so the result matches without the build step.
|
||||
const _PC_C = {
|
||||
idle: '#181830', // bg-dark-600
|
||||
hover: '#1e1e3a', // bg-dark-500
|
||||
text: '#d1d5db', // text-gray-300
|
||||
textDim: '#6b7280', // text-gray-500 (inert controls)
|
||||
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
|
||||
onText: '#86efac', // text-green-300
|
||||
};
|
||||
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
|
||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||
+ 'transition:background-color .15s,color .15s;';
|
||||
function _pcPill(label, title) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
if (title) b.title = title;
|
||||
b.style.cssText = _PC_PILL;
|
||||
// Hover is a pseudo-class we cannot express inline; these two
|
||||
// listeners reproduce hover:bg-dark-500 for non-active pills only
|
||||
// (an active pill keeps its tint on hover, as the other plugins do).
|
||||
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
|
||||
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
|
||||
return b;
|
||||
}
|
||||
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
|
||||
// when the active background style ignores the setting entirely (see
|
||||
// _pcSync and _PC_USES) - the pill stays visible so the layout
|
||||
// does not jump, but it is inert and says why on hover.
|
||||
function _pcPaint(btn, on, disabled, reason) {
|
||||
btn._on = !!on && !disabled;
|
||||
btn.disabled = !!disabled;
|
||||
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
||||
// A toggle button must expose its state, not just its label.
|
||||
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
|
||||
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
|
||||
// which carries the reason a disabled button's own title can't show.
|
||||
btn.style.pointerEvents = disabled ? 'none' : '';
|
||||
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
|
||||
btn.style.opacity = disabled ? '.45' : '1';
|
||||
btn.title = reason || 'React to the audio';
|
||||
if (disabled) {
|
||||
btn.style.backgroundColor = _PC_C.idle;
|
||||
btn.style.color = _PC_C.textDim;
|
||||
return;
|
||||
}
|
||||
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
|
||||
btn.style.color = on ? _PC_C.onText : _PC_C.text;
|
||||
}
|
||||
function _pcGroupLabel(text) {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = text;
|
||||
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
|
||||
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
|
||||
return el;
|
||||
}
|
||||
// Pull every control back to what is actually stored. Runs on mount and
|
||||
// whenever the settings bus reports one of our keys changed, so editing
|
||||
// from the Settings page updates this control and vice-versa.
|
||||
function _pcSync() {
|
||||
// The active style is the EFFECTIVE one, not the stored one: while the
|
||||
// Venue scene override is on it is what's mounted, and it ignores the
|
||||
// whole Background group - picking a style writes `style` but
|
||||
// _bgMountStyle resolves back to venue, so the dropdown would look
|
||||
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
|
||||
// and the user exits Venue from the visualization picker where they
|
||||
// entered it. An unknown id enables everything rather than disabling
|
||||
// it, so a style added without a _PC_USES row is merely unhelpful.
|
||||
const venue = !!getVenueSceneOverride(); // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride() // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride()
|
||||
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
|
||||
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
|
||||
const why = uses.why || 'This background style ignores this setting';
|
||||
if (_pcReason) _pcReason.textContent = why;
|
||||
// Point a screen reader at the reason, but only while a control is
|
||||
// inert - cleared otherwise so an enabled control is not described by a
|
||||
// stale reason.
|
||||
const _pcDescribe = (el, inert) => {
|
||||
if (!el) return;
|
||||
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
|
||||
else el.removeAttribute('aria-describedby');
|
||||
};
|
||||
_pcDescribe(_pcSel, venue);
|
||||
_pcDescribe(_pcReactive, !uses.reactive);
|
||||
_pcDescribe(_pcIntensity, !uses.intensity);
|
||||
if (_pcSel) {
|
||||
// The custom slots stay unselectable until something is uploaded -
|
||||
// same rule settings.html applies.
|
||||
const img = _pcSel.querySelector('option[value="image"]');
|
||||
const vid = _pcSel.querySelector('option[value="video"]');
|
||||
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
|
||||
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
|
||||
_pcSel.value = _bgReadGlobal('style');
|
||||
// The dropdown still SHOWS the stored style (venue has no option),
|
||||
// but it's inert while Venue owns the scene.
|
||||
_pcSel.disabled = venue;
|
||||
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
|
||||
_pcSel.style.opacity = venue ? '.45' : '1';
|
||||
_pcSel.style.cursor = venue ? 'not-allowed' : '';
|
||||
// Restore the base tooltip when Venue exits — blanking it would
|
||||
// permanently drop the mount-time 'Background style' hint. Matches
|
||||
// how the intensity slider and Reactive pill restore theirs.
|
||||
_pcSel.title = venue ? why : 'Background style';
|
||||
}
|
||||
if (_pcReactive) {
|
||||
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
|
||||
uses.reactive ? 'React to the audio' : why);
|
||||
}
|
||||
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
|
||||
// enabled so the control's own title takes over.
|
||||
if (_pcReactiveWrap) {
|
||||
_pcReactiveWrap.title = uses.reactive ? '' : why;
|
||||
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
|
||||
}
|
||||
if (_pcIntensity) {
|
||||
_pcIntensity.value = String(_bgReadGlobal('intensity'));
|
||||
_pcIntensity.disabled = !uses.intensity;
|
||||
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
|
||||
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
|
||||
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
|
||||
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
|
||||
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
|
||||
}
|
||||
if (_pcIntensityWrap) {
|
||||
_pcIntensityWrap.title = uses.intensity ? '' : why;
|
||||
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
|
||||
}
|
||||
}
|
||||
// Mirror the current values into the Settings panel's controls when it's
|
||||
// in the DOM.
|
||||
//
|
||||
// settings.html hydrates ONCE from localStorage when the panel is injected
|
||||
// and never subscribes to the settings bus, so before this existed there
|
||||
// was only one writer and it could not go stale. Adding the in-player
|
||||
// picker made a second writer, and the panel had no way to hear about it —
|
||||
// change the style mid-song and Settings would still show the old value.
|
||||
//
|
||||
// Assigning .value / .checked programmatically does NOT fire a 'change'
|
||||
// event, so this cannot loop back into the setters.
|
||||
function _pcSyncSettingsPanel() {
|
||||
try {
|
||||
const st = document.getElementById('h3d-bg-style');
|
||||
if (st) st.value = _bgReadGlobal('style');
|
||||
const re = document.getElementById('h3d-bg-reactive');
|
||||
if (re) re.checked = !!_bgReadGlobal('reactive');
|
||||
const inten = _bgReadGlobal('intensity');
|
||||
const ie = document.getElementById('h3d-bg-intensity');
|
||||
if (ie) ie.value = String(inten);
|
||||
// The panel prints the numeric value beside the slider; keep its
|
||||
// formatting identical to settings.html's own hydration.
|
||||
const il = document.getElementById('h3d-bg-intensity-label');
|
||||
if (il) il.textContent = Number(inten).toFixed(2);
|
||||
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
|
||||
}
|
||||
function _pcMount() {
|
||||
// A screen change can swap the popover out from under us, orphaning
|
||||
// the control. Re-resolve only when the cached node is actually gone.
|
||||
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
|
||||
if (_pcEl) return true;
|
||||
const slot = _pcSlot();
|
||||
if (!slot) return false;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'h3d-pc';
|
||||
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
|
||||
// Visually-hidden text carrying the "why greyed out" reason to screen
|
||||
// readers; disabled controls point aria-describedby here. A title alone
|
||||
// is announced unreliably and never on touch. One span suffices - every
|
||||
// greyed control shares the same reason (derived from the single
|
||||
// effective style).
|
||||
_pcReason = document.createElement('span');
|
||||
_pcReason.id = 'h3d-pc-reason';
|
||||
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
|
||||
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
|
||||
box.appendChild(_pcReason);
|
||||
|
||||
box.appendChild(_pcGroupLabel('Background'));
|
||||
// A dropdown, not pills: the style list is 8 entries and growing, and
|
||||
// a pill per style dominated a popover whose other controls are single
|
||||
// toggles. Styled to match the surrounding pills rather than left as a
|
||||
// raw <select>.
|
||||
_pcSel = document.createElement('select');
|
||||
_pcSel.title = 'Background style';
|
||||
_pcSel.setAttribute('aria-label', 'Background style');
|
||||
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
|
||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
|
||||
for (const id of BG_STYLE_IDS) {
|
||||
const o = document.createElement('option');
|
||||
o.value = id;
|
||||
o.textContent = _PC_LABELS[id] || id;
|
||||
_pcSel.appendChild(o);
|
||||
}
|
||||
_pcSel.addEventListener('change', () => {
|
||||
if (_pcSel.disabled) return; // inert under the Venue override
|
||||
try { window.h3dBgSetStyle(_pcSel.value); }
|
||||
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
|
||||
});
|
||||
box.appendChild(_pcSel);
|
||||
const optWrap = document.createElement('div');
|
||||
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
|
||||
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
|
||||
_pcReactive = _pcPill('Reactive', 'React to the audio');
|
||||
_pcReactive.addEventListener('click', () => {
|
||||
if (_pcReactive.disabled) return;
|
||||
try { window.h3dBgSetReactive(!_pcReactive._on); }
|
||||
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
|
||||
});
|
||||
optWrap.appendChild(_pcReactive);
|
||||
box.appendChild(optWrap);
|
||||
|
||||
box.appendChild(_pcGroupLabel('Intensity'));
|
||||
// Wrapper carries the reason on hover when the slider is disabled — a
|
||||
// native-disabled <input> shows no title of its own.
|
||||
_pcIntensityWrap = document.createElement('div');
|
||||
_pcIntensityWrap.style.cssText = 'width:100%;';
|
||||
_pcIntensity = document.createElement('input');
|
||||
_pcIntensity.type = 'range';
|
||||
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
|
||||
_pcIntensity.title = 'Background intensity';
|
||||
_pcIntensity.setAttribute('aria-label', 'Background intensity');
|
||||
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
|
||||
// 'change' (fires on release), NOT 'input'. Every write goes through
|
||||
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
|
||||
// background style down and re-runs build(). On 'input' a single drag
|
||||
// across the range would trigger ~20 full scene rebuilds on the main
|
||||
// thread mid-playback. settings.html's slider makes the same choice:
|
||||
// oninput only repaints its label, onchange calls the setter.
|
||||
_pcIntensity.addEventListener('change', () => {
|
||||
if (_pcIntensity.disabled) return;
|
||||
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
|
||||
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
|
||||
});
|
||||
_pcIntensityWrap.appendChild(_pcIntensity);
|
||||
box.appendChild(_pcIntensityWrap);
|
||||
slot.appendChild(box);
|
||||
_pcEl = box;
|
||||
_pcSync();
|
||||
_pcListener = (key) => {
|
||||
if (key === 'style' || key === 'reactive' || key === 'intensity'
|
||||
|| key === 'customImageDataUrl' || key === 'customVideoName'
|
||||
|| key === 'venueScene') {
|
||||
// 'venueScene' has no dropdown/settings widget of its own, but
|
||||
// toggling Venue changes the EFFECTIVE style, so the greying
|
||||
// must re-evaluate (see _pcSync's effectiveStyle).
|
||||
_pcSync();
|
||||
_pcSyncSettingsPanel();
|
||||
}
|
||||
};
|
||||
_bgSubscribe(_pcListener);
|
||||
return true;
|
||||
}
|
||||
function _pcTeardownDom() {
|
||||
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
|
||||
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
|
||||
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
|
||||
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
|
||||
}
|
||||
function _pcAcquire() {
|
||||
_pcRefs++;
|
||||
_pcBindScreenHook();
|
||||
if (_pcMount()) return;
|
||||
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
|
||||
// the renderer is viable inside the v3 player chrome, and player-chrome.js
|
||||
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
|
||||
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
|
||||
// spinning it out to the ~3s budget for a slot that will never appear.
|
||||
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
|
||||
// The rail popover may not be built yet on a cold load. Retry a few
|
||||
// times, then give up quietly — Settings still works.
|
||||
if (_pcRetryTimer) return;
|
||||
_pcRetry = 0;
|
||||
const tick = () => {
|
||||
_pcRetryTimer = 0;
|
||||
if (_pcRefs <= 0) return; // renderer went away mid-retry
|
||||
// Re-attempt the bus subscription too, not just the mount. On a cold
|
||||
// load the renderer can init before window.feedBack.on exists; the
|
||||
// first _pcBindScreenHook() then no-ops and, without this, the hook
|
||||
// never binds and the control goes permanently deaf to screen
|
||||
// changes. Idempotent via the _pcScreenHook guard.
|
||||
_pcBindScreenHook();
|
||||
if (_pcMount()) return;
|
||||
if (++_pcRetry > 12) return; // ~3s at 250ms
|
||||
_pcRetryTimer = setTimeout(tick, 250);
|
||||
};
|
||||
_pcRetryTimer = setTimeout(tick, 250);
|
||||
}
|
||||
// Re-mount after the player chrome is rebuilt.
|
||||
//
|
||||
// _pcMount's isConnected check can only run when something calls it, and
|
||||
// after the first successful mount nothing did - init() and the retry tick
|
||||
// are the only callers, and the tick stops on success. So a popover that
|
||||
// got swapped out left the control gone until the next song change. This
|
||||
// listener gives that check a real trigger.
|
||||
//
|
||||
// Event-driven and cheap: one _pcMount() call per screen change, and it
|
||||
// early-returns immediately when the cached node is still connected.
|
||||
let _pcScreenHook = null;
|
||||
function _pcBindScreenHook() {
|
||||
if (_pcScreenHook) return;
|
||||
const bus = window.feedBack;
|
||||
if (!bus || typeof bus.on !== 'function') return;
|
||||
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
|
||||
try { bus.on('screen:changed', _pcScreenHook); }
|
||||
catch (e) { _pcScreenHook = null; }
|
||||
}
|
||||
function _pcRelease() {
|
||||
_pcRefs = Math.max(0, _pcRefs - 1);
|
||||
if (_pcRefs > 0) return;
|
||||
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
|
||||
// Drop the screen:changed subscription too, not just the DOM. The
|
||||
// refcount guard inside the hook makes a stale one harmless, but the
|
||||
// listener and its closure would otherwise outlive the control for the
|
||||
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
|
||||
// again, binding another hook to the same bus while the old one stays.
|
||||
// _pcBindScreenHook re-binds on the next acquire.
|
||||
if (_pcScreenHook) {
|
||||
try {
|
||||
const bus = window.feedBack;
|
||||
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
|
||||
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
|
||||
_pcScreenHook = null;
|
||||
}
|
||||
_pcTeardownDom();
|
||||
}
|
||||
|
||||
return { _pcAcquire, _pcRelease };
|
||||
}
|
||||
@@ -15,11 +15,10 @@
|
||||
// style reads `intensity`, and none of them read audio bands under
|
||||
// Butterchurn, so a live-looking knob that does nothing is a real bug.
|
||||
//
|
||||
// screen.js is a single ~16k-line IIFE, so the control cannot be imported. The
|
||||
// self-contained `_pc*` block is sliced out of the real source and evaluated
|
||||
// with its few collaborators stubbed (BG_STYLE_IDS, _bgReadSetting,
|
||||
// _bgSubscribe/_bgUnsubscribe). The slice markers are asserted before use: move
|
||||
// or rename the block and this fails loudly rather than testing nothing.
|
||||
// h3d-carve-5: the _pc* block was moved from screen.js to src/bg-control.js.
|
||||
// load() now evaluates the factory module (stripping the `export` keyword),
|
||||
// calls createBgControl({DI}) with stubbed deps, and injects test-only getters
|
||||
// into the return object so the private state vars remain observable.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
@@ -28,9 +27,7 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
|
||||
const START = ' const _PC_LABELS = {';
|
||||
const END_CRLF = ' /* ======================================================================\r\n * Factory';
|
||||
const END_LF = ' /* ======================================================================\n * Factory';
|
||||
const BG_CONTROL_JS = path.join(__dirname, '..', 'src', 'bg-control.js');
|
||||
|
||||
// What each style is expected to consume, derived by reading the BG_STYLES
|
||||
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
|
||||
@@ -102,14 +99,24 @@ function makeDom() {
|
||||
}
|
||||
|
||||
function load({ store: initialStore } = {}) {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const start = src.indexOf(START);
|
||||
assert.notEqual(start, -1, 'could not find the _PC_LABELS marker in screen.js');
|
||||
let end = src.indexOf(END_CRLF);
|
||||
if (end === -1) end = src.indexOf(END_LF);
|
||||
assert.notEqual(end, -1, 'could not find the Factory banner marker in screen.js');
|
||||
assert.ok(end > start, 'slice markers found out of order in screen.js');
|
||||
const block = src.slice(start, end);
|
||||
// h3d-carve-5: load from src/bg-control.js (factory module) instead of
|
||||
// slicing screen.js. Strip `export` for vm eval; inject test-only getters
|
||||
// into the return object so private _pc* state vars remain observable.
|
||||
const bgSrc = fs.readFileSync(BG_CONTROL_JS, 'utf8');
|
||||
const stripped = bgSrc.replace(/^export\s+/gm, '');
|
||||
// Augment the factory's return with accessor getters for private state so
|
||||
// all existing test assertions (api.el, api.sel, api.refs, ...) keep working.
|
||||
const instrumented = stripped.replace(
|
||||
/return\s*\{\s*_pcAcquire\s*,\s*_pcRelease\s*\}/,
|
||||
'return { _pcAcquire, _pcRelease,'
|
||||
+ ' get el() { return _pcEl; },'
|
||||
+ ' get sel() { return _pcSel; },'
|
||||
+ ' get react() { return _pcReactive; },'
|
||||
+ ' get intens() { return _pcIntensity; },'
|
||||
+ ' get reason() { return _pcReason; },'
|
||||
+ ' get refs() { return _pcRefs; } }',
|
||||
);
|
||||
assert.notEqual(instrumented, stripped, 'return-augmentation anchor not found in bg-control.js');
|
||||
|
||||
const dom = makeDom();
|
||||
const store = Object.assign({
|
||||
@@ -126,14 +133,12 @@ function load({ store: initialStore } = {}) {
|
||||
const writes = [];
|
||||
const timers = [];
|
||||
|
||||
// DI dependencies as vm-globals. Tests mutate sandbox._venueSceneOverride
|
||||
// directly; getVenueSceneOverride in the factory call reads it via the vm global.
|
||||
const sandbox = {
|
||||
console,
|
||||
BG_STYLE_IDS,
|
||||
// Module-scope in screen.js; the _pc* block reads it to resolve the
|
||||
// effective style under the Venue override. Tests flip it via
|
||||
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
|
||||
_venueSceneOverride: false,
|
||||
_bgReadSetting: (_panelKey, key) => store[key],
|
||||
_bgReadGlobal: (key) => store[key],
|
||||
_bgSubscribe: (fn) => listeners.add(fn),
|
||||
_bgUnsubscribe: (fn) => listeners.delete(fn),
|
||||
@@ -165,18 +170,18 @@ function load({ store: initialStore } = {}) {
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
|
||||
const api = vm.runInNewContext(
|
||||
block
|
||||
+ '\n({ _pcAcquire, _pcRelease,'
|
||||
+ ' get el() { return _pcEl; },'
|
||||
+ ' get sel() { return _pcSel; },'
|
||||
+ ' get react() { return _pcReactive; },'
|
||||
+ ' get intens() { return _pcIntensity; },'
|
||||
+ ' get reason() { return _pcReason; },'
|
||||
+ ' get refs() { return _pcRefs; } })',
|
||||
// Step 1: define createBgControl in the vm context.
|
||||
vm.runInContext(instrumented, sandbox);
|
||||
|
||||
// Step 2: call the factory; DI values are vm-globals so the call names them directly.
|
||||
const api = vm.runInContext(
|
||||
'createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,'
|
||||
+ ' getVenueSceneOverride: () => _venueSceneOverride })',
|
||||
sandbox,
|
||||
);
|
||||
|
||||
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
|
||||
const screenHooks = () => (bus['screen:changed'] || []).length;
|
||||
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Class-killer tests for src/bg-control.js — h3d-carve-5.
|
||||
//
|
||||
// bg-control.js uses a factory export (createBgControl({DI})) because its
|
||||
// dependencies are IIFE-scope values that cannot be ES-module imports.
|
||||
// screen.js destructures { _pcAcquire, _pcRelease } from the factory result.
|
||||
//
|
||||
// Test strategy:
|
||||
// - Source-scan tests check structural invariants (critical paths, DI wiring,
|
||||
// accessor call site, tombstone).
|
||||
// - Screen.js wiring tests check the import clause and destructure form.
|
||||
// - Generic stranded-caller test (adapted from bc-panel.js test 12) checks
|
||||
// that every _pc* symbol in the bg-control.js factory return is also in
|
||||
// screen.js's createBgControl destructure — a bare _pcFoo reference in the
|
||||
// IIFE that isn't in the destructure is the same stranded-caller bug class.
|
||||
// - Construction-order test: createBgControl call must appear AFTER all DI
|
||||
// definitions in screen.js and BEFORE createFactory.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const BG_CONTROL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bg-control.js');
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
let _src;
|
||||
function src() { if (!_src) _src = fs.readFileSync(BG_CONTROL_JS, 'utf8'); return _src; }
|
||||
|
||||
let _screenSrc;
|
||||
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
|
||||
|
||||
// ── 1. createBgControl is exported (not private) ──────────────────────────────
|
||||
|
||||
test('createBgControl is exported from bg-control.js', () => {
|
||||
// Mutation: remove `export` → screen.js import throws SyntaxError /
|
||||
// "does not provide an export" at module-graph load time → highway never
|
||||
// initialises; all 3D-Hwy users see a blank canvas.
|
||||
assert.match(src(), /^export\s+function\s+createBgControl\s*\(/m,
|
||||
'createBgControl must be a line-start export function declaration');
|
||||
});
|
||||
|
||||
// ── 2. DI params declared (all five) ─────────────────────────────────────────
|
||||
|
||||
test('createBgControl destructures all five DI params', () => {
|
||||
// Mutation: remove one DI param → that function is `undefined` inside the
|
||||
// factory → every call to e.g. _bgReadGlobal throws TypeError: not a function.
|
||||
const s = src();
|
||||
const sig = s.match(/export\s+function\s+createBgControl\s*\(\s*\{([^}]+)\}/);
|
||||
assert.ok(sig, 'createBgControl signature must use destructuring params');
|
||||
const params = sig[1];
|
||||
assert.match(params, /BG_STYLE_IDS/, 'DI must include BG_STYLE_IDS');
|
||||
assert.match(params, /_bgReadGlobal/, 'DI must include _bgReadGlobal');
|
||||
assert.match(params, /_bgSubscribe/, 'DI must include _bgSubscribe');
|
||||
assert.match(params, /_bgUnsubscribe/, 'DI must include _bgUnsubscribe');
|
||||
assert.match(params, /getVenueSceneOverride/, 'DI must include getVenueSceneOverride');
|
||||
});
|
||||
|
||||
// ── 3. getVenueSceneOverride() called as function (not captured at construction) ──
|
||||
|
||||
test('_pcSync calls getVenueSceneOverride() not _venueSceneOverride directly', () => {
|
||||
// Mutation: revert beyond-subst 2 to `!!_venueSceneOverride` → factory
|
||||
// captures the initial `false` at construction time; the accessor is never
|
||||
// called; Venue-active state is always `false` → UI never goes inert under
|
||||
// Venue; user can "pick" a background while Venue scene is active but the
|
||||
// pick goes nowhere because Venue owns the mount.
|
||||
const s = src();
|
||||
// Must call the accessor (with parens).
|
||||
assert.match(s, /getVenueSceneOverride\(\)/,
|
||||
'_pcSync must call getVenueSceneOverride() rather than capturing the var at construction');
|
||||
// Must NOT contain the raw closure variable name in executable code (bare, without call
|
||||
// parens). Strip line comments first so the comment-doc in the file header doesn't fire.
|
||||
const noLineComments = s.replace(/\/\/[^\n]*/g, '');
|
||||
assert.doesNotMatch(noLineComments, /\b_venueSceneOverride\b/,
|
||||
'bg-control.js must not reference bare _venueSceneOverride in code — use getVenueSceneOverride()');
|
||||
});
|
||||
|
||||
// ── 4. _bgSubscribe called inside _pcMount ────────────────────────────────────
|
||||
|
||||
test('_bgSubscribe is called inside _pcMount to register the settings listener', () => {
|
||||
// Mutation: remove _bgSubscribe call → control mounts but never receives
|
||||
// settings-bus events; a style change from Settings page never syncs back
|
||||
// to the in-player picker; the two UIs drift permanently.
|
||||
const s = src();
|
||||
const mountIdx = s.indexOf('function _pcMount()');
|
||||
assert.ok(mountIdx >= 0, '_pcMount must be defined in bg-control.js');
|
||||
const mountBlock = s.slice(mountIdx, mountIdx + 6000); // _bgSubscribe ~5200 chars in
|
||||
assert.match(mountBlock, /_bgSubscribe\s*\(/,
|
||||
'_bgSubscribe must be called inside _pcMount to register the listener');
|
||||
});
|
||||
|
||||
// ── 5. _bgUnsubscribe called inside _pcTeardownDom ───────────────────────────
|
||||
|
||||
test('_bgUnsubscribe is called inside _pcTeardownDom to deregister the listener', () => {
|
||||
// Mutation: remove _bgUnsubscribe call → listener closure outlives the control;
|
||||
// after release the stale closure still calls _pcSync on every settings change;
|
||||
// null refs (_pcSel etc.) throw on first setting write post-teardown.
|
||||
const s = src();
|
||||
const teardownIdx = s.indexOf('function _pcTeardownDom()');
|
||||
assert.ok(teardownIdx >= 0, '_pcTeardownDom must be defined in bg-control.js');
|
||||
const teardownBlock = s.slice(teardownIdx, teardownIdx + 500);
|
||||
assert.match(teardownBlock, /_bgUnsubscribe\s*\(/,
|
||||
'_bgUnsubscribe must be called inside _pcTeardownDom to remove the listener');
|
||||
});
|
||||
|
||||
// ── 6. _pcRelease calls _pcTeardownDom ───────────────────────────────────────
|
||||
|
||||
test('_pcRelease calls _pcTeardownDom when refcount reaches zero', () => {
|
||||
// Mutation: remove _pcTeardownDom() call from _pcRelease → DOM node is
|
||||
// never removed; the settings listener stays alive; under splitscreen each
|
||||
// renderer destroys independently but the control never disappears → orphaned
|
||||
// picker remains visible and partially interactive after 3D-Hwy is deselected.
|
||||
const s = src();
|
||||
const releaseIdx = s.indexOf('function _pcRelease()');
|
||||
assert.ok(releaseIdx >= 0, '_pcRelease must be defined in bg-control.js');
|
||||
const releaseBlock = s.slice(releaseIdx, releaseIdx + 1200); // _pcTeardownDom ~1040 chars in
|
||||
assert.match(releaseBlock, /_pcTeardownDom\s*\(\s*\)/,
|
||||
'_pcRelease must call _pcTeardownDom() when refcount reaches zero');
|
||||
});
|
||||
|
||||
// ── 7. _pcAcquire and _pcRelease returned from factory ───────────────────────
|
||||
|
||||
test('createBgControl returns { _pcAcquire, _pcRelease }', () => {
|
||||
// Mutation: remove either from return → screen.js destructure gets undefined;
|
||||
// first call to _pcAcquire / _pcRelease from init()/destroy() throws
|
||||
// TypeError: not a function → highway init crashes on every song load.
|
||||
const s = src();
|
||||
const returnMatch = s.match(/return\s*\{([^}]+)\}/);
|
||||
assert.ok(returnMatch, 'createBgControl must have a return { ... } statement');
|
||||
const returned = returnMatch[1];
|
||||
assert.match(returned, /_pcAcquire/, 'createBgControl must return _pcAcquire');
|
||||
assert.match(returned, /_pcRelease/, 'createBgControl must return _pcRelease');
|
||||
});
|
||||
|
||||
// ── 8. screen.js imports createBgControl from src/bg-control.js ──────────────
|
||||
|
||||
test('screen.js imports createBgControl from src/bg-control.js', () => {
|
||||
// Mutation: remove import → createBgControl is undefined in the IIFE;
|
||||
// the destructure const { _pcAcquire, _pcRelease } = createBgControl({...})
|
||||
// throws TypeError at module eval time → plugin never loads.
|
||||
assert.match(screenSrc(),
|
||||
/import\s+\{[^}]*createBgControl[^}]*\}\s+from\s+['"]\.\/src\/bg-control\.js['"]/,
|
||||
'screen.js must import createBgControl from ./src/bg-control.js');
|
||||
});
|
||||
|
||||
// ── 9. screen.js calls createBgControl with all five DI args ─────────────────
|
||||
|
||||
test('screen.js passes all five DI arguments to createBgControl', () => {
|
||||
// Mutation: omit one DI arg → the corresponding param is `undefined` inside
|
||||
// the factory closure; first call to it (on mount, on settings change) throws.
|
||||
const s = screenSrc();
|
||||
const callMatch = s.match(/createBgControl\s*\(\s*\{([^}]+)\}/);
|
||||
assert.ok(callMatch, 'screen.js must call createBgControl({...})');
|
||||
const args = callMatch[1];
|
||||
assert.match(args, /BG_STYLE_IDS/, 'createBgControl call must pass BG_STYLE_IDS');
|
||||
assert.match(args, /_bgReadGlobal/, 'createBgControl call must pass _bgReadGlobal');
|
||||
assert.match(args, /_bgSubscribe/, 'createBgControl call must pass _bgSubscribe');
|
||||
assert.match(args, /_bgUnsubscribe/, 'createBgControl call must pass _bgUnsubscribe');
|
||||
assert.match(args, /getVenueSceneOverride/, 'createBgControl call must pass getVenueSceneOverride');
|
||||
});
|
||||
|
||||
// ── 10. screen.js IIFE does not redefine _pcAcquire or _pcRelease ────────────
|
||||
|
||||
test('screen.js IIFE does not redeclare _pcAcquire or _pcRelease', () => {
|
||||
// Mutation: re-add `function _pcAcquire()` to the IIFE → IIFE-scope function
|
||||
// shadows the destructured import; the factory's _pcRelease holds a stale
|
||||
// closure over the old _pcRefs; refcount goes out of sync; the control
|
||||
// never unmounts.
|
||||
const s = screenSrc();
|
||||
const iife = s.replace(/^import\s+.*\n/gm, '');
|
||||
assert.doesNotMatch(iife, /function\s+_pcAcquire\s*\(/,
|
||||
'IIFE must not redeclare _pcAcquire');
|
||||
assert.doesNotMatch(iife, /function\s+_pcRelease\s*\(/,
|
||||
'IIFE must not redeclare _pcRelease');
|
||||
});
|
||||
|
||||
// ── 11. Construction order: createBgControl called before createFactory ───────
|
||||
|
||||
test('createBgControl call appears before createFactory in screen.js', () => {
|
||||
// Mutation: move createBgControl call inside createFactory → each renderer
|
||||
// instance gets its own independent control (refcount broken across instances);
|
||||
// or if moved after createFactory but before register, correct for
|
||||
// single-instance but still wrong order risk. This test ensures the call
|
||||
// stays at module scope BEFORE the factory.
|
||||
const s = screenSrc();
|
||||
const bgCallIdx = s.indexOf('createBgControl(');
|
||||
const factoryIdx = s.indexOf('function createFactory()');
|
||||
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
|
||||
assert.ok(factoryIdx >= 0, 'createFactory must exist in screen.js');
|
||||
assert.ok(bgCallIdx < factoryIdx,
|
||||
'createBgControl must be called before createFactory in screen.js');
|
||||
});
|
||||
|
||||
// ── 12. Construction order: DI values defined before createBgControl call ─────
|
||||
|
||||
test('all DI values are defined before the createBgControl call in screen.js', () => {
|
||||
// Mutation: move createBgControl call before BG_STYLE_IDS / _bgReadGlobal /
|
||||
// _bgSubscribe / _bgUnsubscribe / getVenueSceneOverride binding →
|
||||
// undefined passed as DI params; factory closure captures undefined → TypeError.
|
||||
const s = screenSrc();
|
||||
const bgCallIdx = s.indexOf('createBgControl(');
|
||||
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
|
||||
const bgStyleIdsIdx = s.indexOf('BG_STYLE_IDS =');
|
||||
const bgReadIdx = s.indexOf('function _bgReadGlobal(');
|
||||
const bgSubIdx = s.indexOf('function _bgSubscribe(');
|
||||
const venueIdx = s.indexOf('let _venueSceneOverride');
|
||||
assert.ok(bgStyleIdsIdx < bgCallIdx, 'BG_STYLE_IDS must be defined before createBgControl call');
|
||||
assert.ok(bgReadIdx < bgCallIdx, '_bgReadGlobal must be defined before createBgControl call');
|
||||
assert.ok(bgSubIdx < bgCallIdx, '_bgSubscribe must be defined before createBgControl call');
|
||||
assert.ok(venueIdx < bgCallIdx, '_venueSceneOverride must be defined before createBgControl call');
|
||||
});
|
||||
|
||||
// ── 13. Generic stranded-caller: _pc* returned symbols in screen.js destructure ─
|
||||
|
||||
test('every _pc* symbol returned by createBgControl is in the screen.js destructure', () => {
|
||||
// Adapted from bc-panel.js test 12 for the factory pattern.
|
||||
// For a factory module, the stranded-caller class is: a symbol that appears
|
||||
// in the `return { ... }` of createBgControl but is NOT in the `const { ... }
|
||||
// = createBgControl(...)` destructure in screen.js — meaning the symbol is
|
||||
// exported at runtime but screen.js never binds it, so any IIFE caller of
|
||||
// that symbol hits ReferenceError (or the undefined stub from a stale
|
||||
// function-scope redeclaration).
|
||||
//
|
||||
// Mutation: add `_pcNewFn` to bg-control.js return but not to screen.js
|
||||
// destructure → leaked is non-empty → test RED.
|
||||
const bgSrc = src();
|
||||
const scrSrc = screenSrc();
|
||||
|
||||
// Symbols in the return { ... } of createBgControl.
|
||||
const returnMatch = bgSrc.match(/return\s*\{([^}]+)\}/);
|
||||
assert.ok(returnMatch, 'bg-control.js must have a return { ... } statement');
|
||||
const returned = new Set(
|
||||
returnMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
|
||||
);
|
||||
|
||||
// Symbols in the screen.js destructure.
|
||||
const destructureMatch = scrSrc.match(/const\s*\{\s*([^}]+)\}\s*=\s*createBgControl\s*\(/);
|
||||
assert.ok(destructureMatch, 'screen.js must destructure the createBgControl result');
|
||||
const destructured = new Set(
|
||||
destructureMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
|
||||
);
|
||||
|
||||
// Strip import lines and line comments from IIFE body to avoid false positives.
|
||||
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
|
||||
const noComments = noImports.replace(/\/\/[^\n]*/g, '');
|
||||
|
||||
// Returned symbols referenced in the IIFE body but absent from the destructure.
|
||||
const leaked = [...returned].filter(
|
||||
sym => new RegExp('\\b' + sym + '\\b').test(noComments) && !destructured.has(sym)
|
||||
);
|
||||
assert.deepStrictEqual(leaked, [],
|
||||
'screen.js references createBgControl return symbols not in its destructure: ' +
|
||||
leaked.join(', '));
|
||||
});
|
||||
@@ -100,6 +100,10 @@ function loadHighway3dStatics() {
|
||||
reconnectAudio() { return false; }, chart() {}, tint() {}, render() {},
|
||||
resize() {}, destroy() {},
|
||||
}),
|
||||
// h3d-carve-5: player-chrome bg-control moved to src/bg-control.js.
|
||||
createBgControl: () => ({
|
||||
_pcAcquire() {}, _pcRelease() {},
|
||||
}),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
||||
|
||||
Reference in New Issue
Block a user