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
223 changes: 223 additions & 0 deletions apps/web/src/lib/SoundSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<script lang="ts">
import { Settings, X, Upload, Trash2, Play } from "@lucide/svelte";
import {
getVolume,
setVolume,
getCustomSound,
setCustomSound,
removeCustomSound,
fileToDataUrl,
playJoinSound,
playLeaveSound,
} from "$lib/sounds";
import { onMount } from "svelte";

let open = $state(false);
let volume = $state(0.5);
let hasJoinSound = $state(false);
let hasLeaveSound = $state(false);

onMount(() => {
volume = getVolume();
hasJoinSound = !!getCustomSound("join");
hasLeaveSound = !!getCustomSound("leave");
});

function onVolumeChange(e: Event) {
const v = parseFloat((e.target as HTMLInputElement).value);
volume = v;
setVolume(v);
}

async function uploadSound(type: "join" | "leave") {
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const dataUrl = await fileToDataUrl(file);
if (!dataUrl) {
alert("File too large. Max 512 KB.");
return;
}
setCustomSound(type, dataUrl);
if (type === "join") hasJoinSound = true;
else hasLeaveSound = true;
};
input.click();
}

function clearSound(type: "join" | "leave") {
removeCustomSound(type);
if (type === "join") hasJoinSound = false;
else hasLeaveSound = false;
}

function preview(type: "join" | "leave") {
if (type === "join") playJoinSound();
else playLeaveSound();
}
</script>

<!-- Gear icon button -->
<button
type="button"
class="settings-toggle"
aria-label="Sound settings"
onclick={() => (open = true)}
>
<Settings class="theme-icon" strokeWidth={1.8} />
</button>

<!-- Modal backdrop + dialog -->
{#if open}
<div
class="fixed inset-0 z-[200] flex items-center justify-center p-4 modal-overlay backdrop-blur-md"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) open = false;
}}
onkeydown={(e) => {
if (e.key === "Escape") open = false;
}}
>
<div
class="border-2 border-voca-border bg-voca-bg text-voca-fg p-6 max-w-sm w-full"
role="dialog"
aria-label="Sound settings"
>
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-bold">SOUND SETTINGS</h2>
<button
type="button"
class="brutalist-button !p-1"
onclick={() => (open = false)}
aria-label="Close"
>
<X size={16} strokeWidth={2} />
</button>
</div>

<!-- Volume -->
<label class="block text-xs font-bold mb-1">
VOLUME: {Math.round(volume * 100)}%
<input
type="range"
min="0"
max="1"
step="0.05"
value={volume}
oninput={onVolumeChange}
class="w-full mb-6 accent-voca-fg"
/>
</label>

