diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index a40c3c41..158cf171 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -95,7 +95,6 @@ export function AppLayout() { const activeDmCall = useVoiceStore((s) => s.activeDmCall); const { connect: connectVoice, - connectDm: connectDmVoice, disconnect: disconnectVoice, isConnected: isVoiceConnected, isConnecting: isVoiceConnecting, @@ -133,7 +132,7 @@ export function AppLayout() { lastAttemptedRef.current = targetChannelId; if (activeDmCall) { - await connectDmVoice(activeDmCall.dmChannelId); + await connectVoice(activeDmCall.dmChannelId, true); } else { await connectVoice(targetChannelId); } @@ -162,8 +161,7 @@ export function AppLayout() { isWsConnected, isLoading, user, - connectVoice, - connectDmVoice, + connectVoice, disconnectVoice ]); diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index 1ce593e6..f35adcda 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -9,7 +9,6 @@ import { TypingIndicator } from '../chat/TypingIndicator'; import { VoiceGrid } from '../voice/VoiceGrid'; import { VoiceControlBar } from '../voice/VoiceControlBar'; import { VoiceChatPanel } from '../voice/VoiceChatPanel'; -import { DmCallView } from '../voice/DmCallView'; import { FriendsPage } from '../chat/FriendsPage'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; @@ -85,8 +84,37 @@ export function MainContent() { if (isInDmCall) { return ( -
- +
+
+
+ + + + {dmName} + {connectionError ? ( + Connection Failed + ) : isLiveKitConnected ? ( + <> + Connected + {participants.length} in call + + ) : ( + Connecting... + )} +
+
+ +
+
+ +
+ +
+ +
); } diff --git a/packages/web/src/components/voice/DmCallView.tsx b/packages/web/src/components/voice/DmCallView.tsx deleted file mode 100644 index 7c29990a..00000000 --- a/packages/web/src/components/voice/DmCallView.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import React, { useEffect } from 'react'; -import { useVoiceStore } from '../../stores/voiceStore'; -import { useServerStore } from '../../stores/serverStore'; -import { useAuthStore } from '../../stores/authStore'; -import { getActiveRoom } from '../../hooks/useLiveKit'; -import { wsSend } from '../../hooks/useWebSocket'; -import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare'; - -export function DmCallView() { - const activeDmCall = useVoiceStore((s) => s.activeDmCall); - const participants = useVoiceStore((s) => s.participants); - const isMuted = useVoiceStore((s) => s.isMuted); - const isDeafened = useVoiceStore((s) => s.isDeafened); - const isCameraOn = useVoiceStore((s) => s.isCameraOn); - const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); - const toggleMic = useVoiceStore((s) => s.toggleMic); - const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); - const toggleCamera = useVoiceStore((s) => s.toggleCamera); - const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall); - const leaveVoice = useVoiceStore((s) => s.leaveVoice); - const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds); - - const dmChannels = useServerStore((s) => s.dmChannels); - const authUser = useAuthStore((s) => s.user); - - const dmChannel = dmChannels.find(dm => dm.id === activeDmCall?.dmChannelId); - const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); - const otherName = otherUser?.displayName ?? otherUser?.username ?? 'User'; - - // Mute/deafen toggling is handled by syncMic in useLiveKit via store state. - // DmCallView only needs to toggle store + handle remote audio silencing for deafen. - const handleMute = () => { - toggleMic(); - }; - - const handleDeafen = () => { - const room = getActiveRoom(); - const willDeafen = !isDeafened; - // Silence/restore remote participant audio for deafen - if (room) { - room.remoteParticipants.forEach((p) => { - p.audioTrackPublications.forEach((pub) => { - if (pub.track) { - (pub.track as any).setVolume?.(willDeafen ? 0 : 1); - } - }); - }); - } - toggleDeafen(); - // Auto-mute on deafen, auto-unmute on undeafen - if (willDeafen && !isMuted) toggleMic(); - if (!willDeafen && isMuted) toggleMic(); - }; - - const handleCamera = async () => { - const room = getActiveRoom(); - if (room) { - const willEnable = !isCameraOn; - if (willEnable) { - const videoQuality = useVoiceStore.getState().videoQuality; - const preset = SCREEN_QUALITY_MAP[videoQuality]; - if (preset) { - await room.localParticipant.setCameraEnabled(true, - { resolution: preset.resolution }, - { - videoEncoding: preset.encoding, - simulcast: videoQuality === '1080p' || videoQuality === '720p' - } - ); - } else { - await room.localParticipant.setCameraEnabled(true); - } - } else { - await room.localParticipant.setCameraEnabled(false); - } - } - toggleCamera(); - }; - - const handleScreenShare = async () => { - const room = getActiveRoom(); - if (!room) return; - try { - if (!isScreenSharing) { - await startScreenShare(room); - } else { - await stopScreenShare(room); - } - } catch (err) { - console.error('[DmCallView] Failed to toggle screen share:', err); - } - }; - - const handleEndCall = () => { - if (activeDmCall) { - wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); - } - setActiveDmCall(null); - leaveVoice(); - }; - - // Attach video elements - useEffect(() => { - participants.forEach((p) => { - if (p.videoTrack) { - const el = document.getElementById(`dm-video-${p.userId}`) as HTMLVideoElement | null; - if (el && (el.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id !== p.videoTrack.id) { - el.srcObject = new MediaStream([p.videoTrack]); - } - } - }); - }, [participants]); - - if (!activeDmCall) return null; - - const localParticipant = participants.find(p => p.isLocal); - const remoteParticipant = participants.find(p => !p.isLocal); - - return ( -
- {/* Header */} -
-
- - - - {otherName} - In Call -
-
- - {/* Main call area - 1-on-1 layout */} -
- {/* Remote participant (or waiting) */} -
- {remoteParticipant?.videoTrack ? ( -
-
- ) : ( -
-
- {otherName.charAt(0).toUpperCase()} -
- {remoteParticipant && speakingParticipantIds.has(remoteParticipant.identity) && ( -
- )} -
- )} - - {remoteParticipant ? otherName : 'Connecting...'} - - {remoteParticipant?.isMuted && ( - - - - - Muted - - )} -
- - {/* Local participant */} -
- {localParticipant?.videoTrack ? ( -
-
- ) : ( -
-
- {(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()} -
- {localParticipant && speakingParticipantIds.has(localParticipant.identity) && ( -
- )} -
- )} - - {authUser?.displayName ?? authUser?.username ?? 'You'} (You) - -
-
- - {/* Control bar */} -
- {/* Mute */} - - - {/* Deafen */} - - - {/* Camera */} - - - {/* Screen Share */} - - - {/* Spacer */} -
- - {/* End Call */} - -
-
- ); -} diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 97efd3dd..657ef93d 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -106,8 +106,14 @@ export function VoiceControlBar() { }; const handleDisconnect = () => { - wsSend({ type: 'voice_leave' }); - useVoiceStore.getState().leaveVoice(); + const { activeDmCall } = useVoiceStore.getState(); + if (activeDmCall) { + wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); + useVoiceStore.getState().setActiveDmCall(null); + } else { + wsSend({ type: 'voice_leave' }); + useVoiceStore.getState().leaveVoice(); + } if (voiceFullscreen) { useUIStore.getState().setVoiceFullscreen(false); if (document.fullscreenElement) { diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 6714ac0c..73d8d2df 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -25,10 +25,12 @@ export function VoiceControls() { const [showVideoQuality, setShowVideoQuality] = useState(false); const [showConnectionInfo, setShowConnectionInfo] = useState(false); - if (!currentVoiceChannelId) return null; + const activeDmCall = useVoiceStore((s) => s.activeDmCall); + + if (!currentVoiceChannelId && !activeDmCall) return null; const channel = channels.find(c => c.id === currentVoiceChannelId); - const channelName = channel?.name ?? 'Voice Channel'; + const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel'); const handleCamera = async () => { const room = getActiveRoom(); @@ -66,8 +68,14 @@ export function VoiceControls() { }; const handleDisconnect = () => { - wsSend({ type: 'voice_leave' }); - useVoiceStore.getState().leaveVoice(); + const { activeDmCall } = useVoiceStore.getState(); + if (activeDmCall) { + wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); + useVoiceStore.getState().setActiveDmCall(null); + } else { + wsSend({ type: 'voice_leave' }); + useVoiceStore.getState().leaveVoice(); + } }; const statusColor = connectionError diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index a00e097c..d3aaff2b 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -285,8 +285,9 @@ export function useLiveKit() { }; }, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]); - const connect = useCallback(async (channelId: string) => { - if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return; + const connect = useCallback(async (channelId: string, isDm?: boolean) => { + const storedId = isDm ? `dm-${channelId}` : channelId; + if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return; const gen = ++_connectGeneration; // Ensure AudioContext is created and resumed before tracks arrive @@ -323,7 +324,7 @@ export function useLiveKit() { } try { - const { token, url } = await api.livekit.token(channelId); + const { token, url } = isDm ? await api.livekit.dmToken(channelId) : await api.livekit.token(channelId); if (gen !== _connectGeneration) return; const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } }); roomRef.current = newRoom; @@ -428,9 +429,9 @@ export function useLiveKit() { await newRoom.connect(url, token); if (gen !== _connectGeneration) { newRoom.disconnect(); return; } - _activeRoom = newRoom; - connectedChannelRef.current = channelId; - setConnectedChannelId(channelId); + _activeRoom = newRoom; + connectedChannelRef.current = storedId; + setConnectedChannelId(storedId); setRoom(newRoom); setIsConnected(true); useVoiceStore.getState().setIsLiveKitConnected(true); @@ -462,152 +463,6 @@ export function useLiveKit() { finally { if (gen === _connectGeneration) setIsConnecting(false); } }, [updateParticipants, handleDataReceived]); - const connectDm = useCallback(async (dmChannelId: string) => { - const gen = ++_connectGeneration; - - // Ensure AudioContext is created and resumed before tracks arrive - await AudioManager.getInstance().resumeContext(); - - // 1. Reset state immediately - SpeakingDetector.getInstance().clear(); - setRoom(null); - useVoiceStore.getState().setParticipants([]); - useVoiceStore.getState().setSpeakingParticipants(new Set()); - setIsConnected(false); - setIsConnecting(true); - setConnectionState(ConnectionState.Connecting); - setConnectionError(null); - setConnectedChannelId(null); - - useVoiceStore.getState().setConnectionError(null); - useVoiceStore.getState().setConnectionQuality('unknown'); - - // 2. Strictly disconnect previous room (Local Ref OR Global Ref) - const roomToDisconnect = roomRef.current || _activeRoom; - - if (roomToDisconnect) { - try { - console.log('[LiveKit] Disconnecting previous room (DM):', roomToDisconnect.name); - await roomToDisconnect.disconnect(); - } catch (err) { - console.warn('Error disconnecting from previous room:', err); - } - roomRef.current = null; - _activeRoom = null; - } - - try { - const { token, url } = await api.livekit.dmToken(dmChannelId); - if (gen !== _connectGeneration) return; - const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } }); - roomRef.current = newRoom; - const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; - newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate); - newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); - newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { - if (track.kind === Track.Kind.Audio) { - (track as RemoteAudioTrack).detach(); - } - guardedUpdate(); - }); - newRoom.on(RoomEvent.TrackUnsubscribed, (track) => { - if (track.kind === Track.Kind.Audio) { - (track as RemoteAudioTrack).detach(); - } - guardedUpdate(); - }); - newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => { - if (publication.source === Track.Source.ScreenShare) { - const { userId } = parseIdentity(newRoom.localParticipant.identity); - useVoiceStore.getState().watchStream(userId); - } - guardedUpdate(); - }); - newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => { - if (publication.source === Track.Source.ScreenShare) { - const { userId } = parseIdentity(newRoom.localParticipant.identity); - useVoiceStore.getState().unwatchStream(userId); - // OS-level "Stop sharing" fires this without going through stopScreenShare - handleScreenShareUnpublished(); - } - guardedUpdate(); - }); - newRoom.on(RoomEvent.TrackMuted, guardedUpdate); - newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); - newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => { - if ( - publication.source === Track.Source.ScreenShare || - publication.source === Track.Source.ScreenShareAudio - ) { - (publication as RemoteTrackPublication).setSubscribed(false); - } - guardedUpdate(); - }); - newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => { - if (publication.source === Track.Source.ScreenShare) { - const { userId } = parseIdentity(participant.identity); - const state = useVoiceStore.getState(); - state.unwatchStream(userId); - state.clearStreamVolume(userId); - state.clearStreamMute(userId); - } - guardedUpdate(); - }); - newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: ConnectionQuality, participant: Participant) => { - if (participant.identity === newRoom.localParticipant.identity) { - useVoiceStore.getState().setConnectionQuality(quality as any); - } - }); - newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { - if (roomRef.current === newRoom) { - setConnectionState(state); - const connected = state === ConnectionState.Connected; - const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting; - - setIsConnected(connected); - setIsConnecting(connecting); - - useVoiceStore.getState().setIsLiveKitConnected(connected); - - if (connected) { - updateParticipants(); - } - } - }); - await newRoom.connect(url, token); - if (gen !== _connectGeneration) { newRoom.disconnect(); return; } - const fullId = `dm-${dmChannelId}`; - _activeRoom = newRoom; - connectedChannelRef.current = fullId; - setConnectedChannelId(fullId); - setRoom(newRoom); - setIsConnected(true); - useVoiceStore.getState().setIsLiveKitConnected(true); - updateParticipants(); - - // Unsubscribe from any remote screen share tracks that auto-subscribed during connect - newRoom.remoteParticipants.forEach((rp) => { - rp.trackPublications.forEach((pub) => { - if ( - (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) && - pub.isSubscribed - ) { - (pub as RemoteTrackPublication).setSubscribed(false); - } - }); - }); - - const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState(); - useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false }); - - if (wasDeafened) { - newRoom.remoteParticipants.forEach((p) => p.setVolume(0)); - } - updateParticipants(); - } catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); } } - finally { if (gen === _connectGeneration) setIsConnecting(false); } - }, [updateParticipants, handleDataReceived]); - const disconnect = useCallback(async () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); @@ -704,5 +559,5 @@ export function useLiveKit() { }, []); - return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare }; + return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, disconnect, toggleMic, toggleCamera, toggleScreenShare }; }