1436 lines
56 KiB
JavaScript
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>
|
|
);
|
|
};
|