Binary file not shown.
+5
-2
@@ -18,6 +18,7 @@ import {
|
|||||||
RouteErrorBoundary,
|
RouteErrorBoundary,
|
||||||
TopLevelErrorBoundary,
|
TopLevelErrorBoundary,
|
||||||
} from "@/components/app-error-boundary";
|
} from "@/components/app-error-boundary";
|
||||||
|
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
@@ -87,8 +88,10 @@ const AppWithProviders = () => (
|
|||||||
<TopLevelErrorBoundary>
|
<TopLevelErrorBoundary>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<SoundEffectsProvider>
|
||||||
<Toaster />
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</SoundEffectsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</TopLevelErrorBoundary>
|
</TopLevelErrorBoundary>
|
||||||
|
|||||||
Vendored
+5
@@ -2,3 +2,8 @@ declare module "*.wav" {
|
|||||||
const src: string;
|
const src: string;
|
||||||
export default src;
|
export default src;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "*.mp3" {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText } from "lucide-react";
|
import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2 } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -10,6 +10,7 @@ import { Muted } from "@/components/ui/typography";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { CopyableEmail } from "@/components/copyable-email";
|
import { CopyableEmail } from "@/components/copyable-email";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useSoundEffectsStore } from "@/stores/sound-effects-store";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { logError, toUserMessage } from "@/lib/errors";
|
import { logError, toUserMessage } from "@/lib/errors";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -73,6 +74,8 @@ export default function SettingsPage() {
|
|||||||
const [emailNotifications, setEmailNotifications] = useState(
|
const [emailNotifications, setEmailNotifications] = useState(
|
||||||
user?.email_notifications_enabled ?? true,
|
user?.email_notifications_enabled ?? true,
|
||||||
);
|
);
|
||||||
|
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
|
||||||
|
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
|
||||||
const [version, setVersion] = useState<string>();
|
const [version, setVersion] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,6 +149,19 @@ export default function SettingsPage() {
|
|||||||
onCheckedChange={handleToggleEmailNotifications}
|
onCheckedChange={handleToggleEmailNotifications}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
|
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||||
|
<Volume2 className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||||
|
Sound effects
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={soundEffectsEnabled}
|
||||||
|
onCheckedChange={setSoundEffectsEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Web Audio engine for HUD sound effects.
|
||||||
|
*
|
||||||
|
* Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio
|
||||||
|
* can't overlap the same source and stalls when triggered rapidly). Adds a
|
||||||
|
* small pitch jitter on each play so repeated sounds don't feel robotic — the
|
||||||
|
* trick games use to keep keypress chirps from grating.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logError } from "@/lib/errors";
|
||||||
|
|
||||||
|
type EngineOptions = {
|
||||||
|
/** Master volume 0..1 applied on top of per-call volume. */
|
||||||
|
masterVolume: number;
|
||||||
|
/** When false, all play() calls are no-ops. */
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlayOptions = {
|
||||||
|
/** 0..1, multiplied with master volume. */
|
||||||
|
volume?: number;
|
||||||
|
/** ±fraction of playbackRate jitter; 0.05 = ±5%. */
|
||||||
|
pitchVariance?: number;
|
||||||
|
/** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */
|
||||||
|
throttleMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SoundEffectsEngine {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private masterGain: GainNode | null = null;
|
||||||
|
private limiter: DynamicsCompressorNode | null = null;
|
||||||
|
private buffers = new Map<string, AudioBuffer>();
|
||||||
|
private loading = new Map<string, Promise<AudioBuffer | null>>();
|
||||||
|
private lastPlayedAt = new Map<string, number>();
|
||||||
|
private options: EngineOptions = { masterVolume: 0.02, enabled: true };
|
||||||
|
|
||||||
|
setOptions(next: Partial<EngineOptions>) {
|
||||||
|
this.options = { ...this.options, ...next };
|
||||||
|
if (this.masterGain) {
|
||||||
|
this.masterGain.gain.value = this.options.masterVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */
|
||||||
|
private getContext(): AudioContext | null {
|
||||||
|
if (this.ctx) return this.ctx;
|
||||||
|
try {
|
||||||
|
const Ctor =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext;
|
||||||
|
if (!Ctor) return null;
|
||||||
|
this.ctx = new Ctor();
|
||||||
|
this.masterGain = this.ctx.createGain();
|
||||||
|
this.masterGain.gain.value = this.options.masterVolume;
|
||||||
|
|
||||||
|
// Brickwall limiter: caps peaks regardless of system volume so the
|
||||||
|
// click stays controlled when the OS volume is cranked. Threshold sets
|
||||||
|
// the ceiling; high ratio + fast attack make it a hard limiter.
|
||||||
|
this.limiter = this.ctx.createDynamicsCompressor();
|
||||||
|
this.limiter.threshold.value = -24; // dB ceiling for peaks
|
||||||
|
this.limiter.knee.value = 0;
|
||||||
|
this.limiter.ratio.value = 20;
|
||||||
|
this.limiter.attack.value = 0.001;
|
||||||
|
this.limiter.release.value = 0.08;
|
||||||
|
|
||||||
|
this.masterGain.connect(this.limiter);
|
||||||
|
this.limiter.connect(this.ctx.destination);
|
||||||
|
return this.ctx;
|
||||||
|
} catch (err) {
|
||||||
|
logError(err, { scope: "soundEffects.createContext" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload a sound by name from a URL. Safe to call multiple times — caches.
|
||||||
|
* Missing/failed loads resolve to null and the sound silently no-ops on play.
|
||||||
|
*/
|
||||||
|
preload(name: string, url: string): Promise<AudioBuffer | null> {
|
||||||
|
if (this.buffers.has(name)) {
|
||||||
|
return Promise.resolve(this.buffers.get(name)!);
|
||||||
|
}
|
||||||
|
const existing = this.loading.get(name);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const promise = fetch(url)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`);
|
||||||
|
return res.arrayBuffer();
|
||||||
|
})
|
||||||
|
.then((data) => ctx.decodeAudioData(data))
|
||||||
|
.then((buffer) => {
|
||||||
|
this.buffers.set(name, buffer);
|
||||||
|
return buffer;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logError(err, { scope: "soundEffects.preload", name });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loading.set(name, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
play(name: string, opts: PlayOptions = {}) {
|
||||||
|
if (!this.options.enabled) return;
|
||||||
|
|
||||||
|
const buffer = this.buffers.get(name);
|
||||||
|
if (!buffer) return; // silently skip if not loaded
|
||||||
|
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const master = this.masterGain;
|
||||||
|
if (!ctx || !master) return;
|
||||||
|
|
||||||
|
// Resume suspended context (autoplay policies). Resume is async but
|
||||||
|
// start(0) is queued correctly once the context resumes.
|
||||||
|
if (ctx.state === "suspended") {
|
||||||
|
void ctx.resume().catch((err) =>
|
||||||
|
logError(err, { scope: "soundEffects.resume" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const throttleMs = opts.throttleMs ?? 15;
|
||||||
|
const now = ctx.currentTime * 1000;
|
||||||
|
const last = this.lastPlayedAt.get(name) ?? -Infinity;
|
||||||
|
if (now - last < throttleMs) return;
|
||||||
|
this.lastPlayedAt.set(name, now);
|
||||||
|
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
|
||||||
|
const variance = opts.pitchVariance ?? 0.04;
|
||||||
|
if (variance > 0) {
|
||||||
|
// Centered around 1.0; e.g. variance 0.04 → 0.96..1.04
|
||||||
|
const jitter = 1 + (Math.random() * 2 - 1) * variance;
|
||||||
|
source.playbackRate.value = jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.value = opts.volume ?? 1;
|
||||||
|
|
||||||
|
source.connect(gain).connect(master);
|
||||||
|
source.start(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const soundEffects = new SoundEffectsEngine();
|
||||||
|
export type { PlayOptions };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { preloadAllSounds, playSound } from "./sounds";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts global HUD sound behavior:
|
||||||
|
* - Preloads every registered sound file once.
|
||||||
|
* - Plays "click" on any click whose target (or ancestor) is recognizably
|
||||||
|
* clickable: <button>, <a>, role="button"/etc., or `cursor: pointer`.
|
||||||
|
* Centralizing here means div/span clickables get sound automatically
|
||||||
|
* without touching every call site.
|
||||||
|
* - Plays "key-tap" on keydown OUTSIDE text inputs, so navigating menus and
|
||||||
|
* overlays feels tactile but typing into compose stays silent.
|
||||||
|
*/
|
||||||
|
export function SoundEffectsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
preloadAllSounds();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (e.button !== 0) return; // left click only
|
||||||
|
if (!(e.target instanceof Element)) return;
|
||||||
|
if (isClickable(e.target)) playSound("click");
|
||||||
|
};
|
||||||
|
document.addEventListener("click", handler);
|
||||||
|
return () => document.removeEventListener("click", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.repeat) return;
|
||||||
|
if (isPureModifier(e.key)) return;
|
||||||
|
if (isTextInputTarget(e.target)) return;
|
||||||
|
|
||||||
|
if (e.key === "Escape" || e.key === "Enter" || e.key === "Tab") {
|
||||||
|
playSound("key-action");
|
||||||
|
} else {
|
||||||
|
playSound("key-tap");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLICKABLE_ROLES = new Set([
|
||||||
|
"button",
|
||||||
|
"link",
|
||||||
|
"menuitem",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"menuitemradio",
|
||||||
|
"tab",
|
||||||
|
"switch",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"option",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks up to MAX_DEPTH ancestors looking for a recognizably-clickable element.
|
||||||
|
* Tag/role checks are cheap — getComputedStyle is the fallback for div-style
|
||||||
|
* clickables that only signal intent through `cursor: pointer`.
|
||||||
|
*/
|
||||||
|
function isClickable(target: Element): boolean {
|
||||||
|
const MAX_DEPTH = 6;
|
||||||
|
let el: Element | null = target;
|
||||||
|
for (let depth = 0; el && depth < MAX_DEPTH; depth++, el = el.parentElement) {
|
||||||
|
const tag = el.tagName;
|
||||||
|
if (tag === "BUTTON" || tag === "A" || tag === "SUMMARY") return true;
|
||||||
|
const role = el.getAttribute("role");
|
||||||
|
if (role && CLICKABLE_ROLES.has(role)) return true;
|
||||||
|
if (window.getComputedStyle(el).cursor === "pointer") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPureModifier(key: string): boolean {
|
||||||
|
return key === "Shift" || key === "Control" || key === "Meta" || key === "Alt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextInputTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === "TEXTAREA") return true;
|
||||||
|
if (tag === "INPUT") {
|
||||||
|
const type = (target as HTMLInputElement).type;
|
||||||
|
return (
|
||||||
|
type === "" ||
|
||||||
|
type === "text" ||
|
||||||
|
type === "search" ||
|
||||||
|
type === "email" ||
|
||||||
|
type === "url" ||
|
||||||
|
type === "password" ||
|
||||||
|
type === "tel" ||
|
||||||
|
type === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Sound registry. To add a new effect, drop the file under
|
||||||
|
* `js/assets/sounds/` and add a line to REGISTERED below.
|
||||||
|
*
|
||||||
|
* Names referenced from code that aren't in REGISTERED fall back to "click",
|
||||||
|
* so the system feels alive end-to-end with one asset and stays consistent
|
||||||
|
* as more are added.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import clickUrl from "../../../assets/sounds/click.mp3";
|
||||||
|
import { soundEffects, type PlayOptions } from "./engine";
|
||||||
|
|
||||||
|
/** Effect names referenced from code. Unmapped names fall back to "click". */
|
||||||
|
export type SoundName =
|
||||||
|
| "click"
|
||||||
|
| "key-tap"
|
||||||
|
| "key-action"
|
||||||
|
| "submit"
|
||||||
|
| "error"
|
||||||
|
| "open"
|
||||||
|
| "close";
|
||||||
|
|
||||||
|
/** Files that exist on disk. Add a line when you drop a new file. */
|
||||||
|
const REGISTERED: Partial<Record<SoundName, string>> = {
|
||||||
|
click: clickUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-sound default play options. Tuned for HUD feel — subtle and slightly varied. */
|
||||||
|
const SOUND_DEFAULTS: Record<SoundName, PlayOptions> = {
|
||||||
|
click: { volume: 0.7, pitchVariance: 0.04 },
|
||||||
|
"key-tap": { volume: 0.4, pitchVariance: 0.08, throttleMs: 25 },
|
||||||
|
"key-action": { volume: 0.6, pitchVariance: 0.04 },
|
||||||
|
submit: { volume: 0.85, pitchVariance: 0.02 },
|
||||||
|
error: { volume: 0.7, pitchVariance: 0 },
|
||||||
|
open: { volume: 0.6, pitchVariance: 0.03 },
|
||||||
|
close: { volume: 0.5, pitchVariance: 0.03 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK: SoundName = "click";
|
||||||
|
|
||||||
|
let preloaded = false;
|
||||||
|
|
||||||
|
/** Preload every registered sound file. Idempotent. Called once at app start. */
|
||||||
|
export function preloadAllSounds(): void {
|
||||||
|
if (preloaded) return;
|
||||||
|
preloaded = true;
|
||||||
|
for (const [name, url] of Object.entries(REGISTERED)) {
|
||||||
|
if (url) void soundEffects.preload(name, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play a sound by name. Falls back to "click" if no dedicated file is registered. */
|
||||||
|
export function playSound(name: SoundName, override?: PlayOptions) {
|
||||||
|
const defaults = SOUND_DEFAULTS[name];
|
||||||
|
const resolvedName = REGISTERED[name] ? name : FALLBACK;
|
||||||
|
soundEffects.play(resolvedName, { ...defaults, ...override });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { playSound, type SoundName } from "./sounds";
|
||||||
|
import type { PlayOptions } from "./engine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stable `play(name, opts?)` function. The engine respects the
|
||||||
|
* global enabled/volume state from the sound effects store, so callers don't
|
||||||
|
* need to subscribe themselves.
|
||||||
|
*/
|
||||||
|
export function useSoundEffect() {
|
||||||
|
const play = useCallback((name: SoundName, opts?: PlayOptions) => {
|
||||||
|
playSound(name, opts);
|
||||||
|
}, []);
|
||||||
|
return { play };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { soundEffects } from "@/lib/sound-effects/engine";
|
||||||
|
|
||||||
|
const ENABLED_KEY = "llink:soundEffects-enabled";
|
||||||
|
const DEFAULT_ENABLED = true;
|
||||||
|
|
||||||
|
function loadEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(ENABLED_KEY);
|
||||||
|
return v === null ? DEFAULT_ENABLED : v === "true";
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ENABLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SoundEffectsState {
|
||||||
|
enabled: boolean;
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSoundEffectsStore = create<SoundEffectsState>((set) => {
|
||||||
|
const initialEnabled = loadEnabled();
|
||||||
|
|
||||||
|
// Sync engine with persisted state on first load. Master volume is a fixed
|
||||||
|
// tuning constant in the engine — no UI control.
|
||||||
|
soundEffects.setOptions({ enabled: initialEnabled });
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: initialEnabled,
|
||||||
|
setEnabled: (enabled) => {
|
||||||
|
set({ enabled });
|
||||||
|
soundEffects.setOptions({ enabled });
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ENABLED_KEY, String(enabled));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user