37becba7e2
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
842 lines
29 KiB
TypeScript
842 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|