"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { VideoTile } from "./VideoTile"; import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm"; import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol"; const DEFAULT_ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }]; type RemoteEntry = ParticipantSummary & { stream: MediaStream | null }; type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error"; export function CallRoom({ slug }: { slug: string }) { const router = useRouter(); const [session, setSession] = useState(null); const [phase, setPhase] = useState("loading"); const [errorMessage, setErrorMessage] = useState(null); const [remotes, setRemotes] = useState>(new Map()); const [localStream, setLocalStream] = useState(null); const [micOn, setMicOn] = useState(true); const [camOn, setCamOn] = useState(true); const [sharingScreen, setSharingScreen] = useState(false); const [showParticipants, setShowParticipants] = useState(false); const wsRef = useRef(null); const pcsRef = useRef>(new Map()); const localStreamRef = useRef(null); const screenStreamRef = useRef(null); const sessionRef = useRef(null); const send = useCallback((message: ClientToServerMessage) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)); } }, []); const closePeer = useCallback((participantId: string) => { pcsRef.current.get(participantId)?.close(); pcsRef.current.delete(participantId); setRemotes((prev) => { const next = new Map(prev); next.delete(participantId); return next; }); }, []); const createPeerConnection = useCallback( (participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => { const pc = new RTCPeerConnection({ iceServers: sessionRef.current?.iceServers?.length ? sessionRef.current.iceServers : DEFAULT_ICE_SERVERS, }); pcsRef.current.set(participantId, pc); localStreamRef.current?.getTracks().forEach((track) => { pc.addTrack(track, localStreamRef.current!); }); pc.ontrack = (event) => { setRemotes((prev) => { const next = new Map(prev); const existing = next.get(participantId); next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null }); return next; }); }; pc.onicecandidate = (event) => { if (event.candidate) { send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() }); } }; setRemotes((prev) => { const next = new Map(prev); next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null }); return next; }); if (isInitiator) { pc.createOffer() .then((offer) => pc.setLocalDescription(offer).then(() => offer)) .then((offer) => send({ type: "offer", to: participantId, payload: offer })) .catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen.")); } return pc; }, [send], ); const cleanup = useCallback(() => { wsRef.current?.close(); wsRef.current = null; pcsRef.current.forEach((pc) => pc.close()); pcsRef.current.clear(); localStreamRef.current?.getTracks().forEach((t) => t.stop()); localStreamRef.current = null; screenStreamRef.current?.getTracks().forEach((t) => t.stop()); screenStreamRef.current = null; }, []); // Load session + acquire local media + open signaling connection. This // reads sessionStorage and opens external connections, so it belongs in an // effect; the early setState calls below are unavoidable for the // missing/invalid-session error paths (no server-renderable equivalent). useEffect(() => { const raw = sessionStorage.getItem(MEETING_SESSION_KEY); if (!raw) { // eslint-disable-next-line react-hooks/set-state-in-effect setPhase("error"); setErrorMessage("Keine aktive Meeting-Sitzung gefunden."); return; } let parsed: StoredMeetingSession; try { parsed = JSON.parse(raw); } catch { setPhase("error"); setErrorMessage("Keine aktive Meeting-Sitzung gefunden."); return; } if (parsed.eventSlug !== slug) { setPhase("error"); setErrorMessage("Diese Sitzung gehört zu einem anderen Termin."); return; } setSession(parsed); sessionRef.current = parsed; setPhase("connecting"); let cancelled = false; (async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; } localStreamRef.current = stream; setLocalStream(stream); } catch { // Continue without local media — participant can still watch/listen to others. setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen."); } if (cancelled) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`); wsRef.current = ws; ws.onmessage = (event) => { const message: ServerToClientMessage = JSON.parse(event.data); handleServerMessage(message); }; ws.onclose = () => { setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended")); }; ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen."); })(); function handleServerMessage(message: ServerToClientMessage) { const me = sessionRef.current; if (!me) return; switch (message.type) { case "welcome": { setPhase("connected"); for (const p of message.participants) { createPeerConnection(p.participantId, p.name, p.role, true); } break; } case "peer-joined": { createPeerConnection(message.participantId, message.name, message.role, false); break; } case "peer-left": { closePeer(message.participantId); break; } case "offer": { const pc = pcsRef.current.get(message.from) ?? createPeerConnection(message.from, remotesLookupName(message.from), "participant", false); pc.setRemoteDescription(new RTCSessionDescription(message.payload)) .then(() => pc.createAnswer()) .then((answer) => pc.setLocalDescription(answer).then(() => answer)) .then((answer) => send({ type: "answer", to: message.from, payload: answer })) .catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen.")); break; } case "answer": { pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload)); break; } case "ice-candidate": { pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {}); break; } case "kicked": { setPhase("kicked"); cleanup(); sessionStorage.removeItem(MEETING_SESSION_KEY); break; } } } function remotesLookupName(participantId: string): string { return remotes.get(participantId)?.name ?? "Teilnehmer:in"; } return () => { cancelled = true; cleanup(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]); function toggleMic() { localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn)); setMicOn((v) => !v); } function toggleCam() { localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn)); setCamOn((v) => !v); } async function toggleScreenShare() { if (sharingScreen) { stopScreenShare(); return; } try { const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); screenStreamRef.current = screenStream; const screenTrack = screenStream.getVideoTracks()[0]; screenTrack.onended = () => stopScreenShare(); pcsRef.current.forEach((pc) => { const sender = pc.getSenders().find((s) => s.track?.kind === "video"); sender?.replaceTrack(screenTrack); }); setSharingScreen(true); } catch { // User cancelled the share dialog — nothing to do. } } function stopScreenShare() { screenStreamRef.current?.getTracks().forEach((t) => t.stop()); screenStreamRef.current = null; const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null; pcsRef.current.forEach((pc) => { const sender = pc.getSenders().find((s) => s.track?.kind === "video"); sender?.replaceTrack(cameraTrack); }); setSharingScreen(false); } function kickParticipant(participantId: string) { send({ type: "kick", targetParticipantId: participantId }); } function leaveMeeting() { cleanup(); sessionStorage.removeItem(MEETING_SESSION_KEY); router.push(`/termine/${slug}`); } if (phase === "loading" || phase === "connecting") { return (

Verbindung wird aufgebaut …

); } if (phase === "error" || !session) { return (

{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}

Zurück zur Beitrittsseite
); } if (phase === "kicked") { return (

Du wurdest vom Meeting entfernt.

Erneut beitreten
); } if (phase === "ended") { return (

Die Verbindung zum Meeting wurde beendet.

Zurück zum Termin
); } const isHost = session.role === "host"; const remoteList = [...remotes.values()]; return (
{session.eventTitle}
{errorMessage && (

{errorMessage}

)}
{remoteList.map((r) => ( ))}
{showParticipants && ( )}
{isHost && ( )}
); } function ControlButton({ active, onClick, label, icon, }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen"; }) { return ( ); } function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) { if (name === "mic") { return ( ); } if (name === "cam") { return ( ); } return ( ); }