@rapidmx/meet-plugin 0.14.1 → 0.16.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/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -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 +46 -2
- 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 +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* The in-call chat drawer, beside the call like the participants drawer (`_ParticipantsDrawer.tsx`) - `_CallView.tsx`
|
|
8
|
+
* shows one or the other. The messages come from `CollabSession` (`apps/shared/collab/CollabSession.ts`) and are
|
|
9
|
+
* rendered as Markdown by `ChatMarkdown` (bare links become links, image links become images).
|
|
10
|
+
*
|
|
11
|
+
* The composer is a textarea: Enter sends, Shift+Enter starts a new line. Its emoji button opens the web client's own
|
|
12
|
+
* emoji picker and inserts at the cursor; its GIF button opens the web client's own Giphy picker, and picking a GIF
|
|
13
|
+
* sends it at once as a message holding just its address - which every participant's chat then shows as the GIF
|
|
14
|
+
* itself. Both pickers are the very components the mail composer uses (`@rapidmx/web-client`'s `EmojiPicker` and
|
|
15
|
+
* `GifPicker`); GIF search goes through the server's own Giphy proxy, which a guest's token authenticates like a
|
|
16
|
+
* signed-in user's session.
|
|
17
|
+
*
|
|
18
|
+
* The list follows new messages only while it is already scrolled to the bottom, so reading back through the history
|
|
19
|
+
* isn't interrupted by someone else's message.
|
|
20
|
+
*/
|
|
21
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
22
|
+
import EmojiPicker from "@rapidmx/web-client/shared/components/mail/compose/EmojiPicker.js";
|
|
23
|
+
import GifPicker from "@rapidmx/web-client/shared/components/mail/compose/GifPicker.js";
|
|
24
|
+
import ChatMarkdown from "../shared/collab/chatMarkdown.js";
|
|
25
|
+
import { MAX_CHAT_LENGTH } from "../shared/collab/protocol.js";
|
|
26
|
+
import { EmojiIcon, GifIcon, SendIcon } from "./_icons.js";
|
|
27
|
+
/** How close to the bottom (px) still counts as "at the bottom" for following new messages. */
|
|
28
|
+
const STICK_THRESHOLD_PX = 48;
|
|
29
|
+
/** Within this many characters of `MAX_CHAT_LENGTH`, the composer shows how many are left. */
|
|
30
|
+
const COUNTER_THRESHOLD = 200;
|
|
31
|
+
/** Two messages from the same person this close together (ms) share one name heading. */
|
|
32
|
+
const GROUP_WINDOW_MS = 2 * 60000;
|
|
33
|
+
const ICON_BUTTON = "w-9 h-9 shrink-0 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-40 disabled:cursor-not-allowed";
|
|
34
|
+
function formatTime(ms) {
|
|
35
|
+
return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
36
|
+
}
|
|
37
|
+
export default function ChatPanel({ entries, onSend, onClose }) {
|
|
38
|
+
const [draft, setDraft] = useState("");
|
|
39
|
+
const [picker, setPicker] = useState(null);
|
|
40
|
+
const panelRef = useRef(null);
|
|
41
|
+
const listRef = useRef(null);
|
|
42
|
+
const inputRef = useRef(null);
|
|
43
|
+
const emojiButtonRef = useRef(null);
|
|
44
|
+
const gifButtonRef = useRef(null);
|
|
45
|
+
const stickRef = useRef(true);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
inputRef.current?.focus();
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
// Only an Escape meant for the chat - with focus in it - closes it, not one closing a menu elsewhere.
|
|
51
|
+
const onKeyDown = (event) => {
|
|
52
|
+
if (event.key === "Escape" && !picker && panelRef.current?.contains(document.activeElement)) {
|
|
53
|
+
onClose();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
document.addEventListener("keydown", onKeyDown);
|
|
57
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
58
|
+
}, [onClose, picker]);
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
const list = listRef.current;
|
|
61
|
+
if (list && stickRef.current) {
|
|
62
|
+
list.scrollTop = list.scrollHeight;
|
|
63
|
+
}
|
|
64
|
+
}, [entries]);
|
|
65
|
+
function handleScroll() {
|
|
66
|
+
const list = listRef.current;
|
|
67
|
+
stickRef.current = list.scrollHeight - list.scrollTop - list.clientHeight <= STICK_THRESHOLD_PX;
|
|
68
|
+
}
|
|
69
|
+
function send(text) {
|
|
70
|
+
if (onSend(text)) {
|
|
71
|
+
stickRef.current = true;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
function handleSubmit(event) {
|
|
77
|
+
event?.preventDefault();
|
|
78
|
+
if (send(draft)) {
|
|
79
|
+
setDraft("");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function handleKeyDown(event) {
|
|
83
|
+
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
handleSubmit();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function insertEmoji(native) {
|
|
89
|
+
const input = inputRef.current;
|
|
90
|
+
const start = input.selectionStart ?? draft.length;
|
|
91
|
+
const end = input.selectionEnd ?? draft.length;
|
|
92
|
+
setPicker(null);
|
|
93
|
+
const next = draft.slice(0, start) + native + draft.slice(end);
|
|
94
|
+
// Past the limit the emoji is left out whole, never cut in half.
|
|
95
|
+
if (next.length > MAX_CHAT_LENGTH) {
|
|
96
|
+
input.focus();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setDraft(next);
|
|
100
|
+
requestAnimationFrame(() => {
|
|
101
|
+
input.focus();
|
|
102
|
+
input.setSelectionRange(start + native.length, start + native.length);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function sendGif(url) {
|
|
106
|
+
setPicker(null);
|
|
107
|
+
send(url);
|
|
108
|
+
inputRef.current?.focus();
|
|
109
|
+
}
|
|
110
|
+
const remaining = MAX_CHAT_LENGTH - draft.length;
|
|
111
|
+
return (_jsxs("aside", { ref: panelRef, role: "dialog", "aria-label": "Chat", className: "relative z-10 shrink-0 w-80 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsx("h2", { className: "text-sm font-medium", children: "Chat" }), _jsx("button", { type: "button", className: ICON_BUTTON, "aria-label": "Close chat", onClick: onClose, children: "\u2715" })] }), _jsxs("div", { ref: listRef, onScroll: handleScroll, className: "flex-1 min-h-0 overflow-y-auto px-3 py-2", role: "log", "aria-label": "Messages", "aria-live": "polite", children: [entries.length === 0 && _jsx("p", { className: "mt-6 text-center text-sm text-white/60", children: "Messages are visible to everyone in the call and disappear when it ends." }), entries.map((entry, index) => {
|
|
112
|
+
const previous = entries[index - 1];
|
|
113
|
+
const grouped = !!previous && previous.peer === entry.peer && entry.sentAt - previous.sentAt < GROUP_WINDOW_MS && entry.sentAt >= previous.sentAt;
|
|
114
|
+
return (_jsxs("div", { className: grouped ? "mt-1" : "mt-3", "data-testid": "chat-message", children: [!grouped && (_jsxs("p", { className: "flex items-baseline gap-2 text-xs", children: [_jsx("span", { className: "font-semibold text-white", children: entry.self ? "You" : entry.name }), _jsx("span", { className: "text-white/50", children: formatTime(entry.sentAt) })] })), _jsx("div", { className: "mt-0.5 text-white/90", children: _jsx(ChatMarkdown, { text: entry.text }) })] }, entry.key));
|
|
115
|
+
})] }), _jsxs("form", { onSubmit: handleSubmit, className: "shrink-0 border-t border-white/10 p-2", children: [_jsxs("div", { className: "flex items-end gap-1 rounded-2xl bg-[#3c4043] px-1 py-1", children: [_jsx("button", { ref: emojiButtonRef, type: "button", className: ICON_BUTTON, "aria-label": "Insert emoji", "aria-expanded": picker === "emoji", onClick: () => setPicker((prev) => (prev === "emoji" ? null : "emoji")), children: _jsx(EmojiIcon, {}) }), _jsx("button", { ref: gifButtonRef, type: "button", className: ICON_BUTTON, "aria-label": "Send a GIF", "aria-expanded": picker === "gif", onClick: () => setPicker((prev) => (prev === "gif" ? null : "gif")), children: _jsx(GifIcon, {}) }), _jsx("textarea", { ref: inputRef, rows: 1, value: draft, maxLength: MAX_CHAT_LENGTH, "aria-label": "Message", placeholder: "Send a message", className: "flex-1 min-w-0 max-h-32 resize-none bg-transparent py-2 px-1 text-sm placeholder:text-white/50 focus:outline-none [field-sizing:content]", onChange: (event) => setDraft(event.target.value), onKeyDown: handleKeyDown }), _jsx("button", { type: "submit", className: ICON_BUTTON, "aria-label": "Send message", disabled: !draft.trim(), children: _jsx(SendIcon, {}) })] }), _jsxs("p", { className: "mt-1 px-2 flex justify-between gap-2 text-[11px] text-white/50", children: [_jsx("span", { children: "**bold** *italic* `code` - Shift+Enter for a new line" }), remaining <= COUNTER_THRESHOLD && _jsxs("span", { "aria-live": "polite", children: [remaining, " left"] })] })] }), picker === "emoji" && _jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: insertEmoji, onClose: () => setPicker(null) }), picker === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: sendGif, onClose: () => setPicker(null) })] }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
|
|
3
|
+
* brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
|
|
4
|
+
* on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
|
|
5
|
+
* `#rrggbb`.
|
|
6
|
+
*/
|
|
7
|
+
import React from "react";
|
|
8
|
+
export interface ColorWheelProps {
|
|
9
|
+
color: string;
|
|
10
|
+
onChange: (color: string) => void;
|
|
11
|
+
/** The wheel's diameter, in CSS pixels. */
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
export default function ColorWheel({ color, onChange, size }: ColorWheelProps): React.JSX.Element;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
|
|
8
|
+
* brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
|
|
9
|
+
* on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
|
|
10
|
+
* `#rrggbb`.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useRef, useState } from "react";
|
|
13
|
+
import { hexToHsv, hsvToHex, hsvToRgb, wheelPoint } from "../shared/collab/color.js";
|
|
14
|
+
/** How far one arrow-key press turns the hue (degrees) or moves the saturation. */
|
|
15
|
+
const HUE_STEP = 5;
|
|
16
|
+
const SATURATION_STEP = 0.05;
|
|
17
|
+
export default function ColorWheel({ color, onChange, size = 168 }) {
|
|
18
|
+
const canvasRef = useRef(null);
|
|
19
|
+
const [hsv, setHsv] = useState(() => hexToHsv(color));
|
|
20
|
+
const radius = size / 2;
|
|
21
|
+
// Follows a color chosen elsewhere (a swatch) - unless it is the one this wheel just produced, which would
|
|
22
|
+
// otherwise snap the hue of a gray (where hue is meaningless) back to red.
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setHsv((prev) => (hsvToHex(prev) === color ? prev : hexToHsv(color)));
|
|
25
|
+
}, [color]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const ctx = canvasRef.current?.getContext("2d");
|
|
28
|
+
if (!ctx) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const image = ctx.createImageData(size, size);
|
|
32
|
+
for (let y = 0; y < size; y++) {
|
|
33
|
+
for (let x = 0; x < size; x++) {
|
|
34
|
+
const offset = (y * size + x) * 4;
|
|
35
|
+
if (Math.hypot(x - radius, y - radius) > radius) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const { h, s } = wheelPoint(x, y, radius);
|
|
39
|
+
const [r, g, b] = hsvToRgb({ h, s, v: hsv.v });
|
|
40
|
+
image.data[offset] = r;
|
|
41
|
+
image.data[offset + 1] = g;
|
|
42
|
+
image.data[offset + 2] = b;
|
|
43
|
+
image.data[offset + 3] = 255;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
ctx.putImageData(image, 0, 0);
|
|
47
|
+
}, [hsv.v, size, radius]);
|
|
48
|
+
function pick(next) {
|
|
49
|
+
setHsv(next);
|
|
50
|
+
onChange(hsvToHex(next));
|
|
51
|
+
}
|
|
52
|
+
function pickAt(event) {
|
|
53
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
54
|
+
const scale = rect.width > 0 ? size / rect.width : 1;
|
|
55
|
+
const { h, s } = wheelPoint((event.clientX - rect.left) * scale, (event.clientY - rect.top) * scale, radius);
|
|
56
|
+
pick({ ...hsv, h, s });
|
|
57
|
+
}
|
|
58
|
+
function handleKeyDown(event) {
|
|
59
|
+
const moves = {
|
|
60
|
+
ArrowLeft: { h: (hsv.h - HUE_STEP + 360) % 360 },
|
|
61
|
+
ArrowRight: { h: (hsv.h + HUE_STEP) % 360 },
|
|
62
|
+
ArrowUp: { s: Math.min(1, hsv.s + SATURATION_STEP) },
|
|
63
|
+
ArrowDown: { s: Math.max(0, hsv.s - SATURATION_STEP) },
|
|
64
|
+
};
|
|
65
|
+
const move = moves[event.key];
|
|
66
|
+
if (move) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
pick({ ...hsv, ...move });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const angle = (hsv.h * Math.PI) / 180;
|
|
72
|
+
const markerX = radius + Math.cos(angle) * hsv.s * radius;
|
|
73
|
+
const markerY = radius + Math.sin(angle) * hsv.s * radius;
|
|
74
|
+
return (_jsxs("div", { className: "flex flex-col items-center gap-3", "data-testid": "color-wheel", children: [_jsxs("div", { className: "relative", style: { width: size, height: size }, children: [_jsx("canvas", { ref: canvasRef, width: size, height: size, tabIndex: 0, role: "slider", "aria-label": "Hue and saturation", "aria-valuemin": 0, "aria-valuemax": 359, "aria-valuenow": Math.round(hsv.h), "aria-valuetext": `Hue ${Math.round(hsv.h)} degrees, saturation ${Math.round(hsv.s * 100)}%`, className: "rounded-full cursor-crosshair touch-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onPointerDown: (event) => {
|
|
75
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
76
|
+
pickAt(event);
|
|
77
|
+
}, onPointerMove: (event) => {
|
|
78
|
+
if (event.buttons & 1) {
|
|
79
|
+
pickAt(event);
|
|
80
|
+
}
|
|
81
|
+
}, onKeyDown: handleKeyDown }), _jsx("span", { className: "pointer-events-none absolute w-4 h-4 -ml-2 -mt-2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)]", style: { left: markerX, top: markerY, backgroundColor: hsvToHex(hsv) }, "aria-hidden": "true" })] }), _jsxs("label", { className: "w-full flex items-center gap-2 text-xs text-white/70", children: [_jsx("span", { className: "shrink-0", children: "Brightness" }), _jsx("input", { type: "range", min: 0, max: 100, value: Math.round(hsv.v * 100), "aria-label": "Brightness", className: "flex-1", style: { accentColor: hsvToHex({ ...hsv, v: Math.max(hsv.v, 0.3) }) }, onChange: (event) => pick({ ...hsv, v: Number(event.target.value) / 100 }) })] })] }));
|
|
82
|
+
}
|
|
@@ -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
|
|
@@ -36,12 +39,17 @@ export interface ParticipantTileProps {
|
|
|
36
39
|
status?: string;
|
|
37
40
|
/** Highlights this tile as the current presenter/focus. */
|
|
38
41
|
isFocused?: boolean;
|
|
39
|
-
/**
|
|
42
|
+
/** Marks this tile as showing a screen share rather than a camera - a screen share is never mirrored
|
|
43
|
+
* (regardless of `mirrored`/`isLocal`), since mirroring someone else's shared content would be disorienting. */
|
|
40
44
|
contain?: boolean;
|
|
41
45
|
/** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
|
|
42
46
|
* mirrored. Default `true`; see this module's doc comment. */
|
|
43
47
|
mirrored?: boolean;
|
|
48
|
+
/** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
|
|
49
|
+
* doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
|
|
50
|
+
* local tile while alone in the call, where hiding it would just blank the only thing on screen. */
|
|
51
|
+
onHideSelf?: () => void;
|
|
44
52
|
onClick?: () => void;
|
|
45
53
|
className?: string;
|
|
46
54
|
}
|
|
47
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
55
|
+
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 object-contain bg-black ${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(() => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared whiteboard, shown in place of the video stage while a participant has it open (`_CallView.tsx`). Every
|
|
3
|
+
* participant draws on the same board - `CollabSession` (`apps/shared/collab/CollabSession.ts`) carries the drawing
|
|
4
|
+
* to everyone and `whiteboardRender.ts` draws it - but each one opens and closes their own view of it.
|
|
5
|
+
*
|
|
6
|
+
* ## Tools
|
|
7
|
+
*
|
|
8
|
+
* - **Pens** - ballpoint, sharpie, marker, crayon and coloring pencil (`PEN_TYPES`), each drawn its own way.
|
|
9
|
+
* - **Stencil** - drags out a line, arrow, rectangle, ellipse, triangle or star, previewed while dragging.
|
|
10
|
+
* - **Stamps** - places one of `STAMPS` where clicked.
|
|
11
|
+
* - **Eraser** - erases whatever it passes over, anyone's drawing.
|
|
12
|
+
*
|
|
13
|
+
* Plus a row of sizes, color swatches and a color wheel (`_ColorWheel.tsx`) for any other color, undo (also
|
|
14
|
+
* Ctrl/Cmd+Z: takes back this participant's own last drawing) and clear (for everyone, after a confirmation).
|
|
15
|
+
*
|
|
16
|
+
* ## Everyone's pointers
|
|
17
|
+
*
|
|
18
|
+
* While this participant's pointer is over the board it is shared (`CollabSession.moveCursor()`), as the tool they
|
|
19
|
+
* have picked in the color they have picked; everyone else's (`cursors`) is drawn over the canvas the same way, with
|
|
20
|
+
* their name beside it, so each can see who is pointing at - or drawing - what. They are plain elements over the
|
|
21
|
+
* canvas rather than part of the drawing, so a pointer moving never redraws the board.
|
|
22
|
+
*
|
|
23
|
+
* ## Talking stick
|
|
24
|
+
*
|
|
25
|
+
* While `canDraw` is off (talking-stick mode, and someone else holds the stick - see `_CallView.tsx`), drawing,
|
|
26
|
+
* undo and clear do nothing and a note names who may draw; picking a tool or color still works, ready for when the
|
|
27
|
+
* stick comes round. The pointer is still shared.
|
|
28
|
+
*
|
|
29
|
+
* ## The canvas
|
|
30
|
+
*
|
|
31
|
+
* The board is a fixed 16:9 space (`BOARD_WIDTH` x `BOARD_HEIGHT`) fitted into whatever room the stage has, so a
|
|
32
|
+
* stroke lands in the same place on every participant's screen whatever their window size. The canvas is backed at
|
|
33
|
+
* the display's pixel ratio so lines stay sharp, and redrawn at most once per animation frame whenever the board
|
|
34
|
+
* changes (`revision`).
|
|
35
|
+
*/
|
|
36
|
+
import React from "react";
|
|
37
|
+
import type { CollabSession, RemoteCursor } from "../shared/collab/CollabSession.js";
|
|
38
|
+
import { type PenType, type StencilShape } from "../shared/collab/protocol.js";
|
|
39
|
+
export interface WhiteboardProps {
|
|
40
|
+
session: CollabSession;
|
|
41
|
+
/** `session.board.revision` - a new value redraws the board. */
|
|
42
|
+
revision: number;
|
|
43
|
+
/** Everyone else's pointers on the board - `CollabSession.cursors`. */
|
|
44
|
+
cursors?: readonly RemoteCursor[];
|
|
45
|
+
/** Whether this participant may draw now - see this module's doc comment on talking stick. Defaults to `true`. */
|
|
46
|
+
canDraw?: boolean;
|
|
47
|
+
/** Who may draw while `canDraw` is off, for the note saying so; absent while nobody holds the stick. */
|
|
48
|
+
drawLockedTo?: string;
|
|
49
|
+
onClose: () => void;
|
|
50
|
+
}
|
|
51
|
+
export declare const PEN_LABELS: Record<PenType, string>;
|
|
52
|
+
export declare const SHAPE_LABELS: Record<StencilShape, string>;
|
|
53
|
+
/** The color swatches, in display order. */
|
|
54
|
+
export declare const SWATCHES: readonly ["#202124", "#ffffff", "#9aa0a6", "#d93025", "#f29900", "#fbbc04", "#188038", "#1a73e8", "#9334e6", "#e52592", "#795548", "#00bcd4"];
|
|
55
|
+
/** Black or white, whichever reads better on `color` (`#rrggbb`). */
|
|
56
|
+
export declare function labelTextColor(color: string): string;
|
|
57
|
+
/** A participant's pointer: their tool's own shape in their color, its tip (or a stamp's center) at the point, with
|
|
58
|
+
* their name beside it. */
|
|
59
|
+
export declare function CursorMarker({ cursor, left, top }: {
|
|
60
|
+
cursor: Pick<RemoteCursor, "name" | "tool" | "color" | "stamp">;
|
|
61
|
+
left: number;
|
|
62
|
+
top: number;
|
|
63
|
+
}): React.JSX.Element;
|
|
64
|
+
export default function Whiteboard({ session, revision, cursors, canDraw, drawLockedTo, onClose }: WhiteboardProps): React.JSX.Element;
|