diff --git a/frontend/components/video-conference/video-room.js b/frontend/components/video-conference/video-room.js index bf71693..e2b0da7 100644 --- a/frontend/components/video-conference/video-room.js +++ b/frontend/components/video-conference/video-room.js @@ -1,11 +1,15 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Avatar } from "@/components/ui/avatar" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { ScrollArea } from "@/components/ui/scroll-area" import { toast } from "sonner" +import { useTheme } from "next-themes" import { Video, VideoOff, @@ -14,25 +18,29 @@ import { Phone, MessageSquare, Users, - ScreenShare, StopCircle, Copy, - X, - LinkIcon, - Share2, + Check, + Maximize, + Minimize, + Volume2, + VolumeX, + Share, } from "lucide-react" import { useAuth } from "@/lib/auth" // WebRTC configuration -const ICE_SERVERS = { - iceServers: [ - { urls: "stun:stun.l.google.com:19302" }, - { urls: "stun:stun1.l.google.com:19302" }, - { urls: "stun:stun2.l.google.com:19302" }, - ], -} +const ICE_SERVERS = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" }, +] export function VideoConferenceRoom({ roomId, onClose }) { + const router = useRouter() + const { theme } = useTheme() const { user } = useAuth() const [isCameraOn, setIsCameraOn] = useState(true) const [isMicOn, setIsMicOn] = useState(true) @@ -47,6 +55,10 @@ export function VideoConferenceRoom({ roomId, onClose }) { const [screenStream, setScreenStream] = useState(null) const [peerConnections, setPeerConnections] = useState({}) const [remoteStreams, setRemoteStreams] = useState({}) + const [activeTab, setActiveTab] = useState("video") + const [linkCopied, setLinkCopied] = useState(false) + const [isFullScreen, setIsFullScreen] = useState(false) + const [isMuted, setIsMuted] = useState({}) const localVideoRef = useRef(null) const screenShareRef = useRef(null) @@ -54,6 +66,8 @@ export function VideoConferenceRoom({ roomId, onClose }) { const peerConnectionsRef = useRef({}) const localStreamRef = useRef(null) const screenStreamRef = useRef(null) + const chatContainerRef = useRef(null) + const videoContainerRef = useRef(null) // Generate meeting link useEffect(() => { @@ -64,302 +78,141 @@ export function VideoConferenceRoom({ roomId, onClose }) { // Initialize WebSocket connection useEffect(() => { - // Use secure WebSocket if on HTTPS - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" - const wsUrl = `${protocol}//${window.location.host}/ws/video-conference/${roomId}/` - - // For development with separate backend - // const wsUrl = `ws://localhost:8000/ws/video-conference/${roomId}/`; - - console.log("Connecting to WebSocket:", wsUrl) - - socketRef.current = new WebSocket(wsUrl) - - socketRef.current.onopen = () => { - console.log("WebSocket connection established") - // Join the room - sendToSignalingServer({ - type: "join", - roomId, - userId: user?.id || "anonymous", - userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", - userAvatar: user?.image || "/placeholder.svg", - }) - } - - socketRef.current.onmessage = (event) => { - const message = JSON.parse(event.data) - console.log("WebSocket message received:", message) - - handleSignalingMessage(message) - } - - socketRef.current.onerror = (error) => { - console.error("WebSocket error:", error) - toast.error("Connection error. Please try again.") - } - - socketRef.current.onclose = () => { - console.log("WebSocket connection closed") - } - - return () => { - // Clean up WebSocket connection - if (socketRef.current) { - socketRef.current.close() - } - } - }, [roomId, user]) - - // Initialize local media stream - useEffect(() => { - const initLocalStream = async () => { + const initializeConference = async () => { try { + // Get local media stream const stream = await navigator.mediaDevices.getUserMedia({ video: isCameraOn, audio: isMicOn, }) + localStreamRef.current = stream + if (localVideoRef.current) { localVideoRef.current.srcObject = stream } - setLocalStream(stream) - localStreamRef.current = stream - // Add current user to participants - const currentUser = { - id: user?.id || "current-user", - name: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "You", - isHost: true, - isMicOn, - isCameraOn, - avatar: user?.image || "/placeholder.svg", - isLocal: true, + setParticipants([ + { + id: user?.id || "current-user", + name: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "You", + avatar: user?.image || "/ai-avatar.png", + isLocal: true, + isMicOn, + isCameraOn, + }, + ]) + + // Connect to signaling server + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const wsHost = process.env.NEXT_PUBLIC_API_URL || window.location.host + const wsUrl = `${wsProtocol}//${wsHost}/ws/video-conference/${roomId}/` + + socketRef.current = new WebSocket(wsUrl) + + socketRef.current.onopen = () => { + console.log("WebSocket connection established") + // Join the room + sendToSignalingServer({ + type: "join", + userId: user?.id || "anonymous", + userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", + userAvatar: user?.image || "/ai-avatar.png", + }) } - setParticipants((prev) => { - // Check if user already exists - if (prev.some((p) => p.id === currentUser.id)) { - return prev.map((p) => (p.id === currentUser.id ? { ...p, isMicOn, isCameraOn } : p)) - } - return [currentUser, ...prev] - }) + socketRef.current.onmessage = (event) => { + const message = JSON.parse(event.data) + handleSignalingMessage(message) + } - // If we already have peer connections, update the tracks - Object.values(peerConnectionsRef.current).forEach((pc) => { - replaceTracksInPeerConnection(pc, stream) - }) - } catch (err) { - console.error("Error accessing media devices:", err) - toast.error("Could not access camera or microphone") - setIsCameraOn(false) - setIsMicOn(false) + socketRef.current.onerror = (error) => { + console.error("WebSocket error:", error) + toast.error("Connection error. Please try again.") + } + + socketRef.current.onclose = () => { + console.log("WebSocket connection closed") + // Attempt to reconnect after a delay + setTimeout(() => { + if (document.visibilityState !== "hidden") { + initializeConference() + } + }, 3000) + } + + // Join the room via API + // await apiClient.post(`/api/video-conference/${roomId}/join/`); + } catch (error) { + console.error("Error initializing conference:", error) + toast.error("Could not access camera or microphone. Please check permissions.") } } - initLocalStream() + initializeConference() return () => { - // Clean up local stream + // Stop all media tracks if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((track) => track.stop()) } - } - }, [isCameraOn, isMicOn, user]) - - // Handle screen sharing - const handleScreenShare = async () => { - if (!isScreenSharing) { - try { - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: true, - }) - - if (screenShareRef.current) { - screenShareRef.current.srcObject = stream - } - - setScreenStream(stream) - screenStreamRef.current = stream - setIsScreenSharing(true) - - // Send screen share stream to all peers - Object.values(peerConnectionsRef.current).forEach((pc) => { - stream.getTracks().forEach((track) => { - pc.addTrack(track, stream) - }) - }) - - // Listen for the end of screen sharing - stream.getVideoTracks()[0].onended = () => { - stopScreenSharing() - } - // Notify others that we're screen sharing - sendToSignalingServer({ - type: "screen-share-started", - roomId, - userId: user?.id || "anonymous", - }) - } catch (err) { - console.error("Error sharing screen:", err) - toast.error("Could not share screen") + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((track) => track.stop()) } - } else { - stopScreenSharing() - } - } - - const stopScreenSharing = () => { - if (screenStreamRef.current) { - screenStreamRef.current.getTracks().forEach((track) => track.stop()) - // Remove screen share tracks from peer connections + // Close all peer connections Object.values(peerConnectionsRef.current).forEach((pc) => { - pc.getSenders().forEach((sender) => { - if (screenStreamRef.current.getTracks().includes(sender.track)) { - pc.removeTrack(sender) - } - }) - }) - - screenStreamRef.current = null - setScreenStream(null) - setIsScreenSharing(false) - - // Notify others that we've stopped screen sharing - sendToSignalingServer({ - type: "screen-share-stopped", - roomId, - userId: user?.id || "anonymous", + if (pc) pc.close() }) - } - } - - // Handle sending chat messages - const handleSendMessage = () => { - if (!messageInput.trim()) return - - const newMessage = { - id: Date.now(), - sender: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "You", - senderId: user?.id || "anonymous", - content: messageInput, - timestamp: new Date(), - isLocal: true, - } - setChatMessages((prev) => [...prev, newMessage]) - setMessageInput("") - - // Send message to signaling server - sendToSignalingServer({ - type: "chat-message", - roomId, - userId: user?.id || "anonymous", - userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", - message: messageInput, - timestamp: new Date().toISOString(), - }) - } - - // Handle copying meeting link - const handleCopyLink = () => { - navigator.clipboard.writeText(meetingLink) - toast.success("Meeting link copied to clipboard") - } - - // Handle sharing meeting link - const handleShareLink = () => { - if (navigator.share) { - navigator - .share({ - title: "Join my Volt meeting", - text: "Click the link to join my video conference", - url: meetingLink, - }) - .then(() => toast.success("Meeting link shared")) - .catch((error) => console.error("Error sharing:", error)) - } else { - handleCopyLink() - } - } - - // Handle leaving the meeting - const handleLeaveMeeting = () => { - // Notify others that we're leaving - sendToSignalingServer({ - type: "leave", - roomId, - userId: user?.id || "anonymous", - }) - - // Clean up peer connections - Object.values(peerConnectionsRef.current).forEach((pc) => { - pc.close() - }) - - // Clean up media streams - if (localStreamRef.current) { - localStreamRef.current.getTracks().forEach((track) => track.stop()) - } - - if (screenStreamRef.current) { - screenStreamRef.current.getTracks().forEach((track) => track.stop()) - } - - // Close WebSocket connection - if (socketRef.current) { - socketRef.current.close() - } - - toast.info("You left the meeting") - onClose() - } - - // Send message to signaling server - const sendToSignalingServer = (message) => { - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - socketRef.current.send(JSON.stringify(message)) - } else { - console.error("WebSocket is not connected") - toast.error("Connection lost. Trying to reconnect...") + // Close WebSocket connection + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.close() + } } - } + }, [roomId, user]) // Handle signaling messages - const handleSignalingMessage = async (message) => { - switch (message.type) { - case "user-joined": - handleUserJoined(message) - break - case "user-left": - handleUserLeft(message) - break - case "offer": - handleOffer(message) - break - case "answer": - handleAnswer(message) - break - case "ice-candidate": - handleIceCandidate(message) - break - case "chat-message": - handleChatMessage(message) - break - case "screen-share-started": - handleScreenShareStarted(message) - break - case "screen-share-stopped": - handleScreenShareStopped(message) - break - default: - console.log("Unknown message type:", message.type) - } - } + const handleSignalingMessage = useCallback( + (message) => { + const { type } = message + + switch (type) { + case "user-joined": + handleUserJoined(message) + break + case "user-left": + handleUserLeft(message) + break + case "offer": + handleOffer(message) + break + case "answer": + handleAnswer(message) + break + case "ice-candidate": + handleIceCandidate(message) + break + case "chat-message": + handleChatMessage(message) + break + case "screen-share-started": + handleScreenShareStarted(message) + break + case "screen-share-stopped": + handleScreenShareStopped(message) + break + case "media-state-change": + handleMediaStateChange(message) + break + default: + console.log("Unknown message type:", type) + } + }, + [user], + ) // Handle when a new user joins const handleUserJoined = async (message) => { @@ -650,13 +503,7 @@ export function VideoConferenceRoom({ roomId, onClose }) { // Show a toast notification if chat is closed if (!isChatOpen) { - toast.info(`New message from ${userName}`, { - description: content.length > 30 ? content.substring(0, 30) + "..." : content, - action: { - label: "View", - onClick: () => setIsChatOpen(true), - }, - }) + toast.info(`New message from ${userName}`) } } @@ -684,6 +531,53 @@ export function VideoConferenceRoom({ roomId, onClose }) { }) } + // Handle media state change + const handleMediaStateChange = useCallback((message) => { + const { userId, isCameraOn, isMicOn } = message + + // Update participant state + setParticipants((prev) => prev.map((p) => (p.id === userId ? { ...p, isCameraOn, isMicOn } : p))) + }, []) + + // Create a new peer connection + const createPeerConnection = (userId) => { + const peerConnection = new RTCPeerConnection({ iceServers: ICE_SERVERS }) + + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + sendToSignalingServer({ + type: "ice-candidate", + userId: user?.id || "anonymous", + targetUserId: userId, + candidate: event.candidate, + }) + } + } + + peerConnection.ontrack = (event) => { + // Find the video element for this user + const videoElement = document.getElementById(`video-${userId}`) + if (videoElement && event.streams && event.streams[0]) { + videoElement.srcObject = event.streams[0] + } + } + + peerConnection.onconnectionstatechange = () => { + console.log(`Connection state change: ${peerConnection.connectionState}`) + if ( + peerConnection.connectionState === "failed" || + peerConnection.connectionState === "disconnected" || + peerConnection.connectionState === "closed" + ) { + // Handle connection failure + console.log(`Connection to peer ${userId} failed or closed`) + } + } + + peerConnectionsRef.current[userId] = peerConnection + return peerConnection + } + // Toggle camera const toggleCamera = () => { if (localStreamRef.current) { @@ -732,318 +626,555 @@ export function VideoConferenceRoom({ roomId, onClose }) { } } + // Toggle screen sharing + const handleScreenShare = async () => { + try { + if (!isScreenSharing) { + // Start screen sharing + const screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + }) + + screenStreamRef.current = screenStream + + // Replace video track in all peer connections + const videoTrack = screenStream.getVideoTracks()[0] + + Object.values(peerConnectionsRef.current).forEach((pc) => { + const senders = pc.getSenders() + const videoSender = senders.find((sender) => sender.track && sender.track.kind === "video") + + if (videoSender) { + videoSender.replaceTrack(videoTrack) + } + }) + + // Update local video + if (localVideoRef.current) { + localVideoRef.current.srcObject = screenStream + } + + // Listen for the end of screen sharing + videoTrack.onended = () => { + stopScreenSharing() + } + + setIsScreenSharing(true) + + // Notify other participants + sendToSignalingServer({ + type: "screen-share-started", + userId: user?.id || "anonymous", + }) + } else { + // Stop screen sharing + stopScreenSharing() + } + } catch (error) { + console.error("Error toggling screen share:", error) + toast.error("Could not share screen. Please try again.") + } + } + + const stopScreenSharing = () => { + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((track) => track.stop()) + + // Remove screen share tracks from peer connections + Object.values(peerConnectionsRef.current).forEach((pc) => { + pc.getSenders().forEach((sender) => { + if (screenStreamRef.current.getTracks().includes(sender.track)) { + pc.removeTrack(sender) + } + }) + }) + + screenStreamRef.current = null + setScreenStream(null) + setIsScreenSharing(false) + + // Notify others that we've stopped screen sharing + sendToSignalingServer({ + type: "screen-share-stopped", + roomId, + userId: user?.id || "anonymous", + }) + } + } + + // Send message to signaling server + const sendToSignalingServer = (message) => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send(JSON.stringify(message)) + } else { + console.error("WebSocket is not connected") + toast.error("Connection lost. Trying to reconnect...") + + // Try to reconnect + if (socketRef.current && socketRef.current.readyState === WebSocket.CLOSED) { + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const wsHost = process.env.NEXT_PUBLIC_API_URL || window.location.host + const wsUrl = `${wsProtocol}//${wsHost}/ws/video-conference/${roomId}/` + + socketRef.current = new WebSocket(wsUrl) + + socketRef.current.onopen = () => { + console.log("WebSocket reconnected") + socketRef.current.send(JSON.stringify(message)) + + // Rejoin the room + sendToSignalingServer({ + type: "join", + userId: user?.id || "anonymous", + userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", + userAvatar: user?.image || "/ai-avatar.png", + }) + } + + // Set up other event handlers + socketRef.current.onmessage = (event) => { + const msg = JSON.parse(event.data) + handleSignalingMessage(msg) + } + + socketRef.current.onerror = (error) => { + console.error("WebSocket reconnection error:", error) + } + } + } + } + + // Handle sending chat messages + const handleSendMessage = () => { + if (!messageInput.trim()) return + + const newMessage = { + id: Date.now(), + sender: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "You", + senderId: user?.id || "anonymous", + content: messageInput, + timestamp: new Date(), + isLocal: true, + } + + setChatMessages((prev) => [...prev, newMessage]) + setMessageInput("") + + // Send message to signaling server + sendToSignalingServer({ + type: "chat-message", + roomId, + userId: user?.id || "anonymous", + userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", + message: messageInput, + timestamp: new Date().toISOString(), + }) + } + + // Send chat message + const sendChatMessage = () => { + if (messageInput.trim() === "") return + + const message = { + type: "chat-message", + userId: user?.id || "anonymous", + userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", + message: messageInput, + timestamp: new Date().toISOString(), + } + + sendToSignalingServer(message) + + // Add to local chat + setChatMessages((prev) => [ + ...prev, + { + id: Date.now(), + userId: user?.id || "anonymous", + userName: user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Anonymous", + text: messageInput, + timestamp: new Date().toISOString(), + isLocal: true, + }, + ]) + + setMessageInput("") + + // Scroll to bottom of chat + if (chatContainerRef.current) { + setTimeout(() => { + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight + }, 100) + } + } + + // Handle copying meeting link + const handleCopyLink = () => { + navigator.clipboard.writeText(meetingLink) + toast.success("Meeting link copied to clipboard") + } + + // Copy meeting link + const copyMeetingLink = () => { + const link = `${window.location.origin}/dashboard/video-conference?roomId=${roomId}` + navigator.clipboard.writeText(link) + setLinkCopied(true) + toast.success("Meeting link copied to clipboard") + + setTimeout(() => { + setLinkCopied(false) + }, 3000) + } + + // Toggle fullscreen + const toggleFullScreen = () => { + if (!document.fullscreenElement) { + videoContainerRef.current.requestFullscreen().catch((err) => { + console.error(`Error attempting to enable full-screen mode: ${err.message}`) + }) + setIsFullScreen(true) + } else { + document.exitFullscreen() + setIsFullScreen(false) + } + } + + // Toggle participant audio + const toggleParticipantAudio = (participantId) => { + setIsMuted((prev) => ({ + ...prev, + [participantId]: !prev[participantId], + })) + + // Find the audio element for this participant + const audioElement = document.getElementById(`audio-${participantId}`) + if (audioElement) { + audioElement.muted = !isMuted[participantId] + } + } + + // Handle sharing meeting link + const handleShareLink = () => { + if (navigator.share) { + navigator + .share({ + title: "Join my Volt meeting", + text: "Click the link to join my video conference", + url: meetingLink, + }) + .then(() => toast.success("Meeting link shared")) + .catch((error) => console.error("Error sharing:", error)) + } else { + handleCopyLink() + } + } + + // Handle leaving the meeting + const handleLeaveMeeting = () => { + // Notify others that we're leaving + sendToSignalingServer({ + type: "leave", + roomId, + userId: user?.id || "anonymous", + }) + + // Clean up peer connections + Object.values(peerConnectionsRef.current).forEach((pc) => { + pc.close() + }) + + // Clean up media streams + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()) + } + + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((track) => track.stop()) + } + + // Close WebSocket connection + if (socketRef.current) { + socketRef.current.close() + } + + toast.info("You left the meeting") + onClose() + } + + // Handle leave meeting + const handleLeaveMeeting2 = async () => { + try { + // Notify other participants + sendToSignalingServer({ + type: "leave", + userId: user?.id || "anonymous", + }) + + // Leave the room via API + // await apiClient.post(`/api/video-conference/${roomId}/leave/`); + + // Stop all media tracks + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()) + } + + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((track) => track.stop()) + } + + // Close all peer connections + Object.values(peerConnectionsRef.current).forEach((pc) => { + if (pc) pc.close() + }) + + // Close WebSocket connection + if (socketRef.current) { + socketRef.current.close() + } + + // Navigate back to dashboard + router.push("/dashboard") + } catch (error) { + console.error("Error leaving meeting:", error) + router.push("/dashboard") + } + } + return ( -
Copy meeting link
+{isFullScreen ? "Exit full screen" : "Enter full screen"}
+{participant.name}
+{participant.isLocal ? "You" : "Participant"}
+{msg.userName}
)} +{msg.text}
++ {new Date(msg.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+{isMicOn ? "Turn off microphone" : "Turn on microphone"}
+{isCameraOn ? "Turn off camera" : "Turn on camera"}
+{isScreenSharing ? "Stop sharing screen" : "Share screen"}
+Leave meeting
+