@rapidmx/meet-plugin 0.14.1 → 0.15.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 +22 -2
- package/apps/meet/_CallView.tsx +41 -8
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_ParticipantTile.tsx +82 -9
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_icons.tsx +10 -0
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +52 -1
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +19 -0
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +25 -3
- package/apps/shared/webrtc/types.ts +71 -2
- package/dist/apps/meet/_CallControls.d.ts +10 -3
- package/dist/apps/meet/_CallControls.js +10 -4
- package/dist/apps/meet/_CallView.d.ts +18 -4
- package/dist/apps/meet/_CallView.js +36 -9
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +9 -2
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_icons.d.ts +4 -0
- package/dist/apps/meet/_icons.js +4 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +44 -1
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +4 -0
- package/dist/apps/shared/relay/RelayTransport.js +14 -0
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +20 -1
- package/dist/apps/shared/webrtc/types.d.ts +66 -2
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/package.json +1 -1
|
@@ -16,10 +16,24 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
*
|
|
17
17
|
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
18
18
|
* is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
|
|
19
|
-
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
20
|
+
*
|
|
21
|
+
* ## Where the local tile appears
|
|
22
|
+
*
|
|
23
|
+
* In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
|
|
24
|
+
* everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
|
|
25
|
+
* genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
|
|
26
|
+
* tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
|
|
27
|
+
* is reserved for focus/presentation layouts, where there's already a single large tile the local one would
|
|
28
|
+
* otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
|
|
29
|
+
* (see the `alone` branch of the `stage` computation).
|
|
30
|
+
*
|
|
31
|
+
* `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
|
|
32
|
+
* it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
|
|
33
|
+
* directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
|
|
34
|
+
* "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
|
|
35
|
+
* nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
|
|
36
|
+
* just blank it, so the local tile ignores `selfHidden` there and always shows.
|
|
23
37
|
*
|
|
24
38
|
* ## Who is heard
|
|
25
39
|
*
|
|
@@ -145,6 +159,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
145
159
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
|
146
160
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
147
161
|
const [transportMode, setTransportModeState] = useState("auto");
|
|
162
|
+
const [selfHidden, setSelfHidden] = useState(false);
|
|
148
163
|
const managerRef = useRef(null);
|
|
149
164
|
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
150
165
|
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
@@ -375,6 +390,15 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
375
390
|
function handleToggleHand() {
|
|
376
391
|
setHandRaised((prev) => !prev);
|
|
377
392
|
}
|
|
393
|
+
/** Hides the local tile - the tile's own "…" menu calls this directly (one-directional: there is no "show
|
|
394
|
+
* self" from in-grid, since a hidden tile isn't there to click on). Re-showing it is the navbar "…" menu's
|
|
395
|
+
* `handleToggleSelfHidden()` instead. */
|
|
396
|
+
function handleHideSelf() {
|
|
397
|
+
setSelfHidden(true);
|
|
398
|
+
}
|
|
399
|
+
function handleToggleSelfHidden() {
|
|
400
|
+
setSelfHidden((prev) => !prev);
|
|
401
|
+
}
|
|
378
402
|
function handleReaction(emoji) {
|
|
379
403
|
if (managerRef.current?.sendReaction(emoji)) {
|
|
380
404
|
showReaction(emoji, "You");
|
|
@@ -510,24 +534,27 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
510
534
|
// so someone already in the call is not told they are "connecting" each time a newcomer arrives.
|
|
511
535
|
const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
|
|
512
536
|
const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, status: p.transport === "connecting" ? "Awaiting connection…" : undefined, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
|
|
513
|
-
const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
|
|
537
|
+
const selfTile = (className, canHide) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
|
|
514
538
|
// A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
|
|
515
539
|
// to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
|
|
516
|
-
mirrored: media.filters.background !== "image", className: className }));
|
|
540
|
+
mirrored: media.filters.background !== "image", onHideSelf: canHide ? handleHideSelf : undefined, className: className }));
|
|
517
541
|
const mainParticipant = participants.find((p) => p.uid === mainUid);
|
|
518
542
|
const thumbnails = participants.filter((p) => p.uid !== mainUid);
|
|
519
543
|
let stage;
|
|
520
544
|
if (alone) {
|
|
521
|
-
// Nobody else yet: the local participant fills the tile area, presenting or not
|
|
545
|
+
// Nobody else yet: the local participant fills the tile area, presenting or not - and is never hideable,
|
|
546
|
+
// since there'd be nothing left to look at (see this module's doc comment on hiding the local tile).
|
|
522
547
|
stage = isPresenting ? (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) : (selfTile("h-full"));
|
|
523
548
|
}
|
|
524
549
|
else if (showFocusLayout && (mainParticipant || isPresenting)) {
|
|
525
550
|
stage = (_jsxs("div", { className: "h-full flex flex-col gap-2", children: [_jsx("div", { className: "flex-1 min-h-0", "data-testid": "main-tile", children: mainParticipant ? (remoteTile(mainParticipant, "h-full")) : (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) }), thumbnails.length > 0 && (_jsx("div", { className: "flex gap-2 overflow-x-auto h-24 shrink-0", "data-testid": "thumbnails", children: thumbnails.map((p) => (_jsx("div", { className: "w-36 shrink-0", children: remoteTile(p, "h-full") }, p.uid))) }))] }));
|
|
526
551
|
}
|
|
527
552
|
else {
|
|
528
|
-
|
|
553
|
+
// Plain grid, nobody presenting: the local tile joins the grid as an ordinary tile (unless hidden) instead
|
|
554
|
+
// of floating in a corner - see this module's doc comment.
|
|
555
|
+
stage = (_jsxs("div", { className: "h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: [!selfHidden && selfTile("h-full", true), participants.map((p) => remoteTile(p, "h-full"))] }));
|
|
529
556
|
}
|
|
530
|
-
return (_jsxs("div", { className: "fixed inset-0 z-50 flex 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("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_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: `w-9 h-9 flex items-center justify-center rounded-full 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-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _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, 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, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), 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) => {
|
|
557
|
+
return (_jsxs("div", { className: "fixed inset-0 z-50 flex 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("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_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: `w-9 h-9 flex items-center justify-center rounded-full 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-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _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, 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, selfHidden: selfHidden, onToggleSelfHidden: handleToggleSelfHidden, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), onLeave: onLeave }) }), !alone && !selfHidden && showFocusLayout && (_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", true) })), _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) => {
|
|
531
558
|
const stream = remoteStreams[p.uid];
|
|
532
559
|
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
533
560
|
}), _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, waitingRoomEnabled: waitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) })), diagnosticsOpen && (_jsx(DiagnosticsWindow, { onClose: () => setDiagnosticsOpen(false), children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) })), settingsOpen && isHost && (_jsx(SettingsModal, { forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, onClose: () => setSettingsOpen(false) }))] }));
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
|
|
7
7
|
* comment). Purely informational and entirely local: nothing here is sent anywhere.
|
|
8
8
|
*
|
|
9
|
-
* `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up, or the pair moves
|
|
10
|
+
* to the server relay), shown as "Not available yet" rather than a blank space, so it never looks like the panel
|
|
11
|
+
* forgot to load. For a `"websocket"`-relayed participant the round trip is this browser's own leg to the relay
|
|
12
|
+
* server (the relay has no direct path to the peer), and the relay's own counters follow (`RelayRows`): how much of this browser's microphone audio the capture path
|
|
13
|
+
* delivered and how much of it was digital silence, and how this peer's audio is playing back here.
|
|
12
14
|
*/
|
|
13
15
|
import React from "react";
|
|
14
16
|
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
@@ -24,9 +24,25 @@ function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }) {
|
|
|
24
24
|
return (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: kind }), _jsxs("span", { children: [packetsLost !== undefined && `${packetsLost} lost`, jitter !== undefined && ` · ${formatMs(jitter)} jitter`, (bytesSent !== undefined || bytesReceived !== undefined) &&
|
|
25
25
|
` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`] })] }));
|
|
26
26
|
}
|
|
27
|
+
/** `part` as a whole-number percentage of `whole`, or "—" when there is no whole yet. */
|
|
28
|
+
function percent(part, whole) {
|
|
29
|
+
return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
|
|
30
|
+
}
|
|
31
|
+
function plural(count, noun) {
|
|
32
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
33
|
+
}
|
|
34
|
+
/** The server relay's own counters (see `RelayDiagnostics`). The capture row is this browser's own microphone, the
|
|
35
|
+
* playback row this peer's audio as heard here - so a mostly silent capture points at the sender, silence arriving in
|
|
36
|
+
* playback with no capture problem points at the other end's capture, and gaps point at the network. */
|
|
37
|
+
function RelayRows({ relay }) {
|
|
38
|
+
const { send, receive } = relay;
|
|
39
|
+
const notSent = send.audio.framesDropped + send.video.framesDropped;
|
|
40
|
+
return (_jsxs(_Fragment, { children: [send.audioCaptureWallMs > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Your mic capture" }), _jsxs("span", { children: [percent(send.audioCapturedMs, send.audioCaptureWallMs), " of real time \u00B7 ", percent(send.audioSilentMs, send.audioCapturedMs), " silent", send.audioEncoderSkippedMs > 0 && ` · ${formatMs(send.audioEncoderSkippedMs / 1000)} skipped`] })] })), notSent > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Not sent" }), _jsxs("span", { children: [send.audio.framesDropped, " audio \u00B7 ", send.video.framesDropped, " video frames"] })] })), receive && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Playback" }), _jsx("span", { children: `${percent(receive.audio.silentMs, receive.audio.playedMs)} silent · ${plural(receive.audio.gaps, "gap")} ` +
|
|
41
|
+
`(${formatMs(receive.audio.gapMs / 1000)}) · ${formatMs(receive.audio.bufferedMs / 1000)} buffered` })] })), receive && receive.audio.droppedLate > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Dropped late" }), _jsx("span", { children: plural(receive.audio.droppedLate, "packet") })] }))] }));
|
|
42
|
+
}
|
|
27
43
|
export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }) {
|
|
28
44
|
return (_jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "This browser" }), browserCapabilities().map(({ label, supported }) => (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: label }), _jsx("span", { className: supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]", children: supported ? "Supported" : "Not supported" })] }, label))), _jsxs("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: [selfName, " ", _jsx("span", { className: "text-white/60", children: "(you)" })] }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Sending" }), _jsx("span", { children: [micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing" })] }), participants.length === 0 && _jsx("p", { className: "text-white/60 mt-3", children: "Nobody else is in the call yet." }), participants.map((p) => {
|
|
29
45
|
const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
|
|
30
|
-
return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }),
|
|
46
|
+
return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }), !p.diagnostics ? (_jsx("p", { className: `${LABEL} py-0.5`, children: "Not available yet." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, title: p.diagnostics.relay ? "From this browser to the relay server and back" : undefined, children: p.diagnostics.relay ? "Round-trip to server" : "Round-trip time" }), _jsx("span", { children: formatMs(p.diagnostics.roundTripTimeSeconds) })] }), _jsx(StreamRow, { kind: "Audio", ...p.diagnostics.audio }), _jsx(StreamRow, { kind: "Video", ...p.diagnostics.video }), p.diagnostics.relay && _jsx(RelayRows, { relay: p.diagnostics.relay })] }))] }, p.uid));
|
|
31
47
|
})] }));
|
|
32
48
|
}
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
11
11
|
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
12
12
|
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
13
|
-
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
13
|
+
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
14
|
+
*
|
|
15
|
+
* `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
|
|
16
|
+
* hiding the local tile from grid view. */
|
|
14
17
|
import React from "react";
|
|
15
18
|
import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
16
19
|
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
@@ -41,7 +44,11 @@ export interface ParticipantTileProps {
|
|
|
41
44
|
/** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
|
|
42
45
|
* mirrored. Default `true`; see this module's doc comment. */
|
|
43
46
|
mirrored?: boolean;
|
|
47
|
+
/** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
|
|
48
|
+
* doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
|
|
49
|
+
* local tile while alone in the call, where hiding it would just blank the only thing on screen. */
|
|
50
|
+
onHideSelf?: () => void;
|
|
44
51
|
onClick?: () => void;
|
|
45
52
|
className?: string;
|
|
46
53
|
}
|
|
47
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
54
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onHideSelf, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
@@ -15,9 +15,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
15
15
|
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
16
16
|
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
17
17
|
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
18
|
-
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
19
|
+
*
|
|
20
|
+
* `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
|
|
21
|
+
* hiding the local tile from grid view. */
|
|
22
|
+
import { useEffect, useRef, useState } from "react";
|
|
23
|
+
import { MicOffIcon, OverflowIcon } from "./_icons.js";
|
|
21
24
|
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
22
25
|
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
23
26
|
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
@@ -44,15 +47,43 @@ function StatusPill({ status, className }) {
|
|
|
44
47
|
function initialOf(name) {
|
|
45
48
|
return (Array.from(name.trim())[0] ?? "?").toUpperCase();
|
|
46
49
|
}
|
|
47
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onClick, className, }) {
|
|
50
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onHideSelf, onClick, className, }) {
|
|
48
51
|
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
49
52
|
const videoRef = useRef(null);
|
|
50
53
|
const showVideo = !!stream && !cameraOff;
|
|
54
|
+
const [hideMenuOpen, setHideMenuOpen] = useState(false);
|
|
55
|
+
const hideMenuRef = useRef(null);
|
|
51
56
|
// The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
|
|
52
57
|
useEffect(() => {
|
|
53
58
|
if (videoRef.current) {
|
|
54
59
|
videoRef.current.srcObject = stream ?? null;
|
|
55
60
|
}
|
|
56
61
|
}, [stream, showVideo]);
|
|
57
|
-
|
|
62
|
+
// The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
|
|
63
|
+
// this app.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!hideMenuOpen) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const onPointerDown = (event) => {
|
|
69
|
+
if (!hideMenuRef.current?.contains(event.target)) {
|
|
70
|
+
setHideMenuOpen(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const onKeyDown = (event) => {
|
|
74
|
+
if (event.key === "Escape") {
|
|
75
|
+
setHideMenuOpen(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
79
|
+
document.addEventListener("keydown", onKeyDown);
|
|
80
|
+
return () => {
|
|
81
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
82
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
83
|
+
};
|
|
84
|
+
}, [hideMenuOpen]);
|
|
85
|
+
return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("span", { className: "absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base", role: "img", "aria-label": "Hand raised", children: "\u270B" })), (micMuted || onHideSelf) && (_jsxs("div", { className: "absolute top-2 right-2 flex items-center gap-1", children: [micMuted && (_jsx("span", { className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white", role: "img", "aria-label": "Muted", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), onHideSelf && (_jsxs("div", { className: "relative", ref: hideMenuRef, children: [_jsx("button", { type: "button", className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Local video options", "aria-haspopup": "menu", "aria-expanded": hideMenuOpen, onClick: () => setHideMenuOpen((prev) => !prev), children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(OverflowIcon, {}) }) }), hideMenuOpen && (_jsx("div", { role: "menu", "aria-label": "Local video options", className: "absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
86
|
+
setHideMenuOpen(false);
|
|
87
|
+
onHideSelf();
|
|
88
|
+
}, children: "Hide self" }) }))] }))] })), badge && (_jsx("span", { className: `absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"}`, title: badge.title, "data-testid": "transport-badge", children: badge.label })), _jsxs("div", { className: "absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm", children: [name, isLocal ? " (you)" : ""] })] }));
|
|
58
89
|
}
|
|
@@ -34,7 +34,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
34
34
|
*/
|
|
35
35
|
import { useEffect } from "react";
|
|
36
36
|
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
37
|
-
import { MicOffIcon } from "./_icons.js";
|
|
37
|
+
import { BatonIcon, MicOffIcon, RemoveIcon } from "./_icons.js";
|
|
38
38
|
/** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
|
|
39
39
|
* anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
|
|
40
40
|
* indistinguishable from the list just not having loaded yet). */
|
|
@@ -43,11 +43,11 @@ function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
|
|
|
43
43
|
}
|
|
44
44
|
function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
|
|
45
45
|
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
46
|
-
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: "
|
|
46
|
+
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: "w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4", "aria-label": `Give the talking stick to ${name}`, title: `Give the talking stick to ${name}`, onClick: onGiveStick, children: _jsx(BatonIcon, {}) })), canModerate && !micMuted && (_jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4", "aria-label": `Mute ${name}`, title: `Mute ${name}`, onClick: onMute, children: _jsx(MicOffIcon, {}) })), canModerate && (_jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4", "aria-label": `Remove ${name}`, title: `Remove ${name}`, onClick: () => {
|
|
47
47
|
if (window.confirm(`Remove ${name} from the call?`)) {
|
|
48
48
|
onKick?.();
|
|
49
49
|
}
|
|
50
|
-
}, children:
|
|
50
|
+
}, children: _jsx(RemoveIcon, {}) }))] }));
|
|
51
51
|
}
|
|
52
52
|
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
|
|
53
53
|
useEffect(() => {
|
|
@@ -21,3 +21,7 @@ export declare const FlipIcon: () => React.JSX.Element;
|
|
|
21
21
|
* needs to light up (`currentColor`) the same way every other control icon here does. */
|
|
22
22
|
export declare const BatonIcon: () => React.JSX.Element;
|
|
23
23
|
export declare const OverflowIcon: () => React.JSX.Element;
|
|
24
|
+
/** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
|
|
25
|
+
* strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
|
|
26
|
+
* `BatonIcon`. */
|
|
27
|
+
export declare const RemoveIcon: () => React.JSX.Element;
|
package/dist/apps/meet/_icons.js
CHANGED
|
@@ -22,3 +22,7 @@ export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2
|
|
|
22
22
|
* needs to light up (`currentColor`) the same way every other control icon here does. */
|
|
23
23
|
export const BatonIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsxs("g", { transform: "rotate(45 12 12)", children: [_jsx("rect", { x: "5", y: "10.5", width: "13", height: "3", rx: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "3" })] }) }));
|
|
24
24
|
export const OverflowIcon = () => _jsx(Icon, { path: "M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" });
|
|
25
|
+
/** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
|
|
26
|
+
* strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
|
|
27
|
+
* `BatonIcon`. */
|
|
28
|
+
export const RemoveIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }), _jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })] }));
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { RelayReceiveDiagnostics } from "../webrtc/types.js";
|
|
1
2
|
import { type Frame } from "./frames.js";
|
|
2
3
|
import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
|
|
4
|
+
/** The playback half of `RelayReceiveDiagnostics.audio`. */
|
|
5
|
+
export type AudioPlaybackStats = Pick<RelayReceiveDiagnostics["audio"], "playedMs" | "silentMs" | "gaps" | "gapMs" | "droppedLate" | "bufferedMs">;
|
|
3
6
|
export declare const PLAYBACK_SAMPLE_RATE = 48000;
|
|
4
7
|
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
5
8
|
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
@@ -38,8 +41,14 @@ export declare class AudioPlayer {
|
|
|
38
41
|
private decoder;
|
|
39
42
|
private nextTime;
|
|
40
43
|
private readonly timestamps;
|
|
44
|
+
private readonly played;
|
|
45
|
+
private gaps;
|
|
46
|
+
private gapSeconds;
|
|
47
|
+
private droppedLate;
|
|
41
48
|
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
42
49
|
constructor(env: RelayEnv, ctx: AudioContextLike, destination: unknown);
|
|
50
|
+
/** What playback has done so far - see `RelayReceiveDiagnostics`. */
|
|
51
|
+
stats(): AudioPlaybackStats;
|
|
43
52
|
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
44
53
|
push(frame: Frame): void;
|
|
45
54
|
close(): void;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
1
|
import { TimestampUnwrapper } from "./frames.js";
|
|
6
2
|
import { safeClose } from "./senderCommon.js";
|
|
3
|
+
import { SilenceMeter } from "./SilenceMeter.js";
|
|
7
4
|
export const PLAYBACK_SAMPLE_RATE = 48000;
|
|
8
5
|
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
9
6
|
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
@@ -43,11 +40,27 @@ export class AudioPlayer {
|
|
|
43
40
|
this.destination = destination;
|
|
44
41
|
this.nextTime = 0;
|
|
45
42
|
this.timestamps = new TimestampUnwrapper();
|
|
43
|
+
this.played = new SilenceMeter();
|
|
44
|
+
this.gaps = 0;
|
|
45
|
+
this.gapSeconds = 0;
|
|
46
|
+
this.droppedLate = 0;
|
|
47
|
+
}
|
|
48
|
+
/** What playback has done so far - see `RelayReceiveDiagnostics`. */
|
|
49
|
+
stats() {
|
|
50
|
+
return {
|
|
51
|
+
playedMs: this.played.totalMs,
|
|
52
|
+
silentMs: this.played.silentMs,
|
|
53
|
+
gaps: this.gaps,
|
|
54
|
+
gapMs: this.gapSeconds * 1000,
|
|
55
|
+
droppedLate: this.droppedLate,
|
|
56
|
+
bufferedMs: Math.max(0, this.nextTime - this.ctx.currentTime) * 1000,
|
|
57
|
+
};
|
|
46
58
|
}
|
|
47
59
|
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
48
60
|
push(frame) {
|
|
49
61
|
const decoder = this.decoder ?? this.createDecoder();
|
|
50
62
|
if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
|
|
63
|
+
this.droppedLate += 1;
|
|
51
64
|
return;
|
|
52
65
|
}
|
|
53
66
|
try {
|
|
@@ -91,6 +104,7 @@ export class AudioPlayer {
|
|
|
91
104
|
try {
|
|
92
105
|
const now = this.ctx.currentTime;
|
|
93
106
|
if (this.nextTime - now > MAX_AHEAD_SECONDS) {
|
|
107
|
+
this.droppedLate += 1;
|
|
94
108
|
return;
|
|
95
109
|
}
|
|
96
110
|
const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
|
|
@@ -118,6 +132,12 @@ export class AudioPlayer {
|
|
|
118
132
|
source.onended = () => source.disconnect();
|
|
119
133
|
}
|
|
120
134
|
source.start(start);
|
|
135
|
+
this.played.feed(pcm, data.sampleRate);
|
|
136
|
+
if (start > this.nextTime && this.nextTime > 0) {
|
|
137
|
+
// Playback ran dry: everything between the end of the last block and this one is silence.
|
|
138
|
+
this.gaps += 1;
|
|
139
|
+
this.gapSeconds += start - this.nextTime;
|
|
140
|
+
}
|
|
121
141
|
this.nextTime = start + buffer.duration;
|
|
122
142
|
}
|
|
123
143
|
catch {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RelaySendDiagnostics } from "../webrtc/types.js";
|
|
1
2
|
import { type SenderDeps } from "./senderCommon.js";
|
|
2
3
|
export declare const AUDIO_SAMPLE_RATE = 48000;
|
|
3
4
|
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
@@ -35,7 +36,14 @@ export declare class AudioSender {
|
|
|
35
36
|
private encoder;
|
|
36
37
|
private baseMs;
|
|
37
38
|
private samples;
|
|
39
|
+
/** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
|
|
40
|
+
* silent), the wall-clock time it was attached for, and what the encoder had to skip. */
|
|
41
|
+
private readonly captured;
|
|
42
|
+
private wallMs;
|
|
43
|
+
private encoderSkippedSamples;
|
|
38
44
|
constructor(deps: SenderDeps);
|
|
45
|
+
/** The capture half of `RelaySendDiagnostics`. */
|
|
46
|
+
captureStats(): Pick<RelaySendDiagnostics, "audioCapturedMs" | "audioCaptureWallMs" | "audioSilentMs" | "audioEncoderSkippedMs">;
|
|
39
47
|
setActive(active: boolean): void;
|
|
40
48
|
setTrack(track: MediaStreamTrack | null): void;
|
|
41
49
|
/** Brings the capture graph in line with (`active`, `track`): torn down when either is missing, rebuilt when
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
1
|
import { resumeAudioContext } from "./audioResume.js";
|
|
6
2
|
import { KIND_AUDIO } from "./frames.js";
|
|
7
3
|
import { safeClose } from "./senderCommon.js";
|
|
4
|
+
import { SilenceMeter } from "./SilenceMeter.js";
|
|
8
5
|
export const AUDIO_SAMPLE_RATE = 48000;
|
|
9
6
|
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
10
7
|
* thread is interrupted. */
|
|
@@ -36,6 +33,20 @@ export class AudioSender {
|
|
|
36
33
|
this.attachedTrack = null;
|
|
37
34
|
this.baseMs = 0;
|
|
38
35
|
this.samples = 0;
|
|
36
|
+
/** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
|
|
37
|
+
* silent), the wall-clock time it was attached for, and what the encoder had to skip. */
|
|
38
|
+
this.captured = new SilenceMeter();
|
|
39
|
+
this.wallMs = 0;
|
|
40
|
+
this.encoderSkippedSamples = 0;
|
|
41
|
+
}
|
|
42
|
+
/** The capture half of `RelaySendDiagnostics`. */
|
|
43
|
+
captureStats() {
|
|
44
|
+
return {
|
|
45
|
+
audioCapturedMs: this.captured.totalMs,
|
|
46
|
+
audioCaptureWallMs: this.wallMs + (this.attachedTrack ? this.deps.env.now() - this.baseMs : 0),
|
|
47
|
+
audioSilentMs: this.captured.silentMs,
|
|
48
|
+
audioEncoderSkippedMs: (this.encoderSkippedSamples * 1000) / AUDIO_SAMPLE_RATE,
|
|
49
|
+
};
|
|
39
50
|
}
|
|
40
51
|
setActive(active) {
|
|
41
52
|
this.active = active;
|
|
@@ -84,6 +95,9 @@ export class AudioSender {
|
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
97
|
detach() {
|
|
98
|
+
if (this.attachedTrack) {
|
|
99
|
+
this.wallMs += this.deps.env.now() - this.baseMs;
|
|
100
|
+
}
|
|
87
101
|
this.attachedTrack = null;
|
|
88
102
|
this.stopResume?.();
|
|
89
103
|
this.stopResume = undefined;
|
|
@@ -109,11 +123,13 @@ export class AudioSender {
|
|
|
109
123
|
const samples = input.length;
|
|
110
124
|
const timestampUs = Math.round((this.samples * 1000000) / AUDIO_SAMPLE_RATE);
|
|
111
125
|
this.samples += samples;
|
|
126
|
+
this.captured.feed(input, AUDIO_SAMPLE_RATE);
|
|
112
127
|
if (!this.deps.canSend()) {
|
|
113
128
|
return;
|
|
114
129
|
}
|
|
115
130
|
const encoder = this.encoder ?? this.createEncoder();
|
|
116
131
|
if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
|
|
132
|
+
this.encoderSkippedSamples += samples;
|
|
117
133
|
return;
|
|
118
134
|
}
|
|
119
135
|
let audio;
|
|
@@ -23,11 +23,16 @@ export declare const RELAY_BACKOFF_MAX_MS = 15000;
|
|
|
23
23
|
/** How long a socket may take to become `ready` before it is abandoned and retried (a server that accepts the
|
|
24
24
|
* upgrade but never answers the hello would otherwise hang the tier forever). */
|
|
25
25
|
export declare const RELAY_HANDSHAKE_TIMEOUT_MS = 10000;
|
|
26
|
+
/** How often a ready socket pings the server to time the round trip - often enough that the diagnostics panel (which
|
|
27
|
+
* polls every few seconds) always has a recent sample, rarely enough to cost nothing next to the media. */
|
|
28
|
+
export declare const RELAY_PING_INTERVAL_MS = 2000;
|
|
26
29
|
export interface RelayClientOptions {
|
|
27
30
|
url: string;
|
|
28
31
|
peerId: string;
|
|
29
32
|
createSocket: (url: string) => RelaySocket;
|
|
30
33
|
random: () => number;
|
|
34
|
+
/** A monotonic millisecond clock for timing pings. Defaults to `Date.now`. */
|
|
35
|
+
now?: () => number;
|
|
31
36
|
/** Called with the sender's peer id (as stamped by the server) and the original payload. */
|
|
32
37
|
onMedia: (peerId: string, payload: Uint8Array) => void;
|
|
33
38
|
}
|
|
@@ -42,10 +47,15 @@ export declare class RelayClient {
|
|
|
42
47
|
private wanted;
|
|
43
48
|
private reconnectTimer;
|
|
44
49
|
private handshakeTimer;
|
|
50
|
+
private pingTimer;
|
|
51
|
+
private lastRoundTripMs;
|
|
45
52
|
private readonly decoder;
|
|
46
53
|
constructor(options: RelayClientOptions);
|
|
47
54
|
/** True once the server has answered the hello on the current socket, i.e. media may be sent. */
|
|
48
55
|
get ready(): boolean;
|
|
56
|
+
/** The most recent ping's round trip to the server, in milliseconds - `undefined` until the first pong on the
|
|
57
|
+
* current socket (and for good on a server too old to answer pings). */
|
|
58
|
+
get roundTripMs(): number | undefined;
|
|
49
59
|
/** The largest message `sendMedia()` will send: what the server announced in `ready` (validated and clamped), or
|
|
50
60
|
* `MAX_MESSAGE_BYTES` before that, without one, and again after every disconnect until the next `ready`. The
|
|
51
61
|
* frame fragmenter sizes its fragments from this. */
|
|
@@ -62,6 +72,12 @@ export declare class RelayClient {
|
|
|
62
72
|
private openSocket;
|
|
63
73
|
private handleMessage;
|
|
64
74
|
private handleText;
|
|
75
|
+
/** Times the round trip of the ping that `t` came back from. Ignored before ready, for a `t` that isn't a number,
|
|
76
|
+
* or one from the future (nothing this client sent). */
|
|
77
|
+
private handlePong;
|
|
78
|
+
private now;
|
|
79
|
+
/** Stops pinging and forgets the last round trip - it described a socket that is gone. */
|
|
80
|
+
private stopPinging;
|
|
65
81
|
private handleClose;
|
|
66
82
|
/** Detaches and closes the current socket, if any, so its late events can't reach this client. */
|
|
67
83
|
private detach;
|