@rapidmx/meet-plugin 0.10.0 → 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/_CallControls.tsx +11 -2
- package/apps/meet/_CallView.tsx +82 -0
- package/apps/meet/_ParticipantsDrawer.tsx +57 -1
- package/apps/shared/webrtc/MeshConnectionManager.ts +26 -0
- package/apps/shared/webrtc/types.ts +23 -12
- package/dist/apps/meet/_CallControls.d.ts +8 -2
- package/dist/apps/meet/_CallControls.js +5 -3
- package/dist/apps/meet/_CallView.d.ts +17 -0
- package/dist/apps/meet/_CallView.js +62 -2
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +17 -1
- package/dist/apps/meet/_ParticipantsDrawer.js +9 -4
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +19 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +25 -0
- package/dist/apps/shared/webrtc/types.d.ts +25 -11
- package/package.json +1 -1
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
13
13
|
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
14
|
-
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
|
|
14
|
+
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
15
|
+
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
16
|
+
* `_CallView.tsx`'s doc comment.
|
|
15
17
|
*/
|
|
16
18
|
import React, { useEffect, useRef, useState } from "react";
|
|
17
19
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
@@ -57,6 +59,10 @@ export interface CallControlsProps {
|
|
|
57
59
|
onReaction: (emoji: string) => void;
|
|
58
60
|
viewMode: CallViewMode;
|
|
59
61
|
onToggleViewMode: () => void;
|
|
62
|
+
/** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
|
|
63
|
+
* doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
|
|
64
|
+
* doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
|
|
65
|
+
micLocked?: boolean;
|
|
60
66
|
onLeave: () => void;
|
|
61
67
|
}
|
|
62
68
|
|
|
@@ -97,6 +103,7 @@ export default function CallControls({
|
|
|
97
103
|
onReaction,
|
|
98
104
|
viewMode,
|
|
99
105
|
onToggleViewMode,
|
|
106
|
+
micLocked,
|
|
100
107
|
onLeave,
|
|
101
108
|
}: CallControlsProps) {
|
|
102
109
|
const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
|
|
@@ -145,9 +152,11 @@ export default function CallControls({
|
|
|
145
152
|
</button>
|
|
146
153
|
<button
|
|
147
154
|
type="button"
|
|
148
|
-
className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`}
|
|
155
|
+
className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
149
156
|
aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
|
|
150
157
|
aria-pressed={!media.micOn}
|
|
158
|
+
disabled={micLocked}
|
|
159
|
+
title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
|
|
151
160
|
onClick={() => void media.toggleMic()}
|
|
152
161
|
>
|
|
153
162
|
{media.micOn && <LevelBars level={media.audioLevel} />}
|
package/apps/meet/_CallView.tsx
CHANGED
|
@@ -33,6 +33,23 @@
|
|
|
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";
|
|
@@ -185,6 +202,8 @@ export default function CallView({
|
|
|
185
202
|
const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
|
|
186
203
|
const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
|
|
187
204
|
const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
|
|
205
|
+
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
206
|
+
const [talkingStickHolder, setTalkingStickHolder] = useState<string | undefined>(undefined);
|
|
188
207
|
|
|
189
208
|
const managerRef = useRef<MeshConnectionManager | null>(null);
|
|
190
209
|
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
@@ -280,6 +299,10 @@ export default function CallView({
|
|
|
280
299
|
case "kicked":
|
|
281
300
|
onLeave("The host removed you from this call.");
|
|
282
301
|
return;
|
|
302
|
+
case "talking-stick-changed":
|
|
303
|
+
setTalkingStickActive(event.active);
|
|
304
|
+
setTalkingStickHolder(event.holder);
|
|
305
|
+
return;
|
|
283
306
|
}
|
|
284
307
|
});
|
|
285
308
|
|
|
@@ -337,6 +360,25 @@ export default function CallView({
|
|
|
337
360
|
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
338
361
|
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
339
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
|
+
|
|
340
382
|
useEffect(() => {
|
|
341
383
|
if (!presenterUid) {
|
|
342
384
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -462,6 +504,19 @@ export default function CallView({
|
|
|
462
504
|
apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
|
|
463
505
|
}
|
|
464
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
|
+
|
|
465
520
|
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
466
521
|
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
467
522
|
* delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
|
|
@@ -524,6 +579,13 @@ export default function CallView({
|
|
|
524
579
|
: undefined;
|
|
525
580
|
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
526
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;
|
|
527
589
|
const alone = participants.length === 0;
|
|
528
590
|
// This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
|
|
529
591
|
// and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
|
|
@@ -614,6 +676,21 @@ export default function CallView({
|
|
|
614
676
|
✋ {raisedNames.join(", ")}
|
|
615
677
|
</span>
|
|
616
678
|
)}
|
|
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
|
+
)}
|
|
617
694
|
<button
|
|
618
695
|
type="button"
|
|
619
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"
|
|
@@ -662,6 +739,7 @@ export default function CallView({
|
|
|
662
739
|
onReaction={handleReaction}
|
|
663
740
|
viewMode={viewMode}
|
|
664
741
|
onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
|
|
742
|
+
micLocked={micLocked}
|
|
665
743
|
onLeave={onLeave}
|
|
666
744
|
/>
|
|
667
745
|
</footer>
|
|
@@ -716,6 +794,10 @@ export default function CallView({
|
|
|
716
794
|
waitingParticipants={waitingParticipants}
|
|
717
795
|
onAdmit={handleAdmitParticipant}
|
|
718
796
|
onDeny={handleDenyParticipant}
|
|
797
|
+
talkingStickActive={talkingStickActive}
|
|
798
|
+
selfPeerId={peerId}
|
|
799
|
+
talkingStickHolder={talkingStickHolder}
|
|
800
|
+
onGiveTalkingStick={handleGiveTalkingStick}
|
|
719
801
|
onClose={() => setDrawerOpen(false)}
|
|
720
802
|
/>
|
|
721
803
|
)}
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
* `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
|
|
26
26
|
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
27
27
|
* the host needing to close and reopen the drawer.
|
|
28
|
+
*
|
|
29
|
+
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
30
|
+
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
31
|
+
* the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
|
|
32
|
+
* on talking-stick mode for what granting it actually does to the recipient's microphone.
|
|
28
33
|
*/
|
|
29
34
|
import React, { useEffect, useState } from "react";
|
|
30
35
|
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
@@ -63,6 +68,17 @@ export interface ParticipantsDrawerProps {
|
|
|
63
68
|
waitingParticipants: WaitingParticipant[];
|
|
64
69
|
onAdmit: (uid: string) => void;
|
|
65
70
|
onDeny: (uid: string) => void;
|
|
71
|
+
/** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
|
|
72
|
+
* doc comment on talking-stick mode). */
|
|
73
|
+
talkingStickActive: boolean;
|
|
74
|
+
/** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
|
|
75
|
+
* wants to take the stick back. */
|
|
76
|
+
selfPeerId: string;
|
|
77
|
+
/** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
|
|
78
|
+
* undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
|
|
79
|
+
talkingStickHolder?: string;
|
|
80
|
+
/** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
|
|
81
|
+
onGiveTalkingStick: (uid: string) => void;
|
|
66
82
|
onClose: () => void;
|
|
67
83
|
}
|
|
68
84
|
|
|
@@ -187,6 +203,9 @@ function Row({
|
|
|
187
203
|
canModerate,
|
|
188
204
|
onMute,
|
|
189
205
|
onKick,
|
|
206
|
+
hasStick,
|
|
207
|
+
canGiveStick,
|
|
208
|
+
onGiveStick,
|
|
190
209
|
}: {
|
|
191
210
|
name: string;
|
|
192
211
|
isSelf?: boolean;
|
|
@@ -198,6 +217,12 @@ function Row({
|
|
|
198
217
|
canModerate?: boolean;
|
|
199
218
|
onMute?: () => void;
|
|
200
219
|
onKick?: () => void;
|
|
220
|
+
/** Whether this row currently holds the talking stick - shown to every viewer, not just the host. */
|
|
221
|
+
hasStick?: boolean;
|
|
222
|
+
/** Whether to show this row's "Give stick" button - `isSelfHost && talkingStickActive`, decided by the caller;
|
|
223
|
+
* never shown on the row that already holds it. */
|
|
224
|
+
canGiveStick?: boolean;
|
|
225
|
+
onGiveStick?: () => void;
|
|
201
226
|
}) {
|
|
202
227
|
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
203
228
|
return (
|
|
@@ -207,6 +232,11 @@ function Row({
|
|
|
207
232
|
{isSelf ? " (you)" : ""}
|
|
208
233
|
</span>
|
|
209
234
|
{isHost && <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]">Host</span>}
|
|
235
|
+
{hasStick && (
|
|
236
|
+
<span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]" role="img" aria-label="Holding the talking stick">
|
|
237
|
+
🎙️
|
|
238
|
+
</span>
|
|
239
|
+
)}
|
|
210
240
|
{handRaised && (
|
|
211
241
|
<span role="img" aria-label="Hand raised">
|
|
212
242
|
✋
|
|
@@ -225,6 +255,16 @@ function Row({
|
|
|
225
255
|
{badge.label}
|
|
226
256
|
</span>
|
|
227
257
|
)}
|
|
258
|
+
{canGiveStick && !hasStick && (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
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"
|
|
262
|
+
aria-label={`Give the talking stick to ${name}`}
|
|
263
|
+
onClick={onGiveStick}
|
|
264
|
+
>
|
|
265
|
+
Give stick
|
|
266
|
+
</button>
|
|
267
|
+
)}
|
|
228
268
|
{canModerate && !micMuted && (
|
|
229
269
|
<button
|
|
230
270
|
type="button"
|
|
@@ -271,6 +311,10 @@ export default function ParticipantsDrawer({
|
|
|
271
311
|
waitingParticipants,
|
|
272
312
|
onAdmit,
|
|
273
313
|
onDeny,
|
|
314
|
+
talkingStickActive,
|
|
315
|
+
selfPeerId,
|
|
316
|
+
talkingStickHolder,
|
|
317
|
+
onGiveTalkingStick,
|
|
274
318
|
onClose,
|
|
275
319
|
}: ParticipantsDrawerProps) {
|
|
276
320
|
useEffect(() => {
|
|
@@ -320,7 +364,16 @@ export default function ParticipantsDrawer({
|
|
|
320
364
|
<WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
|
|
321
365
|
)}
|
|
322
366
|
<ul className="flex-1 min-h-0 overflow-y-auto p-2 text-sm">
|
|
323
|
-
<Row
|
|
367
|
+
<Row
|
|
368
|
+
name={selfName}
|
|
369
|
+
isSelf
|
|
370
|
+
isHost={isSelfHost}
|
|
371
|
+
micMuted={!micOn}
|
|
372
|
+
handRaised={handRaised}
|
|
373
|
+
hasStick={talkingStickActive && talkingStickHolder === selfPeerId}
|
|
374
|
+
canGiveStick={isSelfHost && talkingStickActive}
|
|
375
|
+
onGiveStick={() => onGiveTalkingStick(selfPeerId)}
|
|
376
|
+
/>
|
|
324
377
|
{participants.map((p) => (
|
|
325
378
|
<Row
|
|
326
379
|
key={p.uid}
|
|
@@ -332,6 +385,9 @@ export default function ParticipantsDrawer({
|
|
|
332
385
|
canModerate={isSelfHost}
|
|
333
386
|
onMute={() => onMute(p.uid)}
|
|
334
387
|
onKick={() => onKick(p.uid)}
|
|
388
|
+
hasStick={talkingStickActive && talkingStickHolder === p.uid}
|
|
389
|
+
canGiveStick={isSelfHost && talkingStickActive}
|
|
390
|
+
onGiveStick={() => onGiveTalkingStick(p.uid)}
|
|
335
391
|
/>
|
|
336
392
|
))}
|
|
337
393
|
</ul>
|
|
@@ -71,6 +71,19 @@
|
|
|
71
71
|
* manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
|
|
72
72
|
* participant's actual departure is `stop()`, which the caller decides to call.
|
|
73
73
|
*
|
|
74
|
+
* ## Talking stick (`talking-stick`)
|
|
75
|
+
*
|
|
76
|
+
* One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
|
|
77
|
+
* there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
|
|
78
|
+
* `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
|
|
79
|
+
* simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
|
|
80
|
+
* local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
|
|
81
|
+
* leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
|
|
82
|
+
* already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
|
|
83
|
+
* right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
|
|
84
|
+
* backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
|
|
85
|
+
* actually in the call together.
|
|
86
|
+
*
|
|
74
87
|
* ## Three media paths, tried in order
|
|
75
88
|
*
|
|
76
89
|
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
@@ -319,6 +332,16 @@ export class MeshConnectionManager {
|
|
|
319
332
|
this.send({ kind: "kicked", to: peerUid });
|
|
320
333
|
}
|
|
321
334
|
|
|
335
|
+
/** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
|
|
336
|
+
* omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
|
|
337
|
+
* there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
|
|
338
|
+
* there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
|
|
339
|
+
* host. */
|
|
340
|
+
setTalkingStick(active: boolean, holder?: string): void {
|
|
341
|
+
this.send({ kind: "talking-stick", active, holder });
|
|
342
|
+
this.emit({ type: "talking-stick-changed", active, holder });
|
|
343
|
+
}
|
|
344
|
+
|
|
322
345
|
private sendHello(): void {
|
|
323
346
|
this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
|
|
324
347
|
}
|
|
@@ -385,6 +408,9 @@ export class MeshConnectionManager {
|
|
|
385
408
|
case "kicked":
|
|
386
409
|
this.emit({ type: "kicked" });
|
|
387
410
|
return;
|
|
411
|
+
case "talking-stick":
|
|
412
|
+
this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
|
|
413
|
+
return;
|
|
388
414
|
}
|
|
389
415
|
}
|
|
390
416
|
|
|
@@ -28,17 +28,18 @@ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮"
|
|
|
28
28
|
* literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
|
|
29
29
|
* grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
|
|
30
30
|
*
|
|
31
|
-
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left
|
|
32
|
-
* participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
|
|
33
|
-
* intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
|
|
34
|
-
* every message out to every subscriber, sender included.
|
|
31
|
+
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
|
|
32
|
+
* unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
|
|
33
|
+
* point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
|
|
34
|
+
* since a channel's pub/sub fans every message out to every subscriber, sender included.
|
|
35
35
|
*
|
|
36
|
-
* `mute-request`/`kicked` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
|
|
37
|
-
* cooperative, like every other message here: the recipient's own client decides whether to honor one, and
|
|
38
|
-
* on the wire distinguishes a genuine host's message from any other participant's, since this client
|
|
39
|
-
* no host identity of its own to check against server-side. `kicked` is paired with the sender also
|
|
40
|
-
* recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
|
|
41
|
-
* not depend on the recipient's client cooperating
|
|
36
|
+
* `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
|
|
37
|
+
* purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
|
|
38
|
+
* nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
|
|
39
|
+
* protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
|
|
40
|
+
* revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
|
|
41
|
+
* enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
|
|
42
|
+
* counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
|
|
42
43
|
*/
|
|
43
44
|
export interface SignalMessage {
|
|
44
45
|
type: "video-meeting-signal";
|
|
@@ -54,7 +55,8 @@ export interface SignalMessage {
|
|
|
54
55
|
| "reaction"
|
|
55
56
|
| "relay-fallback"
|
|
56
57
|
| "mute-request"
|
|
57
|
-
| "kicked"
|
|
58
|
+
| "kicked"
|
|
59
|
+
| "talking-stick";
|
|
58
60
|
/** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
59
61
|
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
60
62
|
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
@@ -73,6 +75,11 @@ export interface SignalMessage {
|
|
|
73
75
|
state?: ParticipantState;
|
|
74
76
|
/** `reaction` only - one of `REACTION_EMOJIS`. */
|
|
75
77
|
emoji?: string;
|
|
78
|
+
/** `talking-stick` only - whether talking-stick mode is currently on. */
|
|
79
|
+
active?: boolean;
|
|
80
|
+
/** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
|
|
81
|
+
* Absent when `active` is `false`, or when nobody holds it yet. */
|
|
82
|
+
holder?: string;
|
|
76
83
|
/** `offer`/`answer` only. */
|
|
77
84
|
sdp?: RTCSessionDescriptionInit;
|
|
78
85
|
/** `ice-candidate` only. */
|
|
@@ -208,4 +215,8 @@ export type MeshEvent =
|
|
|
208
215
|
| { type: "mute-requested" }
|
|
209
216
|
/** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
|
|
210
217
|
* "removed by the host" state rather than the ordinary "you left" one. */
|
|
211
|
-
| { type: "kicked" }
|
|
218
|
+
| { type: "kicked" }
|
|
219
|
+
/** Talking-stick mode turned on or off, or the stick changed hands - see this module's doc comment and
|
|
220
|
+
* `MeshConnectionManager.setTalkingStick()`. `holder` is the peer id now holding it; absent while `active` is
|
|
221
|
+
* `false`, or (transiently) if whoever held it has since left the call. */
|
|
222
|
+
| { type: "talking-stick-changed"; active: boolean; holder?: string };
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
9
9
|
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
10
|
-
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
|
|
10
|
+
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
11
|
+
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
12
|
+
* `_CallView.tsx`'s doc comment.
|
|
11
13
|
*/
|
|
12
14
|
import React from "react";
|
|
13
15
|
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
@@ -31,10 +33,14 @@ export interface CallControlsProps {
|
|
|
31
33
|
onReaction: (emoji: string) => void;
|
|
32
34
|
viewMode: CallViewMode;
|
|
33
35
|
onToggleViewMode: () => void;
|
|
36
|
+
/** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
|
|
37
|
+
* doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
|
|
38
|
+
* doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
|
|
39
|
+
micLocked?: boolean;
|
|
34
40
|
onLeave: () => void;
|
|
35
41
|
}
|
|
36
42
|
/** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
|
|
37
43
|
export declare function LevelBars({ level }: {
|
|
38
44
|
level: number;
|
|
39
45
|
}): React.JSX.Element;
|
|
40
|
-
export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
46
|
+
export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
@@ -12,7 +12,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
12
12
|
*
|
|
13
13
|
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
14
14
|
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
15
|
-
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
|
|
15
|
+
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
16
|
+
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
17
|
+
* `_CallView.tsx`'s doc comment.
|
|
16
18
|
*/
|
|
17
19
|
import { useEffect, useRef, useState } from "react";
|
|
18
20
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
@@ -28,7 +30,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
|
28
30
|
export function LevelBars({ level }) {
|
|
29
31
|
return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
|
|
30
32
|
}
|
|
31
|
-
export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
|
|
33
|
+
export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
|
|
32
34
|
const [openMenu, setOpenMenu] = useState(null);
|
|
33
35
|
const barRef = useRef(null);
|
|
34
36
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
@@ -57,7 +59,7 @@ export default function CallControls({ media, selfName, participants, isPresenti
|
|
|
57
59
|
function toggleMenu(menu) {
|
|
58
60
|
setOpenMenu((prev) => (prev === menu ? null : menu));
|
|
59
61
|
}
|
|
60
|
-
return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
|
|
62
|
+
return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, disabled: micLocked, title: micLocked ? "Only the current talking-stick holder can unmute." : undefined, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
|
|
61
63
|
onReaction(emoji);
|
|
62
64
|
setOpenMenu(null);
|
|
63
65
|
}, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`, "aria-label": "Call diagnostics", "aria-haspopup": "dialog", "aria-expanded": openMenu === "diagnostics", onClick: () => toggleMenu("diagnostics"), children: _jsx(DiagnosticsIcon, {}) }), openMenu === "diagnostics" && (_jsx("div", { role: "dialog", "aria-label": "Call diagnostics", className: "absolute bottom-full mb-3 right-0 w-80 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
|
|
@@ -29,6 +29,23 @@
|
|
|
29
29
|
* auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
|
|
30
30
|
* slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
|
|
31
31
|
* empty main slot once someone else has joined.
|
|
32
|
+
*
|
|
33
|
+
* ## Talking stick mode
|
|
34
|
+
*
|
|
35
|
+
* A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
|
|
36
|
+
* be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
|
|
37
|
+
* else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
|
|
38
|
+
* the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
|
|
39
|
+
* `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
|
|
40
|
+
* `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
|
|
41
|
+
* *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
|
|
42
|
+
* tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
|
|
43
|
+
* (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
|
|
44
|
+
* 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
|
|
45
|
+
* suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
|
|
46
|
+
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
47
|
+
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
48
|
+
* the floor, until the host picks someone (or themselves) again.
|
|
32
49
|
*/
|
|
33
50
|
import React from "react";
|
|
34
51
|
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
@@ -34,6 +34,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
34
34
|
* auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
|
|
35
35
|
* slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
|
|
36
36
|
* empty main slot once someone else has joined.
|
|
37
|
+
*
|
|
38
|
+
* ## Talking stick mode
|
|
39
|
+
*
|
|
40
|
+
* A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
|
|
41
|
+
* be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
|
|
42
|
+
* else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
|
|
43
|
+
* the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
|
|
44
|
+
* `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
|
|
45
|
+
* `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
|
|
46
|
+
* *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
|
|
47
|
+
* tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
|
|
48
|
+
* (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
|
|
49
|
+
* 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
|
|
50
|
+
* suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
|
|
51
|
+
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
52
|
+
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
53
|
+
* the floor, until the host picks someone (or themselves) again.
|
|
37
54
|
*/
|
|
38
55
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
39
56
|
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
@@ -107,6 +124,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
107
124
|
const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
|
|
108
125
|
const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
|
|
109
126
|
const [waitingParticipants, setWaitingParticipants] = useState([]);
|
|
127
|
+
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
128
|
+
const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
|
|
110
129
|
const managerRef = useRef(null);
|
|
111
130
|
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
112
131
|
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
@@ -196,6 +215,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
196
215
|
case "kicked":
|
|
197
216
|
onLeave("The host removed you from this call.");
|
|
198
217
|
return;
|
|
218
|
+
case "talking-stick-changed":
|
|
219
|
+
setTalkingStickActive(event.active);
|
|
220
|
+
setTalkingStickHolder(event.holder);
|
|
221
|
+
return;
|
|
199
222
|
}
|
|
200
223
|
});
|
|
201
224
|
client
|
|
@@ -248,6 +271,25 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
248
271
|
useEffect(() => {
|
|
249
272
|
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
250
273
|
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
274
|
+
// Talking-stick mode forces exactly one microphone on at a time: whoever just became the holder (including the
|
|
275
|
+
// host, on activation) is unmuted; everyone else is muted. Unlike `mute-requested`'s one-time nudge, this is
|
|
276
|
+
// paired with disabling the mic button itself (`micLocked`, passed to `CallControls` below) while it applies,
|
|
277
|
+
// so it isn't merely a suggestion - though still cooperative at the signaling layer, like every other
|
|
278
|
+
// moderation feature here (see `MeshConnectionManager`'s doc comment). Turning the mode off touches nobody's
|
|
279
|
+
// mic - states are left exactly as they are, the same "never retroactive" posture as every other host toggle.
|
|
280
|
+
const selfHasTalkingStick = talkingStickActive && talkingStickHolder === peerId;
|
|
281
|
+
const micLocked = talkingStickActive && !selfHasTalkingStick;
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!talkingStickActive) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (selfHasTalkingStick && !mediaRef.current.micOn) {
|
|
287
|
+
void mediaRef.current.toggleMic();
|
|
288
|
+
}
|
|
289
|
+
else if (!selfHasTalkingStick && mediaRef.current.micOn) {
|
|
290
|
+
void mediaRef.current.toggleMic();
|
|
291
|
+
}
|
|
292
|
+
}, [talkingStickActive, selfHasTalkingStick]);
|
|
251
293
|
useEffect(() => {
|
|
252
294
|
if (!presenterUid) {
|
|
253
295
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -361,6 +403,17 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
361
403
|
setWaitingRoomEnabledState(next);
|
|
362
404
|
apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
|
|
363
405
|
}
|
|
406
|
+
/** Starts or stops talking-stick mode - host-only, see this module's doc comment. Starting makes the host the
|
|
407
|
+
* initial holder; stopping clears the holder too, leaving mic states exactly as they are rather than
|
|
408
|
+
* retroactively unmuting anyone. */
|
|
409
|
+
function handleToggleTalkingStick() {
|
|
410
|
+
managerRef.current?.setTalkingStick(!talkingStickActive, talkingStickActive ? undefined : peerId);
|
|
411
|
+
}
|
|
412
|
+
/** Hands the talking stick to `uid` (a participant's `MeshParticipant.uid`, or this tab's own `peerId` for the
|
|
413
|
+
* host taking it back) - host-only, called from the participants drawer's per-row "Give stick" button. */
|
|
414
|
+
function handleGiveTalkingStick(uid) {
|
|
415
|
+
managerRef.current?.setTalkingStick(true, uid);
|
|
416
|
+
}
|
|
364
417
|
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
365
418
|
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
366
419
|
* delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
|
|
@@ -418,6 +471,13 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
418
471
|
: undefined;
|
|
419
472
|
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
420
473
|
const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
|
|
474
|
+
// Absent (not "Someone") once the holder has left the call - see `MeshConnectionManager`'s doc comment on why
|
|
475
|
+
// nothing reassigns it automatically; the header then says so rather than naming someone no longer here.
|
|
476
|
+
const talkingStickHolderName = !talkingStickActive
|
|
477
|
+
? undefined
|
|
478
|
+
: selfHasTalkingStick
|
|
479
|
+
? "You"
|
|
480
|
+
: participants.find((p) => p.uid === talkingStickHolder)?.name;
|
|
421
481
|
const alone = participants.length === 0;
|
|
422
482
|
// This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
|
|
423
483
|
// and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
|
|
@@ -441,10 +501,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
441
501
|
else {
|
|
442
502
|
stage = (_jsx("div", { className: "h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: participants.map((p) => remoteTile(p, "h-full")) }));
|
|
443
503
|
}
|
|
444
|
-
return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), _jsx("button", { type: "button", 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", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, selfName: selfName, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
|
|
504
|
+
return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), talkingStickActive && (_jsxs("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", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", 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]"}`, "aria-pressed": talkingStickActive, onClick: handleToggleTalkingStick, children: talkingStickActive ? "End talking stick" : "Talking stick" })), _jsx("button", { type: "button", 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", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, selfName: selfName, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), micLocked: micLocked, onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
|
|
445
505
|
const stream = remoteStreams[p.uid];
|
|
446
506
|
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
447
|
-
}), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, onClose: () => setDrawerOpen(false) }))] }));
|
|
507
|
+
}), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) }))] }));
|
|
448
508
|
}
|
|
449
509
|
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
450
510
|
* refuses to start it (an autoplay policy) - the view then asks for a click. */
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
|
|
22
22
|
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
23
23
|
* the host needing to close and reopen the drawer.
|
|
24
|
+
*
|
|
25
|
+
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
26
|
+
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
27
|
+
* the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
|
|
28
|
+
* on talking-stick mode for what granting it actually does to the recipient's microphone.
|
|
24
29
|
*/
|
|
25
30
|
import React from "react";
|
|
26
31
|
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
@@ -56,6 +61,17 @@ export interface ParticipantsDrawerProps {
|
|
|
56
61
|
waitingParticipants: WaitingParticipant[];
|
|
57
62
|
onAdmit: (uid: string) => void;
|
|
58
63
|
onDeny: (uid: string) => void;
|
|
64
|
+
/** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
|
|
65
|
+
* doc comment on talking-stick mode). */
|
|
66
|
+
talkingStickActive: boolean;
|
|
67
|
+
/** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
|
|
68
|
+
* wants to take the stick back. */
|
|
69
|
+
selfPeerId: string;
|
|
70
|
+
/** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
|
|
71
|
+
* undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
|
|
72
|
+
talkingStickHolder?: string;
|
|
73
|
+
/** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
|
|
74
|
+
onGiveTalkingStick: (uid: string) => void;
|
|
59
75
|
onClose: () => void;
|
|
60
76
|
}
|
|
61
|
-
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
|
|
77
|
+
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, }: ParticipantsDrawerProps): React.JSX.Element;
|
|
@@ -26,6 +26,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
26
26
|
* `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
|
|
27
27
|
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
28
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.
|
|
29
34
|
*/
|
|
30
35
|
import { useEffect, useState } from "react";
|
|
31
36
|
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
@@ -64,15 +69,15 @@ function PasswordSection({ hasPassword, onSetPassword }) {
|
|
|
64
69
|
function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
|
|
65
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))) }))] }));
|
|
66
71
|
}
|
|
67
|
-
function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, }) {
|
|
72
|
+
function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
|
|
68
73
|
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
69
|
-
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" }), 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 })), 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: () => {
|
|
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: () => {
|
|
70
75
|
if (window.confirm(`Remove ${name} from the call?`)) {
|
|
71
76
|
onKick?.();
|
|
72
77
|
}
|
|
73
78
|
}, children: "Remove" }))] }));
|
|
74
79
|
}
|
|
75
|
-
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, onClose, }) {
|
|
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, }) {
|
|
76
81
|
useEffect(() => {
|
|
77
82
|
const onKeyDown = (event) => {
|
|
78
83
|
if (event.key === "Escape") {
|
|
@@ -82,5 +87,5 @@ export default function ParticipantsDrawer({ selfName, micOn, handRaised, partic
|
|
|
82
87
|
document.addEventListener("keydown", onKeyDown);
|
|
83
88
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
84
89
|
}, [onClose]);
|
|
85
|
-
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 }), 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) }, p.uid)))] })] })] }));
|
|
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)))] })] })] }));
|
|
86
91
|
}
|
|
@@ -67,6 +67,19 @@
|
|
|
67
67
|
* manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
|
|
68
68
|
* participant's actual departure is `stop()`, which the caller decides to call.
|
|
69
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
|
+
*
|
|
70
83
|
* ## Three media paths, tried in order
|
|
71
84
|
*
|
|
72
85
|
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
@@ -176,6 +189,12 @@ export declare class MeshConnectionManager {
|
|
|
176
189
|
* does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
|
|
177
190
|
* which the caller is expected to call alongside this for an enforced removal). */
|
|
178
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;
|
|
179
198
|
private sendHello;
|
|
180
199
|
private send;
|
|
181
200
|
private emit;
|
|
@@ -71,6 +71,19 @@
|
|
|
71
71
|
* manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
|
|
72
72
|
* participant's actual departure is `stop()`, which the caller decides to call.
|
|
73
73
|
*
|
|
74
|
+
* ## Talking stick (`talking-stick`)
|
|
75
|
+
*
|
|
76
|
+
* One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
|
|
77
|
+
* there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
|
|
78
|
+
* `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
|
|
79
|
+
* simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
|
|
80
|
+
* local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
|
|
81
|
+
* leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
|
|
82
|
+
* already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
|
|
83
|
+
* right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
|
|
84
|
+
* backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
|
|
85
|
+
* actually in the call together.
|
|
86
|
+
*
|
|
74
87
|
* ## Three media paths, tried in order
|
|
75
88
|
*
|
|
76
89
|
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
@@ -235,6 +248,15 @@ export class MeshConnectionManager {
|
|
|
235
248
|
sendKick(peerUid) {
|
|
236
249
|
this.send({ kind: "kicked", to: peerUid });
|
|
237
250
|
}
|
|
251
|
+
/** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
|
|
252
|
+
* omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
|
|
253
|
+
* there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
|
|
254
|
+
* there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
|
|
255
|
+
* host. */
|
|
256
|
+
setTalkingStick(active, holder) {
|
|
257
|
+
this.send({ kind: "talking-stick", active, holder });
|
|
258
|
+
this.emit({ type: "talking-stick-changed", active, holder });
|
|
259
|
+
}
|
|
238
260
|
sendHello() {
|
|
239
261
|
this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
|
|
240
262
|
}
|
|
@@ -298,6 +320,9 @@ export class MeshConnectionManager {
|
|
|
298
320
|
case "kicked":
|
|
299
321
|
this.emit({ type: "kicked" });
|
|
300
322
|
return;
|
|
323
|
+
case "talking-stick":
|
|
324
|
+
this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
|
|
325
|
+
return;
|
|
301
326
|
}
|
|
302
327
|
}
|
|
303
328
|
handleHello(message) {
|
|
@@ -21,21 +21,22 @@ export declare const REACTION_EMOJIS: readonly ["👍", "👏", "❤️", "🎉"
|
|
|
21
21
|
* literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
|
|
22
22
|
* grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
|
|
23
23
|
*
|
|
24
|
-
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left
|
|
25
|
-
* participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
|
|
26
|
-
* intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
|
|
27
|
-
* every message out to every subscriber, sender included.
|
|
24
|
+
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
|
|
25
|
+
* unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
|
|
26
|
+
* point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
|
|
27
|
+
* since a channel's pub/sub fans every message out to every subscriber, sender included.
|
|
28
28
|
*
|
|
29
|
-
* `mute-request`/`kicked` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
|
|
30
|
-
* cooperative, like every other message here: the recipient's own client decides whether to honor one, and
|
|
31
|
-
* on the wire distinguishes a genuine host's message from any other participant's, since this client
|
|
32
|
-
* no host identity of its own to check against server-side. `kicked` is paired with the sender also
|
|
33
|
-
* recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
|
|
34
|
-
* not depend on the recipient's client cooperating
|
|
29
|
+
* `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
|
|
30
|
+
* purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
|
|
31
|
+
* nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
|
|
32
|
+
* protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
|
|
33
|
+
* revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
|
|
34
|
+
* enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
|
|
35
|
+
* counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
|
|
35
36
|
*/
|
|
36
37
|
export interface SignalMessage {
|
|
37
38
|
type: "video-meeting-signal";
|
|
38
|
-
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback" | "mute-request" | "kicked";
|
|
39
|
+
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback" | "mute-request" | "kicked" | "talking-stick";
|
|
39
40
|
/** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
40
41
|
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
41
42
|
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
@@ -54,6 +55,11 @@ export interface SignalMessage {
|
|
|
54
55
|
state?: ParticipantState;
|
|
55
56
|
/** `reaction` only - one of `REACTION_EMOJIS`. */
|
|
56
57
|
emoji?: string;
|
|
58
|
+
/** `talking-stick` only - whether talking-stick mode is currently on. */
|
|
59
|
+
active?: boolean;
|
|
60
|
+
/** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
|
|
61
|
+
* Absent when `active` is `false`, or when nobody holds it yet. */
|
|
62
|
+
holder?: string;
|
|
57
63
|
/** `offer`/`answer` only. */
|
|
58
64
|
sdp?: RTCSessionDescriptionInit;
|
|
59
65
|
/** `ice-candidate` only. */
|
|
@@ -209,4 +215,12 @@ export type MeshEvent = {
|
|
|
209
215
|
* "removed by the host" state rather than the ordinary "you left" one. */
|
|
210
216
|
| {
|
|
211
217
|
type: "kicked";
|
|
218
|
+
}
|
|
219
|
+
/** Talking-stick mode turned on or off, or the stick changed hands - see this module's doc comment and
|
|
220
|
+
* `MeshConnectionManager.setTalkingStick()`. `holder` is the peer id now holding it; absent while `active` is
|
|
221
|
+
* `false`, or (transiently) if whoever held it has since left the call. */
|
|
222
|
+
| {
|
|
223
|
+
type: "talking-stick-changed";
|
|
224
|
+
active: boolean;
|
|
225
|
+
holder?: string;
|
|
212
226
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rapidmx/meet-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "RapidMX plugin that provides WebRTC video conferencing, joinable from a calendar invite or a shared link.",
|
|
5
5
|
"repository": "https://github.com/rapidmx/meet-plugin.git",
|
|
6
6
|
"author": "RapidREST <rapidrests@gmail.com>",
|