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
+23 -23
View File
@@ -17,7 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
@@ -811,19 +811,6 @@
"tslib": "^2.6.2" "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": { "node_modules/@livekit/components-react": {
"version": "2.9.21", "version": "2.9.21",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
@@ -1704,9 +1691,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.379", "version": "1.5.380",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
"integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -2059,6 +2046,19 @@
"@types/dom-mediacapture-record": "^1" "@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": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -2066,9 +2066,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/loglevel": { "node_modules/loglevel": {
"version": "1.9.2", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6.0" "node": ">= 0.6.0"
@@ -2250,9 +2250,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+1 -1
View File
@@ -19,7 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
+11 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useServerStore } from '../stores/server.ts'; import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts'; import { useChannelStore } from '../stores/channel.ts';
import { VoiceChannel } from './VoiceChannel.tsx';
export function ChannelList() { export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId); const activeServerId = useServerStore((state) => state.activeServerId);
@@ -44,7 +45,14 @@ export function ChannelList() {
<div key={category} className="mb-3"> <div key={category} className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">{category}</div> <div className="text-gb-fg-t text-xs uppercase mb-1">{category}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{list.map((channel) => ( {list.map((channel) =>
channel.type === 'voice' ? (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
/>
) : (
<button <button
key={channel.id} key={channel.id}
onClick={() => setActiveChannel(channel.id)} onClick={() => setActiveChannel(channel.id)}
@@ -52,14 +60,11 @@ export function ChannelList() {
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s' channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`} }`}
> >
{channel.type === 'voice' ? (
<span className="text-gb-fg-f">&#9835;</span>
) : (
<span className="text-gb-fg-f">#</span> <span className="text-gb-fg-f">#</span>
)}
<span className="truncate">{channel.name}</span> <span className="truncate">{channel.name}</span>
</button> </button>
))} )
)}
</div> </div>
))} ))}
</div> </div>
+6
View File
@@ -4,6 +4,7 @@ import { useAuthStore } from '../stores/auth.ts';
import { ServerBar } from './ServerBar.tsx'; import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx'; import { ChannelList } from './ChannelList.tsx';
import { MemberList } from './MemberList.tsx'; import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
export function Layout() { export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -54,8 +55,13 @@ export function Layout() {
<ServerBar /> <ServerBar />
<ChannelList /> <ChannelList />
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<VoicePanel />
<div className="flex-1 min-h-0">
<Outlet /> <Outlet />
</div> </div>
</div>
</div>
<MemberList /> <MemberList />
</div> </div>
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s"> <div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
+62
View File
@@ -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 (
<div className="mb-0.5">
<button
onClick={handleClick}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
isActive ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
<span className={isActive ? 'text-gb-orange' : 'text-gb-fg-f'}>
{isActive && isConnected ? '●' : isActive ? '◐' : '○'}
</span>
<span className={isActive ? 'text-gb-orange' : 'text-gb-fg-f'}>
🔊
</span>
<span className="truncate">{channelName}</span>
{isActive && participants.length > 0 && (
<span className="ml-auto text-xxs text-gb-fg-f">
[{participants.length}]
</span>
)}
</button>
{isActive && (
<div className="pl-7 py-0.5">
{participants.map((p) => (
<div
key={p.identity}
className="text-xxs text-gb-fg-f flex items-center gap-1"
>
<span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}>
{p.isMuted ? '○' : '●'}
</span>
<span className={p.isSpeaking ? 'text-gb-green' : ''}>
{p.username}
</span>
</div>
))}
</div>
)}
</div>
);
}
+70
View File
@@ -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 (
<div className="flex items-center gap-2 font-mono text-sm">
<button
onClick={toggleMute}
className={`px-1.5 py-0.5 rounded-sm transition-colors ${
isMuted
? 'text-gb-red bg-gb-bg-s border border-gb-red'
: 'text-gb-fg-s hover:text-gb-orange hover:bg-gb-bg-t border border-transparent'
}`}
title={isMuted ? 'Unmute microphone' : 'Mute microphone'}
>
[{isMuted ? 'm✗' : 'm'}]
</button>
<button
onClick={toggleDeafen}
className={`px-1.5 py-0.5 rounded-sm transition-colors ${
isDeafened
? 'text-gb-red bg-gb-bg-s border border-gb-red'
: 'text-gb-fg-s hover:text-gb-orange hover:bg-gb-bg-t border border-transparent'
}`}
title={isDeafened ? 'Undeafen' : 'Deafen'}
>
[{isDeafened ? 'd✗' : 'd'}]
</button>
<button
onClick={toggleVideo}
className={`px-1.5 py-0.5 rounded-sm transition-colors ${
isVideoOn
? 'text-gb-orange bg-gb-bg-s border border-gb-orange'
: 'text-gb-fg-s hover:text-gb-orange hover:bg-gb-bg-t border border-transparent'
}`}
title={isVideoOn ? 'Turn off camera' : 'Turn on camera'}
>
[cam]
</button>
<button
onClick={toggleScreenShare}
className={`px-1.5 py-0.5 rounded-sm transition-colors ${
isScreenSharing
? 'text-gb-aqua bg-gb-bg-s border border-gb-aqua'
: 'text-gb-fg-s hover:text-gb-aqua hover:bg-gb-bg-t border border-transparent'
}`}
title={isScreenSharing ? 'Stop sharing' : 'Share screen'}
>
[share]
</button>
<div className="flex-1" />
<button
onClick={leaveVoice}
className="px-1.5 py-0.5 rounded-sm text-gb-red hover:bg-gb-red hover:text-gb-bg transition-colors border border-transparent hover:border-gb-red"
title="Disconnect"
>
[x]
</button>
</div>
);
}
+91
View File
@@ -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 (
<div className="bg-gb-bg-s border border-gb-bg-t rounded-sm font-mono text-sm mb-2">
{/* Box-drawn header */}
<div className="px-3 py-1.5 border-b border-gb-bg-t flex items-center gap-2">
<span className="text-gb-fg-f">
<span className="text-gb-orange font-bold">VOICE</span>
{'─'.repeat(Math.max(0, 20 - (currentRoomName?.length ?? 0)))}
</span>
<span className="text-gb-fg-s truncate flex-1">
{currentRoomName || currentRoom}
</span>
<span
className={`text-xxs ${
isConnected ? 'text-gb-green' : 'text-gb-fg-f'
}`}
>
{isConnected ? '● LIVE' : '◐ connecting...'}
</span>
</div>
{/* Error display */}
{error && (
<div className="px-3 py-1 text-gb-red text-xxs border-b border-gb-bg-t">
! {error}
</div>
)}
{/* Participants */}
<div className="px-3 py-1.5 border-b border-gb-bg-t">
<div className="text-xxs text-gb-fg-f mb-1">
participants
</div>
{participants.length === 0 && (
<div className="text-xxs text-gb-fg-f">[empty channel]</div>
)}
{participants.map((p) => (
<div
key={p.identity}
className="flex items-center gap-2 py-0.5 text-xs"
>
<span
className={
p.isSpeaking
? 'text-gb-green'
: p.isMuted
? 'text-gb-red'
: 'text-gb-fg-s'
}
>
{p.isMuted ? '○' : '●'}
</span>
<span
className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-s'}
>
{p.username}
</span>
{p.isMuted && (
<span className="text-xxs text-gb-red">[muted]</span>
)}
{p.isSpeaking && (
<span className="text-xxs text-gb-green">[speaking]</span>
)}
</div>
))}
</div>
{/* Controls */}
<div className="px-3 py-1.5">
<VoiceControls />
</div>
{/* Box-drawn footer */}
<div className="px-3 pb-1 text-xxs text-gb-fg-f">
{'─'.repeat(40)}
</div>
</div>
);
}
+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 });
},
}));
+1 -1
View File
@@ -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"} {"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"}