feat: Add new emoji assets and an UpdateBanner component.
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
This commit is contained in:
662
packages/shared/src/contexts/VoiceContext.jsx
Normal file
662
packages/shared/src/contexts/VoiceContext.jsx
Normal file
@@ -0,0 +1,662 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Room, RoomEvent } 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 { 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';
|
||||
|
||||
const soundMap = {
|
||||
join: joinSound,
|
||||
leave: leaveSound,
|
||||
mute: muteSound,
|
||||
unmute: unmuteSound,
|
||||
deafen: deafenSound,
|
||||
undeafen: undeafenSound,
|
||||
viewer_join: viewerJoinSound,
|
||||
viewer_leave: viewerLeaveSound,
|
||||
};
|
||||
|
||||
const VoiceContext = createContext();
|
||||
|
||||
export const useVoice = () => useContext(VoiceContext);
|
||||
|
||||
function playSound(type) {
|
||||
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) {
|
||||
const audio = new Audio(url);
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch(e => console.error("Sound play failed", e));
|
||||
}
|
||||
|
||||
export const VoiceProvider = ({ children }) => {
|
||||
const { idle } = usePlatform();
|
||||
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 [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 convex = useConvex();
|
||||
|
||||
// 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 = localStorage.getItem('userId');
|
||||
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 = localStorage.getItem('userId');
|
||||
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((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((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 = localStorage.getItem('userId');
|
||||
if (!actorUserId) return;
|
||||
try {
|
||||
await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
|
||||
} catch (e) {
|
||||
console.error('Failed to server mute:', 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 myUserId = localStorage.getItem('userId');
|
||||
const myJoinSoundUrl = useQuery(
|
||||
api.auth.getMyJoinSoundUrl,
|
||||
myUserId ? { userId: myUserId } : "skip"
|
||||
);
|
||||
|
||||
// Refs for detecting other-user joins via voiceStates changes
|
||||
const prevChannelUsersRef = useRef(new Map());
|
||||
const otherJoinInitRef = useRef(false);
|
||||
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
|
||||
|
||||
async function updateVoiceState(fields) {
|
||||
const userId = localStorage.getItem('userId');
|
||||
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) => {
|
||||
if (activeChannelId === channelId) return;
|
||||
|
||||
if (room) await room.disconnect();
|
||||
|
||||
setActiveChannelId(channelId);
|
||||
setActiveChannelName(channelName);
|
||||
setConnectionState('connecting');
|
||||
|
||||
try {
|
||||
const { token: lkToken } = await convex.action(api.voice.getToken, {
|
||||
channelId,
|
||||
userId,
|
||||
username: localStorage.getItem('username') || 'Unknown'
|
||||
});
|
||||
|
||||
if (!lkToken) throw new Error('Failed to get token');
|
||||
|
||||
setToken(lkToken);
|
||||
|
||||
const storedInputDevice = localStorage.getItem('voiceInputDevice');
|
||||
const storedOutputDevice = localStorage.getItem('voiceOutputDevice');
|
||||
|
||||
const newRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
autoSubscribe: true,
|
||||
audioCaptureDefaults: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false,
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}),
|
||||
},
|
||||
publishDefaults: {
|
||||
audioPreset: { maxBitrate: 96_000 },
|
||||
dtx: false,
|
||||
red: true,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: 10_000_000,
|
||||
maxFramerate: 60,
|
||||
},
|
||||
screenShareSimulcastLayers: [],
|
||||
},
|
||||
});
|
||||
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
|
||||
|
||||
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);
|
||||
|
||||
setRoom(newRoom);
|
||||
setConnectionState('connected');
|
||||
window.voiceRoom = newRoom;
|
||||
// Play custom join sound if set, otherwise default
|
||||
if (myJoinSoundUrl) {
|
||||
playSoundUrl(myJoinSoundUrl);
|
||||
} else {
|
||||
playSound('join');
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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);
|
||||
setActiveSpeakers(new Set());
|
||||
return;
|
||||
}
|
||||
playSound('leave');
|
||||
setConnectionState('disconnected');
|
||||
setActiveChannelId(null);
|
||||
setRoom(null);
|
||||
setToken(null);
|
||||
setActiveSpeakers(new Set());
|
||||
|
||||
try {
|
||||
await convex.mutation(api.voiceState.leave, { userId });
|
||||
} catch (e) {
|
||||
console.error('Failed to leave voice state:', e);
|
||||
}
|
||||
});
|
||||
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Voice Connection Failed:', err);
|
||||
setConnectionState('error');
|
||||
setActiveChannelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect when another user moves us to a different voice channel
|
||||
useEffect(() => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
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;
|
||||
(async () => {
|
||||
try {
|
||||
const channel = await convex.query(api.channels.get, { id: serverChannelId });
|
||||
if (room) await room.disconnect();
|
||||
await connectToVoice(serverChannelId, channel?.name || 'Voice', myUserId);
|
||||
} catch (e) {
|
||||
console.error('Failed to reconnect after move:', e);
|
||||
} finally {
|
||||
isMovingRef.current = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [voiceStates, activeChannelId]);
|
||||
|
||||
// Enforce server mute: force-disable mic when server muted, restore when lifted
|
||||
useEffect(() => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
if (!myUserId || !room) return;
|
||||
if (isServerMuted(myUserId)) {
|
||||
room.localParticipant.setMicrophoneEnabled(false);
|
||||
} else if (!isMuted && !isDeafened) {
|
||||
room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
}, [voiceStates, room]);
|
||||
|
||||
// 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(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 (!idle?.getSystemIdleTime) return;
|
||||
|
||||
const afkTimeout = serverSettings.afkTimeout || 300;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const idleSeconds = await idle.getSystemIdleTime();
|
||||
if (idleSeconds >= afkTimeout) {
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (!userId) return;
|
||||
await convex.mutation(api.voiceState.afkMove, {
|
||||
userId,
|
||||
afkChannelId: serverSettings.afkChannelId,
|
||||
});
|
||||
// After server-side move, locally mute
|
||||
setIsMuted(true);
|
||||
if (room) room.localParticipant.setMicrophoneEnabled(false);
|
||||
}
|
||||
} 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 Map();
|
||||
otherJoinInitRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const selfId = localStorage.getItem('userId');
|
||||
const channelUsers = voiceStates[activeChannelId] || [];
|
||||
const currentUsers = new Map();
|
||||
for (const u of channelUsers) {
|
||||
currentUsers.set(u.userId, u);
|
||||
}
|
||||
|
||||
// Skip the first render after joining to avoid playing sounds for users already in the channel
|
||||
if (!otherJoinInitRef.current) {
|
||||
otherJoinInitRef.current = true;
|
||||
prevChannelUsersRef.current = currentUsers;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevChannelUsersRef.current;
|
||||
|
||||
// Detect new users (not self)
|
||||
for (const [uid, userData] of currentUsers) {
|
||||
if (uid !== selfId && !prev.has(uid)) {
|
||||
if (userData.joinSoundUrl) {
|
||||
playSoundUrl(userData.joinSoundUrl);
|
||||
} else {
|
||||
playSound('join');
|
||||
}
|
||||
break; // one sound per update batch
|
||||
}
|
||||
}
|
||||
|
||||
prevChannelUsersRef.current = currentUsers;
|
||||
}, [voiceStates, activeChannelId]);
|
||||
|
||||
// Manage screen share subscriptions — only subscribe when actively watching
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
|
||||
const manageSubscriptions = () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
manageSubscriptions();
|
||||
|
||||
const onTrackChange = () => manageSubscriptions();
|
||||
room.on(RoomEvent.TrackPublished, onTrackChange);
|
||||
room.on(RoomEvent.TrackSubscribed, onTrackChange);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, onTrackChange);
|
||||
room.off(RoomEvent.TrackSubscribed, onTrackChange);
|
||||
};
|
||||
}, [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;
|
||||
}
|
||||
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
// 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]);
|
||||
|
||||
const disconnectVoice = () => {
|
||||
console.log('User manually disconnected voice');
|
||||
if (room) room.disconnect();
|
||||
};
|
||||
|
||||
const toggleMute = async () => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
// Block unmute if server muted or in AFK channel
|
||||
if (isMuted && myUserId && isServerMuted(myUserId)) return;
|
||||
if (isMuted && isInAfkChannel) return;
|
||||
const nextState = !isMuted;
|
||||
setIsMuted(nextState);
|
||||
playSound(nextState ? 'mute' : 'unmute');
|
||||
if (room) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
}
|
||||
await updateVoiceState({ isMuted: nextState });
|
||||
};
|
||||
|
||||
const toggleDeafen = async () => {
|
||||
const nextState = !isDeafened;
|
||||
setIsDeafened(nextState);
|
||||
playSound(nextState ? 'deafen' : 'undeafen');
|
||||
if (room && !isMuted) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
}
|
||||
await updateVoiceState({ isDeafened: nextState });
|
||||
};
|
||||
|
||||
const setScreenSharing = async (active) => {
|
||||
setIsScreenSharingLocal(active);
|
||||
await updateVoiceState({ isScreenSharing: active });
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<VoiceContext.Provider value={{
|
||||
activeChannelId,
|
||||
activeChannelName,
|
||||
connectionState,
|
||||
connectToVoice,
|
||||
disconnectVoice,
|
||||
room,
|
||||
token,
|
||||
voiceStates,
|
||||
activeSpeakers,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing,
|
||||
personallyMutedUsers,
|
||||
togglePersonalMute,
|
||||
isPersonallyMuted,
|
||||
userVolumes,
|
||||
setUserVolume,
|
||||
getUserVolume,
|
||||
serverMute,
|
||||
isServerMuted,
|
||||
isInAfkChannel,
|
||||
serverSettings,
|
||||
watchingStreamOf,
|
||||
setWatchingStreamOf,
|
||||
switchDevice,
|
||||
globalOutputVolume,
|
||||
setGlobalOutputVolume,
|
||||
}}>
|
||||
{children}
|
||||
{room && (
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<RoomAudioRenderer muted={isDeafened} />
|
||||
</LiveKitRoom>
|
||||
)}
|
||||
</VoiceContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user