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:
@@ -0,0 +1,135 @@
|
||||
// IndexedDB-backed custom notification sound. Single-row object store —
|
||||
// user either has one uploaded clip overriding the built-in two-tone
|
||||
// chime, or doesn't and we fall back to the synthesized tone.
|
||||
//
|
||||
// Separate from `soundboardStorage.ts` because the soundboard is a
|
||||
// multi-entry user library with categories + hotkeys + per-clip gain;
|
||||
// this module only needs "one blob, replace-on-upload, wipe-on-reset".
|
||||
|
||||
export interface NotificationSoundEntry {
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
uploadedAt: number;
|
||||
}
|
||||
|
||||
interface StoredNotificationSound extends NotificationSoundEntry {
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
// 1 MB cap — notification sounds should be short (<5s), and we don't
|
||||
// want an IDB quota bust from a 200MB MP3.
|
||||
export const MAX_NOTIFICATION_SOUND_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
// --- Change observer — the notificationSound module subscribes to
|
||||
// invalidate its cached AudioBuffer when upload/reset happens, so the
|
||||
// next ping uses the fresh sound without an app restart.
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function subscribeNotificationSoundChanges(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyChange(): void {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (err: unknown) {
|
||||
console.warn('notification-sound change listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DB_NAME = 'netralax-notification';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'sound';
|
||||
const KEY = 'current';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function getCustomNotificationSound(): Promise<
|
||||
{ blob: Blob; entry: NotificationSoundEntry } | null
|
||||
> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readonly');
|
||||
const req = t.objectStore(STORE).get(KEY);
|
||||
req.onsuccess = () => {
|
||||
const stored = req.result as StoredNotificationSound | undefined;
|
||||
if (!stored) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const { blob, ...entry } = stored;
|
||||
resolve({ blob, entry });
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomNotificationSoundMeta(): Promise<NotificationSoundEntry | null> {
|
||||
const res = await getCustomNotificationSound();
|
||||
return res ? res.entry : null;
|
||||
}
|
||||
|
||||
export async function setCustomNotificationSound(file: File): Promise<NotificationSoundEntry> {
|
||||
if (file.size === 0) throw new Error('empty_file');
|
||||
if (file.size > MAX_NOTIFICATION_SOUND_BYTES) throw new Error('sound_too_large');
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');
|
||||
|
||||
const stored: StoredNotificationSound = {
|
||||
filename: file.name || 'notification.audio',
|
||||
mime,
|
||||
size: file.size,
|
||||
uploadedAt: Date.now(),
|
||||
blob: file,
|
||||
};
|
||||
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readwrite');
|
||||
const req = t.objectStore(STORE).put(stored, KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
|
||||
notifyChange();
|
||||
const { blob: _blob, ...entry } = stored;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function clearCustomNotificationSound(): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readwrite');
|
||||
const req = t.objectStore(STORE).delete(KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
notifyChange();
|
||||
}
|
||||
Reference in New Issue
Block a user