319 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|