Files
backspace/packages/web/src/components/voice/SoundController.js
T
Jannis Braun 1aa40cca15 feat: implement Discord-like stream widget system with separate tiles
Streams now appear as separate tiles in the voice grid alongside the
user's camera/avatar tile, matching Discord's model. Each stream tile
has independent volume, mute, watch/unwatch controls, quality badges,
and stream attenuation that ducks audio when someone speaks.
2026-02-20 03:59:28 +01:00

155 lines
7.5 KiB
JavaScript

import { useEffect, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
import { useWebSocket } from '../../hooks/useWebSocket';
import { AudioManager } from '../../audio/AudioManager';
export function SoundController() {
const audioManager = AudioManager.getInstance();
const currentUser = useAuthStore((s) => s.user);
const { isConnected: isWsConnected } = useWebSocket();
// Refs to track previous states
const isInitialMount = useRef(true);
const prevIsWsConnected = useRef(false);
const prevIsMuted = useRef(useVoiceStore.getState().isMuted);
const prevIsDeafened = useRef(useVoiceStore.getState().isDeafened);
const prevIsCameraOn = useRef(useVoiceStore.getState().isCameraOn);
const prevIsScreenSharing = useRef(useVoiceStore.getState().isScreenSharing);
const prevIsConnected = useRef(useVoiceStore.getState().isLiveKitConnected);
const prevParticipantIds = useRef(new Set(useVoiceStore.getState().participants.map(p => p.userId)));
const prevScreenShareUserIds = useRef(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId)));
const incomingCallLoop = useRef(null);
const outgoingCallLoop = useRef(null);
// WebSocket Reconnect Sound — suppress during active voice (LiveKit handles its own reconnection)
useEffect(() => {
if (isInitialMount.current)
return;
if (isWsConnected && !prevIsWsConnected.current) {
const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected;
if (!isInActiveVoice) {
audioManager.playSound('reconnect');
}
}
prevIsWsConnected.current = isWsConnected;
}, [isWsConnected, audioManager]);
useEffect(() => {
// Set initial mount flag to false after first run
const timer = setTimeout(() => {
isInitialMount.current = false;
prevIsWsConnected.current = isWsConnected;
}, 1000);
// 1. Listen to Voice State Changes
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
if (isInitialMount.current)
return;
// Mute/Unmute
if (state.isMuted !== prevIsMuted.current) {
audioManager.playSound(state.isMuted ? 'mute' : 'unmute');
prevIsMuted.current = state.isMuted;
}
// Deafen/Undeafen
if (state.isDeafened !== prevIsDeafened.current) {
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen');
prevIsDeafened.current = state.isDeafened;
}
// Camera Toggle
if (state.isCameraOn !== prevIsCameraOn.current) {
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off');
prevIsCameraOn.current = state.isCameraOn;
}
// Screen Share Toggle (Self)
if (state.isScreenSharing !== prevIsScreenSharing.current) {
audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended');
prevIsScreenSharing.current = state.isScreenSharing;
}
// Disconnect (Self)
if (prevIsConnected.current && !state.isLiveKitConnected) {
audioManager.playSound('disconnect');
}
// Connect (Self)
if (!prevIsConnected.current && state.isLiveKitConnected) {
audioManager.playSound('user_join');
}
prevIsConnected.current = state.isLiveKitConnected;
// Participant Joins/Leaves & Screen Sharing
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) {
// Someone joined voice (Others only)
state.participants.forEach(p => {
if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) {
audioManager.playSound('user_join');
}
});
// Someone left voice (Others only)
prevParticipantIds.current.forEach(userId => {
if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) {
audioManager.playSound('user_leave');
}
});
// 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');
}
});
// Someone stopped screen sharing (Others only)
prevScreenShareUserIds.current.forEach(userId => {
if (!currentScreenShareUserIds.has(userId) && userId !== currentUser?.id) {
audioManager.playSound('stream_user_left');
}
});
}
prevParticipantIds.current = currentParticipantIds;
prevScreenShareUserIds.current = currentScreenShareUserIds;
// Incoming Call (Ringing)
if (state.incomingCall && !incomingCallLoop.current) {
audioManager.playSound('call_ringing', { loop: true }).then(source => {
incomingCallLoop.current = source;
});
}
else if (!state.incomingCall && incomingCallLoop.current) {
incomingCallLoop.current.stop();
incomingCallLoop.current = null;
}
// Outgoing Call (Calling)
if (state.outgoingCall && !outgoingCallLoop.current) {
audioManager.playSound('call_calling', { loop: true }).then(source => {
outgoingCallLoop.current = source;
});
}
else if (!state.outgoingCall && outgoingCallLoop.current) {
outgoingCallLoop.current.stop();
outgoingCallLoop.current = null;
}
});
// 2. Listen to Chat State Changes (New Messages)
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current)
return;
// Check for new messages in the current channel
if (state.currentChannelId) {
const messages = state.messages.get(state.currentChannelId) || [];
const prevMessages = prevState.messages.get(state.currentChannelId) || [];
if (messages.length > prevMessages.length) {
const lastMessage = messages[messages.length - 1];
// Don't play sound for our own messages
if (lastMessage && lastMessage.userId !== currentUser?.id) {
audioManager.playSound('message');
}
}
}
});
return () => {
clearTimeout(timer);
unsubscribeVoice();
unsubscribeChat();
if (incomingCallLoop.current)
incomingCallLoop.current.stop();
if (outgoingCallLoop.current)
outgoingCallLoop.current.stop();
};
}, [audioManager, currentUser?.id]);
return null;
}