@rapidmx/meet-plugin 0.9.2 → 0.10.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 +204 -13
- package/apps/meet/_CallControls.tsx +64 -3
- package/apps/meet/_CallView.tsx +244 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +341 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/deviceMedia.ts +22 -4
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +38 -10
- package/apps/shared/relay/AudioPlayer.ts +30 -4
- package/apps/shared/relay/relayEnv.ts +8 -1
- package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +55 -7
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +9 -1
- package/dist/apps/meet/_CallControls.js +6 -5
- package/dist/apps/meet/_CallView.d.ts +28 -2
- package/dist/apps/meet/_CallView.js +155 -6
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
- package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
- package/dist/apps/meet/_ParticipantTile.js +9 -2
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
- package/dist/apps/meet/_icons.d.ts +3 -0
- package/dist/apps/meet/_icons.js +3 -0
- package/dist/apps/meet/_meetApi.d.ts +93 -1
- package/dist/apps/meet/_meetApi.js +82 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +11 -2
- package/dist/apps/shared/media/deviceMedia.js +20 -4
- package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
- package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
- package/dist/apps/shared/media/useLocalMedia.js +32 -10
- package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
- package/dist/apps/shared/relay/AudioPlayer.js +31 -4
- package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
- package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
- package/dist/apps/shared/webrtc/types.d.ts +54 -6
- package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
- package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/PasswordUtils.js +52 -0
- package/dist/lib/util/PasswordUtils.js.map +1 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
- package/dist/types/models/types.d.ts +23 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
- package/dist/types/util/PasswordUtils.d.ts +17 -0
- package/package.json +1 -1
package/apps/meet/[token].tsx
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
|
|
7
7
|
* slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
|
|
8
|
-
* `loading` -> `
|
|
8
|
+
* `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
|
|
9
|
+
* the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
|
|
10
|
+
* waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
|
|
11
|
+
* after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
|
|
9
12
|
*
|
|
10
13
|
* ## Session-based name prefill - NOT implemented, and why
|
|
11
14
|
*
|
|
@@ -35,13 +38,26 @@ import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
|
|
|
35
38
|
import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
|
|
36
39
|
import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
|
|
37
40
|
import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
|
|
41
|
+
import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
|
|
38
42
|
import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
|
|
39
43
|
import { useLocalMedia } from "../shared/media/useLocalMedia.js";
|
|
40
44
|
import MeetLobby from "./_MeetLobby.js";
|
|
41
45
|
import CallView from "./_CallView.js";
|
|
42
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
type PublicVideoMeeting,
|
|
48
|
+
type VideoMeetingJoinResult,
|
|
49
|
+
joinMeeting,
|
|
50
|
+
pollAdmission,
|
|
51
|
+
requestAdmission,
|
|
52
|
+
verifyMeetingPassword,
|
|
53
|
+
} from "./_meetApi.js";
|
|
43
54
|
|
|
44
|
-
type Phase = "loading" | "not-found" | "lobby" | "in-call" | "ended";
|
|
55
|
+
type Phase = "loading" | "not-found" | "password" | "admission-request" | "waiting" | "denied" | "lobby" | "in-call" | "ended";
|
|
56
|
+
|
|
57
|
+
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";
|
|
58
|
+
|
|
59
|
+
/** How often `MeetJoinContent` polls while in the `"waiting"` phase. */
|
|
60
|
+
const ADMISSION_POLL_MS = 3_000;
|
|
45
61
|
|
|
46
62
|
export default function MeetJoinPage({ params }: { params: { token: string } }) {
|
|
47
63
|
const { branding } = useBranding();
|
|
@@ -56,17 +72,49 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
|
|
|
56
72
|
const [name, setName] = useState("");
|
|
57
73
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
58
74
|
const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
|
|
59
|
-
|
|
75
|
+
/** The restricted `PublicVideoMeeting` `joinMeeting()` returned while this meeting required a password - just
|
|
76
|
+
* enough to show the password prompt's own title/host, since `joinResult` itself stays unset until one is
|
|
77
|
+
* verified (see `handleSubmitPassword()`). Only read while `phase === "password"`. */
|
|
78
|
+
const [passwordMeeting, setPasswordMeeting] = useState<PublicVideoMeeting | null>(null);
|
|
79
|
+
const [passwordInput, setPasswordInput] = useState("");
|
|
80
|
+
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
81
|
+
/** The restricted `PublicVideoMeeting` `joinMeeting()`/`requestAdmission()` returned while this meeting has a
|
|
82
|
+
* waiting room - read on both the `"admission-request"` and `"waiting"` screens. */
|
|
83
|
+
const [admissionMeeting, setAdmissionMeeting] = useState<PublicVideoMeeting | null>(null);
|
|
84
|
+
const [admissionName, setAdmissionName] = useState("");
|
|
85
|
+
const [admissionPassword, setAdmissionPassword] = useState("");
|
|
86
|
+
const [admissionError, setAdmissionError] = useState<string | null>(null);
|
|
87
|
+
/** The guest token `requestAdmission()` minted, to poll with (`Authorization` header - a guest has no session
|
|
88
|
+
* cookie of their own). `undefined` for an already-authenticated real caller, whose existing session cookie
|
|
89
|
+
* already identifies them to `pollAdmission()` with no header needed. */
|
|
90
|
+
const [pendingGuestToken, setPendingGuestToken] = useState<string | undefined>(undefined);
|
|
91
|
+
/** Set only when the call ended without the participant's own action (currently: kicked by the host) - see
|
|
92
|
+
* `CallView`'s `onLeave` doc comment. Shown instead of the ordinary "you left" message; also hides "Rejoin
|
|
93
|
+
* meeting", since a kicked participant's server-side channel grant has just been revoked and a rejoin attempt
|
|
94
|
+
* would only fail. */
|
|
95
|
+
const [endedReason, setEndedReason] = useState<string | undefined>(undefined);
|
|
96
|
+
const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl, forceMuteOnJoin: joinResult?.meeting.forceMuteOnJoin });
|
|
60
97
|
const { release } = media;
|
|
61
98
|
|
|
62
99
|
React.useEffect(() => {
|
|
63
100
|
let cancelled = false;
|
|
64
101
|
joinMeeting(token)
|
|
65
102
|
.then((result) => {
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
if (cancelled) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if ("requiresAdmission" in result) {
|
|
107
|
+
setAdmissionMeeting(result.meeting);
|
|
108
|
+
setPhase("admission-request");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if ("requiresPassword" in result) {
|
|
112
|
+
setPasswordMeeting(result.meeting);
|
|
113
|
+
setPhase("password");
|
|
114
|
+
return;
|
|
69
115
|
}
|
|
116
|
+
setJoinResult(result);
|
|
117
|
+
setPhase("lobby");
|
|
70
118
|
})
|
|
71
119
|
.catch((err) => {
|
|
72
120
|
if (cancelled) {
|
|
@@ -89,8 +137,69 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
|
|
|
89
137
|
setPhase("in-call");
|
|
90
138
|
}
|
|
91
139
|
|
|
92
|
-
function
|
|
140
|
+
async function handleSubmitPassword(e: React.FormEvent) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
setPasswordError(null);
|
|
143
|
+
try {
|
|
144
|
+
const result = await verifyMeetingPassword(token, passwordInput);
|
|
145
|
+
setJoinResult(result);
|
|
146
|
+
setPhase("lobby");
|
|
147
|
+
} catch (err) {
|
|
148
|
+
setPasswordError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function handleRequestAdmission(e: React.FormEvent) {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
setAdmissionError(null);
|
|
155
|
+
try {
|
|
156
|
+
const result = await requestAdmission(token, {
|
|
157
|
+
name: admissionName.trim(),
|
|
158
|
+
...(admissionMeeting?.hasPassword && { password: admissionPassword }),
|
|
159
|
+
});
|
|
160
|
+
setName(admissionName.trim());
|
|
161
|
+
setPendingGuestToken(result.token);
|
|
162
|
+
setPhase("waiting");
|
|
163
|
+
} catch (err) {
|
|
164
|
+
setAdmissionError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Polls while waiting for the host to respond - stops as soon as this phase is left, one way or the other.
|
|
169
|
+
React.useEffect(() => {
|
|
170
|
+
if (phase !== "waiting") {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let cancelled = false;
|
|
174
|
+
const poll = async () => {
|
|
175
|
+
try {
|
|
176
|
+
const result = await pollAdmission(token, pendingGuestToken);
|
|
177
|
+
if (cancelled || "requiresAdmission" in result) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
setJoinResult(result);
|
|
181
|
+
setPhase("lobby");
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (cancelled || !(err instanceof ApiRequestError) || err.status !== 403) {
|
|
184
|
+
// A transient failure (network blip, server hiccup) - stay on this screen and try again next
|
|
185
|
+
// tick, rather than giving up on what might just be one bad request.
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
setAdmissionError(err.message || "The host did not admit you to this meeting.");
|
|
189
|
+
setPhase("denied");
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
void poll();
|
|
193
|
+
const interval = setInterval(() => void poll(), ADMISSION_POLL_MS);
|
|
194
|
+
return () => {
|
|
195
|
+
cancelled = true;
|
|
196
|
+
clearInterval(interval);
|
|
197
|
+
};
|
|
198
|
+
}, [phase, token, pendingGuestToken]);
|
|
199
|
+
|
|
200
|
+
function handleLeave(reason?: string) {
|
|
93
201
|
release();
|
|
202
|
+
setEndedReason(reason);
|
|
94
203
|
setPhase("ended");
|
|
95
204
|
}
|
|
96
205
|
|
|
@@ -109,6 +218,10 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
|
|
|
109
218
|
meetingTitle={joinResult.meeting.title}
|
|
110
219
|
iceServers={joinResult.iceServers}
|
|
111
220
|
relayEnabled={joinResult.relayEnabled}
|
|
221
|
+
hostUid={joinResult.meeting.hostUid}
|
|
222
|
+
initialForceMuteOnJoin={!!joinResult.meeting.forceMuteOnJoin}
|
|
223
|
+
initialHasPassword={!!joinResult.meeting.hasPassword}
|
|
224
|
+
initialWaitingRoomEnabled={!!joinResult.meeting.waitingRoomEnabled}
|
|
112
225
|
media={media}
|
|
113
226
|
onLeave={handleLeave}
|
|
114
227
|
/>
|
|
@@ -128,14 +241,92 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
|
|
|
128
241
|
<Alert>{loadError ?? "This meeting link isn't valid."}</Alert>
|
|
129
242
|
</MeetCard>
|
|
130
243
|
);
|
|
244
|
+
} else if (phase === "password") {
|
|
245
|
+
content = (
|
|
246
|
+
<MeetCard>
|
|
247
|
+
<h1 className="text-2xl font-bold tracking-tight">{passwordMeeting?.title}</h1>
|
|
248
|
+
{passwordMeeting?.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {passwordMeeting.hostDisplayName}</p>}
|
|
249
|
+
<p className="text-base text-text-muted mt-4 mb-3">This meeting requires a password to join.</p>
|
|
250
|
+
<form onSubmit={(e) => void handleSubmitPassword(e)}>
|
|
251
|
+
<FormField label="Password" htmlFor="meet-password">
|
|
252
|
+
<input
|
|
253
|
+
id="meet-password"
|
|
254
|
+
type="password"
|
|
255
|
+
autoFocus
|
|
256
|
+
className={INPUT_CLASS}
|
|
257
|
+
value={passwordInput}
|
|
258
|
+
onChange={(e) => setPasswordInput(e.target.value)}
|
|
259
|
+
/>
|
|
260
|
+
</FormField>
|
|
261
|
+
{passwordError && <Alert>{passwordError}</Alert>}
|
|
262
|
+
<Button type="submit" className="!w-auto mt-3" disabled={!passwordInput}>
|
|
263
|
+
Join meeting
|
|
264
|
+
</Button>
|
|
265
|
+
</form>
|
|
266
|
+
</MeetCard>
|
|
267
|
+
);
|
|
268
|
+
} else if (phase === "admission-request") {
|
|
269
|
+
content = (
|
|
270
|
+
<MeetCard>
|
|
271
|
+
<h1 className="text-2xl font-bold tracking-tight">{admissionMeeting?.title}</h1>
|
|
272
|
+
{admissionMeeting?.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {admissionMeeting.hostDisplayName}</p>}
|
|
273
|
+
<p className="text-base text-text-muted mt-4 mb-3">The host must let you in before you can join.</p>
|
|
274
|
+
<form onSubmit={(e) => void handleRequestAdmission(e)}>
|
|
275
|
+
<FormField label="Your name" htmlFor="admission-name">
|
|
276
|
+
<input
|
|
277
|
+
id="admission-name"
|
|
278
|
+
type="text"
|
|
279
|
+
autoFocus
|
|
280
|
+
className={INPUT_CLASS}
|
|
281
|
+
value={admissionName}
|
|
282
|
+
onChange={(e) => setAdmissionName(e.target.value)}
|
|
283
|
+
/>
|
|
284
|
+
</FormField>
|
|
285
|
+
{admissionMeeting?.hasPassword && (
|
|
286
|
+
<FormField label="Password" htmlFor="admission-password">
|
|
287
|
+
<input
|
|
288
|
+
id="admission-password"
|
|
289
|
+
type="password"
|
|
290
|
+
className={`${INPUT_CLASS} mt-3`}
|
|
291
|
+
value={admissionPassword}
|
|
292
|
+
onChange={(e) => setAdmissionPassword(e.target.value)}
|
|
293
|
+
/>
|
|
294
|
+
</FormField>
|
|
295
|
+
)}
|
|
296
|
+
{admissionError && <Alert>{admissionError}</Alert>}
|
|
297
|
+
<Button
|
|
298
|
+
type="submit"
|
|
299
|
+
className="!w-auto mt-3"
|
|
300
|
+
disabled={!admissionName.trim() || (!!admissionMeeting?.hasPassword && !admissionPassword)}
|
|
301
|
+
>
|
|
302
|
+
Ask to join
|
|
303
|
+
</Button>
|
|
304
|
+
</form>
|
|
305
|
+
</MeetCard>
|
|
306
|
+
);
|
|
307
|
+
} else if (phase === "waiting") {
|
|
308
|
+
content = (
|
|
309
|
+
<MeetCard>
|
|
310
|
+
<h1 className="text-2xl font-bold tracking-tight">{admissionMeeting?.title}</h1>
|
|
311
|
+
<p className="text-base text-text-muted mt-4">Waiting for the host to let you in…</p>
|
|
312
|
+
</MeetCard>
|
|
313
|
+
);
|
|
314
|
+
} else if (phase === "denied") {
|
|
315
|
+
content = (
|
|
316
|
+
<MeetCard>
|
|
317
|
+
<Alert>{admissionError ?? "The host did not admit you to this meeting."}</Alert>
|
|
318
|
+
</MeetCard>
|
|
319
|
+
);
|
|
131
320
|
} else if (phase === "ended") {
|
|
132
321
|
content = (
|
|
133
322
|
<MeetCard>
|
|
134
|
-
<h1 className="text-xl font-bold tracking-tight mb-2">You left the meeting</h1>
|
|
135
|
-
<p className="text-base text-text-muted mb-4">You can close this page now, or rejoin
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
323
|
+
<h1 className="text-xl font-bold tracking-tight mb-2">{endedReason ? "Removed from the meeting" : "You left the meeting"}</h1>
|
|
324
|
+
<p className="text-base text-text-muted mb-4">{endedReason ?? "You can close this page now, or rejoin."}</p>
|
|
325
|
+
{!endedReason && (
|
|
326
|
+
<Button type="button" className="!w-auto" onClick={() => setPhase("lobby")}>
|
|
327
|
+
Rejoin meeting
|
|
328
|
+
</Button>
|
|
329
|
+
)}
|
|
139
330
|
</MeetCard>
|
|
140
331
|
);
|
|
141
332
|
} else {
|
|
@@ -15,19 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import React, { useEffect, useRef, useState } from "react";
|
|
17
17
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
18
|
+
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
18
19
|
import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
|
|
19
|
-
import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
|
|
20
|
+
import { REACTION_EMOJIS, type MeshParticipant } from "../shared/webrtc/types.js";
|
|
21
|
+
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
20
22
|
import EffectsPanel from "./_EffectsPanel.js";
|
|
21
23
|
import {
|
|
22
24
|
ChevronUpIcon,
|
|
25
|
+
DiagnosticsIcon,
|
|
23
26
|
EffectsIcon,
|
|
24
27
|
EmojiIcon,
|
|
28
|
+
FlipIcon,
|
|
25
29
|
FocusIcon,
|
|
26
30
|
GridIcon,
|
|
27
31
|
HandIcon,
|
|
28
32
|
LeaveIcon,
|
|
29
33
|
MicIcon,
|
|
30
34
|
MicOffIcon,
|
|
35
|
+
RotateIcon,
|
|
31
36
|
ScreenShareIcon,
|
|
32
37
|
VideoIcon,
|
|
33
38
|
VideoOffIcon,
|
|
@@ -37,10 +42,16 @@ export type CallViewMode = "grid" | "focus";
|
|
|
37
42
|
|
|
38
43
|
export interface CallControlsProps {
|
|
39
44
|
media: LocalMedia;
|
|
45
|
+
selfName: string;
|
|
46
|
+
participants: MeshParticipant[];
|
|
40
47
|
isPresenting: boolean;
|
|
41
48
|
/** Someone else is presenting - the share button is disabled and explains why. */
|
|
42
49
|
presentingElsewhereName?: string;
|
|
43
50
|
onToggleShare: () => void;
|
|
51
|
+
/** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
|
|
52
|
+
screenTransform: ScreenTransformState;
|
|
53
|
+
onRotateScreen: () => void;
|
|
54
|
+
onFlipScreen: () => void;
|
|
44
55
|
handRaised: boolean;
|
|
45
56
|
onToggleHand: () => void;
|
|
46
57
|
onReaction: (emoji: string) => void;
|
|
@@ -49,7 +60,7 @@ export interface CallControlsProps {
|
|
|
49
60
|
onLeave: () => void;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
type OpenMenu = MediaKind | "emoji" | "effects" | null;
|
|
63
|
+
type OpenMenu = MediaKind | "emoji" | "effects" | "diagnostics" | null;
|
|
53
64
|
|
|
54
65
|
const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
|
|
55
66
|
const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
|
|
@@ -73,9 +84,14 @@ export function LevelBars({ level }: { level: number }) {
|
|
|
73
84
|
|
|
74
85
|
export default function CallControls({
|
|
75
86
|
media,
|
|
87
|
+
selfName,
|
|
88
|
+
participants,
|
|
76
89
|
isPresenting,
|
|
77
90
|
presentingElsewhereName,
|
|
78
91
|
onToggleShare,
|
|
92
|
+
screenTransform,
|
|
93
|
+
onRotateScreen,
|
|
94
|
+
onFlipScreen,
|
|
79
95
|
handRaised,
|
|
80
96
|
onToggleHand,
|
|
81
97
|
onReaction,
|
|
@@ -204,6 +220,29 @@ export default function CallControls({
|
|
|
204
220
|
<ScreenShareIcon />
|
|
205
221
|
</button>
|
|
206
222
|
|
|
223
|
+
{isPresenting && (
|
|
224
|
+
<>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className={`${BUTTON} w-12 ${NEUTRAL}`}
|
|
228
|
+
aria-label="Rotate shared screen"
|
|
229
|
+
title="If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically."
|
|
230
|
+
onClick={onRotateScreen}
|
|
231
|
+
>
|
|
232
|
+
<RotateIcon />
|
|
233
|
+
</button>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className={`${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`}
|
|
237
|
+
aria-label={screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen"}
|
|
238
|
+
aria-pressed={screenTransform.flipped}
|
|
239
|
+
onClick={onFlipScreen}
|
|
240
|
+
>
|
|
241
|
+
<FlipIcon />
|
|
242
|
+
</button>
|
|
243
|
+
</>
|
|
244
|
+
)}
|
|
245
|
+
|
|
207
246
|
<div className="sm:relative">
|
|
208
247
|
<button
|
|
209
248
|
type="button"
|
|
@@ -259,7 +298,29 @@ export default function CallControls({
|
|
|
259
298
|
{viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
|
|
260
299
|
</button>
|
|
261
300
|
|
|
262
|
-
<
|
|
301
|
+
<div className="sm:relative">
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
className={`${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`}
|
|
305
|
+
aria-label="Call diagnostics"
|
|
306
|
+
aria-haspopup="dialog"
|
|
307
|
+
aria-expanded={openMenu === "diagnostics"}
|
|
308
|
+
onClick={() => toggleMenu("diagnostics")}
|
|
309
|
+
>
|
|
310
|
+
<DiagnosticsIcon />
|
|
311
|
+
</button>
|
|
312
|
+
{openMenu === "diagnostics" && (
|
|
313
|
+
<div
|
|
314
|
+
role="dialog"
|
|
315
|
+
aria-label="Call diagnostics"
|
|
316
|
+
className="absolute bottom-full mb-3 right-0 w-80 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl"
|
|
317
|
+
>
|
|
318
|
+
<DiagnosticsPanel selfName={selfName} micOn={media.micOn} cameraOn={media.cameraOn} participants={participants} />
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
|
|
263
324
|
<LeaveIcon />
|
|
264
325
|
</button>
|
|
265
326
|
</div>
|