feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
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>
This commit is contained in:
@@ -1,21 +1,90 @@
|
||||
// Two-tone notification chime generated via WebAudio. No asset file needed.
|
||||
// Throttled so a burst of messages doesn't turn into a machine gun.
|
||||
// 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;
|
||||
|
||||
export function playNotificationTone(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastPlay < 800) return;
|
||||
lastPlay = now;
|
||||
// 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;
|
||||
if (!AudioCtx) return null;
|
||||
ctx = new AudioCtx();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const ctx = new AudioCtx();
|
||||
const master = ctx.createGain();
|
||||
master.connect(ctx.destination);
|
||||
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 }[] = [
|
||||
@@ -23,23 +92,51 @@ export function playNotificationTone(): void {
|
||||
{ freq: 1320, delay: 0.08 },
|
||||
];
|
||||
for (const { freq, delay } of tones) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const osc = c.createOscillator();
|
||||
const gain = c.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
gain.connect(master);
|
||||
const t0 = ctx.currentTime + delay;
|
||||
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);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
void ctx.close().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
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);
|
||||
})();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user