mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
ship-ci / ci (push) Waiting to run
Tester, mid-gig: "the second song in the gig started when the first one ended. But it showed the flyover intro again." The flyover is arriving at the venue, and you arrive once. #968 stopped it replaying on an arrangement SWITCH (same filename), but a gig's song 2 is a genuinely different file, so it took the full-teardown path and played the arrival flyover again — the camera flew in from the back of the room before every track of the set. The play queue now answers isContinuation(): false for the first song of a set (or a standalone play — an arrival), true for song 2..N. onSongLoaded carries the room over to the new song's loop on a continuation, and only a real arrival plays the intro. Verified on the built AppImage: isContinuation goes false (song 1) -> true (song 2) across an advance, and song 2 no longer flies in. Also confirmed NOT a bug, same session: "didn't show the author for the second song." The credits card shows on a queue advance whenever the song carries authors — reproduced with a song that has them as the advanced-to track. The tester's song 2 simply had no `authors:` metadata (most auto-converted feedpaks don't). No code change. Tests: isContinuation across start/advance/clear, and that onSongLoaded gates the flyover on the continuation check. Both fail on pre-fix source. JS 1214/1214.
724 lines
29 KiB
JavaScript
724 lines
29 KiB
JavaScript
/*
|
|
* fee[dB]ack — Venue crowd video layer (career mode PR1).
|
|
*
|
|
* Crossfades pre-rendered crowd-state loop videos behind the highway based on
|
|
* v3:live-performance-state, plus one-shot reaction stingers. Renders through
|
|
* two video backdrop planes owned by the highway_3d venue background style
|
|
* (window.h3dVenueBackdropSetVideo / window.h3dVenueBackdropSetMix).
|
|
*
|
|
* Inert unless a venue pack manifest is set — by the career plugin via
|
|
* v3VenueCrowd.setManifest(), or (dev only) a JSON manifest in localStorage
|
|
* under feedBack-venue-crowd-dev. With no manifest the static bg plate
|
|
* behaves exactly as before.
|
|
*/
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
// live-performance-hud state → crowd state.
|
|
const CROWD_OF_PERF = {
|
|
smoke: 'bored',
|
|
recovery: 'bored',
|
|
idle: 'neutral',
|
|
steady: 'neutral',
|
|
strong: 'engaged',
|
|
fire: 'ecstatic',
|
|
};
|
|
const CROWD_STATES = ['bored', 'neutral', 'engaged', 'ecstatic'];
|
|
const CROWD_RANK = { bored: 0, neutral: 1, engaged: 2, ecstatic: 3 };
|
|
|
|
const STABLE_MS = 3000; // target must hold this long before a switch
|
|
const DWELL_MS = 8000; // min time between committed switches
|
|
const FADE_MS = 1200; // loop crossfade
|
|
const STINGER_FADE_MS = 400; // stinger fade-in/out
|
|
const STINGER_MIN_GAP_MS = 20000;
|
|
const STREAK_MILESTONES = [25, 50, 100];
|
|
const CANPLAY_TIMEOUT_MS = 4000;
|
|
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
|
|
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Pure, clock-injected decision logic (unit-tested in
|
|
// tests/js/venue_crowd.test.js — keep DOM-free).
|
|
// ---------------------------------------------------------------------
|
|
|
|
function crowdStateOfPerf(perfState) {
|
|
return CROWD_OF_PERF[String(perfState || '').toLowerCase()] || 'neutral';
|
|
}
|
|
|
|
// Hysteresis: a new target must be observed continuously for STABLE_MS,
|
|
// and at least DWELL_MS must have passed since the last committed switch.
|
|
function createCrowdMachine() {
|
|
let current = 'neutral';
|
|
let candidate = null;
|
|
let candidateSince = 0;
|
|
let lastSwitchAt = -Infinity;
|
|
return {
|
|
get current() { return current; },
|
|
reset() {
|
|
current = "neutral";
|
|
candidate = null;
|
|
lastSwitchAt = -Infinity;
|
|
},
|
|
// Commit a state NOW, bypassing stability/dwell (badge ceremony).
|
|
// Stamping lastSwitchAt makes the dwell window hold the forced
|
|
// state before the real perf machine can reassert.
|
|
force(state, nowMs) {
|
|
if (!CROWD_STATES.includes(state)) return;
|
|
current = state;
|
|
candidate = null;
|
|
lastSwitchAt = nowMs;
|
|
},
|
|
// Feed the latest perf state; returns the new crowd state when a
|
|
// transition commits, else null.
|
|
update(perfState, nowMs) {
|
|
const target = crowdStateOfPerf(perfState);
|
|
if (target === current) {
|
|
candidate = null;
|
|
return null;
|
|
}
|
|
if (target !== candidate) {
|
|
candidate = target;
|
|
candidateSince = nowMs;
|
|
return null;
|
|
}
|
|
if (nowMs - candidateSince < STABLE_MS) return null;
|
|
if (nowMs - lastSwitchAt < DWELL_MS) return null;
|
|
current = target;
|
|
candidate = null;
|
|
lastSwitchAt = nowMs;
|
|
return current;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Cheer when the streak crosses a milestone (rising edge only).
|
|
function stingerForStreak(prevStreak, streak) {
|
|
for (const m of STREAK_MILESTONES) {
|
|
if (prevStreak < m && streak >= m) return 'cheer';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// End-of-song reaction from final accuracy.
|
|
function stingerForAccuracy(accuracyPct) {
|
|
const a = Number(accuracyPct);
|
|
if (!Number.isFinite(a)) return null;
|
|
if (a >= 90) return 'cheer';
|
|
if (a >= 75) return 'clap';
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Video layer controller (browser only).
|
|
// ---------------------------------------------------------------------
|
|
|
|
const machine = createCrowdMachine();
|
|
let _manifest = null; // { loops: {state: url}, stingers: {name: url} }
|
|
let _venueActive = false;
|
|
let _videos = [null, null];
|
|
let _activeLayer = 0; // layer currently showing the loop
|
|
let _mix = 0; // 0 → layer0 visible, 1 → layer1 visible
|
|
let _fadeRaf = 0;
|
|
let _stopGen = 0; // bumped by stop(): invalidates ALL in-flight loads
|
|
let _boundToRenderer = false;
|
|
let _pendingLoop = null; // loop switch deferred by an active stinger
|
|
let _loadingLoop = null; // loop currently waiting on canplaythrough
|
|
let _fadingLoop = null; // loop currently crossfading in (not yet active)
|
|
let _stingerUntilEnded = false;
|
|
let _stingerGen = 0; // identity for ended/timeout handlers
|
|
let _introActive = false;
|
|
let _introGen = 0;
|
|
let _audioEl = null; // crowd ambience during the intro flyover
|
|
let _audioFadeTimer = 0;
|
|
let _lastStingerAt = -Infinity;
|
|
let _prevStreak = 0;
|
|
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
|
// Filename of the song song:loaded last reported. An arrangement switch
|
|
// re-emits song:loaded for the SAME file (changeArrangement reloads through
|
|
// the normal load path), and that must not be mistaken for arriving at the
|
|
// venue with a new song — see onSongLoaded.
|
|
let _lastSongFile = '';
|
|
let _bound = false;
|
|
|
|
function now() { return Date.now(); }
|
|
|
|
function h3d(name) {
|
|
return root && typeof root[name] === 'function' ? root[name] : null;
|
|
}
|
|
|
|
function normalizeManifest(m) {
|
|
if (!m || typeof m !== 'object' || !m.loops) return null;
|
|
const base = typeof m.base === 'string' ? m.base : '';
|
|
const abs = (u) => (typeof u === 'string' && u ? base + u : '');
|
|
const loops = {};
|
|
for (const s of CROWD_STATES) loops[s] = abs(m.loops[s]);
|
|
if (!CROWD_STATES.every((s) => loops[s])) return null;
|
|
const stingers = {};
|
|
for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]);
|
|
const intro = {
|
|
video: abs(m.intro && m.intro.video),
|
|
audio: abs(m.intro && m.intro.audio),
|
|
};
|
|
const sfx = {
|
|
up: abs(m.sfx && m.sfx.up),
|
|
down: abs(m.sfx && m.sfx.down),
|
|
};
|
|
return { loops, stingers, intro, sfx };
|
|
}
|
|
|
|
function ensureVideos() {
|
|
if (!_videos[0] && typeof document !== 'undefined') {
|
|
for (let i = 0; i < 2; i++) {
|
|
const v = document.createElement('video');
|
|
// Same autoplay-safe recipe as the highway_3d video bg style:
|
|
// muted + playsInline bypasses gesture requirements; same-origin
|
|
// URLs so VideoTexture never taints.
|
|
v.muted = true;
|
|
v.playsInline = true;
|
|
v.preload = 'auto';
|
|
v.loop = true;
|
|
v.style.display = 'none';
|
|
document.body.appendChild(v);
|
|
_videos[i] = v;
|
|
}
|
|
}
|
|
bindVideosToRenderer();
|
|
}
|
|
|
|
// The highway_3d plugin (and its globals) can register after the venue
|
|
// pack starts — e.g. Venue selected at page load, renderer ready later.
|
|
// Idempotent and retried from start() and the perf-event path so a late
|
|
// renderer still picks the videos up.
|
|
function bindVideosToRenderer() {
|
|
if (_boundToRenderer || !_videos[0]) return;
|
|
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
|
if (!setVideo) return;
|
|
setVideo(0, _videos[0]);
|
|
setVideo(1, _videos[1]);
|
|
_boundToRenderer = true;
|
|
setMix(_mix); // re-push mix the renderer missed while unregistered
|
|
}
|
|
|
|
function setMix(v) {
|
|
_mix = Math.max(0, Math.min(1, v));
|
|
const fn = h3d('h3dVenueBackdropSetMix');
|
|
if (fn) fn(_mix);
|
|
}
|
|
|
|
function cancelFade() {
|
|
if (_fadeRaf && typeof cancelAnimationFrame === 'function') {
|
|
cancelAnimationFrame(_fadeRaf);
|
|
}
|
|
_fadeRaf = 0;
|
|
}
|
|
|
|
function fadeMixTo(target, durationMs, done) {
|
|
cancelFade();
|
|
if (typeof requestAnimationFrame !== 'function') {
|
|
setMix(target);
|
|
if (done) done();
|
|
return;
|
|
}
|
|
const from = _mix;
|
|
const t0 = now();
|
|
const step = () => {
|
|
const k = Math.min(1, (now() - t0) / durationMs);
|
|
setMix(from + (target - from) * k);
|
|
if (k < 1) {
|
|
_fadeRaf = requestAnimationFrame(step);
|
|
} else {
|
|
_fadeRaf = 0;
|
|
if (done) done();
|
|
}
|
|
};
|
|
_fadeRaf = requestAnimationFrame(step);
|
|
}
|
|
|
|
// Load url into the video, resolve when it can play through (or after a
|
|
// timeout — a stalled fetch must not wedge the crowd forever). Tokens are
|
|
// per-element: a later load on the SAME video (a stinger preempting the
|
|
// idle layer) cancels this one, but loads on the other layer don't.
|
|
function loadAndPlay(video, url, loop, cb) {
|
|
const token = (video._fbCrowdToken = (video._fbCrowdToken || 0) + 1);
|
|
const gen = _stopGen;
|
|
let settled = false;
|
|
const settle = (ok) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
// Cleanup must run even for superseded loads or stale listeners
|
|
// accumulate on the two persistent elements; only the callback
|
|
// is gated on still being the current load.
|
|
video.removeEventListener('canplaythrough', onReady);
|
|
video.removeEventListener('error', onError);
|
|
if (token !== video._fbCrowdToken || gen !== _stopGen) return;
|
|
cb(ok);
|
|
};
|
|
const onReady = () => settle(true);
|
|
const onError = () => settle(false);
|
|
video.addEventListener('canplaythrough', onReady);
|
|
video.addEventListener('error', onError);
|
|
video.loop = loop;
|
|
video.src = url;
|
|
video.play().catch(() => { /* browser retries on visibility/gesture */ });
|
|
setTimeout(() => settle(video.readyState >= 3), CANPLAY_TIMEOUT_MS);
|
|
}
|
|
|
|
function idleLayer() { return _activeLayer === 0 ? 1 : 0; }
|
|
|
|
// Crossfade the loop for `state` in on the idle layer.
|
|
function showLoop(state, fadeMs) {
|
|
if (!_manifest || !_videos[0]) return;
|
|
const layer = idleLayer();
|
|
const video = _videos[layer];
|
|
_loadingLoop = state;
|
|
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
|
|
if (_loadingLoop === state) _loadingLoop = null;
|
|
if (!ok || !_venueActive) return;
|
|
_fadingLoop = state;
|
|
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
|
|
// Preempted mid-fade (stinger claimed this layer while we
|
|
// were still ramping): the layer no longer holds this loop —
|
|
// promoting it would pause the real loop and hand fade-back
|
|
// the wrong target.
|
|
if (_fadingLoop !== state) return;
|
|
_fadingLoop = null;
|
|
const old = _videos[_activeLayer];
|
|
_activeLayer = layer;
|
|
if (old && !old.paused) old.pause();
|
|
});
|
|
});
|
|
}
|
|
|
|
function playStinger(name) {
|
|
if (!_manifest || !_manifest.stingers[name] || !_videos[0]) return;
|
|
if (_stingerUntilEnded) return;
|
|
const t = now();
|
|
if (t - _lastStingerAt < STINGER_MIN_GAP_MS) return;
|
|
_lastStingerAt = t;
|
|
_stingerUntilEnded = true;
|
|
const layer = idleLayer();
|
|
const video = _videos[layer];
|
|
// The stinger reuses the idle layer's element, cancelling any loop
|
|
// load still in flight there — and idleLayer() is still the fading-in
|
|
// layer while a crossfade runs (_activeLayer flips on completion), so
|
|
// a mid-fade loop gets overwritten too. Requeue either for when the
|
|
// stinger ends (the machine already advanced, nothing re-fires it).
|
|
const interrupted = _loadingLoop || _fadingLoop;
|
|
if (interrupted) {
|
|
// Freeze any in-flight crossfade: its ramp would keep pushing the
|
|
// mix toward this layer while the stinger replaces the src (loop
|
|
// vanishing / stinger popping in at full opacity).
|
|
cancelFade();
|
|
_pendingLoop = interrupted;
|
|
_loadingLoop = null;
|
|
_fadingLoop = null;
|
|
}
|
|
// A loop switch deferred (or preempted) by this stinger must play
|
|
// once the stinger is done OR failed — the machine already advanced,
|
|
// so nothing re-triggers it later.
|
|
const flushPending = () => {
|
|
if (!_pendingLoop || !_venueActive) return;
|
|
const pending = _pendingLoop;
|
|
_pendingLoop = null;
|
|
showLoop(pending, FADE_MS);
|
|
};
|
|
const myGen = ++_stingerGen;
|
|
const back = () => {
|
|
// Always detach: a handler left behind by a stop()/manifest swap
|
|
// must not fire into a LATER stinger's lifecycle on this reused
|
|
// element (the gen check below guards that; the boolean alone
|
|
// would pass once a new stinger is active).
|
|
video.removeEventListener('ended', back);
|
|
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
|
|
_stingerUntilEnded = false;
|
|
// Fade back to the loop layer (which kept playing underneath).
|
|
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
|
|
flushPending();
|
|
};
|
|
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
|
|
if (!ok || !_venueActive) {
|
|
_stingerUntilEnded = false;
|
|
flushPending();
|
|
return;
|
|
}
|
|
video.addEventListener('ended', back);
|
|
fadeMixTo(layer === 1 ? 1 : 0, STINGER_FADE_MS);
|
|
// Safety: an `ended` that never fires (decode stall) must not
|
|
// freeze the crowd on a stinger frame.
|
|
setTimeout(back, 15000);
|
|
});
|
|
}
|
|
|
|
function ensureAudio() {
|
|
if (_audioEl || typeof document === 'undefined') return;
|
|
_audioEl = document.createElement('audio');
|
|
_audioEl.preload = 'auto';
|
|
_audioEl.style.display = 'none';
|
|
document.body.appendChild(_audioEl);
|
|
}
|
|
|
|
function fadeAudioOut(durationMs) {
|
|
if (!_audioEl || _audioEl.paused) return;
|
|
if (_audioFadeTimer) return; // already fading
|
|
const from = _audioEl.volume;
|
|
const t0 = now();
|
|
_audioFadeTimer = setInterval(() => {
|
|
const k = Math.min(1, (now() - t0) / durationMs);
|
|
_audioEl.volume = from * (1 - k);
|
|
if (k >= 1) {
|
|
clearInterval(_audioFadeTimer);
|
|
_audioFadeTimer = 0;
|
|
_audioEl.pause();
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
function stopAudio() {
|
|
if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; }
|
|
if (_audioEl && !_audioEl.paused) _audioEl.pause();
|
|
}
|
|
|
|
// One-shot flyover intro on song load: video flies from the back of the
|
|
// room onto the stage, crowd ambience plays and ducks out as the song
|
|
// starts (song:play) or as the flyover lands, whichever comes first.
|
|
function playIntro() {
|
|
if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) {
|
|
return false;
|
|
}
|
|
const myGen = ++_introGen;
|
|
_introActive = true;
|
|
const layer = idleLayer();
|
|
const video = _videos[layer];
|
|
const land = () => {
|
|
if (_introGen !== myGen || !_introActive) return;
|
|
_introActive = false;
|
|
video.removeEventListener('ended', land);
|
|
fadeAudioOut(1200);
|
|
const pending = _pendingLoop;
|
|
_pendingLoop = null;
|
|
showLoop(pending || machine.current, 400);
|
|
};
|
|
loadAndPlay(video, _manifest.intro.video, false, (ok) => {
|
|
if (_introGen !== myGen) return;
|
|
if (!ok || !_venueActive) {
|
|
// Failed intro must not leave the song loop-less: fall back
|
|
// to the normal loop exactly like the no-intro path.
|
|
_introActive = false;
|
|
if (_venueActive) showLoop(machine.current, FADE_MS);
|
|
return;
|
|
}
|
|
fadeMixTo(layer === 1 ? 1 : 0, 300);
|
|
video.addEventListener('ended', land);
|
|
setTimeout(land, 15000); // decode-stall safety
|
|
if (_manifest.intro.audio) {
|
|
ensureAudio();
|
|
_audioEl.src = _manifest.intro.audio;
|
|
_audioEl.volume = 1;
|
|
// The user's play gesture precedes song:loaded, so autoplay
|
|
// with sound is normally allowed; degrade silently if not.
|
|
_audioEl.play().catch(() => { /* no gesture yet */ });
|
|
// start ducking shortly before the flyover lands
|
|
video.addEventListener('timeupdate', function duck() {
|
|
if (video.duration && video.duration - video.currentTime < 1.5) {
|
|
video.removeEventListener('timeupdate', duck);
|
|
fadeAudioOut(1400);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
let _sfxEl = null;
|
|
|
|
function sfxEnabled() {
|
|
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
|
|
}
|
|
|
|
// One-shot crowd reaction on committed mood transitions (toggleable):
|
|
// up the ladder → cheer, down → boos. Committed transitions are already
|
|
// hysteresis-limited, so this can't spam.
|
|
function playMoodSfx(direction) {
|
|
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
|
|
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
|
|
if (!url || typeof document === 'undefined') return;
|
|
if (!_sfxEl) {
|
|
_sfxEl = document.createElement('audio');
|
|
_sfxEl.preload = 'auto';
|
|
_sfxEl.style.display = 'none';
|
|
document.body.appendChild(_sfxEl);
|
|
}
|
|
_sfxEl.src = url;
|
|
_sfxEl.volume = 0.6;
|
|
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
|
|
}
|
|
|
|
function onSongPlay() {
|
|
// Song audio starting is the hard cue: the ambience must yield.
|
|
fadeAudioOut(1000);
|
|
}
|
|
|
|
function onPerformanceState(e) {
|
|
if (!_venueActive || !_manifest) return;
|
|
bindVideosToRenderer();
|
|
const d = (e && e.detail) || {};
|
|
// Number(null) === 0: HUD reset events (accuracyPct: null) must not
|
|
// wipe the value the end-of-song stinger reads via stats:recorded.
|
|
if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) {
|
|
_lastAccuracyPct = Number(d.accuracyPct);
|
|
}
|
|
const streak = Number(d.streak) || 0;
|
|
const sting = stingerForStreak(_prevStreak, streak);
|
|
_prevStreak = streak;
|
|
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
|
playStinger(sting);
|
|
}
|
|
const prevRank = CROWD_RANK[machine.current];
|
|
const next = machine.update(d.state, now());
|
|
if (next) {
|
|
playMoodSfx(CROWD_RANK[next] - prevRank);
|
|
// A stinger or the intro owns the idle layer; defer the switch.
|
|
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
|
else showLoop(next, FADE_MS);
|
|
}
|
|
}
|
|
|
|
// song:loaded for the SAME file is an arrangement switch, not an arrival at
|
|
// the venue. changeArrangement() reloads through the normal load path, so
|
|
// the event is indistinguishable from a fresh load except by filename.
|
|
function isArrangementSwitch(prevFile, nextFile) {
|
|
return !!nextFile && nextFile === prevFile;
|
|
}
|
|
|
|
function onSongLoaded(song) {
|
|
const file = String((song && song.filename) || '');
|
|
const sameSong = isArrangementSwitch(_lastSongFile, file);
|
|
_lastSongFile = file;
|
|
|
|
machine.reset();
|
|
_prevStreak = 0;
|
|
_lastAccuracyPct = null;
|
|
|
|
// Switching arrangement is NOT arriving at the venue.
|
|
//
|
|
// changeArrangement() reloads the song through the same path as a fresh
|
|
// load, so highway.js emits song:loaded again — same filename, new
|
|
// arrangement. Treated as a new song, that replayed the arrival flyover:
|
|
// the camera flew in from the back of the room again mid-set, every time
|
|
// the player switched from lead to rhythm. The player is already on
|
|
// stage; the room should just carry on.
|
|
//
|
|
// So keep the video pipeline running and only re-sync the mood: the
|
|
// performance restarts, so the loop must follow the reset machine (a
|
|
// quiet crossfade), never the intro.
|
|
if (sameSong) {
|
|
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
|
|
return;
|
|
}
|
|
|
|
// A genuinely different song — full teardown.
|
|
// Abort any stinger/pending state from the previous song: its ended
|
|
// handler must not fade back into the old song's layers.
|
|
cancelFade();
|
|
_stingerGen++;
|
|
_introGen++;
|
|
_stingerUntilEnded = false;
|
|
_introActive = false;
|
|
stopAudio();
|
|
_pendingLoop = null;
|
|
_loadingLoop = null;
|
|
_fadingLoop = null;
|
|
if (_venueActive && _manifest) {
|
|
// The flyover is ARRIVING at the venue, and you arrive once. Songs
|
|
// 2..N of a set (a gig / album / playlist) are a NEW song but the
|
|
// SAME arrival — the camera should not fly in from the back of the
|
|
// room before every track (tester: "it showed the flyover intro
|
|
// again" on a gig's second song). Continue the room to the new song's
|
|
// loop; only a first-song / standalone arrival flies in.
|
|
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
|
|
else if (!playIntro()) showLoop(machine.current, FADE_MS);
|
|
}
|
|
}
|
|
|
|
// Is this song load a continuation of a play queue (a set already in
|
|
// progress), rather than an arrival? True for song 2..N of a gig/album/
|
|
// playlist. The queue owns the answer; treat any error / absent queue as
|
|
// "not a continuation" so a standalone play still flies in.
|
|
function _isSetContinuation() {
|
|
try {
|
|
const q = window.feedBack && window.feedBack.playQueue;
|
|
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function onStatsRecorded() {
|
|
if (!_venueActive || !_manifest) return;
|
|
// stats:recorded carries only {filename, arrangement} — the accuracy
|
|
// comes from the last v3:live-performance-state of the finished song.
|
|
const sting = stingerForAccuracy(_lastAccuracyPct);
|
|
_lastAccuracyPct = null; // one reaction per song
|
|
if (sting) {
|
|
_lastStingerAt = -Infinity; // end-of-song reaction always allowed
|
|
playStinger(sting);
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
ensureVideos();
|
|
if (!_videos[0]) return;
|
|
_prevStreak = 0;
|
|
// Boot straight into the current machine state on the active layer.
|
|
const video = _videos[_activeLayer];
|
|
loadAndPlay(video, _manifest.loops[machine.current], true, (ok) => {
|
|
if (!ok || !_venueActive) return;
|
|
setMix(_activeLayer === 1 ? 1 : 0);
|
|
});
|
|
}
|
|
|
|
function stop() {
|
|
cancelFade();
|
|
_stopGen++;
|
|
_stingerGen++;
|
|
_introGen++;
|
|
_introActive = false;
|
|
stopAudio();
|
|
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
|
|
_stingerUntilEnded = false;
|
|
_pendingLoop = null;
|
|
_loadingLoop = null;
|
|
_fadingLoop = null;
|
|
for (const v of _videos) {
|
|
if (v && !v.paused) v.pause();
|
|
}
|
|
// Unbind from the renderer: a paused video still holds its last
|
|
// frame, and the venue style keeps a bound plane visible whenever
|
|
// videoWidth > 0 — without this a removed pack would leave a frozen
|
|
// crowd frame over the static plate. start() re-binds.
|
|
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
|
if (_boundToRenderer && setVideo) {
|
|
setVideo(0, null);
|
|
setVideo(1, null);
|
|
}
|
|
_boundToRenderer = false;
|
|
// Mix and active layer must reset together: mix 0 shows layer 0, so a
|
|
// restart that left _activeLayer at 1 would flash layer 0's stale
|
|
// frame until the new loop loads.
|
|
_activeLayer = 0;
|
|
setMix(0);
|
|
}
|
|
|
|
function setVenueActive(on) {
|
|
const next = !!on;
|
|
if (next === _venueActive) {
|
|
// Re-activation (e.g. viz:renderer:ready after a late plugin
|
|
// load): don't restart the loop, but do retry renderer binding.
|
|
if (next && _manifest) bindVideosToRenderer();
|
|
return;
|
|
}
|
|
_venueActive = next;
|
|
if (_venueActive && _manifest) start();
|
|
else stop();
|
|
}
|
|
|
|
function setManifest(m) {
|
|
const norm = normalizeManifest(m);
|
|
_manifest = norm;
|
|
if (_venueActive) {
|
|
// Full stop first even when replacing pack-for-pack: it bumps
|
|
// _stopGen so an in-flight load from the OLD manifest can't
|
|
// settle and fade a stale URL in after the new pack starts.
|
|
stop();
|
|
if (norm) start();
|
|
}
|
|
}
|
|
|
|
function readDevManifest() {
|
|
try {
|
|
const raw = localStorage.getItem(DEV_FLAG_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function bindRuntime() {
|
|
if (_bound) return;
|
|
_bound = true;
|
|
const sm = root && root.feedBack;
|
|
if (sm && typeof sm.on === 'function') {
|
|
sm.on('v3:live-performance-state', onPerformanceState);
|
|
sm.on('stats:recorded', onStatsRecorded);
|
|
// A new song must not inherit the previous song's crowd mood
|
|
// through the hysteresis/dwell window.
|
|
sm.on('song:loaded', onSongLoaded);
|
|
sm.on('song:play', onSongPlay);
|
|
}
|
|
const dev = readDevManifest();
|
|
if (dev && !_manifest) setManifest(dev);
|
|
}
|
|
|
|
// Badge-ceremony hook (career passports): the crowd erupts NOW — ecstatic
|
|
// loop bypassing stability/dwell (the dwell window then holds it while
|
|
// the real perf state waits its turn) plus a cheer. Degrades to a no-op
|
|
// without a pack / outside the player, like every other entry point.
|
|
function celebrate() {
|
|
if (!_venueActive || !_manifest || !_videos[0]) return false;
|
|
machine.force('ecstatic', now());
|
|
if (_stingerUntilEnded || _introActive) {
|
|
// A stinger/intro owns the idle layer (likely the end-of-song
|
|
// accuracy cheer — the crowd is already reacting); queue the
|
|
// ecstatic loop for when it ends, same as onPerformanceState.
|
|
_pendingLoop = 'ecstatic';
|
|
} else {
|
|
showLoop('ecstatic', FADE_MS);
|
|
_lastStingerAt = -Infinity; // a badge earn always gets its cheer
|
|
playStinger('cheer');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function getState() {
|
|
return {
|
|
venueActive: _venueActive,
|
|
hasManifest: !!_manifest,
|
|
crowdState: machine.current,
|
|
activeLayer: _activeLayer,
|
|
mix: _mix,
|
|
stingerActive: _stingerUntilEnded,
|
|
introActive: _introActive,
|
|
};
|
|
}
|
|
|
|
const api = {
|
|
CROWD_STATES,
|
|
STABLE_MS,
|
|
DWELL_MS,
|
|
crowdStateOfPerf,
|
|
createCrowdMachine,
|
|
stingerForStreak,
|
|
stingerForAccuracy,
|
|
normalizeManifest,
|
|
setManifest,
|
|
setVenueActive,
|
|
bindRuntime,
|
|
getState,
|
|
celebrate,
|
|
isArrangementSwitch,
|
|
};
|
|
|
|
if (root) root.v3VenueCrowd = api;
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
|
|
if (typeof document !== 'undefined') {
|
|
// Same defer/DOMContentLoaded dance as venue-scene-3d.js.
|
|
if (document.readyState !== 'complete') {
|
|
document.addEventListener('DOMContentLoaded', bindRuntime);
|
|
} else {
|
|
bindRuntime();
|
|
}
|
|
}
|
|
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
|