f694301ca7
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
264 lines
6.3 KiB
TypeScript
264 lines
6.3 KiB
TypeScript
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 });
|
|
},
|
|
}));
|