From 307085711b64997b737f61f179d7c6905cb60b62 Mon Sep 17 00:00:00 2001 From: Christophe Date: Mon, 13 Apr 2026 20:39:02 +0200 Subject: [PATCH] Ajoute un avertissement sonore pour la phase cube --- app.js | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/app.js b/app.js index 7dc66d7..e5d2b9f 100644 --- a/app.js +++ b/app.js @@ -9,6 +9,7 @@ const DEFAULT_MOVE_LIMIT_MS = 20000; const TIME_MODE_INITIAL_CLOCK_MS = 600000; const CUBE_TIME_CAP_MS = 120000; const CUBE_START_HOLD_MS = 2000; +const CUBE_PHASE_ALERT_NAVIGATION_DELAY_MS = 700; const PRESETS = { fast: { @@ -56,6 +57,8 @@ window.requestAnimationFrame(() => { let match = readStoredMatch(); let dirty = false; +let audioContext = null; +let cubePhaseAlertRetryCleanup = null; if (match) { if (normalizeRecoveredMatch(match)) { @@ -67,6 +70,8 @@ if (match) { } window.addEventListener("beforeunload", flushState); +document.addEventListener("pointerdown", tryUnlockAudioContext, { passive: true }); +document.addEventListener("keydown", tryUnlockAudioContext); document.addEventListener("visibilitychange", () => { if (document.hidden) { if (match) { @@ -110,6 +115,120 @@ function syncViewportHeight() { document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`); } +function getAudioContext() { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) { + return null; + } + + if (!audioContext) { + try { + audioContext = new AudioContextClass(); + } catch { + return null; + } + } + + return audioContext; +} + +function tryUnlockAudioContext() { + const context = getAudioContext(); + if (!context || context.state === "running") { + return; + } + + context.resume().catch(() => undefined); +} + +function playCubePhaseAlert() { + const context = getAudioContext(); + if (!context || context.state !== "running") { + return false; + } + + const pattern = [ + { frequency: 740, offset: 0, duration: 0.12, gain: 0.035 }, + { frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 }, + { frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 }, + ]; + const startAt = context.currentTime + 0.02; + + pattern.forEach(({ frequency, offset, duration, gain }) => { + const oscillator = context.createOscillator(); + const envelope = context.createGain(); + const toneStartAt = startAt + offset; + + oscillator.type = "triangle"; + oscillator.frequency.setValueAtTime(frequency, toneStartAt); + envelope.gain.setValueAtTime(0.0001, toneStartAt); + envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02); + envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration); + + oscillator.connect(envelope); + envelope.connect(context.destination); + oscillator.start(toneStartAt); + oscillator.stop(toneStartAt + duration + 0.03); + }); + + return true; +} + +function clearCubePhaseAlertRetry() { + if (typeof cubePhaseAlertRetryCleanup === "function") { + cubePhaseAlertRetryCleanup(); + cubePhaseAlertRetryCleanup = null; + } +} + +function scheduleCubePhaseAlertRetry(storedMatch) { + if (!storedMatch?.cube?.phaseAlertPending || cubePhaseAlertRetryCleanup) { + return; + } + + const tryPlayOnGesture = () => { + tryUnlockAudioContext(); + if (!playCubePhaseAlert()) { + return; + } + + storedMatch.cube.phaseAlertPending = false; + dirty = true; + persistMatch(); + clearCubePhaseAlertRetry(); + }; + + const events = ["pointerdown", "keydown"]; + cubePhaseAlertRetryCleanup = () => { + events.forEach((eventName) => { + document.removeEventListener(eventName, tryPlayOnGesture); + }); + }; + + events.forEach((eventName) => { + document.addEventListener(eventName, tryPlayOnGesture, { passive: eventName === "pointerdown" }); + }); +} + +function maybePlayPendingCubePhaseAlert(storedMatch) { + if (!storedMatch?.cube?.phaseAlertPending) { + clearCubePhaseAlertRetry(); + return false; + } + + tryUnlockAudioContext(); + if (!playCubePhaseAlert()) { + scheduleCubePhaseAlertRetry(storedMatch); + return false; + } + + storedMatch.cube.phaseAlertPending = false; + dirty = true; + persistMatch(); + clearCubePhaseAlertRetry(); + return true; +} + function initSetupPage() { const form = document.querySelector("#setupForm"); const summary = document.querySelector("#setupSummary"); @@ -325,7 +444,25 @@ function initChronoPage() { return; } + let cubeNavigationTimeoutId = null; const goToCubePage = () => { + if (cubeNavigationTimeoutId !== null) { + return; + } + + if (match?.cube?.phaseAlertPending) { + tryUnlockAudioContext(); + if (playCubePhaseAlert()) { + match.cube.phaseAlertPending = false; + dirty = true; + persistMatch(); + cubeNavigationTimeoutId = window.setTimeout(() => { + replaceTo("cube.html"); + }, CUBE_PHASE_ALERT_NAVIGATION_DELAY_MS); + return; + } + } + dirty = true; persistMatch(); replaceTo("cube.html"); @@ -681,6 +818,8 @@ function initCubePage() { return; } + maybePlayPendingCubePhaseAlert(match); + const refs = { title: document.querySelector("#cubeTitle"), subtitle: document.querySelector("#cubeSubtitle"), @@ -790,6 +929,7 @@ function initCubePage() { resultModalKey = null; replayCubePhase(match); dirty = true; + maybePlayPendingCubePhaseAlert(match); render(); return; } @@ -806,6 +946,7 @@ function initCubePage() { resultModalKey = null; replayCubePhase(match); dirty = true; + maybePlayPendingCubePhaseAlert(match); render(); }); @@ -1303,6 +1444,7 @@ function createMatch(config) { running: false, startedAt: null, elapsedMs: 0, + phaseAlertPending: false, times: { white: null, black: null, @@ -1405,6 +1547,7 @@ function normalizeRecoveredMatch(storedMatch) { running: false, startedAt: null, elapsedMs: 0, + phaseAlertPending: false, times: { white: null, black: null }, playerState: { white: createCubePlayerState(), @@ -1424,6 +1567,11 @@ function normalizeRecoveredMatch(storedMatch) { changed = true; } + if (typeof storedMatch.cube.phaseAlertPending !== "boolean") { + storedMatch.cube.phaseAlertPending = false; + changed = true; + } + storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white); storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black); storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch); @@ -1537,6 +1685,7 @@ function openCubePhase(storedMatch, reason = "") { storedMatch.cube.running = false; storedMatch.cube.startedAt = null; storedMatch.cube.elapsedMs = 0; + storedMatch.cube.phaseAlertPending = true; storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.playerState = { white: createCubePlayerState(), @@ -1730,6 +1879,7 @@ function replayCubePhase(storedMatch) { storedMatch.cube.running = false; storedMatch.cube.startedAt = null; storedMatch.cube.elapsedMs = 0; + storedMatch.cube.phaseAlertPending = true; storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.playerState = { white: createCubePlayerState(),