1.1.2
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-17 21:44:01 -05:00
parent a3f6e29d6a
commit ad54f60225
30 changed files with 2517 additions and 475 deletions

View File

@@ -82,6 +82,11 @@ export const VoiceProvider = ({ children }) => {
);
const isMovingRef = useRef(false);
const isDMCallRef = useRef(false);
// Flipped to true by `disconnectVoice` when the user presses
// disconnect *during* the 'connecting' phase. Each major await in
// `connectToVoice` checks this ref and bails out cleanly so we
// don't end up connected to a channel the user tried to leave.
const cancelConnectRef = useRef(false);
const lastSpokeRef = useRef(Date.now());
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
@@ -288,6 +293,20 @@ export const VoiceProvider = ({ children }) => {
const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
if (activeChannelId === channelId) return;
isDMCallRef.current = isDMCall;
cancelConnectRef.current = false;
// Channel hop — we're already in a voice room. Flip the
// move-in-progress flag BEFORE disconnecting so the old
// room's RoomEvent.Disconnected handler takes the short
// "moving" branch: it clears room/token without touching
// activeChannelId, skips the voiceState.leave mutation, and
// skips the leave sound. Without this the handler races
// against the rest of this function — nulling the channel
// id we're about to set and calling leave() between our
// disconnect and the new voiceState.join, which looks like
// "joins, plays sound, drops out" from the user's side.
const isMove = !!room;
if (isMove) isMovingRef.current = true;
if (room) await room.disconnect();
@@ -296,6 +315,38 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting');
window.__inVoiceCall = true;
// Cancellation check — called after every major async step.
// If the user pressed disconnect while we were awaiting, roll
// back: close the partially-built room (if any; its
// Disconnected handler cleans up state), otherwise reset the
// pre-room UI state manually. Returns true when the caller
// should bail.
const abortIfCancelled = (builtRoom) => {
if (!cancelConnectRef.current) return false;
if (builtRoom) {
builtRoom.disconnect().catch(() => {});
}
// Always reset UI state on cancel, even when a room is
// being torn down. The Disconnected handler's "moving"
// branch preserves activeChannelId (needed for legit
// moves), so we explicitly clear it here.
setActiveChannelId(null);
setActiveChannelName(null);
setConnectionState('disconnected');
setToken(null);
window.__inVoiceCall = false;
voiceService?.stopService();
// If this was a channel hop we flipped isMovingRef on;
// reset it so a later disconnect doesn't wrongly skip
// voiceState.leave.
isMovingRef.current = false;
// Clean up any stale server-side voice state (the old
// channel's row survives a cancelled move because the
// "moving" branch skips voiceState.leave).
convex.mutation(api.voiceState.leave, { userId }).catch(() => {});
return true;
};
try {
// Request microphone permission (triggers Android runtime prompt)
try {
@@ -308,6 +359,8 @@ export const VoiceProvider = ({ children }) => {
return;
}
if (abortIfCancelled(null)) return;
// Prove we control `userId` by signing the (userId, channelId,
// timestamp) tuple with the Ed25519 key decrypted at login.
// The server verifies with our public signing key before minting
@@ -323,6 +376,8 @@ export const VoiceProvider = ({ children }) => {
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
const signature = await platform.crypto.signMessage(signingKey, message);
if (abortIfCancelled(null)) return;
const tokenResult = await convex.action(api.voice.getToken, {
channelId,
userId,
@@ -337,6 +392,8 @@ export const VoiceProvider = ({ children }) => {
return;
}
if (abortIfCancelled(null)) return;
const lkToken = tokenResult.token;
if (!lkToken) throw new Error('Failed to get token');
@@ -411,6 +468,10 @@ export const VoiceProvider = ({ children }) => {
});
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
// Cancelled during the LiveKit handshake — tear the room
// back down before we wire any more state around it.
if (abortIfCancelled(newRoom)) return;
if (storedOutputDevice && storedOutputDevice !== 'default') {
await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
console.warn('Failed to switch output device on connect:', e)
@@ -419,6 +480,8 @@ export const VoiceProvider = ({ children }) => {
await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
if (abortIfCancelled(newRoom)) return;
setRoom(newRoom);
setConnectionState('connected');
// Start native foreground service for background voice on Android
@@ -430,22 +493,12 @@ export const VoiceProvider = ({ children }) => {
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 });
voiceService?.updateNotification({ isMuted: true });
}
// Register the Disconnected / Reconnecting / etc. handlers
// BEFORE `voiceState.join`. If the user taps disconnect in
// the narrow window between `setRoom` and this mutation
// returning, `disconnectVoice` calls `room.disconnect()` —
// without the handler bound there's nothing to tear down
// state. Keeping registration first closes that gap.
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
@@ -556,11 +609,46 @@ export const VoiceProvider = ({ children }) => {
}));
});
// Commit the server-side voice state LAST. Until this
// resolves the reactive `voiceStates` query still reports
// us in the old channel, which is why we keep
// `isMovingRef.current === true` across the mutation —
// otherwise the reconcile effect below sees "server says
// A, client says B" and kicks off a spurious reconnect
// back to A (the bug that caused double-join / double-
// leave sounds when switching channels).
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 });
voiceService?.updateNotification({ isMuted: true });
}
// Now that the server state has been committed and the
// reactive query will (imminently) show us in `channelId`,
// it's safe to re-enable the reconcile effect. A subtle
// point: the query push can land either before or after
// the mutation promise resolves; both orderings are fine
// because in either case by the time the effect next
// reads `voiceStates` it'll either agree with
// activeChannelId or this flag will still be true.
isMovingRef.current = false;
} catch (err) {
console.error('Voice Connection Failed:', err);
setConnectionState('error');
setActiveChannelId(null);
window.__inVoiceCall = false;
isMovingRef.current = false;
}
};
@@ -975,6 +1063,38 @@ export const VoiceProvider = ({ children }) => {
const disconnectVoice = () => {
console.log('User manually disconnected voice');
isDMCallRef.current = false;
// Mid-connect cancel: `connectToVoice` is awaiting somewhere
// (mic permission, Convex token action, LiveKit handshake).
// Flip the cancel ref so the next `abortIfCancelled` check
// tears things down, and reset visible UI state immediately
// so the button reflects the user's intent even before the
// in-flight awaits unwind.
if (connectionState === 'connecting') {
cancelConnectRef.current = true;
setConnectionState('disconnected');
setActiveChannelId(null);
setActiveChannelName(null);
setToken(null);
window.__inVoiceCall = false;
voiceService?.stopService();
// If the LiveKit connect already completed before the
// cancel fired, the room exists in state and we still
// need to drop it. The RoomEvent.Disconnected handler
// will run voiceState.leave and finish cleanup.
if (room) room.disconnect().catch(() => {});
return;
}
// A user-initiated disconnect always wants the "leave for
// real" path, even if we're still inside the
// `connectToVoice(new)` move window (connectionState ===
// 'connected' but `isMovingRef` hasn't cleared yet because
// `voiceState.join` is in flight). Clearing the move flag
// here makes the Disconnected handler take the normal
// branch: play leave, clear activeChannelId, run
// voiceState.leave.
isMovingRef.current = false;
window.__inVoiceCall = false;
voiceService?.stopService();
if (room) room.disconnect();