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:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
+116 -19
View File
@@ -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);
})();
}