feat: interface sounds

This commit is contained in:
zhom committed 2026-10-12 03:00:40 +04:00
1 parent 2cc91ed250
commit 38ab1f1a2d
20 files changed
+544 -12

No files matched your search

+3
View File
@@ -156,6 +156,9 @@ export class AppSession {
// A tip opening by itself mid-test is a modal nobody asked for;
// the tips suite turns it back on for the one session that wants it.
tips_auto_show: false,
// Hundreds of scripted clicks must not play through the speakers
// of the machine running the suite.
ui_sounds: false,
...this.settings,
},
null,
+18
View File
@@ -2279,6 +2279,24 @@ test("first-run onboarding stays recoverable, responsive, and platform-aware", a
);
});
test("interface sounds turn on and off in Appearance and stay as saved", async () => {
await withApp("ui-sounds-setting", async (app) => {
const sounds = async () => (await app.invoke("get_app_settings")).ui_sounds;
assert.equal(await sounds(), false, "the harness keeps the suite silent");
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText(en.settings.appearance.soundsDescription);
const toggle = `[role="switch"][aria-label="${en.settings.appearance.sounds}"]`;
await app.clickSelector(toggle);
await saveSettings(app);
assert.equal(await sounds(), true);
await app.clickSelector(toggle);
await saveSettings(app);
assert.equal(await sounds(), false);
await app.restart();
assert.equal(await sounds(), false, "the choice survives a restart");
});
});
test("predefined theme remains rendered across navigation and restart", async () => {
await withApp("ui-theme-predefined", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
+2 -1
View File
@@ -10,7 +10,7 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:ui-sounds && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:tips": "node --test src/lib/tips.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
@@ -25,6 +25,7 @@
"test:proxy-type": "node --test src/lib/proxy-type.test.mjs",
"test:proxy-first-hop-claims": "node --test src/lib/proxy-first-hop-claims.test.mjs",
"test:profile-search": "node --test src/lib/profile-search.test.mjs",
"test:ui-sounds": "node --test src/lib/ui-sounds.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
"licenses:generate": "node scripts/generate-licenses.mjs",
+11
View File
@@ -239,6 +239,9 @@ pub struct AppSettings {
/// byte counts only, so nothing on disk lists where a profile went.
#[serde(default = "default_record_traffic_domains")]
pub record_traffic_domains: bool,
/// Soft interface sounds for presses, toggles and results.
#[serde(default = "default_ui_sounds")]
pub ui_sounds: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -271,6 +274,10 @@ fn default_record_traffic_domains() -> bool {
true
}
fn default_ui_sounds() -> bool {
true
}
/// The automatic tip flow waits a random gap between two tips, drawn from
/// this range (both ends included), so tips arrive now and then instead of on
/// a fixed day and a busy day of restarts never turns into a tip per launch.
@@ -313,6 +320,7 @@ impl Default for AppSettings {
require_route_for_launch: false,
trash_retention_days: DEFAULT_TRASH_RETENTION_DAYS,
record_traffic_domains: true,
ui_sounds: true,
}
}
}
@@ -1458,6 +1466,7 @@ mod tests {
require_route_for_launch: true,
trash_retention_days: 7,
record_traffic_domains: false,
ui_sounds: false,
};
let save_result = manager.save_settings(&test_settings);
@@ -1478,6 +1487,7 @@ mod tests {
assert!(loaded_settings.require_route_for_launch);
assert_eq!(loaded_settings.trash_retention_days, 7);
assert!(!loaded_settings.record_traffic_domains);
assert!(!loaded_settings.ui_sounds);
}
#[test]
@@ -1486,6 +1496,7 @@ mod tests {
assert!(!settings.require_route_for_launch);
assert_eq!(settings.trash_retention_days, DEFAULT_TRASH_RETENTION_DAYS);
assert!(settings.record_traffic_domains);
assert!(settings.ui_sounds, "sounds are on until turned off");
}
#[test]
+9
View File
@@ -1,5 +1,6 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
@@ -11,12 +12,20 @@ import { WindowDragArea } from "@/components/window-drag-area";
import { InputModalityProvider } from "@/hooks/use-input-modality";
import { LaunchActivityProvider } from "@/hooks/use-launch-activity";
import { setupLogging } from "@/lib/logger";
import { installUiSounds, setUiSoundsEnabled } from "@/lib/ui-sounds";
export function ClientProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
void setupLogging();
}, []);
useEffect(() => {
void invoke<{ ui_sounds?: boolean }>("get_app_settings")
.then((settings) => setUiSoundsEnabled(settings.ui_sounds ?? true))
.catch(() => {});
return installUiSounds();
}, []);
return (
<I18nProvider>
<CustomThemeProvider>
+25
View File
@@ -68,6 +68,7 @@ import {
withThemeTransition,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { setUiSoundsEnabled } from "@/lib/ui-sounds";
import { cn } from "@/lib/utils";
import type { SetDefaultBrowserOutcome } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -86,6 +87,7 @@ interface AppSettings {
require_route_for_launch?: boolean;
trash_retention_days?: number;
record_traffic_domains?: boolean;
ui_sounds?: boolean;
}
/** The retention choices offered, in days. 0 turns the trash off. */
@@ -573,6 +575,7 @@ export function SettingsDialog({
// Update settings with any generated tokens
setSettings(savedSettings);
setUiSoundsEnabled(savedSettings.ui_sounds ?? true);
settingsToSave = savedSettings;
setOriginalSettings(savedSettings);
originalSettingsRef.current = savedSettings;
@@ -847,6 +850,9 @@ export function SettingsDialog({
(originalSettings.record_traffic_domains ?? true)
? [t("settings.privacy.recordSites")]
: []),
...((settings.ui_sounds ?? true) !== (originalSettings.ui_sounds ?? true)
? [t("settings.appearance.sounds")]
: []),
...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !==
(originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS)
? [t("settings.trashRetention")]
@@ -1239,6 +1245,25 @@ export function SettingsDialog({
</div>
</div>
)}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.appearance.sounds")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.appearance.soundsDescription")}
</span>
</div>
<AnimatedSwitch
data-slot="settings-ui-sounds"
aria-label={t("settings.appearance.sounds")}
checked={settings.ui_sounds ?? true}
onCheckedChange={(v) => {
updateSetting("ui_sounds", v === true);
}}
/>
</div>
</div>
{/* Language Section */}
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Eigenes",
"light": "Hell",
"dark": "Dunkel",
"system": "System"
"system": "System",
"sounds": "Oberflächenklänge",
"soundsDescription": "Leise, sanfte Klänge, wenn Sie eine Schaltfläche drücken, einen Schalter umlegen oder eine Aktion gelingt oder fehlschlägt."
},
"language": {
"title": "Sprache",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Your Own",
"light": "Light",
"dark": "Dark",
"system": "System"
"system": "System",
"sounds": "Interface sounds",
"soundsDescription": "Soft, quiet sounds when you press a button, flip a switch, or when an action succeeds or fails."
},
"language": {
"title": "Language",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Tu Propio",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema"
"system": "Sistema",
"sounds": "Sonidos de la interfaz",
"soundsDescription": "Sonidos suaves y bajos al pulsar un botón, cambiar un interruptor o cuando una acción sale bien o falla."
},
"language": {
"title": "Idioma",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Le vôtre",
"light": "Clair",
"dark": "Sombre",
"system": "Système"
"system": "Système",
"sounds": "Sons de l'interface",
"soundsDescription": "Des sons doux et discrets quand vous appuyez sur un bouton, basculez un interrupteur, ou quand une action réussit ou échoue."
},
"language": {
"title": "Langue",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "カスタム",
"light": "ライト",
"dark": "ダーク",
"system": "システム"
"system": "システム",
"sounds": "インターフェースの効果音",
"soundsDescription": "ボタンを押したとき、スイッチを切り替えたとき、操作が成功または失敗したときに、小さく柔らかい音を鳴らします。"
},
"language": {
"title": "言語",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "직접 설정",
"light": "라이트",
"dark": "다크",
"system": "시스템"
"system": "시스템",
"sounds": "인터페이스 소리",
"soundsDescription": "버튼을 누르거나 스위치를 전환하거나 작업이 성공 또는 실패할 때 작고 부드러운 소리를 냅니다."
},
"language": {
"title": "언어",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Seu Próprio",
"light": "Claro",
"dark": "Escuro",
"system": "Sistema"
"system": "Sistema",
"sounds": "Sons da interface",
"soundsDescription": "Sons suaves e baixos ao pressionar um botão, mudar uma chave ou quando uma ação dá certo ou falha."
},
"language": {
"title": "Idioma",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Собственная",
"light": "Светлая",
"dark": "Тёмная",
"system": "Системная"
"system": "Системная",
"sounds": "Звуки интерфейса",
"soundsDescription": "Тихие мягкие звуки при нажатии кнопки, переключении переключателя, а также при успехе или ошибке действия."
},
"language": {
"title": "Язык",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Kendi Temanız",
"light": "Açık",
"dark": "Koyu",
"system": "Sistem"
"system": "Sistem",
"sounds": "Arayüz sesleri",
"soundsDescription": "Bir düğmeye bastığınızda, bir anahtarı değiştirdiğinizde veya bir işlem başarılı olduğunda ya da başarısız olduğunda yumuşak ve sessiz sesler."
},
"language": {
"title": "Dil",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "Của riêng bạn",
"light": "Sáng",
"dark": "Tối",
"system": "Theo hệ thống"
"system": "Theo hệ thống",
"sounds": "Âm thanh giao diện",
"soundsDescription": "Âm thanh nhỏ, nhẹ khi bạn nhấn nút, bật tắt công tắc, hoặc khi một thao tác thành công hay thất bại."
},
"language": {
"title": "Ngôn ngữ",
+3 -1
View File
@@ -121,7 +121,9 @@
"yourOwn": "自定义",
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
"system": "跟随系统",
"sounds": "界面音效",
"soundsDescription": "按下按钮、切换开关,或操作成功或失败时,播放轻柔安静的声音。"
},
"language": {
"title": "语言",
+3
View File
@@ -3,6 +3,7 @@ import React from "react";
import { type ExternalToast, toast as sonnerToast } from "sonner";
import { UnifiedToast } from "@/components/custom-toast";
import i18n from "@/i18n";
import { playUiSound } from "@/lib/ui-sounds";
interface BaseToastProps {
id?: string;
@@ -201,6 +202,7 @@ export function showSuccessToast(
duration?: number;
},
) {
playUiSound("success");
return showToast({
type: "success",
title,
@@ -216,6 +218,7 @@ export function showErrorToast(
duration?: number;
},
) {
playUiSound("error");
return showToast({
type: "error",
title,
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SOUND_CONTROL_SELECTOR,
SOUND_GAP_MS,
SOUNDS,
soundForControl,
} from "./ui-sounds.ts";
/**
* What is pinned here is "never annoying": every sound is short and quiet,
* starts without a click, can not repeat faster than its gap, and only the
* controls a person deliberately used make one.
*/
const length = (voices) =>
Math.max(...voices.map((v) => v.delay + v.attack + v.decay));
test("every sound is short, quiet and starts softly", () => {
for (const [name, voices] of Object.entries(SOUNDS)) {
assert.ok(voices.length > 0, name);
assert.ok(length(voices) <= 0.3, `${name} lasts ${length(voices)} s`);
for (const voice of voices) {
assert.ok(voice.peak > 0 && voice.peak <= 0.1, `${name} peak`);
assert.ok(voice.attack >= 0.001, `${name} must not start with a click`);
}
assert.ok(SOUND_GAP_MS[name] > 0, `${name} needs a gap`);
}
assert.ok(length(SOUNDS.tap) <= 0.04, "a tap is a tick, not a tone");
assert.ok(
SOUND_GAP_MS.error >= 1000 && SOUND_GAP_MS.success >= 1000,
"a run of results makes one sound, not a burst",
);
});
const control = (overrides) => ({
role: null,
tag: "button",
checked: null,
disabled: false,
silenced: false,
...overrides,
});
test("a button taps when pressed, and once only", () => {
assert.equal(soundForControl(control({}), "press", false), "tap");
assert.equal(
soundForControl(control({}), "activate", false),
null,
"the click after a pointer press must not tap again",
);
assert.equal(soundForControl(control({}), "activate", true), "tap");
assert.equal(
soundForControl(control({ tag: "div", role: "option" }), "press", false),
"tap",
);
assert.equal(soundForControl(control({ tag: "a" }), "press", false), "tap");
});
test("a toggle sounds its new state once it changed", () => {
const on = control({ role: "switch", checked: "true" });
const off = control({ role: "checkbox", checked: "false" });
assert.equal(soundForControl(on, "press", false), null);
assert.equal(soundForControl(on, "activate", false), "toggleOn");
assert.equal(soundForControl(off, "activate", true), "toggleOff");
});
test("disabled, silenced and passive elements make no sound", () => {
assert.equal(
soundForControl(control({ disabled: true }), "press", false),
null,
);
assert.equal(
soundForControl(control({ silenced: true }), "press", false),
null,
);
for (const tag of ["div", "input", "span", "textarea"]) {
assert.equal(soundForControl(control({ tag }), "press", false), null, tag);
}
assert.ok(!SOUND_CONTROL_SELECTOR.includes("input"));
assert.ok(SOUND_CONTROL_SELECTOR.includes('[role="switch"]'));
});
+358
View File
@@ -0,0 +1,358 @@
/**
* Interface sounds: short, quiet, synthesized at play time. Nothing is
* recorded or loaded, and a sound only plays while the window is in front.
*
* Each sound is a few voices: a sine, triangle or noise burst with a soft
* attack (no click at the start) and an exponential decay, like a small
* physical switch. Every play shifts the pitch and level a little, so a run
* of presses never sounds like a machine repeating one sample.
*/
export type UiSound = "tap" | "toggleOn" | "toggleOff" | "success" | "error";
export interface SoundVoice {
wave: "sine" | "triangle" | "noise";
/** Hz at the start. Ignored for noise. */
frequency: number;
/** Hz at the end of the decay, for a slight glide. */
endFrequency?: number;
/** Seconds after the sound starts. */
delay: number;
attack: number;
decay: number;
/** Peak level before the master level. */
peak: number;
/** A band-pass that shapes a noise burst into a soft tick. */
bandPass?: { frequency: number; q: number };
}
export const SOUNDS: Record<UiSound, SoundVoice[]> = {
tap: [
{
wave: "sine",
frequency: 1750,
delay: 0,
attack: 0.002,
decay: 0.03,
peak: 0.07,
},
{
wave: "noise",
frequency: 0,
delay: 0,
attack: 0.001,
decay: 0.012,
peak: 0.035,
bandPass: { frequency: 3600, q: 1.1 },
},
],
toggleOn: [
{
wave: "sine",
frequency: 1100,
delay: 0,
attack: 0.002,
decay: 0.035,
peak: 0.06,
},
{
wave: "sine",
frequency: 1650,
delay: 0.045,
attack: 0.002,
decay: 0.05,
peak: 0.06,
},
],
toggleOff: [
{
wave: "sine",
frequency: 1650,
delay: 0,
attack: 0.002,
decay: 0.035,
peak: 0.055,
},
{
wave: "sine",
frequency: 1100,
delay: 0.045,
attack: 0.002,
decay: 0.05,
peak: 0.055,
},
],
success: [
{
wave: "sine",
frequency: 880,
delay: 0,
attack: 0.004,
decay: 0.16,
peak: 0.05,
},
{
wave: "sine",
frequency: 1318.5,
delay: 0.07,
attack: 0.004,
decay: 0.22,
peak: 0.045,
},
],
error: [
{
wave: "triangle",
frequency: 330,
endFrequency: 247,
delay: 0,
attack: 0.004,
decay: 0.14,
peak: 0.08,
},
{
wave: "triangle",
frequency: 262,
endFrequency: 196,
delay: 0.09,
attack: 0.004,
decay: 0.16,
peak: 0.06,
},
],
};
/** The shortest time between two plays of one sound, in ms. */
export const SOUND_GAP_MS: Record<UiSound, number> = {
tap: 35,
toggleOn: 60,
toggleOff: 60,
success: 1200,
error: 1200,
};
/** What the sound picker needs to know about the control that was used. */
export interface ControlInfo {
role: string | null;
tag: string;
/** `aria-checked` after the change, for switches and checkboxes. */
checked: string | null;
disabled: boolean;
/** `data-sound="off"` on the control or a parent. */
silenced: boolean;
}
const PRESSABLE_ROLES = new Set([
"button",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"tab",
"link",
]);
const TOGGLE_ROLES = new Set(["switch", "checkbox", "radio"]);
/**
* `press` is a pointer going down: a button sounds at once, as a real one
* does. `activate` is the click that follows, or a key press: a toggle sounds
* then, once it holds its new state, and a button sounds only when a key
* pressed it (a pointer already made it sound).
*/
export function soundForControl(
control: ControlInfo,
phase: "press" | "activate",
byKeyboard: boolean,
): UiSound | null {
if (control.disabled || control.silenced) return null;
const role = control.role ?? implicitRole(control.tag);
if (role && TOGGLE_ROLES.has(role)) {
if (phase !== "activate") return null;
return control.checked === "true" ? "toggleOn" : "toggleOff";
}
if (role && PRESSABLE_ROLES.has(role)) {
if (phase === "press") return "tap";
return byKeyboard ? "tap" : null;
}
return null;
}
function implicitRole(tag: string): string | null {
switch (tag) {
case "button":
case "summary":
return "button";
case "a":
return "link";
default:
return null;
}
}
/** The elements that can make a sound; the closest one to the event wins. */
export const SOUND_CONTROL_SELECTOR = [
"button",
"summary",
"a[href]",
...[...PRESSABLE_ROLES, ...TOGGLE_ROLES].map((role) => `[role="${role}"]`),
].join(",");
let enabled = true;
let context: AudioContext | null = null;
let output: GainNode | null = null;
let noise: AudioBuffer | null = null;
const lastPlayed = new Map<UiSound, number>();
export function setUiSoundsEnabled(on: boolean) {
enabled = on;
}
function audio(): AudioContext | null {
if (context) return context;
const Context = window.AudioContext;
if (!Context) return null;
context = new Context({ latencyHint: "interactive" });
// A gentle low-pass takes the edge off every voice; the master level keeps
// the whole set well under anything else a computer plays.
const soften = context.createBiquadFilter();
soften.type = "lowpass";
soften.frequency.value = 7000;
output = context.createGain();
output.gain.value = 0.6;
output.connect(soften);
soften.connect(context.destination);
noise = context.createBuffer(
1,
Math.ceil(context.sampleRate * 0.05),
context.sampleRate,
);
const samples = noise.getChannelData(0);
for (let i = 0; i < samples.length; i++) samples[i] = Math.random() * 2 - 1;
return context;
}
function schedule(
ctx: AudioContext,
voice: SoundVoice,
at: number,
pitch: number,
level: number,
) {
if (!output) return;
const start = at + voice.delay;
const end = start + voice.attack + voice.decay;
const envelope = ctx.createGain();
envelope.gain.setValueAtTime(0, start);
envelope.gain.linearRampToValueAtTime(
voice.peak * level,
start + voice.attack,
);
envelope.gain.exponentialRampToValueAtTime(0.0001, end);
let source: AudioScheduledSourceNode;
if (voice.wave === "noise") {
const buffer = ctx.createBufferSource();
buffer.buffer = noise;
source = buffer;
} else {
const oscillator = ctx.createOscillator();
oscillator.type = voice.wave;
oscillator.frequency.setValueAtTime(voice.frequency * pitch, start);
if (voice.endFrequency) {
oscillator.frequency.exponentialRampToValueAtTime(
voice.endFrequency * pitch,
end,
);
}
source = oscillator;
}
let last: AudioNode = source;
if (voice.bandPass) {
const band = ctx.createBiquadFilter();
band.type = "bandpass";
band.frequency.value = voice.bandPass.frequency * pitch;
band.Q.value = voice.bandPass.q;
last.connect(band);
last = band;
}
last.connect(envelope);
envelope.connect(output);
source.start(start);
source.stop(end + 0.02);
}
export function playUiSound(sound: UiSound) {
if (!enabled || typeof window === "undefined") return;
if (document.visibilityState !== "visible" || !document.hasFocus()) return;
const now = performance.now();
if (
now - (lastPlayed.get(sound) ?? Number.NEGATIVE_INFINITY) <
SOUND_GAP_MS[sound]
) {
return;
}
lastPlayed.set(sound, now);
try {
const ctx = audio();
if (!ctx) return;
if (ctx.state === "suspended") void ctx.resume();
const pitch = 1 + (Math.random() - 0.5) * 0.08;
const level = 1 + (Math.random() - 0.5) * 0.2;
const at = ctx.currentTime + 0.004;
for (const voice of SOUNDS[sound]) schedule(ctx, voice, at, pitch, level);
} catch (error) {
console.debug("UI sound not played:", error);
}
}
function controlInfo(element: Element): ControlInfo {
return {
role: element.getAttribute("role"),
tag: element.tagName.toLowerCase(),
checked: element.getAttribute("aria-checked"),
disabled:
element.hasAttribute("disabled") ||
element.getAttribute("aria-disabled") === "true" ||
element.getAttribute("data-disabled") === "true",
silenced: element.closest('[data-sound="off"]') !== null,
};
}
/** Listen for presses and toggles across the whole window. */
export function installUiSounds(): () => void {
const control = (target: EventTarget | null) =>
target instanceof Element ? target.closest(SOUND_CONTROL_SELECTOR) : null;
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
const element = control(event.target);
if (!element) return;
const sound = soundForControl(controlInfo(element), "press", false);
if (sound) playUiSound(sound);
};
const onClick = (event: MouseEvent) => {
const element = control(event.target);
if (!element) return;
// A click with no pointer behind it (detail 0) came from Enter or Space.
const byKeyboard = event.detail === 0;
// Read a toggle's state once the app has drawn the change.
requestAnimationFrame(() => {
const sound = soundForControl(
controlInfo(element),
"activate",
byKeyboard,
);
if (sound) playUiSound(sound);
});
};
window.addEventListener("pointerdown", onPointerDown, {
capture: true,
passive: true,
});
window.addEventListener("click", onClick, { capture: true, passive: true });
return () => {
window.removeEventListener("pointerdown", onPointerDown, { capture: true });
window.removeEventListener("click", onClick, { capture: true });
};
}