Phase 2 frontend: voice store, VoiceChannel, VoicePanel, VoiceControls

Frontend:
- stores/voice.ts: Zustand store for voice state (join, leave, mute, deafen, video, screen share)
- VoiceChannel.tsx: sidebar item showing 🔊 channel name with participant count
- VoicePanel.tsx: box-drawn frame with participant list and controls
- VoiceControls.tsx: [m] mute, [d] deafen, [cam] video, [share] screen, [x] disconnect
- ChannelList.tsx: integrated VoiceChannel for voice-type channels
- Layout.tsx: added VoicePanel above chat area

Deps:
- Added livekit-client, @livekit/components-react
- NODE_ENV fix for dev deps installation
This commit is contained in:
2026-06-28 16:26:19 -04:00
parent 8ee0ce657f
commit f694301ca7
9 changed files with 538 additions and 41 deletions
+263
View File
@@ -0,0 +1,263 @@
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<void>;
leaveVoice: () => Promise<void>;
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<VoiceState>((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<VoiceTokenResponse>('/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 });
},
}));