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
+64
View File
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
createCallE2EE,
@@ -120,6 +121,12 @@ interface CallContextValue {
dismissLastCall: () => void;
setCallMode: (mode: CallMode) => 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);
@@ -482,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false);
}
try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
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) {
console.error('setMicrophoneEnabled failed', micErr);
@@ -973,6 +984,46 @@ export function CallProvider({ children }: { children: ReactNode }) {
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
// call starts fresh at grid/unfocused.
useEffect(() => {
@@ -1017,6 +1068,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
}),
[
state,
@@ -1039,6 +1092,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
],
);
@@ -1063,6 +1118,15 @@ function attachTrack(
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
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.