Files
DiscordClone/packages/shared/src/contexts/VoiceContext.jsx
Bryan1029384756 ad54f60225
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
1.1.2
2026-04-17 21:44:01 -05:00

1436 lines
56 KiB
JavaScript

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 (
<VoiceContext.Provider value={value}>
{children}
{room && (
<LiveKitRoom
room={room}
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
>
<RoomAudioRenderer muted={isDeafened} />
</LiveKitRoom>
)}
</VoiceContext.Provider>
);
};