Files
backspace/packages/web/src/components/voice/SoundController.tsx
T
Jannis Braun 1468b68199 fix: skip participant leave sounds during self-disconnect
Added justDisconnected guard to the participant sound loop. When
the user hangs up, isLiveKitConnected transitions to false — but
in a separate or same subscription tick, the participants list
also empties. Without the guard, SoundController plays user_leave
for every departed participant AND the disconnect sound simultaneously.

Now: if justDisconnected is true, the entire participant loop is
skipped. Only the disconnect sound plays.
2026-04-08 15:05:49 +02:00

187 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
import { AudioManager } from '../../audio/AudioManager';
/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0200). */
function getSfxVolume(): number {
return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100);
}
export function SoundController() {
const audioManager = AudioManager.getInstance();
const currentUser = useAuthStore((s) => s.user);
// Refs to track previous states
const isInitialMount = useRef(true);
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted);
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened);
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
const prevIsScreenSharing = useRef<boolean>(useVoiceStore.getState().isScreenSharing);
const prevIsConnected = useRef<boolean>(useVoiceStore.getState().isLiveKitConnected);
const prevParticipantIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.map(p => p.userId)));
const prevScreenShareUserIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId)));
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const incomingCallLoading = useRef(false); // sync guard for async playSound
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const outgoingCallLoading = useRef(false);
useEffect(() => {
// Set initial mount flag to false after first run
const timer = setTimeout(() => {
isInitialMount.current = false;
}, 1000);
// 1. Listen to Voice State Changes
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
if (isInitialMount.current) return;
const sfxOpts = { volume: getSfxVolume() };
// Mute/Unmute
if (state.isMuted !== prevIsMuted.current) {
audioManager.playSound(state.isMuted ? 'mute' : 'unmute', sfxOpts);
prevIsMuted.current = state.isMuted;
}
// Deafen/Undeafen
if (state.isDeafened !== prevIsDeafened.current) {
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen', sfxOpts);
prevIsDeafened.current = state.isDeafened;
}
// Camera Toggle
if (state.isCameraOn !== prevIsCameraOn.current) {
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts);
prevIsCameraOn.current = state.isCameraOn;
}
// Screen Share Toggle (Self)
if (state.isScreenSharing !== prevIsScreenSharing.current) {
audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended', sfxOpts);
prevIsScreenSharing.current = state.isScreenSharing;
}
// Self-disconnect detection — check BEFORE participant sounds
const justDisconnected = prevIsConnected.current && !state.isLiveKitConnected;
const justConnected = !prevIsConnected.current && state.isLiveKitConnected;
if (justDisconnected) {
audioManager.playSound('disconnect', sfxOpts);
}
if (justConnected) {
audioManager.playSound('user_join', sfxOpts);
}
prevIsConnected.current = state.isLiveKitConnected;
// Participant Joins/Leaves & Screen Sharing
// CRITICAL: Skip entirely if we just disconnected or are not connected.
// Without this guard, hanging up plays "user_leave" for every participant
// (they "left" from our perspective) simultaneously with the disconnect sound.
const currentParticipantIds = new Set(state.participants.map(p => p.userId));
const currentScreenShareUserIds = new Set(state.participants.filter(p => p.isScreenSharing).map(p => p.userId));
if (state.isLiveKitConnected && !justDisconnected) {
// Someone joined voice (Others only)
state.participants.forEach(p => {
if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) {
audioManager.playSound('user_join', sfxOpts);
}
});
// Someone left voice (Others only)
prevParticipantIds.current.forEach(userId => {
if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) {
audioManager.playSound('user_leave', sfxOpts);
}
});
// Someone started screen sharing (Others only)
state.participants.forEach(p => {
if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && p.userId !== currentUser?.id) {
audioManager.playSound('stream_user_joined', sfxOpts);
}
});
// Someone stopped screen sharing (Others only)
prevScreenShareUserIds.current.forEach(userId => {
if (!currentScreenShareUserIds.has(userId) && userId !== currentUser?.id) {
audioManager.playSound('stream_user_left', sfxOpts);
}
});
}
prevParticipantIds.current = currentParticipantIds;
prevScreenShareUserIds.current = currentScreenShareUserIds;
// Incoming Call (Ringing) — sync guard prevents multiple playSound during async load
if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) {
incomingCallLoading.current = true;
audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => {
// If call was cancelled while sound was loading, stop immediately
if (!useVoiceStore.getState().incomingCall) {
source?.stop();
} else {
incomingCallLoop.current = source;
}
incomingCallLoading.current = false;
});
} else if (!state.incomingCall) {
if (incomingCallLoop.current) {
incomingCallLoop.current.stop();
incomingCallLoop.current = null;
}
incomingCallLoading.current = false;
}
// Outgoing Call (Calling) — same sync guard pattern
if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
outgoingCallLoading.current = true;
audioManager.playSound('call_calling', { loop: true, volume: getSfxVolume() }).then(source => {
if (!useVoiceStore.getState().outgoingCall) {
source?.stop();
} else {
outgoingCallLoop.current = source;
}
outgoingCallLoading.current = false;
});
} else if (!state.outgoingCall) {
if (outgoingCallLoop.current) {
outgoingCallLoop.current.stop();
outgoingCallLoop.current = null;
}
outgoingCallLoading.current = false;
}
});
// 2. Listen to Chat State Changes (New Real-Time Messages from any channel)
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current) return;
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
for (const { message } of newEvents) {
if (message.userId !== currentUser?.id) {
audioManager.playSound('message', { volume: getSfxVolume() });
break; // one sound per batch
}
}
}
});
return () => {
clearTimeout(timer);
unsubscribeVoice();
unsubscribeChat();
if (incomingCallLoop.current) incomingCallLoop.current.stop();
if (outgoingCallLoop.current) outgoingCallLoop.current.stop();
};
}, [audioManager, currentUser?.id]);
return null;
}