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