diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index eb501557..dabaefb5 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -46,6 +46,8 @@ class ConnectionManager { private activeCalls: Map = new Map(); // userId → { isMuted, isDeafened } — voice user status (mute/deafen state) private voiceUserStates: Map = new Map(); + // userId → Timeout + private pendingOfflineTimeouts: Map = new Map(); addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -53,6 +55,9 @@ class ConnectionManager { } this.connections.get(userId)!.add(ws); this.wsToUser.set(ws, userId); + + // If they were pending offline, cancel it! + this.cancelDisconnect(userId); } removeConnection(ws: WebSocket): string | undefined { @@ -65,11 +70,68 @@ class ConnectionManager { userConnections.delete(ws); if (userConnections.size === 0) { this.connections.delete(userId); + // Schedule disconnect cleanup + this.scheduleDisconnect(userId); } } return userId; } + private scheduleDisconnect(userId: string) { + if (this.pendingOfflineTimeouts.has(userId)) return; + + const timeout = setTimeout(() => { + this.finalizeDisconnect(userId); + this.pendingOfflineTimeouts.delete(userId); + }, 5000); // 5 second grace period + + this.pendingOfflineTimeouts.set(userId, timeout); + } + + private cancelDisconnect(userId: string) { + const timeout = this.pendingOfflineTimeouts.get(userId); + if (timeout) { + clearTimeout(timeout); + this.pendingOfflineTimeouts.delete(userId); + console.log(`[ConnectionManager] Rescued session for user ${userId}`); + } + } + + private finalizeDisconnect(userId: string) { + // Double check they are still offline + if (this.isUserOnline(userId)) return; + + console.log(`[ConnectionManager] Finalizing disconnect for user ${userId}`); + const db = getDb(); + db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, userId)).run(); + + // Leave voice if in one + const leftChannel = this.leaveAllVoice(userId); + this.clearVoiceUserStatus(userId); + if (leftChannel) { + // Get channel's server to broadcast + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); + if (channel) { + this.sendToServer(channel.serverId, { + type: 'voice_state_update', + channelId: leftChannel, + userId: userId, + action: 'leave', + }); + } + } + + // Broadcast offline to all servers + const userServers = this.getUserServers(userId); + for (const serverId of userServers) { + this.sendToServer(serverId, { + type: 'presence_update', + userId: userId, + status: 'offline', + }); + } + } + getUserConnections(userId: string): Set { return this.connections.get(userId) ?? new Set(); } @@ -551,39 +613,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise { ws.on('close', () => { clearTimeout(authTimeout); if (userId) { - const removedUserId = connectionManager.removeConnection(ws); - - // If user has no more connections, set offline - if (removedUserId && !connectionManager.isUserOnline(removedUserId)) { - const db = getDb(); - db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, removedUserId)).run(); - - // Leave voice if in one - const leftChannel = connectionManager.leaveAllVoice(removedUserId); - connectionManager.clearVoiceUserStatus(removedUserId); - if (leftChannel) { - // Get channel's server to broadcast - const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); - if (channel) { - connectionManager.sendToServer(channel.serverId, { - type: 'voice_state_update', - channelId: leftChannel, - userId: removedUserId, - action: 'leave', - }); - } - } - - // Broadcast offline to all servers - const userServers = connectionManager.getUserServers(removedUserId); - for (const serverId of userServers) { - connectionManager.sendToServer(serverId, { - type: 'presence_update', - userId: removedUserId, - status: 'offline', - }); - } - } + connectionManager.removeConnection(ws); } }); diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 53030886..f1e0b054 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -12,6 +12,7 @@ export class AudioManager { private isInitialized = false; private listeners: Set<() => void> = new Set(); + private soundBuffers: Map = new Map(); private constructor() {} @@ -73,6 +74,45 @@ export class AudioManager { } } + async loadSound(name: string): Promise { + if (this.soundBuffers.has(name)) { + return this.soundBuffers.get(name)!; + } + + if (!this.ctx) this.initContext(); + + try { + const response = await fetch(`/sounds/${name}.mp3`); + if (!response.ok) throw new Error(`Failed to load sound: ${name}`); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await this.ctx!.decodeAudioData(arrayBuffer); + this.soundBuffers.set(name, audioBuffer); + return audioBuffer; + } catch (err) { + console.error(`[AudioManager] Error loading sound ${name}:`, err); + return null; + } + } + + async playSound(name: string, options: { loop?: boolean; volume?: number } = {}): Promise { + await this.resumeContext(); + const buffer = await this.loadSound(name); + if (!buffer || !this.ctx) return null; + + const source = this.ctx.createBufferSource(); + source.buffer = buffer; + source.loop = options.loop || false; + + const gainNode = this.ctx.createGain(); + gainNode.gain.value = options.volume ?? 0.5; + + source.connect(gainNode); + gainNode.connect(this.ctx.destination); + + source.start(0); + return source; + } + async setInputDevice(deviceId: string) { if (!this.isInitialized) this.initContext(); diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index f054783c..4f34bf8d 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -15,6 +15,7 @@ import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; import { PictureInPicture } from '../voice/PictureInPicture'; +import { SoundController } from '../voice/SoundController'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; @@ -238,6 +239,7 @@ export function AppLayout() { + {/* User Profile Popout */} {userProfilePopout.user && userProfilePopout.position && ( diff --git a/packages/web/src/components/voice/SoundController.tsx b/packages/web/src/components/voice/SoundController.tsx new file mode 100644 index 00000000..c60123df --- /dev/null +++ b/packages/web/src/components/voice/SoundController.tsx @@ -0,0 +1,165 @@ +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 + useEffect(() => { + if (isInitialMount.current) return; + if (isWsConnected && !prevIsWsConnected.current) { + 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 + if (prevIsConnected.current && !state.isLiveKitConnected) { + audioManager.playSound('disconnect'); + } + 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 + state.participants.forEach(p => { + if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) { + audioManager.playSound('user_join'); + } + }); + + // Someone left voice + prevParticipantIds.current.forEach(userId => { + if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) { + audioManager.playSound('user_leave'); + } + }); + + // Someone started screen sharing + 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 + 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; +} +