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
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s
This commit is contained in:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user