fix: improve meeting device flow and call controls
This commit is contained in:
+114
-25
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DisconnectReason, Room, RoomEvent, Track, type LocalTrack, type Participant } from "livekit-client";
|
||||
import { PreJoinScreen } from "./PreJoinScreen";
|
||||
import { SfuVideoTile } from "./SfuVideoTile";
|
||||
@@ -32,6 +31,14 @@ function participantRole(participant: Participant, fallback: "host" | "participa
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatCallDuration(durationMs: number | null): string {
|
||||
const totalSeconds = Math.max(0, Math.round((durationMs ?? 0) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${minutes} Min. ${String(seconds).padStart(2, "0")} Sek.`;
|
||||
}
|
||||
|
||||
function snapshotParticipant(
|
||||
participant: Participant,
|
||||
activeSpeakers: Set<string>,
|
||||
@@ -52,7 +59,6 @@ function snapshotParticipant(
|
||||
}
|
||||
|
||||
export function CallRoom({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<StoredMeetingSession | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -63,11 +69,14 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
const [sharingScreen, setSharingScreen] = useState(false);
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [camOn, setCamOn] = useState(true);
|
||||
const [pendingControl, setPendingControl] = useState<"mic" | "cam" | "screen" | null>(null);
|
||||
const [callDurationMs, setCallDurationMs] = useState<number | null>(null);
|
||||
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const localTracksRef = useRef<LocalTrack[]>([]);
|
||||
const activeSpeakersRef = useRef<Set<string>>(new Set());
|
||||
const intentionalCloseRef = useRef(false);
|
||||
const connectedAtRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("token");
|
||||
@@ -132,6 +141,28 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
setParticipants([local, ...remote]);
|
||||
}
|
||||
|
||||
function syncMeetingView(room: Room) {
|
||||
refreshParticipants(room);
|
||||
const screenIsShared = room.localParticipant.isScreenShareEnabled || [...room.remoteParticipants.values()].some((participant) => participant.isScreenShareEnabled);
|
||||
setSharingScreen(room.localParticipant.isScreenShareEnabled);
|
||||
if (screenIsShared) setLayout("screen");
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
try {
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
} catch {
|
||||
// Private browsing may deny Web Storage. The call itself can still end safely.
|
||||
}
|
||||
}
|
||||
|
||||
function finishCall() {
|
||||
const startedAt = connectedAtRef.current;
|
||||
setCallDurationMs(startedAt ? Date.now() - startedAt : 0);
|
||||
connectedAtRef.current = null;
|
||||
setPhase("ended");
|
||||
}
|
||||
|
||||
async function connectRoom(previewTracks: LocalTrack[]) {
|
||||
if (!session) return;
|
||||
setPhase("connecting");
|
||||
@@ -156,7 +187,7 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
roomRef.current = room;
|
||||
const refresh = () => refreshParticipants(room);
|
||||
const refresh = () => syncMeetingView(room);
|
||||
|
||||
room.on(RoomEvent.Connected, refresh);
|
||||
room.on(RoomEvent.ParticipantConnected, refresh);
|
||||
@@ -183,10 +214,10 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
if (reason === DisconnectReason.PARTICIPANT_REMOVED) {
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
localTracksRef.current = [];
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
clearStoredSession();
|
||||
setPhase("kicked");
|
||||
} else if (!intentionalCloseRef.current) {
|
||||
setPhase("ended");
|
||||
finishCall();
|
||||
}
|
||||
});
|
||||
room.on(RoomEvent.MediaDevicesError, () => setErrorMessage("Kamera oder Mikrofon konnten nicht verwendet werden."));
|
||||
@@ -200,6 +231,8 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
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));
|
||||
connectedAtRef.current = Date.now();
|
||||
setCallDurationMs(null);
|
||||
setPhase("connected");
|
||||
} catch (error) {
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
@@ -213,24 +246,48 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
const room = roomRef.current;
|
||||
if (!room || pendingControl) return;
|
||||
const next = !micOn;
|
||||
await roomRef.current?.localParticipant.setMicrophoneEnabled(next);
|
||||
setMicOn(next);
|
||||
setPendingControl("mic");
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(next);
|
||||
setMicOn(next);
|
||||
} catch {
|
||||
setErrorMessage("Das Mikrofon konnte nicht umgeschaltet werden.");
|
||||
} finally {
|
||||
setPendingControl(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCamera() {
|
||||
const room = roomRef.current;
|
||||
if (!room || pendingControl) return;
|
||||
const next = !camOn;
|
||||
await roomRef.current?.localParticipant.setCameraEnabled(next);
|
||||
setCamOn(next);
|
||||
setPendingControl("cam");
|
||||
try {
|
||||
await room.localParticipant.setCameraEnabled(next);
|
||||
setCamOn(next);
|
||||
} catch {
|
||||
setErrorMessage("Die Kamera konnte nicht umgeschaltet werden.");
|
||||
} finally {
|
||||
setPendingControl(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
const room = roomRef.current;
|
||||
if (!room || pendingControl) return;
|
||||
const next = !sharingScreen;
|
||||
setPendingControl("screen");
|
||||
try {
|
||||
await roomRef.current?.localParticipant.setScreenShareEnabled(next);
|
||||
await room.localParticipant.setScreenShareEnabled(next);
|
||||
setSharingScreen(next);
|
||||
if (next) setLayout("screen");
|
||||
} catch {
|
||||
setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden.");
|
||||
} finally {
|
||||
setPendingControl(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,8 +311,8 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
roomRef.current = null;
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
localTracksRef.current = [];
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
router.push(`/termine/${slug}`);
|
||||
clearStoredSession();
|
||||
finishCall();
|
||||
}
|
||||
|
||||
if (phase === "loading") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/60">Meeting wird geladen …</div>;
|
||||
@@ -265,7 +322,24 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p><a href={`/termine/${slug}/beitreten`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zur Beitrittsseite</a></div>;
|
||||
}
|
||||
if (phase === "kicked") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 px-6 text-center text-white"><p>Du wurdest vom Meeting entfernt.</p></div>;
|
||||
if (phase === "ended") return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Die Verbindung zum Meeting wurde beendet."}</p><a href={`/termine/${slug}`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zum Termin</a></div>;
|
||||
if (phase === "ended") {
|
||||
const isTestCall = session.eventSlug.startsWith("test-");
|
||||
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-lg rounded-[2rem] border border-white/10 bg-neutral-900 p-8 text-center shadow-2xl sm:p-10">
|
||||
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-anouma-mauve-dark/30 text-2xl" aria-hidden="true">✓</span>
|
||||
<p className="mt-6 text-xs font-medium uppercase tracking-[0.18em] text-white/50">Video-Call beendet</p>
|
||||
<h1 className="mt-2 font-serif text-4xl">Danke für das Gespräch</h1>
|
||||
<p className="mx-auto mt-4 max-w-sm text-sm leading-relaxed text-white/65">
|
||||
{errorMessage || "Die Verbindung wurde ordentlich beendet."} Deine Gesprächsdauer: <span className="font-medium text-white">{formatCallDuration(callDurationMs)}</span>.
|
||||
</p>
|
||||
<a href={isTestCall ? "/admin" : `/termine/${slug}`} className="mt-8 inline-flex rounded-full bg-white px-6 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]">
|
||||
{isTestCall ? "Zurück zum CRM" : "Zurück zum Termin"}
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const local = participants[0];
|
||||
const remoteParticipants = participants.slice(1);
|
||||
@@ -305,24 +379,39 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
{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>}
|
||||
</div>
|
||||
<footer className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
|
||||
<ControlButton active={micOn} onClick={() => void toggleMic()} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
|
||||
<ControlButton active={camOn} onClick={() => void toggleCamera()} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
|
||||
{session.role === "host" && <ControlButton active={sharingScreen} onClick={() => void toggleScreenShare()} label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"} icon="screen" />}
|
||||
<button type="button" onClick={leaveMeeting} className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800">Meeting verlassen</button>
|
||||
<ControlButton enabled={micOn} busy={pendingControl === "mic"} onClick={() => void toggleMic()} label="Mikrofon" icon="mic" />
|
||||
<ControlButton enabled={camOn} busy={pendingControl === "cam"} onClick={() => void toggleCamera()} label="Kamera" icon="cam" />
|
||||
{session.role === "host" && <ControlButton enabled={sharingScreen} busy={pendingControl === "screen"} onClick={() => void toggleScreenShare()} label="Bildschirm teilen" icon="screen" />}
|
||||
<button type="button" onClick={leaveMeeting} className="flex min-w-[88px] flex-col items-center gap-1 rounded-2xl bg-red-600 px-4 py-2 text-xs font-medium text-white transition-all duration-200 hover:-translate-y-0.5 hover:bg-red-500 hover:shadow-lg hover:shadow-red-900/30 active:translate-y-0 active:scale-[0.97]"><span className="text-lg leading-none" aria-hidden="true">×</span>Verlassen</button>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) {
|
||||
function ControlButton({ enabled, busy, onClick, label, icon }: { enabled: boolean; busy: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) {
|
||||
const actionLabel = icon === "screen" ? (enabled ? "Bildschirmfreigabe beenden" : "Bildschirmfreigabe starten") : `${label} ${enabled ? "ausschalten" : "einschalten"}`;
|
||||
const statusLabel = icon === "screen" ? (enabled ? "Wird geteilt" : "Teilen") : enabled ? "An" : "Aus";
|
||||
const classes = icon === "screen"
|
||||
? enabled
|
||||
? "bg-emerald-600 text-white hover:bg-emerald-500 hover:shadow-emerald-900/30"
|
||||
: "bg-white/10 text-white hover:bg-white/20"
|
||||
: enabled
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-red-600 text-white hover:bg-red-500 hover:shadow-red-900/30";
|
||||
|
||||
return (
|
||||
<button type="button" onClick={onClick} title={label} aria-pressed={active} className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"}`}>
|
||||
<span className="sr-only">{label}</span>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon === "mic" && <><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8" /></>}
|
||||
{icon === "cam" && <><path d="M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" /><path d="m17 10 5-3v10l-5-3" /></>}
|
||||
{icon === "screen" && <><rect x="3" y="4" width="18" height="12" rx="1.5" /><path d="M8 20h8M12 16v4" /></>}
|
||||
</svg>
|
||||
<button type="button" onClick={onClick} disabled={busy} title={actionLabel} aria-label={actionLabel} aria-pressed={enabled} className={`group flex min-w-[88px] flex-col items-center gap-1 rounded-2xl px-4 py-2 text-xs font-medium transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 active:scale-[0.97] disabled:cursor-wait disabled:opacity-70 ${classes}`}>
|
||||
{busy ? (
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-current/30 border-t-current" aria-hidden="true" />
|
||||
) : (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className={`h-5 w-5 fill-none stroke-current transition-transform duration-200 group-hover:scale-110 group-active:scale-90 ${icon === "screen" && enabled ? "animate-pulse" : ""}`} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon === "mic" && <><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8" />{!enabled && <path d="M4 4 20 20" strokeWidth="2.3" />}</>}
|
||||
{icon === "cam" && <><path d="M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" /><path d="m17 10 5-3v10l-5-3" />{!enabled && <path d="M4 4 20 20" strokeWidth="2.3" />}</>}
|
||||
{icon === "screen" && <><rect x="3" y="4" width="18" height="12" rx="1.5" /><path d="M8 20h8M12 16v4" /></>}
|
||||
</svg>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px] font-normal opacity-70">{statusLabel}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -60,7 +60,20 @@ export function SfuVideoTile({
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/75 to-transparent px-3 py-2">
|
||||
<span className="truncate text-sm font-medium text-white">{name}{isLocal && " (du)"}</span>
|
||||
<div className="flex items-center gap-2 text-white/80">
|
||||
{muted && <span aria-label="Mikrofon stumm">⌁</span>}
|
||||
{muted && (
|
||||
<span aria-label="Mikrofon aus" title="Mikrofon aus" className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500/90 text-white">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5 fill-none stroke-current" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 4.6 2.55M15 9.34V6a3 3 0 0 0-5.94-.6M5 11a7 7 0 0 0 10.54 6.02M12 18v3M8 21h8M4 4l16 16" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{videoOff && (
|
||||
<span aria-label="Kamera aus" title="Kamera aus" className="flex h-6 w-6 items-center justify-center rounded-full bg-black/50 text-white">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5 fill-none stroke-current" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" /><path d="m17 10 5-3v10l-5-3M4 4l16 16" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{isHost && <span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">Host</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user