@rapidmx/meet-plugin 0.9.3 → 0.11.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.
- package/apps/meet/[token].tsx +204 -13
- package/apps/meet/_CallControls.tsx +75 -5
- package/apps/meet/_CallView.tsx +326 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +397 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +7 -3
- package/apps/shared/webrtc/MeshConnectionManager.ts +115 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +67 -8
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +16 -2
- package/dist/apps/meet/_CallControls.js +9 -6
- package/dist/apps/meet/_CallView.d.ts +45 -2
- package/dist/apps/meet/_CallView.js +215 -6
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
- package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
- package/dist/apps/meet/_ParticipantTile.js +9 -2
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +77 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +91 -0
- package/dist/apps/meet/_icons.d.ts +3 -0
- package/dist/apps/meet/_icons.js +3 -0
- package/dist/apps/meet/_meetApi.d.ts +93 -1
- package/dist/apps/meet/_meetApi.js +82 -0
- package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
- package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
- package/dist/apps/shared/media/useLocalMedia.js +3 -3
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +49 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +103 -3
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
- package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
- package/dist/apps/shared/webrtc/types.d.ts +69 -7
- package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
- package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/PasswordUtils.js +52 -0
- package/dist/lib/util/PasswordUtils.js.map +1 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
- package/dist/types/models/types.d.ts +23 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
- package/dist/types/util/PasswordUtils.d.ts +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* The participants drawer, opened from the header's participant-count chip: a right-side panel listing everyone in
|
|
8
|
+
* the call, "you" first. The same mic-muted/hand-raised/transport-badge information `_ParticipantTile.tsx` already
|
|
9
|
+
* shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
|
|
10
|
+
* name).
|
|
11
|
+
*
|
|
12
|
+
* Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
|
|
13
|
+
* drawer itself, matching `_CallControls.tsx`'s menus.
|
|
14
|
+
*
|
|
15
|
+
* A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
|
|
16
|
+
* `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
|
|
17
|
+
* button (hidden once that participant is already muted - there is no useful "unmute someone else" action to
|
|
18
|
+
* offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
|
|
19
|
+
* way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
|
|
20
|
+
*
|
|
21
|
+
* The header also gets a host-only "Mute on join" checkbox (`forceMuteOnJoin`/`onToggleForceMuteOnJoin`) - a
|
|
22
|
+
* setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
|
|
23
|
+
* what each row's own "Mute" button is for) - a host-only password section (`hasPassword`/`onSetPassword`), the
|
|
24
|
+
* same "next joiner" scope: setting or changing it never affects anyone already in the call - and a host-only
|
|
25
|
+
* waiting-room toggle plus list (`waitingRoomEnabled`/`onToggleWaitingRoomEnabled`,
|
|
26
|
+
* `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
|
|
27
|
+
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
28
|
+
* the host needing to close and reopen the drawer.
|
|
29
|
+
*
|
|
30
|
+
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
31
|
+
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
32
|
+
* the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
|
|
33
|
+
* on talking-stick mode for what granting it actually does to the recipient's microphone.
|
|
34
|
+
*/
|
|
35
|
+
import { useEffect, useState } from "react";
|
|
36
|
+
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
37
|
+
import { MicOffIcon } from "./_icons.js";
|
|
38
|
+
/** The host-only password form: one text input plus a Set/Change button, and a Remove button once one is set.
|
|
39
|
+
* Owns its own input/error/busy state locally - `_CallView.tsx` only needs to know whether a password exists, not
|
|
40
|
+
* what a half-typed one currently says. */
|
|
41
|
+
function PasswordSection({ hasPassword, onSetPassword }) {
|
|
42
|
+
const [input, setInput] = useState("");
|
|
43
|
+
const [error, setError] = useState(null);
|
|
44
|
+
const [busy, setBusy] = useState(false);
|
|
45
|
+
async function submit(password) {
|
|
46
|
+
setBusy(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
await onSetPassword(password);
|
|
50
|
+
setInput("");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
setError("Could not save - try again.");
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
setBusy(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return (_jsxs("div", { className: "px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-white/70 mb-1.5", children: hasPassword ? "Password protection is on." : "No password required to join." }), _jsxs("form", { className: "flex gap-2", onSubmit: (event) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
if (input) {
|
|
62
|
+
void submit(input);
|
|
63
|
+
}
|
|
64
|
+
}, children: [_jsx("input", { type: "password", "aria-label": hasPassword ? "New password" : "Set a password", placeholder: hasPassword ? "New password" : "Set a password", className: "flex-1 min-w-0 px-2 py-1 rounded bg-white/10 text-white placeholder:text-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", value: input, onChange: (event) => setInput(event.target.value), disabled: busy }), _jsx("button", { type: "submit", className: "px-2 py-1 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy || !input, children: hasPassword ? "Change" : "Set" })] }), hasPassword && (_jsx("button", { type: "button", className: "mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy, onClick: () => void submit(null), children: "Remove password" })), error && _jsx("p", { className: "text-[#f2b8b5] text-xs mt-1.5", children: error })] }));
|
|
65
|
+
}
|
|
66
|
+
/** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
|
|
67
|
+
* anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
|
|
68
|
+
* indistinguishable from the list just not having loaded yet). */
|
|
69
|
+
function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
|
|
70
|
+
return (_jsxs("div", { className: "px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "Waiting to join" }), waitingParticipants.length === 0 ? (_jsx("p", { className: "text-white/70", children: "Nobody is waiting right now." })) : (_jsx("ul", { children: waitingParticipants.map((p) => (_jsxs("li", { className: "flex items-center gap-2 py-1", children: [_jsx("span", { className: "flex-1 min-w-0 truncate", children: p.name }), _jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Admit ${p.name}`, onClick: () => onAdmit(p.uid), children: "Admit" }), _jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Deny ${p.name}`, onClick: () => onDeny(p.uid), children: "Deny" })] }, p.uid))) }))] }));
|
|
71
|
+
}
|
|
72
|
+
function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
|
|
73
|
+
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
74
|
+
return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), hasStick && (_jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", role: "img", "aria-label": "Holding the talking stick", children: "\uD83C\uDF99\uFE0F" })), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canGiveStick && !hasStick && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Give the talking stick to ${name}`, onClick: onGiveStick, children: "Give stick" })), canModerate && !micMuted && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Mute ${name}`, onClick: onMute, children: "Mute" })), canModerate && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Remove ${name}`, onClick: () => {
|
|
75
|
+
if (window.confirm(`Remove ${name} from the call?`)) {
|
|
76
|
+
onKick?.();
|
|
77
|
+
}
|
|
78
|
+
}, children: "Remove" }))] }));
|
|
79
|
+
}
|
|
80
|
+
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const onKeyDown = (event) => {
|
|
83
|
+
if (event.key === "Escape") {
|
|
84
|
+
onClose();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
document.addEventListener("keydown", onKeyDown);
|
|
88
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
89
|
+
}, [onClose]);
|
|
90
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "absolute inset-0 z-20 bg-black/40", onClick: onClose, "aria-hidden": "true" }), _jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "absolute z-20 inset-y-0 right-0 w-72 max-w-[85vw] flex flex-col bg-[#2b2d30] text-white shadow-xl", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsxs("h2", { className: "text-sm font-medium", children: ["Participants (", participants.length + 1, ")"] }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close participants", onClick: onClose, children: "\u2715" })] }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: forceMuteOnJoin, onChange: onToggleForceMuteOnJoin, className: "w-4 h-4" }), "Mute new participants on join"] })), isSelfHost && _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })), isSelfHost && waitingRoomEnabled && (_jsx(WaitingSection, { waitingParticipants: waitingParticipants, onAdmit: onAdmit, onDeny: onDeny })), _jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 text-sm", children: [_jsx(Row, { name: selfName, isSelf: true, isHost: isSelfHost, micMuted: !micOn, handRaised: handRaised, hasStick: talkingStickActive && talkingStickHolder === selfPeerId, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(selfPeerId) }), participants.map((p) => (_jsx(Row, { name: p.name, isHost: isParticipantHost(p.uid), micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, canModerate: isSelfHost, onMute: () => onMute(p.uid), onKick: () => onKick(p.uid), hasStick: talkingStickActive && talkingStickHolder === p.uid, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(p.uid) }, p.uid)))] })] })] }));
|
|
91
|
+
}
|
|
@@ -14,3 +14,6 @@ export declare const ChevronUpIcon: () => React.JSX.Element;
|
|
|
14
14
|
/** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
|
|
15
15
|
export declare const HandIcon: () => React.JSX.Element;
|
|
16
16
|
export declare const EffectsIcon: () => React.JSX.Element;
|
|
17
|
+
export declare const RotateIcon: () => React.JSX.Element;
|
|
18
|
+
export declare const FlipIcon: () => React.JSX.Element;
|
|
19
|
+
export declare const DiagnosticsIcon: () => React.JSX.Element;
|
package/dist/apps/meet/_icons.js
CHANGED
|
@@ -15,3 +15,6 @@ export const ChevronUpIcon = () => _jsx(Icon, { path: "M12 8l-6 6 1.41 1.41L12 1
|
|
|
15
15
|
/** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
|
|
16
16
|
export const HandIcon = () => (_jsx("span", { className: "text-xl leading-none", "aria-hidden": "true", children: "\u270B" }));
|
|
17
17
|
export const EffectsIcon = () => (_jsx(Icon, { path: "M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z" }));
|
|
18
|
+
export const RotateIcon = () => (_jsx(Icon, { path: "M12 6V2L7 7l5 5V8c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }));
|
|
19
|
+
export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" }));
|
|
20
|
+
export const DiagnosticsIcon = () => (_jsx(Icon, { path: "M22 12h-4l-3 9L9 3l-3 9H2v2h5.6l1.4-4.2L13.4 21 17 12h5v-2z" }));
|
|
@@ -14,6 +14,19 @@ export interface PublicVideoMeeting {
|
|
|
14
14
|
visibility: VideoMeetingVisibility;
|
|
15
15
|
status: VideoMeetingStatus;
|
|
16
16
|
hostDisplayName?: string;
|
|
17
|
+
/** The account uid that owns the meeting - the sole source of a "host" identity on the client. Absent when the
|
|
18
|
+
* server can't resolve one (an orphaned mailbox, or a server that predates this field), in which case no host
|
|
19
|
+
* controls are shown to anyone. */
|
|
20
|
+
hostUid?: string;
|
|
21
|
+
/** When `true`, a joining participant's microphone starts muted - see `useLocalMedia`'s `forceMuteOnJoin`
|
|
22
|
+
* option, which this is threaded into. Absent reads as `false`. */
|
|
23
|
+
forceMuteOnJoin?: boolean;
|
|
24
|
+
/** Whether this meeting currently requires a password - never the password or its hash, just whether one is
|
|
25
|
+
* set. Absent reads as `false`. */
|
|
26
|
+
hasPassword?: boolean;
|
|
27
|
+
/** Whether this meeting currently requires the host to admit each participant before they join - see
|
|
28
|
+
* `requestAdmission()`. Absent reads as `false`. */
|
|
29
|
+
waitingRoomEnabled?: boolean;
|
|
17
30
|
}
|
|
18
31
|
/**
|
|
19
32
|
* Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingJoinResult`. `authenticated: true` means the visiting browser
|
|
@@ -42,6 +55,31 @@ export interface VideoMeetingJoinResult {
|
|
|
42
55
|
* hosts them (`mail:videoconf:effects:assets_url`). Absent means the public CDN defaults. */
|
|
43
56
|
effectsAssetsUrl?: string;
|
|
44
57
|
}
|
|
58
|
+
/** Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingPasswordRequiredResult` - returned by `joinMeeting()` in place
|
|
59
|
+
* of a `VideoMeetingJoinResult` when the meeting requires a password that hasn't been submitted yet. Carries only
|
|
60
|
+
* enough to show a password prompt (the meeting's title/host); nothing here grants anything. Narrow on
|
|
61
|
+
* `"requiresPassword" in result && result.requiresPassword`. */
|
|
62
|
+
export interface VideoMeetingPasswordRequiredResult {
|
|
63
|
+
meeting: PublicVideoMeeting;
|
|
64
|
+
requiresPassword: true;
|
|
65
|
+
}
|
|
66
|
+
/** Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingAdmissionRequiredResult` - returned by `requestAdmission()`
|
|
67
|
+
* (and re-returned by `pollAdmission()`) while a `waitingRoomEnabled` meeting's admission request is still
|
|
68
|
+
* pending. Unlike `VideoMeetingPasswordRequiredResult`, this carries `selfUid` (and, for a guest, `token`) - a
|
|
69
|
+
* caller needs a stable, authenticated identity to poll with. `joinMeeting()` itself also answers this shape, with
|
|
70
|
+
* none of those fields, when a waiting room applies and no request has been filed yet. Narrow on
|
|
71
|
+
* `"requiresAdmission" in result`. */
|
|
72
|
+
export interface VideoMeetingAdmissionRequiredResult {
|
|
73
|
+
meeting: PublicVideoMeeting;
|
|
74
|
+
requiresAdmission: true;
|
|
75
|
+
authenticated?: boolean;
|
|
76
|
+
selfUid?: string;
|
|
77
|
+
token?: string;
|
|
78
|
+
expiresAt?: string;
|
|
79
|
+
}
|
|
80
|
+
/** `joinMeeting()`'s actual return type - see `VideoMeetingPasswordRequiredResult`'s and
|
|
81
|
+
* `VideoMeetingAdmissionRequiredResult`'s own doc comments. */
|
|
82
|
+
export type VideoMeetingJoinResponse = VideoMeetingJoinResult | VideoMeetingPasswordRequiredResult | VideoMeetingAdmissionRequiredResult;
|
|
45
83
|
/** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
|
|
46
84
|
* either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
|
|
47
85
|
* existing session already grants them signaling access (`authenticated: true` - see
|
|
@@ -50,4 +88,58 @@ export interface VideoMeetingJoinResult {
|
|
|
50
88
|
* private/public token shape for what it's being used against - the caller (`[token].tsx`) shows a single,
|
|
51
89
|
* friendly "this meeting link isn't valid" state for all of those rather than trying to distinguish them, matching
|
|
52
90
|
* `BaseVideoMeetingRoute.requireMeetingByToken()`'s own explicit "never leak which almost-matched" posture. */
|
|
53
|
-
export declare function joinMeeting(token: string): Promise<
|
|
91
|
+
export declare function joinMeeting(token: string): Promise<VideoMeetingJoinResponse>;
|
|
92
|
+
/** Submits a password for a meeting `joinMeeting()` reported `requiresPassword` for. On success, returns an
|
|
93
|
+
* ordinary granted `VideoMeetingJoinResult` - the same shape and same meaning as `joinMeeting()`'s own non-password
|
|
94
|
+
* case. Rejects with `ApiRequestError` (status `403`) for a wrong or missing password. */
|
|
95
|
+
export declare function verifyMeetingPassword(token: string, password: string): Promise<VideoMeetingJoinResult>;
|
|
96
|
+
/** Sets, replaces, or removes (`password: null`) the meeting's join password - host only, enforced the same way
|
|
97
|
+
* as every other owner-side video-meeting update. Hashed server-side; the plaintext is never stored or returned. */
|
|
98
|
+
export declare function setMeetingPassword(meetingUid: string, password: string | null): Promise<void>;
|
|
99
|
+
/** Files an admission request for a meeting `joinMeeting()` reported `requiresAdmission` for - `password` only
|
|
100
|
+
* when `meeting.hasPassword` is also set (both are checked together on this one call, never in two steps). On
|
|
101
|
+
* success returns `VideoMeetingAdmissionRequiredResult` with a `selfUid` (and, for a guest, `token`) to poll with
|
|
102
|
+
* via `pollAdmission()` - nothing is granted yet. Rejects with `ApiRequestError` (status `403`) for a wrong
|
|
103
|
+
* password, or `400` if `meeting.hasPassword` and no password was given. */
|
|
104
|
+
export declare function requestAdmission(token: string, body: {
|
|
105
|
+
name: string;
|
|
106
|
+
password?: string;
|
|
107
|
+
}): Promise<VideoMeetingAdmissionRequiredResult>;
|
|
108
|
+
/** Polls whether the host has responded to a pending admission request - authenticated as whichever identity
|
|
109
|
+
* `requestAdmission()` (or a previous poll) returned: `guestToken`, when given, is sent as an `Authorization`
|
|
110
|
+
* header (a guest has no session cookie of their own); omitted for an already-authenticated real caller, whose
|
|
111
|
+
* existing session cookie already identifies them. Resolves to a granted `VideoMeetingJoinResult` once admitted,
|
|
112
|
+
* or re-resolves to `VideoMeetingAdmissionRequiredResult` while still pending. Rejects with `ApiRequestError`
|
|
113
|
+
* (status `403`) once denied, or `404` if this identity never requested admission (or already polled past a final
|
|
114
|
+
* answer). */
|
|
115
|
+
export declare function pollAdmission(token: string, guestToken?: string): Promise<VideoMeetingJoinResult | VideoMeetingAdmissionRequiredResult>;
|
|
116
|
+
/** One pending admission request, as `listWaitingParticipants()` returns it. */
|
|
117
|
+
export interface WaitingParticipant {
|
|
118
|
+
uid: string;
|
|
119
|
+
name: string;
|
|
120
|
+
requestedAt: string;
|
|
121
|
+
}
|
|
122
|
+
/** Lists a meeting's pending admission requests, oldest first - host only. */
|
|
123
|
+
export declare function listWaitingParticipants(meetingUid: string): Promise<WaitingParticipant[]>;
|
|
124
|
+
/** Admits a pending admission request - host only. The requester's own next `pollAdmission()` call completes
|
|
125
|
+
* their join; this alone does not notify them of anything. A no-op if `uid` has no pending request. */
|
|
126
|
+
export declare function admitParticipant(meetingUid: string, uid: string): Promise<void>;
|
|
127
|
+
/** Denies a pending admission request - host only. The requester's own next `pollAdmission()` call then rejects
|
|
128
|
+
* with a 403. A no-op if `uid` has no pending request. */
|
|
129
|
+
export declare function denyParticipant(meetingUid: string, uid: string): Promise<void>;
|
|
130
|
+
/** Sets whether the meeting requires the host to admit each participant (`PublicVideoMeeting.waitingRoomEnabled`) -
|
|
131
|
+
* host only, enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who
|
|
132
|
+
* joins (or reloads the join link and joins again) after this call resolves. */
|
|
133
|
+
export declare function setWaitingRoomEnabled(meetingUid: string, waitingRoomEnabled: boolean): Promise<void>;
|
|
134
|
+
/** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
|
|
135
|
+
* session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
|
|
136
|
+
* client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
|
|
137
|
+
* media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
|
|
138
|
+
* (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
|
|
139
|
+
* its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
|
|
140
|
+
export declare function kickParticipant(meetingUid: string, uid: string): Promise<void>;
|
|
141
|
+
/** Sets whether a newly joining participant starts muted (`PublicVideoMeeting.forceMuteOnJoin`) - host only,
|
|
142
|
+
* enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who joins (or
|
|
143
|
+
* reloads the join link and joins again) after this call resolves; it does not retroactively mute anyone already
|
|
144
|
+
* in the call - the host's own "Mute" button (see `kickParticipant()`) is the tool for that. */
|
|
145
|
+
export declare function setForceMuteOnJoin(meetingUid: string, forceMuteOnJoin: boolean): Promise<void>;
|
|
@@ -21,3 +21,85 @@ import { apiFetch } from "@rapidmx/web-client/lib/util/api.js";
|
|
|
21
21
|
export function joinMeeting(token) {
|
|
22
22
|
return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}`);
|
|
23
23
|
}
|
|
24
|
+
/** Submits a password for a meeting `joinMeeting()` reported `requiresPassword` for. On success, returns an
|
|
25
|
+
* ordinary granted `VideoMeetingJoinResult` - the same shape and same meaning as `joinMeeting()`'s own non-password
|
|
26
|
+
* case. Rejects with `ApiRequestError` (status `403`) for a wrong or missing password. */
|
|
27
|
+
export function verifyMeetingPassword(token, password) {
|
|
28
|
+
return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/verify`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: JSON.stringify({ password }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Sets, replaces, or removes (`password: null`) the meeting's join password - host only, enforced the same way
|
|
34
|
+
* as every other owner-side video-meeting update. Hashed server-side; the plaintext is never stored or returned. */
|
|
35
|
+
export function setMeetingPassword(meetingUid, password) {
|
|
36
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
|
|
37
|
+
method: "PUT",
|
|
38
|
+
body: JSON.stringify({ password }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Files an admission request for a meeting `joinMeeting()` reported `requiresAdmission` for - `password` only
|
|
42
|
+
* when `meeting.hasPassword` is also set (both are checked together on this one call, never in two steps). On
|
|
43
|
+
* success returns `VideoMeetingAdmissionRequiredResult` with a `selfUid` (and, for a guest, `token`) to poll with
|
|
44
|
+
* via `pollAdmission()` - nothing is granted yet. Rejects with `ApiRequestError` (status `403`) for a wrong
|
|
45
|
+
* password, or `400` if `meeting.hasPassword` and no password was given. */
|
|
46
|
+
export function requestAdmission(token, body) {
|
|
47
|
+
return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/verify`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Polls whether the host has responded to a pending admission request - authenticated as whichever identity
|
|
53
|
+
* `requestAdmission()` (or a previous poll) returned: `guestToken`, when given, is sent as an `Authorization`
|
|
54
|
+
* header (a guest has no session cookie of their own); omitted for an already-authenticated real caller, whose
|
|
55
|
+
* existing session cookie already identifies them. Resolves to a granted `VideoMeetingJoinResult` once admitted,
|
|
56
|
+
* or re-resolves to `VideoMeetingAdmissionRequiredResult` while still pending. Rejects with `ApiRequestError`
|
|
57
|
+
* (status `403`) once denied, or `404` if this identity never requested admission (or already polled past a final
|
|
58
|
+
* answer). */
|
|
59
|
+
export function pollAdmission(token, guestToken) {
|
|
60
|
+
return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/status`, {
|
|
61
|
+
headers: guestToken ? { Authorization: `jwt ${guestToken}` } : undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Lists a meeting's pending admission requests, oldest first - host only. */
|
|
65
|
+
export function listWaitingParticipants(meetingUid) {
|
|
66
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/waiting`);
|
|
67
|
+
}
|
|
68
|
+
/** Admits a pending admission request - host only. The requester's own next `pollAdmission()` call completes
|
|
69
|
+
* their join; this alone does not notify them of anything. A no-op if `uid` has no pending request. */
|
|
70
|
+
export function admitParticipant(meetingUid, uid) {
|
|
71
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/admit/${encodeURIComponent(uid)}`, { method: "POST" });
|
|
72
|
+
}
|
|
73
|
+
/** Denies a pending admission request - host only. The requester's own next `pollAdmission()` call then rejects
|
|
74
|
+
* with a 403. A no-op if `uid` has no pending request. */
|
|
75
|
+
export function denyParticipant(meetingUid, uid) {
|
|
76
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/deny/${encodeURIComponent(uid)}`, { method: "POST" });
|
|
77
|
+
}
|
|
78
|
+
/** Sets whether the meeting requires the host to admit each participant (`PublicVideoMeeting.waitingRoomEnabled`) -
|
|
79
|
+
* host only, enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who
|
|
80
|
+
* joins (or reloads the join link and joins again) after this call resolves. */
|
|
81
|
+
export function setWaitingRoomEnabled(meetingUid, waitingRoomEnabled) {
|
|
82
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
|
|
83
|
+
method: "PUT",
|
|
84
|
+
body: JSON.stringify({ waitingRoomEnabled }),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
|
|
88
|
+
* session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
|
|
89
|
+
* client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
|
|
90
|
+
* media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
|
|
91
|
+
* (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
|
|
92
|
+
* its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
|
|
93
|
+
export function kickParticipant(meetingUid, uid) {
|
|
94
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/kick/${encodeURIComponent(uid)}`, { method: "POST" });
|
|
95
|
+
}
|
|
96
|
+
/** Sets whether a newly joining participant starts muted (`PublicVideoMeeting.forceMuteOnJoin`) - host only,
|
|
97
|
+
* enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who joins (or
|
|
98
|
+
* reloads the join link and joins again) after this call resolves; it does not retroactively mute anyone already
|
|
99
|
+
* in the call - the host's own "Mute" button (see `kickParticipant()`) is the tool for that. */
|
|
100
|
+
export function setForceMuteOnJoin(meetingUid, forceMuteOnJoin) {
|
|
101
|
+
return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
|
|
102
|
+
method: "PUT",
|
|
103
|
+
body: JSON.stringify({ forceMuteOnJoin }),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotates and/or flips a screen-share track before it's sent, so everyone - the presenter included, since their own
|
|
3
|
+
* preview is this same processor's output - sees it the right way round, rather than however the browser's own
|
|
4
|
+
* window/screen capture happened to hand it back. Window capture in particular has a known, driver/compositor-
|
|
5
|
+
* dependent history of occasionally returning upside-down or sideways frames for a specific captured window; this
|
|
6
|
+
* is the presenter's own manual correction for that, not an automatic fix (there's no reliable way to detect it
|
|
7
|
+
* from pixels alone).
|
|
8
|
+
*
|
|
9
|
+
* Reuses `VideoFilterProcessor.ts`'s own shape (a hidden `<video>` playing the source track, a canvas redrawn on a
|
|
10
|
+
* timer, `captureStream()` exposing the result) but far simpler: no models to load, no per-frame throttling to
|
|
11
|
+
* worry about, just a transform applied on each draw. Entirely independent of the camera pipeline - `useLocalMedia`'s
|
|
12
|
+
* filters never see the screen-share track, and this never touches the camera's.
|
|
13
|
+
*/
|
|
14
|
+
/** A rotation in degrees clockwise. */
|
|
15
|
+
export type ScreenRotation = 0 | 90 | 180 | 270;
|
|
16
|
+
export interface ScreenTransformState {
|
|
17
|
+
rotation: ScreenRotation;
|
|
18
|
+
/** Mirrored left-right, applied before the rotation (so "flip, then turn" - the usual photo-editing order). */
|
|
19
|
+
flipped: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare const NO_SCREEN_TRANSFORM: ScreenTransformState;
|
|
22
|
+
/** The next rotation clockwise, wrapping back to 0 after 270. */
|
|
23
|
+
export declare function rotateClockwise(state: ScreenTransformState): ScreenTransformState;
|
|
24
|
+
export interface ScreenTransformDeps {
|
|
25
|
+
createCanvas(): HTMLCanvasElement;
|
|
26
|
+
createVideo(): HTMLVideoElement;
|
|
27
|
+
now(): number;
|
|
28
|
+
}
|
|
29
|
+
export interface ScreenTransformOptions {
|
|
30
|
+
/** The screen-share track. Only ever read - stopping it stays with whoever owns it (`_CallView.tsx`). */
|
|
31
|
+
source: MediaStreamTrack;
|
|
32
|
+
state?: ScreenTransformState;
|
|
33
|
+
frameRate?: number;
|
|
34
|
+
deps?: Partial<ScreenTransformDeps>;
|
|
35
|
+
}
|
|
36
|
+
export declare class ScreenTransformProcessor {
|
|
37
|
+
/** The transformed video - what to send in place of the screen track's own. */
|
|
38
|
+
readonly track: MediaStreamTrack;
|
|
39
|
+
private readonly deps;
|
|
40
|
+
private readonly interval;
|
|
41
|
+
private readonly video;
|
|
42
|
+
private readonly canvas;
|
|
43
|
+
private readonly ctx;
|
|
44
|
+
private state;
|
|
45
|
+
private stopped;
|
|
46
|
+
private timer;
|
|
47
|
+
constructor(options: ScreenTransformOptions);
|
|
48
|
+
/** Changes the rotation/flip without interrupting the video. */
|
|
49
|
+
setState(state: ScreenTransformState): void;
|
|
50
|
+
stop(): void;
|
|
51
|
+
private tick;
|
|
52
|
+
private render;
|
|
53
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Rotates and/or flips a screen-share track before it's sent, so everyone - the presenter included, since their own
|
|
7
|
+
* preview is this same processor's output - sees it the right way round, rather than however the browser's own
|
|
8
|
+
* window/screen capture happened to hand it back. Window capture in particular has a known, driver/compositor-
|
|
9
|
+
* dependent history of occasionally returning upside-down or sideways frames for a specific captured window; this
|
|
10
|
+
* is the presenter's own manual correction for that, not an automatic fix (there's no reliable way to detect it
|
|
11
|
+
* from pixels alone).
|
|
12
|
+
*
|
|
13
|
+
* Reuses `VideoFilterProcessor.ts`'s own shape (a hidden `<video>` playing the source track, a canvas redrawn on a
|
|
14
|
+
* timer, `captureStream()` exposing the result) but far simpler: no models to load, no per-frame throttling to
|
|
15
|
+
* worry about, just a transform applied on each draw. Entirely independent of the camera pipeline - `useLocalMedia`'s
|
|
16
|
+
* filters never see the screen-share track, and this never touches the camera's.
|
|
17
|
+
*/
|
|
18
|
+
export const NO_SCREEN_TRANSFORM = { rotation: 0, flipped: false };
|
|
19
|
+
/** The next rotation clockwise, wrapping back to 0 after 270. */
|
|
20
|
+
export function rotateClockwise(state) {
|
|
21
|
+
const next = { 0: 90, 90: 180, 180: 270, 270: 0 };
|
|
22
|
+
return { ...state, rotation: next[state.rotation] };
|
|
23
|
+
}
|
|
24
|
+
/** Screen share doesn't need the camera's 30fps - matches `VIDEO_FPS`, what the relay's own `VideoSender` already
|
|
25
|
+
* encodes a shared screen at. */
|
|
26
|
+
const DEFAULT_FRAME_RATE = 15;
|
|
27
|
+
const DEFAULT_DEPS = {
|
|
28
|
+
createCanvas: () => document.createElement("canvas"),
|
|
29
|
+
createVideo: () => document.createElement("video"),
|
|
30
|
+
now: () => performance.now(),
|
|
31
|
+
};
|
|
32
|
+
export class ScreenTransformProcessor {
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.stopped = false;
|
|
35
|
+
this.tick = () => {
|
|
36
|
+
if (this.stopped) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const started = this.deps.now();
|
|
40
|
+
if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
|
|
41
|
+
this.render();
|
|
42
|
+
}
|
|
43
|
+
this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
|
|
44
|
+
};
|
|
45
|
+
this.deps = { ...DEFAULT_DEPS, ...options.deps };
|
|
46
|
+
this.state = options.state ?? NO_SCREEN_TRANSFORM;
|
|
47
|
+
const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
|
|
48
|
+
this.interval = 1000 / frameRate;
|
|
49
|
+
this.canvas = this.deps.createCanvas();
|
|
50
|
+
const ctx = this.canvas.getContext("2d");
|
|
51
|
+
if (!ctx) {
|
|
52
|
+
throw new Error("This browser can't rotate the shared screen.");
|
|
53
|
+
}
|
|
54
|
+
this.ctx = ctx;
|
|
55
|
+
this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
|
|
56
|
+
this.video = this.deps.createVideo();
|
|
57
|
+
this.video.muted = true;
|
|
58
|
+
this.video.playsInline = true;
|
|
59
|
+
this.video.srcObject = new MediaStream([options.source]);
|
|
60
|
+
void this.video.play().catch(() => undefined);
|
|
61
|
+
this.tick();
|
|
62
|
+
}
|
|
63
|
+
/** Changes the rotation/flip without interrupting the video. */
|
|
64
|
+
setState(state) {
|
|
65
|
+
this.state = state;
|
|
66
|
+
}
|
|
67
|
+
stop() {
|
|
68
|
+
this.stopped = true;
|
|
69
|
+
clearTimeout(this.timer);
|
|
70
|
+
this.video.pause();
|
|
71
|
+
this.video.srcObject = null;
|
|
72
|
+
this.track.stop();
|
|
73
|
+
}
|
|
74
|
+
render() {
|
|
75
|
+
const { video, canvas, ctx, state } = this;
|
|
76
|
+
const sourceWidth = video.videoWidth;
|
|
77
|
+
const sourceHeight = video.videoHeight;
|
|
78
|
+
// A quarter turn swaps which way is "wide" - the canvas (and so the track everyone actually receives) must
|
|
79
|
+
// match, or the picture is squashed into the untransformed aspect ratio.
|
|
80
|
+
const swapped = state.rotation === 90 || state.rotation === 270;
|
|
81
|
+
const width = swapped ? sourceHeight : sourceWidth;
|
|
82
|
+
const height = swapped ? sourceWidth : sourceHeight;
|
|
83
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
84
|
+
canvas.width = width;
|
|
85
|
+
canvas.height = height;
|
|
86
|
+
}
|
|
87
|
+
if (state.rotation === 0 && !state.flipped) {
|
|
88
|
+
ctx.drawImage(video, 0, 0, sourceWidth, sourceHeight);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
ctx.save();
|
|
92
|
+
ctx.translate(width / 2, height / 2);
|
|
93
|
+
ctx.rotate((state.rotation * Math.PI) / 180);
|
|
94
|
+
if (state.flipped) {
|
|
95
|
+
ctx.scale(-1, 1);
|
|
96
|
+
}
|
|
97
|
+
ctx.drawImage(video, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight);
|
|
98
|
+
ctx.restore();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -49,5 +49,9 @@ export interface LocalMedia {
|
|
|
49
49
|
export interface LocalMediaOptions {
|
|
50
50
|
/** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
|
|
51
51
|
effectsAssetsUrl?: string;
|
|
52
|
+
/** The host's `VideoMeeting.forceMuteOnJoin` setting - when `true`, this call's microphone starts muted
|
|
53
|
+
* regardless of what was remembered from a previous call, overriding (but never overwriting) that preference -
|
|
54
|
+
* see `ensurePrefs()`. `undefined`/`false` changes nothing. */
|
|
55
|
+
forceMuteOnJoin?: boolean;
|
|
52
56
|
}
|
|
53
|
-
export declare function useLocalMedia({ effectsAssetsUrl }?: LocalMediaOptions): LocalMedia;
|
|
57
|
+
export declare function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin }?: LocalMediaOptions): LocalMedia;
|
|
@@ -77,7 +77,7 @@ function combineErrors(audio, video) {
|
|
|
77
77
|
function preferring(base, preferredId) {
|
|
78
78
|
return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
|
|
79
79
|
}
|
|
80
|
-
export function useLocalMedia({ effectsAssetsUrl } = {}) {
|
|
80
|
+
export function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin } = {}) {
|
|
81
81
|
const [supported, setSupported] = useState(true);
|
|
82
82
|
const [requesting, setRequesting] = useState(false);
|
|
83
83
|
const [error, setError] = useState(null);
|
|
@@ -114,7 +114,7 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
|
|
|
114
114
|
}
|
|
115
115
|
const prefs = loadPreferences();
|
|
116
116
|
prefsRef.current = prefs;
|
|
117
|
-
if (prefs.micEnabled === false) {
|
|
117
|
+
if (prefs.micEnabled === false || forceMuteOnJoin) {
|
|
118
118
|
micEnabledRef.current = false;
|
|
119
119
|
setMicEnabled(false);
|
|
120
120
|
}
|
|
@@ -129,7 +129,7 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
|
|
|
129
129
|
}, () => undefined);
|
|
130
130
|
}
|
|
131
131
|
return prefs;
|
|
132
|
-
}, [applyBackground, applyFilters]);
|
|
132
|
+
}, [applyBackground, applyFilters, forceMuteOnJoin]);
|
|
133
133
|
/** Saves a change to the remembered settings. */
|
|
134
134
|
const remember = useCallback((patch) => {
|
|
135
135
|
prefsRef.current = { ...ensurePrefs(), ...patch };
|
|
@@ -58,6 +58,28 @@
|
|
|
58
58
|
*
|
|
59
59
|
* Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
|
|
60
60
|
*
|
|
61
|
+
* ## Host moderation (`mute-request`, `kicked`)
|
|
62
|
+
*
|
|
63
|
+
* `sendMuteRequest()`/`sendKick()` send a point-to-point signal to one peer; the manager itself does not decide who
|
|
64
|
+
* may send one - a "host" identity exists only in `_CallView.tsx`, which shows the controls that call these
|
|
65
|
+
* methods to nobody but the host (see `types.ts`'s `SignalMessage` doc comment for why this is cooperative, not
|
|
66
|
+
* enforced, at the signaling layer). Receiving one emits `mute-requested`/`kicked` for the caller to act on; this
|
|
67
|
+
* manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
|
|
68
|
+
* participant's actual departure is `stop()`, which the caller decides to call.
|
|
69
|
+
*
|
|
70
|
+
* ## Talking stick (`talking-stick`)
|
|
71
|
+
*
|
|
72
|
+
* One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
|
|
73
|
+
* there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
|
|
74
|
+
* `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
|
|
75
|
+
* simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
|
|
76
|
+
* local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
|
|
77
|
+
* leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
|
|
78
|
+
* already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
|
|
79
|
+
* right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
|
|
80
|
+
* backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
|
|
81
|
+
* actually in the call together.
|
|
82
|
+
*
|
|
61
83
|
* ## Three media paths, tried in order
|
|
62
84
|
*
|
|
63
85
|
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
@@ -88,6 +110,10 @@ export declare function isOfferer(selfId: string, peerId: string): boolean;
|
|
|
88
110
|
export declare const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
89
111
|
/** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
|
|
90
112
|
export declare const DEFAULT_DISCONNECTED_GRACE_MS = 8000;
|
|
113
|
+
/** How often a connected pair's quality stats (the diagnostics panel) are refreshed - low frequency on purpose:
|
|
114
|
+
* this is for a participant occasionally checking why a call feels off, not a live chart, and `getStats()` is not
|
|
115
|
+
* free to call every frame. */
|
|
116
|
+
export declare const DEFAULT_DIAGNOSTICS_POLL_MS = 3000;
|
|
91
117
|
type MediaKind = "audio" | "video";
|
|
92
118
|
export interface MeshConnectionManagerOptions {
|
|
93
119
|
/** The authenticated uid - what `from` must be on the wire (see `SignalMessage.from`). */
|
|
@@ -115,6 +141,8 @@ export interface MeshConnectionManagerOptions {
|
|
|
115
141
|
connectTimeoutMs?: number;
|
|
116
142
|
/** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
|
|
117
143
|
disconnectedGraceMs?: number;
|
|
144
|
+
/** Defaults to `DEFAULT_DIAGNOSTICS_POLL_MS`. */
|
|
145
|
+
diagnosticsPollMs?: number;
|
|
118
146
|
}
|
|
119
147
|
export declare class MeshConnectionManager {
|
|
120
148
|
private readonly options;
|
|
@@ -153,6 +181,20 @@ export declare class MeshConnectionManager {
|
|
|
153
181
|
claimPresenter(): boolean;
|
|
154
182
|
/** Releases presenter status - a no-op unless the local participant currently holds it. */
|
|
155
183
|
releasePresenter(): void;
|
|
184
|
+
/** Asks `peerUid`'s participant to mute - a cooperative signal (see this module's doc comment on host
|
|
185
|
+
* moderation), not enforcement. Nothing here checks that the local participant is actually the host; the caller
|
|
186
|
+
* is responsible for only offering this to one. */
|
|
187
|
+
sendMuteRequest(peerUid: string): void;
|
|
188
|
+
/** Tells `peerUid`'s participant they have been removed from the call. Sends only the cooperative signal - it
|
|
189
|
+
* does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
|
|
190
|
+
* which the caller is expected to call alongside this for an enforced removal). */
|
|
191
|
+
sendKick(peerUid: string): void;
|
|
192
|
+
/** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
|
|
193
|
+
* omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
|
|
194
|
+
* there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
|
|
195
|
+
* there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
|
|
196
|
+
* host. */
|
|
197
|
+
setTalkingStick(active: boolean, holder?: string): void;
|
|
156
198
|
private sendHello;
|
|
157
199
|
private send;
|
|
158
200
|
private emit;
|
|
@@ -168,8 +210,14 @@ export declare class MeshConnectionManager {
|
|
|
168
210
|
/** Reacts to the peer connection's state: notes which path a connected pair got, and gives up on WebRTC for a
|
|
169
211
|
* pair that failed or stays disconnected - see `fallBack()`. Ignores a pair that already left WebRTC. */
|
|
170
212
|
private handleConnectionState;
|
|
171
|
-
/** Records whether a connected pair is direct
|
|
213
|
+
/** Records whether a connected pair is direct, relayed through TURN, or relayed through TURN over TCP (see
|
|
214
|
+
* `MediaTransport`'s doc comment), from the candidate pair ICE selected. */
|
|
172
215
|
private detectTransport;
|
|
216
|
+
/** Idempotent - a pair that reconnects without ever leaving WebRTC (`connected` -> `disconnected` -> `connected`
|
|
217
|
+
* again) does not get a second timer. */
|
|
218
|
+
private startDiagnosticsPolling;
|
|
219
|
+
private stopDiagnosticsPolling;
|
|
220
|
+
private pollDiagnostics;
|
|
173
221
|
private setTransport;
|
|
174
222
|
private clearTimers;
|
|
175
223
|
/**
|