feat: audio devices + fullscreen + banner cleanup (v0.7.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled

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:
2026-04-20 21:26:10 +02:00
parent eb8f9857ff
commit 37becba7e2
7 changed files with 313 additions and 28 deletions
+199 -2
View File
@@ -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>
);
}