"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Hand, LayoutGrid, Lock, MicOff, Presentation, Sparkles, Users, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { ControlBar } from "./controls";
import { DEFAULT_CHAT, DEFAULT_DEVICES, DEFAULT_PARTICIPANTS, MEETING, SCRIPTED_CHAT, YOU_ID, firstName, formatDuration } from "./data";
import { FeedbackScreen } from "./feedback";
import { Lobby } from "./lobby";
import { ReactionGlyph } from "./reaction-glyph";
import { SidePanel } from "./side-panel";
import { Stage } from "./stage";
import { useAudioLevels } from "./use-audio-levels";
import type { ChatMessage, FloatingReaction, JoinSettings, Layout, MeetingDevices, MeetingFeedback, Participant, ReactionKind, SidePanel as PanelKind } from "./types";
export type { ChatMessage, JoinSettings, MeetingDevices, MeetingFeedback, Participant, ReactionKind } from "./types";
export interface VideoMeetingAppProps {
title?: string;
subtitle?: string;
/** Other people in the call (you are added automatically). */
participants?: Participant[];
/** Chat history already in the room when you join. */
initialMessages?: ChatMessage[];
/** Messages that other participants "send" while you're in the call. Pass [] to disable. */
scriptedMessages?: ChatMessage[];
devices?: MeetingDevices;
defaultName?: string;
/** Your avatar hue. */
hue?: number;
/** Skip the pre-join lobby. */
skipLobby?: boolean;
defaultLayout?: Layout;
/** Simulate other people raising hands and reacting. */
simulateActivity?: boolean;
onJoin?: (settings: JoinSettings) => void;
onLeave?: (info: { durationSec: number }) => void;
onSendMessage?: (message: ChatMessage) => void;
onReaction?: (kind: ReactionKind) => void;
onRecordingChange?: (recording: boolean) => void;
onFeedback?: (feedback: MeetingFeedback) => void;
className?: string;
}
type Phase = "lobby" | "call" | "left";
interface Toast {
id: number;
text: string;
tone?: "default" | "rec" | "hand";
}
const SHORTCUTS: [string, string][] = [
["M", "Mute / unmute"],
["Space (hold)", "Push to talk while muted"],
["V", "Camera on / off"],
["S", "Present screen"],
["H", "Raise / lower hand"],
["1 – 6", "Send a reaction"],
["G", "Grid / speaker view"],
["C", "Chat"],
["P", "Participants"],
["R", "Start / stop recording"],
["Esc", "Close panel or dialog"],
];
let msgSeq = 0;
export function VideoMeetingApp({
title = MEETING.title,
subtitle = MEETING.subtitle,
participants = DEFAULT_PARTICIPANTS,
initialMessages = DEFAULT_CHAT,
scriptedMessages = SCRIPTED_CHAT,
devices = DEFAULT_DEVICES,
defaultName = "Alex Morgan",
hue = 225,
skipLobby = false,
defaultLayout = "grid",
simulateActivity = true,
onJoin,
onLeave,
onSendMessage,
onReaction,
onRecordingChange,
onFeedback,
className,
}: VideoMeetingAppProps) {
const reduce = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const [phase, setPhase] = React.useState<Phase>(skipLobby ? "call" : "lobby");
const [me, setMe] = React.useState<JoinSettings>({
name: defaultName,
micOn: true,
cameraOn: true,
cameraId: devices.cameras[0]?.id ?? "",
microphoneId: devices.microphones[0]?.id ?? "",
speakerId: devices.speakers[0]?.id ?? "",
});
const [others, setOthers] = React.useState<Participant[]>(participants);
const [messages, setMessages] = React.useState<ChatMessage[]>(initialMessages);
const [layout, setLayout] = React.useState<Layout>(defaultLayout);
const [panel, setPanel] = React.useState<PanelKind>(null);
const [pinnedId, setPinnedId] = React.useState<string | null>(null);
const [sharing, setSharing] = React.useState(false);
const [hand, setHand] = React.useState(false);
const [recSince, setRecSince] = React.useState<number | null>(null);
const [elapsed, setElapsed] = React.useState(0);
const [unread, setUnread] = React.useState(0);
const [floating, setFloating] = React.useState<FloatingReaction[]>([]);
const [tileReactions, setTileReactions] = React.useState<Record<string, ReactionKind | undefined>>({});
const [toasts, setToasts] = React.useState<Toast[]>([]);
const [shortcuts, setShortcuts] = React.useState(false);
const [ptt, setPtt] = React.useState(false);
const [finalDuration, setFinalDuration] = React.useState(0);
const seq = React.useRef(0);
const fired = React.useRef(new Set<string>());
const panelRef = React.useRef(panel);
const shortcutsRef = React.useRef(shortcuts);
React.useEffect(() => {
panelRef.current = panel;
shortcutsRef.current = shortcuts;
}, [panel, shortcuts]);
const you = React.useMemo<Participant>(() => ({ id: YOU_ID, name: me.name || "You", hue, muted: !me.micOn && !ptt, cameraOn: me.cameraOn, handRaised: hand, title: "Guest" }), [me.name, me.micOn, me.cameraOn, ptt, hand, hue]);
const people = React.useMemo(() => [you, ...others], [you, others]);
const sources = React.useMemo(() => people.map((p) => ({ id: p.id, muted: p.muted, talkativeness: p.id === YOU_ID ? 0.05 : p.talkativeness })), [people]);
const { levels, speakerId } = useAudioLevels(sources, { active: phase === "call", talking: { [YOU_ID]: ptt } });
const toast = React.useCallback((text: string, tone: Toast["tone"] = "default") => {
const id = ++seq.current;
setToasts((t) => [...t.slice(-2), { id, text, tone }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200);
}, []);
const react = React.useCallback(
(kind: ReactionKind, byId: string, byName: string) => {
const id = ++seq.current;
const lane = ((id * 0.618) % 1) * 0.7 + 0.08;
setFloating((f) => [...f.slice(-12), { id, kind, by: byName, lane }]);
setTileReactions((m) => ({ ...m, [byId]: kind }));
setTimeout(() => setFloating((f) => f.filter((x) => x.id !== id)), 3000);
setTimeout(() => setTileReactions((m) => (m[byId] === kind ? { ...m, [byId]: undefined } : m)), 2200);
},
[],
);
// Call clock
React.useEffect(() => {
if (phase !== "call") return;
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(id);
}, [phase]);
// Scripted room activity keyed off the call clock.
React.useEffect(() => {
if (phase !== "call") return;
const once = (key: string, fn: () => void) => {
if (fired.current.has(key)) return;
fired.current.add(key);
fn();
};
for (const m of scriptedMessages) {
if (elapsed >= m.at && others.some((o) => o.id === m.authorId)) {
once(`msg-${m.id}`, () => {
setMessages((ms) => [...ms, { ...m, id: `${m.id}-${elapsed}` }]);
if (panelRef.current !== "chat") setUnread((u) => u + 1);
});
}
}
if (!simulateActivity) return;
const pick = (i: number) => others[i % Math.max(1, others.length)];
if (elapsed >= 9 && others.length > 2)
once("hand-1", () => {
const p = pick(2);
setOthers((os) => os.map((o) => (o.id === p.id ? { ...o, handRaised: true } : o)));
toast(`${firstName(p.name)} raised their hand`, "hand");
});
if (elapsed >= 14 && others.length > 1) once("react-1", () => react("thumbs", pick(1).id, firstName(pick(1).name)));
if (elapsed >= 15 && others.length > 0) once("react-2", () => react("heart", pick(0).id, firstName(pick(0).name)));
if (elapsed >= 33 && others.length > 3) once("react-3", () => react("party", pick(3).id, firstName(pick(3).name)));
}, [elapsed, phase, others, scriptedMessages, simulateActivity, toast, react]);
// ---- actions ----
const join = (s: JoinSettings) => {
setMe(s);
setPhase("call");
setElapsed(0);
fired.current.clear();
onJoin?.(s);
};
const leave = () => {
setFinalDuration(elapsed);
onLeave?.({ durationSec: elapsed });
setPhase("left");
setPanel(null);
setSharing(false);
setHand(false);
setRecSince(null);
setShortcuts(false);
};
const toggleMic = () => setMe((m) => ({ ...m, micOn: !m.micOn }));
const toggleCam = () => setMe((m) => ({ ...m, cameraOn: !m.cameraOn }));
const toggleShare = () => {
toast(sharing ? "You stopped presenting" : "You're presenting to everyone");
setSharing(!sharing);
};
const toggleHand = () => setHand((h) => !h);
const toggleRecord = () => {
const next = recSince === null;
setRecSince(next ? elapsed : null);
toast(next ? "Recording started — everyone has been notified" : "Recording saved to Meeting notes", "rec");
onRecordingChange?.(next);
};
const toggleLayout = () => setLayout((l) => (l === "grid" ? "speaker" : "grid"));
const openPanel = (p: PanelKind) => {
setPanel(p);
if (p === "chat") setUnread(0);
};
const sendReaction = (k: ReactionKind) => {
react(k, YOU_ID, "You");
onReaction?.(k);
};
const send = (text: string) => {
const m: ChatMessage = { id: `me-${++msgSeq}`, authorId: YOU_ID, text, at: elapsed };
setMessages((ms) => [...ms, m]);
onSendMessage?.(m);
};
const muteAll = () => {
setOthers((os) => os.map((o) => ({ ...o, muted: true })));
toast("Everyone else has been muted");
};
const toggleMute = (id: string) => {
if (id === YOU_ID) return toggleMic();
const p = others.find((o) => o.id === id);
if (!p) return;
if (p.muted) toast(`Asked ${firstName(p.name)} to unmute`);
else setOthers((os) => os.map((o) => (o.id === id ? { ...o, muted: true } : o)));
};
const lowerHand = (id: string) => {
if (id === YOU_ID) return setHand(false);
setOthers((os) => os.map((o) => (o.id === id ? { ...o, handRaised: false } : o)));
};
const pin = (id: string) => {
setPinnedId((cur) => (cur === id ? null : id));
if (pinnedId !== id) setLayout("speaker");
};
// ---- keyboard ----
const actions = React.useRef({ toggleMic, toggleCam, toggleShare, toggleHand, toggleRecord, toggleLayout, openPanel, sendReaction });
React.useEffect(() => {
actions.current = { toggleMic, toggleCam, toggleShare, toggleHand, toggleRecord, toggleLayout, openPanel, sendReaction };
});
React.useEffect(() => {
if (phase !== "call") return;
const typing = (el: EventTarget | null) => el instanceof HTMLElement && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
const inScope = () => {
const a = document.activeElement;
return !a || a === document.body || !!rootRef.current?.contains(a);
};
const down = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey || typing(e.target) || !inScope()) return;
const a = actions.current;
const k = e.key.toLowerCase();
if (e.key === "Escape") {
if (shortcutsRef.current) setShortcuts(false);
else setPanel(null);
return;
}
if (e.key === " ") {
if (!(e.target instanceof HTMLButtonElement)) {
e.preventDefault();
if (!e.repeat) setPtt(true);
}
return;
}
const map: Record<string, () => void> = {
m: a.toggleMic,
v: a.toggleCam,
s: a.toggleShare,
h: a.toggleHand,
r: a.toggleRecord,
g: a.toggleLayout,
c: () => a.openPanel(panelRef.current === "chat" ? null : "chat"),
p: () => a.openPanel(panelRef.current === "people" ? null : "people"),
"?": () => setShortcuts((s) => !s),
};
const kinds: ReactionKind[] = ["thumbs", "heart", "clap", "laugh", "party", "idea"];
if (/^[1-6]$/.test(k)) {
e.preventDefault();
a.sendReaction(kinds[Number(k) - 1]);
} else if (map[k]) {
e.preventDefault();
map[k]();
}
};
const up = (e: KeyboardEvent) => e.key === " " && setPtt(false);
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [phase]);
const recording = recSince !== null;
const handsUp = people.filter((p) => p.handRaised).length;
const speakerName = speakerId ? people.find((p) => p.id === speakerId)?.name : null;
return (
<div ref={rootRef} className={cn("relative flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
<AnimatePresence mode="wait" initial={false}>
{phase === "lobby" && (
<motion.div key="lobby" className="h-full" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, scale: 0.98 }}>
<Lobby title={title} subtitle={subtitle} devices={devices} people={others} defaults={me} hue={hue} onJoin={join} />
</motion.div>
)}
{phase === "left" && (
<motion.div key="left" className="h-full" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<FeedbackScreen
title={title}
durationSec={finalDuration}
onRejoin={() => join(me)}
onLobby={() => setPhase("lobby")}
onSubmit={(f) => onFeedback?.(f)}
/>
</motion.div>
)}
{phase === "call" && (
<motion.div key="call" className="flex h-full min-h-0 flex-col" initial={reduce ? false : { opacity: 0, scale: 1.02 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }}>
{/* Top bar */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-3 sm:px-4">
<span className="hidden size-8 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-fuchsia-500 text-white shadow-sm sm:grid" aria-hidden>
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<h2 className="flex items-center gap-1.5 truncate text-sm font-semibold">
<Lock className="size-3 shrink-0 text-emerald-500" aria-label="End-to-end encrypted" />
<span className="truncate">{title}</span>
</h2>
<p className="truncate text-xs text-muted-foreground">
<span className="tabular-nums">{formatDuration(elapsed)}</span>
{speakerName && <span className="hidden sm:inline"> · {speakerName === you.name ? "You are" : `${firstName(speakerName)} is`} speaking</span>}
</p>
</div>
<AnimatePresence>
{recording && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-rose-500/10 px-2.5 text-xs font-semibold text-rose-600 ring-1 ring-rose-500/30 dark:text-rose-400"
role="status"
aria-label={`Recording, ${formatDuration(elapsed - (recSince ?? 0))}`}
>
<motion.span className="size-2 rounded-full bg-rose-500" animate={reduce ? undefined : { opacity: [1, 0.25, 1] }} transition={{ duration: 1.2, repeat: Infinity }} />
REC <span className="tabular-nums">{formatDuration(elapsed - (recSince ?? 0))}</span>
</motion.span>
)}
</AnimatePresence>
{handsUp > 0 && (
<span className="hidden h-7 items-center gap-1 rounded-full bg-amber-400/15 px-2.5 text-xs font-semibold text-amber-700 dark:text-amber-300 sm:inline-flex">
<Hand className="size-3.5" aria-hidden /> {handsUp}
</span>
)}
<div role="radiogroup" aria-label="Layout" className="hidden items-center rounded-lg bg-muted p-0.5 md:flex">
{(
[
["grid", LayoutGrid, "Grid"],
["speaker", Presentation, "Speaker"],
] as const
).map(([k, Icon, label]) => (
<button
key={k}
type="button"
role="radio"
aria-checked={layout === k}
onClick={() => setLayout(k)}
className={cn("relative inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", layout === k ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{layout === k && <motion.span layoutId="vm-layout-pill" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<Icon className="relative size-3.5" aria-hidden />
<span className="relative">{label}</span>
</button>
))}
</div>
<button type="button" onClick={() => openPanel(panel === "people" ? null : "people")} className="inline-flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:hidden" aria-label={`Participants, ${people.length}`}>
<Users className="size-3.5" aria-hidden /> {people.length}
</button>
</header>
{/* Stage + side panel */}
<div className="relative flex min-h-0 flex-1">
<main className="relative min-h-0 min-w-0 flex-1 bg-muted/40 p-2 sm:p-3" aria-label="Meeting stage">
<Stage
people={people}
levels={levels}
speakerId={speakerId}
layout={layout}
pinnedId={pinnedId}
sharing={sharing}
presenter={me.name || "You"}
tileReactions={tileReactions}
onPin={pin}
onStopShare={toggleShare}
/>
{/* Floating reactions */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<AnimatePresence>
{floating.map((r) => (
<motion.div
key={r.id}
className="absolute bottom-4 flex flex-col items-center gap-1"
style={{ left: `${r.lane * 100}%` }}
initial={{ y: 0, opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: [0, 1, 0] } : { y: -360, opacity: [0, 1, 1, 0], scale: [0.5, 1.15, 1, 0.9], x: [0, 14, -12, 8] }}
exit={{ opacity: 0 }}
transition={{ duration: 2.8, ease: "easeOut" }}
>
<ReactionGlyph kind={r.kind} className="size-11" />
<span className="rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">{r.by}</span>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Toasts */}
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex flex-col items-center gap-2 px-4" role="status" aria-live="polite">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: -12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
className="inline-flex max-w-full items-center gap-2 rounded-full border bg-popover/95 px-3.5 py-1.5 text-sm text-popover-foreground shadow-lg backdrop-blur"
>
{t.tone === "rec" && <span className="size-2 shrink-0 rounded-full bg-rose-500" aria-hidden />}
{t.tone === "hand" && <Hand className="size-4 shrink-0 text-amber-500" aria-hidden />}
<span className="truncate">{t.text}</span>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Push-to-talk hint */}
<AnimatePresence>
{ptt && !me.micOn && (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute bottom-5 left-1/2 z-20 -translate-x-1/2 rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold text-white shadow-lg">
Push to talk — you're live
</motion.div>
)}
{!ptt && !me.micOn && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute bottom-5 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-1.5 rounded-full bg-black/60 px-3 py-1 text-xs text-white md:inline-flex">
<MicOff className="size-3" aria-hidden /> You're muted — hold Space to talk
</motion.div>
)}
</AnimatePresence>
</main>
<AnimatePresence initial={false}>
{panel && (
<motion.aside
key="panel"
aria-label={panel === "chat" ? "Chat" : "Participants"}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ type: "spring", stiffness: 420, damping: 38 }}
className="absolute inset-0 z-30 border-l md:static md:w-80 md:shrink-0"
>
<SidePanel
panel={panel}
onPanel={openPanel}
people={people}
levels={levels}
messages={messages}
onSend={send}
onMuteAll={muteAll}
onToggleMute={toggleMute}
onLowerHand={lowerHand}
startMinutes={MEETING.startMinutes}
/>
</motion.aside>
)}
</AnimatePresence>
</div>
{/* Controls */}
<footer className="flex h-[76px] shrink-0 items-center justify-center border-t bg-background/80 backdrop-blur">
<ControlBar
micOn={me.micOn}
cameraOn={me.cameraOn}
sharing={sharing}
handRaised={hand}
recording={recording}
layout={layout}
panel={panel}
unread={unread}
peopleCount={people.length}
onMic={toggleMic}
onCamera={toggleCam}
onShare={toggleShare}
onHand={toggleHand}
onReact={sendReaction}
onRecord={toggleRecord}
onLayout={toggleLayout}
onPanel={openPanel}
onShortcuts={() => setShortcuts(true)}
onLeave={leave}
/>
</footer>
{/* Shortcuts dialog */}
<AnimatePresence>
{shortcuts && (
<motion.div className="absolute inset-0 z-50 grid place-items-center bg-black/50 p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShortcuts(false)}>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="vm-shortcuts-title"
initial={{ scale: 0.95, y: 10 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.97, y: 6 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-2xl border bg-popover p-5 text-popover-foreground shadow-2xl"
>
<div className="mb-3 flex items-center justify-between">
<h3 id="vm-shortcuts-title" className="font-semibold">
Keyboard shortcuts
</h3>
<button type="button" autoFocus onClick={() => setShortcuts(false)} aria-label="Close" className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-4" />
</button>
</div>
<dl className="space-y-1.5 text-sm">
{SHORTCUTS.map(([k, d]) => (
<div key={k} className="flex items-center justify-between gap-3">
<dt className="text-muted-foreground">{d}</dt>
<dd>
<kbd className="rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[11px]">{k}</kbd>
</dd>
</div>
))}
</dl>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default VideoMeetingApp;