diff --git a/components/meeting/CallRoom.tsx b/components/meeting/CallRoom.tsx index 0613648..0742ae0 100644 --- a/components/meeting/CallRoom.tsx +++ b/components/meeting/CallRoom.tsx @@ -1,7 +1,6 @@ "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"; @@ -32,6 +31,14 @@ function participantRole(participant: Participant, fallback: "host" | "participa 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, @@ -52,7 +59,6 @@ function snapshotParticipant( } export function CallRoom({ slug }: { slug: string }) { - const router = useRouter(); const [session, setSession] = useState(null); const [phase, setPhase] = useState("loading"); const [errorMessage, setErrorMessage] = useState(null); @@ -63,11 +69,14 @@ export function CallRoom({ slug }: { slug: string }) { 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"); @@ -132,6 +141,28 @@ export function CallRoom({ slug }: { slug: string }) { 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: LocalTrack[]) { if (!session) return; setPhase("connecting"); @@ -156,7 +187,7 @@ export function CallRoom({ slug }: { slug: string }) { const room = new Room({ adaptiveStream: true, dynacast: true }); roomRef.current = room; - const refresh = () => refreshParticipants(room); + const refresh = () => syncMeetingView(room); room.on(RoomEvent.Connected, refresh); room.on(RoomEvent.ParticipantConnected, refresh); @@ -183,10 +214,10 @@ export function CallRoom({ slug }: { slug: string }) { if (reason === DisconnectReason.PARTICIPANT_REMOVED) { localTracksRef.current.forEach((track) => track.stop()); localTracksRef.current = []; - sessionStorage.removeItem(MEETING_SESSION_KEY); + clearStoredSession(); setPhase("kicked"); } else if (!intentionalCloseRef.current) { - setPhase("ended"); + finishCall(); } }); room.on(RoomEvent.MediaDevicesError, () => setErrorMessage("Kamera oder Mikrofon konnten nicht verwendet werden.")); @@ -200,6 +231,8 @@ export function CallRoom({ slug }: { slug: string }) { 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)); + connectedAtRef.current = Date.now(); + setCallDurationMs(null); setPhase("connected"); } catch (error) { localTracksRef.current.forEach((track) => track.stop()); @@ -213,24 +246,48 @@ export function CallRoom({ slug }: { slug: string }) { } async function toggleMic() { + const room = roomRef.current; + if (!room || pendingControl) return; const next = !micOn; - await roomRef.current?.localParticipant.setMicrophoneEnabled(next); - setMicOn(next); + 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; - await roomRef.current?.localParticipant.setCameraEnabled(next); - setCamOn(next); + 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 roomRef.current?.localParticipant.setScreenShareEnabled(next); + await room.localParticipant.setScreenShareEnabled(next); setSharingScreen(next); + if (next) setLayout("screen"); } catch { setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden."); + } finally { + setPendingControl(null); } } @@ -254,8 +311,8 @@ export function CallRoom({ slug }: { slug: string }) { roomRef.current = null; localTracksRef.current.forEach((track) => track.stop()); localTracksRef.current = []; - sessionStorage.removeItem(MEETING_SESSION_KEY); - router.push(`/termine/${slug}`); + clearStoredSession(); + finishCall(); } if (phase === "loading") return
Meeting wird geladen …
; @@ -265,7 +322,24 @@ export function CallRoom({ slug }: { slug: string }) { 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
; + 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); @@ -305,24 +379,39 @@ export function CallRoom({ slug }: { slug: string }) { {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" />} - + void toggleMic()} label="Mikrofon" icon="mic" /> + void toggleCamera()} label="Kamera" icon="cam" /> + {session.role === "host" && void toggleScreenShare()} label="Bildschirm teilen" icon="screen" />} +
); } -function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "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 ( - ); } diff --git a/components/meeting/PreJoinScreen.tsx b/components/meeting/PreJoinScreen.tsx index ee58fd1..34ab512 100644 --- a/components/meeting/PreJoinScreen.tsx +++ b/components/meeting/PreJoinScreen.tsx @@ -1,8 +1,24 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createLocalTracks, Track, type LocalTrack } from "livekit-client"; +function describeDeviceError(error: unknown): string { + const name = error instanceof Error ? error.name : ""; + + if (name === "NotAllowedError" || name === "SecurityError") { + return "Der Browser blockiert Kamera oder Mikrofon. Erlaube beide über das Schloss-Symbol neben der Adresse und versuche es danach erneut."; + } + if (name === "NotFoundError") { + return "Es wurde keine Kamera oder kein Mikrofon gefunden. Prüfe, ob beide Geräte angeschlossen und in den Systemeinstellungen freigegeben sind."; + } + if (name === "NotReadableError" || name === "TrackStartError") { + return "Kamera oder Mikrofon werden gerade von einer anderen App verwendet. Schließe zum Beispiel Zoom, Teams oder eine andere Browser-Registerkarte und versuche es erneut."; + } + + return "Kamera oder Mikrofon konnten nicht gestartet werden. Du kannst dem Meeting trotzdem ohne sie beitreten."; +} + export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[]) => void }) { const [tracks, setTracks] = useState([]); const [error, setError] = useState(null); @@ -12,7 +28,6 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[ const [requestingDevices, setRequestingDevices] = useState(false); const videoRef = useRef(null); const handedOffRef = useRef(false); - const initialDeviceRequestRef = useRef(false); const videoTrack = tracks.find((track) => track.kind === Track.Kind.Video); const audioTrack = tracks.find((track) => track.kind === Track.Kind.Audio); @@ -32,28 +47,24 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[ }; }, [tracks]); - const requestDevices = useCallback(async () => { + async function requestDevices() { if (requestingDevices) return; - tracks.forEach((track) => track.stop()); setError(null); setRequestingDevices(true); try { + // This must originate from the button click. Some privacy-focused + // browsers deliberately ignore a getUserMedia request from useEffect. const nextTracks = await createLocalTracks({ audio: true, video: true }); + tracks.forEach((track) => track.stop()); setTracks(nextTracks); setMicOn(true); setCameraOn(true); - } catch { - setError("Kamera oder Mikrofon konnten nicht freigegeben werden. Du kannst trotzdem ohne sie beitreten."); + } catch (requestError) { + setError(describeDeviceError(requestError)); } finally { setRequestingDevices(false); } - }, [requestingDevices, tracks]); - - useEffect(() => { - if (initialDeviceRequestRef.current) return; - initialDeviceRequestRef.current = true; - void requestDevices(); - }, [requestDevices]); + } async function toggleMic() { if (!audioTrack) return; @@ -102,8 +113,16 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
{videoTrack && cameraOn ? (