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>
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { playNotificationTone } from '../lib/notificationSound';
|
|
import {
|
|
clearCustomNotificationSound,
|
|
getCustomNotificationSoundMeta,
|
|
MAX_NOTIFICATION_SOUND_BYTES,
|
|
type NotificationSoundEntry,
|
|
setCustomNotificationSound,
|
|
subscribeNotificationSoundChanges,
|
|
} from '../lib/notificationSoundStorage';
|
|
import { SpinnerIcon, TrashIcon } from './icons';
|
|
|
|
interface Props {
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const BYTES_PER_MB = 1024 * 1024;
|
|
|
|
// UI for the custom new-message notification chime. Single file slot.
|
|
// Preview button calls playNotificationTone() directly so the user
|
|
// hears exactly what a real incoming message will sound like (same
|
|
// AudioContext, same volume curve).
|
|
export function NotificationSoundSettings({ disabled = false }: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [current, setCurrent] = useState<NotificationSoundEntry | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const meta = await getCustomNotificationSoundMeta();
|
|
setCurrent(meta);
|
|
} catch (err: unknown) {
|
|
console.error('getCustomNotificationSoundMeta failed', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
return subscribeNotificationSoundChanges(() => {
|
|
void refresh();
|
|
});
|
|
}, [refresh]);
|
|
|
|
async function handleFile(file: File): Promise<void> {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
await setCustomNotificationSound(file);
|
|
// refresh fires via subscribeNotificationSoundChanges
|
|
} catch (err: unknown) {
|
|
const code = err instanceof Error ? err.message : 'upload_failed';
|
|
if (code === 'sound_too_large') {
|
|
setError(
|
|
t('app:settings.notif_sound_error_too_large', {
|
|
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
|
max: MAX_NOTIFICATION_SOUND_BYTES / BYTES_PER_MB,
|
|
}),
|
|
);
|
|
} else if (code === 'sound_not_audio') {
|
|
setError(
|
|
t('app:settings.notif_sound_error_not_audio', {
|
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
|
}),
|
|
);
|
|
} else if (code === 'empty_file') {
|
|
setError(
|
|
t('app:settings.notif_sound_error_empty', {
|
|
defaultValue: 'Leere Datei.',
|
|
}),
|
|
);
|
|
} else {
|
|
setError(
|
|
t('app:settings.notif_sound_error_generic', {
|
|
defaultValue: 'Ton konnte nicht gespeichert werden.',
|
|
}),
|
|
);
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
}
|
|
}
|
|
|
|
async function handleReset(): Promise<void> {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
await clearCustomNotificationSound();
|
|
// refresh fires via subscribeNotificationSoundChanges
|
|
} catch (err: unknown) {
|
|
console.error('clearCustomNotificationSound failed', err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function handlePreview(): void {
|
|
// Same code path as a real notification — user hears exactly what
|
|
// peers triggering a message will cause.
|
|
playNotificationTone();
|
|
}
|
|
|
|
const hasCustom = current !== null;
|
|
const sizeMb = current ? (current.size / BYTES_PER_MB).toFixed(2) : null;
|
|
const interactionsDisabled = disabled || busy;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-fg">
|
|
{t('app:settings.notif_sound_title', { defaultValue: 'Benachrichtigungston' })}
|
|
</div>
|
|
<div className="mt-1 text-xs text-fg-muted">
|
|
{hasCustom && current
|
|
? t('app:settings.notif_sound_custom_active', {
|
|
defaultValue: '{{name}} · {{size}} MB',
|
|
name: current.filename,
|
|
size: sizeMb,
|
|
})
|
|
: t('app:settings.notif_sound_default_active', {
|
|
defaultValue: 'Standard (Zwei-Ton-Chime)',
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
disabled={interactionsDisabled}
|
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{t('app:settings.notif_sound_preview', { defaultValue: 'Probe hören' })}
|
|
</button>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) void handleFile(f);
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={interactionsDisabled}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
|
<span>
|
|
{hasCustom
|
|
? t('app:settings.notif_sound_replace', { defaultValue: 'Ersetzen' })
|
|
: t('app:settings.notif_sound_upload', { defaultValue: 'Hochladen' })}
|
|
</span>
|
|
</button>
|
|
{hasCustom && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleReset()}
|
|
disabled={interactionsDisabled}
|
|
aria-label={t('app:settings.notif_sound_reset', { defaultValue: 'Zurücksetzen' })}
|
|
title={t('app:settings.notif_sound_reset', { defaultValue: 'Zurücksetzen' })}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t('app:settings.notif_sound_hint', {
|
|
defaultValue:
|
|
'MP3, WAV, OGG oder M4A bis 1 MB. Spielt bei neuen Nachrichten in Chats die du nicht aktiv ansiehst.',
|
|
})}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|