feat: Introduce comprehensive user settings, voice, chat, and screen sharing features with new components, contexts, icons, and Convex backend integrations.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s

This commit is contained in:
Bryan1029384756
2026-02-18 14:48:57 -06:00
parent a9490f7bd4
commit bdc16b9d3f
22 changed files with 755 additions and 126 deletions

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import { Room, RoomEvent } from 'livekit-client';
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';
@@ -66,6 +66,8 @@ export const VoiceProvider = ({ children }) => {
const isMovingRef = useRef(false);
const isDMCallRef = useRef(false);
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [connectionQualities, setConnectionQualities] = useState({});
const convex = useConvex();
@@ -119,7 +121,7 @@ export const VoiceProvider = ({ children }) => {
// 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(1, (volume / 100) * globalVol));
if (participant) participant.setVolume((volume / 100) * globalVol);
// Sync personal mute state
if (volume === 0) {
setPersonallyMutedUsers(prev => {
@@ -153,7 +155,7 @@ export const VoiceProvider = ({ children }) => {
const vol = userVolumes[userId] ?? 100;
const restoreVol = vol === 0 ? 100 : vol;
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(Math.min(1, (restoreVol / 100) * globalVol));
if (participant) participant.setVolume((restoreVol / 100) * globalVol);
// Update stored volume if it was 0
if (vol === 0) {
setUserVolumes(p => {
@@ -251,6 +253,8 @@ export const VoiceProvider = ({ children }) => {
const storedInputDevice = localStorage.getItem('voiceInputDevice');
const storedOutputDevice = localStorage.getItem('voiceOutputDevice');
const noiseSuppression = localStorage.getItem('voiceNoiseSuppression') !== 'false'; // default true
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
@@ -258,20 +262,27 @@ export const VoiceProvider = ({ children }) => {
audioCaptureDefaults: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: false,
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: 'vp9',
screenShareEncoding: {
maxBitrate: 10_000_000,
maxFramerate: 60,
},
screenShareSimulcastLayers: [],
screenShareSimulcastLayers: [
{ maxBitrate: 2_000_000, maxFramerate: 15, width: 1280, height: 720 },
],
},
});
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
@@ -286,7 +297,6 @@ export const VoiceProvider = ({ children }) => {
setRoom(newRoom);
setConnectionState('connected');
window.voiceRoom = newRoom;
// Play custom join sound if set, otherwise default
if (myJoinSoundUrl) {
playSoundUrl(myJoinSoundUrl);
@@ -316,14 +326,32 @@ export const VoiceProvider = ({ children }) => {
setRoom(null);
setToken(null);
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);
setActiveSpeakers(new Set());
setConnectionQualities({});
try {
await connectToVoice(channelId, channelName, userId, isDMCallRef.current);
} catch (e) {
console.error('Auto-reconnect failed:', e);
}
return;
}
playSound('leave');
setConnectionState('disconnected');
setActiveChannelId(null);
setRoom(null);
setToken(null);
setActiveSpeakers(new Set());
setConnectionQualities({});
try {
await convex.mutation(api.voiceState.leave, { userId });
@@ -336,6 +364,25 @@ export const VoiceProvider = ({ children }) => {
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
});
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,
}));
});
} catch (err) {
console.error('Voice Connection Failed:', err);
setConnectionState('error');
@@ -343,6 +390,24 @@ export const VoiceProvider = ({ children }) => {
}
};
// Heartbeat: send periodic heartbeat to prevent ghost voice states
useEffect(() => {
if (!activeChannelId) return;
const userId = localStorage.getItem('userId');
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]);
// Detect when another user moves us to a different voice channel
useEffect(() => {
const myUserId = localStorage.getItem('userId');
@@ -395,7 +460,7 @@ export const VoiceProvider = ({ children }) => {
participant.setVolume(0);
} else {
const userVol = (userVolumes[identity] ?? 100) / 100;
participant.setVolume(Math.min(1, userVol * globalVol));
participant.setVolume(userVol * globalVol);
}
}
};
@@ -648,6 +713,7 @@ export const VoiceProvider = ({ children }) => {
}
}, [room]);
return (
<VoiceContext.Provider value={{
activeChannelId,
@@ -682,6 +748,8 @@ export const VoiceProvider = ({ children }) => {
globalOutputVolume,
setGlobalOutputVolume,
isReceivingScreenShareAudio,
isReconnecting,
connectionQualities,
}}>
{children}
{room && (