fix: request media directly and expose host moderation

This commit is contained in:
2026-09-17 01:13:07 +02:00
parent 4edc5c2422
commit cb30ecade9
2 changed files with 91 additions and 36 deletions
+39 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { DisconnectReason, Room, RoomEvent, Track, type LocalTrack, type Participant } from "livekit-client";
import { DisconnectReason, Room, RoomEvent, Track, type Participant } from "livekit-client";
import { PreJoinScreen } from "./PreJoinScreen";
import { SfuVideoTile } from "./SfuVideoTile";
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
@@ -73,7 +73,7 @@ export function CallRoom({ slug }: { slug: string }) {
const [callDurationMs, setCallDurationMs] = useState<number | null>(null);
const roomRef = useRef<Room | null>(null);
const localTracksRef = useRef<LocalTrack[]>([]);
const localTracksRef = useRef<MediaStreamTrack[]>([]);
const activeSpeakersRef = useRef<Set<string>>(new Set());
const intentionalCloseRef = useRef(false);
const connectedAtRef = useRef<number | null>(null);
@@ -163,7 +163,7 @@ export function CallRoom({ slug }: { slug: string }) {
setPhase("ended");
}
async function connectRoom(previewTracks: LocalTrack[]) {
async function connectRoom(previewTracks: MediaStreamTrack[]) {
if (!session) return;
setPhase("connecting");
setErrorMessage(null);
@@ -225,12 +225,12 @@ export function CallRoom({ slug }: { slug: string }) {
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,
source: 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));
setMicOn(previewTracks.some((track) => track.kind === "audio" && track.enabled));
setCamOn(previewTracks.some((track) => track.kind === "video" && track.enabled));
connectedAtRef.current = Date.now();
setCallDurationMs(null);
setPhase("connected");
@@ -366,7 +366,9 @@ export function CallRoom({ slug }: { slug: string }) {
<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>
<button type="button" onClick={() => setShowParticipants((value) => !value)} aria-expanded={showParticipants} className={`rounded-full px-3 py-2 text-xs transition-colors ${showParticipants ? "bg-white text-neutral-900" : "bg-white/10 text-white hover:bg-white/15"}`}>
{session.role === "host" ? `Moderieren (${participants.length})` : `Teilnehmer (${participants.length})`}
</button>
</div>
</header>
{errorMessage && <p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>}
@@ -376,7 +378,36 @@ export function CallRoom({ slug }: { slug: string }) {
{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>}
{showParticipants && (
<aside className="w-full border-t border-white/10 p-4 lg:w-80 lg:border-l lg:border-t-0">
<h2 className="text-xs font-medium uppercase tracking-wide text-white/55">{session.role === "host" ? "Moderation" : "Teilnehmer"}</h2>
{session.role === "host" && (
<div className="mt-3 rounded-2xl border border-anouma-rose/20 bg-anouma-mauve-dark/20 p-3 text-xs leading-relaxed text-white/75">
Du bist Host. Hier kannst du sehen, wer beigetreten ist, und Personen aus dem Meeting entfernen.
</div>
)}
<ul className="mt-4 space-y-2">
{participants.map((participant) => {
const isYou = participant.identity === local?.identity;
return (
<li key={participant.identity} className="flex items-center justify-between gap-3 rounded-2xl bg-white/5 px-3 py-2.5 text-sm">
<span className="min-w-0 truncate">{participant.name}{isYou && " (du)"}</span>
{session.role === "host" && !isYou ? (
<button type="button" onClick={() => void kickParticipant(participant.identity)} className="shrink-0 rounded-full bg-red-500/15 px-3 py-1.5 text-xs font-medium text-red-200 transition-colors hover:bg-red-500 hover:text-white">
Entfernen
</button>
) : (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-white/45">{participant.role === "host" ? "Host" : "Im Call"}</span>
)}
</li>
);
})}
</ul>
{session.role === "host" && remoteParticipants.length === 0 && (
<p className="mt-4 text-xs leading-relaxed text-white/45">Sobald weitere Personen beitreten, kannst du sie hier direkt verwalten.</p>
)}
</aside>
)}
</div>
<footer className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
<ControlButton enabled={micOn} busy={pendingControl === "mic"} onClick={() => void toggleMic()} label="Mikrofon" icon="mic" />
+52 -28
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createLocalTracks, Track, type LocalTrack } from "livekit-client";
function describeDeviceError(error: unknown): string {
const name = error instanceof Error ? error.name : "";
@@ -19,43 +18,66 @@ function describeDeviceError(error: unknown): string {
return "Kamera oder Mikrofon konnten nicht gestartet werden. Du kannst dem Meeting trotzdem ohne sie beitreten.";
}
export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[]) => void }) {
const [tracks, setTracks] = useState<LocalTrack[]>([]);
export function PreJoinScreen({ onContinue }: { onContinue: (tracks: MediaStreamTrack[]) => void }) {
const [tracks, setTracks] = useState<MediaStreamTrack[]>([]);
const [error, setError] = useState<string | null>(null);
const [micOn, setMicOn] = useState(true);
const [cameraOn, setCameraOn] = useState(true);
const [speakerTested, setSpeakerTested] = useState(false);
const [requestingDevices, setRequestingDevices] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const tracksRef = useRef<MediaStreamTrack[]>([]);
const handedOffRef = useRef(false);
const videoTrack = tracks.find((track) => track.kind === Track.Kind.Video);
const audioTrack = tracks.find((track) => track.kind === Track.Kind.Audio);
const videoTrack = tracks.find((track) => track.kind === "video");
const audioTrack = tracks.find((track) => track.kind === "audio");
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement || !videoTrack) return;
videoTrack.attach(videoElement);
if (!videoElement || !videoTrack || !cameraOn) return;
videoElement.srcObject = new MediaStream([videoTrack]);
void videoElement.play().catch(() => {
// Autoplay may be restricted until the user interacts with the page.
});
return () => {
videoTrack.detach(videoElement);
videoElement.srcObject = null;
};
}, [videoTrack]);
}, [cameraOn, videoTrack]);
useEffect(() => {
return () => {
if (!handedOffRef.current) tracks.forEach((track) => track.stop());
if (!handedOffRef.current) tracksRef.current.forEach((track) => track.stop());
};
}, [tracks]);
}, []);
async function requestDevices() {
if (requestingDevices) return;
setError(null);
if (!navigator.mediaDevices?.getUserMedia) {
setError("Dein Browser unterstützt keine Kamera- und Mikrofonfreigabe für Video-Calls.");
return;
}
// Start the native browser request immediately while this click still has
// user activation. LiveKit receives these tracks only after the preview.
let request: Promise<MediaStream>;
try {
request = navigator.mediaDevices.getUserMedia({
audio: { autoGainControl: true, echoCancellation: true, noiseSuppression: true },
video: { facingMode: "user", height: { ideal: 720 }, width: { ideal: 1280 } },
});
} catch (requestError) {
setError(describeDeviceError(requestError));
return;
}
setRequestingDevices(true);
try {
// This must originate from the button click. Some privacy-focused
// browsers deliberately ignore a getUserMedia request from useEffect.
const nextTracks = await createLocalTracks({ audio: true, video: true });
tracks.forEach((track) => track.stop());
const stream = await request;
const nextTracks = stream.getTracks();
tracksRef.current.forEach((track) => track.stop());
tracksRef.current = nextTracks;
setTracks(nextTracks);
setMicOn(true);
setCameraOn(true);
@@ -66,18 +88,20 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
}
}
async function toggleMic() {
if (!audioTrack) return;
if (micOn) await audioTrack.mute();
else await audioTrack.unmute();
setMicOn(!micOn);
function toggleMic() {
const track = tracksRef.current.find((candidate) => candidate.kind === "audio");
if (!track) return;
const next = !micOn;
track.enabled = next;
setMicOn(next);
}
async function toggleCamera() {
if (!videoTrack) return;
if (cameraOn) await videoTrack.mute();
else await videoTrack.unmute();
setCameraOn(!cameraOn);
function toggleCamera() {
const track = tracksRef.current.find((candidate) => candidate.kind === "video");
if (!track) return;
const next = !cameraOn;
track.enabled = next;
setCameraOn(next);
}
function testSpeaker() {
@@ -97,7 +121,7 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
function continueToCall() {
handedOffRef.current = true;
onContinue(tracks);
onContinue(tracksRef.current);
}
return (
@@ -141,10 +165,10 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
{requestingDevices ? "Browser-Anfrage ist offen …" : tracks.length > 0 ? "Kamera & Mikrofon 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 px-4 py-3 text-xs transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] disabled:opacity-40 ${micOn ? "bg-white/10 hover:bg-white/15" : "bg-red-500/85 hover:bg-red-500"}`}>
<button type="button" onClick={toggleMic} disabled={!audioTrack} className={`rounded-2xl px-4 py-3 text-xs transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] disabled:opacity-40 ${micOn ? "bg-white/10 hover:bg-white/15" : "bg-red-500/85 hover:bg-red-500"}`}>
{micOn ? "Mikrofon aus" : "Mikrofon an"}
</button>
<button type="button" onClick={() => void toggleCamera()} disabled={!videoTrack} className={`rounded-2xl px-4 py-3 text-xs transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] disabled:opacity-40 ${cameraOn ? "bg-white/10 hover:bg-white/15" : "bg-red-500/85 hover:bg-red-500"}`}>
<button type="button" onClick={toggleCamera} disabled={!videoTrack} className={`rounded-2xl px-4 py-3 text-xs transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] disabled:opacity-40 ${cameraOn ? "bg-white/10 hover:bg-white/15" : "bg-red-500/85 hover:bg-red-500"}`}>
{cameraOn ? "Kamera aus" : "Kamera an"}
</button>
</div>