fix: request media directly and expose host moderation
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user