@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
package/apps/meet/_CallView.tsx
CHANGED
|
@@ -33,12 +33,30 @@
|
|
|
33
33
|
* auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
|
|
34
34
|
* slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
|
|
35
35
|
* empty main slot once someone else has joined.
|
|
36
|
+
*
|
|
37
|
+
* ## Talking stick mode
|
|
38
|
+
*
|
|
39
|
+
* A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
|
|
40
|
+
* be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
|
|
41
|
+
* else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
|
|
42
|
+
* the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
|
|
43
|
+
* `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
|
|
44
|
+
* `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
|
|
45
|
+
* *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
|
|
46
|
+
* tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
|
|
47
|
+
* (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
|
|
48
|
+
* back on - a real restriction for as long as this tab's own UI is the one in front of the participant, not just a
|
|
49
|
+
* suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
|
|
50
|
+
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
51
|
+
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
52
|
+
* the floor, until the host picks someone (or themselves) again.
|
|
36
53
|
*/
|
|
37
54
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
38
55
|
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
39
56
|
import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
|
|
40
57
|
import { startLevelMeter, type LevelMeterHandle } from "../shared/media/levelMeter.js";
|
|
41
58
|
import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
|
|
59
|
+
import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise, type ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
42
60
|
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
43
61
|
import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
|
|
44
62
|
import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
|
|
@@ -46,7 +64,18 @@ import type { MeshParticipant, RelayTransportLike } from "../shared/webrtc/types
|
|
|
46
64
|
import { createRelayTransport } from "../shared/relay/RelayTransport.js";
|
|
47
65
|
import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
|
|
48
66
|
import CallControls, { type CallViewMode } from "./_CallControls.js";
|
|
67
|
+
import {
|
|
68
|
+
admitParticipant,
|
|
69
|
+
denyParticipant,
|
|
70
|
+
kickParticipant,
|
|
71
|
+
listWaitingParticipants,
|
|
72
|
+
setForceMuteOnJoin as apiSetForceMuteOnJoin,
|
|
73
|
+
setMeetingPassword,
|
|
74
|
+
setWaitingRoomEnabled as apiSetWaitingRoomEnabled,
|
|
75
|
+
type WaitingParticipant,
|
|
76
|
+
} from "./_meetApi.js";
|
|
49
77
|
import ParticipantTile from "./_ParticipantTile.js";
|
|
78
|
+
import ParticipantsDrawer from "./_ParticipantsDrawer.js";
|
|
50
79
|
|
|
51
80
|
export interface CallViewProps {
|
|
52
81
|
channel: string;
|
|
@@ -61,15 +90,37 @@ export interface CallViewProps {
|
|
|
61
90
|
/** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
|
|
62
91
|
* connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
|
|
63
92
|
relayEnabled?: boolean;
|
|
93
|
+
/** The meeting's host account uid (`PublicVideoMeeting.hostUid`) - this client's only source of a "host"
|
|
94
|
+
* identity, purely for deciding which controls to show (see `BaseVideoMeetingRoute`'s class doc comment on why
|
|
95
|
+
* this is a client-side-only check, not a server-enforced one for every moderation action). Absent when the
|
|
96
|
+
* server couldn't resolve one, in which case nobody sees host controls. */
|
|
97
|
+
hostUid?: string;
|
|
98
|
+
/** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
|
|
99
|
+
* toggle in the participants drawer, which this view then owns and keeps current itself (see
|
|
100
|
+
* `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
|
|
101
|
+
* no signal for that today, matching every other meeting-settings field's lack of live sync. */
|
|
102
|
+
initialForceMuteOnJoin?: boolean;
|
|
103
|
+
/** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
|
|
104
|
+
* section in the participants drawer, same "owned and kept current by this view" shape as
|
|
105
|
+
* `initialForceMuteOnJoin`. */
|
|
106
|
+
initialHasPassword?: boolean;
|
|
107
|
+
/** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
|
|
108
|
+
* waiting-room toggle, same "owned and kept current by this view" shape as `initialForceMuteOnJoin`. */
|
|
109
|
+
initialWaitingRoomEnabled?: boolean;
|
|
64
110
|
/** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
|
|
65
111
|
media: LocalMedia;
|
|
66
|
-
|
|
112
|
+
/** Called once the participant leaves, for any reason. `reason` is set only when the call ended without the
|
|
113
|
+
* participant's own action - currently, being kicked by the host - so the caller can show a distinct message
|
|
114
|
+
* instead of the ordinary "you left" one; omitted for an ordinary voluntary leave. */
|
|
115
|
+
onLeave: (reason?: string) => void;
|
|
67
116
|
}
|
|
68
117
|
|
|
69
118
|
/** How long a reaction floats on screen. */
|
|
70
119
|
const REACTION_MS = 4_000;
|
|
71
120
|
/** The most reactions shown at once - a burst beyond this drops the oldest. */
|
|
72
121
|
const MAX_REACTIONS = 12;
|
|
122
|
+
/** How often the host's drawer refreshes its own waiting-room list while open. */
|
|
123
|
+
const DEFAULT_WAITING_POLL_MS = 3_000;
|
|
73
124
|
|
|
74
125
|
interface Reaction {
|
|
75
126
|
id: number;
|
|
@@ -104,7 +155,31 @@ export function newPeerId(selfUid: string): string {
|
|
|
104
155
|
return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
105
156
|
}
|
|
106
157
|
|
|
107
|
-
|
|
158
|
+
/** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
|
|
159
|
+
* `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
|
|
160
|
+
* this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
|
|
161
|
+
* `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
|
|
162
|
+
* exactly this suffix's own separator. */
|
|
163
|
+
export function accountUidOf(peerUid: string): string {
|
|
164
|
+
const i = peerUid.lastIndexOf("~");
|
|
165
|
+
return i === -1 ? peerUid : peerUid.slice(0, i);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default function CallView({
|
|
169
|
+
channel,
|
|
170
|
+
token,
|
|
171
|
+
selfUid,
|
|
172
|
+
selfName,
|
|
173
|
+
meetingTitle,
|
|
174
|
+
iceServers,
|
|
175
|
+
relayEnabled,
|
|
176
|
+
hostUid,
|
|
177
|
+
initialForceMuteOnJoin,
|
|
178
|
+
initialHasPassword,
|
|
179
|
+
initialWaitingRoomEnabled,
|
|
180
|
+
media,
|
|
181
|
+
onLeave,
|
|
182
|
+
}: CallViewProps) {
|
|
108
183
|
const [peerId] = useState(() => newPeerId(selfUid));
|
|
109
184
|
const [connectError, setConnectError] = useState<string | null>(null);
|
|
110
185
|
const [participants, setParticipants] = useState<MeshParticipant[]>([]);
|
|
@@ -112,6 +187,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
112
187
|
const [levels, setLevels] = useState<Record<string, number>>({});
|
|
113
188
|
const [presenterUid, setPresenterUid] = useState<string | undefined>(undefined);
|
|
114
189
|
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
|
|
190
|
+
const [screenTransform, setScreenTransform] = useState<ScreenTransformState>(NO_SCREEN_TRANSFORM);
|
|
115
191
|
const [handRaised, setHandRaised] = useState(false);
|
|
116
192
|
const [viewMode, setViewMode] = useState<CallViewMode>("grid");
|
|
117
193
|
const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
|
|
@@ -121,12 +197,29 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
121
197
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
122
198
|
const [audioNonce, setAudioNonce] = useState(0);
|
|
123
199
|
const [signalingReady, setSignalingReady] = useState(false);
|
|
200
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
201
|
+
const [forceMuteOnJoin, setForceMuteOnJoinState] = useState(!!initialForceMuteOnJoin);
|
|
202
|
+
const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
|
|
203
|
+
const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
|
|
204
|
+
const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
|
|
205
|
+
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
206
|
+
const [talkingStickHolder, setTalkingStickHolder] = useState<string | undefined>(undefined);
|
|
124
207
|
|
|
125
208
|
const managerRef = useRef<MeshConnectionManager | null>(null);
|
|
209
|
+
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
210
|
+
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
211
|
+
* actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
|
|
126
212
|
const screenStreamRef = useRef<MediaStream | null>(null);
|
|
213
|
+
const screenTransformRef = useRef<ScreenTransformProcessor | null>(null);
|
|
127
214
|
const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
|
|
128
215
|
const reactionSeqRef = useRef(0);
|
|
129
216
|
const reactionTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
217
|
+
/** The latest `media`, for the mesh-event handler below - that effect runs once at mount (see its own comment),
|
|
218
|
+
* so it would otherwise see only the mic state/toggle function from that first render. */
|
|
219
|
+
const mediaRef = useRef(media);
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
mediaRef.current = media;
|
|
222
|
+
}, [media]);
|
|
130
223
|
const isPresenting = !!screenStream;
|
|
131
224
|
|
|
132
225
|
const showReaction = useCallback((emoji: string, name: string) => {
|
|
@@ -198,6 +291,18 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
198
291
|
stopLocalPresentation(false);
|
|
199
292
|
}
|
|
200
293
|
return;
|
|
294
|
+
case "mute-requested":
|
|
295
|
+
if (mediaRef.current.micOn) {
|
|
296
|
+
void mediaRef.current.toggleMic();
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
case "kicked":
|
|
300
|
+
onLeave("The host removed you from this call.");
|
|
301
|
+
return;
|
|
302
|
+
case "talking-stick-changed":
|
|
303
|
+
setTalkingStickActive(event.active);
|
|
304
|
+
setTalkingStickHolder(event.holder);
|
|
305
|
+
return;
|
|
201
306
|
}
|
|
202
307
|
});
|
|
203
308
|
|
|
@@ -234,6 +339,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
234
339
|
// Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
|
|
235
340
|
// releases them when the participant leaves.
|
|
236
341
|
stopStream(screenStreamRef.current);
|
|
342
|
+
screenTransformRef.current?.stop();
|
|
237
343
|
for (const timer of timers) {
|
|
238
344
|
clearTimeout(timer);
|
|
239
345
|
}
|
|
@@ -254,6 +360,25 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
254
360
|
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
255
361
|
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
256
362
|
|
|
363
|
+
// Talking-stick mode forces exactly one microphone on at a time: whoever just became the holder (including the
|
|
364
|
+
// host, on activation) is unmuted; everyone else is muted. Unlike `mute-requested`'s one-time nudge, this is
|
|
365
|
+
// paired with disabling the mic button itself (`micLocked`, passed to `CallControls` below) while it applies,
|
|
366
|
+
// so it isn't merely a suggestion - though still cooperative at the signaling layer, like every other
|
|
367
|
+
// moderation feature here (see `MeshConnectionManager`'s doc comment). Turning the mode off touches nobody's
|
|
368
|
+
// mic - states are left exactly as they are, the same "never retroactive" posture as every other host toggle.
|
|
369
|
+
const selfHasTalkingStick = talkingStickActive && talkingStickHolder === peerId;
|
|
370
|
+
const micLocked = talkingStickActive && !selfHasTalkingStick;
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (!talkingStickActive) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (selfHasTalkingStick && !mediaRef.current.micOn) {
|
|
376
|
+
void mediaRef.current.toggleMic();
|
|
377
|
+
} else if (!selfHasTalkingStick && mediaRef.current.micOn) {
|
|
378
|
+
void mediaRef.current.toggleMic();
|
|
379
|
+
}
|
|
380
|
+
}, [talkingStickActive, selfHasTalkingStick]);
|
|
381
|
+
|
|
257
382
|
useEffect(() => {
|
|
258
383
|
if (!presenterUid) {
|
|
259
384
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -266,8 +391,12 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
266
391
|
}
|
|
267
392
|
stopStream(screenStreamRef.current);
|
|
268
393
|
screenStreamRef.current = null;
|
|
269
|
-
|
|
394
|
+
screenTransformRef.current?.stop();
|
|
395
|
+
screenTransformRef.current = null;
|
|
396
|
+
// The sync effect above swaps the camera back in as the outgoing video track. The rotation/flip is reset
|
|
397
|
+
// for the next share - each share starts from the capture the browser hands back, not the last one's fix.
|
|
270
398
|
setScreenStream(null);
|
|
399
|
+
setScreenTransform(NO_SCREEN_TRANSFORM);
|
|
271
400
|
}
|
|
272
401
|
|
|
273
402
|
async function handleToggleShare() {
|
|
@@ -286,8 +415,35 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
286
415
|
return;
|
|
287
416
|
}
|
|
288
417
|
screenStreamRef.current = result.value;
|
|
289
|
-
|
|
290
|
-
|
|
418
|
+
const rawTrack = result.value.getVideoTracks()[0];
|
|
419
|
+
rawTrack.onended = () => stopLocalPresentation(true);
|
|
420
|
+
try {
|
|
421
|
+
// Runs the capture through the rotate/flip processor before anyone (presenter included) sees it - see
|
|
422
|
+
// ScreenTransform.ts's doc comment on why window capture sometimes needs this correction.
|
|
423
|
+
const processor = new ScreenTransformProcessor({ source: rawTrack, state: screenTransform });
|
|
424
|
+
screenTransformRef.current = processor;
|
|
425
|
+
setScreenStream(new MediaStream([processor.track]));
|
|
426
|
+
} catch {
|
|
427
|
+
// This browser can't draw the correction - share the raw capture rather than not sharing at all; the
|
|
428
|
+
// rotate/flip buttons simply won't do anything (screenTransformRef stays null).
|
|
429
|
+
setScreenStream(result.value);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function handleRotateScreen() {
|
|
434
|
+
setScreenTransform((prev) => {
|
|
435
|
+
const next = rotateClockwise(prev);
|
|
436
|
+
screenTransformRef.current?.setState(next);
|
|
437
|
+
return next;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function handleFlipScreen() {
|
|
442
|
+
setScreenTransform((prev) => {
|
|
443
|
+
const next = { ...prev, flipped: !prev.flipped };
|
|
444
|
+
screenTransformRef.current?.setState(next);
|
|
445
|
+
return next;
|
|
446
|
+
});
|
|
291
447
|
}
|
|
292
448
|
|
|
293
449
|
function handleToggleHand() {
|
|
@@ -304,6 +460,77 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
304
460
|
setPinnedUid((prev) => (prev === uid ? undefined : uid));
|
|
305
461
|
}
|
|
306
462
|
|
|
463
|
+
/** Asks `peerUid`'s participant to mute - the host-only button in the participants drawer calls this
|
|
464
|
+
* directly, with no confirmation (unlike removing someone, muting is easily undone by the participant
|
|
465
|
+
* themselves). */
|
|
466
|
+
function handleMuteParticipant(peerUid: string) {
|
|
467
|
+
managerRef.current?.sendMuteRequest(peerUid);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Removes `peerUid`'s participant from the call: the cooperative signal (immediate, so a cooperating client
|
|
471
|
+
* leaves without waiting on the network round trip below) and the enforced server-side revoke together - see
|
|
472
|
+
* `kickParticipant()`'s own doc comment for why both matter. The REST call's failure is swallowed rather than
|
|
473
|
+
* surfaced: the host already asked them to leave, and there is no useful recovery action to offer from here
|
|
474
|
+
* for what is almost always a transient network issue. */
|
|
475
|
+
function handleKickParticipant(peerUid: string) {
|
|
476
|
+
managerRef.current?.sendKick(peerUid);
|
|
477
|
+
kickParticipant(channel, accountUidOf(peerUid)).catch(() => undefined);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Flips the host's "mute new participants on join" setting: applied optimistically (the toggle itself is the
|
|
481
|
+
* only feedback this small a control needs) and reverted if the save fails, so the drawer never keeps claiming
|
|
482
|
+
* a setting that isn't actually persisted. Takes effect for whoever joins next - never retroactive, see
|
|
483
|
+
* `setForceMuteOnJoin()`'s own doc comment. */
|
|
484
|
+
function handleToggleForceMuteOnJoin() {
|
|
485
|
+
const next = !forceMuteOnJoin;
|
|
486
|
+
setForceMuteOnJoinState(next);
|
|
487
|
+
apiSetForceMuteOnJoin(channel, next).catch(() => setForceMuteOnJoinState(!next));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Sets, changes, or removes (`password: null`) the host's join password - unlike the force-mute toggle, this
|
|
491
|
+
* is not applied optimistically: the drawer's own `PasswordSection` awaits the result itself (clearing its
|
|
492
|
+
* input on success, showing an inline error on failure), so there is nothing here to revert. `hasPassword`
|
|
493
|
+
* updates only once the save actually succeeds. */
|
|
494
|
+
async function handleSetPassword(password: string | null): Promise<void> {
|
|
495
|
+
await setMeetingPassword(channel, password);
|
|
496
|
+
setHasPassword(!!password);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Flips the host's waiting-room setting - same optimistic-and-revert shape as `handleToggleForceMuteOnJoin()`,
|
|
500
|
+
* for the same reason. Takes effect for whoever joins next; nobody already admitted is retroactively gated. */
|
|
501
|
+
function handleToggleWaitingRoomEnabled() {
|
|
502
|
+
const next = !waitingRoomEnabled;
|
|
503
|
+
setWaitingRoomEnabledState(next);
|
|
504
|
+
apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Starts or stops talking-stick mode - host-only, see this module's doc comment. Starting makes the host the
|
|
508
|
+
* initial holder; stopping clears the holder too, leaving mic states exactly as they are rather than
|
|
509
|
+
* retroactively unmuting anyone. */
|
|
510
|
+
function handleToggleTalkingStick() {
|
|
511
|
+
managerRef.current?.setTalkingStick(!talkingStickActive, talkingStickActive ? undefined : peerId);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Hands the talking stick to `uid` (a participant's `MeshParticipant.uid`, or this tab's own `peerId` for the
|
|
515
|
+
* host taking it back) - host-only, called from the participants drawer's per-row "Give stick" button. */
|
|
516
|
+
function handleGiveTalkingStick(uid: string) {
|
|
517
|
+
managerRef.current?.setTalkingStick(true, uid);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
521
|
+
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
522
|
+
* delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
|
|
523
|
+
function handleAdmitParticipant(uid: string) {
|
|
524
|
+
setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
|
|
525
|
+
admitParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Denies one pending request - same optimistic-removal shape as `handleAdmitParticipant()`. */
|
|
529
|
+
function handleDenyParticipant(uid: string) {
|
|
530
|
+
setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
|
|
531
|
+
denyParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
|
|
532
|
+
}
|
|
533
|
+
|
|
307
534
|
function handleAudioBlocked() {
|
|
308
535
|
setAudioBlocked(true);
|
|
309
536
|
}
|
|
@@ -313,6 +540,36 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
313
540
|
setAudioNonce((prev) => prev + 1);
|
|
314
541
|
}
|
|
315
542
|
|
|
543
|
+
// A participant's `uid` is their tab-scoped peer id (`<account uid>~<random>` - see `newPeerId()`), while
|
|
544
|
+
// `hostUid` is the plain account uid `PublicVideoMeeting.hostUid` names - so telling the host's own tab(s) apart
|
|
545
|
+
// needs the prefix match, not equality. Purely a client-side check (see `hostUid`'s own doc comment on this
|
|
546
|
+
// prop): nobody's tab is prevented from claiming it, only from being shown host controls for it.
|
|
547
|
+
const isHost = !!hostUid && selfUid === hostUid;
|
|
548
|
+
const isParticipantHost = (uid: string): boolean => !!hostUid && uid.startsWith(`${hostUid}~`);
|
|
549
|
+
|
|
550
|
+
// Refreshes the drawer's own waiting-room list while the host has the drawer open - there is no push signal
|
|
551
|
+
// for a newly filed admission request, so polling is the only way the list stays current without the host
|
|
552
|
+
// having to close and reopen the drawer. Stops (and the list is dropped) the moment either condition ends.
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
if (!isHost || !drawerOpen) {
|
|
555
|
+
setWaitingParticipants([]);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
let cancelled = false;
|
|
559
|
+
const poll = () => {
|
|
560
|
+
listWaitingParticipants(channel).then((list) => {
|
|
561
|
+
if (!cancelled) {
|
|
562
|
+
setWaitingParticipants(list);
|
|
563
|
+
}
|
|
564
|
+
}, () => undefined);
|
|
565
|
+
};
|
|
566
|
+
poll();
|
|
567
|
+
const interval = setInterval(poll, DEFAULT_WAITING_POLL_MS);
|
|
568
|
+
return () => {
|
|
569
|
+
cancelled = true;
|
|
570
|
+
clearInterval(interval);
|
|
571
|
+
};
|
|
572
|
+
}, [isHost, drawerOpen, channel]);
|
|
316
573
|
const otherUids = participants.map((p) => p.uid);
|
|
317
574
|
// A pin or an active-speaker pick can name someone who has just left, until the state catches up.
|
|
318
575
|
const stillHere = (uid: string | undefined) => (uid && otherUids.includes(uid) ? uid : undefined);
|
|
@@ -322,6 +579,13 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
322
579
|
: undefined;
|
|
323
580
|
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
324
581
|
const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
|
|
582
|
+
// Absent (not "Someone") once the holder has left the call - see `MeshConnectionManager`'s doc comment on why
|
|
583
|
+
// nothing reassigns it automatically; the header then says so rather than naming someone no longer here.
|
|
584
|
+
const talkingStickHolderName = !talkingStickActive
|
|
585
|
+
? undefined
|
|
586
|
+
: selfHasTalkingStick
|
|
587
|
+
? "You"
|
|
588
|
+
: participants.find((p) => p.uid === talkingStickHolder)?.name;
|
|
325
589
|
const alone = participants.length === 0;
|
|
326
590
|
// This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
|
|
327
591
|
// and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
|
|
@@ -412,9 +676,31 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
412
676
|
✋ {raisedNames.join(", ")}
|
|
413
677
|
</span>
|
|
414
678
|
)}
|
|
415
|
-
|
|
679
|
+
{talkingStickActive && (
|
|
680
|
+
<span className="max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium" data-testid="talking-stick-status">
|
|
681
|
+
🎙️ {talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"}
|
|
682
|
+
</span>
|
|
683
|
+
)}
|
|
684
|
+
{isHost && (
|
|
685
|
+
<button
|
|
686
|
+
type="button"
|
|
687
|
+
className={`px-3 py-1.5 rounded-full text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`}
|
|
688
|
+
aria-pressed={talkingStickActive}
|
|
689
|
+
onClick={handleToggleTalkingStick}
|
|
690
|
+
>
|
|
691
|
+
{talkingStickActive ? "End talking stick" : "Talking stick"}
|
|
692
|
+
</button>
|
|
693
|
+
)}
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
className="px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
697
|
+
aria-label={`${participants.length + 1} participants`}
|
|
698
|
+
aria-haspopup="dialog"
|
|
699
|
+
aria-expanded={drawerOpen}
|
|
700
|
+
onClick={() => setDrawerOpen((prev) => !prev)}
|
|
701
|
+
>
|
|
416
702
|
{participants.length + 1}
|
|
417
|
-
</
|
|
703
|
+
</button>
|
|
418
704
|
</div>
|
|
419
705
|
</header>
|
|
420
706
|
{connectError && (
|
|
@@ -440,14 +726,20 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
440
726
|
<footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
|
441
727
|
<CallControls
|
|
442
728
|
media={media}
|
|
729
|
+
selfName={selfName}
|
|
730
|
+
participants={participants}
|
|
443
731
|
isPresenting={isPresenting}
|
|
444
732
|
presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
|
|
445
733
|
onToggleShare={() => void handleToggleShare()}
|
|
734
|
+
screenTransform={screenTransform}
|
|
735
|
+
onRotateScreen={handleRotateScreen}
|
|
736
|
+
onFlipScreen={handleFlipScreen}
|
|
446
737
|
handRaised={handRaised}
|
|
447
738
|
onToggleHand={handleToggleHand}
|
|
448
739
|
onReaction={handleReaction}
|
|
449
740
|
viewMode={viewMode}
|
|
450
741
|
onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
|
|
742
|
+
micLocked={micLocked}
|
|
451
743
|
onLeave={onLeave}
|
|
452
744
|
/>
|
|
453
745
|
</footer>
|
|
@@ -482,6 +774,33 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
482
774
|
<div role="status" aria-live="polite" className="sr-only">
|
|
483
775
|
{announcement}
|
|
484
776
|
</div>
|
|
777
|
+
|
|
778
|
+
{drawerOpen && (
|
|
779
|
+
<ParticipantsDrawer
|
|
780
|
+
selfName={selfName}
|
|
781
|
+
micOn={media.micOn}
|
|
782
|
+
handRaised={handRaised}
|
|
783
|
+
participants={participants}
|
|
784
|
+
isSelfHost={isHost}
|
|
785
|
+
isParticipantHost={isParticipantHost}
|
|
786
|
+
onMute={handleMuteParticipant}
|
|
787
|
+
onKick={handleKickParticipant}
|
|
788
|
+
forceMuteOnJoin={forceMuteOnJoin}
|
|
789
|
+
onToggleForceMuteOnJoin={handleToggleForceMuteOnJoin}
|
|
790
|
+
hasPassword={hasPassword}
|
|
791
|
+
onSetPassword={handleSetPassword}
|
|
792
|
+
waitingRoomEnabled={waitingRoomEnabled}
|
|
793
|
+
onToggleWaitingRoomEnabled={handleToggleWaitingRoomEnabled}
|
|
794
|
+
waitingParticipants={waitingParticipants}
|
|
795
|
+
onAdmit={handleAdmitParticipant}
|
|
796
|
+
onDeny={handleDenyParticipant}
|
|
797
|
+
talkingStickActive={talkingStickActive}
|
|
798
|
+
selfPeerId={peerId}
|
|
799
|
+
talkingStickHolder={talkingStickHolder}
|
|
800
|
+
onGiveTalkingStick={handleGiveTalkingStick}
|
|
801
|
+
onClose={() => setDrawerOpen(false)}
|
|
802
|
+
/>
|
|
803
|
+
)}
|
|
485
804
|
</div>
|
|
486
805
|
);
|
|
487
806
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The diagnostics panel, opened from the navbar for troubleshooting a call in progress - this browser's own
|
|
7
|
+
* capabilities, then each participant's connection: how their media reaches this one (the same badge
|
|
8
|
+
* `_ParticipantTile.tsx` shows, reused via `TRANSPORT_BADGES` rather than described in different words), and,
|
|
9
|
+
* once available, round-trip time and each kind's packet loss/jitter/bytes transferred
|
|
10
|
+
* (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
|
|
11
|
+
* comment). Purely informational and entirely local: nothing here is sent anywhere.
|
|
12
|
+
*
|
|
13
|
+
* `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up) and always
|
|
14
|
+
* absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
|
|
15
|
+
* "Not available" rather than a blank space, so it never looks like the panel forgot to load.
|
|
16
|
+
*/
|
|
17
|
+
import React from "react";
|
|
18
|
+
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
19
|
+
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
20
|
+
|
|
21
|
+
export interface DiagnosticsPanelProps {
|
|
22
|
+
selfName: string;
|
|
23
|
+
/** Whether the local microphone/camera are actually sending - `LocalMedia.micOn`/`cameraOn`. */
|
|
24
|
+
micOn: boolean;
|
|
25
|
+
cameraOn: boolean;
|
|
26
|
+
participants: MeshParticipant[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** This browser's support for what a call needs - checked once, since it cannot change mid-session. */
|
|
30
|
+
function browserCapabilities(): { label: string; supported: boolean }[] {
|
|
31
|
+
const has = (name: string): boolean => typeof window !== "undefined" && name in window;
|
|
32
|
+
return [
|
|
33
|
+
{ label: "WebRTC", supported: has("RTCPeerConnection") },
|
|
34
|
+
{ label: "Screen sharing", supported: typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia },
|
|
35
|
+
{ label: "WebCodecs (server relay fallback)", supported: has("VideoEncoder") && has("AudioEncoder") },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatMs(seconds: number | undefined): string {
|
|
40
|
+
return seconds === undefined ? "—" : `${Math.round(seconds * 1000)} ms`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatBytes(bytes: number): string {
|
|
44
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ROW = "flex justify-between gap-3 py-0.5";
|
|
48
|
+
const LABEL = "text-white/60";
|
|
49
|
+
|
|
50
|
+
function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }: { kind: string; packetsLost?: number; jitter?: number; bytesSent?: number; bytesReceived?: number }) {
|
|
51
|
+
if (packetsLost === undefined && jitter === undefined && bytesSent === undefined && bytesReceived === undefined) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<div className={ROW}>
|
|
56
|
+
<span className={LABEL}>{kind}</span>
|
|
57
|
+
<span>
|
|
58
|
+
{packetsLost !== undefined && `${packetsLost} lost`}
|
|
59
|
+
{jitter !== undefined && ` · ${formatMs(jitter)} jitter`}
|
|
60
|
+
{(bytesSent !== undefined || bytesReceived !== undefined) &&
|
|
61
|
+
` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`}
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }: DiagnosticsPanelProps) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="text-sm">
|
|
70
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5">This browser</p>
|
|
71
|
+
{browserCapabilities().map(({ label, supported }) => (
|
|
72
|
+
<div key={label} className={ROW}>
|
|
73
|
+
<span className={LABEL}>{label}</span>
|
|
74
|
+
<span className={supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]"}>{supported ? "Supported" : "Not supported"}</span>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
|
|
78
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5">
|
|
79
|
+
{selfName} <span className="text-white/60">(you)</span>
|
|
80
|
+
</p>
|
|
81
|
+
<div className={ROW}>
|
|
82
|
+
<span className={LABEL}>Sending</span>
|
|
83
|
+
<span>{[micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing"}</span>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{participants.length === 0 && <p className="text-white/60 mt-3">Nobody else is in the call yet.</p>}
|
|
87
|
+
{participants.map((p) => {
|
|
88
|
+
const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
|
|
89
|
+
return (
|
|
90
|
+
<div key={p.uid}>
|
|
91
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5">{p.name}</p>
|
|
92
|
+
<div className={ROW}>
|
|
93
|
+
<span className={LABEL}>Connection</span>
|
|
94
|
+
<span title={badge?.title}>{badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct")}</span>
|
|
95
|
+
</div>
|
|
96
|
+
{p.transport === "websocket" || !p.diagnostics ? (
|
|
97
|
+
<p className={`${LABEL} py-0.5`}>Not available{p.transport !== "websocket" && " yet"}.</p>
|
|
98
|
+
) : (
|
|
99
|
+
<>
|
|
100
|
+
<div className={ROW}>
|
|
101
|
+
<span className={LABEL}>Round-trip time</span>
|
|
102
|
+
<span>{formatMs(p.diagnostics.roundTripTimeSeconds)}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<StreamRow kind="Audio" {...p.diagnostics.audio} />
|
|
105
|
+
<StreamRow kind="Video" {...p.diagnostics.video} />
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -20,9 +20,17 @@ import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
|
20
20
|
import { MicOffIcon } from "./_icons.js";
|
|
21
21
|
|
|
22
22
|
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
23
|
-
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
24
|
-
|
|
23
|
+
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
24
|
+
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
25
|
+
* slightly different words. */
|
|
26
|
+
export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
|
|
25
27
|
turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
|
|
28
|
+
"turn-tcp": {
|
|
29
|
+
label: "Relayed (TCP)",
|
|
30
|
+
title:
|
|
31
|
+
"Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
|
|
32
|
+
"Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
|
|
33
|
+
},
|
|
26
34
|
websocket: {
|
|
27
35
|
label: "Server relay",
|
|
28
36
|
title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
|