Files
anouma/components/meeting/CallRoom.tsx
T

319 lines
17 KiB
TypeScript

"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<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 router = useRouter();
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 roomRef = useRef<Room | null>(null);
const localTracksRef = useRef<LocalTrack[]>([]);
const activeSpeakersRef = useRef<Set<string>>(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<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:in"),
);
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 <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") 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 || "Die Verbindung zum Meeting wurde beendet."}</p><a href={`/termine/${slug}`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zum Termin</a></div>;
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)} className="rounded-full bg-white/10 px-3 py-2 text-xs hover:bg-white/15">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-72 lg:border-l lg:border-t-0"><h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/55">Teilnehmer</h2><ul className="space-y-3">{participants.map((participant) => <li key={participant.identity} className="flex items-center justify-between gap-2 text-sm"><span className="truncate">● {participant.name}{participant.identity === local?.identity && " (du)"}</span>{session.role === "host" && participant.identity !== local?.identity && <button type="button" onClick={() => void kickParticipant(participant.identity)} className="shrink-0 text-xs text-white/50 hover:text-red-300">entfernen</button>}</li>)}</ul></aside>}
</div>
<footer className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
<ControlButton active={micOn} onClick={() => void toggleMic()} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
<ControlButton active={camOn} onClick={() => void toggleCamera()} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
{session.role === "host" && <ControlButton active={sharingScreen} onClick={() => void toggleScreenShare()} label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"} icon="screen" />}
<button type="button" onClick={leaveMeeting} className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800">Meeting verlassen</button>
</footer>
</main>
);
}
function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) {
return (
<button type="button" onClick={onClick} title={label} aria-pressed={active} className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"}`}>
<span className="sr-only">{label}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" 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" /></>}
{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" /></>}
{icon === "screen" && <><rect x="3" y="4" width="18" height="12" rx="1.5" /><path d="M8 20h8M12 16v4" /></>}
</svg>
</button>
);
}