diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c62974f..0380f3e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ChatApp", - "version": "0.6.0", + "version": "0.7.0", "identifier": "com.meinname.chatapp", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/apps/desktop/src/components/ConversationHeader.tsx b/apps/desktop/src/components/ConversationHeader.tsx index 999c4e1..21385ac 100644 --- a/apps/desktop/src/components/ConversationHeader.tsx +++ b/apps/desktop/src/components/ConversationHeader.tsx @@ -210,17 +210,14 @@ function ActiveCallBanner({ conversationId }: { conversationId: string }) { const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId; // 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(() => { if (!justLeft) return; - if (active.length === 0) return; - if (othersIn.length === 0) { - // Active reports only us (or something stale) — wait, then dismiss. - const id = window.setTimeout(() => dismissLastCall(), 3000); - return () => window.clearTimeout(id); - } - return undefined; - }, [justLeft, active.length, othersIn.length, dismissLastCall]); + if (othersIn.length > 0) return; // still live — keep banner + const id = window.setTimeout(() => dismissLastCall(), 3000); + return () => window.clearTimeout(id); + }, [justLeft, othersIn.length, dismissLastCall]); if (iAmIn) return null; if (othersIn.length === 0 && !justLeft) return null; diff --git a/apps/desktop/src/components/InCallPanel.tsx b/apps/desktop/src/components/InCallPanel.tsx index d544320..8c1f6a3 100644 --- a/apps/desktop/src/components/InCallPanel.tsx +++ b/apps/desktop/src/components/InCallPanel.tsx @@ -487,7 +487,7 @@ function FullscreenCall({ }, []); return ( -
+
{speaker && (
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