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
This commit is contained in:
@@ -4,13 +4,14 @@ import {
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from '@chat-app/shared/i18n';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
@@ -142,7 +143,10 @@ export function SettingsPage() {
|
||||
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioQualityControls />
|
||||
<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>
|
||||
@@ -642,3 +646,196 @@ function Toggle({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user