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) => ( - - ))} + {channel.name} + + ) + )}
))} 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 && ( +
+ {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 ( +
+ + + + +
+ +
+ ); +} 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