feat: Add new emoji assets and an UpdateBanner component.
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s

This commit is contained in:
Bryan1029384756
2026-02-13 12:20:40 -06:00
parent 63d4208933
commit fe869a3222
3855 changed files with 10226 additions and 15543 deletions

View 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>
);
};