"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { DisconnectReason, Room, RoomEvent, Track, type LocalTrack, type Participant } from "livekit-client"; import { PreJoinScreen } from "./PreJoinScreen"; import { SfuVideoTile } from "./SfuVideoTile"; import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm"; type Phase = "loading" | "prejoin" | "connecting" | "connected" | "kicked" | "ended" | "error"; type LayoutMode = "grid" | "speaker" | "screen"; type ParticipantView = { identity: string; name: string; role: "host" | "participant"; cameraTrack: Track | null; microphoneTrack: Track | null; screenTrack: Track | null; speaking: boolean; microphoneEnabled: boolean; cameraEnabled: boolean; }; function participantRole(participant: Participant, fallback: "host" | "participant"): "host" | "participant" { try { const metadata = JSON.parse(participant.metadata || "{}") as { role?: unknown }; if (metadata.role === "host" || metadata.role === "participant") return metadata.role; } catch { // A malformed metadata value cannot grant host privileges. } return fallback; } function snapshotParticipant( participant: Participant, activeSpeakers: Set, fallbackRole: "host" | "participant", fallbackName: string, ): ParticipantView { return { identity: participant.identity, name: participant.name || fallbackName, role: participantRole(participant, fallbackRole), cameraTrack: participant.getTrackPublication(Track.Source.Camera)?.track ?? null, microphoneTrack: participant.getTrackPublication(Track.Source.Microphone)?.track ?? null, screenTrack: participant.getTrackPublication(Track.Source.ScreenShare)?.track ?? null, speaking: activeSpeakers.has(participant.identity) || participant.isSpeaking, microphoneEnabled: participant.isMicrophoneEnabled, cameraEnabled: participant.isCameraEnabled, }; } 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 [participants, setParticipants] = useState([]); const [layout, setLayout] = useState("grid"); const [activeSpeakerId, setActiveSpeakerId] = useState(null); const [showParticipants, setShowParticipants] = useState(false); const [sharingScreen, setSharingScreen] = useState(false); const [micOn, setMicOn] = useState(true); const [camOn, setCamOn] = useState(true); const roomRef = useRef(null); const localTracksRef = useRef([]); const activeSpeakersRef = useRef>(new Set()); const intentionalCloseRef = useRef(false); useEffect(() => { const raw = sessionStorage.getItem(MEETING_SESSION_KEY); const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("token"); const rawSession = hashToken ? JSON.stringify({ token: hashToken }) : raw; if (!rawSession) { queueMicrotask(() => { setPhase("error"); setErrorMessage("Keine aktive Meeting-Sitzung gefunden."); }); return; } try { const candidate = JSON.parse(rawSession) as Partial; const parsed: StoredMeetingSession = { token: typeof candidate.token === "string" ? candidate.token : "", participantToken: candidate.participantToken, serverUrl: candidate.serverUrl, participantId: typeof candidate.participantId === "string" ? candidate.participantId : "", role: candidate.role === "participant" ? "participant" : "host", name: typeof candidate.name === "string" && candidate.name ? candidate.name : "Host", eventTitle: typeof candidate.eventTitle === "string" && candidate.eventTitle ? candidate.eventTitle : "Video-Call", eventSlug: typeof candidate.eventSlug === "string" && candidate.eventSlug ? candidate.eventSlug : slug, }; if (!parsed.token || parsed.eventSlug !== slug) throw new Error("invalid session"); queueMicrotask(() => { setSession(parsed); setPhase("prejoin"); }); } catch { queueMicrotask(() => { setPhase("error"); setErrorMessage("Keine aktive Meeting-Sitzung gefunden."); }); } return () => { intentionalCloseRef.current = true; roomRef.current?.disconnect(); roomRef.current = null; localTracksRef.current.forEach((track) => track.stop()); localTracksRef.current = []; }; }, [slug]); function refreshParticipants(room: Room) { const local = snapshotParticipant(room.localParticipant, activeSpeakersRef.current, session?.role ?? "participant", session?.name ?? "Du"); const remote = [...room.remoteParticipants.values()].map((participant) => snapshotParticipant(participant, activeSpeakersRef.current, "participant", "Teilnehmer"), ); setParticipants([local, ...remote]); } async function connectRoom(previewTracks: LocalTrack[]) { if (!session) return; setPhase("connecting"); setErrorMessage(null); intentionalCloseRef.current = false; localTracksRef.current = previewTracks; try { let serverUrl = session.serverUrl; let participantToken = session.participantToken; if (!serverUrl || !participantToken) { const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, { method: "POST", headers: { Authorization: `Bearer ${session.token}` }, credentials: "include", }); const data = (await response.json().catch(() => ({}))) as { serverUrl?: string; participantToken?: string; error?: string }; if (!response.ok || !data.serverUrl || !data.participantToken) throw new Error(data.error || "Video-Server nicht erreichbar"); serverUrl = data.serverUrl; participantToken = data.participantToken; } const room = new Room({ adaptiveStream: true, dynacast: true }); roomRef.current = room; const refresh = () => refreshParticipants(room); room.on(RoomEvent.Connected, refresh); room.on(RoomEvent.ParticipantConnected, refresh); room.on(RoomEvent.ParticipantDisconnected, refresh); room.on(RoomEvent.TrackPublished, refresh); room.on(RoomEvent.TrackUnpublished, refresh); room.on(RoomEvent.TrackSubscribed, refresh); room.on(RoomEvent.TrackUnsubscribed, refresh); room.on(RoomEvent.TrackMuted, refresh); room.on(RoomEvent.TrackUnmuted, refresh); room.on(RoomEvent.LocalTrackPublished, refresh); room.on(RoomEvent.LocalTrackUnpublished, refresh); room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => { activeSpeakersRef.current = new Set(speakers.map((speaker) => speaker.identity)); setActiveSpeakerId(speakers[0]?.identity ?? null); refresh(); }); room.on(RoomEvent.Reconnecting, () => setPhase("connecting")); room.on(RoomEvent.Reconnected, () => { setPhase("connected"); refresh(); }); room.on(RoomEvent.Disconnected, (reason) => { if (reason === DisconnectReason.PARTICIPANT_REMOVED) { localTracksRef.current.forEach((track) => track.stop()); localTracksRef.current = []; sessionStorage.removeItem(MEETING_SESSION_KEY); setPhase("kicked"); } else if (!intentionalCloseRef.current) { setPhase("ended"); } }); room.on(RoomEvent.MediaDevicesError, () => setErrorMessage("Kamera oder Mikrofon konnten nicht verwendet werden.")); await room.connect(serverUrl, participantToken); for (const track of previewTracks) { await room.localParticipant.publishTrack(track, { source: track.kind === Track.Kind.Audio ? Track.Source.Microphone : Track.Source.Camera, }); } refresh(); setMicOn(previewTracks.some((track) => track.kind === Track.Kind.Audio && track.mediaStreamTrack.enabled)); setCamOn(previewTracks.some((track) => track.kind === Track.Kind.Video && track.mediaStreamTrack.enabled)); setPhase("connected"); } catch (error) { localTracksRef.current.forEach((track) => track.stop()); localTracksRef.current = []; intentionalCloseRef.current = true; roomRef.current?.disconnect(); roomRef.current = null; setPhase("error"); setErrorMessage(error instanceof Error ? error.message : "Der Video-Call konnte nicht geöffnet werden."); } } async function toggleMic() { const next = !micOn; await roomRef.current?.localParticipant.setMicrophoneEnabled(next); setMicOn(next); } async function toggleCamera() { const next = !camOn; await roomRef.current?.localParticipant.setCameraEnabled(next); setCamOn(next); } async function toggleScreenShare() { const next = !sharingScreen; try { await roomRef.current?.localParticipant.setScreenShareEnabled(next); setSharingScreen(next); } catch { setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden."); } } async function kickParticipant(identity: string) { if (!session) return; const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/kick`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` }, body: JSON.stringify({ participantId: identity }), credentials: "include", }); if (!response.ok) { const data = (await response.json().catch(() => ({}))) as { error?: string }; setErrorMessage(data.error || "Teilnehmer konnte nicht entfernt werden."); } } function leaveMeeting() { intentionalCloseRef.current = true; roomRef.current?.disconnect(); roomRef.current = null; localTracksRef.current.forEach((track) => track.stop()); localTracksRef.current = []; sessionStorage.removeItem(MEETING_SESSION_KEY); router.push(`/termine/${slug}`); } if (phase === "loading") return
Meeting wird geladen …
; if (phase === "prejoin" && session) return ; if (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.

; if (phase === "ended") return

{errorMessage || "Die Verbindung zum Meeting wurde beendet."}

Zurück zum Termin
; const local = participants[0]; const remoteParticipants = participants.slice(1); const screenOwner = participants.find((participant) => participant.screenTrack); const activeSpeaker = participants.find((participant) => participant.identity === activeSpeakerId) ?? remoteParticipants[0] ?? local; const tile = (participant: ParticipantView, options: { compact?: boolean; videoTrack?: Track | null; name?: string } = {}) => ( ); return (

{session.eventTitle}

LiveKit-SFU · {participants.length} Teilnehmer

{(["grid", "speaker", "screen"] as LayoutMode[]).map((mode) => )}
{errorMessage &&

{errorMessage}

}
{layout === "grid" &&
{participants.map((participant) => tile(participant, { videoTrack: participant.screenTrack ?? participant.cameraTrack, name: participant.screenTrack ? `${participant.name} · Bildschirm` : participant.name }))}
} {layout === "speaker" && activeSpeaker &&
{tile(activeSpeaker, { videoTrack: activeSpeaker.screenTrack ?? activeSpeaker.cameraTrack })}
{participants.filter((participant) => participant.identity !== activeSpeaker.identity).map((participant) => tile(participant, { compact: true }))}
} {layout === "screen" &&
{screenOwner ?
{tile(screenOwner, { videoTrack: screenOwner.screenTrack, name: `${screenOwner.name} · Bildschirm` })}
:
Noch niemand teilt den Bildschirm.
}
{participants.map((participant) => tile(participant, { compact: true }))}
}
{showParticipants && }
void toggleMic()} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" /> void toggleCamera()} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" /> {session.role === "host" && void toggleScreenShare()} label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"} icon="screen" />}
); } function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) { return ( ); }