Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/app/InterfaceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
/>
</div>

{switchRow(
"interface-sounds",
t(locale, "set.soundTitle"),
t(locale, "set.soundDescription"),
preferences.enabledSounds,
set("enabledSounds"),
)}

{switchRow(
"interface-reduced-motion",
t(locale, "set.animations"),
Expand Down
75 changes: 1 addition & 74 deletions src/app/Toasts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<Toast["kind"], ReactNode> = {
success: <path d="M20 6 9 17l-5-5" />,
Expand Down
68 changes: 5 additions & 63 deletions src/click-sound.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,9 @@
let context: AudioContext | null = null;
let buffer: AudioBuffer | null = null;
let preparePromise: Promise<void> | null = null;
let audioRequest: Promise<ArrayBuffer> | 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<ArrayBuffer> {
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<void> {
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<void> {
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);
}
2 changes: 0 additions & 2 deletions src/control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,7 +30,6 @@ function isChromium(): boolean {

registerServiceWorker();
mountOfflineBanner();
initClickSound();

function LaunchHero(): ReactNode {
return (
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const de: Record<I18nKey, string> = {
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const es: Record<I18nKey, string> = {
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const fr: Record<I18nKey, string> = {
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const ja: Record<I18nKey, string> = {
"test.btn.forward": "進む",
"set.title": "設定",
"set.back": "デバイスに戻る",
"set.soundTitle": "サウンド",
"set.soundDescription": "マウスクリック音と新しい接続音を有効にしてください",
"set.profiles": "プロファイル",
"set.profileKey": "プロファイルキー",
"set.profileKeyBody": "このマウスの設定を、同じモデルの別の個体に移せるコピー&ペースト用のキーです。相手側の設定画面に貼り付けると同じ設定が読み込まれます。",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const ko: Record<I18nKey, string> = {
"test.btn.forward": "앞으로",
"set.title": "설정",
"set.back": "장치로 돌아가기",
"set.soundTitle": "사운드",
"set.soundDescription": "마우스 클릭 소리나 새 연결 소리 같은 소리를 활성화하세요.",
"set.profiles": "프로필",
"set.profileKey": "프로필 키",
"set.profileKeyBody": "이 마우스의 설정을 같은 모델의 다른 기기로 옮길 수 있는 복사용 키입니다. 그쪽 설정에 붙여넣으면 동일한 설정을 불러올 수 있습니다.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const pt: Record<I18nKey, string> = {
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const ru: Record<I18nKey, string> = {
"test.btn.forward": "Вперёд",
"set.title": "Настройки",
"set.back": "Назад к устройству",
"set.soundTitle": "Звук",
"set.soundDescription": "Включите звуки, такие как щелчки мыши и новые соединения.",
"set.profiles": "ПРОФИЛИ",
"set.profileKey": "Ключ профиля",
"set.profileKeyBody": "Ключ для копирования: переносит настройки этой мыши на другую такой же модели. Вставьте его в Настройках там, чтобы получить ту же конфигурацию.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const vi: Record<I18nKey, string> = {
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n-zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const zh: Record<I18nKey, string> = {
"test.btn.forward": "前进",
"set.title": "设置",
"set.back": "返回设备",
"set.soundTitle": "音效",
"set.soundDescription": "启用鼠标点击和新连接等声音",
"set.profiles": "配置文件",
"set.profileKey": "配置密钥",
"set.profileKeyBody": "可复制粘贴的密钥,能把这只鼠标的设置带到同型号的另一台设备上。在那台设备的设置里粘贴它,即可加载相同的配置。",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/interface-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/interface-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface InterfacePreferences {
expandSections: boolean;
showExperimental: boolean;
instantFlash: boolean;
enabledSounds: boolean;
glassIntensity: number;
}

Expand Down Expand Up @@ -66,6 +67,7 @@ export const DEFAULT_INTERFACE_PREFERENCES: InterfacePreferences = {
expandSections: false,
showExperimental: true,
instantFlash: false,
enabledSounds: true,
glassIntensity: 100,
};

Expand Down Expand Up @@ -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 {
Expand Down
Loading