diff --git a/src/app/App.tsx b/src/app/App.tsx index 04cdb4b..db69a51 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -16,6 +16,7 @@ import { WhatsNewDialog } from "./WhatsNewDialog"; import { AiOverlay } from "./AiOverlay"; import { ToastHost } from "./Toasts"; import { useControl } from "./useControl"; +import { setSoundsEnabled } from "../sound-manager"; export function App(): ReactNode { const snapshot = useControl(); @@ -75,6 +76,10 @@ export function App(): ReactNode { setSidebarCollapsed(resolvedPage === "dashboard"); }, [resolvedPage]); + useEffect(() => { + setSoundsEnabled(preferences.enabledSounds); + }, [preferences.enabledSounds]); + function openArtworkRequest(): void { if (snapshot.status?.name) setArtworkDeviceName(snapshot.status.name); setArtworkReqOpen(true); diff --git a/src/app/InterfaceSettings.tsx b/src/app/InterfaceSettings.tsx index efa8d58..c155018 100644 --- a/src/app/InterfaceSettings.tsx +++ b/src/app/InterfaceSettings.tsx @@ -177,6 +177,14 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): /> + {switchRow( + "interface-sounds", + t(locale, "set.soundTitle"), + t(locale, "set.soundDescription"), + preferences.enabledSounds, + set("enabledSounds"), + )} + {switchRow( "interface-reduced-motion", t(locale, "set.animations"), diff --git a/src/app/Toasts.tsx b/src/app/Toasts.tsx index af20591..2c048ae 100644 --- a/src/app/Toasts.tsx +++ b/src/app/Toasts.tsx @@ -3,80 +3,7 @@ import * as control from "../device/controller"; import type { Toast } from "../device/types"; import type { InterfaceLocale } from "../interface-preferences"; import { t } from "../i18n"; - -let audioContext: AudioContext | null = null; - -function getAudioContext(): AudioContext | null { - if (audioContext) return audioContext; - try { - const Ctor = - window.AudioContext ?? - (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!Ctor) return null; - audioContext = new Ctor(); - } catch { - return null; - } - return audioContext; -} - -/* A chime that fired before the first user gesture (Chrome suspends the - context until then) waits here and plays on the earliest interaction. */ -let pendingChime: Toast["kind"] | null = null; - -function unlockAudio(): void { - const ctx = getAudioContext(); - if (ctx && ctx.state === "suspended") void ctx.resume(); - if (pendingChime !== null) { - const kind = pendingChime; - pendingChime = null; - void playToastSound(kind); - } -} - -/* Browsers suspend audio until the first user gesture. Resume on every - pointer/key interaction (not once) so any pending chime always fires. */ -if (typeof window !== "undefined") { - const gestureOptions: AddEventListenerOptions = { passive: true, capture: true }; - window.addEventListener("pointerdown", unlockAudio, gestureOptions); - window.addEventListener("keydown", unlockAudio, gestureOptions); - window.addEventListener("pointerup", unlockAudio, gestureOptions); - window.addEventListener("click", unlockAudio, gestureOptions); -} - -async function playToastSound(kind: Toast["kind"]): Promise { - try { - const ctx = getAudioContext(); - if (!ctx) return; - if (ctx.state === "suspended") { - await ctx.resume().catch(() => {}); - } - if (ctx.state !== "running") { - pendingChime = kind; - return; - } - - const base = - kind === "success" ? 659.25 : kind === "info" ? 523.25 : kind === "warning" ? 392 : 311.13; - const frequencies = kind === "error" ? [base] : [base, base * 1.25]; - for (const freq of frequencies) { - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "sine"; - osc.frequency.value = freq; - osc.connect(gain).connect(ctx.destination); - const start = Math.max(ctx.currentTime + 0.02, ctx.currentTime); - gain.gain.setValueAtTime(0, start); - gain.gain.linearRampToValueAtTime(0.3, start + 0.02); - gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.4); - osc.start(start); - osc.stop(start + 0.45); - await new Promise((resolve) => window.setTimeout(resolve, 160)); - } - } catch (error) { - console.warn("Toast chime could not play", error); - } -} +import { playToastSound } from "../sound-manager"; const TOAST_ICON: Record = { success: , diff --git a/src/click-sound.ts b/src/click-sound.ts index 7a56aec..b5a12a1 100644 --- a/src/click-sound.ts +++ b/src/click-sound.ts @@ -1,67 +1,9 @@ -let context: AudioContext | null = null; -let buffer: AudioBuffer | null = null; -let preparePromise: Promise | null = null; -let audioRequest: Promise | null = null; +import { setSoundsEnabled } from "./sound-manager"; -function audioCtor(): typeof AudioContext | null { - if (typeof window === "undefined") return null; - return (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ?? null); -} - -function fetchAudio(): Promise { - if (!audioRequest) { - audioRequest = fetch(new URL("/sounds/click.wav", window.location.href).toString()) - .then((response) => { - if (!response.ok) throw new Error(`click sound fetch failed: ${response.status}`); - return response.arrayBuffer(); - }); - } - return audioRequest; -} - -function prepare(): Promise { - if (!preparePromise) { - preparePromise = (async () => { - const Ctor = audioCtor(); - if (!Ctor) return; - context = new Ctor(); - buffer = await context.decodeAudioData(await fetchAudio()); - })().catch((error) => { - console.warn("OpenMouse click sound unavailable:", error); - preparePromise = null; - }); - } - return preparePromise; -} - -async function play(): Promise { - const ctx = context; - const audio = buffer; - if (!ctx || !audio) return; - if (ctx.state === "suspended") await ctx.resume(); - - const now = ctx.currentTime; - const length = audio.duration; - - const gain = ctx.createGain(); - gain.gain.setValueAtTime(0.0001, now); - gain.gain.linearRampToValueAtTime(1.35, now + 0.003); - gain.gain.setValueAtTime(1.35, now + Math.max(0.04, length - 0.06)); - gain.gain.setTargetAtTime(0.0001, now + length - 0.06, 0.028); - gain.connect(ctx.destination); - - const source = ctx.createBufferSource(); - source.buffer = audio; - source.connect(gain); - source.start(now, 0, length); +export function initClickSound(): void { + setSoundsEnabled(true); } -export function initClickSound(): void { - void fetchAudio(); - void prepare(); - window.addEventListener("pointerdown", () => { - void prepare().then(play); - }, { - passive: true, - }); +export function removeClickSound(): void { + setSoundsEnabled(false); } \ No newline at end of file diff --git a/src/control.tsx b/src/control.tsx index 6aaccd1..32cc4ea 100644 --- a/src/control.tsx +++ b/src/control.tsx @@ -11,7 +11,6 @@ import { start } from "./device/controller"; import { isBeforeLaunch } from "./launch"; import { mountOfflineBanner } from "./offline-banner"; import { registerServiceWorker } from "./register-sw"; -import { initClickSound } from "./click-sound"; import { MIN_HEIGHT, MIN_WIDTH, useViewportTooSmall } from "./app/useViewportTooSmall"; import { usePresence } from "./app/usePresence"; @@ -31,7 +30,6 @@ function isChromium(): boolean { registerServiceWorker(); mountOfflineBanner(); -initClickSound(); function LaunchHero(): ReactNode { return ( diff --git a/src/i18n-de.ts b/src/i18n-de.ts index c98e070..5b1824e 100644 --- a/src/i18n-de.ts +++ b/src/i18n-de.ts @@ -81,6 +81,8 @@ export const de: Record = { "test.btn.forward": "Vor", "set.title": "Einstellungen", "set.back": "Zurück zum Gerät", + "set.soundTitle": "Klang", + "set.soundDescription": "Aktiviere Geräusche wie Mausklicks und neue Verbindungen", "set.profiles": "PROFILE", "set.profileKey": "Profilschlüssel", "set.profileKeyBody": "Ein zum Kopieren geeigneter Schlüssel, der die Einstellungen dieser Maus auf ein anderes Gerät desselben Modells überträgt. Füge ihn dort in den Einstellungen ein, um dieselbe Konfiguration zu laden.", diff --git a/src/i18n-es.ts b/src/i18n-es.ts index f83f9e3..e170e04 100644 --- a/src/i18n-es.ts +++ b/src/i18n-es.ts @@ -81,6 +81,8 @@ export const es: Record = { "test.btn.forward": "Adelante", "set.title": "Configuración", "set.back": "Volver al dispositivo", + "set.soundTitle": "Sonido", + "set.soundDescription": "Activa sonidos como clics de ratón y nuevas conexiones", "set.profiles": "PERFILES", "set.profileKey": "Clave de perfil", "set.profileKeyBody": "Una clave para copiar y pegar que lleva la configuración de este mouse a otra unidad del mismo modelo. Pégala en Configuración allí para cargar el mismo ajuste.", diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts index b1f96af..208d347 100644 --- a/src/i18n-fr.ts +++ b/src/i18n-fr.ts @@ -81,6 +81,8 @@ export const fr: Record = { "test.btn.forward": "Avant", "set.title": "Paramètres", "set.back": "Retour à l'appareil", + "set.soundTitle": "Son", + "set.soundDescription": "Activez des sons comme des clics de souris et de nouvelles connexions", "set.profiles": "PROFILS", "set.profileKey": "Clé de profil", "set.profileKeyBody": "Une clé à copier-coller qui transfère les réglages de cette souris vers une autre unité du même modèle. Collez-la dans les Paramètres là-bas pour charger la même configuration.", diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts index fd069c2..2f35ef0 100644 --- a/src/i18n-ja.ts +++ b/src/i18n-ja.ts @@ -81,6 +81,8 @@ export const ja: Record = { "test.btn.forward": "進む", "set.title": "設定", "set.back": "デバイスに戻る", + "set.soundTitle": "サウンド", + "set.soundDescription": "マウスクリック音と新しい接続音を有効にしてください", "set.profiles": "プロファイル", "set.profileKey": "プロファイルキー", "set.profileKeyBody": "このマウスの設定を、同じモデルの別の個体に移せるコピー&ペースト用のキーです。相手側の設定画面に貼り付けると同じ設定が読み込まれます。", diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts index 2c9a009..c0eb5ac 100644 --- a/src/i18n-ko.ts +++ b/src/i18n-ko.ts @@ -81,6 +81,8 @@ export const ko: Record = { "test.btn.forward": "앞으로", "set.title": "설정", "set.back": "장치로 돌아가기", + "set.soundTitle": "사운드", + "set.soundDescription": "마우스 클릭 소리나 새 연결 소리 같은 소리를 활성화하세요.", "set.profiles": "프로필", "set.profileKey": "프로필 키", "set.profileKeyBody": "이 마우스의 설정을 같은 모델의 다른 기기로 옮길 수 있는 복사용 키입니다. 그쪽 설정에 붙여넣으면 동일한 설정을 불러올 수 있습니다.", diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts index 3229937..dc12bf5 100644 --- a/src/i18n-pt.ts +++ b/src/i18n-pt.ts @@ -81,6 +81,8 @@ export const pt: Record = { "test.btn.forward": "Avançar", "set.title": "Configurações", "set.back": "Voltar ao dispositivo", + "set.soundTitle": "Som", + "set.soundDescription": "Ativar sons como cliques do rato e novas ligações", "set.profiles": "PERFIS", "set.profileKey": "Chave de perfil", "set.profileKeyBody": "Uma chave de copiar e colar que leva as configurações deste mouse para outra unidade do mesmo modelo. Cole-a nas Configurações de lá para carregar o mesmo setup.", diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts index f5b50a5..19741be 100644 --- a/src/i18n-ru.ts +++ b/src/i18n-ru.ts @@ -81,6 +81,8 @@ export const ru: Record = { "test.btn.forward": "Вперёд", "set.title": "Настройки", "set.back": "Назад к устройству", + "set.soundTitle": "Звук", + "set.soundDescription": "Включите звуки, такие как щелчки мыши и новые соединения.", "set.profiles": "ПРОФИЛИ", "set.profileKey": "Ключ профиля", "set.profileKeyBody": "Ключ для копирования: переносит настройки этой мыши на другую такой же модели. Вставьте его в Настройках там, чтобы получить ту же конфигурацию.", diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts index bb5d21c..15ade41 100644 --- a/src/i18n-vi.ts +++ b/src/i18n-vi.ts @@ -47,6 +47,8 @@ export const vi: Record = { "ov.off": "Tắt", "set.title": "Cài đặt", "set.back": "Quay lại thiết bị", + "set.soundTitle": "Âm thanh", + "set.soundDescription": "Bật các âm thanh như nhấp chuột và kết nối mới", "set.profiles": "PROFILES", "set.profileKey": "Mã profile", "set.profileKeyBody": "Một mã có thể sao chép và dán để mang cài đặt của chuột này sang một thiết bị khác cùng model. Dán mã đó vào phần Cài đặt trên thiết bị kia để tải cùng cấu hình.", diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts index 7bf3bfd..d8b5ea4 100644 --- a/src/i18n-zh.ts +++ b/src/i18n-zh.ts @@ -81,6 +81,8 @@ export const zh: Record = { "test.btn.forward": "前进", "set.title": "设置", "set.back": "返回设备", + "set.soundTitle": "音效", + "set.soundDescription": "启用鼠标点击和新连接等声音", "set.profiles": "配置文件", "set.profileKey": "配置密钥", "set.profileKeyBody": "可复制粘贴的密钥,能把这只鼠标的设置带到同型号的另一台设备上。在那台设备的设置里粘贴它,即可加载相同的配置。", diff --git a/src/i18n.ts b/src/i18n.ts index 74bb8c6..5ca9c10 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -94,6 +94,8 @@ const en = { "set.importKey": "Import a key", "set.importPlaceholder": "Paste a profile key here…", "set.import": "Import", + "set.soundTitle": "Sound", + "set.soundDescription": "Enable sounds like mouse clicks and new connections", "set.importNote": "Imported settings are staged like any other edit — nothing is written until you flash them.", "set.bridge": "OPENMOUSE BRIDGE", "set.bridgeTitle": "Game detection and battery alerts", diff --git a/src/interface-preferences.test.ts b/src/interface-preferences.test.ts index 39c950e..ac0b001 100644 --- a/src/interface-preferences.test.ts +++ b/src/interface-preferences.test.ts @@ -24,6 +24,7 @@ test("interface preferences restore only supported values", () => { const storage = new MemoryStorage(); saveInterfacePreferences(storage, { theme: "Violet", + enabledSounds: true, colorMode: "Light", locale: "pt", reducedMotion: true, @@ -35,6 +36,7 @@ test("interface preferences restore only supported values", () => { assert.deepEqual(loadInterfacePreferences(storage), { theme: "Violet", + enabledSounds: true, colorMode: "Light", locale: "pt", reducedMotion: true, diff --git a/src/interface-preferences.ts b/src/interface-preferences.ts index cb05aaf..7a90027 100644 --- a/src/interface-preferences.ts +++ b/src/interface-preferences.ts @@ -39,6 +39,7 @@ export interface InterfacePreferences { expandSections: boolean; showExperimental: boolean; instantFlash: boolean; + enabledSounds: boolean; glassIntensity: number; } @@ -66,6 +67,7 @@ export const DEFAULT_INTERFACE_PREFERENCES: InterfacePreferences = { expandSections: false, showExperimental: true, instantFlash: false, + enabledSounds: true, glassIntensity: 100, }; @@ -120,6 +122,7 @@ export function loadInterfacePreferences(storage: Storage): InterfacePreferences expandSections: saved.expandSections === true, showExperimental: saved.showExperimental !== false, instantFlash: saved.instantFlash === true, + enabledSounds: saved.enabledSounds !== false, glassIntensity: clampGlassIntensity(saved.glassIntensity), }; } catch { diff --git a/src/sound-manager.ts b/src/sound-manager.ts new file mode 100644 index 0000000..91aeb15 --- /dev/null +++ b/src/sound-manager.ts @@ -0,0 +1,141 @@ +import type { ToastKind } from "./device/types"; + +let context: AudioContext | null = null; +let clickBuffer: AudioBuffer | null = null; +let clickPreparePromise: Promise | null = null; +let clickAudioRequest: Promise | null = null; +let pendingToast: ToastKind | null = null; +let enabled = false; +let gestureListenerInstalled = false; + +function audioCtor(): typeof AudioContext | null { + if (typeof window === "undefined") return null; + return window.AudioContext + ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + ?? null; +} + +function getContext(): AudioContext | null { + if (context) return context; + const Ctor = audioCtor(); + if (!Ctor) return null; + try { + context = new Ctor(); + } catch { + return null; + } + return context; +} + +function fetchClickAudio(): Promise { + if (!clickAudioRequest) { + clickAudioRequest = fetch(new URL("/sounds/click.wav", window.location.href).toString()) + .then((response) => { + if (!response.ok) throw new Error(`click sound fetch failed: ${response.status}`); + return response.arrayBuffer(); + }); + } + return clickAudioRequest; +} + +function prepareClick(): Promise { + if (!clickPreparePromise) { + clickPreparePromise = (async () => { + const ctx = getContext(); + if (!ctx) return; + clickBuffer = await ctx.decodeAudioData(await fetchClickAudio()); + })().catch((error) => { + console.warn("OpenMouse click sound unavailable:", error); + clickPreparePromise = null; + }); + } + return clickPreparePromise; +} + +async function playClick(): Promise { + if (!enabled) return; + const ctx = context; + const audio = clickBuffer; + if (!ctx || !audio) return; + if (ctx.state === "suspended") await ctx.resume(); + + const now = ctx.currentTime; + const length = audio.duration; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.linearRampToValueAtTime(1.35, now + 0.003); + gain.gain.setValueAtTime(1.35, now + Math.max(0.04, length - 0.06)); + gain.gain.setTargetAtTime(0.0001, now + length - 0.06, 0.028); + gain.connect(ctx.destination); + + const source = ctx.createBufferSource(); + source.buffer = audio; + source.connect(gain); + source.start(now, 0, length); +} + +async function playToast(kind: ToastKind): Promise { + try { + if (!enabled) return; + const ctx = getContext(); + if (!ctx) return; + if (ctx.state === "suspended") await ctx.resume().catch(() => {}); + if (ctx.state !== "running") { + pendingToast = kind; + return; + } + + const base = kind === "success" ? 659.25 : kind === "info" ? 523.25 : kind === "warning" ? 392 : 311.13; + const frequencies = kind === "error" ? [base] : [base, base * 1.25]; + for (const frequency of frequencies) { + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.type = "sine"; + oscillator.frequency.value = frequency; + oscillator.connect(gain).connect(ctx.destination); + const start = Math.max(ctx.currentTime + 0.02, ctx.currentTime); + gain.gain.setValueAtTime(0, start); + gain.gain.linearRampToValueAtTime(0.3, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.4); + oscillator.start(start); + oscillator.stop(start + 0.45); + await new Promise((resolve) => window.setTimeout(resolve, 160)); + } + } catch (error) { + console.warn("Toast chime could not play", error); + } +} + +function unlock(): void { + const ctx = getContext(); + if (ctx && ctx.state === "suspended") void ctx.resume(); + if (pendingToast !== null) { + const kind = pendingToast; + pendingToast = null; + void playToast(kind); + } + void prepareClick().then(playClick); +} + +export function setSoundsEnabled(nextEnabled: boolean): void { + enabled = nextEnabled; + if (!enabled) { + pendingToast = null; + if (gestureListenerInstalled) { + window.removeEventListener("pointerdown", unlock, true); + gestureListenerInstalled = false; + } + return; + } + + void fetchClickAudio(); + void prepareClick(); + if (!gestureListenerInstalled) { + window.addEventListener("pointerdown", unlock, { passive: true, capture: true }); + gestureListenerInstalled = true; + } +} + +export function playToastSound(kind: ToastKind): void { + void playToast(kind); +} \ No newline at end of file