Files
ChatApp/apps/desktop/src/pages/SettingsPage.tsx
T
2026-05-16 19:13:31 +02:00

1727 lines
63 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 {
AtIcon,
BellIcon,
LockIcon,
MicIcon,
MonitorShareIcon,
MusicIcon,
ShieldIcon,
SignOutIcon,
SunIcon,
UsersIcon,
} from '../components/icons';
import { MicTestSection } from '../components/MicTestSection';
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { DeviceListTab } from '../components/settings/DeviceListTab';
import { SecurityCenter } from '../components/SecurityCenter';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useTheme } from '../context/ThemeContext';
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
import {
AVATAR_TARGET_DIM,
deleteAvatarObject,
uploadAvatarBlob,
} from '../lib/avatarUpload';
import {
BANNER_MAX_INPUT_BYTES,
BANNER_TARGET_HEIGHT,
BANNER_TARGET_WIDTH,
deleteBannerObject,
uploadBannerBlob,
} from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog';
import { Lightbox } from '../components/Lightbox';
import {
getPttSettings,
keyCodeToLabel,
type PttSettings,
subscribePttSettings,
updatePttSettings,
} from '../lib/pttSettings';
import {
getVoiceHotkeys,
subscribeVoiceHotkeys,
updateVoiceHotkey,
type VoiceHotkeyKind,
type VoiceHotkeys,
} from '../lib/voiceHotkeys';
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, 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 });
}
// Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
// section and only that section renders. activeTab is the single source of
// truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
// routing conflict with HashRouter.
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
];
const [activeTab, setActiveTab] = useState<TabId>('profile');
return (
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
{/* Sidebar */}
<aside className="hidden lg:block">
<div className="sticky top-8 space-y-1">
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
<nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
{tabs.map(({ id, label, Icon }) => {
const active = activeTab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={active}
aria-controls={'settings-panel-' + id}
onClick={() => setActiveTab(id)}
className={
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent/15 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
}
>
<Icon
className={
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
}
/>
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
</div>
</aside>
{/* Content panel — only the active tab renders */}
<main className="min-w-0">
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
<div className="mb-6 space-y-3 lg:hidden">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
<select
value={activeTab}
onChange={(e) => setActiveTab(e.target.value as TabId)}
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-label={t('app:settings.title')}
>
{tabs.map(({ id, label }) => (
<option key={id} value={id}>{label}</option>
))}
</select>
</div>
<div
id={'settings-panel-' + activeTab}
role="tabpanel"
aria-labelledby={'settings-tab-' + activeTab}
>
{activeTab === 'profile' && (
<Section
title={t('app:settings.section_account')}
description={t('app:settings.section_account_hint', {
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
})}
>
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
)}
{activeTab === 'appearance' && (
<Section
title={t('app:settings.section_appearance')}
description={t('app:settings.section_appearance_hint', {
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
})}
>
<ThemeRow />
<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>
<SubGroup>
<AutoStartControls />
</SubGroup>
</Section>
)}
{activeTab === 'privacy' && (
<Section
title={t('app:settings.section_privacy')}
description={t('app:settings.section_privacy_hint', {
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
})}
>
<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>
)}
{activeTab === 'notifications' && (
<Section
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
description={t('app:settings.section_notifications_hint', {
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
})}
>
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
<NotificationSoundSettings disabled={busy} />
</SubSection>
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
<RingtoneSettings disabled={busy} />
</SubSection>
</Section>
)}
{activeTab === 'voice' && (
<Section
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
description={t('app:settings.section_voice_hint', {
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
})}
>
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
<AudioDeviceControls />
</SubSection>
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
<AudioQualityControls />
</SubSection>
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
<PttControls />
</SubSection>
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
<div className="space-y-2">
<VoiceHotkeyControls kind="mute" />
<VoiceHotkeyControls kind="deafen" />
<VoiceHotkeyControls kind="hangup" />
<VoiceHotkeyControls kind="screenShare" />
<VoiceHotkeyControls kind="video" />
</div>
</SubSection>
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
<CallE2EEControls />
</SubSection>
</Section>
)}
{activeTab === 'screen-share' && (
<Section
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
description={t('app:settings.section_screen_share_hint', {
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
})}
>
<ScreenShareControls />
</Section>
)}
{activeTab === 'soundboard' && (
<Section
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
description={t('app:settings.section_soundboard_hint', {
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
})}
>
<SoundboardSettings />
</Section>
)}
{activeTab === 'security' && (
<Section
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
description={t('app:settings.section_security_hint', {
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
})}
>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
)}
{activeTab === 'devices' && (
<Section
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
description={t('app:settings.section_devices_hint', {
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
})}
>
<DeviceListTab />
</Section>
)}
{activeTab === 'account' && (
<Section
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
description={t('app:settings.section_account_mgmt_hint', {
defaultValue: 'Abmelden oder Konto-Aktionen.',
})}
tone="danger"
>
<button
type="button"
onClick={() => void signOut()}
className="inline-flex cursor-pointer items-center gap-2 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"
>
<SignOutIcon className="h-4 w-4" />
{t('app:settings.sign_out')}
</button>
</Section>
)}
</div>
</main>
</div>
</div>
);
}
function AutoStartControls() {
const { t } = useTranslation(['app']);
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const on = await isAutoStartEnabled();
if (!cancelled) setEnabled(on);
})();
return () => {
cancelled = true;
};
}, []);
async function handleToggle(next: boolean) {
setBusy(true);
setError(null);
try {
await setAutoStart(next);
setEnabled(next);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'autostart failed');
} finally {
setBusy(false);
}
}
return (
<>
<Toggle
label={t('app:settings.autostart', {
defaultValue: 'Mit Windows starten',
})}
hint={t('app:settings.autostart_hint', {
defaultValue:
'ChatApp automatisch mitstarten wenn du dich am System anmeldest.',
})}
checked={enabled ?? false}
disabled={busy || enabled === null}
onChange={(v) => void handleToggle(v)}
/>
{error && <p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>}
</>
);
}
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 VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
const { t } = useTranslation(['app']);
const [hotkeys, setHotkeys] = useState<VoiceHotkeys>(() => getVoiceHotkeys());
const [capturing, setCapturing] = useState(false);
useEffect(() => subscribeVoiceHotkeys(setHotkeys), []);
useEffect(() => {
if (!capturing) return;
const onKey = (e: KeyboardEvent) => {
// Modifier-only presses shouldn't bind — wait for a real key to
// arrive. Escape aborts the capture.
if (e.code === 'Escape') {
e.preventDefault();
setCapturing(false);
return;
}
if (
e.code === 'ControlLeft' ||
e.code === 'ControlRight' ||
e.code === 'ShiftLeft' ||
e.code === 'ShiftRight' ||
e.code === 'AltLeft' ||
e.code === 'AltRight' ||
e.code === 'MetaLeft' ||
e.code === 'MetaRight'
) {
return;
}
e.preventDefault();
updateVoiceHotkey(kind, {
key: e.code,
ctrl: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
alt: e.altKey,
});
setCapturing(false);
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturing, kind]);
const binding = hotkeys[kind];
const labels: Record<VoiceHotkeyKind, { label: string; hint: string }> = {
mute: {
label: t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' }),
hint: t('app:settings.hotkey_mute_hint', {
defaultValue:
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
}),
},
deafen: {
label: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' }),
hint: t('app:settings.hotkey_deafen_hint', {
defaultValue: 'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
}),
},
hangup: {
label: t('app:settings.hotkey_hangup_enabled', { defaultValue: 'Auflegen-Hotkey' }),
hint: t('app:settings.hotkey_hangup_hint', {
defaultValue: 'Beendet den aktiven Anruf sofort.',
}),
},
screenShare: {
label: t('app:settings.hotkey_screenshare_enabled', {
defaultValue: 'Bildschirmfreigabe-Hotkey',
}),
hint: t('app:settings.hotkey_screenshare_hint', {
defaultValue: 'Startet oder stoppt die Bildschirmfreigabe.',
}),
},
video: {
label: t('app:settings.hotkey_video_enabled', { defaultValue: 'Kamera-Hotkey' }),
hint: t('app:settings.hotkey_video_hint', {
defaultValue: 'Schaltet die Kamera während eines Anrufs an oder aus.',
}),
},
};
const toggleLabel = labels[kind].label;
const toggleHint = labels[kind].hint;
return (
<>
<Toggle
label={toggleLabel}
hint={toggleHint}
checked={binding.enabled}
onChange={(v) => updateVoiceHotkey(kind, { enabled: v })}
/>
<SettingRow
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[10rem] 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.hotkey_press_combo', {
defaultValue: 'Kombination drücken…',
})
: binding.keyLabel}
</button>
<button
type="button"
onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
disabled={!binding.enabled}
aria-pressed={binding.global}
title={
binding.global
? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
: 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
}
className={
'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
(binding.global
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
<span aria-hidden>🌐</span>
<span>{binding.global ? 'Global' : 'Im Fenster'}</span>
</button>
</div>
</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>
<SettingRow
label={t('app:settings.noise_suppression', { defaultValue: 'Noise Suppression' })}
>
<InlineToggle
checked={cfg.noiseSuppression}
onChange={(v) => updateAudioSettings({ noiseSuppression: v })}
/>
</SettingRow>
<p className="text-xs text-fg-muted">
{t('app:settings.noise_suppression_hint', {
defaultValue:
'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.',
})}
</p>
<SettingRow
label={t('app:settings.video_blur', {
defaultValue: 'Video-Hintergrund unscharf',
})}
>
<InlineToggle
checked={cfg.videoBackgroundBlur}
onChange={(v) => updateAudioSettings({ videoBackgroundBlur: v })}
/>
</SettingRow>
<p className="text-xs text-fg-muted">
{t('app:settings.video_blur_hint', {
defaultValue:
'Blendet den Hintergrund hinter dir aus. Braucht etwas GPU-Leistung und lädt beim ersten Aktivieren ~1,5 MB Modell nach.',
})}
</p>
<SettingRow
label={t('app:settings.voice_threshold', {
defaultValue: 'Sprach-Erkennungs-Schwelle',
})}
>
<div className="flex w-48 items-center gap-2">
<input
type="range"
min={0.005}
max={0.1}
step={0.005}
value={cfg.voiceThreshold}
onChange={(e) =>
updateAudioSettings({ voiceThreshold: Number(e.target.value) })
}
className="flex-1 accent-accent"
/>
<span className="w-10 tabular-nums text-right text-[11px] text-fg-muted">
{(cfg.voiceThreshold * 100).toFixed(1)}
</span>
</div>
</SettingRow>
<p className="text-xs text-fg-muted">
{t('app:settings.voice_threshold_hint', {
defaultValue:
'Wann der grüne Sprech-Ring aufleuchtet. Niedriger = empfindlicher (leise Stimme erfassen), höher = tolerant gegen Raumlärm.',
})}
</p>
</>
);
}
function InlineToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (next: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(checked ? 'bg-accent' : 'bg-surface')
}
>
<span
className={
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition ' +
(checked ? 'translate-x-5' : '')
}
/>
</button>
);
}
interface AvatarControlsProps {
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
busy: boolean;
}
function DisplayNameControls({ patchProfile, busy }: AvatarControlsProps) {
const { t } = useTranslation(['app', 'auth']);
const { profile } = useAuth();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
function startEdit() {
setDraft(profile?.displayName ?? '');
setError(null);
setEditing(true);
// Focus on next tick so the input has mounted.
window.setTimeout(() => inputRef.current?.focus(), 0);
}
function cancel() {
setEditing(false);
setDraft('');
setError(null);
}
async function save() {
const trimmed = draft.trim();
if (trimmed.length === 0) {
setError(
t('app:settings.display_name_required', {
defaultValue: 'Anzeigename darf nicht leer sein.',
}),
);
return;
}
if (trimmed === profile?.displayName) {
cancel();
return;
}
setSaving(true);
setError(null);
try {
await patchProfile({ displayName: trimmed });
setEditing(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
if (!editing) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</dt>
<div className="flex min-w-0 items-center gap-2">
<dd className="max-w-[40ch] truncate text-right text-sm text-fg" title={profile?.displayName ?? ''}>
{profile?.displayName ?? '—'}
</dd>
<button
type="button"
onClick={startEdit}
disabled={busy || !profile}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2.5 py-1 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
>
{t('app:settings.edit', { defaultValue: 'Bearbeiten' })}
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
ref={inputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void save();
} else if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
maxLength={64}
disabled={saving}
className="flex-1 min-w-[12rem] rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-sm text-fg outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60 dark:bg-[#313338]"
/>
<button
type="button"
onClick={() => void save()}
disabled={saving || busy}
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"
>
{saving
? t('app:settings.display_name_saving', { defaultValue: 'Speichere…' })
: t('app:settings.save', { defaultValue: 'Speichern' })}
</button>
<button
type="button"
onClick={cancel}
disabled={saving}
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
>
{t('app:settings.cancel', { defaultValue: 'Abbrechen' })}
</button>
</div>
{error && (
<p className="text-xs text-rose-500 dark:text-rose-300">{error}</p>
)}
</div>
);
}
// Default banner gradient when the user hasn't uploaded their own. Sits on
// the same accent + surface tokens as the rest of the app so it never clashes
// with theme changes. Used both here in settings and in UserProfilePopover.
export const DEFAULT_BANNER_CLASS =
'bg-gradient-to-br from-accent/40 via-accent/15 to-surface-3';
function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
const { t } = useTranslation(['app']);
const { profile } = useAuth();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const bannerInputRef = useRef<HTMLInputElement | null>(null);
const [avatarBusy, setAvatarBusy] = useState(false);
const [bannerBusy, setBannerBusy] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [bannerError, setBannerError] = useState<string | null>(null);
// Crop-dialog plumbing. The picked File lives here until the user
// confirms a crop or cancels; on confirm we hand the resulting Blob to
// the matching upload helper. Keeping `kind` separate from `file` lets
// the same dialog component drive both flows with different aspect
// ratios.
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
// Lightbox toggle for the avatar live-preview. Clicking the in-page
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
const userId = profile?.userId;
const avatarUrl = profile?.avatarUrl ?? null;
const bannerUrl = profile?.bannerUrl ?? null;
// Avatar pick → open crop dialog. Legacy `uploadAvatar` (center-crop) is
// kept around for callers that bypass the picker, but the SettingsPage
// path always goes through the crop flow now so the user controls the
// framing.
function openAvatarCrop(file: File) {
if (!userId) return;
if (!file.type.startsWith('image/')) {
setAvatarError('only image files are accepted');
return;
}
setAvatarError(null);
setCropFile(file);
setCropKind('avatar');
}
async function handleAvatarCropConfirm(blob: Blob) {
if (!userId) return;
setAvatarBusy(true);
setAvatarError(null);
try {
const newUrl = await uploadAvatarBlob(userId, blob);
const oldUrl = avatarUrl;
await patchProfile({ avatarUrl: newUrl });
if (oldUrl) {
void deleteAvatarObject(oldUrl).catch(() => undefined);
}
closeCropDialog();
} catch (err: unknown) {
setAvatarError(err instanceof Error ? err.message : 'upload failed');
} finally {
setAvatarBusy(false);
}
}
function closeCropDialog() {
setCropFile(null);
setCropKind(null);
if (avatarInputRef.current) avatarInputRef.current.value = '';
if (bannerInputRef.current) bannerInputRef.current.value = '';
}
async function handleAvatarRemove() {
if (!userId || !avatarUrl) return;
setAvatarError(null);
setAvatarBusy(true);
try {
await patchProfile({ avatarUrl: null });
void deleteAvatarObject(avatarUrl).catch(() => undefined);
} catch (err: unknown) {
setAvatarError(err instanceof Error ? err.message : 'remove failed');
} finally {
setAvatarBusy(false);
}
}
function openBannerCrop(file: File) {
if (!userId) return;
if (!file.type.startsWith('image/')) {
setBannerError('only image files are accepted');
return;
}
if (file.size > BANNER_MAX_INPUT_BYTES) {
setBannerError('image must be 8 MB or smaller');
return;
}
setBannerError(null);
setCropFile(file);
setCropKind('banner');
}
async function handleBannerCropConfirm(blob: Blob) {
if (!userId) return;
setBannerBusy(true);
setBannerError(null);
try {
const newUrl = await uploadBannerBlob(userId, blob);
const oldUrl = bannerUrl;
await patchProfile({ bannerUrl: newUrl });
if (oldUrl) {
void deleteBannerObject(oldUrl).catch(() => undefined);
}
closeCropDialog();
} catch (err: unknown) {
setBannerError(err instanceof Error ? err.message : 'upload failed');
} finally {
setBannerBusy(false);
}
}
async function handleBannerRemove() {
if (!userId || !bannerUrl) return;
setBannerError(null);
setBannerBusy(true);
try {
await patchProfile({ bannerUrl: null });
void deleteBannerObject(bannerUrl).catch(() => undefined);
} catch (err: unknown) {
setBannerError(err instanceof Error ? err.message : 'remove failed');
} finally {
setBannerBusy(false);
}
}
const displayName = profile?.displayName ?? profile?.username;
const lockedAll = busy || avatarBusy || bannerBusy;
return (
<div className="space-y-4">
{/* Live preview — banner with avatar overlapping bottom-left, mirrors
how the profile shows up in UserProfilePopover. The avatar row is
explicitly stacked above the banner via `relative z-10`; without
it, browsers can paint the negatively-margin'd avatar behind the
banner's background image when the parent doesn't establish a
stacking context. */}
<div className="relative overflow-hidden rounded-xl border border-line bg-surface-3">
<div
className={
'relative z-0 aspect-[3/1] w-full bg-cover bg-center ' +
(bannerUrl ? '' : DEFAULT_BANNER_CLASS)
}
style={bannerUrl ? { backgroundImage: 'url("' + bannerUrl + '")' } : undefined}
/>
<div
className="relative z-10 flex items-end gap-3 px-4 pb-3"
style={{ marginTop: '-2rem' }}
>
<button
type="button"
onClick={() => {
if (avatarUrl) setAvatarPreviewOpen(true);
}}
// Disabled when there's no uploaded avatar — clicking the
// generated-initial placeholder would open an empty lightbox.
disabled={!avatarUrl}
aria-label={
avatarUrl
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
: undefined
}
// appearance-none + reset border/bg/padding so the native
// button chrome (outset border, button-face background, 1px
// padding) doesn't draw a box around the avatar circle.
className={
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
</button>
<div className="min-w-0 flex-1 pb-1">
<div className="truncate text-sm font-semibold text-fg">
{displayName ?? '—'}
</div>
{profile?.username && (
<div className="truncate text-xs text-fg-muted">@{profile.username}</div>
)}
</div>
</div>
</div>
{/* Banner controls */}
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-fg">
{t('app:settings.banner', { defaultValue: 'Banner' })}
</div>
<div className="text-xs text-fg-muted">
{t('app:settings.banner_hint', {
defaultValue: '3:1 Format, max 8 MB. Standard ist ein Farbverlauf.',
})}
</div>
{bannerError && (
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{bannerError}</div>
)}
</div>
<input
ref={bannerInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) openBannerCrop(f);
}}
/>
<button
type="button"
onClick={() => bannerInputRef.current?.click()}
disabled={lockedAll}
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"
>
{bannerBusy
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
: bannerUrl
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
</button>
{bannerUrl && (
<button
type="button"
onClick={() => void handleBannerRemove()}
disabled={lockedAll}
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>
{/* Avatar controls */}
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
<div className="min-w-0 flex-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>
{avatarError && (
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{avatarError}</div>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) openAvatarCrop(f);
}}
/>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
disabled={lockedAll}
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"
>
{avatarBusy
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
: avatarUrl
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
</button>
{avatarUrl && (
<button
type="button"
onClick={() => void handleAvatarRemove()}
disabled={lockedAll}
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>
<ImageCropDialog
open={cropFile !== null && cropKind !== null}
file={cropFile}
aspect={cropKind === 'banner' ? 3 : 1}
outputWidth={cropKind === 'banner' ? BANNER_TARGET_WIDTH : AVATAR_TARGET_DIM}
outputHeight={cropKind === 'banner' ? BANNER_TARGET_HEIGHT : AVATAR_TARGET_DIM}
title={
cropKind === 'banner'
? t('app:settings.crop_banner_title', { defaultValue: 'Banner zuschneiden' })
: t('app:settings.crop_avatar_title', { defaultValue: 'Profilbild zuschneiden' })
}
onConfirm={(blob) => {
if (cropKind === 'banner') void handleBannerCropConfirm(blob);
else if (cropKind === 'avatar') void handleAvatarCropConfirm(blob);
}}
onClose={closeCropDialog}
/>
{avatarPreviewOpen && avatarUrl && (
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
)}
</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,
description,
children,
tone,
}: {
title: string;
description?: string;
children: React.ReactNode;
tone?: 'default' | 'danger';
}) {
return (
<section
className={
'rounded-2xl border bg-surface-2 p-6 ' +
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
}
>
<header className="mb-5 border-b border-line pb-4">
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
{title}
</h2>
{description && (
<p className="mt-1 text-xs text-fg-muted">{description}</p>
)}
</header>
<div className="space-y-4">{children}</div>
</section>
);
}
// Sub-heading inside a Section — used to chunk dense sections like Voice into
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
// No border: `--color-line` is already a semi-transparent token, and applying
// the `/60` opacity modifier brightens it (Tailwind overrides the original
// alpha) which made the sub-cards look harsher than the outer Section. Plain
// background tint + caps heading carry the grouping signal on their own.
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2 rounded-xl bg-surface-3/50 p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h3>
<div className="space-y-3">{children}</div>
</div>
);
}
// Lighter wrapper for a single related extra control inside a Section that
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
// Appearance).
function SubGroup({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-3 border-t border-line pt-4">{children}</div>
);
}
function Row({
label,
value,
mono,
icon,
}: {
label: string;
value: string;
mono?: boolean;
icon?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
{icon}
{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>
);
}
// Theme picker row inside the Appearance section. Same pill-segmented style
// as the language selector so the two siblings read as one control surface.
// The toggle was previously a rail icon in the sidebar; moved here so it
// sits with the other appearance preferences.
function ThemeRow() {
const { t } = useTranslation(['app']);
const { theme, setTheme } = useTheme();
const options: Array<{ value: 'light' | 'dark'; label: string }> = [
{
value: 'light',
label: t('app:theme.light', { defaultValue: 'Light' }),
},
{
value: 'dark',
label: t('app:theme.dark', { defaultValue: 'Dark' }),
},
];
return (
<SettingRow label={t('app:settings.theme', { defaultValue: 'Design' })}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{options.map((o) => {
const active = theme === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => setTheme(o.value)}
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')
}
>
{o.label}
</button>
);
})}
</div>
</SettingRow>
);
}
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, setVideoInputDevice } = useCall();
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
const [inputId, setInputId] = useState<string | null>(
() => getAudioSettings().inputDeviceId,
);
const [outputId, setOutputId] = useState<string | null>(
() => getAudioSettings().outputDeviceId,
);
const [cameraId, setCameraId] = useState<string | null>(
() => getAudioSettings().videoInputDeviceId,
);
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'));
setCameras(list.filter((d) => d.kind === 'videoinput'));
// 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);
setCameraId(s.videoInputDeviceId);
});
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 handleCamera = useCallback(
async (id: string) => {
const next = id === '' ? null : id;
setCameraId(next);
await setVideoInputDevice(next);
},
[setVideoInputDevice],
);
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>
<div>
<div className="text-sm font-semibold text-fg">
{t('app:settings.camera_title', { defaultValue: 'Kamera' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.camera_hint', {
defaultValue:
'Bevorzugte Kamera. Bei aktivem Anruf wird live umgeschaltet.',
})}
</p>
<div className="mt-2 flex items-center gap-2">
<select
value={cameraId ?? ''}
onChange={(e) => void handleCamera(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>
{cameras.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
</option>
))}
</select>
</div>
</div>
<div className="border-t border-line pt-3">
<MicTestSection />
</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>
);
}