feat: Optimize WebRTC pipeline for 60fps screen sharing
- Implemented 'Overdrive' logic to force high bitrates on Chrome - Fixed 'Auto' preset to default to stable 720p60 - Added persistent 'Triple-Kick' hammer to prevent bitrate throttling - Fixed sidebar connection status sync - Added comprehensive diagnostic logger
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,298 @@
|
||||
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 { VideoPresets, VideoPreset } from 'livekit-client';
|
||||
|
||||
const QUALITY_MAP: Record<string, any> = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
|
||||
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 toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall);
|
||||
const leaveVoice = useVoiceStore((s) => s.leaveVoice);
|
||||
|
||||
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';
|
||||
|
||||
const handleMute = () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
}
|
||||
toggleMic();
|
||||
};
|
||||
|
||||
const handleDeafen = () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
const newDeafened = !isDeafened;
|
||||
room.remoteParticipants.forEach((p) => {
|
||||
p.audioTrackPublications.forEach((pub) => {
|
||||
if (pub.track) {
|
||||
(pub.track as any).setVolume?.(newDeafened ? 0 : 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (newDeafened) {
|
||||
room.localParticipant.setMicrophoneEnabled(false);
|
||||
} else if (!isMuted) {
|
||||
room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
}
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
const willEnable = !isCameraOn;
|
||||
if (willEnable) {
|
||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||
const preset = 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 = () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
room.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
}
|
||||
toggleScreenShare();
|
||||
};
|
||||
|
||||
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-[#111214] min-w-0">
|
||||
{/* Header */}
|
||||
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#111214]">
|
||||
<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-[#2b2d31] 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-[#2b2d31] 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?.isSpeaking && (
|
||||
<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-[#2b2d31] 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-[#2b2d31] 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?.isSpeaking && (
|
||||
<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-[#1e1f22] 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-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'
|
||||
}`}
|
||||
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-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'
|
||||
}`}
|
||||
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-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'
|
||||
}`}
|
||||
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-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'
|
||||
}`}
|
||||
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-[#3f4147] 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
export function IncomingCallModal() {
|
||||
const incomingCall = useVoiceStore((s) => s.incomingCall);
|
||||
const setIncomingCall = useVoiceStore((s) => s.setIncomingCall);
|
||||
const timerRef = useRef(null);
|
||||
// Auto-dismiss after 30 seconds
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Auto-reject after timeout
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [incomingCall, setIncomingCall]);
|
||||
if (!incomingCall)
|
||||
return null;
|
||||
const handleAccept = () => {
|
||||
if (timerRef.current)
|
||||
clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
|
||||
};
|
||||
const handleDecline = () => {
|
||||
if (timerRef.current)
|
||||
clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[100] flex items-center justify-center", children: [_jsx("div", { className: "absolute inset-0 bg-black/60" }), _jsxs("div", { className: "relative bg-[#1e1f22] rounded-lg shadow-2xl w-[340px] overflow-hidden", children: [_jsxs("div", { className: "absolute inset-0 overflow-hidden", children: [_jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-discord-green/5 animate-ping", style: { animationDuration: '2s' } }), _jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-discord-green/10 animate-ping", style: { animationDuration: '2s', animationDelay: '0.5s' } })] }), _jsxs("div", { className: "relative p-8 flex flex-col items-center gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-20 h-20 rounded-full bg-discord-blurple flex items-center justify-center text-white text-3xl font-bold", children: incomingCall.callerName.charAt(0).toUpperCase() }), _jsx("div", { className: "absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-discord-green flex items-center justify-center", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "white", children: _jsx("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" }) }) })] }), _jsxs("div", { className: "text-center", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header", children: incomingCall.callerName }), _jsx("p", { className: "text-[14px] text-discord-text-muted mt-1", children: "Incoming Voice Call..." })] }), _jsxs("div", { className: "flex items-center gap-6 mt-2", children: [_jsx("button", { onClick: handleDecline, className: "w-14 h-14 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors group", title: "Decline", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("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" }) }) }), _jsx("button", { onClick: handleAccept, className: "w-14 h-14 rounded-full bg-discord-green hover:bg-discord-green/80 flex items-center justify-center transition-colors group", title: "Accept", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("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" }) }) })] })] })] })] }));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
|
||||
export function IncomingCallModal() {
|
||||
const incomingCall = useVoiceStore((s) => s.incomingCall);
|
||||
const setIncomingCall = useVoiceStore((s) => s.setIncomingCall);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Auto-reject after timeout
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [incomingCall, setIncomingCall]);
|
||||
|
||||
if (!incomingCall) return null;
|
||||
|
||||
const handleAccept = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
|
||||
{/* Call card */}
|
||||
<div className="relative bg-[#1e1f22] rounded-lg shadow-2xl w-[340px] overflow-hidden">
|
||||
{/* Ring animation background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-discord-green/5 animate-ping" style={{ animationDuration: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-discord-green/10 animate-ping" style={{ animationDuration: '2s', animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-8 flex flex-col items-center gap-4">
|
||||
{/* Caller avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full bg-discord-blurple flex items-center justify-center text-white text-3xl font-bold">
|
||||
{incomingCall.callerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/* Ringing phone icon */}
|
||||
<div className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-discord-green flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Caller info */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-[20px] font-bold text-discord-text-header">{incomingCall.callerName}</h3>
|
||||
<p className="text-[14px] text-discord-text-muted mt-1">Incoming Voice Call...</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-6 mt-2">
|
||||
{/* Decline */}
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
className="w-14 h-14 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors group"
|
||||
title="Decline"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
|
||||
<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>
|
||||
|
||||
{/* Accept */}
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="w-14 h-14 rounded-full bg-discord-green hover:bg-discord-green/80 flex items-center justify-center transition-colors group"
|
||||
title="Accept"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { VideoPreset } from 'livekit-client';
|
||||
const PRESETS = [
|
||||
{ value: 'auto', label: 'Auto', desc: 'Adjusts to your connection' },
|
||||
{ value: '1080p60', label: '1080p 60fps', desc: '1920x1080, 15000 kbps' },
|
||||
{ value: '1080p', label: '1080p 30fps', desc: '1920x1080, 8000 kbps' },
|
||||
{ value: '720p60', label: '720p 60fps', desc: '1280x720, 8000 kbps' },
|
||||
{ value: '720p', label: '720p 30fps', desc: '1280x720, 5000 kbps' },
|
||||
{ value: '540p', label: '540p 30fps', desc: '960x540, 2000 kbps' },
|
||||
{ value: '360p', label: '360p 30fps', desc: '640x360, 1000 kbps' },
|
||||
];
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
export function VideoQualityPopover({ open, onClose, anchorRect }) {
|
||||
const popoverRef = useRef(null);
|
||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||
const setVideoQuality = useVoiceStore((s) => s.setVideoQuality);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return;
|
||||
const handleClick = (e) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open, onClose]);
|
||||
if (!open)
|
||||
return null;
|
||||
const handleSelect = async (quality) => {
|
||||
setVideoQuality(quality);
|
||||
onClose();
|
||||
};
|
||||
return (_jsxs("div", { ref: popoverRef, className: "absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[240px] bg-[#2b2d31] rounded-lg shadow-lg border border-[#1e1f22] z-50 overflow-hidden", children: [_jsx("div", { className: "px-3 py-2 border-b border-[#1e1f22]", children: _jsx("span", { className: "text-[14px] font-bold text-discord-text-primary", children: "Video Quality" }) }), _jsx("div", { className: "py-1", children: PRESETS.map((preset) => (_jsxs("button", { onClick: () => handleSelect(preset.value), className: `w-full px-3 py-2 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors ${videoQuality === preset.value ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [_jsxs("div", { className: "text-left", children: [_jsx("div", { className: "text-[14px] font-medium", children: preset.label }), _jsx("div", { className: "text-[12px] text-discord-text-muted", children: preset.desc })] }), videoQuality === preset.value && (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0 ml-2", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }))] }, preset.value))) })] }));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
||||
|
||||
interface VideoQualityPopoverProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRect?: DOMRect | null;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ value: 'auto' as const, label: 'Auto', desc: 'Adjusts to your connection' },
|
||||
{ value: '1080p60' as const, label: '1080p 60fps', desc: '1920x1080, 15000 kbps' },
|
||||
{ value: '1080p' as const, label: '1080p 30fps', desc: '1920x1080, 8000 kbps' },
|
||||
{ value: '720p60' as const, label: '720p 60fps', desc: '1280x720, 8000 kbps' },
|
||||
{ value: '720p' as const, label: '720p 30fps', desc: '1280x720, 5000 kbps' },
|
||||
{ value: '540p' as const, label: '540p 30fps', desc: '960x540, 2000 kbps' },
|
||||
{ value: '360p' as const, label: '360p 30fps', desc: '640x360, 1000 kbps' },
|
||||
] as const;
|
||||
|
||||
const QUALITY_MAP: Record<string, VideoPreset> = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
|
||||
export function VideoQualityPopover({ open, onClose, anchorRect }: VideoQualityPopoverProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||
const setVideoQuality = useVoiceStore((s) => s.setVideoQuality);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSelect = async (quality: typeof videoQuality) => {
|
||||
setVideoQuality(quality);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[240px] bg-[#2b2d31] rounded-lg shadow-lg border border-[#1e1f22] z-50 overflow-hidden"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-[#1e1f22]">
|
||||
<span className="text-[14px] font-bold text-discord-text-primary">Video Quality</span>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => handleSelect(preset.value)}
|
||||
className={`w-full px-3 py-2 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors ${
|
||||
videoQuality === preset.value ? 'text-discord-text-primary' : 'text-discord-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-[14px] font-medium">{preset.label}</div>
|
||||
<div className="text-[12px] text-discord-text-muted">{preset.desc}</div>
|
||||
</div>
|
||||
{videoQuality === preset.value && (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0 ml-2">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { MessageList } from '../chat/MessageList';
|
||||
import { MessageInput } from '../chat/MessageInput';
|
||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function VoiceChatPanel({ channelId, channelName }) {
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
return (_jsxs("div", { className: "w-[340px] flex-shrink-0 bg-discord-bg-primary flex flex-col border-l border-[#2b2d31]", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0", children: [_jsx("span", { className: "font-bold text-discord-text-primary text-[16px]", children: "Chat" }), _jsx("button", { onClick: toggleVoiceChat, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Close Chat", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] }), _jsx(MessageList, { channelId: channelId }), _jsx(TypingIndicator, { channelId: channelId }), _jsx(MessageInput, { channelId: channelId, channelName: channelName })] }));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { MessageList } from '../chat/MessageList';
|
||||
import { MessageInput } from '../chat/MessageInput';
|
||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
interface VoiceChatPanelProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export function VoiceChatPanel({ channelId, channelName }: VoiceChatPanelProps) {
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
|
||||
return (
|
||||
<div className="w-[340px] flex-shrink-0 bg-discord-bg-primary flex flex-col border-l border-[#2b2d31]">
|
||||
{/* Chat header */}
|
||||
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0">
|
||||
<span className="font-bold text-discord-text-primary text-[16px]">Chat</span>
|
||||
<button
|
||||
onClick={toggleVoiceChat}
|
||||
className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded"
|
||||
title="Close Chat"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<MessageList channelId={channelId} />
|
||||
|
||||
{/* Typing indicator */}
|
||||
<TypingIndicator channelId={channelId} />
|
||||
|
||||
{/* Input */}
|
||||
<MessageInput channelId={channelId} channelName={channelName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { VideoPreset } from 'livekit-client';
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
export function VoiceControlBar() {
|
||||
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 toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const handleMute = React.useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle mic:', err);
|
||||
}
|
||||
}
|
||||
toggleMic();
|
||||
}, [isMuted, toggleMic]);
|
||||
const handleDeafen = React.useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
const willDeafen = !isDeafened;
|
||||
if (willDeafen) {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
room.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||
if (!isMuted)
|
||||
toggleMic();
|
||||
}
|
||||
else {
|
||||
const outputVolume = useVoiceStore.getState().outputVolume;
|
||||
room.remoteParticipants.forEach((p) => p.setVolume(outputVolume / 100));
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
if (isMuted)
|
||||
toggleMic();
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
|
||||
}
|
||||
}
|
||||
toggleDeafen();
|
||||
}, [isDeafened, isMuted, toggleDeafen, toggleMic]);
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
const willEnable = !isCameraOn;
|
||||
if (willEnable) {
|
||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||
const preset = 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();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
||||
}
|
||||
};
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
toggleScreenShare();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||
}
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
if (voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleFullscreen = () => {
|
||||
if (!voiceFullscreen) {
|
||||
document.documentElement.requestFullscreen?.().catch(() => { });
|
||||
}
|
||||
else {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
}
|
||||
toggleVoiceFullscreen();
|
||||
};
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
||||
return;
|
||||
if (e.key === 'm' || e.key === 'M') {
|
||||
e.preventDefault();
|
||||
handleMute();
|
||||
}
|
||||
else if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault();
|
||||
handleDeafen();
|
||||
}
|
||||
else if (e.key === 'Escape' && voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleMute, handleDeafen, voiceFullscreen]);
|
||||
return (_jsxs("div", { className: "h-[72px] bg-[#1a1b1e] flex items-center justify-center gap-2 px-4 flex-shrink-0", children: [_jsx("button", { onClick: handleMute, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isMuted || isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute (M)' : 'Mute (M)', children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), (isMuted || isDeafened) && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleDeafen, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen (D)' : 'Deafen (D)', children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleCamera, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isCameraOn
|
||||
? 'bg-[#2b2d31] text-discord-green hover:bg-[#36373d]'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isScreenSharing
|
||||
? 'bg-[#2b2d31] text-discord-green hover:bg-[#36373d]'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) }), _jsxs("div", { className: "relative", children: [_jsx("button", { onClick: () => setQualityOpen(!qualityOpen), className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${qualityOpen
|
||||
? 'bg-[#2b2d31] text-discord-text-primary'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: "Video Quality", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) }), _jsx(VideoQualityPopover, { open: qualityOpen, onClose: () => setQualityOpen(false) })] }), _jsx("div", { className: "w-[1px] h-8 bg-[#3f4147] mx-1" }), _jsx("button", { onClick: toggleVoiceChat, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${voiceChatOpen
|
||||
? 'bg-[#2b2d31] text-discord-text-primary'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: "Toggle Chat", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z" }) }) }), _jsx("button", { onClick: handleFullscreen, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${voiceFullscreen
|
||||
? 'bg-[#2b2d31] text-discord-text-primary'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: voiceFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen', children: voiceFullscreen ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })) }), _jsx("div", { className: "w-[1px] h-8 bg-[#3f4147] mx-1" }), _jsx("button", { onClick: handleDisconnect, className: "w-12 h-12 flex items-center justify-center rounded-full bg-discord-red hover:bg-discord-red-hover transition-colors text-white", title: "Disconnect", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] }));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
||||
|
||||
const QUALITY_MAP: Record<string, any> = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
|
||||
export function VoiceControlBar() {
|
||||
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 toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
|
||||
const handleMute = React.useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
} catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle mic:', err);
|
||||
}
|
||||
}
|
||||
toggleMic();
|
||||
}, [isMuted, toggleMic]);
|
||||
|
||||
const handleDeafen = React.useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
const willDeafen = !isDeafened;
|
||||
if (willDeafen) {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
room.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||
if (!isMuted) toggleMic();
|
||||
} else {
|
||||
const outputVolume = useVoiceStore.getState().outputVolume;
|
||||
room.remoteParticipants.forEach((p) => p.setVolume(outputVolume / 100));
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
if (isMuted) toggleMic();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
|
||||
}
|
||||
}
|
||||
toggleDeafen();
|
||||
}, [isDeafened, isMuted, toggleDeafen, toggleMic]);
|
||||
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) return;
|
||||
try {
|
||||
const willEnable = !isCameraOn;
|
||||
if (willEnable) {
|
||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||
const preset = 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();
|
||||
} catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) return;
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
toggleScreenShare();
|
||||
} catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
if (voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (!voiceFullscreen) {
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
} else {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
toggleVoiceFullscreen();
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'm' || e.key === 'M') {
|
||||
e.preventDefault();
|
||||
handleMute();
|
||||
} else if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault();
|
||||
handleDeafen();
|
||||
} else if (e.key === 'Escape' && voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleMute, handleDeafen, voiceFullscreen]);
|
||||
|
||||
return (
|
||||
<div className="h-[72px] bg-[#1a1b1e] flex items-center justify-center gap-2 px-4 flex-shrink-0">
|
||||
{/* Mute */}
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
isMuted || isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isMuted ? 'Unmute (M)' : 'Mute (M)'}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
{(isMuted || isDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Deafen */}
|
||||
<button
|
||||
onClick={handleDeafen}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Camera */}
|
||||
<button
|
||||
onClick={handleCamera}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
isCameraOn
|
||||
? 'bg-[#2b2d31] text-discord-green hover:bg-[#36373d]'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
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.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Screen Share */}
|
||||
<button
|
||||
onClick={handleScreenShare}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
isScreenSharing
|
||||
? 'bg-[#2b2d31] text-discord-green hover:bg-[#36373d]'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
||||
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Video Quality */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setQualityOpen(!qualityOpen)}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
qualityOpen
|
||||
? 'bg-[#2b2d31] text-discord-text-primary'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
title="Video Quality"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<VideoQualityPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-[1px] h-8 bg-[#3f4147] mx-1" />
|
||||
|
||||
{/* Chat Toggle */}
|
||||
<button
|
||||
onClick={toggleVoiceChat}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
voiceChatOpen
|
||||
? 'bg-[#2b2d31] text-discord-text-primary'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
title="Toggle Chat"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Fullscreen Toggle */}
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-full transition-colors ${
|
||||
voiceFullscreen
|
||||
? 'bg-[#2b2d31] text-discord-text-primary'
|
||||
: 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={voiceFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen'}
|
||||
>
|
||||
{voiceFullscreen ? (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-[1px] h-8 bg-[#3f4147] mx-1" />
|
||||
|
||||
{/* Disconnect */}
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="w-12 h-12 flex items-center justify-center rounded-full bg-discord-red hover:bg-discord-red-hover transition-colors text-white"
|
||||
title="Disconnect"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
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 toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
@@ -16,12 +20,54 @@ export function VoiceControls() {
|
||||
return null;
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? 'Voice Channel';
|
||||
const handleMute = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle mic:', err);
|
||||
}
|
||||
}
|
||||
toggleMic();
|
||||
};
|
||||
const handleDeafen = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
const willDeafen = !isDeafened;
|
||||
if (willDeafen) {
|
||||
// Deafen: mute mic + mute all remote audio
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
room.remoteParticipants.forEach((p) => {
|
||||
p.setVolume(0);
|
||||
});
|
||||
if (!isMuted)
|
||||
toggleMic(); // Also mute mic when deafening
|
||||
}
|
||||
else {
|
||||
// Undeafen: restore remote audio, unmute mic
|
||||
const outputVolume = useVoiceStore.getState().outputVolume;
|
||||
const scaled = outputVolume / 100;
|
||||
room.remoteParticipants.forEach((p) => {
|
||||
p.setVolume(scaled);
|
||||
});
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
if (isMuted)
|
||||
toggleMic(); // Unmute mic when undeafening
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle deafen:', err);
|
||||
}
|
||||
}
|
||||
toggleDeafen();
|
||||
};
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
console.warn('[VoiceControls] handleCamera: no active room');
|
||||
if (!room)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await room.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
toggleCamera();
|
||||
@@ -32,10 +78,8 @@ export function VoiceControls() {
|
||||
};
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
console.warn('[VoiceControls] handleScreenShare: no active room');
|
||||
if (!room)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
toggleScreenShare();
|
||||
@@ -48,5 +92,23 @@ export function VoiceControls() {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
};
|
||||
return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary px-2 py-[10px]", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-discord-yellow'}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate leading-[18px]", children: connectionError ? connectionError : channelName })] }), _jsx("button", { onClick: handleDisconnect, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-colors flex-shrink-0", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] }), _jsxs("div", { className: "flex items-center justify-center gap-1", children: [_jsx("button", { onClick: handleCamera, className: `w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors ${isCameraOn ? 'text-discord-green bg-discord-green/10 hover:bg-discord-green/20' : 'text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors ${isScreenSharing ? 'text-discord-green bg-discord-green/10 hover:bg-discord-green/20' : 'text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) })] })] }));
|
||||
const statusColor = connectionError
|
||||
? 'text-discord-red'
|
||||
: isLiveKitConnected
|
||||
? 'text-discord-green'
|
||||
: 'text-discord-yellow';
|
||||
const statusBgColor = connectionError
|
||||
? 'bg-discord-red/20'
|
||||
: isLiveKitConnected
|
||||
? 'bg-discord-green/20'
|
||||
: 'bg-discord-yellow/20';
|
||||
return (_jsxs("div", { className: "bg-[#232428] border-t border-discord-bg-tertiary", children: [_jsxs("div", { className: "flex items-center gap-2 px-2 pt-[10px] pb-1", children: [_jsx("div", { className: `w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`, children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: statusColor, children: _jsx("path", { d: "M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" }) }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${statusColor}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-[16px]", children: connectionError ? connectionError : channelName })] }), _jsxs("div", { className: "flex items-center gap-0.5 flex-shrink-0", children: [_jsx("button", { className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Connection Info", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" }) }) }), _jsx("button", { onClick: handleDisconnect, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Disconnect", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }), _jsxs("div", { className: "flex items-center gap-1 px-2 pb-[10px] pt-1", children: [_jsx("button", { onClick: handleMute, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isMuted || isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isMuted ? 'Unmute' : 'Mute', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), (isMuted || isDeafened) && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleDeafen, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleCamera, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isCameraOn
|
||||
? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isScreenSharing
|
||||
? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) })] })] }));
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ import { wsSend } from '../../hooks/useWebSocket';
|
||||
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
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 toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
@@ -19,6 +23,47 @@ export function VoiceControls() {
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? 'Voice Channel';
|
||||
|
||||
const handleMute = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
} catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle mic:', err);
|
||||
}
|
||||
}
|
||||
toggleMic();
|
||||
};
|
||||
|
||||
const handleDeafen = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
const willDeafen = !isDeafened;
|
||||
if (willDeafen) {
|
||||
// Deafen: mute mic + mute all remote audio
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
room.remoteParticipants.forEach((p) => {
|
||||
p.setVolume(0);
|
||||
});
|
||||
if (!isMuted) toggleMic(); // Also mute mic when deafening
|
||||
} else {
|
||||
// Undeafen: restore remote audio, unmute mic
|
||||
const outputVolume = useVoiceStore.getState().outputVolume;
|
||||
const scaled = outputVolume / 100;
|
||||
room.remoteParticipants.forEach((p) => {
|
||||
p.setVolume(scaled);
|
||||
});
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
if (isMuted) toggleMic(); // Unmute mic when undeafening
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle deafen:', err);
|
||||
}
|
||||
}
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) return;
|
||||
@@ -60,16 +105,14 @@ export function VoiceControls() {
|
||||
|
||||
return (
|
||||
<div className="bg-[#232428] border-t border-discord-bg-tertiary">
|
||||
{/* Row 1: Signal icon + status text + right icons */}
|
||||
{/* Row 1: Signal icon + status text + disconnect */}
|
||||
<div className="flex items-center gap-2 px-2 pt-[10px] pb-1">
|
||||
{/* Signal icon */}
|
||||
<div className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}>
|
||||
<path d="M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
||||
@@ -79,15 +122,12 @@ export function VoiceControls() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right icons */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{/* Signal quality */}
|
||||
<button className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded" title="Connection Info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Disconnect */}
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded"
|
||||
@@ -100,8 +140,41 @@ export function VoiceControls() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Media control buttons */}
|
||||
{/* Row 2: Mute, Deafen, Camera, Screen Share */}
|
||||
<div className="flex items-center gap-1 px-2 pb-[10px] pt-1">
|
||||
{/* Mute */}
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
||||
isMuted || isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
{(isMuted || isDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Deafen */}
|
||||
<button
|
||||
onClick={handleDeafen}
|
||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
||||
isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Camera */}
|
||||
<button
|
||||
onClick={handleCamera}
|
||||
@@ -139,26 +212,6 @@ export function VoiceControls() {
|
||||
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Noise Suppression */}
|
||||
<button
|
||||
className="flex-1 h-[34px] flex items-center justify-center rounded-[4px] bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary transition-colors"
|
||||
title="Noise Suppression"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L9.19 8.63L2 9.24L7.46 13.97L5.82 21L12 17.27L18.18 21L16.54 13.97L22 9.24L14.81 8.63L12 2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Activities */}
|
||||
<button
|
||||
className="flex-1 h-[34px] flex items-center justify-center rounded-[4px] bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary transition-colors"
|
||||
title="Activities"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.5 2C5.01 2 3 4.01 3 6.5C3 8.99 5.01 11 7.5 11S12 8.99 12 6.5C12 4.01 9.99 2 7.5 2ZM16.5 2C14.01 2 12 4.01 12 6.5C12 8.99 14.01 11 16.5 11S21 8.99 21 6.5C21 4.01 18.99 2 16.5 2ZM7.5 13C5.01 13 3 15.01 3 17.5S5.01 22 7.5 22 12 19.99 12 17.5 9.99 13 7.5 13ZM16.5 13C14.01 13 12 15.01 12 17.5S14.01 22 16.5 22 21 19.99 21 17.5 18.99 13 16.5 13Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { VoiceUser } from './VoiceUser';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
export function VoiceGrid({ participants }) {
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||
if (participants.length === 0) {
|
||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "No one is in this voice channel" }) }));
|
||||
}
|
||||
const focusedParticipant = focusedParticipantId
|
||||
? participants.find(p => p.identity === focusedParticipantId)
|
||||
: null;
|
||||
// Focus mode: one large tile + sidebar strip
|
||||
if (focusedParticipant) {
|
||||
const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId);
|
||||
return (_jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsx("div", { className: "flex-1 p-2", onDoubleClick: () => setFocusedParticipant(null), children: _jsx(VoiceUser, { participant: focusedParticipant, large: true }) }), otherParticipants.length > 0 && (_jsx("div", { className: "w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2", children: otherParticipants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }))] }));
|
||||
}
|
||||
// Default grid mode
|
||||
const gridClass = (() => {
|
||||
if (participants.length === 1)
|
||||
return 'grid-cols-1 max-w-2xl mx-auto';
|
||||
@@ -11,7 +23,9 @@ export function VoiceGrid({ participants }) {
|
||||
return 'grid-cols-2 max-w-4xl mx-auto';
|
||||
if (participants.length <= 4)
|
||||
return 'grid-cols-2';
|
||||
return 'grid-cols-3';
|
||||
if (participants.length <= 9)
|
||||
return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
})();
|
||||
return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx(VoiceUser, { participant: p }, p.identity))) }) }));
|
||||
return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }) }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { VoiceUser } from './VoiceUser';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||
|
||||
interface VoiceGridProps {
|
||||
@@ -7,6 +8,9 @@ interface VoiceGridProps {
|
||||
}
|
||||
|
||||
export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
|
||||
@@ -15,18 +19,61 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const focusedParticipant = focusedParticipantId
|
||||
? participants.find(p => p.identity === focusedParticipantId)
|
||||
: null;
|
||||
|
||||
// Focus mode: one large tile + sidebar strip
|
||||
if (focusedParticipant) {
|
||||
const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId);
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main focused view */}
|
||||
<div
|
||||
className="flex-1 p-2"
|
||||
onDoubleClick={() => setFocusedParticipant(null)}
|
||||
>
|
||||
<VoiceUser participant={focusedParticipant} large />
|
||||
</div>
|
||||
|
||||
{/* Side strip of other participants */}
|
||||
{otherParticipants.length > 0 && (
|
||||
<div className="w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2">
|
||||
{otherParticipants.map((p) => (
|
||||
<div
|
||||
key={p.identity}
|
||||
onClick={() => setFocusedParticipant(p.identity)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<VoiceUser participant={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default grid mode
|
||||
const gridClass = (() => {
|
||||
if (participants.length === 1) return 'grid-cols-1 max-w-2xl mx-auto';
|
||||
if (participants.length === 2) return 'grid-cols-2 max-w-4xl mx-auto';
|
||||
if (participants.length <= 4) return 'grid-cols-2';
|
||||
return 'grid-cols-3';
|
||||
if (participants.length <= 9) return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
<div className={`grid ${gridClass} gap-2 h-full`}>
|
||||
{participants.map((p) => (
|
||||
<VoiceUser key={p.identity} participant={p} />
|
||||
<div
|
||||
key={p.identity}
|
||||
onClick={() => setFocusedParticipant(p.identity)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<VoiceUser participant={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
export function VoiceUser({ participant }) {
|
||||
export function VoiceUser({ participant, large }) {
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl)
|
||||
@@ -26,14 +29,42 @@ export function VoiceUser({ participant }) {
|
||||
const stream = new MediaStream([participant.audioTrack]);
|
||||
audioEl.srcObject = stream;
|
||||
}, [participant.audioTrack]);
|
||||
// Mute remote audio when deafened
|
||||
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (audioEl) {
|
||||
audioEl.muted = isDeafened;
|
||||
if (!audioEl)
|
||||
return;
|
||||
if (isDeafened) {
|
||||
audioEl.volume = 0;
|
||||
}
|
||||
}, [isDeafened]);
|
||||
else {
|
||||
// Both are 0-200 scale with 100 = default. Combine as fractions.
|
||||
const combined = (outputVolume / 100) * (perUserVolume / 100);
|
||||
audioEl.volume = Math.min(Math.max(combined, 0), 1);
|
||||
}
|
||||
audioEl.muted = isDeafened;
|
||||
}, [isDeafened, outputVolume, perUserVolume]);
|
||||
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
|
||||
const isLocal = participant.isLocal;
|
||||
return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${participant.isSpeaking ? 'ring-2 ring-discord-green' : ''}`, style: { aspectRatio: '16/9', minHeight: '200px' }, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-cover" })) : (_jsx(Avatar, { src: null, name: participant.username, size: 80 })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-medium text-white", children: participant.username }), _jsx("div", { className: "flex items-center gap-1", children: participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) })] }) }), participant.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" }))] }));
|
||||
// Volume context menu
|
||||
const [volumeMenu, setVolumeMenu] = useState(null);
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
const handleContextMenu = useCallback((e) => {
|
||||
if (isLocal)
|
||||
return; // No volume control for self
|
||||
e.preventDefault();
|
||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||
}, [isLocal]);
|
||||
// Close volume menu on click outside
|
||||
useEffect(() => {
|
||||
if (!volumeMenu)
|
||||
return;
|
||||
const close = () => setVolumeMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [volumeMenu]);
|
||||
return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center transition-all ${participant.isSpeaking ? 'ring-[3px] ring-discord-green' : 'ring-1 ring-transparent'} ${large ? 'h-full' : ''}`, style: large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain' : 'object-cover'}`, style: {
|
||||
imageRendering: 'crisp-edges',
|
||||
WebkitFontSmoothing: 'antialiased'
|
||||
} })) : (_jsx("div", { className: "flex flex-col items-center justify-center gap-2", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 80 }) })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: `font-medium text-white ${large ? 'text-base' : 'text-sm'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/50 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), participant.isScreenSharing && (_jsx("div", { className: "w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" }) }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] }));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||
|
||||
interface VoiceUserProps {
|
||||
participant: ParticipantInfo;
|
||||
large?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceUser({ participant }: VoiceUserProps) {
|
||||
export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
@@ -33,23 +38,48 @@ export function VoiceUser({ participant }: VoiceUserProps) {
|
||||
audioEl.srcObject = stream;
|
||||
}, [participant.audioTrack]);
|
||||
|
||||
// Mute remote audio when deafened
|
||||
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (audioEl) {
|
||||
audioEl.muted = isDeafened;
|
||||
if (!audioEl) return;
|
||||
if (isDeafened) {
|
||||
audioEl.volume = 0;
|
||||
} else {
|
||||
// Both are 0-200 scale with 100 = default. Combine as fractions.
|
||||
const combined = (outputVolume / 100) * (perUserVolume / 100);
|
||||
audioEl.volume = Math.min(Math.max(combined, 0), 1);
|
||||
}
|
||||
}, [isDeafened]);
|
||||
audioEl.muted = isDeafened;
|
||||
}, [isDeafened, outputVolume, perUserVolume]);
|
||||
|
||||
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
|
||||
const isLocal = participant.isLocal;
|
||||
|
||||
// Volume context menu
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (isLocal) return; // No volume control for self
|
||||
e.preventDefault();
|
||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||
}, [isLocal]);
|
||||
|
||||
// Close volume menu on click outside
|
||||
useEffect(() => {
|
||||
if (!volumeMenu) return;
|
||||
const close = () => setVolumeMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [volumeMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${
|
||||
participant.isSpeaking ? 'ring-2 ring-discord-green' : ''
|
||||
}`}
|
||||
style={{ aspectRatio: '16/9', minHeight: '200px' }}
|
||||
className={`relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center transition-all ${
|
||||
participant.isSpeaking ? 'ring-[3px] ring-discord-green' : 'ring-1 ring-transparent'
|
||||
} ${large ? 'h-full' : ''}`}
|
||||
style={large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Audio element for remote participants */}
|
||||
{!isLocal && <audio ref={audioRef} autoPlay />}
|
||||
@@ -60,20 +90,31 @@ export function VoiceUser({ participant }: VoiceUserProps) {
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={isLocal}
|
||||
className="w-full h-full object-cover"
|
||||
className={`w-full h-full ${large ? 'object-contain' : 'object-cover'}`}
|
||||
style={{
|
||||
imageRendering: 'crisp-edges',
|
||||
WebkitFontSmoothing: 'antialiased'
|
||||
} as any}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
src={null}
|
||||
name={participant.username}
|
||||
size={80}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Avatar
|
||||
src={null}
|
||||
name={participant.username}
|
||||
size={large ? 100 : 80}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{participant.username}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-medium text-white ${large ? 'text-base' : 'text-sm'}`}>{participant.username}</span>
|
||||
{isLocal && (
|
||||
<span className="text-[10px] text-white/50 font-medium">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{participant.isMuted && (
|
||||
<div className="w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center">
|
||||
@@ -83,13 +124,44 @@ export function VoiceUser({ participant }: VoiceUserProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{participant.isScreenSharing && (
|
||||
<div className="w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speaking indicator */}
|
||||
{participant.isSpeaking && (
|
||||
<div className="absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" />
|
||||
{/* Per-participant volume menu (right-click) */}
|
||||
{volumeMenu && !isLocal && (
|
||||
<div
|
||||
className="fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px]"
|
||||
style={{ left: volumeMenu.x, top: volumeMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider">
|
||||
User Volume
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={perUserVolume}
|
||||
onChange={(e) => setParticipantVolume(participant.userId, parseInt(e.target.value))}
|
||||
className="flex-1 accent-discord-blurple h-1"
|
||||
/>
|
||||
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">
|
||||
{perUserVolume}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user