825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
4.3 KiB
TypeScript
143 lines
4.3 KiB
TypeScript
// Notification chime. Plays either a user-uploaded custom sound (if one
|
|
// exists in IndexedDB) or the built-in two-tone synth chime. Throttled
|
|
// so a burst of messages doesn't turn into a machine gun.
|
|
//
|
|
// Uses a single persistent AudioContext — a fresh one per notification
|
|
// lands in the `suspended` state under Chromium's autoplay policy
|
|
// whenever the user hasn't interacted recently, so the tone silently
|
|
// never plays.
|
|
//
|
|
// The custom AudioBuffer is decoded eagerly: on module init and on any
|
|
// upload/reset, kick the load so it's ready before the next incoming
|
|
// message. Playback always awaits `ctx.resume()` before starting the
|
|
// source so we don't race an async resume in a realtime-event context
|
|
// that has no prior user gesture.
|
|
|
|
import {
|
|
getCustomNotificationSound,
|
|
subscribeNotificationSoundChanges,
|
|
} from './notificationSoundStorage';
|
|
|
|
let ctx: AudioContext | null = null;
|
|
let lastPlay = 0;
|
|
|
|
// Cached decoded custom sound. `null` = no custom upload (fall back to
|
|
// synth). `undefined` = not yet loaded. `false` = decode failed.
|
|
let customBuffer: AudioBuffer | null | undefined | false = undefined;
|
|
let customLoadInFlight: Promise<void> | null = null;
|
|
|
|
function getCtx(): AudioContext | null {
|
|
if (ctx) return ctx;
|
|
const AudioCtx =
|
|
window.AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AudioCtx) return null;
|
|
ctx = new AudioCtx();
|
|
return ctx;
|
|
}
|
|
|
|
async function loadCustomBuffer(c: AudioContext): Promise<void> {
|
|
try {
|
|
const record = await getCustomNotificationSound();
|
|
if (!record) {
|
|
customBuffer = null;
|
|
return;
|
|
}
|
|
const arr = await record.blob.arrayBuffer();
|
|
const buf = await c.decodeAudioData(arr.slice(0));
|
|
customBuffer = buf;
|
|
} catch (err: unknown) {
|
|
console.warn('notification: custom sound decode failed', err);
|
|
customBuffer = false;
|
|
}
|
|
}
|
|
|
|
function kickLoad(): void {
|
|
if (customLoadInFlight) return;
|
|
const c = getCtx();
|
|
if (!c) return;
|
|
customLoadInFlight = loadCustomBuffer(c).finally(() => {
|
|
customLoadInFlight = null;
|
|
});
|
|
}
|
|
|
|
subscribeNotificationSoundChanges(() => {
|
|
customBuffer = undefined;
|
|
customLoadInFlight = null;
|
|
kickLoad();
|
|
});
|
|
|
|
// Eager-load at module init so the buffer is ready before the first
|
|
// notification fires. Safe to call at top level — decodeAudioData
|
|
// doesn't need the context running.
|
|
kickLoad();
|
|
|
|
function playCustom(c: AudioContext, buffer: AudioBuffer): void {
|
|
const src = c.createBufferSource();
|
|
src.buffer = buffer;
|
|
const gain = c.createGain();
|
|
gain.gain.value = 1;
|
|
src.connect(gain);
|
|
gain.connect(c.destination);
|
|
src.start();
|
|
}
|
|
|
|
function playSynthTone(c: AudioContext): void {
|
|
const master = c.createGain();
|
|
master.connect(c.destination);
|
|
master.gain.value = 0.12;
|
|
|
|
const tones: { freq: number; delay: number }[] = [
|
|
{ freq: 880, delay: 0 },
|
|
{ freq: 1320, delay: 0.08 },
|
|
];
|
|
for (const { freq, delay } of tones) {
|
|
const osc = c.createOscillator();
|
|
const gain = c.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = freq;
|
|
osc.connect(gain);
|
|
gain.connect(master);
|
|
const t0 = c.currentTime + delay;
|
|
gain.gain.setValueAtTime(0, t0);
|
|
gain.gain.linearRampToValueAtTime(1, t0 + 0.015);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
|
|
osc.start(t0);
|
|
osc.stop(t0 + 0.3);
|
|
}
|
|
}
|
|
|
|
export function playNotificationTone(): void {
|
|
const now = Date.now();
|
|
if (now - lastPlay < 800) return;
|
|
lastPlay = now;
|
|
|
|
void (async () => {
|
|
const c = getCtx();
|
|
if (!c) return;
|
|
if (c.state === 'suspended') {
|
|
try {
|
|
await c.resume();
|
|
} catch {
|
|
/* autoplay denial — silent this round */
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If the load hasn't finished yet (first notification after a cold
|
|
// start, before the eager load completed) wait briefly for it so
|
|
// we don't emit the synth tone over a user-configured custom clip.
|
|
// Cap the wait so a never-resolving decode doesn't block a message.
|
|
if (customBuffer === undefined && customLoadInFlight) {
|
|
const timeout = new Promise<void>((r) => window.setTimeout(r, 500));
|
|
await Promise.race([customLoadInFlight, timeout]);
|
|
}
|
|
|
|
if (customBuffer && typeof customBuffer !== 'boolean') {
|
|
playCustom(c, customBuffer);
|
|
return;
|
|
}
|
|
playSynthTone(c);
|
|
})();
|
|
}
|