Integrate LiveKit SFU and harden booking workflow

This commit is contained in:
2026-09-16 19:38:28 +02:00
parent f1cd07b3ad
commit d0864e2911
55 changed files with 1648 additions and 1234 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea
* validation attributes, autoComplete, etc. all keep working exactly as
* before.
*/
const fieldClass =
export const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor: string; hidden?: boolean }) {
@@ -155,13 +155,21 @@
padding: 6px 10px;
border-radius: 8px;
background: #f7f4f1;
text-decoration: none;
color: #1f1f1f;
font-size: 13px;
}
.entry:hover {
background: #efe7e1;
}
.entryLink {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
color: inherit;
text-decoration: none;
}
.badge {
font-size: 10px;
+31 -16
View File
@@ -1,6 +1,8 @@
import type { AdminViewServerProps } from "payload";
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
import { berlinParts } from "@/lib/calendar/timezone";
import { CalendarFeedPanel } from "./CalendarFeedPanel";
import { AdminCallButton } from "./AdminCallButton";
import styles from "./AdminCalendarView.module.css";
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -27,7 +29,8 @@ const BADGE_CLASS: Record<string, string> = {
};
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
const { year, month, day } = berlinParts(d);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function parseMonthParam(value: string | null): Date {
if (value && /^\d{4}-\d{2}$/.test(value)) {
@@ -40,7 +43,11 @@ function monthParam(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return new Date(iso).toLocaleTimeString("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
}
export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) {
@@ -48,7 +55,9 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
const searchParams = req.searchParams;
const month = parseMonthParam(searchParams.get("month"));
const selectedDayParam = searchParams.get("day");
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T00:00:00`) : null;
// Noon UTC stays on the same Berlin calendar day even when the server runs
// in UTC and the selected date is around a DST transition.
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T12:00:00Z`) : null;
const gridStart = new Date(month.getFullYear(), month.getMonth(), 1);
const firstWeekday = (gridStart.getDay() + 6) % 7;
@@ -82,7 +91,10 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
const dayItems = selectedDay ? (itemsByDay.get(dateKey(selectedDay)) ?? []) : [];
const hours = Array.from({ length: 13 }, (_, i) => i + 8); // 08:00–20:00
const startHours = dayItems.length ? dayItems.map((item) => berlinParts(new Date(item.startTime)).hour) : [];
const firstHour = Math.min(8, ...startHours);
const lastHour = Math.max(20, ...startHours);
const hours = Array.from({ length: lastHour - firstHour + 1 }, (_, i) => i + firstHour);
return (
<div className={styles.page}>
@@ -156,23 +168,26 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
{selectedDay &&
dayItems.length > 0 &&
hours.map((hour) => {
const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour);
const hourEntries = dayItems.filter((item) => berlinParts(new Date(item.startTime)).hour === hour);
return (
<div key={hour} className={styles.hourRow}>
<div className={styles.hourLabel}>{String(hour).padStart(2, "0")}:00</div>
<div className={styles.hourContent}>
{hourEntries.map((entry) => (
<a key={entry.id} href={entry.href} className={styles.entry}>
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
{STATUS_LABEL[entry.status] ?? entry.status}
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
</a>
<div key={entry.id} className={styles.entry}>
<a href={entry.href} className={styles.entryLink}>
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
{STATUS_LABEL[entry.status] ?? entry.status}
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
</a>
{entry.callEventSlug && <AdminCallButton eventSlug={entry.callEventSlug} />}
</div>
))}
</div>
</div>
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [url, setUrl] = useState<string | null>(null);
async function startCall() {
setLoading(true);
setError(null);
setCopied(false);
try {
const response = await fetch("/api/admin/meetings/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ eventSlug }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
return;
}
setUrl(data.testUrl);
try {
await navigator.clipboard.writeText(data.testUrl);
setCopied(true);
} catch {
setError("Der Link wurde erzeugt. Bitte kopiere ihn aus dem Feld.");
}
} catch {
setError("Verbindung fehlgeschlagen.");
} finally {
setLoading(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<button
type="button"
onClick={startCall}
disabled={loading}
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
>
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
</button>
{url && !copied && (
<input
type="text"
readOnly
value={url}
aria-label="Test-Link"
onFocus={(event) => event.currentTarget.select()}
className="w-56 rounded border border-anouma-taupe/30 bg-white px-2 py-1 text-xs text-anouma-plum"
/>
)}
{error && <span className="text-xs text-red-700">{error}</span>}
</span>
);
}
+14 -16
View File
@@ -4,15 +4,10 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest, Event, Offer } from "@/payload-types";
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
import type { MeetingStatus } from "@/lib/meeting/status";
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
export function BookingCard({ booking }: { booking: BookingRequest }) {
export function BookingCard({ booking, meetingStatus }: { booking: BookingRequest; meetingStatus?: MeetingStatus | null }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +41,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
<div>
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
<p className="mt-1 text-sm text-anouma-plum/80">
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)}
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<BookingStatusBadge status={displayStatus} />
@@ -62,8 +57,8 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
<p className="mt-1 text-base text-anouma-plum">
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "}
{fmtTime(booking.proposedAlternative.endTime!)}
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "}
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
</p>
<div className="mt-3 flex flex-wrap gap-3">
<button
@@ -88,14 +83,17 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
{booking.status === "confirmed" && (
<div className="mt-4 space-y-3">
{booking.appointmentType === "online" && event && (
<a
href={`/termine/${event.slug}/beitreten`}
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
>
{booking.appointmentType === "online" && event && meetingStatus !== "ended" && meetingStatus !== "scheduled" && (
<a href={`/termine/${event.slug}/beitreten`} className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum">
Video-Call betreten
</a>
)}
{booking.appointmentType === "online" && event && meetingStatus === "scheduled" && (
<p className="text-sm text-anouma-plum/75">Der Video-Call wird 10 Minuten vor Beginn freigeschaltet.</p>
)}
{booking.appointmentType === "online" && event && meetingStatus === "ended" && (
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
)}
+4 -7
View File
@@ -5,19 +5,16 @@ import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { TextAreaField, FormError } from "@/components/FormField";
import { track } from "@/lib/analytics/client";
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
return berlinDateKey(d);
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
}
const fmtTime = (iso: string) => formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
const fmtDate = (iso: string) => formatBerlinDate(new Date(iso), { weekday: "long", day: "2-digit", month: "long" });
export function BookingWidget({
offerSlug,
+3 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { berlinDateKey, formatBerlinDate } from "@/lib/calendar/timezone";
export type CalendarMarker = {
date: Date;
@@ -23,7 +24,7 @@ function startOfMonth(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
return berlinDateKey(d);
}
export function MonthCalendar({
@@ -62,7 +63,7 @@ export function MonthCalendar({
return result;
}, [month]);
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
const monthLabel = formatBerlinDate(month, { month: "long", year: "numeric" });
const today = dateKey(new Date());
return (
+6 -8
View File
@@ -4,13 +4,11 @@ import { useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { BookingCard } from "./BookingCard";
import { resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest } from "@/payload-types";
import type { BookingRequest, MeetingSetting } from "@/payload-types";
import { berlinDateKey } from "@/lib/calendar/timezone";
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
}
export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
export function PersonalCalendar({ bookings, meetingSettings }: { bookings: BookingRequest[]; meetingSettings: MeetingSetting }) {
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const markers: CalendarMarker[] = bookings.map((b) => ({
@@ -19,7 +17,7 @@ export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
}));
const selectedBookings = selectedDay
? bookings.filter((b) => dateKey(new Date(b.date)) === dateKey(selectedDay))
? bookings.filter((b) => berlinDateKey(new Date(b.date)) === berlinDateKey(selectedDay))
: [];
return (
@@ -30,7 +28,7 @@ export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
selectedBookings.length > 0 ? (
<div className="space-y-4">
{selectedBookings.map((b) => (
<BookingCard key={b.id} booking={b} />
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
))}
</div>
) : (
+256 -416
View File
@@ -1,478 +1,318 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { VideoTile } from "./VideoTile";
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";
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
type Phase = "loading" | "prejoin" | "connecting" | "connected" | "kicked" | "ended" | "error";
type LayoutMode = "grid" | "speaker" | "screen";
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
type ParticipantView = {
identity: string;
name: string;
role: "host" | "participant";
cameraTrack: Track | null;
microphoneTrack: Track | null;
screenTrack: Track | null;
speaking: boolean;
microphoneEnabled: boolean;
cameraEnabled: boolean;
};
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
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 [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
const [localStream, setLocalStream] = useState<MediaStream | 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 [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 remotesRef = useRef<Map<string, RemoteEntry>>(new Map());
const roomRef = useRef<Room | null>(null);
const localTracksRef = useRef<LocalTrack[]>([]);
const activeSpeakersRef = useRef<Set<string>>(new Set());
const intentionalCloseRef = useRef(false);
useEffect(() => {
remotesRef.current = remotes;
}, [remotes]);
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.");
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;
}
let parsed: StoredMeetingSession;
try {
parsed = JSON.parse(raw);
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 {
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`);
wsRef.current = ws;
// Token travels as the first message, not a `?token=` query param, so
// it doesn't end up persisted in reverse-proxy/access logs.
ws.onopen = () => {
ws.send(JSON.stringify({ type: "auth", token: parsed.token }));
};
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;
}
case "auth-error": {
setPhase("error");
setErrorMessage("Die Meeting-Sitzung ist abgelaufen oder ungültig.");
cleanup();
sessionStorage.removeItem(MEETING_SESSION_KEY);
break;
}
}
}
function remotesLookupName(participantId: string): string {
return remotesRef.current.get(participantId)?.name ?? "Teilnehmer:in";
queueMicrotask(() => {
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
});
}
return () => {
cancelled = true;
cleanup();
intentionalCloseRef.current = true;
roomRef.current?.disconnect();
roomRef.current = null;
localTracksRef.current.forEach((track) => track.stop());
localTracksRef.current = [];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug]);
function toggleMic() {
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
setMicOn((v) => !v);
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]);
}
function toggleCam() {
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
setCamOn((v) => !v);
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() {
if (sharingScreen) {
stopScreenShare();
return;
}
const next = !sharingScreen;
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);
await roomRef.current?.localParticipant.setScreenShareEnabled(next);
setSharingScreen(next);
} catch {
// User cancelled the share dialog — nothing to do.
setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden.");
}
}
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);
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",
});
setSharingScreen(false);
}
function kickParticipant(participantId: string) {
send({ type: "kick", targetParticipantId: participantId });
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setErrorMessage(data.error || "Teilnehmer konnte nicht entfernt werden.");
}
}
function leaveMeeting() {
cleanup();
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" || 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 === "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-[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>
);
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>;
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()];
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 (
<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"} />
))}
<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>
{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>
)}
</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>
<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>
<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";
}) {
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"
}`}
>
<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} />
<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>
);
}
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>
);
}
+31 -13
View File
@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { TextField, FormError } from "@/components/FormField";
import { fieldClass, TextField, FormError } from "@/components/FormField";
export const MEETING_SESSION_KEY = "anouma-meeting-session";
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
@@ -28,18 +28,20 @@ function getOrCreateClientId(): string {
export type StoredMeetingSession = {
token: string;
participantToken?: string;
serverUrl?: string;
participantId: string;
role: "host" | "participant";
name: string;
eventTitle: string;
eventSlug: string;
iceServers: RTCIceServer[];
};
export function JoinForm({ slug }: { slug: string }) {
const router = useRouter();
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -63,12 +65,13 @@ export function JoinForm({ slug }: { slug: string }) {
const session: StoredMeetingSession = {
token: data.token,
participantToken: data.participantToken,
serverUrl: data.serverUrl,
participantId: data.participantId,
role: data.role,
name,
eventTitle: data.eventTitle,
eventSlug: slug,
iceServers: data.iceServers,
};
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
router.push(`/termine/${slug}/call`);
@@ -90,16 +93,31 @@ export function JoinForm({ slug }: { slug: string }) {
placeholder="Name eingeben"
autoComplete="name"
/>
<TextField
label="Meeting-Passwort"
id="password"
type="text"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
autoComplete="off"
/>
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="password" className="block text-sm font-medium text-anouma-plum">
Meeting-Passwort
</label>
<button
type="button"
className="text-xs font-medium text-anouma-mauve-dark underline-offset-2 hover:underline"
onClick={() => setShowPassword((visible) => !visible)}
aria-pressed={showPassword}
>
{showPassword ? "Verbergen" : "Anzeigen"}
</button>
</div>
<input
id="password"
className={fieldClass}
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
autoComplete="off"
/>
</div>
{error && <FormError>{error}</FormError>}
<button
type="submit"
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createLocalTracks, Track, type LocalTrack } from "livekit-client";
export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[]) => void }) {
const [tracks, setTracks] = useState<LocalTrack[]>([]);
const [error, setError] = useState<string | null>(null);
const [micOn, setMicOn] = useState(true);
const [cameraOn, setCameraOn] = useState(true);
const [speakerTested, setSpeakerTested] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const handedOffRef = useRef(false);
const videoTrack = tracks.find((track) => track.kind === Track.Kind.Video);
const audioTrack = tracks.find((track) => track.kind === Track.Kind.Audio);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement || !videoTrack) return;
videoTrack.attach(videoElement);
return () => {
videoTrack.detach(videoElement);
};
}, [videoTrack]);
useEffect(() => {
return () => {
if (!handedOffRef.current) tracks.forEach((track) => track.stop());
};
}, [tracks]);
async function requestDevices() {
tracks.forEach((track) => track.stop());
setError(null);
try {
const nextTracks = await createLocalTracks({ audio: true, video: true });
setTracks(nextTracks);
setMicOn(true);
setCameraOn(true);
} catch {
setError("Kamera oder Mikrofon konnten nicht freigegeben werden. Du kannst trotzdem ohne sie beitreten.");
}
}
async function toggleMic() {
if (!audioTrack) return;
if (micOn) await audioTrack.mute();
else await audioTrack.unmute();
setMicOn(!micOn);
}
async function toggleCamera() {
if (!videoTrack) return;
if (cameraOn) await videoTrack.mute();
else await videoTrack.unmute();
setCameraOn(!cameraOn);
}
function testSpeaker() {
const AudioContextClass = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const context = new AudioContextClass();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 660;
gain.gain.value = 0.08;
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + 0.25);
oscillator.addEventListener("ended", () => void context.close());
setSpeakerTested(true);
}
function continueToCall() {
handedOffRef.current = true;
onContinue(tracks);
}
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-3xl rounded-[2rem] border border-white/10 bg-neutral-900 p-6 shadow-2xl sm:p-8">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-white/50">Vorbereitung</p>
<h1 className="mt-2 font-serif text-3xl">Prüfe deine Geräte</h1>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-white/65">
Erlaube Kamera und Mikrofon, teste kurz den Ton und tritt dann dem geschützten Meeting bei.
</p>
<div className="mt-7 grid gap-5 lg:grid-cols-[1.35fr_1fr]">
<div className="relative aspect-video overflow-hidden rounded-3xl bg-neutral-800">
{videoTrack && cameraOn ? (
<video ref={videoRef} autoPlay muted playsInline className="h-full w-full object-cover [transform:scaleX(-1)]" />
) : (
<div className="flex h-full items-center justify-center text-sm text-white/45">Kamera ausgeschaltet</div>
)}
{tracks.length > 0 && (
<span className="absolute bottom-3 left-3 rounded-full bg-black/50 px-3 py-1 text-xs text-white/80">
{micOn ? "Mikrofon an" : "Mikrofon aus"}
</span>
)}
</div>
<div className="flex flex-col justify-center gap-3">
<button
type="button"
onClick={requestDevices}
className="rounded-full bg-white px-5 py-3 text-sm font-medium text-neutral-900 hover:bg-white/85"
>
{tracks.length > 0 ? "Geräte erneut prüfen" : "Kamera & Mikrofon aktivieren"}
</button>
<div className="grid grid-cols-2 gap-3">
<button type="button" onClick={() => void toggleMic()} disabled={!audioTrack} className="rounded-2xl bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
{micOn ? "Mikrofon aus" : "Mikrofon an"}
</button>
<button type="button" onClick={() => void toggleCamera()} disabled={!videoTrack} className="rounded-2xl bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
{cameraOn ? "Kamera aus" : "Kamera an"}
</button>
</div>
<button type="button" onClick={testSpeaker} className="rounded-2xl border border-white/15 px-4 py-3 text-left text-xs text-white/80 hover:bg-white/10">
{speakerTested ? "✓ Lautsprecher getestet" : "Lautsprecher testen"}
</button>
{error && <p role="alert" className="text-xs leading-relaxed text-amber-200">{error}</p>}
<button type="button" onClick={continueToCall} className="mt-2 rounded-full bg-anouma-mauve-dark px-5 py-3.5 text-sm font-medium hover:bg-anouma-plum">
Meeting beitreten
</button>
</div>
</div>
</section>
</main>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useRef } from "react";
import type { Track } from "livekit-client";
export function SfuVideoTile({
videoTrack,
audioTrack,
name,
isLocal = false,
isHost = false,
muted = false,
videoOff = false,
speaking = false,
compact = false,
}: {
videoTrack?: Track | null;
audioTrack?: Track | null;
name: string;
isLocal?: boolean;
isHost?: boolean;
muted?: boolean;
videoOff?: boolean;
speaking?: boolean;
compact?: boolean;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement || !videoTrack || videoOff) return;
videoTrack.attach(videoElement);
return () => {
videoTrack.detach(videoElement);
};
}, [videoTrack, videoOff]);
useEffect(() => {
const audioElement = audioRef.current;
if (!audioElement || !audioTrack || isLocal) return;
audioTrack.attach(audioElement);
return () => {
audioTrack.detach(audioElement);
};
}, [audioTrack, isLocal]);
return (
<div className={`relative aspect-video overflow-hidden rounded-2xl bg-neutral-800 ${speaking ? "ring-2 ring-anouma-rose" : ""}`}>
{videoTrack && !videoOff ? (
<video ref={videoRef} autoPlay playsInline muted={isLocal} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-800">
<div className={`${compact ? "h-10 w-10 text-lg" : "h-16 w-16 text-2xl"} flex items-center justify-center rounded-full bg-neutral-700 font-serif text-white`}>
{name.charAt(0).toUpperCase()}
</div>
</div>
)}
{!isLocal && <audio ref={audioRef} autoPlay />}
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/75 to-transparent px-3 py-2">
<span className="truncate text-sm font-medium text-white">{name}{isLocal && " (du)"}</span>
<div className="flex items-center gap-2 text-white/80">
{muted && <span aria-label="Mikrofon stumm">⌁</span>}
{isHost && <span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">Host</span>}
</div>
</div>
</div>
);
}