Files
anouma/components/meeting/PreJoinScreen.tsx
T

145 lines
6.1 KiB
TypeScript

"use client";
import { useCallback, 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 [requestingDevices, setRequestingDevices] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const handedOffRef = useRef(false);
const initialDeviceRequestRef = 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]);
const requestDevices = useCallback(async () => {
if (requestingDevices) return;
tracks.forEach((track) => track.stop());
setError(null);
setRequestingDevices(true);
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.");
} finally {
setRequestingDevices(false);
}
}, [requestingDevices, tracks]);
useEffect(() => {
if (initialDeviceRequestRef.current) return;
initialDeviceRequestRef.current = true;
void requestDevices();
}, [requestDevices]);
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}
disabled={requestingDevices}
className="rounded-full bg-white px-5 py-3 text-sm font-medium text-neutral-900 hover:bg-white/85 disabled:cursor-wait disabled:opacity-75"
>
{requestingDevices ? "Berechtigung wird angefragt …" : 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>
);
}