Files
ChatApp/apps/desktop/src/pages/SettingsPage.tsx
T
byGalax 37becba7e2
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
feat: audio devices + fullscreen + banner cleanup (v0.7.0)
Audio device selection:
- audioSettings: persisted inputDeviceId + outputDeviceId
- CallContext: uses stored input deviceId on mic enable, new
  setAudioInputDevice / setAudioOutputDevice actions that hot-swap
  without reconnect. Output swap applies HTMLMediaElement.setSinkId
  to every attached remote-audio element (LiveKit's switchActiveDevice
  only tracks elements it attached itself)
- SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with
  enumerateDevices, devicechange listener, permission-probe button.
  setSinkId-unsupported fallback is messaged but non-blocking

Fullscreen:
- FullscreenCall was absolute inset-0 z-40 which trapped it inside the
  <main> pane — sidebar + chat-list stayed visible. Switched to
  fixed inset-0 z-[60] so the call overlays the whole window
  Discord-style
- ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API
  unreliable under Tauri WKWebView), portalled to document.body when
  active so no ancestor stacking context can clip it. Esc exits

ActiveCallBanner:
- cleanup effect returned early when presence was entirely empty,
  leaving the "1 im Raum" fallback stuck after both peers left. Now
  schedules dismissLastCall as soon as othersIn.length === 0, with a
  3s grace window to absorb presence re-sync flicker

Bump tauri version 0.6.0 -> 0.7.0
2026-04-20 21:26:10 +02:00

