import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client'; import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react'; import { useQuery, useConvex } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; import { findTrackPubs } from '../utils/streamUtils.jsx'; import { VoiceRecorder } from '../utils/voiceRecorder'; import { usePlatform } from '../platform'; import '@livekit/components-styles'; import joinSound from '../assets/sounds/join_call.mp3'; import leaveSound from '../assets/sounds/leave_call.mp3'; import muteSound from '../assets/sounds/mute.mp3'; import unmuteSound from '../assets/sounds/unmute.mp3'; import deafenSound from '../assets/sounds/deafen.mp3'; import undeafenSound from '../assets/sounds/undeafen.mp3'; import viewerJoinSound from '../assets/sounds/screenshare_viewer_join.mp3'; import viewerLeaveSound from '../assets/sounds/screenshare_viewer_leave.mp3'; import screenshareStartSound from '../assets/sounds/screenshare_start.mp3'; import screenshareStopSound from '../assets/sounds/screenshare_stop.mp3'; import cameraOnSound from '../assets/sounds/camera_on.mp3'; import cameraOffSound from '../assets/sounds/camera_off.mp3'; const soundMap = { join: joinSound, leave: leaveSound, mute: muteSound, unmute: unmuteSound, deafen: deafenSound, undeafen: undeafenSound, viewer_join: viewerJoinSound, viewer_leave: viewerLeaveSound, screenshare_start: screenshareStartSound, screenshare_stop: screenshareStopSound, camera_on: cameraOnSound, camera_off: cameraOffSound, }; const VoiceContext = createContext(); export const useVoice = () => useContext(VoiceContext); let _suppressAppSounds = false; function playSound(type) { if (_suppressAppSounds) return; const src = soundMap[type]; if (!src) return; const audio = new Audio(src); audio.volume = 0.5; audio.play().catch(e => console.error("Sound play failed", e)); } function playSoundUrl(url) { if (_suppressAppSounds) return; const audio = new Audio(url); audio.volume = 0.5; audio.play().catch(e => console.error("Sound play failed", e)); } export const VoiceProvider = ({ children }) => { const platform = usePlatform(); const { idle, voiceService } = platform; const [activeChannelId, setActiveChannelId] = useState(null); const [activeChannelName, setActiveChannelName] = useState(null); const [connectionState, setConnectionState] = useState('disconnected'); const [room, setRoom] = useState(null); const [token, setToken] = useState(null); const [activeSpeakers, setActiveSpeakers] = useState(new Set()); const speakerRemovalTimers = useRef(new Map()); const clearSpeakerTimers = useCallback(() => { for (const timer of speakerRemovalTimers.current.values()) { clearTimeout(timer); } speakerRemovalTimers.current.clear(); }, []); const [isMuted, setIsMuted] = useState(false); const [isDeafened, setIsDeafened] = useState(false); const [isScreenSharing, setIsScreenSharingLocal] = useState(false); const [globalOutputVolume, setGlobalOutputVolume] = useState(() => parseInt(localStorage.getItem('voiceOutputVolume') || '100') ); const isMovingRef = useRef(false); const isDMCallRef = useRef(false); // Flipped to true by `disconnectVoice` when the user presses // disconnect *during* the 'connecting' phase. Each major await in // `connectToVoice` checks this ref and bails out cleanly so we // don't end up connected to a channel the user tried to leave. const cancelConnectRef = useRef(false); const lastSpokeRef = useRef(Date.now()); const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [connectionQualities, setConnectionQualities] = useState({}); // Voice recording — crash-safe per-participant audio capture. // Only available on Electron (`platform.features.hasRecording`). // `recordingError` is surfaced once per failure so UI can toast // it; consumers should clear it after reading. const [isRecording, setIsRecording] = useState(false); const [recordingStartedAt, setRecordingStartedAt] = useState(null); const [recordingSessionId, setRecordingSessionId] = useState(null); const [recordingError, setRecordingError] = useState(null); const voiceRecorderRef = useRef(null); const convex = useConvex(); // Single source of truth for the signed-in user id. All the // voice/presence effects below used to read localStorage // directly — that worked but wasn't reactive, so a // logout-then-login in the same tab could leave effects // operating on a stale id until something else triggered a // rerender. The `storage` and `brycord:auth-change` listeners // keep this state in sync across tabs (the former) and // within-tab login/logout (the latter, emitted by // hooks/useLogout + the login page). const [myUserId, setMyUserId] = useState( typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null, ); useEffect(() => { if (typeof window === 'undefined') return; const sync = () => { setMyUserId( typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null, ); }; window.addEventListener('storage', sync); window.addEventListener('brycord:auth-change', sync); return () => { window.removeEventListener('storage', sync); window.removeEventListener('brycord:auth-change', sync); }; }, []); // Stream watching state (lifted from VoiceStage so PiP can persist across navigation) const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null); const setWatchingStreamOf = useCallback((identity) => { setWatchingStreamOfRaw(identity); // Sync to backend const userId = myUserId; if (userId) { convex.mutation(api.voiceState.setWatchingStream, { userId, ...(identity ? { watchingStream: identity } : {}), }).catch(e => console.error('Failed to set watching stream:', e)); } // Play join sound for the viewer starting to watch if (identity) { playSound('viewer_join'); } }, [convex]); const clearWatchingStream = useCallback(() => { setWatchingStreamOfRaw(null); const userId = myUserId; if (userId) { convex.mutation(api.voiceState.setWatchingStream, { userId }).catch( e => console.error('Failed to clear watching stream:', e) ); } }, [convex]); // Personal mute state (persisted to localStorage) const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => { const saved = localStorage.getItem('personallyMutedUsers'); return new Set(saved ? JSON.parse(saved) : []); }); // Per-user volume state: userId → 0-200 (persisted to localStorage) const [userVolumes, setUserVolumes] = useState(() => { const saved = localStorage.getItem('userVolumes'); return saved ? JSON.parse(saved) : {}; }); const setUserVolume = useCallback((userId, volume) => { setUserVolumes(prev => { const next = { ...prev, [userId]: volume }; localStorage.setItem('userVolumes', JSON.stringify(next)); return next; }); // Apply volume to LiveKit participant (factoring in global output volume) const participant = room?.remoteParticipants?.get(userId); const globalVol = globalOutputVolume / 100; if (participant) participant.setVolume(Math.min(2, (volume / 100) * globalVol)); // Sync personal mute state if (volume === 0) { setPersonallyMutedUsers(prev => { const next = new Set(prev); next.add(userId); localStorage.setItem('personallyMutedUsers', JSON.stringify([...next])); return next; }); } else { setPersonallyMutedUsers(prev => { if (!prev.has(userId)) return prev; const next = new Set(prev); next.delete(userId); localStorage.setItem('personallyMutedUsers', JSON.stringify([...next])); return next; }); } }, [room, globalOutputVolume]); const getUserVolume = useCallback((userId) => { return userVolumes[userId] ?? 100; }, [userVolumes]); const togglePersonalMute = (userId) => { const globalVol = globalOutputVolume / 100; setPersonallyMutedUsers(prev => { const next = new Set(prev); if (next.has(userId)) { next.delete(userId); // Restore to stored volume (default 100) const vol = userVolumes[userId] ?? 100; const restoreVol = vol === 0 ? 100 : vol; const participant = room?.remoteParticipants?.get(userId); if (participant) participant.setVolume(Math.min(2, (restoreVol / 100) * globalVol)); // Update stored volume if it was 0 if (vol === 0) { setUserVolumes(p => { const n = { ...p, [userId]: 100 }; localStorage.setItem('userVolumes', JSON.stringify(n)); return n; }); } } else { next.add(userId); const participant = room?.remoteParticipants?.get(userId); if (participant) participant.setVolume(0); } localStorage.setItem('personallyMutedUsers', JSON.stringify([...next])); return next; }); }; const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId); const serverMute = async (targetUserId, isServerMuted) => { const actorUserId = myUserId; if (!actorUserId) return; try { await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted }); } catch (e) { console.error('Failed to server mute:', e); } }; const disconnectUser = async (targetUserId) => { const actorUserId = myUserId; if (!actorUserId) return; try { await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId }); } catch (e) { console.error('Failed to disconnect user:', e); } }; const isServerMuted = (userId) => { for (const users of Object.values(voiceStates)) { const user = users.find(u => u.userId === userId); if (user) return !!user.isServerMuted; } return false; }; const voiceStates = useQuery(api.voiceState.getAll) || {}; const serverSettings = useQuery(api.serverSettings.get); // Subscribe to own join sound URL for self-join playback const myJoinSoundUrl = useQuery( api.auth.getMyJoinSoundUrl, myUserId ? { userId: myUserId } : "skip" ); // Refs for detecting other-user joins via voiceStates changes const prevChannelUsersRef = useRef(new Set()); const otherJoinInitRef = useRef(false); const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId); async function updateVoiceState(fields) { const userId = myUserId; if (!userId || !activeChannelId) return; try { await convex.mutation(api.voiceState.updateState, { userId, ...fields }); } catch (e) { console.error('Failed to update voice state:', e); } } const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => { if (activeChannelId === channelId) return; isDMCallRef.current = isDMCall; cancelConnectRef.current = false; // Channel hop — we're already in a voice room. Flip the // move-in-progress flag BEFORE disconnecting so the old // room's RoomEvent.Disconnected handler takes the short // "moving" branch: it clears room/token without touching // activeChannelId, skips the voiceState.leave mutation, and // skips the leave sound. Without this the handler races // against the rest of this function — nulling the channel // id we're about to set and calling leave() between our // disconnect and the new voiceState.join, which looks like // "joins, plays sound, drops out" from the user's side. const isMove = !!room; if (isMove) isMovingRef.current = true; if (room) await room.disconnect(); setActiveChannelId(channelId); setActiveChannelName(channelName); setConnectionState('connecting'); window.__inVoiceCall = true; // Cancellation check — called after every major async step. // If the user pressed disconnect while we were awaiting, roll // back: close the partially-built room (if any; its // Disconnected handler cleans up state), otherwise reset the // pre-room UI state manually. Returns true when the caller // should bail. const abortIfCancelled = (builtRoom) => { if (!cancelConnectRef.current) return false; if (builtRoom) { builtRoom.disconnect().catch(() => {}); } // Always reset UI state on cancel, even when a room is // being torn down. The Disconnected handler's "moving" // branch preserves activeChannelId (needed for legit // moves), so we explicitly clear it here. setActiveChannelId(null); setActiveChannelName(null); setConnectionState('disconnected'); setToken(null); window.__inVoiceCall = false; voiceService?.stopService(); // If this was a channel hop we flipped isMovingRef on; // reset it so a later disconnect doesn't wrongly skip // voiceState.leave. isMovingRef.current = false; // Clean up any stale server-side voice state (the old // channel's row survives a cancelled move because the // "moving" branch skips voiceState.leave). convex.mutation(api.voiceState.leave, { userId }).catch(() => {}); return true; }; try { // Request microphone permission (triggers Android runtime prompt) try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream.getTracks().forEach(t => t.stop()); } catch (e) { console.error('Microphone permission denied:', e); setConnectionState('error'); setActiveChannelId(null); return; } if (abortIfCancelled(null)) return; // Prove we control `userId` by signing the (userId, channelId, // timestamp) tuple with the Ed25519 key decrypted at login. // The server verifies with our public signing key before minting // a LiveKit JWT, so voice rooms can't be joined by forging args. const signingKey = sessionStorage.getItem('signingKey'); if (!signingKey) { console.error('Missing signing key — cannot authorize voice join'); setConnectionState('error'); setActiveChannelId(null); return; } const timestamp = Date.now(); const message = `voice-token:${userId}:${channelId}:${timestamp}`; const signature = await platform.crypto.signMessage(signingKey, message); if (abortIfCancelled(null)) return; const tokenResult = await convex.action(api.voice.getToken, { channelId, userId, timestamp, signature, }); if ('error' in tokenResult) { console.error('Voice token rejected:', tokenResult.error); setConnectionState('error'); setActiveChannelId(null); return; } if (abortIfCancelled(null)) return; const lkToken = tokenResult.token; if (!lkToken) throw new Error('Failed to get token'); setToken(lkToken); // Voice Settings tab (settings/UserSettingsModal.tsx) writes // everything to a single `voiceSettings` JSON blob. Read it // here so user choices actually flow through to LiveKit. // Defaults: echoCancellation off (user preference — can be // re-enabled in settings), everything else on. let voiceSettings = { inputDeviceId: 'default', outputDeviceId: 'default', videoDeviceId: 'default', inputVolume: 100, noiseSuppression: true, echoCancellation: false, autoGainControl: true, }; try { const raw = localStorage.getItem('voiceSettings'); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { voiceSettings = { ...voiceSettings, ...parsed }; } } } catch { /* fall through to defaults */ } const storedInputDevice = voiceSettings.inputDeviceId; const storedOutputDevice = voiceSettings.outputDeviceId; const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); const newRoom = new Room({ webAudioMix: true, adaptiveStream: true, dynacast: true, autoSubscribe: true, rtcConfig: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ], }, audioCaptureDefaults: { autoGainControl: voiceSettings.autoGainControl, echoCancellation: voiceSettings.echoCancellation, noiseSuppression: voiceSettings.noiseSuppression, channelCount: 1, sampleRate: 48000, ...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}), }, videoCaptureDefaults: { resolution: VideoPresets.h720.resolution, }, publishDefaults: { audioPreset: { maxBitrate: 96_000 }, dtx: false, red: true, videoEncoding: VideoPresets.h720.encoding, videoCodec: isMobile ? 'vp8' : 'vp9', screenShareEncoding: { maxBitrate: 10_000_000, maxFramerate: 60, }, screenShareSimulcastLayers: [ { maxBitrate: 2_000_000, maxFramerate: 15, width: 1280, height: 720 }, ], }, }); await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken); // Cancelled during the LiveKit handshake — tear the room // back down before we wire any more state around it. if (abortIfCancelled(newRoom)) return; if (storedOutputDevice && storedOutputDevice !== 'default') { await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e => console.warn('Failed to switch output device on connect:', e) ); } await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened); if (abortIfCancelled(newRoom)) return; setRoom(newRoom); setConnectionState('connected'); // Start native foreground service for background voice on Android voiceService?.startService({ channelName, isMuted, isDeafened }); // Play custom join sound if set, otherwise default if (myJoinSoundUrl) { playSoundUrl(myJoinSoundUrl); } else { playSound('join'); } // Register the Disconnected / Reconnecting / etc. handlers // BEFORE `voiceState.join`. If the user taps disconnect in // the narrow window between `setRoom` and this mutation // returning, `disconnectVoice` calls `room.disconnect()` — // without the handler bound there's nothing to tear down // state. Keeping registration first closes that gap. newRoom.on(RoomEvent.Disconnected, async (reason) => { console.warn('Voice Room Disconnected. Reason:', reason); // If we're being moved, skip leave mutation — we'll reconnect shortly if (isMovingRef.current) { setRoom(null); setToken(null); clearSpeakerTimers(); setActiveSpeakers(new Set()); setConnectionQualities({}); return; } // Auto-reconnect on token expiry if (reason === DisconnectReason.TOKEN_EXPIRED) { console.log('Token expired, auto-reconnecting...'); setRoom(null); setToken(null); clearSpeakerTimers(); setActiveSpeakers(new Set()); setConnectionQualities({}); try { await connectToVoice(channelId, channelName, userId, isDMCallRef.current); } catch (e) { console.error('Auto-reconnect failed:', e); } return; } voiceService?.stopService(); playSound('leave'); setConnectionState('disconnected'); setActiveChannelId(null); window.__inVoiceCall = false; setRoom(null); setToken(null); clearSpeakerTimers(); setActiveSpeakers(new Set()); setConnectionQualities({}); try { await convex.mutation(api.voiceState.leave, { userId }); } catch (e) { console.error('Failed to leave voice state:', e); } }); newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => { const currentSpeakerIds = new Set(speakers.map(p => p.identity)); // Track when local user last spoke (for Android AFK detection) const localIdentity = newRoom.localParticipant?.identity; if (localIdentity && currentSpeakerIds.has(localIdentity)) { lastSpokeRef.current = Date.now(); } // Cancel pending removal timers for anyone who is speaking again for (const id of currentSpeakerIds) { const timer = speakerRemovalTimers.current.get(id); if (timer) { clearTimeout(timer); speakerRemovalTimers.current.delete(id); } } setActiveSpeakers(prev => { const next = new Set(prev); // Add new speakers immediately for (const id of currentSpeakerIds) { next.add(id); } // Schedule delayed removal for speakers no longer in the event for (const id of prev) { if (!currentSpeakerIds.has(id) && !speakerRemovalTimers.current.has(id)) { const timer = setTimeout(() => { speakerRemovalTimers.current.delete(id); setActiveSpeakers(s => { const updated = new Set(s); updated.delete(id); return updated; }); }, 300); speakerRemovalTimers.current.set(id, timer); } } return next; }); }); newRoom.on(RoomEvent.Reconnecting, () => { console.warn('Voice room reconnecting...'); setIsReconnecting(true); setConnectionState('reconnecting'); }); newRoom.on(RoomEvent.Reconnected, () => { console.log('Voice room reconnected'); setIsReconnecting(false); setConnectionState('connected'); }); newRoom.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => { setConnectionQualities(prev => ({ ...prev, [participant.identity]: quality, })); }); // Commit the server-side voice state LAST. Until this // resolves the reactive `voiceStates` query still reports // us in the old channel, which is why we keep // `isMovingRef.current === true` across the mutation — // otherwise the reconcile effect below sees "server says // A, client says B" and kicks off a spurious reconnect // back to A (the bug that caused double-join / double- // leave sounds when switching channels). await convex.mutation(api.voiceState.join, { channelId, userId, username: localStorage.getItem('username') || 'Unknown', isMuted, isDeafened, }); // Auto-mute when joining AFK channel if (serverSettings?.afkChannelId === channelId) { setIsMuted(true); await newRoom.localParticipant.setMicrophoneEnabled(false); await convex.mutation(api.voiceState.updateState, { userId, isMuted: true }); voiceService?.updateNotification({ isMuted: true }); } // Now that the server state has been committed and the // reactive query will (imminently) show us in `channelId`, // it's safe to re-enable the reconcile effect. A subtle // point: the query push can land either before or after // the mutation promise resolves; both orderings are fine // because in either case by the time the effect next // reads `voiceStates` it'll either agree with // activeChannelId or this flag will still be true. isMovingRef.current = false; } catch (err) { console.error('Voice Connection Failed:', err); setConnectionState('error'); setActiveChannelId(null); window.__inVoiceCall = false; isMovingRef.current = false; } }; // Heartbeat: send periodic heartbeat to prevent ghost voice states useEffect(() => { if (!activeChannelId) return; const userId = myUserId; if (!userId) return; const sendHeartbeat = () => { convex.mutation(api.voiceState.heartbeat, { userId }).catch(e => console.warn('Heartbeat failed:', e) ); }; // Send immediately, then every 30 seconds sendHeartbeat(); const interval = setInterval(sendHeartbeat, 30_000); return () => clearInterval(interval); }, [activeChannelId, convex]); // Handle notification action buttons (Android foreground service). // Capacitor's plugin API has historically shifted between returning // a listener handle synchronously and returning a Promise, so we // normalize both shapes into the same cleanup code instead of // leaving a silent no-op when neither matches. useEffect(() => { if (!voiceService) return; let cancelled = false; let resolvedHandle = null; const handle = voiceService.addNotificationActionListener((event) => { switch (event.action) { case 'disconnect': disconnectVoice(); break; case 'toggleMute': toggleMute(); break; case 'toggleDeafen': toggleDeafen(); break; } }); if (handle && typeof handle.then === 'function') { handle.then((l) => { if (cancelled) { l?.remove?.(); } else { resolvedHandle = l; } }).catch(() => { /* nothing to clean up */ }); } else { resolvedHandle = handle; } return () => { cancelled = true; try { resolvedHandle?.remove?.(); } catch (e) { console.warn('Failed to remove notification listener:', e); } }; }, [voiceService, activeChannelId]); // Detect when another user moves us to a different voice channel. // // `connectToVoice` is a plain function (not useCallback), so listing // it as a dep would re-run this effect on every render — an infinite // reconnect loop. We stash the latest reference in a ref so the // effect can call the freshest copy without triggering itself. The // audit flagged the old setup for potentially using stale // credentials if the move fired mid-render; the ref closes that gap. const connectToVoiceRef = useRef(connectToVoice); useEffect(() => { connectToVoiceRef.current = connectToVoice; }); useEffect(() => { if (!myUserId || !activeChannelId || isMovingRef.current) return; // Find which channel the server says we're in let serverChannelId = null; for (const [chId, users] of Object.entries(voiceStates)) { if (users.some(u => u.userId === myUserId)) { serverChannelId = chId; break; } } // If server says we're in a different channel, reconnect if (serverChannelId && serverChannelId !== activeChannelId) { isMovingRef.current = true; const currentRoom = room; (async () => { try { const channel = await convex.query(api.channels.get, { id: serverChannelId }); if (currentRoom) await currentRoom.disconnect(); await connectToVoiceRef.current(serverChannelId, channel?.name || 'Voice', myUserId); } catch (e) { console.error('Failed to reconnect after move:', e); } finally { isMovingRef.current = false; } })(); } }, [voiceStates, activeChannelId, room, convex, myUserId]); // Enforce server mute: force-disable mic when server muted, restore when lifted useEffect(() => { if (!myUserId || !room) return; if (isServerMuted(myUserId)) { room.localParticipant.setMicrophoneEnabled(false); } else if (!isMuted && !isDeafened) { room.localParticipant.setMicrophoneEnabled(true); } }, [voiceStates, room, myUserId]); // Re-apply personal mutes/volumes when room or participants change useEffect(() => { if (!room) return; const globalVol = globalOutputVolume / 100; const applyVolumes = () => { for (const [identity, participant] of room.remoteParticipants) { if (personallyMutedUsers.has(identity)) { participant.setVolume(0); } else { const userVol = (userVolumes[identity] ?? 100) / 100; participant.setVolume(Math.min(2, userVol * globalVol)); } } }; applyVolumes(); room.on(RoomEvent.ParticipantConnected, applyVolumes); return () => room.off(RoomEvent.ParticipantConnected, applyVolumes); }, [room, personallyMutedUsers, userVolumes, globalOutputVolume]); // AFK idle polling: move user to AFK channel when idle exceeds timeout useEffect(() => { if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return; if (isDMCallRef.current) return; // Skip AFK for DM calls const isCapacitor = !!window.Capacitor?.isNativePlatform?.(); // On desktop/web, require system idle API; on Capacitor, use lastSpokeRef if (!isCapacitor && !idle?.getSystemIdleTime) return; const afkTimeout = serverSettings.afkTimeout || 300; const interval = setInterval(async () => { try { let idleSeconds; if (isCapacitor) { // On Android, idle = time since user last spoke in voice idleSeconds = Math.floor((Date.now() - lastSpokeRef.current) / 1000); } else { idleSeconds = await idle.getSystemIdleTime(); } if (idleSeconds >= afkTimeout) { const userId = myUserId; if (!userId) return; // On Capacitor, also set user status to idle if (isCapacitor) { const signingKey = sessionStorage.getItem('signingKey'); if (signingKey) { const authTimestamp = Date.now(); const authSignature = await platform.crypto.signMessage( signingKey, `updateStatus:${userId}:idle:${authTimestamp}`, ); await convex.action(api.authActions.updateStatus, { userId, status: 'idle', authTimestamp, authSignature, }); } } await convex.mutation(api.voiceState.afkMove, { userId, afkChannelId: serverSettings.afkChannelId, }); // After server-side move, locally mute setIsMuted(true); if (room) room.localParticipant.setMicrophoneEnabled(false); voiceService?.updateNotification({ isMuted: true }); } } catch (e) { console.error('AFK check failed:', e); } }, 15000); return () => clearInterval(interval); }, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]); // Detect other users joining the same voice channel and play their join sound useEffect(() => { if (!activeChannelId) { prevChannelUsersRef.current = new Set(); otherJoinInitRef.current = false; return; } const selfId = myUserId; const channelUsers = voiceStates[activeChannelId] || []; const currentUserIds = new Set(channelUsers.map(u => u.userId)); // Guard: ignore transient empty states when we previously had users if (currentUserIds.size === 0 && prevChannelUsersRef.current.size > 0) { return; } // Skip the first render after joining to avoid playing sounds for users already in the channel if (!otherJoinInitRef.current) { otherJoinInitRef.current = true; prevChannelUsersRef.current = currentUserIds; return; } const prevIds = prevChannelUsersRef.current; // Detect new users (not self) for (const uid of currentUserIds) { if (uid !== selfId && !prevIds.has(uid)) { const userData = channelUsers.find(u => u.userId === uid); if (userData?.joinSoundUrl) { playSoundUrl(userData.joinSoundUrl); } else { playSound('join'); } break; // one sound per update batch } } prevChannelUsersRef.current = currentUserIds; }, [voiceStates, activeChannelId, myUserId]); // Manage screen share subscriptions — only subscribe when actively watching useEffect(() => { if (!room) return; const manageSubscriptions = () => { let receivingAudio = false; for (const p of room.remoteParticipants.values()) { const { screenSharePub, screenShareAudioPub } = findTrackPubs(p); const shouldSubscribe = watchingStreamOf === p.identity; if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) { screenSharePub.setSubscribed(shouldSubscribe); } if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) { screenShareAudioPub.setSubscribed(shouldSubscribe); } if (shouldSubscribe && screenShareAudioPub && screenShareAudioPub.isSubscribed) { receivingAudio = true; } } _suppressAppSounds = receivingAudio; setIsReceivingScreenShareAudio(receivingAudio); }; manageSubscriptions(); const onTrackChange = () => manageSubscriptions(); room.on(RoomEvent.TrackPublished, onTrackChange); room.on(RoomEvent.TrackSubscribed, onTrackChange); room.on(RoomEvent.TrackUnsubscribed, onTrackChange); return () => { room.off(RoomEvent.TrackPublished, onTrackChange); room.off(RoomEvent.TrackSubscribed, onTrackChange); room.off(RoomEvent.TrackUnsubscribed, onTrackChange); _suppressAppSounds = false; setIsReceivingScreenShareAudio(false); }; }, [room, watchingStreamOf]); // Auto-exit if watched participant stops streaming or disconnects useEffect(() => { if (watchingStreamOf === null || !room) return; const checkWatched = () => { // Check if participant is still connected const participant = room.remoteParticipants.get(watchingStreamOf) || (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null); if (!participant) { clearWatchingStream(); return; } // Check if they're still screen sharing const { screenSharePub } = findTrackPubs(participant); if (!screenSharePub) { clearWatchingStream(); } }; // Also listen for voiceStates changes — covered by the dependency array re-run room.on(RoomEvent.ParticipantDisconnected, checkWatched); room.on(RoomEvent.TrackUnpublished, checkWatched); return () => { room.off(RoomEvent.ParticipantDisconnected, checkWatched); room.off(RoomEvent.TrackUnpublished, checkWatched); }; }, [room, watchingStreamOf]); // Reset watching state when room disconnects useEffect(() => { if (!room) { clearWatchingStream(); } }, [room]); // Detect viewer join/leave for the stream we're watching and play sounds const prevViewersRef = useRef(new Set()); const viewerDetectionInitRef = useRef(false); useEffect(() => { if (!watchingStreamOf) { prevViewersRef.current = new Set(); viewerDetectionInitRef.current = false; return; } // Collect all users currently watching the same stream const currentViewers = new Set(); for (const users of Object.values(voiceStates)) { for (const u of users) { if (u.watchingStream === watchingStreamOf && u.userId !== myUserId) { currentViewers.add(u.userId); } } } // Skip first render to avoid spurious sounds on load if (!viewerDetectionInitRef.current) { viewerDetectionInitRef.current = true; prevViewersRef.current = currentViewers; return; } const prev = prevViewersRef.current; // New viewers joined for (const uid of currentViewers) { if (!prev.has(uid)) { playSound('viewer_join'); break; // one sound per update batch } } // Viewers left (excluding self) for (const uid of prev) { if (!currentViewers.has(uid)) { playSound('viewer_leave'); break; // one sound per update batch } } prevViewersRef.current = currentViewers; }, [voiceStates, watchingStreamOf, myUserId]); // Detect screen-share publications starting / stopping across the // active voice channel (including the local user) and play a // "stream started" / "stream stopped" SFX on transitions. Reuses // the viewer_join / viewer_leave clips since they're already // loaded and sound right for the event. const prevSharersRef = useRef(new Set()); const sharerDetectionInitRef = useRef(false); useEffect(() => { if (!activeChannelId) { prevSharersRef.current = new Set(); sharerDetectionInitRef.current = false; return; } const channelUsers = voiceStates[activeChannelId] || []; const currentSharers = new Set(); for (const u of channelUsers) { if (u.isScreenSharing) currentSharers.add(u.userId); } if (!sharerDetectionInitRef.current) { sharerDetectionInitRef.current = true; prevSharersRef.current = currentSharers; return; } const prev = prevSharersRef.current; // Stream started — someone in the channel is now sharing // who wasn't before. for (const uid of currentSharers) { if (!prev.has(uid)) { playSound('screenshare_start'); break; } } // Stream stopped — someone who was sharing isn't anymore. for (const uid of prev) { if (!currentSharers.has(uid)) { playSound('screenshare_stop'); break; } } prevSharersRef.current = currentSharers; }, [voiceStates, activeChannelId]); const disconnectVoice = () => { console.log('User manually disconnected voice'); isDMCallRef.current = false; // Mid-connect cancel: `connectToVoice` is awaiting somewhere // (mic permission, Convex token action, LiveKit handshake). // Flip the cancel ref so the next `abortIfCancelled` check // tears things down, and reset visible UI state immediately // so the button reflects the user's intent even before the // in-flight awaits unwind. if (connectionState === 'connecting') { cancelConnectRef.current = true; setConnectionState('disconnected'); setActiveChannelId(null); setActiveChannelName(null); setToken(null); window.__inVoiceCall = false; voiceService?.stopService(); // If the LiveKit connect already completed before the // cancel fired, the room exists in state and we still // need to drop it. The RoomEvent.Disconnected handler // will run voiceState.leave and finish cleanup. if (room) room.disconnect().catch(() => {}); return; } // A user-initiated disconnect always wants the "leave for // real" path, even if we're still inside the // `connectToVoice(new)` move window (connectionState === // 'connected' but `isMovingRef` hasn't cleared yet because // `voiceState.join` is in flight). Clearing the move flag // here makes the Disconnected handler take the normal // branch: play leave, clear activeChannelId, run // voiceState.leave. isMovingRef.current = false; window.__inVoiceCall = false; voiceService?.stopService(); if (room) room.disconnect(); }; const toggleMute = async () => { // Block unmute if server muted or in AFK channel if (isMuted && myUserId && isServerMuted(myUserId)) return; if (isMuted && isInAfkChannel) return; const nextState = !isMuted; // Flip LiveKit first. If this rejects we bail before committing any // UI state — otherwise the user sees "muted" while their mic is // still publishing to everyone in the room (a privacy leak, not // just a UX nit). if (room) { try { await room.localParticipant.setMicrophoneEnabled(!nextState); } catch (e) { console.error('Failed to toggle microphone:', e); return; } } setIsMuted(nextState); playSound(nextState ? 'mute' : 'unmute'); voiceService?.updateNotification({ isMuted: nextState }); try { await updateVoiceState({ isMuted: nextState }); } catch (e) { // LiveKit is already in the right state, so audio is safe; // the server's voice-states row is just stale. Other clients // will pick up the correct value from our next successful // mutation or heartbeat. Log and move on. console.error('Failed to sync mute state to server:', e); } }; const toggleDeafen = async () => { const nextState = !isDeafened; if (room && !isMuted) { try { await room.localParticipant.setMicrophoneEnabled(!nextState); } catch (e) { console.error('Failed to toggle microphone for deafen:', e); return; } } setIsDeafened(nextState); playSound(nextState ? 'deafen' : 'undeafen'); voiceService?.updateNotification({ isDeafened: nextState }); try { await updateVoiceState({ isDeafened: nextState }); } catch (e) { console.error('Failed to sync deafen state to server:', e); } }; // Actually flip the LiveKit screen-share publication on/off. The // earlier implementation only toggled local React state + the // voiceStates row, so the Share button lit up but no track ever // got published. `setScreenShareEnabled` is LiveKit's one-call // helper that prompts for `getDisplayMedia`, publishes the // resulting track, and tears it down on false. const setScreenSharing = async (active) => { if (!room) return; // Snapshot the screen-share publications *before* disabling so we // can explicitly stop the underlying MediaStreamTracks afterwards. // LiveKit's `setScreenShareEnabled(false)` unpublishes but doesn't // always fully release the getDisplayMedia tracks before returning, // which caused intermittent "NotAllowedError: Permission denied" // when the user re-shared immediately. const toStop = []; if (!active) { const pubs = room.localParticipant?.trackPublications; if (pubs?.forEach) { pubs.forEach((pub) => { const src = pub.source ?? pub.track?.source; if (src === 'screen_share' || src === 'screen_share_audio') { if (pub.track?.mediaStreamTrack) { toStop.push(pub.track.mediaStreamTrack); } } }); } } try { await room.localParticipant.setScreenShareEnabled(active, { audio: true, }); } catch (e) { console.warn('Failed to toggle screen share:', e); const published = !!room.localParticipant.getTrackPublication?.( 'screen_share', ); setIsScreenSharingLocal(published); await updateVoiceState({ isScreenSharing: published }); return; } if (!active) { for (const mst of toStop) { try { mst.stop(); } catch { /* already stopped */ } } } setIsScreenSharingLocal(active); await updateVoiceState({ isScreenSharing: active }); }; // Camera toggle — publishes / unpublishes the local webcam track // via LiveKit's `setCameraEnabled` helper. Mirrors the mic and // screen-share code paths so the UI can show a single consistent // "active" state for each media type. const [isCameraOn, setIsCameraOn] = useState(false); const setCamera = async (active) => { if (!room) return; try { await room.localParticipant.setCameraEnabled(active); setIsCameraOn(active); playSound(active ? 'camera_on' : 'camera_off'); } catch (e) { console.warn('Failed to toggle camera:', e); const published = !!room.localParticipant.isCameraEnabled; setIsCameraOn(published); } }; const toggleCamera = () => setCamera(!isCameraOn); const switchDevice = useCallback(async (kind, deviceId) => { if (!room) return; try { await room.switchActiveDevice(kind, deviceId); } catch (e) { console.warn(`Failed to switch ${kind} device:`, e); } }, [room]); // ── Voice recording ───────────────────────────────────────── // Starts a `VoiceRecorder` bound to the current room. Returns // the session metadata on success so the UI can show a confirm // indicator. Gracefully no-ops (with a console warning) on // platforms that don't support recording. const startRecording = useCallback(async () => { if (!platform?.features?.hasRecording || !platform.recording) { console.warn('Recording is not available on this platform.'); return null; } if (!room) { console.warn('Cannot start recording — not connected to a voice channel.'); return null; } if (voiceRecorderRef.current) { return { sessionId: voiceRecorderRef.current.sessionId, startedAt: voiceRecorderRef.current.startedAt, }; } try { // Resolve the user-preferred recording folder (settings // writes to localStorage under `voiceSettings`, same // bucket the mic/AGC toggles live in). let rootDir = null; try { const raw = localStorage.getItem('voiceSettings'); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed.recordingDir === 'string') { rootDir = parsed.recordingDir; } } } catch {} const recorder = new VoiceRecorder({ platform, room, channelId: activeChannelId, channelName: activeChannelName, rootDir, onError: (err) => { console.error('Voice recorder error:', err); setRecordingError(err.message || 'Recording error'); }, }); await recorder.start(); voiceRecorderRef.current = recorder; setRecordingSessionId(recorder.sessionId); setRecordingStartedAt(recorder.startedAt); setIsRecording(true); setRecordingError(null); return { sessionId: recorder.sessionId, startedAt: recorder.startedAt }; } catch (err) { console.error('Failed to start recording:', err); setRecordingError(err?.message || 'Failed to start recording'); voiceRecorderRef.current = null; setIsRecording(false); setRecordingSessionId(null); setRecordingStartedAt(null); return null; } }, [platform, room, activeChannelId, activeChannelName]); const stopRecording = useCallback(async () => { const recorder = voiceRecorderRef.current; if (!recorder) return; voiceRecorderRef.current = null; try { await recorder.stop(); } catch (err) { console.error('Failed to stop recording cleanly:', err); setRecordingError(err?.message || 'Failed to stop recording'); } finally { setIsRecording(false); setRecordingSessionId(null); setRecordingStartedAt(null); } }, []); // Auto-stop the recorder if the user disconnects from the // voice channel — we don't want an orphaned VoiceRecorder // holding references to a destroyed LiveKit room. useEffect(() => { if (!room && voiceRecorderRef.current) { void stopRecording(); } }, [room, stopRecording]); // Stable callback so the Provider value doesn't churn on a fresh arrow // function every render. const clearRecordingError = useCallback(() => setRecordingError(null), []); // Memoize the provider value so components that subscribe via // `useVoice()` don't re-render on every parent render. Inline object // literals caused every voice-aware component (sidebar, chat header, // user tiles, voice bar) to re-render whenever *anything* upstream // changed — a huge perf hit during active voice sessions. const value = useMemo(() => ({ activeChannelId, activeChannelName, connectionState, connectToVoice, disconnectVoice, room, token, voiceStates, activeSpeakers, isMuted, isDeafened, toggleMute, toggleDeafen, isScreenSharing, setScreenSharing, isCameraOn, setCamera, toggleCamera, personallyMutedUsers, togglePersonalMute, isPersonallyMuted, userVolumes, setUserVolume, getUserVolume, serverMute, disconnectUser, isServerMuted, isInAfkChannel, serverSettings, watchingStreamOf, setWatchingStreamOf, switchDevice, globalOutputVolume, setGlobalOutputVolume, isReceivingScreenShareAudio, isReconnecting, connectionQualities, isRecording, recordingStartedAt, recordingSessionId, recordingError, startRecording, stopRecording, clearRecordingError, }), [ activeChannelId, activeChannelName, connectionState, connectToVoice, disconnectVoice, room, token, voiceStates, activeSpeakers, isMuted, isDeafened, toggleMute, toggleDeafen, isScreenSharing, setScreenSharing, isCameraOn, setCamera, toggleCamera, personallyMutedUsers, togglePersonalMute, isPersonallyMuted, userVolumes, setUserVolume, getUserVolume, serverMute, disconnectUser, isServerMuted, isInAfkChannel, serverSettings, watchingStreamOf, setWatchingStreamOf, switchDevice, globalOutputVolume, setGlobalOutputVolume, isReceivingScreenShareAudio, isReconnecting, connectionQualities, isRecording, recordingStartedAt, recordingSessionId, recordingError, startRecording, stopRecording, clearRecordingError, ]); return ( {children} {room && ( )} ); };