Compare commits
4 Commits
8c6778230b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b6bf970684 | |||
| 34fb48cdec | |||
| c8416ef2e9 | |||
| e666465d62 |
@@ -1,55 +1,56 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Auto-advance: after the dial sits idle for the interval, the altitude drifts one
|
||||
// step deeper (descend), wrapping past the deepest. A default-on toggle disables it.
|
||||
// The e2e injects a short interval via window.__hefAutoAdvanceMs before load.
|
||||
// Auto-advance moves to the next altitude when the altitude's loop VIDEO completes a
|
||||
// playthrough (not on a fixed timer). Descends one step + wraps; a default-on toggle
|
||||
// disables it; manual drag / a playing morph defer it. The e2e simulates a completed
|
||||
// loop by dispatching the loop element's "ended" (the wrap-point and ended share the
|
||||
// same advance path).
|
||||
|
||||
async function boot(page: Page, intervalMs: number) {
|
||||
await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs);
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await expect(page.locator("#scale-name")).toContainText("cosmos");
|
||||
}
|
||||
const state = (page: Page) => page.evaluate(() => (window as any).__hefState());
|
||||
const idx = async (page: Page) => (await state(page)).ringIndex as number;
|
||||
|
||||
// Simulate the loop video finishing one playthrough.
|
||||
async function completeLoopVideo(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const v = document.querySelector("#vid-loop") as HTMLVideoElement;
|
||||
v.dataset.loopTail = "1"; // armed, as after a real landing
|
||||
v.dispatchEvent(new Event("ended"));
|
||||
});
|
||||
}
|
||||
|
||||
const idx = (page: Page) => page.evaluate(() => (window as any).__hefState().ringIndex as number);
|
||||
|
||||
test("auto-advances one altitude (descending) after the idle interval", async ({ page }) => {
|
||||
await boot(page, 1200);
|
||||
const n = await page.evaluate(() => (window as any).__hefState && (document.querySelectorAll(".dial-label").length));
|
||||
test("a completed loop video advances one altitude (descending, wraps)", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
|
||||
const start = await idx(page);
|
||||
// Wait past the interval + the morph animation for the landing.
|
||||
await completeLoopVideo(page);
|
||||
await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 });
|
||||
const after = await idx(page);
|
||||
expect(after).toBe((start + 1) % n); // descended exactly one, wraps
|
||||
expect(await idx(page)).toBe((start + 1) % n);
|
||||
});
|
||||
|
||||
test("Auto-advance off keeps the altitude parked", async ({ page }) => {
|
||||
// Disable BEFORE launch so the idle timer never arms (deterministic — no race with
|
||||
// the first interval firing during boot).
|
||||
await page.addInitScript(() => { (window as any).__hefAutoAdvanceMs = 800; });
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
test("Auto-advance off: a completed video stays on the altitude", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
|
||||
const start = await idx(page);
|
||||
await page.waitForTimeout(2200); // well past two 800ms intervals
|
||||
expect(await idx(page)).toBe(start); // never moved
|
||||
await completeLoopVideo(page);
|
||||
await page.waitForTimeout(1500);
|
||||
expect(await idx(page)).toBe(start);
|
||||
});
|
||||
|
||||
test("the idle countdown resets on interaction (no advance right after a manual turn)", async ({ page }) => {
|
||||
await boot(page, 1500);
|
||||
// Immediately before the first interval elapses, interact (keyboard step). That step
|
||||
// itself advances by 1; the reset then means no AUTO advance fires for another
|
||||
// full interval — so shortly after, we should still be at exactly that +1.
|
||||
await page.waitForTimeout(900);
|
||||
const before = await idx(page);
|
||||
await page.locator("#dial").focus();
|
||||
await page.locator("#dial").press("ArrowDown"); // manual descend + reset
|
||||
await page.waitForFunction((b) => (window as any).__hefState().ringIndex !== b, before, { timeout: 4000 });
|
||||
const manual = await idx(page);
|
||||
await page.waitForTimeout(900); // < the 1500ms interval
|
||||
expect(await idx(page)).toBe(manual); // countdown reset → no extra auto step yet
|
||||
test("keeps touring: a second completed video advances again", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
|
||||
const start = await idx(page);
|
||||
await completeLoopVideo(page);
|
||||
// wait until it has advanced AND settled (pos back on an integer detent)
|
||||
await page.waitForFunction((s) => { const st = (window as any).__hefState(); return st.ringIndex !== s && Number.isInteger(st.pos); }, start, { timeout: 8000 });
|
||||
await completeLoopVideo(page);
|
||||
await page.waitForFunction((a) => (window as any).__hefState().ringIndex === (a.start + 2) % a.n, { start, n }, { timeout: 8000 });
|
||||
expect(await idx(page)).toBe((start + 2) % n);
|
||||
});
|
||||
|
||||
@@ -4,12 +4,16 @@ import { test, expect } from "@playwright/test";
|
||||
// offset via a narrow timeupdate window. If that window is ever missed the clip
|
||||
// fires `ended` and — without a safety net — freezes on its last frame ("videos
|
||||
// aren't looping"). This asserts the `ended` safety net recovers playback.
|
||||
// (With Auto-advance ON — the default — a completed video instead moves to the next
|
||||
// altitude; that path is covered by auto-advance.spec.ts. Here we disable it to test
|
||||
// the loop-back recovery in isolation.)
|
||||
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Enter via the welcome screen (Video defaults on → the loop video loads).
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await page.evaluate(() => { const c = document.querySelector("#auto-advance") as HTMLInputElement; if (c) c.checked = false; });
|
||||
// Wait for the loop video to actually have media before forcing the stuck state.
|
||||
await page.waitForFunction(
|
||||
() => { const v = document.getElementById("vid-loop") as HTMLVideoElement; return v && v.duration > 0; },
|
||||
|
||||
@@ -1,57 +1,63 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Music is a rotating playlist that crossfades to a random OTHER track on a timer
|
||||
// (docs/music-candidate-pool.md). The e2e injects short rotate/xfade intervals via
|
||||
// window.__hefMusicRotateMs / __hefMusicXfadeMs before load.
|
||||
// Music is a shuffle: each track plays to its END, then a random OTHER track plays
|
||||
// (never an immediate repeat), gaplessly, for as long as Music is selected. The e2e
|
||||
// injects a short crossfade via window.__hefMusicXfadeMs and simulates a track finishing
|
||||
// by dispatching its "ended" event (real tracks are minutes long).
|
||||
|
||||
async function boot(page: Page, rotateMs: number, xfadeMs: number) {
|
||||
await page.addInitScript(([r, x]) => {
|
||||
(window as any).__hefMusicRotateMs = r;
|
||||
(window as any).__hefMusicXfadeMs = x;
|
||||
}, [rotateMs, xfadeMs]);
|
||||
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
|
||||
|
||||
async function boot(page: Page, xfadeMs = 300) {
|
||||
await page.addInitScript((x) => { (window as any).__hefMusicXfadeMs = x; }, xfadeMs);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
// The track on the louder (active) music element.
|
||||
async function activeTrack(page: Page): Promise<string> {
|
||||
// id ("aud"/"aud-b") of the louder (active) music element, and the track it holds.
|
||||
async function active(page: Page): Promise<{ id: string; url: string }> {
|
||||
return await page.evaluate(() => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
const b = document.querySelector("#aud-b") as HTMLAudioElement;
|
||||
const el = b.volume > a.volume ? b : a;
|
||||
return el.dataset.url || "";
|
||||
return { id: el.id, url: el.dataset.url || "" };
|
||||
});
|
||||
}
|
||||
// Simulate the active track finishing.
|
||||
async function endActive(page: Page, id: string) {
|
||||
await page.evaluate((i) => (document.querySelector("#" + i) as HTMLAudioElement).dispatchEvent(new Event("ended")), id);
|
||||
}
|
||||
|
||||
test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => {
|
||||
await boot(page, 1200, 300);
|
||||
test("when the current track ends, a new RANDOM track plays", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.selectOption("#audio-source", "music");
|
||||
await page.waitForTimeout(200);
|
||||
const first = await activeTrack(page);
|
||||
expect(first).toMatch(/audio\/music\/\w+\.mp3/);
|
||||
// Wait past one rotation (1200ms) + the crossfade (300ms) + buffer.
|
||||
const first = await active(page);
|
||||
expect(first.url).toMatch(MUSIC_RE);
|
||||
await endActive(page, first.id);
|
||||
await page.waitForFunction((f) => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
const b = document.querySelector("#aud-b") as HTMLAudioElement;
|
||||
const el = b.volume > a.volume ? b : a;
|
||||
return (el.dataset.url || "") !== f && (el.dataset.url || "").includes("/audio/music/");
|
||||
}, first, { timeout: 6000 });
|
||||
const second = await activeTrack(page);
|
||||
expect(second).toMatch(/audio\/music\/\w+\.mp3/);
|
||||
expect(second).not.toBe(first); // rotated to another track
|
||||
return (el.dataset.url || "").includes("/audio/music/") && (el.dataset.url || "") !== f;
|
||||
}, first.url, { timeout: 6000 });
|
||||
const second = await active(page);
|
||||
expect(second.url).toMatch(MUSIC_RE);
|
||||
expect(second.url).not.toBe(first.url); // never an immediate repeat
|
||||
});
|
||||
|
||||
test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => {
|
||||
await boot(page, 900, 250);
|
||||
test("Music keeps playing across several track-ends", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.selectOption("#audio-source", "music");
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.waitForTimeout(1300);
|
||||
seen.add(await activeTrack(page));
|
||||
await page.waitForTimeout(400);
|
||||
const cur = await active(page);
|
||||
seen.add(cur.url);
|
||||
await endActive(page, cur.id);
|
||||
await page.waitForTimeout(400); // let the crossfade settle
|
||||
}
|
||||
// Over 3 rotations we should have heard more than one distinct track.
|
||||
expect([...seen].every((u) => /audio\/music\/\w+\.mp3/.test(u))).toBe(true);
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
expect([...seen].every((u) => MUSIC_RE.test(u))).toBe(true);
|
||||
expect(seen.size).toBeGreaterThan(1); // it moved through multiple tracks
|
||||
});
|
||||
|
||||
+64
-51
@@ -328,6 +328,7 @@ function ensureClipMedia() {
|
||||
// tail. The morph element (#vid) is driven directly by the scrub.
|
||||
loopVid.addEventListener("timeupdate", () => {
|
||||
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
|
||||
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
|
||||
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
|
||||
}
|
||||
});
|
||||
@@ -339,6 +340,7 @@ loopVid.addEventListener("timeupdate", () => {
|
||||
// rarely hits this; loaded real machines do.
|
||||
loopVid.addEventListener("ended", () => {
|
||||
if (loopVid.dataset.loopTail !== "1") return;
|
||||
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
|
||||
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
|
||||
loopVid.play().catch(() => {});
|
||||
});
|
||||
@@ -848,7 +850,6 @@ function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
|
||||
let wheelAccum = 0, wheelTimer = null;
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
bumpAutoAdvance();
|
||||
wheelAccum += e.deltaY;
|
||||
clearTimeout(wheelTimer);
|
||||
wheelTimer = setTimeout(() => {
|
||||
@@ -858,26 +859,19 @@ function onWheel(e) {
|
||||
}, 90);
|
||||
}
|
||||
|
||||
// --- Auto-advance: after the dial sits idle for AUTO_ADVANCE_MS, drift one altitude
|
||||
// deeper (descend; past the deepest it wraps back to the top), a slow kiosk tour. Any
|
||||
// manual interaction restarts the idle countdown; a default-on toggle disables it.
|
||||
// (E2E injects a short interval via window.__hefAutoAdvanceMs before load.)
|
||||
const AUTO_ADVANCE_MS = (typeof window !== "undefined" && window.__hefAutoAdvanceMs) || 60000;
|
||||
let autoAdvanceTimer = null;
|
||||
// --- Auto-advance: when the altitude's base loop VIDEO completes one playthrough, drift
|
||||
// one altitude deeper (descend; past the deepest it wraps back to the top) — a slow kiosk
|
||||
// tour paced by the footage itself. A default-on toggle disables it; a manual drag or a
|
||||
// playing morph defers the move (it happens on the NEXT completion instead). Hooked into
|
||||
// the loop element's completion points (the timeupdate wrap + the ended safety net).
|
||||
function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; }
|
||||
function scheduleAutoAdvance() {
|
||||
clearTimeout(autoAdvanceTimer);
|
||||
autoAdvanceTimer = null;
|
||||
if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running
|
||||
autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS);
|
||||
function autoAdvanceOnLoopComplete() {
|
||||
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) {
|
||||
autoScrub(Math.round(pos) + 1); // completed a playthrough → next altitude
|
||||
return true; // advanced — caller skips the normal loop-back
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function onAutoAdvance() {
|
||||
// Skip a beat (but keep the cadence) if the visitor is mid-drag or a morph is playing.
|
||||
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) autoScrub(Math.round(pos) + 1);
|
||||
scheduleAutoAdvance();
|
||||
}
|
||||
// Restart the idle countdown on any manual interaction (drag/wheel/key/knob).
|
||||
function bumpAutoAdvance() { if (videoEverOn) scheduleAutoAdvance(); }
|
||||
|
||||
// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
|
||||
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
|
||||
@@ -1086,7 +1080,6 @@ function setPos(next) {
|
||||
function onDialDown(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
e.preventDefault();
|
||||
bumpAutoAdvance();
|
||||
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos };
|
||||
}
|
||||
function onDialMove(e) {
|
||||
@@ -1113,7 +1106,6 @@ function onDialUp(e) {
|
||||
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
|
||||
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
|
||||
autoScrub(Math.round(pos));
|
||||
bumpAutoAdvance();
|
||||
}
|
||||
|
||||
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
|
||||
@@ -1144,7 +1136,7 @@ function onDialKey(e) {
|
||||
case "End": jumpToScale(ring.scales.length - 1); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) { e.preventDefault(); bumpAutoAdvance(); }
|
||||
if (handled) e.preventDefault();
|
||||
}
|
||||
|
||||
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
|
||||
@@ -1466,7 +1458,7 @@ function updateAudioStatus() {
|
||||
}
|
||||
aud.addEventListener("error", updateAudioStatus);
|
||||
audB.addEventListener("error", updateAudioStatus);
|
||||
setInterval(updateAudioStatus, 400);
|
||||
setInterval(() => { updateAudioStatus(); musicWatchdog(); }, 400);
|
||||
|
||||
// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
|
||||
// if a stale one keeps running after a new fade or a direct volume set, it drags the
|
||||
@@ -1523,20 +1515,22 @@ function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
|
||||
|
||||
// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each
|
||||
// other (not one looping track), altitude-independent — the same relaxing rotation at
|
||||
// every altitude. Every MUSIC_ROTATE_MS it crossfades to a random OTHER track (never an
|
||||
// immediate repeat), reusing the two audio elements. Intervals are injectable for tests.
|
||||
// every altitude. Each track plays to its END, then a random OTHER one plays (never an
|
||||
// immediate repeat); a short crossfade a few seconds before the end keeps it GAPLESS, so
|
||||
// Music is always playing while selected. Reuses the two audio elements. The crossfade
|
||||
// duration is injectable for tests.
|
||||
const MUSIC_PLAYLIST = [
|
||||
"music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3",
|
||||
"music/overheat.mp3", "music/concentration.mp3",
|
||||
];
|
||||
const MUSIC_ROTATE_MS = (typeof window !== "undefined" && window.__hefMusicRotateMs) || 5 * 60 * 1000;
|
||||
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
|
||||
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
|
||||
const MUSIC_XFADE_S = MUSIC_XFADE_MS / 1000;
|
||||
function musicTrackUrl(i) {
|
||||
return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)];
|
||||
}
|
||||
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
|
||||
let musicEl = null; // the element currently carrying the active music track
|
||||
let musicTimer = null; // rotation timer
|
||||
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
|
||||
let musicEl = null; // the element currently carrying the active music track
|
||||
let musicXfading = false; // true during a track→track crossfade (guards re-entry)
|
||||
|
||||
// The url the active source would play right now (for the diagnostic readout).
|
||||
function currentAudioUrl() {
|
||||
@@ -1582,40 +1576,62 @@ function silenceAll() {
|
||||
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
|
||||
}
|
||||
|
||||
function scheduleMusicRotate() {
|
||||
clearTimeout(musicTimer);
|
||||
musicTimer = setTimeout(rotateMusic, MUSIC_ROTATE_MS);
|
||||
}
|
||||
// Crossfade the active music element out and a random OTHER track in on the other element.
|
||||
function rotateMusic() {
|
||||
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) { scheduleMusicRotate(); return; }
|
||||
// Crossfade the finishing track out and a random OTHER track in on the other element.
|
||||
function advanceMusic() {
|
||||
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) return;
|
||||
musicXfading = true;
|
||||
const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random());
|
||||
const nextEl = musicEl === aud ? audB : aud;
|
||||
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
|
||||
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS); // ...fade it in,
|
||||
fadeVolume(musicEl, 0, MUSIC_XFADE_MS, () => musicEl.pause()); // ...fade the old out.
|
||||
const prevEl = musicEl;
|
||||
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
|
||||
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS, () => { musicXfading = false; }); // ...fade it in,
|
||||
fadeVolume(prevEl, 0, MUSIC_XFADE_MS, () => prevEl.pause()); // ...fade the finishing one out.
|
||||
musicEl = nextEl; musicIdx = next;
|
||||
scheduleMusicRotate();
|
||||
updateAudioStatus();
|
||||
}
|
||||
// Start the rotation (first call) or just re-apply the master volume to the active track.
|
||||
// The FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock
|
||||
// them on Safari (the timer-driven crossfade later plays the 2nd element outside a gesture).
|
||||
// timeupdate: a few seconds before the active track ends, crossfade to a random next
|
||||
// (gapless). Only the active element, in Music mode, once per track.
|
||||
function onMusicProgress(e) {
|
||||
if (musicXfading || audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
|
||||
const d = musicEl.duration;
|
||||
if (d && isFinite(d) && d > MUSIC_XFADE_S * 2 && musicEl.currentTime >= d - MUSIC_XFADE_S) advanceMusic();
|
||||
}
|
||||
// ended: safety net — a track that finishes without a crossfade (short track / missed
|
||||
// timeupdate) immediately rolls a new random one, so Music never stops while selected.
|
||||
function onMusicEnded(e) {
|
||||
if (audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
|
||||
musicXfading = false;
|
||||
advanceMusic();
|
||||
}
|
||||
// Start playback (first call) or just re-apply the master volume to the active track. The
|
||||
// FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock them
|
||||
// on Safari (later crossfades play the 2nd element outside a gesture).
|
||||
function startMusic(vol) {
|
||||
if (musicIdx < 0) {
|
||||
musicXfading = false;
|
||||
musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length);
|
||||
musicEl = aud;
|
||||
ensurePlaying(aud, musicTrackUrl(musicIdx), vol);
|
||||
ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent)
|
||||
scheduleMusicRotate();
|
||||
} else {
|
||||
cancelFade(musicEl);
|
||||
musicEl.volume = HEFScrub.clamp01(vol);
|
||||
}
|
||||
updateAudioStatus();
|
||||
}
|
||||
// Stop the rotation and reset (on leaving Music, or going silent).
|
||||
function stopMusic() { clearTimeout(musicTimer); musicTimer = null; musicIdx = -1; musicEl = null; }
|
||||
// Reset (on leaving Music, or going silent).
|
||||
function stopMusic() { musicXfading = false; musicIdx = -1; musicEl = null; }
|
||||
|
||||
// Keep Music playing while selected: resume the active track if it stalls/pauses unexpectedly.
|
||||
function musicWatchdog() {
|
||||
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0 || !musicEl) return;
|
||||
if (musicEl.paused && !musicXfading) { const pr = musicEl.play(); if (pr) pr.catch(() => {}); }
|
||||
}
|
||||
|
||||
for (const el of [aud, audB]) {
|
||||
el.addEventListener("timeupdate", onMusicProgress);
|
||||
el.addEventListener("ended", onMusicEnded);
|
||||
}
|
||||
|
||||
// Mid-segment scrub. Soundtrack: crossfade scale `loIndex` (gain 1-frac) against
|
||||
// loIndex+1 (gain frac), scaled by the master volume. Music: hold the single loop.
|
||||
@@ -1671,7 +1687,6 @@ function beginExperience() {
|
||||
updateAudioLevelLabel();
|
||||
applyAudio();
|
||||
applyPlaySpeed();
|
||||
scheduleAutoAdvance(); // start the idle-drift countdown now that we're running
|
||||
}
|
||||
|
||||
// Welcome screen ----------------------------------------------------------------
|
||||
@@ -1746,9 +1761,7 @@ async function main() {
|
||||
initWelcome(); // wire the welcome-screen controls + Launch button
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", () => { bumpAutoAdvance(); debounced(); });
|
||||
// Auto-advance toggle: (re)start or cancel the idle-drift countdown on change.
|
||||
$("auto-advance").addEventListener("change", scheduleAutoAdvance);
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
|
||||
// Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this
|
||||
// input gesture so the first raise from 0 unlocks + plays on Safari (which blocks
|
||||
// play() outside a user gesture).
|
||||
|
||||
Reference in New Issue
Block a user