diff --git a/web/package-lock.json b/web/package-lock.json
index 8fa6cff..63d9e95 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -17,7 +17,7 @@
},
"devDependencies": {
"@types/react": "^18.3.3",
- "@types/react-dom": "^18.3.0",
+ "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
@@ -811,19 +811,6 @@
"tslib": "^2.6.2"
}
},
- "node_modules/@livekit/components-core/node_modules/loglevel": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
- "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6.0"
- },
- "funding": {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/loglevel"
- }
- },
"node_modules/@livekit/components-react": {
"version": "2.9.21",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
@@ -1704,9 +1691,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.379",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz",
- "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==",
+ "version": "1.5.380",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
+ "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
"dev": true,
"license": "ISC"
},
@@ -2059,6 +2046,19 @@
"@types/dom-mediacapture-record": "^1"
}
},
+ "node_modules/livekit-client/node_modules/loglevel": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
+ "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -2066,9 +2066,9 @@
"license": "MIT"
},
"node_modules/loglevel": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
- "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
+ "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
@@ -2250,9 +2250,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
diff --git a/web/package.json b/web/package.json
index 71f29f5..447416c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@types/react": "^18.3.3",
- "@types/react-dom": "^18.3.0",
+ "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx
index a75ea6d..b0618da 100644
--- a/web/src/components/ChannelList.tsx
+++ b/web/src/components/ChannelList.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
+import { VoiceChannel } from './VoiceChannel.tsx';
export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId);
@@ -44,22 +45,26 @@ export function ChannelList() {
{category}
---
- {list.map((channel) => (
-
+ )
+ )}
))}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index ee1a047..a30953b 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -4,6 +4,7 @@ import { useAuthStore } from '../stores/auth.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { MemberList } from './MemberList.tsx';
+import { VoicePanel } from './VoicePanel.tsx';
export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -54,7 +55,12 @@ export function Layout() {
diff --git a/web/src/components/VoiceChannel.tsx b/web/src/components/VoiceChannel.tsx
new file mode 100644
index 0000000..23a6aaa
--- /dev/null
+++ b/web/src/components/VoiceChannel.tsx
@@ -0,0 +1,62 @@
+import { useVoiceStore } from '../stores/voice.ts';
+
+interface VoiceChannelProps {
+ channelId: string;
+ channelName: string;
+}
+
+export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
+ const currentRoom = useVoiceStore((state) => state.currentRoom);
+ const isConnected = useVoiceStore((state) => state.isConnected);
+ const joinVoice = useVoiceStore((state) => state.joinVoice);
+ const participants = useVoiceStore((state) => state.participants);
+
+ const isActive = currentRoom === channelId;
+
+ const handleClick = () => {
+ if (!isActive) {
+ joinVoice(channelId, channelName);
+ }
+ };
+
+ return (
+
+
+
+ {isActive && isConnected ? '●' : isActive ? '◐' : '○'}
+
+
+ 🔊
+
+ {channelName}
+ {isActive && participants.length > 0 && (
+
+ [{participants.length}]
+
+ )}
+
+ {isActive && (
+
+ {participants.map((p) => (
+
+
+ {p.isMuted ? '○' : '●'}
+
+
+ {p.username}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/VoiceControls.tsx b/web/src/components/VoiceControls.tsx
new file mode 100644
index 0000000..b07dfb6
--- /dev/null
+++ b/web/src/components/VoiceControls.tsx
@@ -0,0 +1,70 @@
+import { useVoiceStore } from '../stores/voice.ts';
+
+export function VoiceControls() {
+ const isMuted = useVoiceStore((state) => state.isMuted);
+ const isDeafened = useVoiceStore((state) => state.isDeafened);
+ const isVideoOn = useVoiceStore((state) => state.isVideoOn);
+ const isScreenSharing = useVoiceStore((state) => state.isScreenSharing);
+ const toggleMute = useVoiceStore((state) => state.toggleMute);
+ const toggleDeafen = useVoiceStore((state) => state.toggleDeafen);
+ const toggleVideo = useVoiceStore((state) => state.toggleVideo);
+ const toggleScreenShare = useVoiceStore((state) => state.toggleScreenShare);
+ const leaveVoice = useVoiceStore((state) => state.leaveVoice);
+
+ return (
+
+
+ [{isMuted ? 'm✗' : 'm'}]
+
+
+ [{isDeafened ? 'd✗' : 'd'}]
+
+
+ [cam]
+
+
+ [share]
+
+
+
+ [x]
+
+
+ );
+}
diff --git a/web/src/components/VoicePanel.tsx b/web/src/components/VoicePanel.tsx
new file mode 100644
index 0000000..f1be5f3
--- /dev/null
+++ b/web/src/components/VoicePanel.tsx
@@ -0,0 +1,91 @@
+import { useVoiceStore } from '../stores/voice.ts';
+import { VoiceControls } from './VoiceControls.tsx';
+
+export function VoicePanel() {
+ const isConnected = useVoiceStore((state) => state.isConnected);
+ const currentRoom = useVoiceStore((state) => state.currentRoom);
+ const currentRoomName = useVoiceStore((state) => state.currentRoomName);
+ const participants = useVoiceStore((state) => state.participants);
+ const error = useVoiceStore((state) => state.error);
+
+ if (!currentRoom) return null;
+
+ return (
+
+ {/* Box-drawn header */}
+
+
+ ┌─┤
+ VOICE
+ ├─{'─'.repeat(Math.max(0, 20 - (currentRoomName?.length ?? 0)))}┐
+
+
+ {currentRoomName || currentRoom}
+
+
+ {isConnected ? '● LIVE' : '◐ connecting...'}
+
+
+
+ {/* Error display */}
+ {error && (
+
+ ! {error}
+
+ )}
+
+ {/* Participants */}
+
+
+ ── participants ──
+
+ {participants.length === 0 && (
+
[empty channel]
+ )}
+ {participants.map((p) => (
+
+
+ {p.isMuted ? '○' : '●'}
+
+
+ {p.username}
+
+ {p.isMuted && (
+ [muted]
+ )}
+ {p.isSpeaking && (
+ [speaking]
+ )}
+
+ ))}
+
+
+ {/* Controls */}
+
+
+
+
+ {/* Box-drawn footer */}
+
+ └{'─'.repeat(40)}┘
+
+
+ );
+}
diff --git a/web/src/stores/voice.ts b/web/src/stores/voice.ts
new file mode 100644
index 0000000..2fa6705
--- /dev/null
+++ b/web/src/stores/voice.ts
@@ -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;
+ 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 });
+ },
+}));
diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo
index da0a401..d218bb6 100644
--- a/web/tsconfig.app.tsbuildinfo
+++ b/web/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/GiphyPicker.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/components/UserSettings.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/ws.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/GiphyPicker.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
\ No newline at end of file