refactor: unify DM calls with server voice architecture
Merge connectDm() into connect() with isDm flag, eliminating ~145 lines of duplicated LiveKit room setup. DM calls now inherit all event handlers (SpeakingDetector cleanup, deafen broadcasts, metadata changes). Replace monolithic DmCallView with shared VoiceGrid + VoiceControlBar components, making DM calls group-DM-ready with full feature parity.
This commit is contained in:
@@ -95,7 +95,6 @@ export function AppLayout() {
|
|||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
const {
|
const {
|
||||||
connect: connectVoice,
|
connect: connectVoice,
|
||||||
connectDm: connectDmVoice,
|
|
||||||
disconnect: disconnectVoice,
|
disconnect: disconnectVoice,
|
||||||
isConnected: isVoiceConnected,
|
isConnected: isVoiceConnected,
|
||||||
isConnecting: isVoiceConnecting,
|
isConnecting: isVoiceConnecting,
|
||||||
@@ -133,7 +132,7 @@ export function AppLayout() {
|
|||||||
lastAttemptedRef.current = targetChannelId;
|
lastAttemptedRef.current = targetChannelId;
|
||||||
|
|
||||||
if (activeDmCall) {
|
if (activeDmCall) {
|
||||||
await connectDmVoice(activeDmCall.dmChannelId);
|
await connectVoice(activeDmCall.dmChannelId, true);
|
||||||
} else {
|
} else {
|
||||||
await connectVoice(targetChannelId);
|
await connectVoice(targetChannelId);
|
||||||
}
|
}
|
||||||
@@ -162,8 +161,7 @@ export function AppLayout() {
|
|||||||
isWsConnected,
|
isWsConnected,
|
||||||
isLoading,
|
isLoading,
|
||||||
user,
|
user,
|
||||||
connectVoice,
|
connectVoice,
|
||||||
connectDmVoice,
|
|
||||||
disconnectVoice
|
disconnectVoice
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { TypingIndicator } from '../chat/TypingIndicator';
|
|||||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||||
import { VoiceControlBar } from '../voice/VoiceControlBar';
|
import { VoiceControlBar } from '../voice/VoiceControlBar';
|
||||||
import { VoiceChatPanel } from '../voice/VoiceChatPanel';
|
import { VoiceChatPanel } from '../voice/VoiceChatPanel';
|
||||||
import { DmCallView } from '../voice/DmCallView';
|
|
||||||
import { FriendsPage } from '../chat/FriendsPage';
|
import { FriendsPage } from '../chat/FriendsPage';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
@@ -85,8 +84,37 @@ export function MainContent() {
|
|||||||
|
|
||||||
if (isInDmCall) {
|
if (isInDmCall) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
<div
|
||||||
<DmCallView />
|
ref={voiceContainerRef}
|
||||||
|
className={`flex-1 flex flex-col bg-[#0b0c0e] min-w-0 group/voice relative ${voiceFullscreen ? 'h-screen' : ''}`}
|
||||||
|
>
|
||||||
|
<div className={`h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e] transition-opacity duration-300 ${voiceFullscreen ? 'opacity-0 hover:opacity-100' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
||||||
|
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-bold text-discord-text-primary">{dmName}</span>
|
||||||
|
{connectionError ? (
|
||||||
|
<span className="text-xs text-discord-red font-medium ml-2">Connection Failed</span>
|
||||||
|
) : isLiveKitConnected ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
|
||||||
|
<span className="text-xs text-discord-text-muted ml-1">{participants.length} in call</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-discord-yellow font-medium ml-2">Connecting...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<MemberListToggleButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden pb-20">
|
||||||
|
<VoiceGrid participants={participants} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VoiceControlBar />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="flex-1 flex flex-col bg-[#0b0c0e] min-w-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-green">
|
|
||||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-bold text-discord-text-primary">{otherName}</span>
|
|
||||||
<span className="text-xs text-discord-green font-medium ml-2">In Call</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main call area - 1-on-1 layout */}
|
|
||||||
<div className="flex-1 flex items-center justify-center gap-8 p-8">
|
|
||||||
{/* Remote participant (or waiting) */}
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
{remoteParticipant?.videoTrack ? (
|
|
||||||
<div className="w-[360px] h-[270px] rounded-xl overflow-hidden bg-[#111214] relative">
|
|
||||||
<video
|
|
||||||
id={`dm-video-${remoteParticipant.userId}`}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted={false}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded text-xs text-white">
|
|
||||||
{otherName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-[200px] h-[200px] rounded-full bg-[#111214] flex items-center justify-center relative">
|
|
||||||
<div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold">
|
|
||||||
{otherName.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{remoteParticipant && speakingParticipantIds.has(remoteParticipant.identity) && (
|
|
||||||
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-discord-text-secondary text-sm font-medium">
|
|
||||||
{remoteParticipant ? otherName : 'Connecting...'}
|
|
||||||
</span>
|
|
||||||
{remoteParticipant?.isMuted && (
|
|
||||||
<span className="text-discord-text-muted text-xs flex items-center gap-1">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z" />
|
|
||||||
</svg>
|
|
||||||
Muted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Local participant */}
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
{localParticipant?.videoTrack ? (
|
|
||||||
<div className="w-[360px] h-[270px] rounded-xl overflow-hidden bg-[#111214] relative">
|
|
||||||
<video
|
|
||||||
id={`dm-video-${localParticipant.userId}`}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
className="w-full h-full object-cover mirror"
|
|
||||||
style={{ transform: 'scaleX(-1)' }}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded text-xs text-white">
|
|
||||||
You
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-[200px] h-[200px] rounded-full bg-[#111214] flex items-center justify-center relative">
|
|
||||||
<div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold">
|
|
||||||
{(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{localParticipant && speakingParticipantIds.has(localParticipant.identity) && (
|
|
||||||
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-discord-text-secondary text-sm font-medium">
|
|
||||||
{authUser?.displayName ?? authUser?.username ?? 'You'} (You)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Control bar */}
|
|
||||||
<div className="h-[72px] bg-[#111214] flex items-center justify-center gap-4 px-4 flex-shrink-0">
|
|
||||||
{/* Mute */}
|
|
||||||
<button
|
|
||||||
onClick={handleMute}
|
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
|
||||||
isMuted ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' : 'bg-[#111214] text-discord-text-primary hover:bg-[#2b2d31]'
|
|
||||||
}`}
|
|
||||||
title={isMuted ? 'Unmute' : 'Mute'}
|
|
||||||
>
|
|
||||||
{isMuted ? (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Deafen */}
|
|
||||||
<button
|
|
||||||
onClick={handleDeafen}
|
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
|
||||||
isDeafened ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' : 'bg-[#111214] text-discord-text-primary hover:bg-[#2b2d31]'
|
|
||||||
}`}
|
|
||||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
|
||||||
>
|
|
||||||
{isDeafened ? (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 101.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12A4.5 4.5 0 0014 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Camera */}
|
|
||||||
<button
|
|
||||||
onClick={handleCamera}
|
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
|
||||||
isCameraOn ? 'bg-discord-blurple/20 text-discord-blurple hover:bg-discord-blurple/30' : 'bg-[#111214] text-discord-text-primary hover:bg-[#2b2d31]'
|
|
||||||
}`}
|
|
||||||
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
|
||||||
>
|
|
||||||
{isCameraOn ? (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Screen Share */}
|
|
||||||
<button
|
|
||||||
onClick={handleScreenShare}
|
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
|
||||||
isScreenSharing ? 'bg-discord-blurple/20 text-discord-blurple hover:bg-discord-blurple/30' : 'bg-[#111214] text-discord-text-primary hover:bg-[#2b2d31]'
|
|
||||||
}`}
|
|
||||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<div className="w-[1px] h-8 bg-[#2b2d31] mx-2" />
|
|
||||||
|
|
||||||
{/* End Call */}
|
|
||||||
<button
|
|
||||||
onClick={handleEndCall}
|
|
||||||
className="w-12 h-12 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors text-white"
|
|
||||||
title="End Call"
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -106,8 +106,14 @@ export function VoiceControlBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
wsSend({ type: 'voice_leave' });
|
const { activeDmCall } = useVoiceStore.getState();
|
||||||
useVoiceStore.getState().leaveVoice();
|
if (activeDmCall) {
|
||||||
|
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId });
|
||||||
|
useVoiceStore.getState().setActiveDmCall(null);
|
||||||
|
} else {
|
||||||
|
wsSend({ type: 'voice_leave' });
|
||||||
|
useVoiceStore.getState().leaveVoice();
|
||||||
|
}
|
||||||
if (voiceFullscreen) {
|
if (voiceFullscreen) {
|
||||||
useUIStore.getState().setVoiceFullscreen(false);
|
useUIStore.getState().setVoiceFullscreen(false);
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ export function VoiceControls() {
|
|||||||
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
||||||
const [showConnectionInfo, setShowConnectionInfo] = 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 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 handleCamera = async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
@@ -66,8 +68,14 @@ export function VoiceControls() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
wsSend({ type: 'voice_leave' });
|
const { activeDmCall } = useVoiceStore.getState();
|
||||||
useVoiceStore.getState().leaveVoice();
|
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
|
const statusColor = connectionError
|
||||||
|
|||||||
@@ -285,8 +285,9 @@ export function useLiveKit() {
|
|||||||
};
|
};
|
||||||
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]);
|
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]);
|
||||||
|
|
||||||
const connect = useCallback(async (channelId: string) => {
|
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
|
||||||
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
|
const storedId = isDm ? `dm-${channelId}` : channelId;
|
||||||
|
if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;
|
||||||
const gen = ++_connectGeneration;
|
const gen = ++_connectGeneration;
|
||||||
|
|
||||||
// Ensure AudioContext is created and resumed before tracks arrive
|
// Ensure AudioContext is created and resumed before tracks arrive
|
||||||
@@ -323,7 +324,7 @@ export function useLiveKit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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;
|
if (gen !== _connectGeneration) return;
|
||||||
const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||||
roomRef.current = newRoom;
|
roomRef.current = newRoom;
|
||||||
@@ -428,9 +429,9 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
await newRoom.connect(url, token);
|
await newRoom.connect(url, token);
|
||||||
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
|
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
|
||||||
_activeRoom = newRoom;
|
_activeRoom = newRoom;
|
||||||
connectedChannelRef.current = channelId;
|
connectedChannelRef.current = storedId;
|
||||||
setConnectedChannelId(channelId);
|
setConnectedChannelId(storedId);
|
||||||
setRoom(newRoom);
|
setRoom(newRoom);
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||||
@@ -462,152 +463,6 @@ export function useLiveKit() {
|
|||||||
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
||||||
}, [updateParticipants, handleDataReceived]);
|
}, [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 () => {
|
const disconnect = useCallback(async () => {
|
||||||
_connectGeneration++;
|
_connectGeneration++;
|
||||||
SpeakingDetector.getInstance().clear();
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user