import { create } from 'zustand'; import { Room, RoomEvent, Track, type RemoteAudioTrack, type RemoteParticipant, type Participant, type TrackPublication, } from 'livekit-client'; import { api } from '../lib/api.ts'; import { useWebSocketStore } from './ws.ts'; export interface VoiceParticipant { identity: string; username: string; isMuted: boolean; isSpeaking: boolean; } interface VoiceTokenResponse { token: string; livekit_url: string; } interface VoiceState { currentRoom: string | null; currentRoomName: string | null; isConnected: boolean; isMuted: boolean; isDeafened: boolean; isVideoOn: boolean; isScreenSharing: boolean; participants: VoiceParticipant[]; error: string | null; _room: Room | null; joinVoice: (channelId: string, channelName: string) => Promise; leaveVoice: () => Promise; toggleMute: () => void; toggleDeafen: () => void; toggleVideo: () => void; toggleScreenShare: () => void; } function participantToVoice(p: Participant): VoiceParticipant { const micPub = p.getTrackPublication(Track.Source.Microphone); return { identity: p.identity, username: p.name || p.identity, isMuted: micPub?.isMuted ?? true, isSpeaking: p.isSpeaking, }; } export const useVoiceStore = create((set, get) => ({ currentRoom: null, currentRoomName: null, isConnected: false, isMuted: false, isDeafened: false, isVideoOn: false, isScreenSharing: false, participants: [], error: null, _room: null, joinVoice: async (channelId, channelName) => { const existing = get()._room; if (existing) { await get().leaveVoice(); } set({ error: null }); try { const resp = await api.post('/voice/token', { room_name: channelId, }); const room = new Room({ adaptiveStream: true, dynacast: true, }); room.on(RoomEvent.Connected, () => { set({ isConnected: true, error: null }); syncParticipants(); }); room.on(RoomEvent.Disconnected, () => { set({ isConnected: false, currentRoom: null, currentRoomName: null, participants: [], isMuted: false, isDeafened: false, isVideoOn: false, isScreenSharing: false, }); }); room.on(RoomEvent.ParticipantConnected, () => { syncParticipants(); }); room.on(RoomEvent.ParticipantDisconnected, () => { syncParticipants(); }); room.on(RoomEvent.TrackMuted, () => { syncParticipants(); }); room.on(RoomEvent.TrackUnmuted, () => { syncParticipants(); }); room.on( RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => { set((state) => ({ participants: state.participants.map((p) => ({ ...p, isSpeaking: speakers.some((s) => s.identity === p.identity), })), })); }, ); function syncParticipants() { const r = get()._room; if (!r) return; const all: Participant[] = [ r.localParticipant, ...Array.from(r.remoteParticipants.values()), ]; set({ participants: all.map(participantToVoice) }); } await room.connect(resp.livekit_url, resp.token); // Mute mic by default on join await room.localParticipant.setMicrophoneEnabled(false); set({ isMuted: true }); set({ _room: room, currentRoom: channelId, currentRoomName: channelName, }); // Notify WebSocket peers const wsSend = useWebSocketStore.getState().send; wsSend({ type: 'VOICE_JOIN', payload: { room_name: channelId }, }); } catch (err) { set({ error: err instanceof Error ? err.message : 'Failed to join voice', }); } }, leaveVoice: async () => { const room = get()._room; if (!room) return; const channelId = get().currentRoom; const wsSend = useWebSocketStore.getState().send; if (channelId) { wsSend({ type: 'VOICE_LEAVE', payload: { room_name: channelId }, }); } await room.disconnect(); set({ _room: null, currentRoom: null, currentRoomName: null, isConnected: false, isMuted: false, isDeafened: false, isVideoOn: false, isScreenSharing: false, participants: [], error: null, }); }, toggleMute: () => { const room = get()._room; if (!room) return; const newMuted = !get().isMuted; room.localParticipant.setMicrophoneEnabled(!newMuted); set({ isMuted: newMuted }); // If un-deafening while unmuting, also restore speaker if (!newMuted && get().isDeafened) { set({ isDeafened: false }); } const wsSend = useWebSocketStore.getState().send; wsSend({ type: 'VOICE_MUTE', payload: { room_name: get().currentRoom, muted: newMuted }, }); }, toggleDeafen: () => { const room = get()._room; if (!room) return; const newDeafened = !get().isDeafened; if (newDeafened) { // Deafen: mute mic too room.localParticipant.setMicrophoneEnabled(false); set({ isDeafened: true, isMuted: true }); } else { set({ isDeafened: false }); } // Mute/unmute all remote audio tracks room.remoteParticipants.forEach((p: RemoteParticipant) => { p.audioTrackPublications.forEach((pub: TrackPublication) => { if (pub.track && pub.kind === Track.Kind.Audio) { (pub.track as RemoteAudioTrack).setVolume(newDeafened ? 0 : 1.0); } }); }); const wsSend = useWebSocketStore.getState().send; wsSend({ type: 'VOICE_DEAFEN', payload: { room_name: get().currentRoom, deafened: newDeafened }, }); }, toggleVideo: async () => { const room = get()._room; if (!room) return; const newState = !get().isVideoOn; await room.localParticipant.setCameraEnabled(newState); set({ isVideoOn: newState }); }, toggleScreenShare: async () => { const room = get()._room; if (!room) return; const newState = !get().isScreenSharing; await room.localParticipant.setScreenShareEnabled(newState); set({ isScreenSharing: newState }); }, }));