@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.
- package/LICENSE +382 -0
- package/README.md +38 -0
- package/apps/meet/[token].tsx +144 -0
- package/apps/meet/_CallControls.tsx +69 -0
- package/apps/meet/_CallView.tsx +338 -0
- package/apps/meet/_MeetChrome.tsx +30 -0
- package/apps/meet/_MeetLobby.tsx +223 -0
- package/apps/meet/_ParticipantTile.tsx +66 -0
- package/apps/meet/_layout.tsx +34 -0
- package/apps/meet/_meetApi.ts +66 -0
- package/apps/meet/index.tsx +22 -0
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
- package/apps/settings-video-conferencing/_layout.tsx +47 -0
- package/apps/settings-video-conferencing/index.tsx +192 -0
- package/apps/shared/media/activeSpeaker.ts +45 -0
- package/apps/shared/media/deviceMedia.ts +141 -0
- package/apps/shared/media/levelMeter.ts +91 -0
- package/apps/shared/push/GuestSignalingClient.ts +338 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
- package/apps/shared/webrtc/realPeerConnection.ts +37 -0
- package/apps/shared/webrtc/types.ts +90 -0
- package/dist/apps/meet/[token].d.ts +33 -0
- package/dist/apps/meet/[token].js +101 -0
- package/dist/apps/meet/_CallControls.d.ts +18 -0
- package/dist/apps/meet/_CallControls.js +6 -0
- package/dist/apps/meet/_CallView.d.ts +41 -0
- package/dist/apps/meet/_CallView.js +225 -0
- package/dist/apps/meet/_MeetChrome.d.ts +13 -0
- package/dist/apps/meet/_MeetChrome.js +10 -0
- package/dist/apps/meet/_MeetLobby.d.ts +29 -0
- package/dist/apps/meet/_MeetLobby.js +120 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
- package/dist/apps/meet/_ParticipantTile.js +25 -0
- package/dist/apps/meet/_layout.d.ts +9 -0
- package/dist/apps/meet/_layout.js +8 -0
- package/dist/apps/meet/_meetApi.d.ts +47 -0
- package/dist/apps/meet/_meetApi.js +23 -0
- package/dist/apps/meet/index.d.ts +5 -0
- package/dist/apps/meet/index.js +8 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
- package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/_layout.js +8 -0
- package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/index.js +92 -0
- package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
- package/dist/apps/shared/media/activeSpeaker.js +39 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
- package/dist/apps/shared/media/deviceMedia.js +110 -0
- package/dist/apps/shared/media/levelMeter.d.ts +40 -0
- package/dist/apps/shared/media/levelMeter.js +61 -0
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
- package/dist/apps/shared/webrtc/types.d.ts +94 -0
- package/dist/apps/shared/webrtc/types.js +9 -0
- package/dist/lib/index.js +25 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
- package/dist/lib/models/types.js +19 -0
- package/dist/lib/models/types.js.map +1 -0
- package/dist/lib/mongo.js +13 -0
- package/dist/lib/mongo.js.map +1 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
- package/dist/lib/sql.js +13 -0
- package/dist/lib/sql.js.map +1 -0
- package/dist/lib/util/BookingIntegrationUtils.js +115 -0
- package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +68 -0
- package/dist/lib/util/IceServerUtils.js.map +1 -0
- package/dist/lib/util/PublicUrlUtils.js +37 -0
- package/dist/lib/util/PublicUrlUtils.js.map +1 -0
- package/dist/lib/util/RouteAccessUtils.js +32 -0
- package/dist/lib/util/RouteAccessUtils.js.map +1 -0
- package/dist/lib/util/TokenUtils.js +33 -0
- package/dist/lib/util/TokenUtils.js.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
- package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
- package/dist/types/models/types.d.ts +131 -0
- package/dist/types/mongo.d.ts +8 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
- package/dist/types/sql.d.ts +8 -0
- package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
- package/dist/types/util/IceServerUtils.d.ts +57 -0
- package/dist/types/util/PublicUrlUtils.d.ts +11 -0
- package/dist/types/util/RouteAccessUtils.d.ts +8 -0
- package/dist/types/util/TokenUtils.d.ts +23 -0
- package/package.json +181 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Full-mesh WebRTC connection management: one direct `RTCPeerConnection` per other participant, signaled entirely
|
|
7
|
+
* over the meeting's own push channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
|
|
8
|
+
* `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
|
|
9
|
+
* instance per call and mirrors its events into React state.
|
|
10
|
+
*
|
|
11
|
+
* ## Who calls whom
|
|
12
|
+
*
|
|
13
|
+
* On learning of another participant (via `hello`), a peer connection for that pair is created by whichever side
|
|
14
|
+
* has the lexicographically smaller uid (`isOfferer()`) - a well-known, deterministic, race-free rule for a mesh:
|
|
15
|
+
* both sides independently compute the same answer with no coordination round trip, and exactly one side ever
|
|
16
|
+
* calls `createOffer()` for a given pair.
|
|
17
|
+
*
|
|
18
|
+
* ## Join announcement and roster discovery
|
|
19
|
+
*
|
|
20
|
+
* The channel has no history (a push event published while a socket was down is never replayed - see
|
|
21
|
+
* `@rapidmx/react-shared`'s `pushClient.ts` doc comment for the identical guarantee on the mail push channel this
|
|
22
|
+
* one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
|
|
23
|
+
* subscribed *and* who joined before them - never the other way around. Every participant who learns of a
|
|
24
|
+
* genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
|
|
25
|
+
* first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
|
|
26
|
+
* roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
|
|
27
|
+
* ever discovers) rather than a risk of runaway flooding.
|
|
28
|
+
*
|
|
29
|
+
* ## Presenter (single-writer screen share)
|
|
30
|
+
*
|
|
31
|
+
* `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
|
|
32
|
+
* A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
|
|
33
|
+
* *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
|
|
34
|
+
* participant converges on the same winner by resolving the collision deterministically: the lexicographically
|
|
35
|
+
* smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
|
|
36
|
+
* `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
|
|
37
|
+
*
|
|
38
|
+
* ## Presentation mode's screen share
|
|
39
|
+
*
|
|
40
|
+
* Sharing a screen never adds a second video track or renegotiates a connection - `replaceLocalVideoTrack()`
|
|
41
|
+
* simply swaps each peer connection's existing outgoing video `RTCRtpSender`'s track (`sender.replaceTrack()`),
|
|
42
|
+
* the same track object already flowing to everyone from the moment they joined. Stopping a share replaces it
|
|
43
|
+
* back to the camera track the same way.
|
|
44
|
+
*/
|
|
45
|
+
import type { MeshEvent, MeshParticipant, RTCPeerConnectionFactory, RTCPeerConnectionLike, SignalMessage, SignalingChannel } from "./types.js";
|
|
46
|
+
|
|
47
|
+
/** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
|
|
48
|
+
export function isOfferer(selfUid: string, peerUid: string): boolean {
|
|
49
|
+
return selfUid < peerUid;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PeerState {
|
|
53
|
+
uid: string;
|
|
54
|
+
name: string;
|
|
55
|
+
pc: RTCPeerConnectionLike;
|
|
56
|
+
remoteDescriptionSet: boolean;
|
|
57
|
+
pendingCandidates: RTCIceCandidateInit[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MeshConnectionManagerOptions {
|
|
61
|
+
selfUid: string;
|
|
62
|
+
selfName: string;
|
|
63
|
+
iceServers: RTCIceServer[];
|
|
64
|
+
channel: SignalingChannel;
|
|
65
|
+
createPeerConnection: RTCPeerConnectionFactory;
|
|
66
|
+
/** The local camera/microphone stream, attached to every new peer connection as it's created. */
|
|
67
|
+
localStream: MediaStream;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class MeshConnectionManager {
|
|
71
|
+
private readonly peers = new Map<string, PeerState>();
|
|
72
|
+
private readonly listeners = new Set<(event: MeshEvent) => void>();
|
|
73
|
+
private unsubscribe: (() => void) | undefined;
|
|
74
|
+
private started = false;
|
|
75
|
+
private stopped = false;
|
|
76
|
+
private currentPresenterUid: string | undefined;
|
|
77
|
+
|
|
78
|
+
constructor(private readonly options: MeshConnectionManagerOptions) {}
|
|
79
|
+
|
|
80
|
+
get participants(): MeshParticipant[] {
|
|
81
|
+
return [...this.peers.values()].map((peer) => ({ uid: peer.uid, name: peer.name }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get presenterUid(): string | undefined {
|
|
85
|
+
return this.currentPresenterUid;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onEvent(listener: (event: MeshEvent) => void): () => void {
|
|
89
|
+
this.listeners.add(listener);
|
|
90
|
+
return () => this.listeners.delete(listener);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
|
|
94
|
+
start(): void {
|
|
95
|
+
if (this.started) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.started = true;
|
|
99
|
+
this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
|
|
100
|
+
this.sendHello();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
|
|
104
|
+
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
|
|
105
|
+
* own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
|
|
106
|
+
* stream's lifecycle, only attaches it. */
|
|
107
|
+
stop(): void {
|
|
108
|
+
if (this.stopped) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.stopped = true;
|
|
112
|
+
if (this.started) {
|
|
113
|
+
this.send({ kind: "bye" });
|
|
114
|
+
}
|
|
115
|
+
this.unsubscribe?.();
|
|
116
|
+
this.unsubscribe = undefined;
|
|
117
|
+
for (const peer of this.peers.values()) {
|
|
118
|
+
peer.pc.close();
|
|
119
|
+
}
|
|
120
|
+
this.peers.clear();
|
|
121
|
+
this.listeners.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
|
|
125
|
+
* `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
|
|
126
|
+
* camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
|
|
127
|
+
* it). */
|
|
128
|
+
replaceLocalVideoTrack(track: MediaStreamTrack | null): void {
|
|
129
|
+
for (const peer of this.peers.values()) {
|
|
130
|
+
const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
|
|
131
|
+
void sender?.replaceTrack(track);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
|
|
136
|
+
* else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
|
|
137
|
+
* rather than let a claim silently do nothing. */
|
|
138
|
+
claimPresenter(): boolean {
|
|
139
|
+
if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.options.selfUid) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
this.currentPresenterUid = this.options.selfUid;
|
|
143
|
+
this.send({ kind: "presenter-claim" });
|
|
144
|
+
this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Releases presenter status - a no-op unless the local participant currently holds it. */
|
|
149
|
+
releasePresenter(): void {
|
|
150
|
+
if (this.currentPresenterUid !== this.options.selfUid) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.currentPresenterUid = undefined;
|
|
154
|
+
this.send({ kind: "presenter-release" });
|
|
155
|
+
this.emit({ type: "presenter-changed", uid: undefined });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private sendHello(): void {
|
|
159
|
+
this.send({ kind: "hello", name: this.options.selfName });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private send(partial: Omit<SignalMessage, "type" | "from">): void {
|
|
163
|
+
this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private emit(event: MeshEvent): void {
|
|
167
|
+
for (const listener of [...this.listeners]) {
|
|
168
|
+
listener(event);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private handleMessage(message: SignalMessage): void {
|
|
173
|
+
if (message.type !== "video-meeting-signal" || message.from === this.options.selfUid) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (message.to !== undefined && message.to !== this.options.selfUid) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
switch (message.kind) {
|
|
180
|
+
case "hello":
|
|
181
|
+
this.handleHello(message);
|
|
182
|
+
return;
|
|
183
|
+
case "bye":
|
|
184
|
+
this.handleBye(message.from);
|
|
185
|
+
return;
|
|
186
|
+
case "offer":
|
|
187
|
+
void this.handleOffer(message);
|
|
188
|
+
return;
|
|
189
|
+
case "answer":
|
|
190
|
+
void this.handleAnswer(message);
|
|
191
|
+
return;
|
|
192
|
+
case "ice-candidate":
|
|
193
|
+
void this.handleIceCandidate(message);
|
|
194
|
+
return;
|
|
195
|
+
case "presenter-claim":
|
|
196
|
+
this.handlePresenterClaim(message.from);
|
|
197
|
+
return;
|
|
198
|
+
case "presenter-release":
|
|
199
|
+
this.handlePresenterRelease(message.from);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private handleHello(message: SignalMessage): void {
|
|
205
|
+
if (this.peers.has(message.from)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const name = message.name ?? message.from;
|
|
209
|
+
const peer = this.createPeer(message.from, name);
|
|
210
|
+
this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
|
|
211
|
+
// Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
|
|
212
|
+
// doc comment on roster discovery.
|
|
213
|
+
this.sendHello();
|
|
214
|
+
if (isOfferer(this.options.selfUid, message.from)) {
|
|
215
|
+
void this.initiateOffer(peer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private handleBye(uid: string): void {
|
|
220
|
+
const peer = this.peers.get(uid);
|
|
221
|
+
if (!peer) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
peer.pc.close();
|
|
225
|
+
this.peers.delete(uid);
|
|
226
|
+
this.emit({ type: "participant-left", uid });
|
|
227
|
+
if (this.currentPresenterUid === uid) {
|
|
228
|
+
this.currentPresenterUid = undefined;
|
|
229
|
+
this.emit({ type: "presenter-changed", uid: undefined });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private createPeer(uid: string, name: string): PeerState {
|
|
234
|
+
const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
|
|
235
|
+
const peer: PeerState = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
|
|
236
|
+
this.peers.set(uid, peer);
|
|
237
|
+
for (const track of this.options.localStream.getTracks()) {
|
|
238
|
+
pc.addTrack(track, this.options.localStream);
|
|
239
|
+
}
|
|
240
|
+
pc.onicecandidate = (event) => {
|
|
241
|
+
if (event.candidate) {
|
|
242
|
+
this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
pc.ontrack = (event) => {
|
|
246
|
+
const stream = event.streams[0];
|
|
247
|
+
if (stream) {
|
|
248
|
+
this.emit({ type: "remote-stream", uid, stream });
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
pc.onconnectionstatechange = () => {
|
|
252
|
+
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
|
253
|
+
this.handleBye(uid);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
return peer;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async initiateOffer(peer: PeerState): Promise<void> {
|
|
260
|
+
const offer = await peer.pc.createOffer();
|
|
261
|
+
await peer.pc.setLocalDescription(offer);
|
|
262
|
+
this.send({ kind: "offer", to: peer.uid, sdp: offer });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private async handleOffer(message: SignalMessage): Promise<void> {
|
|
266
|
+
if (!message.sdp) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
let peer = this.peers.get(message.from);
|
|
270
|
+
const isNew = !peer;
|
|
271
|
+
if (!peer) {
|
|
272
|
+
peer = this.createPeer(message.from, message.from);
|
|
273
|
+
}
|
|
274
|
+
await peer.pc.setRemoteDescription(message.sdp);
|
|
275
|
+
this.flushPendingCandidates(peer);
|
|
276
|
+
if (isNew) {
|
|
277
|
+
this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
|
|
278
|
+
}
|
|
279
|
+
const answer = await peer.pc.createAnswer();
|
|
280
|
+
await peer.pc.setLocalDescription(answer);
|
|
281
|
+
this.send({ kind: "answer", to: peer.uid, sdp: answer });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async handleAnswer(message: SignalMessage): Promise<void> {
|
|
285
|
+
const peer = this.peers.get(message.from);
|
|
286
|
+
if (!peer || !message.sdp) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await peer.pc.setRemoteDescription(message.sdp);
|
|
290
|
+
this.flushPendingCandidates(peer);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async handleIceCandidate(message: SignalMessage): Promise<void> {
|
|
294
|
+
if (!message.candidate) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const peer = this.peers.get(message.from);
|
|
298
|
+
if (!peer) {
|
|
299
|
+
// No connection tracked for this peer yet (a candidate that raced ahead of its own offer/answer) -
|
|
300
|
+
// nothing to buffer it against, and one lost trickle-ICE candidate does not by itself break a
|
|
301
|
+
// connection (there are normally several). See this module's known-limitations note in the Phase 2
|
|
302
|
+
// report.
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!peer.remoteDescriptionSet) {
|
|
306
|
+
peer.pendingCandidates.push(message.candidate);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
await peer.pc.addIceCandidate(message.candidate);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private flushPendingCandidates(peer: PeerState): void {
|
|
313
|
+
peer.remoteDescriptionSet = true;
|
|
314
|
+
const pending = peer.pendingCandidates;
|
|
315
|
+
peer.pendingCandidates = [];
|
|
316
|
+
for (const candidate of pending) {
|
|
317
|
+
void peer.pc.addIceCandidate(candidate);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private handlePresenterClaim(from: string): void {
|
|
322
|
+
if (this.currentPresenterUid === undefined) {
|
|
323
|
+
this.currentPresenterUid = from;
|
|
324
|
+
this.emit({ type: "presenter-changed", uid: from });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (this.currentPresenterUid === from) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
|
|
331
|
+
// - see this module's doc comment - so every participant converges on the same winner. When the
|
|
332
|
+
// already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
|
|
333
|
+
// presenter is `this.options.selfUid` itself, whose own optimistic claim simply stays in place.
|
|
334
|
+
if (from < this.currentPresenterUid) {
|
|
335
|
+
const loser = this.currentPresenterUid;
|
|
336
|
+
this.currentPresenterUid = from;
|
|
337
|
+
this.emit({ type: "presenter-changed", uid: from });
|
|
338
|
+
if (loser === this.options.selfUid) {
|
|
339
|
+
this.send({ kind: "presenter-release" });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private handlePresenterRelease(from: string): void {
|
|
345
|
+
if (this.currentPresenterUid === from) {
|
|
346
|
+
this.currentPresenterUid = undefined;
|
|
347
|
+
this.emit({ type: "presenter-changed", uid: undefined });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Adapts the real browser `RTCPeerConnection` to `RTCPeerConnectionLike` (`types.ts`) - `MeshConnectionManager`'s
|
|
6
|
+
* own tests never use this (they inject a fake `RTCPeerConnectionLike` directly); `apps/meet/_CallView.tsx` is
|
|
7
|
+
* this factory's only real caller. `createBrowserPeerConnection` is a plain function value, never invoked at
|
|
8
|
+
* module scope, so importing this file is safe during this plugin's page SSR, where `RTCPeerConnection` does not
|
|
9
|
+
* exist at all - it would only throw if actually *called* outside a browser. */
|
|
10
|
+
import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
|
|
13
|
+
const pc = new RTCPeerConnection(config);
|
|
14
|
+
const like: RTCPeerConnectionLike = {
|
|
15
|
+
addTrack: (track, stream) => pc.addTrack(track, stream),
|
|
16
|
+
getSenders: () => pc.getSenders(),
|
|
17
|
+
createOffer: () => pc.createOffer(),
|
|
18
|
+
createAnswer: () => pc.createAnswer(),
|
|
19
|
+
setLocalDescription: (description) => pc.setLocalDescription(description),
|
|
20
|
+
setRemoteDescription: (description) => pc.setRemoteDescription(description),
|
|
21
|
+
addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
|
|
22
|
+
close: () => pc.close(),
|
|
23
|
+
onicecandidate: null,
|
|
24
|
+
ontrack: null,
|
|
25
|
+
onconnectionstatechange: null,
|
|
26
|
+
get connectionState() {
|
|
27
|
+
return pc.connectionState;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
// Forwards the real browser events to whatever handler `MeshConnectionManager` has currently assigned on
|
|
31
|
+
// `like` - a level of indirection needed because `like`'s own handler properties are reassigned *after* this
|
|
32
|
+
// adapter object is constructed and returned (see `MeshConnectionManager.createPeer()`).
|
|
33
|
+
pc.onicecandidate = (event) => like.onicecandidate?.({ candidate: event.candidate ? event.candidate.toJSON() : null });
|
|
34
|
+
pc.ontrack = (event) => like.ontrack?.({ streams: event.streams });
|
|
35
|
+
pc.onconnectionstatechange = () => like.onconnectionstatechange?.();
|
|
36
|
+
return like;
|
|
37
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/push/GuestSignalingClient.ts`) -
|
|
6
|
+
* kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
|
|
7
|
+
* an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
|
|
8
|
+
* its own doc comment. */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
|
|
12
|
+
* and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
|
|
13
|
+
* offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads"). `type` is a fixed
|
|
14
|
+
* literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
|
|
15
|
+
* grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
|
|
16
|
+
*
|
|
17
|
+
* `hello`/`bye`/`presenter-claim`/`presenter-release` are broadcast (`to` left unset - every participant is
|
|
18
|
+
* relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) - a recipient
|
|
19
|
+
* that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to every
|
|
20
|
+
* subscriber, sender included.
|
|
21
|
+
*/
|
|
22
|
+
export interface SignalMessage {
|
|
23
|
+
type: "video-meeting-signal";
|
|
24
|
+
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release";
|
|
25
|
+
/** The sender's uid - the real, already-authenticated caller's own uid when `join()` returned
|
|
26
|
+
* `authenticated: true`, else the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted (either way, see
|
|
27
|
+
* `VideoMeetingJoinResult.selfUid`). */
|
|
28
|
+
from: string;
|
|
29
|
+
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
|
|
30
|
+
to?: string;
|
|
31
|
+
/** `hello` only - the display name the sender chose in the lobby. */
|
|
32
|
+
name?: string;
|
|
33
|
+
/** `offer`/`answer` only. */
|
|
34
|
+
sdp?: RTCSessionDescriptionInit;
|
|
35
|
+
/** `ice-candidate` only. */
|
|
36
|
+
candidate?: RTCIceCandidateInit;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What `MeshConnectionManager` needs from a signaling transport - implemented by
|
|
40
|
+
* `apps/shared/push/GuestSignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
|
|
41
|
+
* tests (see `test/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
|
|
42
|
+
export interface SignalingChannel {
|
|
43
|
+
send(message: SignalMessage): void;
|
|
44
|
+
/** Calls `handler` with every message received on the channel (including, per the pub/sub fan-out this
|
|
45
|
+
* transport is built on, this same client's own sends - `MeshConnectionManager` filters those out itself
|
|
46
|
+
* rather than relying on the transport to). Returns the function that stops listening. */
|
|
47
|
+
onMessage(handler: (message: SignalMessage) => void): () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The subset of `RTCRtpSender` `MeshConnectionManager.replaceLocalVideoTrack()` uses (presentation mode - see
|
|
51
|
+
* this plugin's Phase 2 `.claude/NOTES.md` entry: sharing replaces the outgoing camera track rather than adding a
|
|
52
|
+
* second video track, so no renegotiation is ever needed). A real `RTCRtpSender` already satisfies this shape. */
|
|
53
|
+
export interface RTCRtpSenderLike {
|
|
54
|
+
track: MediaStreamTrack | null;
|
|
55
|
+
replaceTrack(track: MediaStreamTrack | null): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The subset of `RTCPeerConnection` `MeshConnectionManager` uses - what a test's fake implements, and what
|
|
59
|
+
* `apps/shared/webrtc/realPeerConnection.ts` adapts the real browser constructor to. Event handlers are plain settable
|
|
60
|
+
* properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
|
|
61
|
+
* this stands in for and keeping a test fake to the bare minimum. */
|
|
62
|
+
export interface RTCPeerConnectionLike {
|
|
63
|
+
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
|
|
64
|
+
getSenders(): RTCRtpSenderLike[];
|
|
65
|
+
createOffer(): Promise<RTCSessionDescriptionInit>;
|
|
66
|
+
createAnswer(): Promise<RTCSessionDescriptionInit>;
|
|
67
|
+
setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
|
|
68
|
+
setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
|
|
69
|
+
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
|
|
70
|
+
close(): void;
|
|
71
|
+
onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
|
|
72
|
+
ontrack: ((event: { streams: readonly MediaStream[] }) => void) | null;
|
|
73
|
+
onconnectionstatechange: (() => void) | null;
|
|
74
|
+
connectionState: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] }) => RTCPeerConnectionLike;
|
|
78
|
+
|
|
79
|
+
/** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
|
|
80
|
+
* the local participant themselves (the UI already knows its own name/uid without asking the manager). */
|
|
81
|
+
export interface MeshParticipant {
|
|
82
|
+
uid: string;
|
|
83
|
+
name: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type MeshEvent =
|
|
87
|
+
| { type: "participant-joined"; participant: MeshParticipant }
|
|
88
|
+
| { type: "participant-left"; uid: string }
|
|
89
|
+
| { type: "remote-stream"; uid: string; stream: MediaStream }
|
|
90
|
+
| { type: "presenter-changed"; uid: string | undefined };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
|
|
3
|
+
* slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
|
|
4
|
+
* `loading` -> `lobby` -> `in-call` -> `ended`.
|
|
5
|
+
*
|
|
6
|
+
* ## Session-based name prefill - NOT implemented, and why
|
|
7
|
+
*
|
|
8
|
+
* The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
|
|
9
|
+
* holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
|
|
10
|
+
* plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
|
|
11
|
+
* an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
|
|
12
|
+
* `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
|
|
13
|
+
* (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
|
|
14
|
+
* `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
|
|
15
|
+
* concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
|
|
16
|
+
* that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
|
|
17
|
+
* ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
|
|
18
|
+
* phase's scope):
|
|
19
|
+
*
|
|
20
|
+
* 1. Have `PublicPageRoute.fetchProps()` check the incoming `jwt` cookie itself (the way `wwwRoute` already does)
|
|
21
|
+
* and pass `userUid`/`authServerUrl` through like the `www`/admin hosts do.
|
|
22
|
+
* 2. Add a small authenticated "who am I" endpoint on this server's own origin (no `authServerUrl` needed at all),
|
|
23
|
+
* which this page could call with `apiFetch()` the same way any other same-origin authenticated call works.
|
|
24
|
+
*
|
|
25
|
+
* Per the spec, the name field is always left empty and editable instead - correct for the common case (most
|
|
26
|
+
* invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
|
|
27
|
+
*/
|
|
28
|
+
import React from "react";
|
|
29
|
+
export default function MeetJoinPage({ params }: {
|
|
30
|
+
params: {
|
|
31
|
+
token: string;
|
|
32
|
+
};
|
|
33
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } 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 public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
|
|
8
|
+
* slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
|
|
9
|
+
* `loading` -> `lobby` -> `in-call` -> `ended`.
|
|
10
|
+
*
|
|
11
|
+
* ## Session-based name prefill - NOT implemented, and why
|
|
12
|
+
*
|
|
13
|
+
* The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
|
|
14
|
+
* holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
|
|
15
|
+
* plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
|
|
16
|
+
* an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
|
|
17
|
+
* `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
|
|
18
|
+
* (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
|
|
19
|
+
* `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
|
|
20
|
+
* concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
|
|
21
|
+
* that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
|
|
22
|
+
* ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
|
|
23
|
+
* phase's scope):
|
|
24
|
+
*
|
|
25
|
+
* 1. Have `PublicPageRoute.fetchProps()` check the incoming `jwt` cookie itself (the way `wwwRoute` already does)
|
|
26
|
+
* and pass `userUid`/`authServerUrl` through like the `www`/admin hosts do.
|
|
27
|
+
* 2. Add a small authenticated "who am I" endpoint on this server's own origin (no `authServerUrl` needed at all),
|
|
28
|
+
* which this page could call with `apiFetch()` the same way any other same-origin authenticated call works.
|
|
29
|
+
*
|
|
30
|
+
* Per the spec, the name field is always left empty and editable instead - correct for the common case (most
|
|
31
|
+
* invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
|
|
32
|
+
*/
|
|
33
|
+
import React, { useState } from "react";
|
|
34
|
+
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
35
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
36
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
37
|
+
import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
|
|
38
|
+
import { default as MeetLobby } from "./_MeetLobby.js";
|
|
39
|
+
import CallView from "./_CallView.js";
|
|
40
|
+
import { joinMeeting } from "./_meetApi.js";
|
|
41
|
+
export default function MeetJoinPage({ params }) {
|
|
42
|
+
const { branding } = useBranding();
|
|
43
|
+
return (_jsx(MeetPageShell, { branding: branding, children: _jsx(MeetJoinContent, { token: params.token }) }));
|
|
44
|
+
}
|
|
45
|
+
function MeetJoinContent({ token }) {
|
|
46
|
+
const [phase, setPhase] = useState("loading");
|
|
47
|
+
const [joinResult, setJoinResult] = useState(null);
|
|
48
|
+
const [preferences, setPreferences] = useState(null);
|
|
49
|
+
const [loadError, setLoadError] = useState(null);
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
joinMeeting(token)
|
|
53
|
+
.then((result) => {
|
|
54
|
+
if (!cancelled) {
|
|
55
|
+
setJoinResult(result);
|
|
56
|
+
setPhase("lobby");
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
if (cancelled) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
64
|
+
setPhase("not-found");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setLoadError(err instanceof ApiRequestError ? err.message : "Could not load this meeting.");
|
|
68
|
+
setPhase("not-found");
|
|
69
|
+
});
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
};
|
|
73
|
+
}, [token]);
|
|
74
|
+
function handleJoin(prefs) {
|
|
75
|
+
setPreferences(prefs);
|
|
76
|
+
setPhase("in-call");
|
|
77
|
+
}
|
|
78
|
+
function handleLeave() {
|
|
79
|
+
setPhase("ended");
|
|
80
|
+
}
|
|
81
|
+
if (phase === "loading") {
|
|
82
|
+
return (_jsx(MeetCard, { children: _jsx("p", { className: "text-base text-text-muted", children: "Loading\u2026" }) }));
|
|
83
|
+
}
|
|
84
|
+
if (phase === "not-found") {
|
|
85
|
+
return (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
|
|
86
|
+
}
|
|
87
|
+
if (phase === "ended") {
|
|
88
|
+
return (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted", children: "You can close this page now." })] }));
|
|
89
|
+
}
|
|
90
|
+
if (phase === "in-call" && joinResult && preferences) {
|
|
91
|
+
return (_jsx(CallView, { channel: joinResult.meeting.uid,
|
|
92
|
+
// `token` is present only for the common anonymous case (`authenticated: false`) - when the
|
|
93
|
+
// caller's own existing session already authenticated them (`authenticated: true`), there is no
|
|
94
|
+
// guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
|
|
95
|
+
// browser's own already-existing `jwt` session cookie instead (see its own doc comment and
|
|
96
|
+
// `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
|
|
97
|
+
token: joinResult.token, selfUid: joinResult.selfUid, selfName: preferences.name, iceServers: joinResult.iceServers, initialStream: preferences.stream, initialMicOn: preferences.micOn, initialCameraOn: preferences.cameraOn, onLeave: handleLeave }));
|
|
98
|
+
}
|
|
99
|
+
// phase === "lobby" (join Result is always set by then - see the effect above).
|
|
100
|
+
return (_jsx(MeetCard, { maxWidth: "max-w-4xl", children: _jsx(MeetLobby, { meeting: joinResult.meeting, onJoin: handleJoin }) }));
|
|
101
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The in-call bottom control bar: mute/unmute, camera on/off, share screen, grid/focus toggle, leave. Deliberately
|
|
2
|
+
* minimal per this plugin's Phase 2 spec ("no chat, no recording, no reactions"). */
|
|
3
|
+
import React from "react";
|
|
4
|
+
export type CallViewMode = "grid" | "focus";
|
|
5
|
+
export interface CallControlsProps {
|
|
6
|
+
micOn: boolean;
|
|
7
|
+
onToggleMic: () => void;
|
|
8
|
+
cameraOn: boolean;
|
|
9
|
+
onToggleCamera: () => void;
|
|
10
|
+
isPresenting: boolean;
|
|
11
|
+
/** Someone else is presenting - the share button is disabled and explains why. */
|
|
12
|
+
presentingElsewhereName?: string;
|
|
13
|
+
onToggleShare: () => void;
|
|
14
|
+
viewMode: CallViewMode;
|
|
15
|
+
onToggleViewMode: () => void;
|
|
16
|
+
onLeave: () => void;
|
|
17
|
+
}
|
|
18
|
+
export default function CallControls({ micOn, onToggleMic, cameraOn, onToggleCamera, isPresenting, presentingElsewhereName, onToggleShare, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
3
|
+
export default function CallControls({ micOn, onToggleMic, cameraOn, onToggleCamera, isPresenting, presentingElsewhereName, onToggleShare, viewMode, onToggleViewMode, onLeave, }) {
|
|
4
|
+
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
5
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2 py-3 px-4 bg-surface border-t border-border", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleMic, children: micOn ? "Mute" : "Unmute" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleCamera, children: cameraOn ? "Turn camera off" : "Turn camera on" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: isPresenting ? "Stop sharing" : "Share screen" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleViewMode, children: viewMode === "grid" ? "Focused view" : "Grid view" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: onLeave, children: "Leave" })] }));
|
|
6
|
+
}
|