<!-- Join sound -->
<div class="mb-4">
<p class="text-xs font-bold mb-2">JOIN SOUND</p>
<div class="flex gap-2">
<button
type="button"
class="brutalist-button text-xs flex-1"
onclick={() => uploadSound("join")}
>
<span class="inline-flex items-center gap-1">
<Upload size={12} />
{hasJoinSound ? "REPLACE" : "UPLOAD"}
</span>
</button>
<button
type="button"
class="brutalist-button text-xs"
onclick={() => preview("join")}
aria-label="Preview join sound"
>
<Play size={12} />
</button>
{#if hasJoinSound}
<button
type="button"
class="brutalist-button text-xs"
onclick={() => clearSound("join")}
aria-label="Remove custom join sound"
>
<Trash2 size={12} />
</button>
{/if}
</div>
<p class="text-xs opacity-50 mt-1">
{hasJoinSound ? "Custom sound set" : "Using default tone"}
</p>
</div>

<!-- Leave sound -->
<div class="mb-2">
<p class="text-xs font-bold mb-2">LEAVE SOUND</p>
<div class="flex gap-2">
<button
type="button"
class="brutalist-button text-xs flex-1"
onclick={() => uploadSound("leave")}
>
<span class="inline-flex items-center gap-1">
<Upload size={12} />
{hasLeaveSound ? "REPLACE" : "UPLOAD"}
</span>
</button>
<button
type="button"
class="brutalist-button text-xs"
onclick={() => preview("leave")}
aria-label="Preview leave sound"
>
<Play size={12} />
</button>
{#if hasLeaveSound}
<button
type="button"
class="brutalist-button text-xs"
onclick={() => clearSound("leave")}
aria-label="Remove custom leave sound"
>
<Trash2 size={12} />
</button>
{/if}
</div>
<p class="text-xs opacity-50 mt-1">
{hasLeaveSound ? "Custom sound set" : "Using default tone"}
</p>
</div>

<p class="text-xs opacity-40 mt-4">
Max file size: 512 KB. Accepts mp3, wav, ogg.
</p>
</div>
</div>
{/if}

<style>
.settings-toggle {
position: fixed;
top: 0.75rem;
left: 0.75rem;
border: 1px solid var(--color-voca-border);
background: var(--color-voca-bg);
color: var(--color-voca-fg);
width: 2rem;
height: 2rem;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 120;
}

@media (hover: hover) {
.settings-toggle:hover {
background: var(--color-voca-fg);
color: var(--color-voca-bg);
}
}
</style>
133 changes: 133 additions & 0 deletions apps/web/src/lib/sounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Notification sounds for peer join/leave events.
*
* Supports custom sounds stored as base64 data URLs in localStorage,
* with configurable volume. Falls back to Web Audio API generated tones.
*/

const STORAGE_KEYS = {
joinSound: 'voca:sound:join',
leaveSound: 'voca:sound:leave',
volume: 'voca:sound:volume',
} as const;

let audioCtx: AudioContext | null = null;

function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return audioCtx;
}

/** Get the notification volume (0-1). Defaults to 0.5. */
export function getVolume(): number {
try {
const stored = localStorage.getItem(STORAGE_KEYS.volume);
if (stored !== null) return Math.max(0, Math.min(1, parseFloat(stored)));
} catch { }
return 0.5;
}

/** Set the notification volume (0-1). */
export function setVolume(v: number) {
try {
localStorage.setItem(STORAGE_KEYS.volume, String(Math.max(0, Math.min(1, v))));
} catch { }
}

/** Get a custom sound data URL from localStorage. */
export function getCustomSound(type: 'join' | 'leave'): string | null {
try {
return localStorage.getItem(type === 'join' ? STORAGE_KEYS.joinSound : STORAGE_KEYS.leaveSound);
} catch {
return null;
}
}

/** Store a custom sound as a base64 data URL. */
export function setCustomSound(type: 'join' | 'leave', dataUrl: string) {
try {
localStorage.setItem(
type === 'join' ? STORAGE_KEYS.joinSound : STORAGE_KEYS.leaveSound,
dataUrl,
);
} catch { }
}

/** Remove a custom sound, reverting to the default tone. */
export function removeCustomSound(type: 'join' | 'leave') {
try {
localStorage.removeItem(type === 'join' ? STORAGE_KEYS.joinSound : STORAGE_KEYS.leaveSound);
} catch { }
}

/**
* Read a File as a base64 data URL.
* Returns null if the file is too large (> 512KB).
*/
export function fileToDataUrl(file: File): Promise<string | null> {
if (file.size > 512 * 1024) return Promise.resolve(null);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}

// ---------- playback ----------

function playCustom(dataUrl: string, volume: number) {
const audio = new Audio(dataUrl);
audio.volume = volume;
audio.play().catch(() => { });
}

function playGeneratedTone(frequencies: number[], durations: number[], volume: number) {
const ctx = getAudioContext();
let time = ctx.currentTime;

for (let i = 0; i < frequencies.length; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();

osc.type = 'sine';
osc.frequency.setValueAtTime(frequencies[i], time);

gain.gain.setValueAtTime(volume * 0.25, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + durations[i]);

osc.connect(gain);
gain.connect(ctx.destination);

osc.start(time);
osc.stop(time + durations[i]);

time += durations[i] * 0.6;
}
}

/** Play the join notification sound. */
export function playJoinSound() {
const vol = getVolume();
if (vol === 0) return;
const custom = getCustomSound('join');
if (custom) {
playCustom(custom, vol);
} else {
playGeneratedTone([523.25, 659.25], [0.12, 0.18], vol); // C5 → E5
}
}

/** Play the leave notification sound. */
export function playLeaveSound() {
const vol = getVolume();
if (vol === 0) return;
const custom = getCustomSound('leave');
if (custom) {
playCustom(custom, vol);
} else {
playGeneratedTone([493.88, 392.0], [0.12, 0.2], vol); // B4 → G4
}
}
3 changes: 3 additions & 0 deletions apps/web/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { Monitor, Moon, Sun } from "@lucide/svelte";
import { VocaClient, validatePassword } from "@treyorr/voca-svelte";
import { onMount } from "svelte";
import SoundSettings from "$lib/SoundSettings.svelte";

let isCreating = $state(false);
let error = $state<string | null>(null);
Expand Down Expand Up @@ -67,6 +68,8 @@
<title>voca.vc</title>
</svelte:head>

<SoundSettings />

<div class="theme-toggle" role="group" aria-label="Color theme">
<button
type="button"
Expand Down
Loading