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:
@@ -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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user