feat: implement global audio effects and presence grace period
This commit is contained in:
@@ -46,6 +46,8 @@ class ConnectionManager {
|
|||||||
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
|
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
|
||||||
// userId → { isMuted, isDeafened } — voice user status (mute/deafen state)
|
// userId → { isMuted, isDeafened } — voice user status (mute/deafen state)
|
||||||
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean }> = new Map();
|
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean }> = new Map();
|
||||||
|
// userId → Timeout
|
||||||
|
private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||||
|
|
||||||
addConnection(userId: string, ws: WebSocket): void {
|
addConnection(userId: string, ws: WebSocket): void {
|
||||||
if (!this.connections.has(userId)) {
|
if (!this.connections.has(userId)) {
|
||||||
@@ -53,6 +55,9 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
this.connections.get(userId)!.add(ws);
|
this.connections.get(userId)!.add(ws);
|
||||||
this.wsToUser.set(ws, userId);
|
this.wsToUser.set(ws, userId);
|
||||||
|
|
||||||
|
// If they were pending offline, cancel it!
|
||||||
|
this.cancelDisconnect(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeConnection(ws: WebSocket): string | undefined {
|
removeConnection(ws: WebSocket): string | undefined {
|
||||||
@@ -65,11 +70,68 @@ class ConnectionManager {
|
|||||||
userConnections.delete(ws);
|
userConnections.delete(ws);
|
||||||
if (userConnections.size === 0) {
|
if (userConnections.size === 0) {
|
||||||
this.connections.delete(userId);
|
this.connections.delete(userId);
|
||||||
|
// Schedule disconnect cleanup
|
||||||
|
this.scheduleDisconnect(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 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<WebSocket> {
|
getUserConnections(userId: string): Set<WebSocket> {
|
||||||
return this.connections.get(userId) ?? new Set();
|
return this.connections.get(userId) ?? new Set();
|
||||||
}
|
}
|
||||||
@@ -551,39 +613,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
|||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
clearTimeout(authTimeout);
|
clearTimeout(authTimeout);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const removedUserId = connectionManager.removeConnection(ws);
|
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export class AudioManager {
|
|||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
||||||
private listeners: Set<() => void> = new Set();
|
private listeners: Set<() => void> = new Set();
|
||||||
|
private soundBuffers: Map<string, AudioBuffer> = new Map();
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
@@ -73,6 +74,45 @@ export class AudioManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadSound(name: string): Promise<AudioBuffer | null> {
|
||||||
|
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<AudioBufferSourceNode | null> {
|
||||||
|
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) {
|
async setInputDevice(deviceId: string) {
|
||||||
if (!this.isInitialized) this.initContext();
|
if (!this.isInitialized) this.initContext();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ServerSettingsModal } from '../modals/ServerSettings';
|
|||||||
import { NewDmModal } from '../modals/NewDmModal';
|
import { NewDmModal } from '../modals/NewDmModal';
|
||||||
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
||||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||||
|
import { SoundController } from '../voice/SoundController';
|
||||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
@@ -238,6 +239,7 @@ export function AppLayout() {
|
|||||||
<IncomingCallModal />
|
<IncomingCallModal />
|
||||||
<ImagePreview />
|
<ImagePreview />
|
||||||
<PictureInPicture />
|
<PictureInPicture />
|
||||||
|
<SoundController />
|
||||||
|
|
||||||
{/* User Profile Popout */}
|
{/* User Profile Popout */}
|
||||||
{userProfilePopout.user && userProfilePopout.position && (
|
{userProfilePopout.user && userProfilePopout.position && (
|
||||||
|
|||||||
@@ -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<boolean>(false);
|
||||||
|
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 outgoingCallLoop = useRef<AudioBufferSourceNode | null>(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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user