feat: Add a large collection of emoji and other frontend assets, including a sound file, and a backend package.json.
This commit is contained in:
334
Frontend/Electron/src/contexts/VoiceContext.jsx
Normal file
334
Frontend/Electron/src/contexts/VoiceContext.jsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { Room, RoomEvent } from 'livekit-client';
|
||||
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
|
||||
import { io } from 'socket.io-client';
|
||||
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';
|
||||
|
||||
const VoiceContext = createContext();
|
||||
|
||||
export const useVoice = () => useContext(VoiceContext);
|
||||
|
||||
export const VoiceProvider = ({ children }) => {
|
||||
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 [voiceStates, setVoiceStates] = useState({}); // { channelId: [users...] }
|
||||
const [activeSpeakers, setActiveSpeakers] = useState(new Set()); // Set<userId>
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
|
||||
const socketRef = useRef(null);
|
||||
|
||||
// Sound Helper
|
||||
const playSound = (type) => {
|
||||
const sounds = {
|
||||
join: joinSound,
|
||||
leave: leaveSound,
|
||||
mute: muteSound,
|
||||
unmute: unmuteSound,
|
||||
deafen: deafenSound,
|
||||
undeafen: undeafenSound
|
||||
};
|
||||
const src = sounds[type];
|
||||
if (src) {
|
||||
const audio = new Audio(src);
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch(e => console.error("Sound play failed", e));
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Socket for Voice States
|
||||
useEffect(() => {
|
||||
const socket = io('http://localhost:3000');
|
||||
socketRef.current = socket;
|
||||
// ... (Socket logic same as before) ...
|
||||
socket.on('full_voice_state', (states) => {
|
||||
setVoiceStates(states);
|
||||
});
|
||||
|
||||
socket.on('voice_state_update', (data) => {
|
||||
setVoiceStates(prev => {
|
||||
const newState = { ...prev };
|
||||
const currentUsers = newState[data.channelId] || [];
|
||||
|
||||
if (data.action === 'joined') {
|
||||
if (!currentUsers.find(u => u.userId === data.userId)) {
|
||||
currentUsers.push({
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
isMuted: data.isMuted,
|
||||
isDeafened: data.isDeafened
|
||||
});
|
||||
}
|
||||
} else if (data.action === 'left') {
|
||||
const index = currentUsers.findIndex(u => u.userId === data.userId);
|
||||
if (index !== -1) currentUsers.splice(index, 1);
|
||||
} else if (data.action === 'state_update') {
|
||||
const user = currentUsers.find(u => u.userId === data.userId);
|
||||
if (user) {
|
||||
if (data.isMuted !== undefined) user.isMuted = data.isMuted;
|
||||
if (data.isDeafened !== undefined) user.isDeafened = data.isDeafened;
|
||||
if (data.isScreenSharing !== undefined) user.isScreenSharing = data.isScreenSharing;
|
||||
}
|
||||
}
|
||||
|
||||
newState[data.channelId] = [...currentUsers];
|
||||
return newState;
|
||||
});
|
||||
});
|
||||
|
||||
socket.emit('request_voice_state');
|
||||
return () => socket.disconnect();
|
||||
}, []);
|
||||
|
||||
const connectToVoice = async (channelId, channelName, userId) => {
|
||||
if (activeChannelId === channelId) return;
|
||||
|
||||
if (room) await room.disconnect();
|
||||
|
||||
setActiveChannelId(channelId);
|
||||
setActiveChannelName(channelName);
|
||||
setConnectionState('connecting');
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:3000/api/voice/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-user-id': userId },
|
||||
body: JSON.stringify({ channelId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.token) throw new Error('Failed to get token');
|
||||
|
||||
setToken(data.token);
|
||||
|
||||
// Disable adaptiveStream to ensure all tracks are available/subscribed immediately
|
||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true });
|
||||
const liveKitUrl = 'ws://localhost:7880';
|
||||
await newRoom.connect(liveKitUrl, data.token);
|
||||
|
||||
// Auto-enable microphone & Apply Mute/Deafen State
|
||||
const shouldEnableMic = !isMuted && !isDeafened;
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(shouldEnableMic);
|
||||
|
||||
setRoom(newRoom);
|
||||
setConnectionState('connected');
|
||||
window.voiceRoom = newRoom; // For debugging
|
||||
playSound('join');
|
||||
|
||||
// Emit Join Event
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit('voice_state_change', {
|
||||
channelId,
|
||||
userId,
|
||||
username: localStorage.getItem('username') || 'Unknown',
|
||||
username: localStorage.getItem('username') || 'Unknown',
|
||||
action: 'joined',
|
||||
isMuted: isMuted,
|
||||
isDeafened: isDeafened,
|
||||
isScreenSharing: false // Initial state is always false
|
||||
});
|
||||
}
|
||||
|
||||
// Events
|
||||
newRoom.on(RoomEvent.Disconnected, (reason) => {
|
||||
console.warn('Voice Room Disconnected. Reason:', reason);
|
||||
playSound('leave');
|
||||
setConnectionState('disconnected');
|
||||
setActiveChannelId(null);
|
||||
setRoom(null);
|
||||
setToken(null);
|
||||
setActiveSpeakers(new Set());
|
||||
// Emit Leave Event
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit('voice_state_change', {
|
||||
channelId,
|
||||
userId,
|
||||
action: 'left'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
// speakers is generic Participant[]
|
||||
// We need to map to user identities (which we used as userId in token usually)
|
||||
// LiveKit identity = our userId
|
||||
const newActive = new Set();
|
||||
speakers.forEach(p => newActive.add(p.identity));
|
||||
setActiveSpeakers(newActive);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Voice Connection Failed:', err);
|
||||
setConnectionState('error');
|
||||
setActiveChannelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectVoice = () => {
|
||||
console.log('User manually disconnected voice');
|
||||
if (room) room.disconnect();
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const nextState = !isMuted;
|
||||
setIsMuted(nextState);
|
||||
playSound(nextState ? 'mute' : 'unmute');
|
||||
if (room) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
}
|
||||
|
||||
if (socketRef.current && activeChannelId) {
|
||||
socketRef.current.emit('voice_state_change', {
|
||||
channelId: activeChannelId,
|
||||
userId: localStorage.getItem('userId'),
|
||||
username: localStorage.getItem('username'),
|
||||
action: 'state_update',
|
||||
isMuted: nextState
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeafen = () => {
|
||||
const nextState = !isDeafened;
|
||||
setIsDeafened(nextState);
|
||||
playSound(nextState ? 'deafen' : 'undeafen');
|
||||
// Logic: if deafened, mute mic too (usually)
|
||||
if (nextState) {
|
||||
// If becoming deafened, ensuring mic is muted too is standard discord behavior
|
||||
if (room && !isMuted) {
|
||||
room.localParticipant.setMicrophoneEnabled(false);
|
||||
}
|
||||
} else {
|
||||
// If undeafening, restore mic if it wasn't explicitly muted?
|
||||
// Simplified: If undeafened, and isMuted is false, unmute mic.
|
||||
if (room && !isMuted) {
|
||||
room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (socketRef.current && activeChannelId) {
|
||||
socketRef.current.emit('voice_state_change', {
|
||||
channelId: activeChannelId,
|
||||
userId: localStorage.getItem('userId'),
|
||||
username: localStorage.getItem('username'),
|
||||
action: 'state_update',
|
||||
isDeafened: nextState // Send the NEW State
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
||||
|
||||
const setScreenSharing = (active) => {
|
||||
setIsScreenSharingLocal(active);
|
||||
|
||||
// Optimistic Update for UI
|
||||
setVoiceStates(prev => {
|
||||
const newState = { ...prev };
|
||||
if (activeChannelId) {
|
||||
const currentUsers = newState[activeChannelId] || [];
|
||||
// Use map to ensure we create a new array/object reference if needed,
|
||||
// though mutation in React state setter is risky, strictly we should clone.
|
||||
// But strictly:
|
||||
const userId = localStorage.getItem('userId');
|
||||
const userIndex = currentUsers.findIndex(u => u.userId === userId);
|
||||
if (userIndex !== -1) {
|
||||
const updatedUser = { ...currentUsers[userIndex], isScreenSharing: active };
|
||||
const updatedUsers = [...currentUsers];
|
||||
updatedUsers[userIndex] = updatedUser;
|
||||
newState[activeChannelId] = updatedUsers;
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
|
||||
if (socketRef.current && activeChannelId) {
|
||||
socketRef.current.emit('voice_state_change', {
|
||||
channelId: activeChannelId,
|
||||
userId: localStorage.getItem('userId'),
|
||||
username: localStorage.getItem('username'),
|
||||
action: 'state_update',
|
||||
isScreenSharing: active
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VoiceContext.Provider value={{
|
||||
activeChannelId,
|
||||
activeChannelName,
|
||||
connectionState,
|
||||
connectToVoice,
|
||||
disconnectVoice,
|
||||
room,
|
||||
token,
|
||||
voiceStates,
|
||||
activeSpeakers,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing
|
||||
}}>
|
||||
{/* Provide LiveKit Context globally if room exists, or a dummy context?
|
||||
Actually LiveKitRoom requires a token or room. If room is null, we can't render it.
|
||||
But we need children to always render.
|
||||
|
||||
Solution: Only wrap in LiveKitRoom if room exists.
|
||||
BUT: If we later mistakenly expect context when room is null, it's fine.
|
||||
However, if we wrap conditionally, the component tree changes when room logic changes, which might unmount children?
|
||||
No, children are passed in. If we put conditional wrapper around children:
|
||||
{room ? <LiveKitRoom>{children}</LiveKitRoom> : children}
|
||||
This WILL remount children when room connects/disconnects. That is BAD for ChatArea etc.
|
||||
|
||||
Better: LiveKitRoom ALWAYS, but pass null room? LiveKitRoom might not like null room.
|
||||
Documentation says: "If you want to manage the room connection yourself... pass the room instance."
|
||||
|
||||
Alternative: We only needed LiveKitRoom for VoiceStage (and audio renderer).
|
||||
ChatArea doesn't need it.
|
||||
VoiceStage is only rendered when activeChannel.type is voice, which implies we are likely connected (or clicking it connects).
|
||||
|
||||
Wait, if I use the conditional wrapper in VoiceContext to wrap children, `Sidebar` (which is a child) might remount?
|
||||
Yes, `Sidebar` is inside `VoiceProvider`.
|
||||
If `VoiceProvider` changes structure, `Sidebar` remounts.
|
||||
Sidebar holds a lot of state? No, usually lifted. But remounting Sidebar is jarring.
|
||||
|
||||
Maybe we DON'T wrap children in VoiceContext.
|
||||
Instead, we keep `VoiceContext` as is (rendering audio renderer), AND `VoiceStage` wraps itself in `LiveKitRoom`.
|
||||
BUT `VoiceStage` causing disconnect suggests `LiveKitRoom` cleanup is the problem.
|
||||
|
||||
Why did `VoiceStage`'s `LiveKitRoom` cause disconnect?
|
||||
Because on Unmount, `LiveKitRoom` calls `room.disconnect()`.
|
||||
When user clicks channel, `VoiceStage` Mounts.
|
||||
But user said "I click on it again and it disconnects me".
|
||||
Wait, user clicks "again" to SHOW `VoiceStage`.
|
||||
So `VoiceStage` MOUNTS.
|
||||
Why does it disconnect?
|
||||
Maybe `LiveKitRoom` ON MOUNT does something?
|
||||
Or maybe `Sidebar` logic caused a re-render/disconnect?
|
||||
In `Sidebar`: `connectToVoice` calls `if (room) await room.disconnect()`.
|
||||
*/}
|
||||
{children}
|
||||
{room && (
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
|
||||
>
|
||||
{/* Mute audio renderer if deafened */}
|
||||
<RoomAudioRenderer muted={isDeafened} />
|
||||
</LiveKitRoom>
|
||||
)}
|
||||
</VoiceContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user