Files
feedBack/static/v3/venue-mood-fx.js
T
4b4c156fce refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (R3a) (#872)
Puts every external `<script>` in the v3 shell into the deferred queue, and
keeps each script's boot() firing at DOMContentLoaded exactly as it does today.
Behaviourally a no-op; it is what makes the ES-module flips safe.

WHY. `type="module"` defers execution to after HTML parse. Classic-`defer` and
module scripts share ONE "execute after parsing" list and run in DOCUMENT ORDER,
but a plain classic script runs DURING parse — ahead of all of them. So the
moment capabilities.js becomes a module while app.js is still plain, app.js runs
FIRST, and its 11 top-level `window.feedBack.on(...)` calls (app.js:6245-6722)
hit a bare `{}` — `_ensureFeedBackEventBus()` (capabilities.js:33), which
attaches .on/.emit/.off, would not have run yet. TypeError, app.js dies
mid-parse. Deferring everything now keeps document order == execution order
through the rest of the migration.

THE CATCH (Codex preflight caught this — a real ordering change). 22 scripts
guard their boot with `if (document.readyState === 'loading')`. A deferred
script runs at readyState 'interactive', so that test is FALSE and the else-branch
fires boot() immediately, at the script's position in document order — instead of
at DOMContentLoaded, after every script has evaluated.

That matters far more than one call site: a scan of the shell's scripts found
**43 forward references** where a script's boot() reads a global that a LATER
script defines (shell.js -> profile.js's window.v3Onboarding, songs.js ->
settings.js's window._confirmDialog, badges.js -> songs.js's
window.displayTuningName, ...). Every one of them resolves today only because
all boots happen at DOMContentLoaded. So the guards now treat 'interactive' as
not-ready (`!== 'complete'`), restoring that exactly.

Codex's specific finding (first-run onboarding silently skipped) did NOT
reproduce — shell.js's boot() awaits /api/profile, and that yield lets the
remaining deferred scripts run first. But the race it described is real, the
guard is silent when it fails (`&& window.v3Onboarding`), and the other 42
forward refs have no such await protecting them. Fixed at the root rather than
at the one site.

VERIFIED. A/B against origin/main on a fresh profile, 13 probes (onboarding
overlay, v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen,
bus, capabilities.version, createHighway, plugin scripts, mounted screens):
IDENTICAL, zero console/page errors on both. pytest 2396, node 1028/1028,
ESLint 0 errors, Codex 0.

New guard: test_every_external_script_defers_so_document_order_is_execution_order
fails if any external tag is plain classic — verified to fail on a single
reverted tag, so it actually bites.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:57:04 +02:00

523 lines
21 KiB
JavaScript

/*
* fee[dB]ack v0.3.0 — Venue Mood FX (visual-only reactive stage/crowd).
*
* Phase 1: bottom CSS crowd strip is disabled until real venue scene assets
* ship inside highway_3d. Venue visualization still maps to 3D Highway; a
* subtle full-screen wash + hint text only.
*
* Subscribes to v3:live-performance-state from the Live Performance HUD.
*/
(function (root) {
'use strict';
const KEY = 'feedBack-venue-mood-fx';
const MOTION_KEY = 'feedBack-venue-motion';
const SETTINGS = Object.freeze({ OFF: 'off', SUBTLE: 'subtle', FULL: 'full' });
const DEFAULT = SETTINGS.SUBTLE;
const MOTION_DEFAULT = SETTINGS.SUBTLE;
const VENUE_VIZ_ID = 'venue';
// Bottom blob/crowd DOM strip — off until asset-based 3D venue scene lands.
const STRIP_OVERLAY_ENABLED = false;
function isVenueVisualizationActive(vizMode) {
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
return root.v3VenueViz.isVenueVisualization(vizMode);
}
return String(vizMode || '') === VENUE_VIZ_ID;
}
const MOOD_SETTING_CLASSES = ['venue-mood-off', 'venue-mood-subtle', 'venue-mood-full'];
const MOOD_STATE_CLASSES = [
'venue-mood-state-idle',
'venue-mood-state-steady',
'venue-mood-state-strong',
'venue-mood-state-fire',
'venue-mood-state-recovery',
'venue-mood-state-smoke',
];
const MOOD_STATE_IDS = ['idle', 'steady', 'strong', 'fire', 'recovery', 'smoke'];
function normalizeVenueMoodSetting(value) {
if (value === SETTINGS.OFF || value === SETTINGS.FULL) return value;
if (value === SETTINGS.SUBTLE) return SETTINGS.SUBTLE;
return DEFAULT;
}
function normalizeVenueMotionSetting(value) {
return normalizeVenueMoodSetting(value);
}
function venueMotionProfile(mode) {
const m = normalizeVenueMotionSetting(mode);
if (m === SETTINGS.OFF) {
return Object.freeze({
breathe: 0, parallax: 0, hazeDrift: 0, warmthPulse: 0, shimmer: 0,
});
}
if (m === SETTINGS.FULL) {
return Object.freeze({
breathe: 0.014, parallax: 0.010, hazeDrift: 0.020, warmthPulse: 0.028, shimmer: 0.10,
});
}
return Object.freeze({
breathe: 0.005, parallax: 0.004, hazeDrift: 0.007, warmthPulse: 0.010, shimmer: 0.04,
});
}
function venueMotionIntensity(mode) {
const profile = venueMotionProfile(mode);
return profile.breathe + profile.parallax + profile.hazeDrift;
}
function prefersReducedMotion() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; }
}
function venueMoodClassForState(state) {
const s = String(state || 'idle').toLowerCase();
return MOOD_STATE_IDS.includes(s) ? 'venue-mood-state-' + s : 'venue-mood-state-idle';
}
function readVizMode() {
try {
if (root && root.v3VenueViz && typeof root.v3VenueViz.getSelectedVizId === 'function') {
return String(root.v3VenueViz.getSelectedVizId());
}
const sel = typeof document !== 'undefined' ? document.getElementById('viz-picker') : null;
if (sel && sel.value) return String(sel.value);
return localStorage.getItem('vizSelection') || 'default';
} catch (_) {
return 'default';
}
}
function isElementDisplayed(el) {
if (!el) return false;
try {
if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity) === 0) return false;
} else if (el.style && el.style.display === 'none') {
return false;
}
return true;
} catch (_) {
return false;
}
}
function hasVisible3dWrap() {
try {
if (typeof document === 'undefined') return false;
const wraps = document.querySelectorAll('.h3d-wrap[data-h3d-primary], .h3d-wrap');
for (let i = 0; i < wraps.length; i++) {
if (isElementDisplayed(wraps[i])) return true;
}
return false;
} catch (_) {
return false;
}
}
// Back-compat alias — only counts a wrap that is actually displayed.
function hasActive3dWrap() {
return hasVisible3dWrap();
}
function isSuppressedBy3d(setting, vizMode, has3dWrap) {
if (normalizeVenueMoodSetting(setting) === SETTINGS.OFF) return false;
if (isVenueVisualizationActive(vizMode)) return false;
const viz = String(vizMode || 'default');
if (viz === 'highway_3d') return true;
if (viz === 'auto' && has3dWrap) return true;
return false;
}
function shouldShowStripOverlay(setting, vizMode, has3dWrap, sessionActive) {
if (!STRIP_OVERLAY_ENABLED) return false;
if (!sessionActive) return false;
return shouldEnableVenueMood(setting, vizMode, has3dWrap);
}
function shouldEnableVenueMood(setting, vizMode, has3dWrap) {
if (normalizeVenueMoodSetting(setting) === SETTINGS.OFF) return false;
return !isSuppressedBy3d(setting, vizMode, has3dWrap);
}
function get() {
try { return normalizeVenueMoodSetting(localStorage.getItem(KEY)); } catch (_) { return DEFAULT; }
}
// True once the user (or a prior session) has persisted a mood preference.
// DEFAULT is 'subtle', so get() can't distinguish "never set" from an
// explicit 'subtle' — read the raw key to tell them apart.
function hasStoredMoodSetting() {
try { return localStorage.getItem(KEY) != null; } catch (_) { return false; }
}
function getMotion() {
try { return normalizeVenueMotionSetting(localStorage.getItem(MOTION_KEY)); } catch (_) { return MOTION_DEFAULT; }
}
function set(value) {
const next = normalizeVenueMoodSetting(value);
try { localStorage.setItem(KEY, next); } catch (_) { /* private mode / quota */ }
const sel = typeof document !== 'undefined' ? document.getElementById('venue-mood-fx-select') : null;
if (sel && sel.value !== next) sel.value = next;
return next;
}
function setMotion(value) {
const next = normalizeVenueMotionSetting(value);
try { localStorage.setItem(MOTION_KEY, next); } catch (_) { /* private mode / quota */ }
const sel = typeof document !== 'undefined' ? document.getElementById('venue-motion-select') : null;
if (sel && sel.value !== next) sel.value = next;
syncMotionToRenderer(next);
return next;
}
function syncMotionToRenderer(mode) {
const next = normalizeVenueMotionSetting(mode != null ? mode : getMotion());
const host = (typeof window !== 'undefined' && window)
|| (typeof globalThis !== 'undefined' && globalThis)
|| root;
if (host && typeof host.h3dVenueSceneSetMotionMode === 'function') {
host.h3dVenueSceneSetMotionMode(next);
}
}
function applyClasses(player, layer, setting, state, visible) {
if (!player || !player.classList) return;
MOOD_SETTING_CLASSES.forEach((c) => player.classList.remove(c));
MOOD_STATE_CLASSES.forEach((c) => player.classList.remove(c));
player.classList.add('venue-mood-' + normalizeVenueMoodSetting(setting));
player.classList.add(venueMoodClassForState(state));
if (layer && layer.classList) {
MOOD_STATE_CLASSES.forEach((c) => layer.classList.remove(c));
layer.classList.add(venueMoodClassForState(state));
if (visible) {
layer.classList.remove('hidden');
layer.setAttribute('aria-hidden', 'false');
} else {
layer.classList.add('hidden');
layer.setAttribute('aria-hidden', 'true');
}
}
}
let _runtime = null;
let _lastLoggedSetting = null;
function bindRuntime(sm, dom) {
if (!sm || typeof sm.on !== 'function') return null;
const player = dom && dom.player
? dom.player
: (typeof document !== 'undefined' ? document.getElementById('player') : null);
const layer = dom && dom.layer
? dom.layer
: (typeof document !== 'undefined' ? document.getElementById('v3-venue-mood-fx') : null);
const hint3d = dom && dom.hint3d
? dom.hint3d
: (typeof document !== 'undefined' ? document.getElementById('venue-mood-fx-3d-hint') : null);
const hintVenue = dom && dom.hintVenue
? dom.hintVenue
: (typeof document !== 'undefined' ? document.getElementById('venue-viz-mode-hint') : null);
const hintFailed = dom && dom.hintFailed
? dom.hintFailed
: (typeof document !== 'undefined' ? document.getElementById('venue-viz-load-failed-hint') : null);
const badge = dom && dom.badge
? dom.badge
: (typeof document !== 'undefined' ? document.getElementById('v3-venue-mode-badge') : null);
const sceneWash = dom && dom.sceneWash
? dom.sceneWash
: (typeof document !== 'undefined' ? document.getElementById('v3-venue-scene-wash') : null);
let sessionActive = false;
let currentState = 'idle';
function updateVizHints(vizMode) {
const isVenue = isVenueVisualizationActive(vizMode);
const loadFailed = !!(root && root.v3VenueScene3d && typeof root.v3VenueScene3d.getState === 'function' &&
root.v3VenueScene3d.getState().loadFailed);
const sceneLoaded = !!(root && root.v3VenueScene3d && typeof root.v3VenueScene3d.isSceneLoaded === 'function' &&
root.v3VenueScene3d.isSceneLoaded());
if (hintVenue && hintVenue.classList) {
if (isVenue && !loadFailed) hintVenue.classList.remove('hidden');
else hintVenue.classList.add('hidden');
}
if (hintFailed && hintFailed.classList) {
if (isVenue && loadFailed) hintFailed.classList.remove('hidden');
else hintFailed.classList.add('hidden');
}
if (hint3d && hint3d.classList) {
if (String(vizMode || '') === 'highway_3d') hint3d.classList.remove('hidden');
else hint3d.classList.add('hidden');
}
if (sceneLoaded || loadFailed) syncVenuePlaceholder(vizMode);
}
function syncVenueVizPlayerClass(vizMode) {
if (root && root.v3VenueViz && typeof root.v3VenueViz.syncPlayerVizClass === 'function') {
root.v3VenueViz.syncPlayerVizClass(vizMode);
} else if (player && player.classList) {
const on = isVenueVisualizationActive(vizMode);
if (typeof player.classList.toggle === 'function') {
player.classList.toggle('is-venue-visualization', on);
} else if (on) {
player.classList.add('is-venue-visualization');
} else {
player.classList.remove('is-venue-visualization');
}
}
}
function syncVenuePlaceholder(vizMode) {
const isVenue = isVenueVisualizationActive(vizMode);
const showDom = isVenue && !!(root && root.v3VenueScene3d &&
typeof root.v3VenueScene3d.shouldShowDomPlaceholder === 'function' &&
root.v3VenueScene3d.shouldShowDomPlaceholder());
if (badge && badge.classList) {
if (showDom) badge.classList.remove('hidden');
else badge.classList.add('hidden');
}
if (sceneWash && sceneWash.classList) {
if (showDom) sceneWash.classList.remove('hidden');
else sceneWash.classList.add('hidden');
}
}
function syncVenueSceneClass(vizMode) {
if (!player || !player.classList) return;
const pending = isVenueVisualizationActive(vizMode) && sessionActive && !STRIP_OVERLAY_ENABLED;
if (typeof player.classList.toggle === 'function') {
player.classList.toggle('venue-scene-pending', pending);
} else if (pending) {
player.classList.add('venue-scene-pending');
} else {
player.classList.remove('venue-scene-pending');
}
}
function refreshVisibility() {
const setting = get();
const vizMode = readVizMode();
const has3dWrap = hasVisible3dWrap();
const enabled = shouldEnableVenueMood(setting, vizMode, has3dWrap);
const showStrip = shouldShowStripOverlay(setting, vizMode, has3dWrap, sessionActive);
applyClasses(player, layer, setting, currentState, showStrip);
syncVenueVizPlayerClass(vizMode);
syncVenueSceneClass(vizMode);
syncVenuePlaceholder(vizMode);
updateVizHints(vizMode);
return showStrip;
}
function getState() {
const setting = get();
const vizMode = readVizMode();
const has3dWrap = hasVisible3dWrap();
const enabled = shouldEnableVenueMood(setting, vizMode, has3dWrap);
const showStrip = shouldShowStripOverlay(setting, vizMode, has3dWrap, sessionActive);
return {
setting,
vizMode,
has3dWrap,
enabled,
sessionActive,
visible: showStrip,
stripOverlayEnabled: STRIP_OVERLAY_ENABLED,
state: currentState,
suppressedBy3d: isSuppressedBy3d(setting, vizMode, has3dWrap),
isVenueVisualization: isVenueVisualizationActive(vizMode),
venueScenePending: isVenueVisualizationActive(vizMode) && sessionActive && !STRIP_OVERLAY_ENABLED,
};
}
function onPerformanceState(e) {
const d = (e && e.detail) || {};
if (!sessionActive) return;
const next = d.state || 'idle';
// This fires once per note hit/miss. When the mood state is unchanged
// (e.g. a run of hits all in 'fire'), refreshVisibility would recompute
// an identical result while forcing a style/layout recalc via
// hasVisible3dWrap()'s getComputedStyle loop — so bail on no-op events.
if (next === currentState) return;
currentState = next;
refreshVisibility();
}
function beginSession() {
sessionActive = true;
currentState = 'idle';
refreshVisibility();
}
function endSession() {
sessionActive = false;
currentState = 'idle';
refreshVisibility();
}
function onSettingChange(value) {
const next = set(value);
const st = getState();
if (next !== _lastLoggedSetting) {
_lastLoggedSetting = next;
console.info('[venue-mood] setting=' + st.setting
+ ' state=' + st.state
+ ' suppressed=' + st.suppressedBy3d
+ ' visible=' + st.visible);
}
refreshVisibility();
}
function bindMotionSelect() {
const sel = typeof document !== 'undefined' ? document.getElementById('venue-motion-select') : null;
if (!sel || sel.dataset.venueMotionBound === '1') return;
sel.dataset.venueMotionBound = '1';
sel.value = getMotion();
sel.addEventListener('change', () => { setMotion(sel.value); });
syncMotionToRenderer(sel.value);
}
function bindSelect() {
const sel = typeof document !== 'undefined' ? document.getElementById('venue-mood-fx-select') : null;
if (!sel || sel.dataset.venueMoodBound === '1') return;
sel.dataset.venueMoodBound = '1';
sel.value = get();
sel.addEventListener('change', () => { onSettingChange(sel.value); });
}
function bindVizPicker() {
const sel = typeof document !== 'undefined' ? document.getElementById('viz-picker') : null;
if (!sel || sel.dataset.venueMoodVizBound === '1') return;
sel.dataset.venueMoodVizBound = '1';
sel.addEventListener('change', () => {
// Default to FULL only the first time Venue is chosen; never
// clobber a preference the user already set (incl. 'subtle'/'off').
if (isVenueVisualizationActive(sel.value) && !hasStoredMoodSetting()) {
set(SETTINGS.FULL);
}
refreshVisibility();
});
}
sm.on('v3:live-performance-state', onPerformanceState);
sm.on('song:loading', beginSession);
sm.on('song:arrangement-changed', () => {
if (!sessionActive) return;
currentState = 'idle';
refreshVisibility();
});
sm.on('song:stop', endSession);
sm.on('song:ended', endSession);
sm.on('viz:renderer:ready', refreshVisibility);
sm.on('viz:reverted', refreshVisibility);
bindSelect();
bindMotionSelect();
applyClasses(player, layer, get(), 'idle', false);
refreshVisibility();
const runtime = {
getSetting: get,
setSetting: set,
refreshVisibility,
beginSession,
endSession,
onPerformanceState,
onSettingChange,
getSessionActive: () => sessionActive,
getCurrentState: () => currentState,
getState,
};
_runtime = runtime;
return runtime;
}
function onVenueVisualizationSelected() {
// Default the mood to FULL only on the first-ever Venue selection; once
// the user has a stored preference (incl. an explicit 'subtle' or 'off')
// re-entering Venue must not overwrite it.
if (!hasStoredMoodSetting()) set(SETTINGS.FULL);
if (_runtime && typeof _runtime.refreshVisibility === 'function') _runtime.refreshVisibility();
}
function getState() {
if (_runtime && typeof _runtime.getState === 'function') return _runtime.getState();
const setting = get();
const vizMode = readVizMode();
const has3dWrap = hasVisible3dWrap();
const enabled = shouldEnableVenueMood(setting, vizMode, has3dWrap);
return {
setting,
vizMode,
has3dWrap,
enabled,
sessionActive: false,
visible: false,
stripOverlayEnabled: STRIP_OVERLAY_ENABLED,
state: 'idle',
suppressedBy3d: isSuppressedBy3d(setting, vizMode, has3dWrap),
isVenueVisualization: isVenueVisualizationActive(vizMode),
venueScenePending: false,
};
}
const api = {
KEY,
MOTION_KEY,
SETTINGS,
DEFAULT,
MOTION_DEFAULT,
VENUE_VIZ_ID,
STRIP_OVERLAY_ENABLED,
MOOD_SETTING_CLASSES,
MOOD_STATE_CLASSES,
normalizeVenueMoodSetting,
normalizeVenueMotionSetting,
venueMotionProfile,
venueMotionIntensity,
prefersReducedMotion,
venueMoodClassForState,
readVizMode,
isVenueVisualizationActive,
isElementDisplayed,
hasVisible3dWrap,
hasActive3dWrap,
isSuppressedBy3d,
shouldEnableVenueMood,
shouldShowStripOverlay,
get,
getMotion,
set,
setMotion,
syncMotionToRenderer,
applyClasses,
bindRuntime,
onVenueVisualizationSelected,
getState,
};
if (root) root.v3VenueMoodFx = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
const boot = () => {
const sm = root && root.feedBack;
if (sm) bindRuntime(sm);
};
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}
}(typeof window !== 'undefined' ? window : null));