Files
anouma/components/meeting/CallRoom.tsx
T

449 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string>,
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<StoredMeetingSession | null>(null);
const [phase, setPhase] = useState<Phase>("loading");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [participants, setParticipants] = useState<ParticipantView[]>([]);
const [layout, setLayout] = useState<LayoutMode>("grid");
const [activeSpeakerId, setActiveSpeakerId] = useState<string | null>(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<number | null>(null);
const roomRef = useRef<Room | null>(null);
const localTracksRef = useRef<MediaStreamTrack[]>([]);
const activeSpeakersRef = useRef<Set<string>>(new Set());
const intentionalCloseRef = useRef(false);
const connectedAtRef = useRef<number | null>(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<StoredMeetingSession>;
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 <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/60">Meeting wird geladen …</div>;
if (phase === "prejoin" && session) return <PreJoinScreen onContinue={connectRoom} />;
if (phase === "connecting") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/65">Verbindung wird aufgebaut …</div>;
if (phase === "error" || !session) {
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p><a href={`/termine/${slug}/beitreten`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zur Beitrittsseite</a></div>;
}
if (phase === "kicked") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 px-6 text-center text-white"><p>Du wurdest vom Meeting entfernt.</p></div>;
if (phase === "ended") {
const isTestCall = session.eventSlug.startsWith("test-");
return (
<main className="flex min-h-screen items-center justify-center bg-neutral-950 px-5 py-10 text-white">
<section className="w-full max-w-lg rounded-[2rem] border border-white/10 bg-neutral-900 p-8 text-center shadow-2xl sm:p-10">
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-anouma-mauve-dark/30 text-2xl" aria-hidden="true">✓</span>
<p className="mt-6 text-xs font-medium uppercase tracking-[0.18em] text-white/50">Video-Call beendet</p>
<h1 className="mt-2 font-serif text-4xl">Danke für das Gespräch</h1>
<p className="mx-auto mt-4 max-w-sm text-sm leading-relaxed text-white/65">
{errorMessage || "Die Verbindung wurde ordentlich beendet."} Deine Gesprächsdauer: <span className="font-medium text-white">{formatCallDuration(callDurationMs)}</span>.
</p>
<a href={isTestCall ? "/admin" : `/termine/${slug}`} className="mt-8 inline-flex rounded-full bg-white px-6 py-3 text-sm font-medium text-neutral-900 transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/85 hover:shadow-lg hover:shadow-white/10 active:translate-y-0 active:scale-[0.98]">
{isTestCall ? "Zurück zum CRM" : "Zurück zum Termin"}
</a>
</section>
</main>
);
}
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 } = {}) => (
<SfuVideoTile
key={`${participant.identity}-${options.name ?? "camera"}`}
videoTrack={options.videoTrack ?? participant.cameraTrack}
audioTrack={participant.microphoneTrack}
name={options.name ?? participant.name}
isLocal={participant.identity === local?.identity}
isHost={participant.role === "host"}
muted={!participant.microphoneEnabled}
videoOff={!participant.cameraEnabled && !options.videoTrack}
speaking={participant.speaking}
compact={options.compact}
/>
);
return (
<main className="flex min-h-screen flex-col bg-neutral-950 text-white">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-5 py-4">
<div><p className="text-sm font-medium">{session.eventTitle}</p><p className="text-xs text-white/45">LiveKit-SFU · {participants.length} Teilnehmer</p></div>
<div className="flex items-center gap-2">
{(["grid", "speaker", "screen"] as LayoutMode[]).map((mode) => <button key={mode} type="button" onClick={() => setLayout(mode)} aria-pressed={layout === mode} className={`rounded-full px-3 py-2 text-xs ${layout === mode ? "bg-white text-neutral-900" : "bg-white/10 text-white/75 hover:bg-white/15"}`}>{mode === "grid" ? "Galerie" : mode === "speaker" ? "Sprecher" : "Bildschirm"}</button>)}
<button type="button" onClick={() => setShowParticipants((value) => !value)} aria-expanded={showParticipants} className={`rounded-full px-3 py-2 text-xs transition-colors ${showParticipants ? "bg-white text-neutral-900" : "bg-white/10 text-white hover:bg-white/15"}`}>
{session.role === "host" ? `Moderieren (${participants.length})` : `Teilnehmer (${participants.length})`}
</button>
</div>
</header>
{errorMessage && <p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>}
<div className="flex flex-1 flex-col lg:flex-row">
<section className="flex-1 p-4">
{layout === "grid" && <div className="grid auto-rows-fr grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">{participants.map((participant) => tile(participant, { videoTrack: participant.screenTrack ?? participant.cameraTrack, name: participant.screenTrack ? `${participant.name} · Bildschirm` : participant.name }))}</div>}
{layout === "speaker" && activeSpeaker && <div className="space-y-3"><div className="mx-auto max-w-5xl">{tile(activeSpeaker, { videoTrack: activeSpeaker.screenTrack ?? activeSpeaker.cameraTrack })}</div><div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{participants.filter((participant) => participant.identity !== activeSpeaker.identity).map((participant) => tile(participant, { compact: true }))}</div></div>}
{layout === "screen" && <div className="space-y-3">{screenOwner ? <div className="mx-auto max-w-6xl">{tile(screenOwner, { videoTrack: screenOwner.screenTrack, name: `${screenOwner.name} · Bildschirm` })}</div> : <div className="flex aspect-video items-center justify-center rounded-3xl border border-dashed border-white/15 text-sm text-white/45">Noch niemand teilt den Bildschirm.</div>}<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{participants.map((participant) => tile(participant, { compact: true }))}</div></div>}
</section>
{showParticipants && (
<aside className="w-full border-t border-white/10 p-4 lg:w-80 lg:border-l lg:border-t-0">
<h2 className="text-xs font-medium uppercase tracking-wide text-white/55">{session.role === "host" ? "Moderation" : "Teilnehmer"}</h2>
{session.role === "host" && (
<div className="mt-3 rounded-2xl border border-anouma-rose/20 bg-anouma-mauve-dark/20 p-3 text-xs leading-relaxed text-white/75">
Du bist Host. Hier kannst du sehen, wer beigetreten ist, und Personen aus dem Meeting entfernen.
</div>
)}
<ul className="mt-4 space-y-2">
{participants.map((participant) => {
const isYou = participant.identity === local?.identity;
return (
<li key={participant.identity} className="flex items-center justify-between gap-3 rounded-2xl bg-white/5 px-3 py-2.5 text-sm">
<span className="min-w-0 truncate">{participant.name}{isYou && " (du)"}</span>
{session.role === "host" && !isYou ? (
<button type="button" onClick={() => void kickParticipant(participant.identity)} className="shrink-0 rounded-full bg-red-500/15 px-3 py-1.5 text-xs font-medium text-red-200 transition-colors hover:bg-red-500 hover:text-white">
Entfernen
</button>
) : (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-white/45">{participant.role === "host" ? "Host" : "Im Call"}</span>
)}
</li>
);
})}
</ul>
{session.role === "host" && remoteParticipants.length === 0 && (
<p className="mt-4 text-xs leading-relaxed text-white/45">Sobald weitere Personen beitreten, kannst du sie hier direkt verwalten.</p>
)}
</aside>
)}
</div>
<footer className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
<ControlButton enabled={micOn} busy={pendingControl === "mic"} onClick={() => void toggleMic()} label="Mikrofon" icon="mic" />
<ControlButton enabled={camOn} busy={pendingControl === "cam"} onClick={() => void toggleCamera()} label="Kamera" icon="cam" />
{session.role === "host" && <ControlButton enabled={sharingScreen} busy={pendingControl === "screen"} onClick={() => void toggleScreenShare()} label="Bildschirm teilen" icon="screen" />}
<button type="button" onClick={leaveMeeting} className="flex min-w-[88px] flex-col items-center gap-1 rounded-2xl bg-red-600 px-4 py-2 text-xs font-medium text-white transition-all duration-200 hover:-translate-y-0.5 hover:bg-red-500 hover:shadow-lg hover:shadow-red-900/30 active:translate-y-0 active:scale-[0.97]"><span className="text-lg leading-none" aria-hidden="true">×</span>Verlassen</button>
</footer>
</main>
);
}
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 (
<button type="button" onClick={onClick} disabled={busy} title={actionLabel} aria-label={actionLabel} aria-pressed={enabled} className={`group flex min-w-[88px] flex-col items-center gap-1 rounded-2xl px-4 py-2 text-xs font-medium transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 active:scale-[0.97] disabled:cursor-wait disabled:opacity-70 ${classes}`}>
{busy ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-current/30 border-t-current" aria-hidden="true" />
) : (
<svg aria-hidden="true" viewBox="0 0 24 24" className={`h-5 w-5 fill-none stroke-current transition-transform duration-200 group-hover:scale-110 group-active:scale-90 ${icon === "screen" && enabled ? "animate-pulse" : ""}`} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{icon === "mic" && <><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8" />{!enabled && <path d="M4 4 20 20" strokeWidth="2.3" />}</>}
{icon === "cam" && <><path d="M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" /><path d="m17 10 5-3v10l-5-3" />{!enabled && <path d="M4 4 20 20" strokeWidth="2.3" />}</>}
{icon === "screen" && <><rect x="3" y="4" width="18" height="12" rx="1.5" /><path d="M8 20h8M12 16v4" /></>}
</svg>
)}
<span>{label}</span>
<span className="text-[10px] font-normal opacity-70">{statusLabel}</span>
</button>
);
}