188 lines
8.9 KiB
TypeScript
188 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
function describeDeviceError(error: unknown): string {
|
|
const name = error instanceof Error ? error.name : "";
|
|
|
|
if (name === "NotAllowedError" || name === "SecurityError") {
|
|
return "Der Browser blockiert Kamera oder Mikrofon. Erlaube beide über das Schloss-Symbol neben der Adresse und versuche es danach erneut.";
|
|
}
|
|
if (name === "NotFoundError") {
|
|
return "Es wurde keine Kamera oder kein Mikrofon gefunden. Prüfe, ob beide Geräte angeschlossen und in den Systemeinstellungen freigegeben sind.";
|
|
}
|
|
if (name === "NotReadableError" || name === "TrackStartError") {
|
|
return "Kamera oder Mikrofon werden gerade von einer anderen App verwendet. Schließe zum Beispiel Zoom, Teams oder eine andere Browser-Registerkarte und versuche es erneut.";
|
|
}
|
|
|
|
return "Kamera oder Mikrofon konnten nicht gestartet werden. Du kannst dem Meeting trotzdem ohne sie beitreten.";
|
|
}
|
|
|
|
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 === "video");
|
|
const audioTrack = tracks.find((track) => track.kind === "audio");
|
|
|
|
useEffect(() => {
|
|
const videoElement = videoRef.current;
|
|
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 () => {
|
|
videoElement.srcObject = null;
|
|
};
|
|
}, [cameraOn, videoTrack]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (!handedOffRef.current) tracksRef.current.forEach((track) => track.stop());
|
|
};
|
|
}, []);
|
|
|
|
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 {
|
|
const stream = await request;
|
|
const nextTracks = stream.getTracks();
|
|
tracksRef.current.forEach((track) => track.stop());
|
|
tracksRef.current = nextTracks;
|
|
setTracks(nextTracks);
|
|
setMicOn(true);
|
|
setCameraOn(true);
|
|
} catch (requestError) {
|
|
setError(describeDeviceError(requestError));
|
|
} finally {
|
|
setRequestingDevices(false);
|
|
}
|
|
}
|
|
|
|
function toggleMic() {
|
|
const track = tracksRef.current.find((candidate) => candidate.kind === "audio");
|
|
if (!track) return;
|
|
const next = !micOn;
|
|
track.enabled = next;
|
|
setMicOn(next);
|
|
}
|
|
|
|
function toggleCamera() {
|
|
const track = tracksRef.current.find((candidate) => candidate.kind === "video");
|
|
if (!track) return;
|
|
const next = !cameraOn;
|
|
track.enabled = next;
|
|
setCameraOn(next);
|
|
}
|
|
|
|
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(tracksRef.current);
|
|
}
|
|
|
|
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)]" />
|
|
) : requestingDevices ? (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-white/65">
|
|
<span className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white" aria-hidden="true" />
|
|
Berechtigungsfenster wird geöffnet …
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm text-white/55">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10" aria-hidden="true">▣</span>
|
|
{error ? "Kameravorschau nicht verfügbar" : "Kamera noch nicht aktiviert"}
|
|
</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={() => void requestDevices()}
|
|
disabled={requestingDevices}
|
|
className="rounded-full bg-white px-5 py-3 text-sm font-medium text-neutral-900 transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/85 hover:shadow-lg hover:shadow-white/10 active:translate-y-0 active:scale-[0.98] disabled:cursor-wait disabled:opacity-75"
|
|
>
|
|
{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={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={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>
|
|
<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="rounded-2xl border border-amber-200/20 bg-amber-200/10 p-3 text-xs leading-relaxed text-amber-100">{error}</p>}
|
|
<button type="button" onClick={continueToCall} disabled={requestingDevices} className="mt-2 rounded-full bg-anouma-mauve-dark px-5 py-3.5 text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 hover:bg-anouma-plum hover:shadow-lg hover:shadow-anouma-mauve-dark/20 active:translate-y-0 active:scale-[0.98] disabled:cursor-wait disabled:opacity-60">
|
|
Meeting beitreten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|