- setup.sh: interactive/non-interactive one-shot installer (build, DB healthcheck, migrate, seed, start), idempotent secret generation, NPM reverse-proxy network auto-detection and optional join, optional AUTO_UPDATE cron install. - update.sh: release-tag-gated updates only (never bare main), DB backup with retention before every update, lock file against concurrent runs, automatic code rollback on failed post-update healthcheck. - Dockerfile: multi-stage build, non-root user, built-in HEALTHCHECK against the new /api/health route, wholesale COPY so new source dirs (e.g. scripts/) never silently go missing at runtime. - docker-compose.yml: internal anouma-network (configurable), named volume for Postgres, app depends_on postgres healthy, no unnecessary published ports; docker-compose.override.yml.example documents joining an existing NPM network without ever touching NPM itself. - Fix host-detection: isHost was Boolean(user), wrongly granting host privileges to logged-in customers; now checks user.collection === "users". - Wire configurable STUN/TURN servers through to the WebRTC client (lib/meeting/iceServers.ts) so a TURN server can be added later via env vars only, no code changes. - DEPLOYMENT.md, updated README.md and .env.example documenting the whole flow: NPM integration, env vars, WebRTC, updates, backups, rollback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { VideoTile } from "./VideoTile";
|
|
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
|
|
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
|
|
|
|
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
|
|
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
|
|
|
|
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
|
|
|
|
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 [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
|
|
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
|
const [micOn, setMicOn] = useState(true);
|
|
const [camOn, setCamOn] = useState(true);
|
|
const [sharingScreen, setSharingScreen] = useState(false);
|
|
const [showParticipants, setShowParticipants] = useState(false);
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const pcsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
|
const localStreamRef = useRef<MediaStream | null>(null);
|
|
const screenStreamRef = useRef<MediaStream | null>(null);
|
|
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
|
|
|
const send = useCallback((message: ClientToServerMessage) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify(message));
|
|
}
|
|
}, []);
|
|
|
|
const closePeer = useCallback((participantId: string) => {
|
|
pcsRef.current.get(participantId)?.close();
|
|
pcsRef.current.delete(participantId);
|
|
setRemotes((prev) => {
|
|
const next = new Map(prev);
|
|
next.delete(participantId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const createPeerConnection = useCallback(
|
|
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: sessionRef.current?.iceServers?.length ? sessionRef.current.iceServers : DEFAULT_ICE_SERVERS,
|
|
});
|
|
pcsRef.current.set(participantId, pc);
|
|
|
|
localStreamRef.current?.getTracks().forEach((track) => {
|
|
pc.addTrack(track, localStreamRef.current!);
|
|
});
|
|
|
|
pc.ontrack = (event) => {
|
|
setRemotes((prev) => {
|
|
const next = new Map(prev);
|
|
const existing = next.get(participantId);
|
|
next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null });
|
|
return next;
|
|
});
|
|
};
|
|
|
|
pc.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() });
|
|
}
|
|
};
|
|
|
|
setRemotes((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null });
|
|
return next;
|
|
});
|
|
|
|
if (isInitiator) {
|
|
pc.createOffer()
|
|
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
|
|
.then((offer) => send({ type: "offer", to: participantId, payload: offer }))
|
|
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
|
}
|
|
|
|
return pc;
|
|
},
|
|
[send],
|
|
);
|
|
|
|
const cleanup = useCallback(() => {
|
|
wsRef.current?.close();
|
|
wsRef.current = null;
|
|
pcsRef.current.forEach((pc) => pc.close());
|
|
pcsRef.current.clear();
|
|
localStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
localStreamRef.current = null;
|
|
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
screenStreamRef.current = null;
|
|
}, []);
|
|
|
|
// Load session + acquire local media + open signaling connection. This
|
|
// reads sessionStorage and opens external connections, so it belongs in an
|
|
// effect; the early setState calls below are unavoidable for the
|
|
// missing/invalid-session error paths (no server-renderable equivalent).
|
|
useEffect(() => {
|
|
const raw = sessionStorage.getItem(MEETING_SESSION_KEY);
|
|
if (!raw) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setPhase("error");
|
|
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
|
return;
|
|
}
|
|
let parsed: StoredMeetingSession;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
setPhase("error");
|
|
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
|
return;
|
|
}
|
|
if (parsed.eventSlug !== slug) {
|
|
setPhase("error");
|
|
setErrorMessage("Diese Sitzung gehört zu einem anderen Termin.");
|
|
return;
|
|
}
|
|
setSession(parsed);
|
|
sessionRef.current = parsed;
|
|
setPhase("connecting");
|
|
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
if (cancelled) {
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
return;
|
|
}
|
|
localStreamRef.current = stream;
|
|
setLocalStream(stream);
|
|
} catch {
|
|
// Continue without local media — participant can still watch/listen to others.
|
|
setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen.");
|
|
}
|
|
|
|
if (cancelled) return;
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
|
|
wsRef.current = ws;
|
|
|
|
ws.onmessage = (event) => {
|
|
const message: ServerToClientMessage = JSON.parse(event.data);
|
|
handleServerMessage(message);
|
|
};
|
|
ws.onclose = () => {
|
|
setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended"));
|
|
};
|
|
ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen.");
|
|
})();
|
|
|
|
function handleServerMessage(message: ServerToClientMessage) {
|
|
const me = sessionRef.current;
|
|
if (!me) return;
|
|
|
|
switch (message.type) {
|
|
case "welcome": {
|
|
setPhase("connected");
|
|
for (const p of message.participants) {
|
|
createPeerConnection(p.participantId, p.name, p.role, true);
|
|
}
|
|
break;
|
|
}
|
|
case "peer-joined": {
|
|
createPeerConnection(message.participantId, message.name, message.role, false);
|
|
break;
|
|
}
|
|
case "peer-left": {
|
|
closePeer(message.participantId);
|
|
break;
|
|
}
|
|
case "offer": {
|
|
const pc =
|
|
pcsRef.current.get(message.from) ??
|
|
createPeerConnection(message.from, remotesLookupName(message.from), "participant", false);
|
|
pc.setRemoteDescription(new RTCSessionDescription(message.payload))
|
|
.then(() => pc.createAnswer())
|
|
.then((answer) => pc.setLocalDescription(answer).then(() => answer))
|
|
.then((answer) => send({ type: "answer", to: message.from, payload: answer }))
|
|
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
|
break;
|
|
}
|
|
case "answer": {
|
|
pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload));
|
|
break;
|
|
}
|
|
case "ice-candidate": {
|
|
pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {});
|
|
break;
|
|
}
|
|
case "kicked": {
|
|
setPhase("kicked");
|
|
cleanup();
|
|
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function remotesLookupName(participantId: string): string {
|
|
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [slug]);
|
|
|
|
function toggleMic() {
|
|
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
|
|
setMicOn((v) => !v);
|
|
}
|
|
|
|
function toggleCam() {
|
|
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
|
|
setCamOn((v) => !v);
|
|
}
|
|
|
|
async function toggleScreenShare() {
|
|
if (sharingScreen) {
|
|
stopScreenShare();
|
|
return;
|
|
}
|
|
try {
|
|
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
|
screenStreamRef.current = screenStream;
|
|
const screenTrack = screenStream.getVideoTracks()[0];
|
|
screenTrack.onended = () => stopScreenShare();
|
|
|
|
pcsRef.current.forEach((pc) => {
|
|
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
|
sender?.replaceTrack(screenTrack);
|
|
});
|
|
setSharingScreen(true);
|
|
} catch {
|
|
// User cancelled the share dialog — nothing to do.
|
|
}
|
|
}
|
|
|
|
function stopScreenShare() {
|
|
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
screenStreamRef.current = null;
|
|
const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null;
|
|
pcsRef.current.forEach((pc) => {
|
|
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
|
sender?.replaceTrack(cameraTrack);
|
|
});
|
|
setSharingScreen(false);
|
|
}
|
|
|
|
function kickParticipant(participantId: string) {
|
|
send({ type: "kick", targetParticipantId: participantId });
|
|
}
|
|
|
|
function leaveMeeting() {
|
|
cleanup();
|
|
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
|
router.push(`/termine/${slug}`);
|
|
}
|
|
|
|
if (phase === "loading" || phase === "connecting") {
|
|
return (
|
|
<div className="flex min-h-[70vh] items-center justify-center bg-neutral-900 text-white">
|
|
<p className="text-sm text-white/70">Verbindung wird aufgebaut …</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (phase === "error" || !session) {
|
|
return (
|
|
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
|
<p className="text-lg">{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-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
|
<p className="text-lg">Du wurdest vom Meeting entfernt.</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"
|
|
>
|
|
Erneut beitreten
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (phase === "ended") {
|
|
return (
|
|
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
|
<p className="text-lg">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 isHost = session.role === "host";
|
|
const remoteList = [...remotes.values()];
|
|
|
|
return (
|
|
<div className="flex min-h-[80vh] flex-col bg-neutral-900 text-white">
|
|
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
|
<span className="text-sm font-medium">{session.eventTitle}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowParticipants((v) => !v)}
|
|
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium hover:bg-white/20"
|
|
>
|
|
Teilnehmer ({remoteList.length + 1})
|
|
</button>
|
|
</div>
|
|
|
|
{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">
|
|
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<VideoTile stream={localStream} name={session.name} isLocal isHost={isHost} muted={!micOn} videoOff={!camOn} />
|
|
{remoteList.map((r) => (
|
|
<VideoTile key={r.participantId} stream={r.stream} name={r.name} isHost={r.role === "host"} />
|
|
))}
|
|
</div>
|
|
|
|
{showParticipants && (
|
|
<aside className="w-64 shrink-0 border-l border-white/10 p-4">
|
|
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/60">Teilnehmer</h2>
|
|
<ul className="space-y-2">
|
|
<li className="flex items-center justify-between text-sm">
|
|
<span>● {session.name} (du)</span>
|
|
</li>
|
|
{remoteList.map((r) => (
|
|
<li key={r.participantId} className="flex items-center justify-between text-sm">
|
|
<span>● {r.name}</span>
|
|
{isHost && (
|
|
<button
|
|
type="button"
|
|
onClick={() => kickParticipant(r.participantId)}
|
|
className="text-xs text-white/50 hover:text-red-300"
|
|
>
|
|
entfernen
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
|
|
<ControlButton active={micOn} onClick={toggleMic} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
|
|
<ControlButton active={camOn} onClick={toggleCam} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
|
|
{isHost && (
|
|
<ControlButton
|
|
active={sharingScreen}
|
|
onClick={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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<Icon name={icon} off={!active} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) {
|
|
if (name === "mic") {
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
|
<path
|
|
d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0M12 18v3"
|
|
stroke="white"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
if (name === "cam") {
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
|
<rect x="2" y="6" width="14" height="12" rx="2" stroke="white" strokeWidth="1.8" />
|
|
<path d="M16 10l6-3v10l-6-3" stroke="white" strokeWidth="1.8" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<rect x="2" y="4" width="20" height="13" rx="2" stroke="white" strokeWidth="1.8" />
|
|
<path d="M8 21h8M12 17v4" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|