diff --git a/src/app/api/session/[id]/signal/route.ts b/src/app/api/session/[id]/signal/route.ts new file mode 100644 index 0000000..d05372a --- /dev/null +++ b/src/app/api/session/[id]/signal/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; + +interface SignalMessage { + id: string; + sender: "seeker" | "expert"; + type: "offer" | "answer" | "candidate" | "status"; + data: any; + timestamp: number; +} + +const globalSignals = global as unknown as { + sessionSignals?: Record; +}; + +if (!globalSignals.sessionSignals) { + globalSignals.sessionSignals = {}; +} + +// POST /api/session/[id]/signal +// Body: { sender: 'seeker' | 'expert', type: 'offer' | 'answer' | 'candidate' | 'status', data: any } +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + let body: any; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { sender, type, data } = body; + if (!sender || !type || !data) { + return NextResponse.json( + { error: "Missing required fields: sender, type, data" }, + { status: 400 } + ); + } + + if (sender !== "seeker" && sender !== "expert") { + return NextResponse.json( + { error: "sender must be 'seeker' or 'expert'" }, + { status: 400 } + ); + } + + if (!globalSignals.sessionSignals) { + globalSignals.sessionSignals = {}; + } + + if (!globalSignals.sessionSignals[id]) { + globalSignals.sessionSignals[id] = []; + } + + const newSignal: SignalMessage = { + id: Math.random().toString(36).substring(2, 9), + sender, + type, + data, + timestamp: Date.now(), + }; + + globalSignals.sessionSignals[id].push(newSignal); + + // Keep array small to prevent memory leak + if (globalSignals.sessionSignals[id].length > 200) { + globalSignals.sessionSignals[id] = globalSignals.sessionSignals[id].slice(-200); + } + + return NextResponse.json({ success: true, signal: newSignal }, { status: 200 }); +} + +// GET /api/session/[id]/signal?role=seeker|expert&since=timestamp +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const role = searchParams.get("role"); + const sinceStr = searchParams.get("since"); + const since = sinceStr ? parseInt(sinceStr, 10) : 0; + + if (!role || (role !== "seeker" && role !== "expert")) { + return NextResponse.json( + { error: "role query param must be 'seeker' or 'expert'" }, + { status: 400 } + ); + } + + if (!globalSignals.sessionSignals || !globalSignals.sessionSignals[id]) { + return NextResponse.json({ signals: [] }, { status: 200 }); + } + + // Get signals sent by the OTHER peer after the since timestamp + const otherRole = role === "seeker" ? "expert" : "seeker"; + const signals = globalSignals.sessionSignals[id].filter( + (sig) => sig.sender === otherRole && sig.timestamp > since + ); + + return NextResponse.json({ signals }, { status: 200 }); +} + +// DELETE /api/session/[id]/signal +// Clears all signals for this session +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + if (globalSignals.sessionSignals && globalSignals.sessionSignals[id]) { + delete globalSignals.sessionSignals[id]; + } + + return NextResponse.json({ success: true }, { status: 200 }); +} diff --git a/src/app/session/[id]/page.tsx b/src/app/session/[id]/page.tsx index 67c1c8f..428093c 100644 --- a/src/app/session/[id]/page.tsx +++ b/src/app/session/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { LiveCounter } from "@/components/session/LiveCounter"; import { SessionNotes } from "@/components/session/SessionNotes"; import { AppealFormModal } from "@/components/session/AppealForm"; @@ -12,6 +12,7 @@ import { Modal } from "@/components/ui/Modal"; import { useWallet } from "@/providers/WalletProvider"; import { CodeWorkspace } from "@/components/session/CodeWorkspace"; import { SessionChat } from "@/components/session/SessionChat"; +import VideoCall from "@/components/session/VideoCall"; import { User, Wallet, @@ -73,6 +74,7 @@ export default function SessionPage() { const router = useRouter(); const wallet = useWallet(); const sessionId = params.id as string; + const searchParams = useSearchParams(); const [session] = useState(() => ({ ...MOCK_SESSION, @@ -88,6 +90,11 @@ export default function SessionPage() { const [showCodeEditor, setShowCodeEditor] = useState(false); const [showChat, setShowChat] = useState(false); const [showAppealModal, setShowAppealModal] = useState(false); + const [isPictureInPicture, setIsPictureInPicture] = useState(false); + + const roleParam = searchParams.get("role"); + const isExpert = roleParam === "expert"; + const userRole = isExpert ? "expert" : "seeker"; /** * A resolved dispute attached to this session, if any. @@ -187,31 +194,62 @@ export default function SessionPage() {
- -
- - +
+ + + +
+ + + Session Active + + + + Live + +
+

