fix: improve meeting device flow and call controls
This commit is contained in:
@@ -1,8 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 : "";
|
||||
|
||||
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: LocalTrack[]) => void }) {
|
||||
const [tracks, setTracks] = useState<LocalTrack[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -12,7 +28,6 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
|
||||
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);
|
||||
@@ -32,28 +47,24 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
|
||||
};
|
||||
}, [tracks]);
|
||||
|
||||
const requestDevices = useCallback(async () => {
|
||||
async function requestDevices() {
|
||||
if (requestingDevices) return;
|
||||
tracks.forEach((track) => track.stop());
|
||||
setError(null);
|
||||
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());
|
||||
setTracks(nextTracks);
|
||||
setMicOn(true);
|
||||
setCameraOn(true);
|
||||
} catch {
|
||||
setError("Kamera oder Mikrofon konnten nicht freigegeben werden. Du kannst trotzdem ohne sie beitreten.");
|
||||
} catch (requestError) {
|
||||
setError(describeDeviceError(requestError));
|
||||
} finally {
|
||||
setRequestingDevices(false);
|
||||
}
|
||||
}, [requestingDevices, tracks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialDeviceRequestRef.current) return;
|
||||
initialDeviceRequestRef.current = true;
|
||||
void requestDevices();
|
||||
}, [requestDevices]);
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!audioTrack) return;
|
||||
@@ -102,8 +113,16 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
|
||||
<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 items-center justify-center text-sm text-white/45">Kamera ausgeschaltet</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">
|
||||
@@ -115,25 +134,25 @@ export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[
|
||||
<div className="flex flex-col justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestDevices}
|
||||
onClick={() => void 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"
|
||||
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 ? "Berechtigung wird angefragt …" : tracks.length > 0 ? "Geräte erneut prüfen" : "Kamera & Mikrofon aktivieren"}
|
||||
{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 bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
|
||||
<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"}`}>
|
||||
{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">
|
||||
<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"}`}>
|
||||
{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">
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user