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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.6.0", "version": "0.7.0",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
@@ -210,17 +210,14 @@ function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId; const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
// Once presence confirms the room is empty, drop the "just left" hint so the // Once presence confirms the room is empty, drop the "just left" hint so the
// banner hides cleanly instead of sticking forever. // banner hides cleanly instead of sticking forever. Grace window handles the
// brief gap between hangup and presence re-sync so we don't flicker.
useEffect(() => { useEffect(() => {
if (!justLeft) return; if (!justLeft) return;
if (active.length === 0) return; if (othersIn.length > 0) return; // still live — keep banner
if (othersIn.length === 0) { const id = window.setTimeout(() => dismissLastCall(), 3000);
// Active reports only us (or something stale) — wait, then dismiss. return () => window.clearTimeout(id);
const id = window.setTimeout(() => dismissLastCall(), 3000); }, [justLeft, othersIn.length, dismissLastCall]);
return () => window.clearTimeout(id);
}
return undefined;
}, [justLeft, active.length, othersIn.length, dismissLastCall]);
if (iAmIn) return null; if (iAmIn) return null;
if (othersIn.length === 0 && !justLeft) return null; if (othersIn.length === 0 && !justLeft) return null;
+1 -1
View File
@@ -487,7 +487,7 @@ function FullscreenCall({
}, []); }, []);
return ( return (
<div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface"> <div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
{speaker && ( {speaker && (
<div className="absolute inset-0"> <div className="absolute inset-0">
@@ -1,5 +1,6 @@
import type { RemoteTrack } from 'livekit-client'; import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext'; import type { RemoteScreenShare } from '../context/CallContext';
@@ -32,30 +33,31 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
}; };
}, [share.track, watching]); }, [share.track, watching]);
// Esc exits CSS fullscreen.
useEffect(() => { useEffect(() => {
const onChange = () => { if (!isFullscreen) return;
setIsFullscreen(document.fullscreenElement === containerRef.current); const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsFullscreen(false);
}; };
document.addEventListener('fullscreenchange', onChange); window.addEventListener('keydown', onKey);
return () => document.removeEventListener('fullscreenchange', onChange); return () => window.removeEventListener('keydown', onKey);
}, []); }, [isFullscreen]);
// CSS-only "app fullscreen" — Discord-style: overlay the whole window
// including the left sidebar + chat list. Native Fullscreen API is
// unreliable in Tauri's WKWebView and doesn't add useful chrome-hiding
// beyond what `fixed inset-0 z-[60]` already gives us.
const toggleFullscreen = () => { const toggleFullscreen = () => {
const el = containerRef.current; setIsFullscreen((v) => !v);
if (!el) return;
if (document.fullscreenElement === el) {
void document.exitFullscreen();
} else {
void el.requestFullscreen();
}
}; };
return ( const viewerNode = (
<div <div
ref={containerRef} ref={containerRef}
className={ className={
'overflow-hidden rounded-xl border border-emerald-500/30 bg-black ' + isFullscreen
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '') ? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
} }
> >
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200"> <div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
@@ -130,6 +132,14 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)} )}
</div> </div>
); );
// When in fullscreen, portal out of the call-panel subtree into <body> so
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
// AppShell's root — portalled node sits above them via z-[60].
if (isFullscreen) {
return createPortal(viewerNode, document.body);
}
return viewerNode;
} }
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) { function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
+64
View File
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import { import {
getAudioQualityParams, getAudioQualityParams,
getAudioSettings, getAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings'; } from '../lib/audioSettings';
import { import {
createCallE2EE, createCallE2EE,
@@ -120,6 +121,12 @@ interface CallContextValue {
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void; setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void; setFocusedId: (id: string | null) => void;
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
// hot-swaps the input on an active call without a reconnect.
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
// currently-attached remote-audio elements.
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
} }
const CallContext = createContext<CallContextValue | null>(null); const CallContext = createContext<CallContextValue | null>(null);
@@ -482,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false); setIsE2EEActive(false);
} }
try { try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, { await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation, echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression, noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl, autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1, channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz, sampleRate: aParams.sampleRateHz,
// Plain string maps to `ideal` — if the device is gone we fall back
// to OS default instead of throwing NotFoundError.
...(inputId ? { deviceId: inputId } : {}),
}); });
} catch (micErr: unknown) { } catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr); console.error('setMicrophoneEnabled failed', micErr);
@@ -973,6 +984,46 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedIdState(id); setFocusedIdState(id);
}, []); }, []);
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId });
const r = roomRef.current;
if (!r) return;
try {
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
// reconnect. Pass empty string or `default` to revert to OS default.
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
} catch (err: unknown) {
console.error('switchActiveDevice(audioinput) failed', err);
}
}, []);
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ outputDeviceId: deviceId });
const sinkId = deviceId ?? '';
// Apply to every <audio> element we've attached to the body. LiveKit's
// switchActiveDevice only tracks elements it attached itself; our custom
// appendChild path bypasses that, so we iterate and setSinkId manually.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
for (const el of Array.from(els)) {
if (typeof el.setSinkId !== 'function') continue;
try {
await el.setSinkId(sinkId);
} catch (err: unknown) {
console.warn('setSinkId on audio element failed', err);
}
}
const r = roomRef.current;
if (r) {
try {
await r.switchActiveDevice('audiooutput', sinkId || 'default');
} catch (err: unknown) {
console.warn('switchActiveDevice(audiooutput) failed', err);
}
}
}, []);
// Reset UI call-mode state when the call leaves any active phase so the next // Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused. // call starts fresh at grid/unfocused.
useEffect(() => { useEffect(() => {
@@ -1017,6 +1068,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
}), }),
[ [
state, state,
@@ -1039,6 +1092,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
], ],
); );
@@ -1063,6 +1118,15 @@ function attachTrack(
audio.setAttribute('playsinline', 'true'); audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? ''); audio.setAttribute('data-livekit-track', track.sid ?? '');
document.body.appendChild(audio); document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
// doesn't track custom-appended elements).
const sinkId = getAudioSettings().outputDeviceId;
if (sinkId && typeof audio.setSinkId === 'function') {
void audio.setSinkId(sinkId).catch((err: unknown) => {
console.warn('setSinkId on attach failed', err);
});
}
} }
} }
// Video is handled later in M2.6/M3 by a dedicated <video> element. // Video is handled later in M2.6/M3 by a dedicated <video> element.
+17
View File
@@ -9,10 +9,19 @@ export type AudioQuality = 'voice' | 'hifi';
export interface AudioSettings { export interface AudioSettings {
quality: AudioQuality; quality: AudioQuality;
// Preferred input deviceId from enumerateDevices. null = use browser default
// (whatever the OS points at). Persisted across sessions, so "grandma's
// mic is default" stays even after browser picks the wrong device.
inputDeviceId: string | null;
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
} }
const DEFAULTS: AudioSettings = { const DEFAULTS: AudioSettings = {
quality: 'voice', quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
}; };
export interface AudioQualityParams { export interface AudioQualityParams {
@@ -73,6 +82,14 @@ function read(): AudioSettings {
const parsed = JSON.parse(raw) as Partial<AudioSettings>; const parsed = JSON.parse(raw) as Partial<AudioSettings>;
cached = { cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality, quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
typeof parsed.inputDeviceId === 'string' && parsed.inputDeviceId.length > 0
? parsed.inputDeviceId
: DEFAULTS.inputDeviceId,
outputDeviceId:
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
}; };
return cached; return cached;
} catch { } catch {
+199 -2
View File
@@ -4,13 +4,14 @@ import {
SUPPORTED_LOCALES, SUPPORTED_LOCALES,
type SupportedLocale, type SupportedLocale,
} from '@chat-app/shared/i18n'; } from '@chat-app/shared/i18n';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog'; import { BackupExportDialog } from '../components/BackupExportDialog';
import { LockIcon } from '../components/icons'; import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload'; import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
@@ -142,7 +143,10 @@ export function SettingsPage() {
{/* Voice / Push-to-Talk + Audio Quality + E2EE */} {/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}> <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"> <div className="mt-3 border-t border-line pt-3">
<PttControls /> <PttControls />
</div> </div>
@@ -642,3 +646,196 @@ function Toggle({
</label> </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>
);
}