@rapidmx/meet-plugin 0.2.0 → 0.3.1
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 +276 -114
- 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 +237 -55
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +58 -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 +24 -4
- package/dist/apps/meet/_CallView.js +142 -57
- 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 +63 -18
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +58 -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
package/apps/meet/_CallView.tsx
CHANGED
|
@@ -11,6 +11,21 @@
|
|
|
11
11
|
* program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
|
|
12
12
|
* identical `apps/shared/` convention for reusable frontend-only code.
|
|
13
13
|
*
|
|
14
|
+
* ## Fitting the window
|
|
15
|
+
*
|
|
16
|
+
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
17
|
+
* is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
|
|
18
|
+
* the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
|
|
19
|
+
* tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
|
|
20
|
+
* window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
|
|
21
|
+
* alone.
|
|
22
|
+
*
|
|
23
|
+
* ## Who is heard
|
|
24
|
+
*
|
|
25
|
+
* A tile never plays sound. Each remote stream is played by its own hidden `<audio>` element, so a participant is
|
|
26
|
+
* heard whether or not their camera is on and whichever layout is showing. If the browser refuses to start the
|
|
27
|
+
* audio (an autoplay policy), a banner asks for a click, which is the gesture that allows it.
|
|
28
|
+
*
|
|
14
29
|
* ## Layout precedence
|
|
15
30
|
*
|
|
16
31
|
* `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
|
|
@@ -19,15 +34,16 @@
|
|
|
19
34
|
* slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
|
|
20
35
|
* empty main slot once someone else has joined.
|
|
21
36
|
*/
|
|
22
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
37
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
38
|
+
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
23
39
|
import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
|
|
24
40
|
import { startLevelMeter, type LevelMeterHandle } from "../shared/media/levelMeter.js";
|
|
25
|
-
import { requestDisplayMedia,
|
|
41
|
+
import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
|
|
42
|
+
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
26
43
|
import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
|
|
27
44
|
import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
|
|
28
45
|
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
29
46
|
import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
|
|
30
|
-
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
31
47
|
import CallControls, { type CallViewMode } from "./_CallControls.js";
|
|
32
48
|
import ParticipantTile from "./_ParticipantTile.js";
|
|
33
49
|
|
|
@@ -39,13 +55,24 @@ export interface CallViewProps {
|
|
|
39
55
|
token?: string;
|
|
40
56
|
selfUid: string;
|
|
41
57
|
selfName: string;
|
|
58
|
+
meetingTitle: string;
|
|
42
59
|
iceServers: RTCIceServer[];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
initialCameraOn: boolean;
|
|
60
|
+
/** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
|
|
61
|
+
media: LocalMedia;
|
|
46
62
|
onLeave: () => void;
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
/** How long a reaction floats on screen. */
|
|
66
|
+
const REACTION_MS = 4_000;
|
|
67
|
+
/** The most reactions shown at once - a burst beyond this drops the oldest. */
|
|
68
|
+
const MAX_REACTIONS = 12;
|
|
69
|
+
|
|
70
|
+
interface Reaction {
|
|
71
|
+
id: number;
|
|
72
|
+
emoji: string;
|
|
73
|
+
name: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
/** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
|
|
50
77
|
* joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
|
|
51
78
|
export function computeMainUid(options: {
|
|
@@ -66,48 +93,60 @@ export function computeMainUid(options: {
|
|
|
66
93
|
return options.otherUids[0];
|
|
67
94
|
}
|
|
68
95
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
onLeave,
|
|
79
|
-
}: CallViewProps) {
|
|
96
|
+
/** This tab's identity in the call: the account (or guest) uid plus a random suffix, so the same account joining
|
|
97
|
+
* from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
|
|
98
|
+
* `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
|
|
99
|
+
export function newPeerId(selfUid: string): string {
|
|
100
|
+
return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps) {
|
|
104
|
+
const [peerId] = useState(() => newPeerId(selfUid));
|
|
80
105
|
const [connectError, setConnectError] = useState<string | null>(null);
|
|
81
106
|
const [participants, setParticipants] = useState<MeshParticipant[]>([]);
|
|
82
107
|
const [remoteStreams, setRemoteStreams] = useState<Record<string, MediaStream>>({});
|
|
83
108
|
const [levels, setLevels] = useState<Record<string, number>>({});
|
|
84
109
|
const [presenterUid, setPresenterUid] = useState<string | undefined>(undefined);
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
87
|
-
const [isPresenting, setIsPresenting] = useState(false);
|
|
110
|
+
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
|
|
111
|
+
const [handRaised, setHandRaised] = useState(false);
|
|
88
112
|
const [viewMode, setViewMode] = useState<CallViewMode>("grid");
|
|
89
113
|
const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
|
|
90
114
|
const [activeSpeakerUid, setActiveSpeakerUid] = useState<string | undefined>(undefined);
|
|
115
|
+
const [reactions, setReactions] = useState<Reaction[]>([]);
|
|
116
|
+
const [announcement, setAnnouncement] = useState("");
|
|
117
|
+
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
118
|
+
const [audioNonce, setAudioNonce] = useState(0);
|
|
91
119
|
|
|
92
120
|
const managerRef = useRef<MeshConnectionManager | null>(null);
|
|
93
|
-
const clientRef = useRef<GuestSignalingClient | null>(null);
|
|
94
|
-
const localStreamRef = useRef(initialStream);
|
|
95
121
|
const screenStreamRef = useRef<MediaStream | null>(null);
|
|
96
|
-
const cameraTrackRef = useRef<MediaStreamTrack | null>(initialStream.getVideoTracks()[0] ?? null);
|
|
97
122
|
const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
|
|
98
|
-
const
|
|
123
|
+
const reactionSeqRef = useRef(0);
|
|
124
|
+
const reactionTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
125
|
+
const isPresenting = !!screenStream;
|
|
126
|
+
|
|
127
|
+
const showReaction = useCallback((emoji: string, name: string) => {
|
|
128
|
+
const id = ++reactionSeqRef.current;
|
|
129
|
+
setReactions((prev) => [...prev.slice(-(MAX_REACTIONS - 1)), { id, emoji, name }]);
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
reactionTimersRef.current.delete(timer);
|
|
132
|
+
setReactions((prev) => prev.filter((reaction) => reaction.id !== id));
|
|
133
|
+
}, REACTION_MS);
|
|
134
|
+
reactionTimersRef.current.add(timer);
|
|
135
|
+
}, []);
|
|
99
136
|
|
|
100
137
|
useEffect(() => {
|
|
101
138
|
let cancelled = false;
|
|
102
139
|
const client = new GuestSignalingClient({ channel, token });
|
|
103
|
-
clientRef.current = client;
|
|
104
140
|
const manager = new MeshConnectionManager({
|
|
105
141
|
selfUid,
|
|
142
|
+
peerId,
|
|
106
143
|
selfName,
|
|
107
144
|
iceServers,
|
|
108
145
|
channel: client,
|
|
109
146
|
createPeerConnection: createBrowserPeerConnection,
|
|
110
|
-
|
|
147
|
+
localAudioTrack: media.audioTrack,
|
|
148
|
+
localVideoTrack: media.videoTrack,
|
|
149
|
+
localState: { audioOn: media.micOn, videoOn: media.cameraOn },
|
|
111
150
|
});
|
|
112
151
|
managerRef.current = manager;
|
|
113
152
|
|
|
@@ -116,6 +155,9 @@ export default function CallView({
|
|
|
116
155
|
case "participant-joined":
|
|
117
156
|
setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
|
|
118
157
|
return;
|
|
158
|
+
case "participant-updated":
|
|
159
|
+
setParticipants((prev) => prev.map((p) => (p.uid === event.participant.uid ? event.participant : p)));
|
|
160
|
+
return;
|
|
119
161
|
case "participant-left":
|
|
120
162
|
setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
|
|
121
163
|
setRemoteStreams((prev) => {
|
|
@@ -135,9 +177,16 @@ export default function CallView({
|
|
|
135
177
|
setLevels((prev) => ({ ...prev, [event.uid]: level })),
|
|
136
178
|
);
|
|
137
179
|
return;
|
|
180
|
+
case "hand-raised":
|
|
181
|
+
playRaisedHandChime();
|
|
182
|
+
setAnnouncement(`${event.name} raised a hand`);
|
|
183
|
+
return;
|
|
184
|
+
case "reaction":
|
|
185
|
+
showReaction(event.emoji, event.name);
|
|
186
|
+
return;
|
|
138
187
|
case "presenter-changed":
|
|
139
188
|
setPresenterUid(event.uid);
|
|
140
|
-
if (event.uid !==
|
|
189
|
+
if (event.uid !== peerId && screenStreamRef.current) {
|
|
141
190
|
// Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
|
|
142
191
|
// own capture without re-sending a release the manager already handled internally.
|
|
143
192
|
stopLocalPresentation(false);
|
|
@@ -159,8 +208,15 @@ export default function CallView({
|
|
|
159
208
|
}
|
|
160
209
|
});
|
|
161
210
|
|
|
211
|
+
// A closing tab doesn't unmount React, so say goodbye explicitly - otherwise everyone else keeps a tile for a
|
|
212
|
+
// participant who is gone.
|
|
213
|
+
const onPageHide = () => manager.stop();
|
|
214
|
+
window.addEventListener("pagehide", onPageHide);
|
|
215
|
+
|
|
216
|
+
const timers = reactionTimersRef.current;
|
|
162
217
|
return () => {
|
|
163
218
|
cancelled = true;
|
|
219
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
164
220
|
unsubscribe();
|
|
165
221
|
manager.stop();
|
|
166
222
|
client.close();
|
|
@@ -168,13 +224,29 @@ export default function CallView({
|
|
|
168
224
|
handle.stop();
|
|
169
225
|
}
|
|
170
226
|
levelMetersRef.current = {};
|
|
171
|
-
|
|
227
|
+
// Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
|
|
228
|
+
// releases them when the participant leaves.
|
|
172
229
|
stopStream(screenStreamRef.current);
|
|
230
|
+
for (const timer of timers) {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
timers.clear();
|
|
173
234
|
};
|
|
174
235
|
// Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
|
|
175
236
|
// component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
|
|
176
237
|
}, []);
|
|
177
238
|
|
|
239
|
+
// What is sent follows what the participant has: the camera or the shared screen, and the microphone.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
managerRef.current?.setLocalTrack("audio", media.audioTrack);
|
|
242
|
+
}, [media.audioTrack]);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
managerRef.current?.setLocalTrack("video", screenStream?.getVideoTracks()[0] ?? media.videoTrack);
|
|
245
|
+
}, [media.videoTrack, screenStream]);
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
248
|
+
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
249
|
+
|
|
178
250
|
useEffect(() => {
|
|
179
251
|
if (!presenterUid) {
|
|
180
252
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -182,21 +254,17 @@ export default function CallView({
|
|
|
182
254
|
}, [levels, presenterUid]);
|
|
183
255
|
|
|
184
256
|
function stopLocalPresentation(alsoRelease: boolean): void {
|
|
185
|
-
isPresentingRef.current = false;
|
|
186
|
-
setIsPresenting(false);
|
|
187
257
|
if (alsoRelease) {
|
|
188
258
|
managerRef.current?.releasePresenter();
|
|
189
259
|
}
|
|
190
260
|
stopStream(screenStreamRef.current);
|
|
191
261
|
screenStreamRef.current = null;
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
// this is correct whether or not the camera happens to be off right now.
|
|
195
|
-
managerRef.current?.replaceLocalVideoTrack(cameraTrackRef.current);
|
|
262
|
+
// The sync effect above swaps the camera back in as the outgoing video track.
|
|
263
|
+
setScreenStream(null);
|
|
196
264
|
}
|
|
197
265
|
|
|
198
266
|
async function handleToggleShare() {
|
|
199
|
-
if (
|
|
267
|
+
if (screenStreamRef.current) {
|
|
200
268
|
stopLocalPresentation(true);
|
|
201
269
|
return;
|
|
202
270
|
}
|
|
@@ -211,114 +279,208 @@ export default function CallView({
|
|
|
211
279
|
return;
|
|
212
280
|
}
|
|
213
281
|
screenStreamRef.current = result.value;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
isPresentingRef.current = true;
|
|
217
|
-
setIsPresenting(true);
|
|
218
|
-
screenTrack.onended = () => stopLocalPresentation(true);
|
|
282
|
+
setScreenStream(result.value);
|
|
283
|
+
result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
|
|
219
284
|
}
|
|
220
285
|
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
const next = !prev;
|
|
224
|
-
setTracksEnabled(localStreamRef.current, "audio", next);
|
|
225
|
-
return next;
|
|
226
|
-
});
|
|
286
|
+
function handleToggleHand() {
|
|
287
|
+
setHandRaised((prev) => !prev);
|
|
227
288
|
}
|
|
228
289
|
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// whether that track is actually the one being sent (camera) or has been swapped out for a screen
|
|
234
|
-
// share (`replaceLocalVideoTrack()`) is an orthogonal concern this toggle never touches.
|
|
235
|
-
setTracksEnabled(localStreamRef.current, "video", next);
|
|
236
|
-
return next;
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function handleLeave() {
|
|
241
|
-
onLeave();
|
|
290
|
+
function handleReaction(emoji: string) {
|
|
291
|
+
if (managerRef.current?.sendReaction(emoji)) {
|
|
292
|
+
showReaction(emoji, "You");
|
|
293
|
+
}
|
|
242
294
|
}
|
|
243
295
|
|
|
244
296
|
function togglePin(uid: string) {
|
|
245
297
|
setPinnedUid((prev) => (prev === uid ? undefined : uid));
|
|
246
298
|
}
|
|
247
299
|
|
|
300
|
+
function handleAudioBlocked() {
|
|
301
|
+
setAudioBlocked(true);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function handleResumeAudio() {
|
|
305
|
+
setAudioBlocked(false);
|
|
306
|
+
setAudioNonce((prev) => prev + 1);
|
|
307
|
+
}
|
|
308
|
+
|
|
248
309
|
const otherUids = participants.map((p) => p.uid);
|
|
310
|
+
// A pin or an active-speaker pick can name someone who has just left, until the state catches up.
|
|
311
|
+
const stillHere = (uid: string | undefined) => (uid && otherUids.includes(uid) ? uid : undefined);
|
|
249
312
|
const showFocusLayout = !!presenterUid || viewMode === "focus";
|
|
250
|
-
const mainUid = showFocusLayout
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
name={selfName}
|
|
257
|
-
stream={isPresenting ? null : localStreamRef.current}
|
|
258
|
-
isLocal
|
|
259
|
-
cameraOff={!cameraOn && !isPresenting}
|
|
260
|
-
micMuted={!micOn}
|
|
261
|
-
isFocused={mainUid === selfUid}
|
|
262
|
-
onClick={() => togglePin(selfUid)}
|
|
263
|
-
/>
|
|
264
|
-
);
|
|
313
|
+
const mainUid = showFocusLayout
|
|
314
|
+
? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
|
|
315
|
+
: undefined;
|
|
316
|
+
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
317
|
+
const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
|
|
318
|
+
const alone = participants.length === 0;
|
|
265
319
|
|
|
266
|
-
const
|
|
320
|
+
const remoteTile = (p: MeshParticipant, className?: string) => (
|
|
267
321
|
<ParticipantTile
|
|
268
322
|
key={p.uid}
|
|
269
323
|
name={p.name}
|
|
270
324
|
stream={remoteStreams[p.uid] ?? null}
|
|
271
|
-
cameraOff={!
|
|
325
|
+
cameraOff={!p.videoOn}
|
|
326
|
+
micMuted={!p.audioOn}
|
|
327
|
+
handRaised={p.handRaised}
|
|
272
328
|
isFocused={mainUid === p.uid}
|
|
329
|
+
contain={presenterUid === p.uid}
|
|
330
|
+
className={className}
|
|
273
331
|
onClick={() => togglePin(p.uid)}
|
|
274
332
|
/>
|
|
275
|
-
)
|
|
333
|
+
);
|
|
276
334
|
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
335
|
+
const selfTile = (className?: string) => (
|
|
336
|
+
<ParticipantTile
|
|
337
|
+
name={selfName}
|
|
338
|
+
stream={media.videoStream}
|
|
339
|
+
isLocal
|
|
340
|
+
cameraOff={!media.cameraOn}
|
|
341
|
+
micMuted={!media.micOn}
|
|
342
|
+
handRaised={handRaised}
|
|
343
|
+
className={className}
|
|
344
|
+
/>
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const mainParticipant = participants.find((p) => p.uid === mainUid);
|
|
348
|
+
const thumbnails = participants.filter((p) => p.uid !== mainUid);
|
|
349
|
+
|
|
350
|
+
let stage: React.ReactNode;
|
|
351
|
+
if (alone) {
|
|
352
|
+
// Nobody else yet: the local participant fills the tile area, presenting or not.
|
|
353
|
+
stage = isPresenting ? (
|
|
354
|
+
<ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
|
|
355
|
+
) : (
|
|
356
|
+
selfTile("h-full")
|
|
357
|
+
);
|
|
358
|
+
} else if (showFocusLayout && (mainParticipant || isPresenting)) {
|
|
359
|
+
stage = (
|
|
360
|
+
<div className="h-full flex flex-col gap-2">
|
|
361
|
+
<div className="flex-1 min-h-0" data-testid="main-tile">
|
|
362
|
+
{mainParticipant ? (
|
|
363
|
+
remoteTile(mainParticipant, "h-full")
|
|
364
|
+
) : (
|
|
365
|
+
<ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
{thumbnails.length > 0 && (
|
|
369
|
+
<div className="flex gap-2 overflow-x-auto h-24 shrink-0" data-testid="thumbnails">
|
|
370
|
+
{thumbnails.map((p) => (
|
|
371
|
+
<div key={p.uid} className="w-36 shrink-0">
|
|
372
|
+
{remoteTile(p, "h-full")}
|
|
373
|
+
</div>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
} else {
|
|
380
|
+
stage = (
|
|
381
|
+
<div className="h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]">
|
|
382
|
+
{participants.map((p) => remoteTile(p, "h-full"))}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
284
386
|
|
|
285
387
|
return (
|
|
286
|
-
<div className="
|
|
287
|
-
{
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
388
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden">
|
|
389
|
+
<style>{`@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; } }`}</style>
|
|
390
|
+
<header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
|
|
391
|
+
<h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
|
|
392
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
393
|
+
{raisedNames.length > 0 && (
|
|
394
|
+
<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">
|
|
395
|
+
✋ {raisedNames.join(", ")}
|
|
396
|
+
</span>
|
|
397
|
+
)}
|
|
398
|
+
<span className="px-3 py-1.5 rounded-full bg-[#3c4043] text-sm" aria-label={`${participants.length + 1} participants`}>
|
|
399
|
+
{participants.length + 1}
|
|
400
|
+
</span>
|
|
401
|
+
</div>
|
|
402
|
+
</header>
|
|
403
|
+
{connectError && (
|
|
404
|
+
<div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
|
|
405
|
+
{connectError}
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
{audioBlocked && (
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
|
|
412
|
+
onClick={handleResumeAudio}
|
|
413
|
+
>
|
|
414
|
+
Click here to turn on sound
|
|
415
|
+
</button>
|
|
416
|
+
)}
|
|
417
|
+
<main className="relative flex-1 min-h-0 px-3 pb-3">
|
|
418
|
+
{stage}
|
|
419
|
+
{presenterName && presenterUid !== peerId && (
|
|
420
|
+
<p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
|
|
304
421
|
)}
|
|
422
|
+
</main>
|
|
423
|
+
<footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
|
424
|
+
<CallControls
|
|
425
|
+
media={media}
|
|
426
|
+
isPresenting={isPresenting}
|
|
427
|
+
presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
|
|
428
|
+
onToggleShare={() => void handleToggleShare()}
|
|
429
|
+
handRaised={handRaised}
|
|
430
|
+
onToggleHand={handleToggleHand}
|
|
431
|
+
onReaction={handleReaction}
|
|
432
|
+
viewMode={viewMode}
|
|
433
|
+
onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
|
|
434
|
+
onLeave={onLeave}
|
|
435
|
+
/>
|
|
436
|
+
</footer>
|
|
437
|
+
|
|
438
|
+
{!alone && (
|
|
439
|
+
<div
|
|
440
|
+
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"
|
|
441
|
+
data-testid="self-view"
|
|
442
|
+
>
|
|
443
|
+
{selfTile("h-full")}
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
<div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
|
|
448
|
+
{reactions.map((reaction) => (
|
|
449
|
+
<div
|
|
450
|
+
key={reaction.id}
|
|
451
|
+
className="absolute bottom-0 flex flex-col items-center"
|
|
452
|
+
style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
|
|
453
|
+
data-testid="reaction"
|
|
454
|
+
>
|
|
455
|
+
<span className="text-4xl leading-none">{reaction.emoji}</span>
|
|
456
|
+
<span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
|
|
457
|
+
</div>
|
|
458
|
+
))}
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{participants.map((p) => {
|
|
462
|
+
const stream = remoteStreams[p.uid];
|
|
463
|
+
return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
|
|
464
|
+
})}
|
|
465
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
466
|
+
{announcement}
|
|
305
467
|
</div>
|
|
306
|
-
<CallControls
|
|
307
|
-
micOn={micOn}
|
|
308
|
-
onToggleMic={handleToggleMic}
|
|
309
|
-
cameraOn={cameraOn}
|
|
310
|
-
onToggleCamera={handleToggleCamera}
|
|
311
|
-
isPresenting={isPresenting}
|
|
312
|
-
presentingElsewhereName={presenterUid && presenterUid !== selfUid ? presenterName : undefined}
|
|
313
|
-
onToggleShare={handleToggleShare}
|
|
314
|
-
viewMode={viewMode}
|
|
315
|
-
onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
|
|
316
|
-
onLeave={handleLeave}
|
|
317
|
-
/>
|
|
318
468
|
</div>
|
|
319
469
|
);
|
|
320
470
|
}
|
|
321
471
|
|
|
472
|
+
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
473
|
+
* refuses to start it (an autoplay policy) - the view then asks for a click. */
|
|
474
|
+
function RemoteAudio({ stream, onBlocked }: { stream: MediaStream; onBlocked: () => void }) {
|
|
475
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
const element = audioRef.current!;
|
|
478
|
+
element.srcObject = stream;
|
|
479
|
+
void Promise.resolve(element.play()).catch(onBlocked);
|
|
480
|
+
}, [stream, onBlocked]);
|
|
481
|
+
return <audio ref={audioRef} autoPlay data-testid="remote-audio" />;
|
|
482
|
+
}
|
|
483
|
+
|
|
322
484
|
function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
|
|
323
485
|
ref.current[uid]?.stop();
|
|
324
486
|
delete ref.current[uid];
|