@rapidmx/meet-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +382 -0
  2. package/README.md +38 -0
  3. package/apps/meet/[token].tsx +144 -0
  4. package/apps/meet/_CallControls.tsx +69 -0
  5. package/apps/meet/_CallView.tsx +338 -0
  6. package/apps/meet/_MeetChrome.tsx +30 -0
  7. package/apps/meet/_MeetLobby.tsx +223 -0
  8. package/apps/meet/_ParticipantTile.tsx +66 -0
  9. package/apps/meet/_layout.tsx +34 -0
  10. package/apps/meet/_meetApi.ts +66 -0
  11. package/apps/meet/index.tsx +22 -0
  12. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
  13. package/apps/settings-video-conferencing/_layout.tsx +47 -0
  14. package/apps/settings-video-conferencing/index.tsx +192 -0
  15. package/apps/shared/media/activeSpeaker.ts +45 -0
  16. package/apps/shared/media/deviceMedia.ts +141 -0
  17. package/apps/shared/media/levelMeter.ts +91 -0
  18. package/apps/shared/push/GuestSignalingClient.ts +338 -0
  19. package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
  20. package/apps/shared/webrtc/realPeerConnection.ts +37 -0
  21. package/apps/shared/webrtc/types.ts +90 -0
  22. package/dist/apps/meet/[token].d.ts +33 -0
  23. package/dist/apps/meet/[token].js +101 -0
  24. package/dist/apps/meet/_CallControls.d.ts +18 -0
  25. package/dist/apps/meet/_CallControls.js +6 -0
  26. package/dist/apps/meet/_CallView.d.ts +41 -0
  27. package/dist/apps/meet/_CallView.js +225 -0
  28. package/dist/apps/meet/_MeetChrome.d.ts +13 -0
  29. package/dist/apps/meet/_MeetChrome.js +10 -0
  30. package/dist/apps/meet/_MeetLobby.d.ts +29 -0
  31. package/dist/apps/meet/_MeetLobby.js +120 -0
  32. package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
  33. package/dist/apps/meet/_ParticipantTile.js +25 -0
  34. package/dist/apps/meet/_layout.d.ts +9 -0
  35. package/dist/apps/meet/_layout.js +8 -0
  36. package/dist/apps/meet/_meetApi.d.ts +47 -0
  37. package/dist/apps/meet/_meetApi.js +23 -0
  38. package/dist/apps/meet/index.d.ts +5 -0
  39. package/dist/apps/meet/index.js +8 -0
  40. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
  41. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
  42. package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
  43. package/dist/apps/settings-video-conferencing/_layout.js +8 -0
  44. package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
  45. package/dist/apps/settings-video-conferencing/index.js +92 -0
  46. package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
  47. package/dist/apps/shared/media/activeSpeaker.js +39 -0
  48. package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
  49. package/dist/apps/shared/media/deviceMedia.js +110 -0
  50. package/dist/apps/shared/media/levelMeter.d.ts +40 -0
  51. package/dist/apps/shared/media/levelMeter.js +61 -0
  52. package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
  53. package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
  54. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
  55. package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
  56. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  57. package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
  58. package/dist/apps/shared/webrtc/types.d.ts +94 -0
  59. package/dist/apps/shared/webrtc/types.js +9 -0
  60. package/dist/lib/index.js +25 -0
  61. package/dist/lib/index.js.map +1 -0
  62. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
  63. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
  64. package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
  65. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
  66. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
  67. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
  68. package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
  69. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
  70. package/dist/lib/models/types.js +19 -0
  71. package/dist/lib/models/types.js.map +1 -0
  72. package/dist/lib/mongo.js +13 -0
  73. package/dist/lib/mongo.js.map +1 -0
  74. package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
  75. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
  76. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
  77. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
  78. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
  79. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
  80. package/dist/lib/sql.js +13 -0
  81. package/dist/lib/sql.js.map +1 -0
  82. package/dist/lib/util/BookingIntegrationUtils.js +115 -0
  83. package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
  84. package/dist/lib/util/IceServerUtils.js +68 -0
  85. package/dist/lib/util/IceServerUtils.js.map +1 -0
  86. package/dist/lib/util/PublicUrlUtils.js +37 -0
  87. package/dist/lib/util/PublicUrlUtils.js.map +1 -0
  88. package/dist/lib/util/RouteAccessUtils.js +32 -0
  89. package/dist/lib/util/RouteAccessUtils.js.map +1 -0
  90. package/dist/lib/util/TokenUtils.js +33 -0
  91. package/dist/lib/util/TokenUtils.js.map +1 -0
  92. package/dist/types/index.d.ts +20 -0
  93. package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
  94. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
  95. package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
  96. package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
  97. package/dist/types/models/types.d.ts +131 -0
  98. package/dist/types/mongo.d.ts +8 -0
  99. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
  100. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
  101. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
  102. package/dist/types/sql.d.ts +8 -0
  103. package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
  104. package/dist/types/util/IceServerUtils.d.ts +57 -0
  105. package/dist/types/util/PublicUrlUtils.d.ts +11 -0
  106. package/dist/types/util/RouteAccessUtils.d.ts +8 -0
  107. package/dist/types/util/TokenUtils.d.ts +23 -0
  108. package/package.json +181 -0
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { createVideoMeeting, updateVideoMeeting } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
12
+ /** The title a freshly created personal room starts with - editable afterward like any other meeting's title. */
13
+ export const PERSONAL_ROOM_TITLE = "Personal Meeting Room";
14
+ /**
15
+ * The "get and manage your own persistent public link" card - see this plugin's `.claude/NOTES.md` Phase 4 entry
16
+ * for why a personal room is simply the mailbox's own oldest non-cancelled `PUBLIC` `VideoMeeting`, not a distinct
17
+ * model concept. Offers to create one when there is none, and otherwise shows its (editable) title and its
18
+ * shareable link, exactly as `create()`/`findById()` compute it server-side.
19
+ */
20
+ export default function PersonalRoomCard({ mailboxUid, room, onCreated, onRenamed }) {
21
+ const [titleDraft, setTitleDraft] = useState(room?.title ?? "");
22
+ const [creating, setCreating] = useState(false);
23
+ const [saving, setSaving] = useState(false);
24
+ const [copied, setCopied] = useState(false);
25
+ const [error, setError] = useState(null);
26
+ // Reset the draft whenever the identified room itself changes (freshly created, or renamed elsewhere) - not
27
+ // on every parent re-render, so an in-progress edit here survives an unrelated state update on the page (e.g.
28
+ // cancelling a different meeting in the list below).
29
+ useEffect(() => {
30
+ setTitleDraft(room?.title ?? "");
31
+ }, [room?.uid, room?.title]);
32
+ async function handleCreate() {
33
+ setCreating(true);
34
+ setError(null);
35
+ try {
36
+ const result = await createVideoMeeting({ mailboxUid, title: PERSONAL_ROOM_TITLE, visibility: "public" });
37
+ onCreated({ ...result.meeting, publicJoinUrl: result.publicJoinUrl });
38
+ }
39
+ catch (err) {
40
+ setError(err instanceof ApiRequestError ? err.message : "Could not create your personal room.");
41
+ }
42
+ finally {
43
+ setCreating(false);
44
+ }
45
+ }
46
+ async function handleRename(e) {
47
+ e.preventDefault();
48
+ const trimmed = titleDraft.trim();
49
+ if (!room || !trimmed || trimmed === room.title) {
50
+ return;
51
+ }
52
+ setSaving(true);
53
+ setError(null);
54
+ try {
55
+ const updated = await updateVideoMeeting(room.uid, { title: trimmed });
56
+ onRenamed(room.uid, updated.title);
57
+ }
58
+ catch (err) {
59
+ setError(err instanceof ApiRequestError ? err.message : "Could not rename your personal room.");
60
+ }
61
+ finally {
62
+ setSaving(false);
63
+ }
64
+ }
65
+ async function handleCopy(url) {
66
+ try {
67
+ await navigator.clipboard.writeText(url);
68
+ setError(null);
69
+ setCopied(true);
70
+ setTimeout(() => setCopied(false), 2000);
71
+ }
72
+ catch {
73
+ setError("Could not copy the link. Copy it by hand instead.");
74
+ }
75
+ }
76
+ return (_jsxs("section", { "aria-label": "Your personal room", className: "border border-border rounded-md p-5 mb-6", children: [_jsx("h2", { className: "text-sm font-bold", children: "Your personal room" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "A permanent link you can share any time - anyone with it can join, no account needed on their end." }), error && _jsx(Alert, { children: error }), !room ? (_jsx(Button, { type: "button", className: "!w-auto", loading: creating, disabled: creating, onClick: handleCreate, children: "Create my personal room" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("form", { onSubmit: handleRename, className: "flex items-end gap-2", children: [_jsxs("label", { className: "flex-1 flex flex-col gap-1", children: [_jsx("span", { className: "text-xs text-text-muted", children: "Room name" }), _jsx("input", { type: "text", className: INPUT_CLASS, value: titleDraft, onChange: (e) => setTitleDraft(e.target.value) })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto shrink-0", loading: saving, disabled: saving || !titleDraft.trim() || titleDraft.trim() === room.title, children: "Save" })] }), room.publicJoinUrl ? (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("code", { className: "flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap", children: room.publicJoinUrl }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => handleCopy(room.publicJoinUrl), children: copied ? "Copied" : "Copy" })] })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No public join page URL is configured for this deployment yet - ask an administrator to set one in the Video Conferencing plugin's settings before sharing this room." }))] }))] }));
77
+ }
@@ -0,0 +1,16 @@
1
+ import React, { PropsWithChildren } from "react";
2
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
3
+ export interface LayoutProps {
4
+ /**
5
+ * Supplied by the server's `WwwRoute` `fetchProps()`, which every `www` plugin app's route extends. This file
6
+ * is a copy of `@rapidmx/web-client`'s `apps/www/_layout.tsx` (mirroring `booking-plugin`'s own
7
+ * `apps/settings-booking-types/_layout.tsx` exactly), since every app directory needs its own layout.
8
+ * `@rapidrest/react` spreads the same merged page props onto this layout as onto the page it wraps, so it
9
+ * renders correctly server-side on the very first byte of the response: no client-side flash from the stock
10
+ * defaults below, and a crawler reading the raw HTML sees the real branding too. `undefined` only on the
11
+ * framework's unwrapped `_500` fallback path, which doesn't receive props at all - falling back to the stock
12
+ * defaults there is acceptable.
13
+ */
14
+ branding?: Branding;
15
+ }
16
+ export default function Layout({ children, branding }: PropsWithChildren<LayoutProps>): React.JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
3
+ export default function Layout({ children, branding }) {
4
+ const title = branding?.title || branding?.companyName ? `${branding?.title || branding?.companyName}: Mail` : "RapidMX: Mail";
5
+ const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
6
+ const stylesheetHref = branding?.stylesheetUrl;
7
+ return (_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("title", { children: title }), _jsx("link", { rel: "icon", type: "image/svg+xml", href: iconHref }), _jsx("link", { rel: "alternate icon", href: "/favicon.ico" }), _jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), _jsx("link", { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" }), stylesheetHref && _jsx("link", { rel: "stylesheet", href: stylesheetHref, id: CUSTOM_STYLESHEET_LINK_ID })] }), _jsx("body", { children: children })] }));
8
+ }
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import { VideoMeetingDetail } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
3
+ import { SettingsShellProps } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
4
+ export type SettingsVideoConferencingPageProps = Omit<SettingsShellProps, "active">;
5
+ export default function SettingsVideoConferencingPage(props: SettingsVideoConferencingPageProps): React.JSX.Element;
6
+ /**
7
+ * Picks a mailbox's "personal room" out of `listVideoMeetings()`'s results: the oldest still-active (non-cancelled)
8
+ * `PUBLIC` meeting. `BaseVideoMeetingRoute` has no field or route naming a "personal room" as a concept distinct
9
+ * from any other public meeting a mailbox happens to have - a public meeting IS just a `VideoMeeting` with
10
+ * `visibility: "public"` (see this plugin's `.claude/NOTES.md` Phase 4 entry for the full reasoning behind this
11
+ * convention, and why no backend addition was needed to support it). Oldest-first keeps the identification stable
12
+ * across reloads, regardless of when in the list a later public meeting (created some other way, e.g. directly
13
+ * through the API) happens to land; skipping a cancelled one means cancelling today's room and creating a new one
14
+ * always finds the fresh one on the next load, never a dead link.
15
+ */
16
+ export declare function findPersonalRoom(meetings: VideoMeetingDetail[]): VideoMeetingDetail | undefined;
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listVideoMeetings, updateVideoMeeting } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
9
+ import SettingsShell, { useSettingsShell } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import PersonalRoomCard from "./_PersonalRoomCard.js";
12
+ /** However many of a mailbox's meetings this page ever needs to see at once - generous enough that a real user's
13
+ * whole history fits in one request (this is a personal settings page, not a paged archive), matching
14
+ * `SettingsShell`'s own `listMailboxes({ limit: 100 })` precedent for "large enough not to page in practice". */
15
+ const LIST_LIMIT = 200;
16
+ const STATUS_LABELS = {
17
+ scheduled: "Scheduled",
18
+ active: "In progress",
19
+ ended: "Ended",
20
+ cancelled: "Cancelled",
21
+ };
22
+ export default function SettingsVideoConferencingPage(props) {
23
+ return (_jsx(SettingsShell, { ...props, active: "video-conferencing", children: _jsx(VideoConferencingSettingsContent, {}) }));
24
+ }
25
+ /**
26
+ * Picks a mailbox's "personal room" out of `listVideoMeetings()`'s results: the oldest still-active (non-cancelled)
27
+ * `PUBLIC` meeting. `BaseVideoMeetingRoute` has no field or route naming a "personal room" as a concept distinct
28
+ * from any other public meeting a mailbox happens to have - a public meeting IS just a `VideoMeeting` with
29
+ * `visibility: "public"` (see this plugin's `.claude/NOTES.md` Phase 4 entry for the full reasoning behind this
30
+ * convention, and why no backend addition was needed to support it). Oldest-first keeps the identification stable
31
+ * across reloads, regardless of when in the list a later public meeting (created some other way, e.g. directly
32
+ * through the API) happens to land; skipping a cancelled one means cancelling today's room and creating a new one
33
+ * always finds the fresh one on the next load, never a dead link.
34
+ */
35
+ export function findPersonalRoom(meetings) {
36
+ return meetings
37
+ .filter((m) => m.visibility === "public" && m.status !== "cancelled")
38
+ .sort((a, b) => new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime())[0];
39
+ }
40
+ function VideoConferencingSettingsContent() {
41
+ const { mailboxUid } = useSettingsShell();
42
+ const [meetings, setMeetings] = useState([]);
43
+ const [loading, setLoading] = useState(true);
44
+ const [loadError, setLoadError] = useState(null);
45
+ const [copiedUid, setCopiedUid] = useState(null);
46
+ const [cancellingUid, setCancellingUid] = useState(null);
47
+ const [actionError, setActionError] = useState(null);
48
+ // `SettingsShell` only ever renders its children once `mailboxUid` has resolved - same established non-null
49
+ // pattern as `booking-plugin`'s own settings pages.
50
+ useEffect(() => {
51
+ setLoading(true);
52
+ setLoadError(null);
53
+ listVideoMeetings(mailboxUid, { limit: LIST_LIMIT })
54
+ .then(setMeetings)
55
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your video meetings."))
56
+ .finally(() => setLoading(false));
57
+ }, [mailboxUid]);
58
+ const personalRoom = findPersonalRoom(meetings);
59
+ // Every other meeting worth showing here: any other public meeting, or a private one with a linked calendar
60
+ // event (the calendar compose hook's own meetings - Phase 3). A private, calendar-less meeting is an artifact
61
+ // of this plugin's own API used directly (e.g. testing), not something a real user created through this page
62
+ // or a calendar invite, so it's left out. The personal room itself is surfaced above instead of repeated here.
63
+ const otherMeetings = meetings
64
+ .filter((m) => m.uid !== personalRoom?.uid && (m.visibility === "public" || !!m.calendarEventUid))
65
+ .sort((a, b) => new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime());
66
+ async function handleCopy(url, uid) {
67
+ try {
68
+ await navigator.clipboard.writeText(url);
69
+ setActionError(null);
70
+ setCopiedUid(uid);
71
+ setTimeout(() => setCopiedUid((current) => (current === uid ? null : current)), 2000);
72
+ }
73
+ catch {
74
+ setActionError("Could not copy the link. Copy it by hand instead.");
75
+ }
76
+ }
77
+ async function handleCancel(uid) {
78
+ setCancellingUid(uid);
79
+ setActionError(null);
80
+ try {
81
+ const updated = await updateVideoMeeting(uid, { status: "cancelled" });
82
+ setMeetings((current) => current.map((m) => (m.uid === uid ? { ...m, status: updated.status } : m)));
83
+ }
84
+ catch (err) {
85
+ setActionError(err instanceof ApiRequestError ? err.message : "Could not cancel this meeting.");
86
+ }
87
+ finally {
88
+ setCancellingUid(null);
89
+ }
90
+ }
91
+ return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-4xl", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Video Conferencing" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Your personal meeting room, and every video meeting created for you - from a calendar invite or directly here." }), loadError && _jsx(Alert, { children: loadError }), actionError && _jsx(Alert, { children: actionError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(PersonalRoomCard, { mailboxUid: mailboxUid, room: personalRoom, onCreated: (meeting) => setMeetings((current) => [meeting, ...current]), onRenamed: (uid, title) => setMeetings((current) => current.map((m) => (m.uid === uid ? { ...m, title } : m))) }), _jsx("h2", { className: "text-sm font-bold mb-2", children: "Meetings" }), otherMeetings.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No other meetings yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Title", "Visibility", "Status", "Created", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: otherMeetings.map((meeting) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: meeting.title }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border capitalize", children: meeting.visibility }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: STATUS_LABELS[meeting.status] ?? meeting.status }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border whitespace-nowrap", children: new Date(meeting.dateCreated).toLocaleString() }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border text-right whitespace-nowrap", children: [meeting.visibility === "public" && meeting.publicJoinUrl && (_jsx("button", { type: "button", "aria-label": `Copy link to ${meeting.title}`, className: "text-primary-dark hover:underline font-medium mr-4", onClick: () => handleCopy(meeting.publicJoinUrl, meeting.uid), children: copiedUid === meeting.uid ? "Copied" : "Copy link" })), meeting.status === "scheduled" && (_jsx("button", { type: "button", "aria-label": `Cancel ${meeting.title}`, className: "text-danger hover:underline font-medium disabled:opacity-55", disabled: cancellingUid === meeting.uid, onClick: () => handleCancel(meeting.uid), children: cancellingUid === meeting.uid ? "Cancelling…" : "Cancel" }))] })] }, meeting.uid))) })] }) }))] }))] }) }));
92
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Pure active-speaker selection logic, decoupled from `levelMeter.ts`'s `AnalyserNode` wiring so it can be unit
3
+ * tested with plain numbers - no `AudioContext`/DOM at all. A simple volume-threshold heuristic, as this plugin's
4
+ * Phase 2 `.claude/NOTES.md` entry calls for ("this doesn't need to be sophisticated"): the loudest participant
5
+ * currently above `threshold` becomes the pick; below threshold, the previous pick is kept rather than falling
6
+ * back to nobody, so the focused tile doesn't flicker to a blank state during a brief pause in speech.
7
+ */
8
+ /** 0-100 (`levelMeter.ts`'s own output range). Chosen empirically as "clearly speaking" vs. room noise/silence -
9
+ * not derived from any spec, and the easiest constant to retune later. */
10
+ export declare const DEFAULT_ACTIVE_SPEAKER_THRESHOLD = 12;
11
+ /**
12
+ * Picks who should be auto-focused, given every remote participant's current audio level (local participant
13
+ * excluded by the caller - see `_CallView.tsx`, which never lets "yourself" be the auto-detected speaker).
14
+ *
15
+ * @param levels uid -> level (0-100), one entry per participant currently being measured.
16
+ * @param current The currently auto-focused uid, if any - kept when no one is clearly above `threshold`, and used
17
+ * to break an exact tie (staying on the current speaker rather than jittering to another equally-loud one).
18
+ * @param threshold See `DEFAULT_ACTIVE_SPEAKER_THRESHOLD`.
19
+ */
20
+ export declare function pickActiveSpeaker(levels: Readonly<Record<string, number>>, current: string | undefined, threshold?: number): string | undefined;
@@ -0,0 +1,39 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Pure active-speaker selection logic, decoupled from `levelMeter.ts`'s `AnalyserNode` wiring so it can be unit
7
+ * tested with plain numbers - no `AudioContext`/DOM at all. A simple volume-threshold heuristic, as this plugin's
8
+ * Phase 2 `.claude/NOTES.md` entry calls for ("this doesn't need to be sophisticated"): the loudest participant
9
+ * currently above `threshold` becomes the pick; below threshold, the previous pick is kept rather than falling
10
+ * back to nobody, so the focused tile doesn't flicker to a blank state during a brief pause in speech.
11
+ */
12
+ /** 0-100 (`levelMeter.ts`'s own output range). Chosen empirically as "clearly speaking" vs. room noise/silence -
13
+ * not derived from any spec, and the easiest constant to retune later. */
14
+ export const DEFAULT_ACTIVE_SPEAKER_THRESHOLD = 12;
15
+ /**
16
+ * Picks who should be auto-focused, given every remote participant's current audio level (local participant
17
+ * excluded by the caller - see `_CallView.tsx`, which never lets "yourself" be the auto-detected speaker).
18
+ *
19
+ * @param levels uid -> level (0-100), one entry per participant currently being measured.
20
+ * @param current The currently auto-focused uid, if any - kept when no one is clearly above `threshold`, and used
21
+ * to break an exact tie (staying on the current speaker rather than jittering to another equally-loud one).
22
+ * @param threshold See `DEFAULT_ACTIVE_SPEAKER_THRESHOLD`.
23
+ */
24
+ export function pickActiveSpeaker(levels, current, threshold = DEFAULT_ACTIVE_SPEAKER_THRESHOLD) {
25
+ let best;
26
+ let bestLevel = threshold;
27
+ for (const [uid, level] of Object.entries(levels)) {
28
+ if (level > bestLevel || (level === bestLevel && uid === current)) {
29
+ best = uid;
30
+ bestLevel = level;
31
+ }
32
+ }
33
+ if (best) {
34
+ return best;
35
+ }
36
+ // Nobody is clearly above threshold: keep the current pick only if they're still a measured participant
37
+ // (they may have left), otherwise there's no reasonable pick at all.
38
+ return current && current in levels ? current : undefined;
39
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Thin, dependency-injectable wrappers over `navigator.mediaDevices` - `enumerateDevices()`/`getUserMedia()`/
3
+ * `getDisplayMedia()` - used by both the join/lobby page's device preview and the in-call view's mute/camera/share
4
+ * controls (see this plugin's Phase 2 `.claude/NOTES.md` entry). Kept under `src/` rather than `apps/meet/` because
5
+ * it is pure logic with no JSX, reused by more than one page/component.
6
+ *
7
+ * Every function accepts an optional `MediaDevicesLike` so a test can inject a fake without a real browser; the
8
+ * real `navigator.mediaDevices` is used by default, resolved lazily inside each function (never at module scope)
9
+ * so importing this module is safe during this plugin's page SSR, where no browser globals exist at all.
10
+ *
11
+ * Nothing here ever throws: every browser-API call is wrapped in `classifyMediaError()` and returned as a
12
+ * discriminated `{ ok: false, error }` result, so a caller (a React component) never needs a try/catch of its own.
13
+ */
14
+ /** The subset of `MediaDevices` this module calls - what a test's fake implements. */
15
+ export interface MediaDevicesLike {
16
+ enumerateDevices?: () => Promise<MediaDeviceInfo[]>;
17
+ getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
18
+ getDisplayMedia?: (constraints?: DisplayMediaStreamOptions) => Promise<MediaStream>;
19
+ }
20
+ export type MediaAccessErrorKind = "unsupported" | "permission-denied" | "not-found" | "unknown";
21
+ export interface MediaAccessError {
22
+ kind: MediaAccessErrorKind;
23
+ /** Already a complete, user-facing sentence - a caller shows this directly, no further mapping needed. */
24
+ message: string;
25
+ }
26
+ export type MediaResult<T> = {
27
+ ok: true;
28
+ value: T;
29
+ } | {
30
+ ok: false;
31
+ error: MediaAccessError;
32
+ };
33
+ /** `navigator.mediaDevices`, or `undefined` where there is none (SSR, a non-HTTPS/insecure context in some
34
+ * browsers, or a very old browser) - resolved lazily so this is safe to call from module code executed under SSR,
35
+ * where `navigator` itself does not exist. */
36
+ export declare function defaultMediaDevices(): MediaDevicesLike | undefined;
37
+ /** Whether `devices.getUserMedia` is actually callable - the one capability every caller here needs at minimum. */
38
+ export declare function isMediaDevicesSupported(devices?: MediaDevicesLike | undefined): boolean;
39
+ /** Maps a `getUserMedia()`/`getDisplayMedia()`/`enumerateDevices()` rejection to a friendly, already-complete
40
+ * message - the handful of `DOMException` names a browser actually raises for these calls, matched by name rather
41
+ * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). */
42
+ export declare function classifyMediaError(err: unknown): MediaAccessError;
43
+ export interface DeviceLists {
44
+ cameras: MediaDeviceInfo[];
45
+ microphones: MediaDeviceInfo[];
46
+ }
47
+ /**
48
+ * Lists the available camera/microphone devices. Labels are only populated once permission has been granted (a
49
+ * browser rule, not something this function can work around) - a caller wanting labeled devices should call
50
+ * `requestUserMedia()` first, then this again.
51
+ */
52
+ export declare function listDevices(devices?: MediaDevicesLike | undefined): Promise<MediaResult<DeviceLists>>;
53
+ /** Requests camera/microphone access. `constraints` lets the caller ask for a specific `deviceId` (device
54
+ * switching) or disable a track kind entirely (e.g. `{ video: false, audio: true }` for a mic-only preview). */
55
+ export declare function requestUserMedia(constraints: MediaStreamConstraints, devices?: MediaDevicesLike | undefined): Promise<MediaResult<MediaStream>>;
56
+ /** Requests a screen/window/tab share via the browser's own picker. Audio is never requested (this design shares
57
+ * camera-equivalent video only - see this plugin's Phase 2 `.claude/NOTES.md` entry on presentation mode). */
58
+ export declare function requestDisplayMedia(devices?: MediaDevicesLike | undefined): Promise<MediaResult<MediaStream>>;
59
+ /** Stops every track of `stream` (releasing the camera/microphone/screen-share indicator) - tolerates `undefined`/
60
+ * `null` so a caller never needs its own guard. */
61
+ export declare function stopStream(stream: MediaStream | undefined | null): void;
62
+ /** Enables/disables every track of `stream` matching `kind`, for the mute/camera-off toggles - tracks are kept
63
+ * (not stopped/removed), matching every mainstream video-call app's "mute" behavior: instant, reversible, and
64
+ * without re-requesting `getUserMedia()` or renegotiating the `RTCPeerConnection`. */
65
+ export declare function setTracksEnabled(stream: MediaStream | undefined | null, kind: "audio" | "video", enabled: boolean): void;
@@ -0,0 +1,110 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Thin, dependency-injectable wrappers over `navigator.mediaDevices` - `enumerateDevices()`/`getUserMedia()`/
7
+ * `getDisplayMedia()` - used by both the join/lobby page's device preview and the in-call view's mute/camera/share
8
+ * controls (see this plugin's Phase 2 `.claude/NOTES.md` entry). Kept under `src/` rather than `apps/meet/` because
9
+ * it is pure logic with no JSX, reused by more than one page/component.
10
+ *
11
+ * Every function accepts an optional `MediaDevicesLike` so a test can inject a fake without a real browser; the
12
+ * real `navigator.mediaDevices` is used by default, resolved lazily inside each function (never at module scope)
13
+ * so importing this module is safe during this plugin's page SSR, where no browser globals exist at all.
14
+ *
15
+ * Nothing here ever throws: every browser-API call is wrapped in `classifyMediaError()` and returned as a
16
+ * discriminated `{ ok: false, error }` result, so a caller (a React component) never needs a try/catch of its own.
17
+ */
18
+ /** `navigator.mediaDevices`, or `undefined` where there is none (SSR, a non-HTTPS/insecure context in some
19
+ * browsers, or a very old browser) - resolved lazily so this is safe to call from module code executed under SSR,
20
+ * where `navigator` itself does not exist. */
21
+ export function defaultMediaDevices() {
22
+ return typeof navigator === "undefined" ? undefined : (navigator.mediaDevices);
23
+ }
24
+ /** Whether `devices.getUserMedia` is actually callable - the one capability every caller here needs at minimum. */
25
+ export function isMediaDevicesSupported(devices = defaultMediaDevices()) {
26
+ return typeof devices?.getUserMedia === "function";
27
+ }
28
+ const UNSUPPORTED_ERROR = {
29
+ kind: "unsupported",
30
+ message: "This browser (or this page's connection) doesn't support camera/microphone access. Try a modern browser over HTTPS.",
31
+ };
32
+ /** Maps a `getUserMedia()`/`getDisplayMedia()`/`enumerateDevices()` rejection to a friendly, already-complete
33
+ * message - the handful of `DOMException` names a browser actually raises for these calls, matched by name rather
34
+ * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). */
35
+ export function classifyMediaError(err) {
36
+ const name = err instanceof Error ? err.name : "";
37
+ switch (name) {
38
+ case "NotAllowedError":
39
+ case "SecurityError":
40
+ return { kind: "permission-denied", message: "Camera/microphone access was denied. Allow access in your browser and try again." };
41
+ case "NotFoundError":
42
+ case "OverconstrainedError":
43
+ return { kind: "not-found", message: "No camera or microphone was found on this device." };
44
+ default:
45
+ return { kind: "unknown", message: "Could not access your camera or microphone. Please try again." };
46
+ }
47
+ }
48
+ /**
49
+ * Lists the available camera/microphone devices. Labels are only populated once permission has been granted (a
50
+ * browser rule, not something this function can work around) - a caller wanting labeled devices should call
51
+ * `requestUserMedia()` first, then this again.
52
+ */
53
+ export async function listDevices(devices = defaultMediaDevices()) {
54
+ if (!devices?.enumerateDevices) {
55
+ return { ok: false, error: UNSUPPORTED_ERROR };
56
+ }
57
+ try {
58
+ const all = await devices.enumerateDevices();
59
+ return {
60
+ ok: true,
61
+ value: {
62
+ cameras: all.filter((d) => d.kind === "videoinput"),
63
+ microphones: all.filter((d) => d.kind === "audioinput"),
64
+ },
65
+ };
66
+ }
67
+ catch (err) {
68
+ return { ok: false, error: classifyMediaError(err) };
69
+ }
70
+ }
71
+ /** Requests camera/microphone access. `constraints` lets the caller ask for a specific `deviceId` (device
72
+ * switching) or disable a track kind entirely (e.g. `{ video: false, audio: true }` for a mic-only preview). */
73
+ export async function requestUserMedia(constraints, devices = defaultMediaDevices()) {
74
+ if (!devices?.getUserMedia) {
75
+ return { ok: false, error: UNSUPPORTED_ERROR };
76
+ }
77
+ try {
78
+ return { ok: true, value: await devices.getUserMedia(constraints) };
79
+ }
80
+ catch (err) {
81
+ return { ok: false, error: classifyMediaError(err) };
82
+ }
83
+ }
84
+ /** Requests a screen/window/tab share via the browser's own picker. Audio is never requested (this design shares
85
+ * camera-equivalent video only - see this plugin's Phase 2 `.claude/NOTES.md` entry on presentation mode). */
86
+ export async function requestDisplayMedia(devices = defaultMediaDevices()) {
87
+ if (!devices?.getDisplayMedia) {
88
+ return { ok: false, error: UNSUPPORTED_ERROR };
89
+ }
90
+ try {
91
+ return { ok: true, value: await devices.getDisplayMedia({ video: true, audio: false }) };
92
+ }
93
+ catch (err) {
94
+ return { ok: false, error: classifyMediaError(err) };
95
+ }
96
+ }
97
+ /** Stops every track of `stream` (releasing the camera/microphone/screen-share indicator) - tolerates `undefined`/
98
+ * `null` so a caller never needs its own guard. */
99
+ export function stopStream(stream) {
100
+ stream?.getTracks().forEach((track) => track.stop());
101
+ }
102
+ /** Enables/disables every track of `stream` matching `kind`, for the mute/camera-off toggles - tracks are kept
103
+ * (not stopped/removed), matching every mainstream video-call app's "mute" behavior: instant, reversible, and
104
+ * without re-requesting `getUserMedia()` or renegotiating the `RTCPeerConnection`. */
105
+ export function setTracksEnabled(stream, kind, enabled) {
106
+ const tracks = kind === "audio" ? (stream?.getAudioTracks() ?? []) : (stream?.getVideoTracks() ?? []);
107
+ for (const track of tracks) {
108
+ track.enabled = enabled;
109
+ }
110
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `Web Audio API` `AnalyserNode` wiring for a per-track volume meter - the browser-glue half of active-speaker
3
+ * detection; `activeSpeaker.ts` holds the actual selection logic as plain, DOM-free functions over the numbers
4
+ * this module produces. Every browser constructor is behind an injectable factory, so a test never needs a real
5
+ * `AudioContext`.
6
+ */
7
+ /** The subset of `AnalyserNode` this module uses - what a test's fake implements. */
8
+ export interface AnalyserNodeLike {
9
+ fftSize: number;
10
+ getByteTimeDomainData(array: Uint8Array): void;
11
+ }
12
+ /** The subset of `AudioContext` this module uses. */
13
+ export interface AudioContextLike {
14
+ createAnalyser(): AnalyserNodeLike;
15
+ createMediaStreamSource(stream: MediaStream): {
16
+ connect(node: AnalyserNodeLike): void;
17
+ };
18
+ close(): Promise<void> | void;
19
+ }
20
+ export type AudioContextFactory = () => AudioContextLike;
21
+ /** The real `AudioContext`/`webkitAudioContext` constructor, or `undefined` where there is none (SSR, a browser
22
+ * with no Web Audio support). Resolved lazily - never at module scope - so importing this module is SSR-safe. */
23
+ export declare function defaultAudioContextFactory(): AudioContextFactory | undefined;
24
+ export interface LevelMeterHandle {
25
+ /** Stops polling and releases the `AudioContext` - idempotent. */
26
+ stop(): void;
27
+ }
28
+ /** How often the meter samples and reports a level, in milliseconds. Fast enough to feel responsive for
29
+ * active-speaker switching without generating excessive React state churn. */
30
+ export declare const DEFAULT_LEVEL_METER_INTERVAL_MS = 200;
31
+ /**
32
+ * Starts reporting `stream`'s audio level (0-100, a simple mean-absolute-deviation-from-silence over each sample
33
+ * window - not true RMS/dBFS, deliberately: this is a threshold heuristic, not a metering instrument) to `onLevel`
34
+ * every `intervalMs`. Returns `undefined` (nothing started) when there is no usable `AudioContext` or `stream` has
35
+ * no audio track to measure - a caller treats that the same as "can't tell, don't auto-switch on this stream".
36
+ */
37
+ export declare function startLevelMeter(stream: MediaStream, onLevel: (level: number) => void, options?: {
38
+ factory?: AudioContextFactory;
39
+ intervalMs?: number;
40
+ }): LevelMeterHandle | undefined;
@@ -0,0 +1,61 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * `Web Audio API` `AnalyserNode` wiring for a per-track volume meter - the browser-glue half of active-speaker
7
+ * detection; `activeSpeaker.ts` holds the actual selection logic as plain, DOM-free functions over the numbers
8
+ * this module produces. Every browser constructor is behind an injectable factory, so a test never needs a real
9
+ * `AudioContext`.
10
+ */
11
+ /** The real `AudioContext`/`webkitAudioContext` constructor, or `undefined` where there is none (SSR, a browser
12
+ * with no Web Audio support). Resolved lazily - never at module scope - so importing this module is SSR-safe. */
13
+ export function defaultAudioContextFactory() {
14
+ if (typeof window === "undefined") {
15
+ return undefined;
16
+ }
17
+ const Ctor = window.AudioContext ??
18
+ window.webkitAudioContext;
19
+ return Ctor ? () => new Ctor() : undefined;
20
+ }
21
+ /** How often the meter samples and reports a level, in milliseconds. Fast enough to feel responsive for
22
+ * active-speaker switching without generating excessive React state churn. */
23
+ export const DEFAULT_LEVEL_METER_INTERVAL_MS = 200;
24
+ /**
25
+ * Starts reporting `stream`'s audio level (0-100, a simple mean-absolute-deviation-from-silence over each sample
26
+ * window - not true RMS/dBFS, deliberately: this is a threshold heuristic, not a metering instrument) to `onLevel`
27
+ * every `intervalMs`. Returns `undefined` (nothing started) when there is no usable `AudioContext` or `stream` has
28
+ * no audio track to measure - a caller treats that the same as "can't tell, don't auto-switch on this stream".
29
+ */
30
+ export function startLevelMeter(stream, onLevel, options = {}) {
31
+ const factory = options.factory ?? defaultAudioContextFactory();
32
+ if (!factory || stream.getAudioTracks().length === 0) {
33
+ return undefined;
34
+ }
35
+ const context = factory();
36
+ const analyser = context.createAnalyser();
37
+ analyser.fftSize = 512;
38
+ context.createMediaStreamSource(stream).connect(analyser);
39
+ const buffer = new Uint8Array(analyser.fftSize);
40
+ const timer = setInterval(() => {
41
+ analyser.getByteTimeDomainData(buffer);
42
+ let sum = 0;
43
+ for (let i = 0; i < buffer.length; i++) {
44
+ sum += Math.abs(buffer[i] - 128);
45
+ }
46
+ // The maximum possible mean deviation from the 128 midpoint is 128 (a square wave at full scale) -
47
+ // normalized to a friendlier 0-100 range, matching `activeSpeaker.ts`'s `DEFAULT_ACTIVE_SPEAKER_THRESHOLD`.
48
+ onLevel(Math.min(100, (sum / buffer.length / 128) * 100));
49
+ }, options.intervalMs ?? DEFAULT_LEVEL_METER_INTERVAL_MS);
50
+ let stopped = false;
51
+ return {
52
+ stop: () => {
53
+ if (stopped) {
54
+ return;
55
+ }
56
+ stopped = true;
57
+ clearInterval(timer);
58
+ void context.close();
59
+ },
60
+ };
61
+ }