842 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { updateOwnProfile } from '@chat-app/shared/auth';
import {
changeLocale as changeLocaleI18n,
SUPPORTED_LOCALES,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
import { devLocalSecretStore } from '../lib/secretStore';
import {
getPttSettings,
keyCodeToLabel,
type PttSettings,
subscribePttSettings,
updatePttSettings,
} from '../lib/pttSettings';
import {
AUDIO_QUALITY_ORDER,
type AudioQuality,
type AudioSettings,
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
type CallE2EESettings,
getCallE2EESettings,
isE2EESupported,
subscribeCallE2EESettings,
updateCallE2EESettings,
} from '../lib/callE2EE';
import {
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
type ScreenShareSettings,
subscribeScreenShareSettings,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { supabase } from '../lib/supabase';
const LOCALE_LABELS: Record<SupportedLocale, string> = {
en: 'English',
de: 'Deutsch',
};
export function SettingsPage() {
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
const { profile, device, refreshProfile, signOut } = useAuth();
const [busy, setBusy] = useState(false);
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
setBusy(true);
try {
await updateOwnProfile(supabase, patch);
await refreshProfile();
} catch (err: unknown) {
console.error('updateProfile failed', err);
} finally {
setBusy(false);
}
}
async function handleLocaleChange(locale: SupportedLocale) {
await changeLocaleI18n(locale);
void patchProfile({ locale });
}
return (
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
</header>
{/* Account */}
<Section title={t('app:settings.section_account')}>
<AvatarControls
patchProfile={patchProfile}
busy={busy}
/>
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
{/* Appearance */}
<Section title={t('app:settings.section_appearance')}>
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
</div>
</SettingRow>
</Section>
{/* Privacy */}
<Section title={t('app:settings.section_privacy')}>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioDeviceControls />
<div className="mt-3 border-t border-line pt-3">
<AudioQualityControls />
</div>
<div className="mt-3 border-t border-line pt-3">
<PttControls />
</div>
<div className="mt-3 border-t border-line pt-3">
<CallE2EEControls />
</div>
</Section>
{/* Screen-share quality */}
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
<ScreenShareControls />
</Section>
{/* Devices */}
<Section title={t('app:settings.section_devices')}>
{device && (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
<LockIcon className="h-4 w-4" />
{t('app:settings.this_device')}
</div>
<dl className="mt-3 space-y-1.5 text-xs">
<Row label={t('auth:signed_in.display_name')} value={device.name} />
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
</dl>
</div>
)}
<DeviceKeyBackupControls />
</Section>
{/* Danger zone */}
<Section title={t('app:settings.danger_zone')}>
<button
type="button"
onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
>
{t('app:settings.sign_out')}
</button>
</Section>
</div>
</div>
);
}
function PttControls() {
const { t } = useTranslation(['app']);
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
const [capturing, setCapturing] = useState(false);
useEffect(() => {
return subscribePttSettings(setPtt);
}, []);
useEffect(() => {
if (!capturing) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code === 'Escape') {
setCapturing(false);
return;
}
updatePttSettings({ key: e.code, keyLabel: keyCodeToLabel(e.code) });
setCapturing(false);
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturing]);
return (
<>
<Toggle
label={t('app:settings.ptt_enabled', { defaultValue: 'Push-to-Talk' })}
hint={t('app:settings.ptt_enabled_hint', {
defaultValue:
'Mic bleibt stumm bis die Taste gedrückt wird. Sonst overrides der normale Mute-Button.',
})}
checked={ptt.enabled}
onChange={(v) => updatePttSettings({ enabled: v })}
/>
<SettingRow
label={t('app:settings.ptt_key', { defaultValue: 'Hotkey' })}
>
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing
? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-line bg-surface-3 text-fg hover:brightness-95')
}
>
{capturing
? t('app:settings.ptt_press_key', { defaultValue: 'Taste drücken…' })
: ptt.keyLabel}
</button>
</SettingRow>
</>
);
}
function CallE2EEControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
const [supported] = useState<boolean>(() => isE2EESupported());
useEffect(() => subscribeCallE2EESettings(setCfg), []);
return (
<>
<Toggle
label={t('app:settings.e2ee_calls', { defaultValue: 'Ende-zu-Ende-Verschlüsselung (Calls)' })}
hint={
supported
? t('app:settings.e2ee_calls_hint', {
defaultValue:
'Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.',
})
: t('app:settings.e2ee_calls_unsupported', {
defaultValue:
'Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.',
})
}
checked={cfg.enabled && supported}
disabled={!supported}
onChange={(v) => updateCallE2EESettings({ enabled: v })}
/>
</>
);
}
function AudioQualityControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<AudioSettings>(() => getAudioSettings());
useEffect(() => subscribeAudioSettings(setCfg), []);
const params = getAudioQualityParams(cfg.quality);
const labels: Record<AudioQuality, string> = {
voice: t('app:settings.audio_voice', { defaultValue: 'Sprache (Empfohlen)' }),
hifi: t('app:settings.audio_hifi', { defaultValue: 'HiFi / Musik' }),
};
return (
<>
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
<div className="inline-flex rounded-lg border border-line bg-surface-2 p-1">
{AUDIO_QUALITY_ORDER.map((q) => {
const active = cfg.quality === q;
return (
<button
key={q}
type="button"
onClick={() => updateAudioSettings({ quality: q })}
className={
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg')
}
>
{labels[q]}
</button>
);
})}
</div>
</SettingRow>
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
<div className="grid grid-cols-4 gap-3">
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
<Stat label="Sample" value={params.sampleRateHz / 1000 + ' kHz'} />
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
</div>
</div>
<p className="text-xs text-fg-muted">
{cfg.quality === 'hifi'
? t('app:settings.audio_hifi_hint', {
defaultValue:
'Stereo 510 kbps Opus ohne Noise-Suppression/Echo-Cancellation — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.',
})
: t('app:settings.audio_voice_hint', {
defaultValue:
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
})}
</p>
</>
);
}
interface AvatarControlsProps {
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
busy: boolean;
}
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
const { t } = useTranslation(['app']);
const { profile } = useAuth();
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const userId = profile?.userId;
const url = profile?.avatarUrl ?? null;
async function handleFile(file: File) {
if (!userId) return;
setError(null);
setUploading(true);
try {
const newUrl = await uploadAvatar(userId, file);
const oldUrl = url;
await patchProfile({ avatarUrl: newUrl });
if (oldUrl) {
// Best-effort cleanup of the previous file (don't block on it).
void deleteAvatarObject(oldUrl).catch(() => {
/* ignore */
});
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'upload failed');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleRemove() {
if (!userId || !url) return;
setError(null);
setUploading(true);
try {
await patchProfile({ avatarUrl: null });
void deleteAvatarObject(url).catch(() => undefined);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'remove failed');
} finally {
setUploading(false);
}
}
return (
<div className="flex items-center gap-4">
<Avatar
url={url}
displayName={profile?.displayName ?? profile?.username}
className="h-16 w-16 text-2xl"
/>
<div className="flex flex-1 flex-col gap-1">
<div className="text-sm font-medium text-fg">
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
</div>
<div className="text-xs text-fg-muted">
{t('app:settings.avatar_hint', {
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
})}
</div>
{error && (
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy || uploading}
className="cursor-pointer 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"
>
{uploading
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
: url
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
</button>
{url && (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy || uploading}
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
</button>
)}
</div>
);
}
function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [open, setOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
const [err, setErr] = useState<string | null>(null);
const canRun = !!profile?.userId && !!device?.id;
async function handleOpen() {
if (!canRun) return;
setErr(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) throw new Error(t('app:backup.no_key_here', {
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
}));
setPrivateKey(priv);
setOpen(true);
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'failed to load key');
}
}
function handleClose() {
setOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
}
return (
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
<div className="text-sm font-semibold text-fg">
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.device_key_backup_hint_v2', {
defaultValue:
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
})}
</p>
<button
type="button"
disabled={!canRun}
onClick={() => void handleOpen()}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
{err && (
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
)}
{open && profile && device && privateKey && (
<BackupExportDialog
open={open}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={handleClose}
/>
)}
</div>
);
}
function ScreenShareControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
useEffect(() => subscribeScreenShareSettings(setCfg), []);
const params = getPresetParams(cfg.preset);
return (
<>
<SettingRow label={t('app:settings.screen_share_quality', { defaultValue: 'Qualität' })}>
<select
value={cfg.preset}
onChange={(e) =>
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
}
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs text-fg focus:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label}
</option>
))}
</select>
</SettingRow>
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
<div className="grid grid-cols-3 gap-3">
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
<Stat
label="Resolution"
value={params.dims ? params.dims.width + '×' + params.dims.height : 'Auto'}
/>
<Stat label="Framerate" value={params.framerate + ' fps'} />
</div>
</div>
<p className="text-xs text-fg-muted">
{t('app:settings.screen_share_hint', {
defaultValue:
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
})}
</p>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">{label}</div>
<div className="mt-0.5 font-mono text-fg">{value}</div>
</div>
);
}
function formatBitrate(kbps: number): string {
if (kbps >= 1000) {
return (kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1) + ' Mbps';
}
return kbps + ' kbps';
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-line bg-surface-2 p-5">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-fg-muted">{label}</dt>
<dd
className={
'max-w-[60%] truncate text-right text-sm text-fg ' +
(mono ? 'font-mono text-xs' : '')
}
title={value}
>
{value}
</dd>
</div>
);
}
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-fg">{label}</span>
{children}
</div>
);
}
function Toggle({
label,
hint,
checked,
disabled,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1">
<span className="block text-sm text-fg">{label}</span>
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
</span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="peer sr-only"
/>
<span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Audio device selection — persisted input + output deviceIds, hot-swap on
// active calls. Output swap uses HTMLMediaElement.setSinkId on our attached
// <audio> elements (LiveKit's switchActiveDevice only tracks elements it
// attached itself).
// ---------------------------------------------------------------------------
function AudioDeviceControls() {
const { t } = useTranslation(['app']);
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
const [inputId, setInputId] = useState<string | null>(
() => getAudioSettings().inputDeviceId,
);
const [outputId, setOutputId] = useState<string | null>(
() => getAudioSettings().outputDeviceId,
);
const [error, setError] = useState<string | null>(null);
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
'unknown',
);
const refresh = useCallback(async () => {
try {
const list = await navigator.mediaDevices.enumerateDevices();
setInputs(list.filter((d) => d.kind === 'audioinput'));
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
// If labels are empty, permission hasn't been granted yet — browsers
// mask device names until a getUserMedia call succeeds at least once.
const hasLabels = list.some(
(d) => (d.kind === 'audioinput' || d.kind === 'audiooutput') && d.label.length > 0,
);
setPermission(hasLabels ? 'granted' : 'unknown');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'enumerateDevices failed');
}
}, []);
useEffect(() => {
void refresh();
const onChange = () => void refresh();
try {
navigator.mediaDevices.addEventListener('devicechange', onChange);
} catch {
/* some browsers omit devicechange */
}
const unsubSettings = subscribeAudioSettings((s) => {
setInputId(s.inputDeviceId);
setOutputId(s.outputDeviceId);
});
return () => {
try {
navigator.mediaDevices.removeEventListener('devicechange', onChange);
} catch {
/* ignore */
}
unsubSettings();
};
}, [refresh]);
const requestPermission = useCallback(async () => {
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((t) => t.stop());
setPermission('granted');
await refresh();
} catch (err: unknown) {
setPermission('denied');
setError(err instanceof Error ? err.message : 'permission denied');
}
}, [refresh]);
const handleInput = useCallback(
async (id: string) => {
const next = id === '' ? null : id;
setInputId(next);
await setAudioInputDevice(next);
},
[setAudioInputDevice],
);
const handleOutput = useCallback(
async (id: string) => {
const next = id === '' ? null : id;
setOutputId(next);
await setAudioOutputDevice(next);
},
[setAudioOutputDevice],
);
const outputSupported =
typeof HTMLAudioElement !== 'undefined' &&
typeof HTMLAudioElement.prototype.setSinkId === 'function';
return (
<div className="space-y-4">
<div>
<div className="text-sm font-semibold text-fg">
{t('app:settings.mic_title', { defaultValue: 'Mikrofon' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.mic_hint', {
defaultValue: 'Eingabegerät. Bei aktivem Anruf wird live umgeschaltet.',
})}
</p>
<div className="mt-2 flex items-center gap-2">
<select
value={inputId ?? ''}
onChange={(e) => void handleInput(e.target.value)}
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
<option value="">
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
</option>
{inputs.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
</option>
))}
</select>
<button
type="button"
onClick={() => void refresh()}
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs font-medium text-fg transition hover:bg-surface-3"
>
{t('app:settings.mic_refresh', { defaultValue: 'Neu laden' })}
</button>
</div>
</div>
<div>
<div className="text-sm font-semibold text-fg">
{t('app:settings.speaker_title', { defaultValue: 'Ausgabegerät' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.speaker_hint', {
defaultValue:
'Lautsprecher oder Kopfhörer. Wird auf alle aktiven Audio-Streams angewendet.',
})}
</p>
{!outputSupported && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
{t('app:settings.speaker_unsupported', {
defaultValue: 'Browser unterstützt setSinkId nicht — Ausgabe folgt System-Default.',
})}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<select
value={outputId ?? ''}
disabled={!outputSupported}
onChange={(e) => void handleOutput(e.target.value)}
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-50"
>
<option value="">
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
</option>
{outputs.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
</option>
))}
</select>
</div>
</div>
{permission !== 'granted' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
<span>
{t('app:settings.mic_permission_hint', {
defaultValue:
'Ohne Mikrofon-Freigabe erscheinen Gerätenamen nicht. Einmal erlauben und Liste lädt neu.',
})}
</span>
<button
type="button"
onClick={() => void requestPermission()}
className="shrink-0 cursor-pointer rounded-md bg-amber-600 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-amber-500"
>
{t('app:settings.mic_grant', { defaultValue: 'Freigeben' })}
</button>
</div>
)}
{error && (
<p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>
)}
</div>
);
}