Video call is floating

+
+ + ) : ( +
+ setIsPictureInPicture(!isPictureInPicture)} + sessionId={sessionId} + role={userRole} /> +
+ )} -
- - - Session Active - - - - Live - -
- - + {isPictureInPicture && ( + setIsPictureInPicture(!isPictureInPicture)} + sessionId={sessionId} + role={userRole} + /> + )}
{[ diff --git a/src/components/session/VideoCall.tsx b/src/components/session/VideoCall.tsx index 4ac0321..a3a6100 100644 --- a/src/components/session/VideoCall.tsx +++ b/src/components/session/VideoCall.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useState, useRef, useEffect } from 'react'; -import { Mic, MicOff, Video, VideoOff, Phone, Settings, Maximize2, MessageCircle, MonitorUp } from 'lucide-react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Mic, MicOff, Video, VideoOff, Phone, Settings, Maximize2, MessageCircle, MonitorUp, Loader2 } from 'lucide-react'; interface VideoCallProps { expertName: string; @@ -11,131 +11,446 @@ interface VideoCallProps { onEndCall?: () => void; isPictureInPicture?: boolean; onTogglePIP?: () => void; + sessionId?: string; + role?: "seeker" | "expert"; } +const ICE_SERVERS = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' } +]; + export default function VideoCall({ expertName, seekerName, - expertAvatar, - seekerAvatar, + expertAvatar = "", + seekerAvatar = "", onEndCall, isPictureInPicture = false, onTogglePIP, + sessionId = "default", + role = "seeker", }: VideoCallProps) { const [isMuted, setIsMuted] = useState(false); const [isVideoOn, setIsVideoOn] = useState(true); const [callDuration, setCallDuration] = useState(0); const [isScreenSharing, setIsScreenSharing] = useState(false); const [toastMessage, setToastMessage] = useState(null); - + + // WebRTC & Peer States + const [connectionState, setConnectionState] = useState("new"); + const [isRemoteVideoOn, setIsRemoteVideoOn] = useState(true); + const [isRemoteAudioOn, setIsRemoteAudioOn] = useState(true); + const [isRemoteConnected, setIsRemoteConnected] = useState(false); + const videoRefLocal = useRef(null); const videoRefRemote = useRef(null); - // Timer for call duration + const localStreamRef = useRef(null); + const remoteStreamRef = useRef(null); + const peerConnectionRef = useRef(null); + const pollingIntervalRef = useRef(null); + const lastSignalTimeRef = useRef(0); + const isReconnectingRef = useRef(false); + + const showToast = useCallback((message: string) => { + setToastMessage(message); + setTimeout(() => setToastMessage(null), 3000); + }, []); + + // Post signals to Next.js API endpoint + const sendSignal = useCallback(async (type: "offer" | "answer" | "candidate" | "status", data: any) => { + try { + await fetch(`/api/session/${sessionId}/signal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sender: role, + type, + data, + }), + }); + } catch (err) { + console.error("Failed to send signaling message:", err); + } + }, [sessionId, role]); + + // Clean up WebRTC peer connection and streams + const cleanupConnection = useCallback(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + + if (peerConnectionRef.current) { + peerConnectionRef.current.onicecandidate = null; + peerConnectionRef.current.ontrack = null; + peerConnectionRef.current.onconnectionstatechange = null; + peerConnectionRef.current.close(); + peerConnectionRef.current = null; + } + + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()); + localStreamRef.current = null; + } + + if (videoRefLocal.current) { + videoRefLocal.current.srcObject = null; + } + if (videoRefRemote.current) { + videoRefRemote.current.srcObject = null; + } + + remoteStreamRef.current = null; + setIsRemoteConnected(false); + }, []); + + // Initialize a WebRTC Connection + const initializeConnection = useCallback(async () => { + try { + // 1. Acquire Local Camera and Mic + let localStream: MediaStream; + try { + localStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + } catch (err) { + console.warn("Could not access camera/microphone. Using fallback empty streams.", err); + showToast("Device warning: Camera/Microphone not accessible."); + // Fallback: Create silent audio track & blank video track if media unavailable + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, 640, 480); + } + const videoTrack = (canvas as any).captureStream?.(25)?.getVideoTracks()[0] || null; + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const dst = audioContext.createMediaStreamDestination(); + oscillator.connect(dst); + const audioTrack = dst.stream.getAudioTracks()[0] || null; + + const tracks = []; + if (videoTrack) tracks.push(videoTrack); + if (audioTrack) tracks.push(audioTrack); + localStream = new MediaStream(tracks); + } + + localStreamRef.current = localStream; + if (videoRefLocal.current) { + videoRefLocal.current.srcObject = localStream; + } + + // Apply initial mute/video toggle settings + localStream.getAudioTracks().forEach(t => t.enabled = !isMuted); + localStream.getVideoTracks().forEach(t => t.enabled = isVideoOn); + + // 2. Setup RTCPeerConnection + const pc = new RTCPeerConnection({ + iceServers: ICE_SERVERS, + }); + peerConnectionRef.current = pc; + + // 3. Add tracks to Connection + localStream.getTracks().forEach((track) => { + pc.addTrack(track, localStream); + }); + + // 4. Handle Remote Track + pc.ontrack = (event) => { + if (event.streams && event.streams[0]) { + remoteStreamRef.current = event.streams[0]; + setIsRemoteConnected(true); + if (videoRefRemote.current) { + videoRefRemote.current.srcObject = event.streams[0]; + } + } + }; + + // 5. Handle ICE Candidates + pc.onicecandidate = (event) => { + if (event.candidate) { + sendSignal("candidate", event.candidate); + } + }; + + // 6. Monitor Connection State + pc.onconnectionstatechange = () => { + setConnectionState(pc.connectionState); + if (pc.connectionState === "connected") { + setIsRemoteConnected(true); + isReconnectingRef.current = false; + } else if (pc.connectionState === "disconnected" || pc.connectionState === "failed") { + setIsRemoteConnected(false); + handleReconnect(); + } + }; + + // 7. Seeker Initiates Offer + if (role === "seeker") { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await sendSignal("offer", offer); + } + + // 8. Start Signaling Polling Loop + startPolling(); + + // Publish initial state status + sendSignal("status", { video: isVideoOn, audio: !isMuted }); + + } catch (err) { + console.error("Failed to initialize connection:", err); + showToast("WebRTC connection failed. Retrying..."); + handleReconnect(); + } + }, [role, isMuted, isVideoOn, sendSignal, showToast]); + + // Polling Loop for Signals + const startPolling = useCallback(() => { + if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current); + + pollingIntervalRef.current = setInterval(async () => { + try { + const response = await fetch( + `/api/session/${sessionId}/signal?role=${role}&since=${lastSignalTimeRef.current}` + ); + if (!response.ok) return; + + const { signals } = await response.json(); + if (!signals || signals.length === 0) return; + + const pc = peerConnectionRef.current; + if (!pc) return; + + for (const sig of signals) { + lastSignalTimeRef.current = Math.max(lastSignalTimeRef.current, sig.timestamp); + + if (sig.type === "offer" && role === "expert") { + await pc.setRemoteDescription(new RTCSessionDescription(sig.data)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + await sendSignal("answer", answer); + } else if (sig.type === "answer" && role === "seeker") { + await pc.setRemoteDescription(new RTCSessionDescription(sig.data)); + } else if (sig.type === "candidate") { + try { + await pc.addIceCandidate(new RTCIceCandidate(sig.data)); + } catch (e) { + console.warn("Error adding ICE candidate:", e); + } + } else if (sig.type === "status") { + setIsRemoteVideoOn(sig.data.video); + setIsRemoteAudioOn(sig.data.audio); + } + } + } catch (err) { + console.error("Error in signaling polling:", err); + } + }, 1000); + }, [sessionId, role, sendSignal]); + + // Reconnection Logic on Drop + const handleReconnect = useCallback(async () => { + if (isReconnectingRef.current) return; + isReconnectingRef.current = true; + showToast("Connection dropped. Reconnecting..."); + + // Clean connection references, but preserve local track if possible + if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current); + if (peerConnectionRef.current) { + peerConnectionRef.current.close(); + peerConnectionRef.current = null; + } + setIsRemoteConnected(false); + + // Short backoff delay + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Re-initialize WebRTC connection + initializeConnection(); + }, [initializeConnection, showToast]); + + // Initial mount setup useEffect(() => { + initializeConnection(); + + // Call duration timer const timer = setInterval(() => { setCallDuration((prev) => prev + 1); }, 1000); - return () => clearInterval(timer); - }, []); + return () => { + clearInterval(timer); + cleanupConnection(); + }; + }, [initializeConnection, cleanupConnection]); - const formatTime = (seconds: number) => { - const hrs = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - return `${hrs > 0 ? hrs + ':' : ''}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + // Handle Mute/Unmute Mic Toggle + const toggleMute = () => { + const nextMuted = !isMuted; + setIsMuted(nextMuted); + if (localStreamRef.current) { + localStreamRef.current.getAudioTracks().forEach((track) => { + track.enabled = !nextMuted; + }); + } + sendSignal("status", { video: isVideoOn, audio: !nextMuted }); }; - const showToast = (message: string) => { - setToastMessage(message); - setTimeout(() => setToastMessage(null), 3000); + // Handle Camera On/Off Toggle + const toggleVideo = () => { + const nextVideoOn = !isVideoOn; + setIsVideoOn(nextVideoOn); + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach((track) => { + track.enabled = nextVideoOn; + }); + } + sendSignal("status", { video: nextVideoOn, audio: !isMuted }); }; + // Handle Screen Sharing const toggleScreenShare = async () => { try { + const pc = peerConnectionRef.current; + if (!pc) return; + if (!isScreenSharing) { const stream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + setIsScreenSharing(true); + showToast("Screen sharing started"); + + const screenTrack = stream.getVideoTracks()[0]; + const sender = pc.getSenders().find((s) => s.track && s.track.kind === "video"); + if (sender) { + await sender.replaceTrack(screenTrack); + } + + // Display local screen stream in preview if (videoRefLocal.current) { videoRefLocal.current.srcObject = stream; } - setIsScreenSharing(true); - showToast("Screen sharing started"); - - // Listen for the user stopping screen share via browser UI - stream.getVideoTracks()[0].onended = () => { + + // Listen for screen sharing stop via browser UI + screenTrack.onended = async () => { setIsScreenSharing(false); showToast("Screen sharing ended"); - if (videoRefLocal.current) { - videoRefLocal.current.srcObject = null; + if (localStreamRef.current) { + const camTrack = localStreamRef.current.getVideoTracks()[0]; + if (sender && camTrack) { + await sender.replaceTrack(camTrack); + } + if (videoRefLocal.current) { + videoRefLocal.current.srcObject = localStreamRef.current; + } } }; } else { setIsScreenSharing(false); showToast("Screen sharing ended"); - if (videoRefLocal.current && videoRefLocal.current.srcObject) { - const tracks = (videoRefLocal.current.srcObject as MediaStream).getTracks(); - tracks.forEach(track => track.stop()); - videoRefLocal.current.srcObject = null; + if (localStreamRef.current && videoRefLocal.current) { + const camTrack = localStreamRef.current.getVideoTracks()[0]; + const sender = pc.getSenders().find((s) => s.track && s.track.kind === "video"); + if (sender && camTrack) { + await sender.replaceTrack(camTrack); + } + videoRefLocal.current.srcObject = localStreamRef.current; } } } catch (err) { - console.error("Error sharing screen", err); + console.error("Error sharing screen:", err); showToast("Failed to share screen"); } }; + const formatTime = (seconds: number) => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return `${hrs > 0 ? hrs + ':' : ''}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const isLocalUserExpert = role === "expert"; + const remoteUserName = isLocalUserExpert ? seekerName : expertName; + const remoteUserAvatar = isLocalUserExpert ? seekerAvatar : expertAvatar; + + // PICTURE-IN-PICTURE LAYOUT if (isPictureInPicture) { return (
{/* Remote Video - PIP Mode */} -
-