"use client"; import { useEffect, useRef, useState } from "react"; import { DisconnectReason, Room, RoomEvent, Track, 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 formatCallDuration(durationMs: number | null): string { const totalSeconds = Math.max(0, Math.round((durationMs ?? 0) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes} Min. ${String(seconds).padStart(2, "0")} Sek.`; } 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 [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 [pendingControl, setPendingControl] = useState<"mic" | "cam" | "screen" | null>(null); const [callDurationMs, setCallDurationMs] = useState(null); const roomRef = useRef(null); const localTracksRef = useRef([]); const activeSpeakersRef = useRef>(new Set()); const intentionalCloseRef = useRef(false); const connectedAtRef = useRef(null); useEffect(() => { const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("token"); let rawSession: string | null = hashToken ? JSON.stringify({ token: hashToken }) : null; // Hash-based links (for example the admin test call) must work even when // browser privacy settings deny access to Web Storage. if (!rawSession) { try { rawSession = sessionStorage.getItem(MEETING_SESSION_KEY); } catch { rawSession = null; } } 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]); } function syncMeetingView(room: Room) { refreshParticipants(room); const screenIsShared = room.localParticipant.isScreenShareEnabled || [...room.remoteParticipants.values()].some((participant) => participant.isScreenShareEnabled); setSharingScreen(room.localParticipant.isScreenShareEnabled); if (screenIsShared) setLayout("screen"); } function clearStoredSession() { try { sessionStorage.removeItem(MEETING_SESSION_KEY); } catch { // Private browsing may deny Web Storage. The call itself can still end safely. } } function finishCall() { const startedAt = connectedAtRef.current; setCallDurationMs(startedAt ? Date.now() - startedAt : 0); connectedAtRef.current = null; setPhase("ended"); } async function connectRoom(previewTracks: MediaStreamTrack[]) { 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 = () => syncMeetingView(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 = []; clearStoredSession(); setPhase("kicked"); } else if (!intentionalCloseRef.current) { finishCall(); } }); 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 === "audio" ? Track.Source.Microphone : Track.Source.Camera, }); } refresh(); setMicOn(previewTracks.some((track) => track.kind === "audio" && track.enabled)); setCamOn(previewTracks.some((track) => track.kind === "video" && track.enabled)); connectedAtRef.current = Date.now(); setCallDurationMs(null); 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 room = roomRef.current; if (!room || pendingControl) return; const next = !micOn; setPendingControl("mic"); try { await room.localParticipant.setMicrophoneEnabled(next); setMicOn(next); } catch { setErrorMessage("Das Mikrofon konnte nicht umgeschaltet werden."); } finally { setPendingControl(null); } } async function toggleCamera() { const room = roomRef.current; if (!room || pendingControl) return; const next = !camOn; setPendingControl("cam"); try { await room.localParticipant.setCameraEnabled(next); setCamOn(next); } catch { setErrorMessage("Die Kamera konnte nicht umgeschaltet werden."); } finally { setPendingControl(null); } } async function toggleScreenShare() { const room = roomRef.current; if (!room || pendingControl) return; const next = !sharingScreen; setPendingControl("screen"); try { await room.localParticipant.setScreenShareEnabled(next); setSharingScreen(next); if (next) setLayout("screen"); } catch { setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden."); } finally { setPendingControl(null); } } 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 = []; clearStoredSession(); finishCall(); } 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") { const isTestCall = session.eventSlug.startsWith("test-"); return (

Video-Call beendet

Danke für das Gespräch

{errorMessage || "Die Verbindung wurde ordentlich beendet."} Deine Gesprächsdauer: {formatCallDuration(callDurationMs)}.

{isTestCall ? "Zurück zum CRM" : "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="Mikrofon" icon="mic" /> void toggleCamera()} label="Kamera" icon="cam" /> {session.role === "host" && void toggleScreenShare()} label="Bildschirm teilen" icon="screen" />}
); } function ControlButton({ enabled, busy, onClick, label, icon }: { enabled: boolean; busy: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) { const actionLabel = icon === "screen" ? (enabled ? "Bildschirmfreigabe beenden" : "Bildschirmfreigabe starten") : `${label} ${enabled ? "ausschalten" : "einschalten"}`; const statusLabel = icon === "screen" ? (enabled ? "Wird geteilt" : "Teilen") : enabled ? "An" : "Aus"; const classes = icon === "screen" ? enabled ? "bg-emerald-600 text-white hover:bg-emerald-500 hover:shadow-emerald-900/30" : "bg-white/10 text-white hover:bg-white/20" : enabled ? "bg-white/10 text-white hover:bg-white/20" : "bg-red-600 text-white hover:bg-red-500 hover:shadow-red-900/30"; return ( ); }