@rapidmx/meet-plugin 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/meet/[token].tsx +51 -46
- package/apps/meet/_CallControls.tsx +255 -32
- package/apps/meet/_CallView.tsx +275 -115
- package/apps/meet/_MeetLobby.tsx +111 -142
- package/apps/meet/_ParticipantTile.tsx +51 -19
- package/apps/meet/_icons.tsx +54 -0
- package/apps/shared/media/chime.ts +74 -0
- package/apps/shared/media/deviceMedia.ts +3 -13
- package/apps/shared/media/useLocalMedia.ts +337 -0
- package/apps/shared/push/GuestSignalingClient.ts +2 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +52 -15
- package/dist/apps/meet/[token].js +31 -19
- package/dist/apps/meet/_CallControls.d.ts +20 -7
- package/dist/apps/meet/_CallControls.js +72 -3
- package/dist/apps/meet/_CallView.d.ts +23 -4
- package/dist/apps/meet/_CallView.js +141 -58
- package/dist/apps/meet/_MeetLobby.d.ts +16 -19
- package/dist/apps/meet/_MeetLobby.js +49 -99
- package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
- package/dist/apps/meet/_ParticipantTile.js +12 -9
- package/dist/apps/meet/_icons.d.ts +15 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/shared/media/chime.d.ts +31 -0
- package/dist/apps/shared/media/chime.js +49 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
- package/dist/apps/shared/media/deviceMedia.js +3 -12
- package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
- package/dist/apps/shared/media/useLocalMedia.js +259 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +52 -15
- package/dist/apps/shared/webrtc/types.js +3 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
- package/package.json +181 -181
|
@@ -4,117 +4,67 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
/**
|
|
7
|
-
* The join/lobby step: name entry,
|
|
8
|
-
* "Join" button.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The join/lobby step: name entry, a preview of the camera, the microphone and camera on/off buttons and pickers,
|
|
8
|
+
* and the "Join" button. The camera and microphone are `LocalMedia` (`apps/shared/media/useLocalMedia.ts`), owned by
|
|
9
|
+
* the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
|
|
10
|
+
* so leaving the lobby for the call must not (and does not) stop them.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
|
|
13
|
+
* gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
|
|
14
|
+
* there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
|
|
15
|
+
* nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
|
|
16
|
+
*
|
|
17
|
+
* Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
|
|
18
|
+
* genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
|
|
19
|
+
* actually happens on this page today).
|
|
17
20
|
*/
|
|
18
21
|
import { useEffect, useRef, useState } from "react";
|
|
19
22
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
20
23
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
21
24
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
22
|
-
import {
|
|
25
|
+
import { LevelBars } from "./_CallControls.js";
|
|
26
|
+
import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
|
|
23
27
|
const INPUT_CLASS = "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
|
|
24
|
-
|
|
28
|
+
const ROUND_BUTTON = "flex items-center justify-center gap-1.5 h-12 min-w-12 px-3 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
|
|
29
|
+
/** What the preview says when there is no picture to show. */
|
|
30
|
+
function previewMessage(media) {
|
|
31
|
+
if (!media.supported) {
|
|
32
|
+
return "This browser can't use a camera or microphone here.";
|
|
33
|
+
}
|
|
34
|
+
if (media.requesting) {
|
|
35
|
+
return "Waiting for camera and microphone access…";
|
|
36
|
+
}
|
|
37
|
+
const status = media.status.video;
|
|
38
|
+
switch (status) {
|
|
39
|
+
case "off":
|
|
40
|
+
return "Camera is off";
|
|
41
|
+
case "denied":
|
|
42
|
+
return "Camera access is blocked";
|
|
43
|
+
case "unavailable":
|
|
44
|
+
return "No camera found";
|
|
45
|
+
case "error":
|
|
46
|
+
return "Camera couldn't start";
|
|
47
|
+
default:
|
|
48
|
+
return "No camera preview";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export default function MeetLobby({ meeting, media, initialName, onJoin }) {
|
|
25
52
|
const [name, setName] = useState(initialName ?? "");
|
|
26
|
-
const [micOn, setMicOn] = useState(true);
|
|
27
|
-
const [cameraOn, setCameraOn] = useState(true);
|
|
28
|
-
const [stream, setStream] = useState(null);
|
|
29
|
-
const [devices, setDevices] = useState({ cameras: [], microphones: [] });
|
|
30
|
-
const [error, setError] = useState(null);
|
|
31
|
-
const [supported, setSupported] = useState(true);
|
|
32
53
|
const videoRef = useRef(null);
|
|
33
|
-
|
|
34
|
-
//
|
|
54
|
+
const { requestAccess } = media;
|
|
55
|
+
// Asks once, when the lobby opens - see this module's doc comment for the button that asks again.
|
|
35
56
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
setSupported(false);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
void requestUserMedia({ audio: true, video: true }).then(async (result) => {
|
|
42
|
-
if (cancelled) {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (!result.ok) {
|
|
46
|
-
setError(result.error);
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
setStream(result.value);
|
|
50
|
-
const list = await listDevices();
|
|
51
|
-
if (!cancelled && list.ok) {
|
|
52
|
-
setDevices(list.value);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
return () => {
|
|
56
|
-
cancelled = true;
|
|
57
|
-
};
|
|
58
|
-
// Intentionally empty deps - see this effect's own comment on why this never re-runs.
|
|
57
|
+
void requestAccess();
|
|
58
|
+
// Only on mount: `requestAccess` is stable, and a later change of devices is the participant's own doing.
|
|
59
59
|
}, []);
|
|
60
|
-
//
|
|
61
|
-
// transitioning into the call - `[token].tsx` requests its own continuation of these tracks first).
|
|
62
|
-
useEffect(() => stopOnUnmount(stream), [stream]);
|
|
60
|
+
// The `<video>` element only exists while there is a stream, so its stream is bound whenever it (re)appears.
|
|
63
61
|
useEffect(() => {
|
|
64
62
|
if (videoRef.current) {
|
|
65
|
-
videoRef.current.srcObject =
|
|
66
|
-
}
|
|
67
|
-
}, [stream]);
|
|
68
|
-
function handleToggleMic() {
|
|
69
|
-
setMicOn((prev) => {
|
|
70
|
-
const next = !prev;
|
|
71
|
-
setTracksEnabled(stream, "audio", next);
|
|
72
|
-
return next;
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
function handleToggleCamera() {
|
|
76
|
-
setCameraOn((prev) => {
|
|
77
|
-
const next = !prev;
|
|
78
|
-
setTracksEnabled(stream, "video", next);
|
|
79
|
-
return next;
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
async function handleSwitchDevice(kind, deviceId) {
|
|
83
|
-
const result = await requestUserMedia({ [kind]: { deviceId: { exact: deviceId } }, [kind === "audio" ? "video" : "audio"]: false });
|
|
84
|
-
if (!result.ok) {
|
|
85
|
-
setError(result.error);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const [newTrack] = kind === "audio" ? result.value.getAudioTracks() : result.value.getVideoTracks();
|
|
89
|
-
// `stream` is always set by the time this can be called - the device picker this is wired to only ever
|
|
90
|
-
// renders once `devices` is populated, which itself only happens after an initial successful
|
|
91
|
-
// `requestUserMedia()` call has already set `stream` (see the mount effect above).
|
|
92
|
-
if (!newTrack) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const [oldTrack] = kind === "audio" ? stream.getAudioTracks() : stream.getVideoTracks();
|
|
96
|
-
if (oldTrack) {
|
|
97
|
-
stream.removeTrack(oldTrack);
|
|
98
|
-
oldTrack.stop();
|
|
63
|
+
videoRef.current.srcObject = media.videoStream;
|
|
99
64
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
function handleJoin() {
|
|
107
|
-
onJoin({
|
|
108
|
-
name: name.trim(),
|
|
109
|
-
micOn,
|
|
110
|
-
cameraOn,
|
|
111
|
-
stream: stream ?? new MediaStream(),
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: meeting.title }), meeting.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", meeting.hostDisplayName] }), _jsxs("div", { className: "mt-6 grid sm:grid-cols-2 gap-6", children: [_jsxs("div", { children: [_jsx("div", { className: "aspect-video bg-black/80 rounded-md overflow-hidden flex items-center justify-center", children: stream && cameraOn ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover" })) : (_jsx("p", { className: "text-white/70 text-sm", children: cameraOn ? "No camera preview" : "Camera is off" })) }), _jsxs("div", { className: "flex gap-2 mt-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleToggleMic, disabled: !stream, children: micOn ? "Mute mic" : "Unmute mic" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleToggleCamera, disabled: !stream, children: cameraOn ? "Turn camera off" : "Turn camera on" })] }), !supported && _jsx(Alert, { children: "This browser (or this page's connection) doesn't support camera/microphone access." }), error && _jsx(Alert, { children: error.message }), devices.cameras.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Camera", _jsx("select", { className: `${INPUT_CLASS} mt-1`, onChange: (e) => handleSwitchDevice("video", e.target.value), children: devices.cameras.map((d) => (_jsx("option", { value: d.deviceId, children: d.label || "Camera" }, d.deviceId))) })] })), devices.microphones.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Microphone", _jsx("select", { className: `${INPUT_CLASS} mt-1`, onChange: (e) => handleSwitchDevice("audio", e.target.value), children: devices.microphones.map((d) => (_jsx("option", { value: d.deviceId, children: d.label || "Microphone" }, d.deviceId))) })] }))] }), _jsxs("div", { className: "flex flex-col justify-center gap-3", children: [_jsx(FormField, { label: "Your name", htmlFor: "meet-name", children: _jsx("input", { id: "meet-name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Enter your name" }) }), _jsx(Button, { type: "button", className: "!w-auto", disabled: !name.trim(), onClick: handleJoin, children: "Join meeting" })] })] })] }));
|
|
115
|
-
}
|
|
116
|
-
/** `useEffect` cleanup helper for stopping a lobby preview stream on unmount/replacement - a named function
|
|
117
|
-
* (rather than an inline arrow) purely so its intent reads clearly at the call site. */
|
|
118
|
-
function stopOnUnmount(stream) {
|
|
119
|
-
return () => stopStream(stream);
|
|
65
|
+
}, [media.videoStream]);
|
|
66
|
+
const cameraOk = media.status.video === "live" || media.status.video === "off";
|
|
67
|
+
const needsAccess = media.supported && !media.requesting && (media.status.audio !== "live" || !cameraOk);
|
|
68
|
+
const nothingToSend = !media.audioTrack && !media.videoTrack && !media.requesting;
|
|
69
|
+
return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: meeting.title }), meeting.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", meeting.hostDisplayName] }), _jsxs("div", { className: "mt-6 grid sm:grid-cols-2 gap-6", children: [_jsxs("div", { children: [_jsxs("div", { className: "relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center", children: [media.videoStream ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover [transform:scaleX(-1)]" })) : (_jsx("p", { className: "px-4 text-center text-white/70 text-sm", children: previewMessage(media) })), _jsxs("div", { className: "absolute bottom-3 inset-x-0 flex justify-center gap-3", children: [_jsxs("button", { type: "button", className: `${ROUND_BUTTON} ${media.micOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), _jsx("button", { type: "button", className: `${ROUND_BUTTON} ${media.cameraOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}) })] })] }), !media.supported && (_jsx(Alert, { children: "This browser (or this page's connection) doesn't support camera/microphone access. Try a current browser over HTTPS - a link opened inside another app often needs to be opened in the browser itself." })), media.error && _jsx(Alert, { children: media.error.message }), needsAccess && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mt-3", onClick: () => void requestAccess(), children: media.error ? "Try again" : "Allow camera and microphone" })), nothingToSend && _jsx("p", { className: "text-sm text-text-muted mt-3", children: "You'll join without a camera or microphone." }), media.devices.cameras.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Camera", _jsxs("select", { className: `${INPUT_CLASS} mt-1`, value: media.selectedDeviceIds.video ?? "", onChange: (e) => void media.selectDevice("video", e.target.value), children: [!media.selectedDeviceIds.video && _jsx("option", { value: "", children: "Choose a camera" }), media.devices.cameras.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label || `Camera ${i + 1}` }, d.deviceId)))] })] })), media.devices.microphones.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Microphone", _jsxs("select", { className: `${INPUT_CLASS} mt-1`, value: media.selectedDeviceIds.audio ?? "", onChange: (e) => void media.selectDevice("audio", e.target.value), children: [!media.selectedDeviceIds.audio && _jsx("option", { value: "", children: "Choose a microphone" }), media.devices.microphones.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label || `Microphone ${i + 1}` }, d.deviceId)))] })] }))] }), _jsxs("div", { className: "flex flex-col justify-center gap-3", children: [_jsx(FormField, { label: "Your name", htmlFor: "meet-name", children: _jsx("input", { id: "meet-name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Enter your name" }) }), _jsx(Button, { type: "button", className: "!w-auto", disabled: !name.trim(), onClick: () => onJoin(name.trim()), children: "Join meeting" })] })] })] }));
|
|
120
70
|
}
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
/** One participant's video tile - used by
|
|
2
|
-
* Renders a placeholder (the participant's initial) instead of a `<video>` element
|
|
3
|
-
* their camera is off, rather than showing a black/frozen frame.
|
|
1
|
+
/** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
|
|
2
|
+
* corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
|
|
3
|
+
* whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
|
|
4
|
+
*
|
|
5
|
+
* A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
|
|
6
|
+
* own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
|
|
7
|
+
* in the current layout - can never silence anyone. */
|
|
4
8
|
import React from "react";
|
|
5
9
|
export interface ParticipantTileProps {
|
|
6
10
|
name: string;
|
|
7
11
|
stream?: MediaStream | null;
|
|
8
|
-
/** The local participant's own tile must be `muted` (never plays back its own audio) - a real remote tile
|
|
9
|
-
* never sets this. */
|
|
10
12
|
isLocal?: boolean;
|
|
13
|
+
/** Shows the initial instead of the video. */
|
|
11
14
|
cameraOff?: boolean;
|
|
12
15
|
micMuted?: boolean;
|
|
16
|
+
handRaised?: boolean;
|
|
13
17
|
/** Highlights this tile as the current presenter/focus. */
|
|
14
18
|
isFocused?: boolean;
|
|
19
|
+
/** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
|
|
20
|
+
contain?: boolean;
|
|
15
21
|
onClick?: () => void;
|
|
16
22
|
className?: string;
|
|
17
23
|
}
|
|
18
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, isFocused, onClick, className }: ParticipantTileProps): React.JSX.Element;
|
|
24
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, isFocused, contain, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
@@ -3,23 +3,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
/** One participant's video tile - used by
|
|
7
|
-
* Renders a placeholder (the participant's initial) instead of a `<video>` element
|
|
8
|
-
* their camera is off, rather than showing a black/frozen frame.
|
|
6
|
+
/** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
|
|
7
|
+
* corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
|
|
8
|
+
* whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
|
|
9
|
+
*
|
|
10
|
+
* A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
|
|
11
|
+
* own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
|
|
12
|
+
* in the current layout - can never silence anyone. */
|
|
9
13
|
import { useEffect, useRef } from "react";
|
|
14
|
+
import { MicOffIcon } from "./_icons.js";
|
|
10
15
|
function initialOf(name) {
|
|
11
16
|
return (Array.from(name.trim())[0] ?? "?").toUpperCase();
|
|
12
17
|
}
|
|
13
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, isFocused, onClick, className }) {
|
|
18
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, isFocused, contain, onClick, className, }) {
|
|
14
19
|
const videoRef = useRef(null);
|
|
15
20
|
const showVideo = !!stream && !cameraOff;
|
|
21
|
+
// The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
|
|
16
22
|
useEffect(() => {
|
|
17
23
|
if (videoRef.current) {
|
|
18
|
-
/* v8 ignore next -- unreachable via real usage: the `<video>` element only ever renders at all when
|
|
19
|
-
`showVideo` (`!!stream && !cameraOff`) is true, so `stream` is always truthy whenever `videoRef.current`
|
|
20
|
-
is non-null here; the `?? null` exists purely to satisfy `srcObject`'s type, not for a real code path. */
|
|
21
24
|
videoRef.current.srcObject = stream ?? null;
|
|
22
25
|
}
|
|
23
|
-
}, [stream]);
|
|
24
|
-
return (_jsxs("div", { className: `relative overflow-hidden rounded-
|
|
26
|
+
}, [stream, showVideo]);
|
|
27
|
+
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 ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain ? "[transform:scaleX(-1)]" : ""}` })) : (_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) })), 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 && (_jsx("span", { className: "absolute top-2 right-2 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, {}) }) })), _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)" : ""] })] }));
|
|
25
28
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The in-call control icons, as inline SVG (24x24, Material Design paths, `currentColor`) so this plugin needs no
|
|
2
|
+
* icon package of its own. Decorative - every button that uses one carries its own `aria-label`. */
|
|
3
|
+
import React from "react";
|
|
4
|
+
export declare const MicIcon: () => React.JSX.Element;
|
|
5
|
+
export declare const MicOffIcon: () => React.JSX.Element;
|
|
6
|
+
export declare const VideoIcon: () => React.JSX.Element;
|
|
7
|
+
export declare const VideoOffIcon: () => React.JSX.Element;
|
|
8
|
+
export declare const ScreenShareIcon: () => React.JSX.Element;
|
|
9
|
+
export declare const EmojiIcon: () => React.JSX.Element;
|
|
10
|
+
export declare const GridIcon: () => React.JSX.Element;
|
|
11
|
+
export declare const FocusIcon: () => React.JSX.Element;
|
|
12
|
+
export declare const LeaveIcon: () => React.JSX.Element;
|
|
13
|
+
export declare const ChevronUpIcon: () => React.JSX.Element;
|
|
14
|
+
/** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
|
|
15
|
+
export declare const HandIcon: () => React.JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
function Icon({ path, className = "w-6 h-6" }) {
|
|
3
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", className: className, fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: path }) }));
|
|
4
|
+
}
|
|
5
|
+
export const MicIcon = () => (_jsx(Icon, { path: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" }));
|
|
6
|
+
export const MicOffIcon = () => (_jsx(Icon, { path: "M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z" }));
|
|
7
|
+
export const VideoIcon = () => _jsx(Icon, { path: "M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" });
|
|
8
|
+
export const VideoOffIcon = () => (_jsx(Icon, { path: "M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" }));
|
|
9
|
+
export const ScreenShareIcon = () => (_jsx(Icon, { path: "M21 3H3c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm0 16.02H3V4.98h18v14.04zM10 12H8l4-4 4 4h-2v4h-4v-4z" }));
|
|
10
|
+
export const EmojiIcon = () => (_jsx(Icon, { path: "M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" }));
|
|
11
|
+
export const GridIcon = () => _jsx(Icon, { path: "M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 0h8v8h-8v-8z" });
|
|
12
|
+
export const FocusIcon = () => _jsx(Icon, { path: "M3 3h18v12H3V3zm0 14h5v4H3v-4zm6.5 0h5v4h-5v-4zM16 17h5v4h-5v-4z" });
|
|
13
|
+
export const LeaveIcon = () => (_jsx(Icon, { path: "M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" }));
|
|
14
|
+
export const ChevronUpIcon = () => _jsx(Icon, { path: "M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z", className: "w-5 h-5" });
|
|
15
|
+
/** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
|
|
16
|
+
export const HandIcon = () => (_jsx("span", { className: "text-xl leading-none", "aria-hidden": "true", children: "\u270B" }));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The short two-note chime played when another participant raises their hand - synthesized with Web Audio, so there
|
|
2
|
+
* is no sound file to ship. Never throws and does nothing where there is no `AudioContext` (SSR, an old browser):
|
|
3
|
+
* the raised hand is also shown on screen, the chime is an extra. */
|
|
4
|
+
/** The subset of `AudioContext` this module uses - what a test's fake implements. */
|
|
5
|
+
export interface ChimeAudioContext {
|
|
6
|
+
readonly currentTime: number;
|
|
7
|
+
readonly state: string;
|
|
8
|
+
readonly destination: unknown;
|
|
9
|
+
createGain(): {
|
|
10
|
+
gain: {
|
|
11
|
+
setValueAtTime(value: number, time: number): void;
|
|
12
|
+
exponentialRampToValueAtTime(value: number, time: number): void;
|
|
13
|
+
};
|
|
14
|
+
connect(destination: unknown): void;
|
|
15
|
+
};
|
|
16
|
+
createOscillator(): {
|
|
17
|
+
type: string;
|
|
18
|
+
frequency: {
|
|
19
|
+
setValueAtTime(value: number, time: number): void;
|
|
20
|
+
};
|
|
21
|
+
connect(destination: unknown): void;
|
|
22
|
+
start(time: number): void;
|
|
23
|
+
stop(time: number): void;
|
|
24
|
+
};
|
|
25
|
+
resume(): Promise<void> | void;
|
|
26
|
+
close(): Promise<void> | void;
|
|
27
|
+
}
|
|
28
|
+
export type ChimeContextFactory = () => ChimeAudioContext | undefined;
|
|
29
|
+
/** The real `AudioContext`/`webkitAudioContext`, or `undefined` where there is none. */
|
|
30
|
+
export declare function defaultChimeContext(): ChimeAudioContext | undefined;
|
|
31
|
+
export declare function playRaisedHandChime(createContext?: ChimeContextFactory): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** The short two-note chime played when another participant raises their hand - synthesized with Web Audio, so there
|
|
6
|
+
* is no sound file to ship. Never throws and does nothing where there is no `AudioContext` (SSR, an old browser):
|
|
7
|
+
* the raised hand is also shown on screen, the chime is an extra. */
|
|
8
|
+
/** The real `AudioContext`/`webkitAudioContext`, or `undefined` where there is none. */
|
|
9
|
+
export function defaultChimeContext() {
|
|
10
|
+
if (typeof window === "undefined") {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
const Ctor = window.AudioContext ??
|
|
14
|
+
window.webkitAudioContext;
|
|
15
|
+
return Ctor ? new Ctor() : undefined;
|
|
16
|
+
}
|
|
17
|
+
/** The two notes (Hz) and how long each lasts (seconds). */
|
|
18
|
+
const NOTES = [880, 1320];
|
|
19
|
+
const NOTE_SECONDS = 0.18;
|
|
20
|
+
export function playRaisedHandChime(createContext = defaultChimeContext) {
|
|
21
|
+
try {
|
|
22
|
+
const context = createContext();
|
|
23
|
+
if (!context) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (context.state === "suspended") {
|
|
27
|
+
void context.resume();
|
|
28
|
+
}
|
|
29
|
+
const start = context.currentTime;
|
|
30
|
+
const end = start + NOTE_SECONDS * NOTES.length + 0.25;
|
|
31
|
+
const gain = context.createGain();
|
|
32
|
+
gain.gain.setValueAtTime(0.0001, start);
|
|
33
|
+
gain.gain.exponentialRampToValueAtTime(0.25, start + 0.03);
|
|
34
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, end);
|
|
35
|
+
gain.connect(context.destination);
|
|
36
|
+
NOTES.forEach((frequency, index) => {
|
|
37
|
+
const oscillator = context.createOscillator();
|
|
38
|
+
oscillator.type = "sine";
|
|
39
|
+
oscillator.frequency.setValueAtTime(frequency, start);
|
|
40
|
+
oscillator.connect(gain);
|
|
41
|
+
oscillator.start(start + index * NOTE_SECONDS);
|
|
42
|
+
oscillator.stop(start + (index + 1) * NOTE_SECONDS + 0.1);
|
|
43
|
+
});
|
|
44
|
+
setTimeout(() => void context.close(), (end - start) * 1000 + 200);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Audio is best-effort here - the hand is shown on screen regardless.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thin, dependency-injectable wrappers over `navigator.mediaDevices` - `enumerateDevices()`/`getUserMedia()`/
|
|
3
|
-
* `getDisplayMedia()` - used by
|
|
4
|
-
*
|
|
5
|
-
* it is pure logic with no JSX, reused by more than one
|
|
3
|
+
* `getDisplayMedia()` - used by `useLocalMedia()` (the camera and microphone) and the in-call view's share-screen
|
|
4
|
+
* control (see this plugin's Phase 2 `.claude/NOTES.md` entry). Kept under `apps/shared/` rather than `apps/meet/`
|
|
5
|
+
* because it is pure logic with no JSX, reused by more than one module.
|
|
6
6
|
*
|
|
7
7
|
* Every function accepts an optional `MediaDevicesLike` so a test can inject a fake without a real browser; the
|
|
8
8
|
* real `navigator.mediaDevices` is used by default, resolved lazily inside each function (never at module scope)
|
|
@@ -59,7 +59,3 @@ export declare function requestDisplayMedia(devices?: MediaDevicesLike | undefin
|
|
|
59
59
|
/** Stops every track of `stream` (releasing the camera/microphone/screen-share indicator) - tolerates `undefined`/
|
|
60
60
|
* `null` so a caller never needs its own guard. */
|
|
61
61
|
export declare function stopStream(stream: MediaStream | undefined | null): void;
|
|
62
|
-
/** Enables/disables every track of `stream` matching `kind`, for the mute/camera-off toggles - tracks are kept
|
|
63
|
-
* (not stopped/removed), matching every mainstream video-call app's "mute" behavior: instant, reversible, and
|
|
64
|
-
* without re-requesting `getUserMedia()` or renegotiating the `RTCPeerConnection`. */
|
|
65
|
-
export declare function setTracksEnabled(stream: MediaStream | undefined | null, kind: "audio" | "video", enabled: boolean): void;
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/**
|
|
6
6
|
* Thin, dependency-injectable wrappers over `navigator.mediaDevices` - `enumerateDevices()`/`getUserMedia()`/
|
|
7
|
-
* `getDisplayMedia()` - used by
|
|
8
|
-
*
|
|
9
|
-
* it is pure logic with no JSX, reused by more than one
|
|
7
|
+
* `getDisplayMedia()` - used by `useLocalMedia()` (the camera and microphone) and the in-call view's share-screen
|
|
8
|
+
* control (see this plugin's Phase 2 `.claude/NOTES.md` entry). Kept under `apps/shared/` rather than `apps/meet/`
|
|
9
|
+
* because it is pure logic with no JSX, reused by more than one module.
|
|
10
10
|
*
|
|
11
11
|
* Every function accepts an optional `MediaDevicesLike` so a test can inject a fake without a real browser; the
|
|
12
12
|
* real `navigator.mediaDevices` is used by default, resolved lazily inside each function (never at module scope)
|
|
@@ -99,12 +99,3 @@ export async function requestDisplayMedia(devices = defaultMediaDevices()) {
|
|
|
99
99
|
export function stopStream(stream) {
|
|
100
100
|
stream?.getTracks().forEach((track) => track.stop());
|
|
101
101
|
}
|
|
102
|
-
/** Enables/disables every track of `stream` matching `kind`, for the mute/camera-off toggles - tracks are kept
|
|
103
|
-
* (not stopped/removed), matching every mainstream video-call app's "mute" behavior: instant, reversible, and
|
|
104
|
-
* without re-requesting `getUserMedia()` or renegotiating the `RTCPeerConnection`. */
|
|
105
|
-
export function setTracksEnabled(stream, kind, enabled) {
|
|
106
|
-
const tracks = kind === "audio" ? (stream?.getAudioTracks() ?? []) : (stream?.getVideoTracks() ?? []);
|
|
107
|
-
for (const track of tracks) {
|
|
108
|
-
track.enabled = enabled;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type DeviceLists, type MediaAccessError } from "./deviceMedia.js";
|
|
2
|
+
export type MediaKind = "audio" | "video";
|
|
3
|
+
/** `pending`: not asked yet, or being asked. `live`: a track is running. `off`: the participant turned the camera
|
|
4
|
+
* off. `denied`/`unavailable`/`error`: the browser said no, there is no such device (or it was unplugged), or
|
|
5
|
+
* something else went wrong. */
|
|
6
|
+
export type TrackStatus = "pending" | "live" | "off" | "denied" | "unavailable" | "error";
|
|
7
|
+
export declare const AUDIO_LEVEL_STEPS = 5;
|
|
8
|
+
export interface LocalMedia {
|
|
9
|
+
/** `false` when this browser (or page connection) has no `getUserMedia` at all. */
|
|
10
|
+
supported: boolean;
|
|
11
|
+
/** A permission request is in flight. */
|
|
12
|
+
requesting: boolean;
|
|
13
|
+
/** The message for the most recent failed request, ready to show. */
|
|
14
|
+
error: MediaAccessError | null;
|
|
15
|
+
audioTrack: MediaStreamTrack | null;
|
|
16
|
+
videoTrack: MediaStreamTrack | null;
|
|
17
|
+
/** A stream holding just the camera track, for a `<video>` element - `null` while the camera is off. */
|
|
18
|
+
videoStream: MediaStream | null;
|
|
19
|
+
/** The user wants the microphone live (unmuted). */
|
|
20
|
+
micEnabled: boolean;
|
|
21
|
+
/** The microphone is live and unmuted: there is a track and `micEnabled`. */
|
|
22
|
+
micOn: boolean;
|
|
23
|
+
cameraOn: boolean;
|
|
24
|
+
status: Record<MediaKind, TrackStatus>;
|
|
25
|
+
devices: DeviceLists;
|
|
26
|
+
selectedDeviceIds: Partial<Record<MediaKind, string>>;
|
|
27
|
+
/** 0-`AUDIO_LEVEL_STEPS`: how loud the microphone is right now - always 0 while muted. */
|
|
28
|
+
audioLevel: number;
|
|
29
|
+
/** Asks for the camera and microphone (or just `kind`), see this module's doc comment. */
|
|
30
|
+
requestAccess(kind?: MediaKind): Promise<void>;
|
|
31
|
+
toggleMic(): Promise<void>;
|
|
32
|
+
toggleCamera(): Promise<void>;
|
|
33
|
+
selectDevice(kind: MediaKind, deviceId: string): Promise<void>;
|
|
34
|
+
/** Stops both tracks. */
|
|
35
|
+
release(): void;
|
|
36
|
+
}
|
|
37
|
+
export declare function useLocalMedia(): LocalMedia;
|