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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user