diff --git a/apps/desktop/src/components/ScreenShareViewer.tsx b/apps/desktop/src/components/ScreenShareViewer.tsx
index 1084733..79cd952 100644
--- a/apps/desktop/src/components/ScreenShareViewer.tsx
+++ b/apps/desktop/src/components/ScreenShareViewer.tsx
@@ -1,5 +1,6 @@
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext';
@@ -32,30 +33,31 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
};
}, [share.track, watching]);
+ // Esc exits CSS fullscreen.
useEffect(() => {
- const onChange = () => {
- setIsFullscreen(document.fullscreenElement === containerRef.current);
+ if (!isFullscreen) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setIsFullscreen(false);
};
- document.addEventListener('fullscreenchange', onChange);
- return () => document.removeEventListener('fullscreenchange', onChange);
- }, []);
+ window.addEventListener('keydown', onKey);
+ 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 el = containerRef.current;
- if (!el) return;
- if (document.fullscreenElement === el) {
- void document.exitFullscreen();
- } else {
- void el.requestFullscreen();
- }
+ setIsFullscreen((v) => !v);
};
- return (
+ const viewerNode = (
@@ -130,6 +132,14 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)}
);
+
+ // When in fullscreen, portal out of the call-panel subtree into 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 }) {
diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx
index a0aeeee..9ed43e0 100644
--- a/apps/desktop/src/context/CallContext.tsx
+++ b/apps/desktop/src/context/CallContext.tsx
@@ -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
;
+ // Runtime speaker/headphone switch. Persists + applies setSinkId to all
+ // currently-attached remote-audio elements.
+ setAudioOutputDevice: (deviceId: string | null) => Promise;
}
const CallContext = createContext(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