449 lines
23 KiB
TypeScript
449 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
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";
|
||
|
||
type Phase = "loading" | "prejoin" | "connecting" | "connected" | "kicked" | "ended" | "error";
|
||
type LayoutMode = "grid" | "speaker" | "screen";
|
||
|
||
type ParticipantView = {
|
||
identity: string;
|
||
name: string;
|
||
role: "host" | "participant";
|
||
cameraTrack: Track | null;
|
||
microphoneTrack: Track | null;
|
||
screenTrack: Track | null;
|
||
speaking: boolean;
|
||
microphoneEnabled: boolean;
|
||
cameraEnabled: boolean;
|
||
};
|
||
|
||
function participantRole(participant: Participant, fallback: "host" | "participant"): "host" | "participant" {
|
||
try {
|
||
const metadata = JSON.parse(participant.metadata || "{}") as { role?: unknown };
|
||
if (metadata.role === "host" || metadata.role === "participant") return metadata.role;
|
||
} catch {
|
||
// A malformed metadata value cannot grant host privileges.
|
||
}
|
||
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>,
|
||
fallbackRole: "host" | "participant",
|
||
fallbackName: string,
|
||
): ParticipantView {
|
||
return {
|
||
identity: participant.identity,
|
||
name: participant.name || fallbackName,
|
||
role: participantRole(participant, fallbackRole),
|
||
cameraTrack: participant.getTrackPublication(Track.Source.Camera)?.track ?? null,
|
||
microphoneTrack: participant.getTrackPublication(Track.Source.Microphone)?.track ?? null,
|
||
screenTrack: participant.getTrackPublication(Track.Source.ScreenShare)?.track ?? null,
|
||
speaking: activeSpeakers.has(participant.identity) || participant.isSpeaking,
|
||
microphoneEnabled: participant.isMicrophoneEnabled,
|
||
cameraEnabled: participant.isCameraEnabled,
|
||
};
|
||
}
|
||
|
||
export function CallRoom({ slug }: { slug: string }) {
|
||
const [session, setSession] = useState<StoredMeetingSession | null>(null);
|
||
const [phase, setPhase] = useState<Phase>("loading");
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
const [participants, setParticipants] = useState<ParticipantView[]>([]);
|
||
const [layout, setLayout] = useState<LayoutMode>("grid");
|
||
const [activeSpeakerId, setActiveSpeakerId] = useState<string | null>(null);
|
||
const [showParticipants, setShowParticipants] = useState(false);
|
||
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<MediaStreamTrack[]>([]);
|
||
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");
|
||
let rawSession: string | null = hashToken ? JSON.stringify({ token: hashToken }) : null;
|
||
|
||
// Hash-based links (for example the admin test call) must work even when
|
||
// browser privacy settings deny access to Web Storage.
|
||
if (!rawSession) {
|
||
try {
|
||
rawSession = sessionStorage.getItem(MEETING_SESSION_KEY);
|
||
} catch {
|
||
rawSession = null;
|
||
}
|
||
}
|
||
|
||
if (!rawSession) {
|
||
queueMicrotask(() => {
|
||
setPhase("error");
|
||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const candidate = JSON.parse(rawSession) as Partial<StoredMeetingSession>;
|
||
const parsed: StoredMeetingSession = {
|
||
token: typeof candidate.token === "string" ? candidate.token : "",
|
||
participantToken: candidate.participantToken,
|
||
serverUrl: candidate.serverUrl,
|
||
participantId: typeof candidate.participantId === "string" ? candidate.participantId : "",
|
||
role: candidate.role === "participant" ? "participant" : "host",
|
||
name: typeof candidate.name === "string" && candidate.name ? candidate.name : "Host",
|
||
eventTitle: typeof candidate.eventTitle === "string" && candidate.eventTitle ? candidate.eventTitle : "Video-Call",
|
||
eventSlug: typeof candidate.eventSlug === "string" && candidate.eventSlug ? candidate.eventSlug : slug,
|
||
};
|
||
if (!parsed.token || parsed.eventSlug !== slug) throw new Error("invalid session");
|
||
queueMicrotask(() => {
|
||
setSession(parsed);
|
||
setPhase("prejoin");
|
||
});
|
||
} catch {
|
||
queueMicrotask(() => {
|
||
setPhase("error");
|
||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||
});
|
||
}
|
||
|
||
return () => {
|
||
intentionalCloseRef.current = true;
|
||
roomRef.current?.disconnect();
|
||
roomRef.current = null;
|
||
localTracksRef.current.forEach((track) => track.stop());
|
||
localTracksRef.current = [];
|
||
};
|
||
}, [slug]);
|
||
|
||
function refreshParticipants(room: Room) {
|
||
const local = snapshotParticipant(room.localParticipant, activeSpeakersRef.current, session?.role ?? "participant", session?.name ?? "Du");
|
||
const remote = [...room.remoteParticipants.values()].map((participant) =>
|
||
snapshotParticipant(participant, activeSpeakersRef.current, "participant", "Teilnehmer"),
|
||
);
|
||
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: MediaStreamTrack[]) {
|
||
if (!session) return;
|
||
setPhase("connecting");
|
||
setErrorMessage(null);
|
||
intentionalCloseRef.current = false;
|
||
localTracksRef.current = previewTracks;
|
||
|
||
try {
|
||
let serverUrl = session.serverUrl;
|
||
let participantToken = session.participantToken;
|
||
if (!serverUrl || !participantToken) {
|
||
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${session.token}` },
|
||
credentials: "include",
|
||
});
|
||
const data = (await response.json().catch(() => ({}))) as { serverUrl?: string; participantToken?: string; error?: string };
|
||
if (!response.ok || !data.serverUrl || !data.participantToken) throw new Error(data.error || "Video-Server nicht erreichbar");
|
||
serverUrl = data.serverUrl;
|
||
participantToken = data.participantToken;
|
||
}
|
||
|
||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||
roomRef.current = room;
|
||
const refresh = () => syncMeetingView(room);
|
||
|
||
room.on(RoomEvent.Connected, refresh);
|
||
room.on(RoomEvent.ParticipantConnected, refresh);
|
||
room.on(RoomEvent.ParticipantDisconnected, refresh);
|
||
room.on(RoomEvent.TrackPublished, refresh);
|
||
room.on(RoomEvent.TrackUnpublished, refresh);
|
||
room.on(RoomEvent.TrackSubscribed, refresh);
|
||
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||
room.on(RoomEvent.TrackMuted, refresh);
|
||
room.on(RoomEvent.TrackUnmuted, refresh);
|
||
room.on(RoomEvent.LocalTrackPublished, refresh);
|
||
room.on(RoomEvent.LocalTrackUnpublished, refresh);
|
||
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||
activeSpeakersRef.current = new Set(speakers.map((speaker) => speaker.identity));
|
||
setActiveSpeakerId(speakers[0]?.identity ?? null);
|
||
refresh();
|
||
});
|
||
room.on(RoomEvent.Reconnecting, () => setPhase("connecting"));
|
||
room.on(RoomEvent.Reconnected, () => {
|
||
setPhase("connected");
|
||
refresh();
|
||
});
|
||
room.on(RoomEvent.Disconnected, (reason) => {
|
||
if (reason === DisconnectReason.PARTICIPANT_REMOVED) {
|
||
localTracksRef.current.forEach((track) => track.stop());
|
||
localTracksRef.current = [];
|
||
clearStoredSession();
|
||
setPhase("kicked");
|
||
} else if (!intentionalCloseRef.current) {
|
||
finishCall();
|
||
}
|
||
});
|
||
room.on(RoomEvent.MediaDevicesError, () => setErrorMessage("Kamera oder Mikrofon konnten nicht verwendet werden."));
|
||
|
||
await room.connect(serverUrl, participantToken);
|
||
for (const track of previewTracks) {
|
||
await room.localParticipant.publishTrack(track, {
|
||
source: track.kind === "audio" ? Track.Source.Microphone : Track.Source.Camera,
|
||
});
|
||
}
|
||
refresh();
|
||
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");
|
||
} catch (error) {
|
||
localTracksRef.current.forEach((track) => track.stop());
|
||
localTracksRef.current = [];
|
||
intentionalCloseRef.current = true;
|
||
roomRef.current?.disconnect();
|
||
roomRef.current = null;
|
||
setPhase("error");
|
||
setErrorMessage(error instanceof Error ? error.message : "Der Video-Call konnte nicht geöffnet werden.");
|
||
}
|
||
}
|
||
|
||
async function toggleMic() {
|
||
const room = roomRef.current;
|
||
if (!room || pendingControl) return;
|
||
const next = !micOn;
|
||
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;
|
||
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 room.localParticipant.setScreenShareEnabled(next);
|
||
setSharingScreen(next);
|
||
if (next) setLayout("screen");
|
||
} catch {
|
||
setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden.");
|
||
} finally {
|
||
setPendingControl(null);
|
||
}
|
||
}
|
||
|
||
async function kickParticipant(identity: string) {
|
||
if (!session) return;
|
||
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/kick`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` },
|
||
body: JSON.stringify({ participantId: identity }),
|
||
credentials: "include",
|
||
});
|
||
if (!response.ok) {
|
||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||
setErrorMessage(data.error || "Teilnehmer konnte nicht entfernt werden.");
|
||
}
|
||
}
|
||
|
||
function leaveMeeting() {
|
||
intentionalCloseRef.current = true;
|
||
roomRef.current?.disconnect();
|
||
roomRef.current = null;
|
||
localTracksRef.current.forEach((track) => track.stop());
|
||
localTracksRef.current = [];
|
||
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>;
|
||
if (phase === "prejoin" && session) return <PreJoinScreen onContinue={connectRoom} />;
|
||
if (phase === "connecting") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/65">Verbindung wird aufgebaut …</div>;
|
||
if (phase === "error" || !session) {
|
||
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") {
|
||
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);
|
||
const screenOwner = participants.find((participant) => participant.screenTrack);
|
||
const activeSpeaker = participants.find((participant) => participant.identity === activeSpeakerId) ?? remoteParticipants[0] ?? local;
|
||
const tile = (participant: ParticipantView, options: { compact?: boolean; videoTrack?: Track | null; name?: string } = {}) => (
|
||
<SfuVideoTile
|
||
key={`${participant.identity}-${options.name ?? "camera"}`}
|
||
videoTrack={options.videoTrack ?? participant.cameraTrack}
|
||
audioTrack={participant.microphoneTrack}
|
||
name={options.name ?? participant.name}
|
||
isLocal={participant.identity === local?.identity}
|
||
isHost={participant.role === "host"}
|
||
muted={!participant.microphoneEnabled}
|
||
videoOff={!participant.cameraEnabled && !options.videoTrack}
|
||
speaking={participant.speaking}
|
||
compact={options.compact}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<main className="flex min-h-screen flex-col bg-neutral-950 text-white">
|
||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-5 py-4">
|
||
<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)} 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>}
|
||
<div className="flex flex-1 flex-col lg:flex-row">
|
||
<section className="flex-1 p-4">
|
||
{layout === "grid" && <div className="grid auto-rows-fr grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">{participants.map((participant) => tile(participant, { videoTrack: participant.screenTrack ?? participant.cameraTrack, name: participant.screenTrack ? `${participant.name} · Bildschirm` : participant.name }))}</div>}
|
||
{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-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" />
|
||
<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({ 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} 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>
|
||
);
|
||
}
|