mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
feat(venue-crowd): flyover intro with crowd-ambience ducking
On song:loaded, an optional pack intro plays once: a camera flyover video (idle layer, one-shot) with bar-crowd ambience audio that ducks out on song:play, near the flyover's landing, or at handoff — whichever first. Machine commits and stingers defer during the intro; stop()/song-change abort it. Packs without an intro behave as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9d8fda6d46
commit
99812b8ed3
+100
-5
@@ -116,6 +116,10 @@
|
||||
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
|
||||
@@ -136,7 +140,11 @@
|
||||
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]);
|
||||
return { loops, stingers };
|
||||
const intro = {
|
||||
video: abs(m.intro && m.intro.video),
|
||||
audio: abs(m.intro && m.intro.audio),
|
||||
};
|
||||
return { loops, stingers, intro };
|
||||
}
|
||||
|
||||
function ensureVideos() {
|
||||
@@ -322,6 +330,85 @@
|
||||
});
|
||||
}
|
||||
|
||||
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) { _introActive = false; 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;
|
||||
}
|
||||
|
||||
function onSongPlay() {
|
||||
// Song audio starting is the hard cue: the ambience must yield.
|
||||
fadeAudioOut(1000);
|
||||
}
|
||||
|
||||
function onPerformanceState(e) {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
bindVideosToRenderer();
|
||||
@@ -334,13 +421,13 @@
|
||||
const streak = Number(d.streak) || 0;
|
||||
const sting = stingerForStreak(_prevStreak, streak);
|
||||
_prevStreak = streak;
|
||||
if (sting && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
||||
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
||||
playStinger(sting);
|
||||
}
|
||||
const next = machine.update(d.state, now());
|
||||
if (next) {
|
||||
// A stinger owns the idle layer; defer the switch until it ends.
|
||||
if (_stingerUntilEnded) _pendingLoop = next;
|
||||
// A stinger or the intro owns the idle layer; defer the switch.
|
||||
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
||||
else showLoop(next, FADE_MS);
|
||||
}
|
||||
}
|
||||
@@ -353,12 +440,15 @@
|
||||
// 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) {
|
||||
showLoop(machine.current, FADE_MS);
|
||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +480,9 @@
|
||||
cancelFade();
|
||||
_stopGen++;
|
||||
_stingerGen++;
|
||||
_introGen++;
|
||||
_introActive = false;
|
||||
stopAudio();
|
||||
_stingerUntilEnded = false;
|
||||
_pendingLoop = null;
|
||||
_loadingLoop = null;
|
||||
@@ -459,6 +552,7 @@
|
||||
// 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);
|
||||
@@ -472,6 +566,7 @@
|
||||
activeLayer: _activeLayer,
|
||||
mix: _mix,
|
||||
stingerActive: _stingerUntilEnded,
|
||||
introActive: _introActive,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user