diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index a6da1d3..b92b402 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -83,15 +83,27 @@ test("audio is a 0-10 level dial", async ({ page }) => { expect(ctl.max).toBe("10"); }); -test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => { +test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => { await boot(page); // Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites. expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false); expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0"); - // Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10. + // "Run simulation" is revealed once preload finishes — the obvious starting point. + expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false); + // Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10 + // and dismisses the Run prompt. await enableVideo(page); - await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3"); - expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3"); + await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2"); + expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("2"); + expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true); +}); + +test("the Run simulation button starts video, snaps audio to 2/10, and dismisses itself", async ({ page }) => { + await boot(page); + await page.click("#run-sim"); + await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true); + await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2"); + expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true); }); test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => { diff --git a/simulator/static/app.js b/simulator/static/app.js index 54e4c2d..f843d44 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1239,6 +1239,23 @@ function hideLoading() { let videoEverOn = false; // has Video ever been switched on this session? +// The gentle audio level the experience starts at — coupled in on the first +// Video-on (via the Run simulation button or the Video toggle directly). +const START_AUDIO_LEVEL = "2"; + +// Begin the experience: Video on, audio snapped to the gentle start level, and +// the "Run simulation" prompt dismissed. Audio is set + played in THIS gesture so +// it unlocks on Safari (which blocks play() outside a user gesture). One flip = the +// full experience at a gentle level. Idempotent across re-toggles via videoEverOn. +function beginExperience() { + videoEverOn = true; + const btn = $("run-sim"); + if (btn) btn.classList.add("hidden"); + $("audio").value = START_AUDIO_LEVEL; + updateAudioLevelLabel(); + applyAudio(); +} + async function main() { devLiveReload(); try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); } @@ -1255,14 +1272,17 @@ async function main() { // play() outside a user gesture). $("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); }); updateAudioLevelLabel(); - // Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one - // flip = the full experience at a gentle level). If audio is already raised, leave it. - // Set + played in THIS gesture so it unlocks on Safari. + // "Run simulation" button: the obvious starting point. Turns Video on, snaps + // audio to the start level, and dismisses itself — all in this click gesture. + $("run-sim").addEventListener("click", () => { + $("visual").checked = true; // setting .checked does NOT fire "change", so we drive the rest here + beginExperience(); + debounced(); + }); + // Video toggle: the FIRST time video turns on (without the Run simulation button), + // begin the experience too — couple audio in and dismiss the prompt. $("visual").addEventListener("change", () => { - if ($("visual").checked && !videoEverOn) { - videoEverOn = true; - if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); } - } + if ($("visual").checked && !videoEverOn) beginExperience(); debounced(); }); // Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump. @@ -1274,11 +1294,13 @@ async function main() { update(); // render the initial state (both toggles off → black, silent) await preloadAllMedia(); // download all media, updating the loading bar hideLoading(); // experience is ready — boots SILENT (video off, audio 0) + $("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage // No un-gestured auto-start: a browser autoplay policy blocks an audio play() made // outside a user gesture (muted video survives, sound does not), so an auto-start // would show video but stay silent. Instead the experience waits for the operator to - // turn Video on — that click IS the gesture, and its handler (above) lifts audio to - // 3/10 and starts the soundtrack in-gesture, so sound reliably comes up with video. + // press "Run simulation" (or turn Video on directly) — that click IS the gesture, and + // beginExperience() lifts audio to the start level and plays the soundtrack in-gesture, + // so sound reliably comes up with video. } // Populate the language dropdown from the registry and wire live switching. diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index a46f167..f50ac29 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -19,6 +19,7 @@ const UI_STRINGS = { "app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" }, "loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" }, + "run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", ja: "シミュレーションを開始" }, "output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" }, "output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" }, "output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index 838b86e..4f6d190 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -26,6 +26,7 @@ + diff --git a/simulator/static/style.css b/simulator/static/style.css index a4971a1..c92b1fd 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -51,6 +51,20 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl .black { position: absolute; inset: 0; background: #000; opacity: 1; z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; } .hidden { display: none; } +/* "Run simulation" — the obvious starting point, shown over the (black) stage once + media is preloaded. Sits above the black cover (z-index 50). Dismissed when the + experience begins (the button, or turning Video on directly). */ +.run-sim { + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + z-index: 60; cursor: pointer; user-select: none; + padding: 0.85rem 2rem; border: 0; border-radius: 999px; + font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em; + color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af); + box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); } +.run-sim:active { transform: translate(-50%, -50%) scale(0.98); } /* Stack the two Output toggles (Video / Audio) with a little breathing room. */ .dev-switch + .dev-switch { margin-top: 0.45rem; } /* Live audio status readout (diagnostic) — turns green when actually playing. */ diff --git a/tests/test_e2e_audio.py b/tests/test_e2e_audio.py index f5d7065..7f67af6 100644 --- a/tests/test_e2e_audio.py +++ b/tests/test_e2e_audio.py @@ -7,10 +7,10 @@ blanking) — real Safari/iOS autoplay and real-GPU compositing still need a dev by-ear/eye check. The experience BOOTS SILENT — video off, audio dial at 0 — so nothing plays until a -real user gesture. Turning Video ON (a click) couples the audio dial up to 3/10 and -starts the soundtrack IN that gesture, sidestepping the browser autoplay block that -swallowed an un-gestured auto-start. Audio is a 0–10 range dial (not a checkbox); -set it by writing `value` + firing an `input` event. +real user gesture. The "Run simulation" button (or turning Video ON directly) couples +the audio dial up to 2/10 and starts the soundtrack IN that gesture, sidestepping the +browser autoplay block that swallowed an un-gestured auto-start. Audio is a 0–10 range +dial (not a checkbox); set it by writing `value` + firing an `input` event. Install: pip install -e '.[e2e]' && python -m playwright install chromium """ @@ -100,12 +100,36 @@ def test_app_boots_silent_video_off(page): ) is True -def test_video_on_couples_audio_to_three_and_plays(page): - # Turning Video on (a click gesture) lifts the audio dial to 3/10 and starts the - # soundtrack IN that gesture: video shows (black hidden), a scale soundtrack plays. +def test_run_simulation_button_shows_after_load(page): + # Once media is preloaded the "Run simulation" prompt is revealed over the (still + # black) stage — the obvious starting point — while the experience stays silent. + page.wait_for_selector("#run-sim:not(.hidden)") + assert page.is_checked("#visual") is False + assert page.evaluate("document.getElementById('audio').value") == "0" + + +def test_run_simulation_button_starts_video_and_audio(page): + # Pressing "Run simulation" (a click gesture) turns Video on, snaps the audio dial + # to 2/10, dismisses itself, and starts the soundtrack IN that gesture. + page.click("#run-sim") + page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed + assert page.is_checked("#visual") is True + page.wait_for_function("document.getElementById('audio').value === '2'") + page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'") + page.wait_for_selector("#black.hidden", state="attached") # video showing + page.wait_for_function( + "(() => { const a = document.getElementById('aud');" + " return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused; })()" + ) + + +def test_video_on_directly_couples_audio_to_two_and_dismisses_button(page): + # Turning Video on directly (skipping the Run simulation button) ALSO begins the + # experience: audio snaps to 2/10, the soundtrack plays, and the button goes away. _toggle_visual(page) # off -> on - page.wait_for_function("document.getElementById('audio').value === '3'") - page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'") + page.wait_for_function("document.getElementById('audio').value === '2'") + page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'") + page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed page.wait_for_selector("#black.hidden", state="attached") # video showing page.wait_for_function( "(() => { const a = document.getElementById('aud');"