@rapidmx/meet-plugin 0.14.0 → 0.15.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/_CallControls.tsx +119 -2
- package/apps/meet/_CallView.tsx +59 -122
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_ParticipantTile.tsx +82 -9
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_icons.tsx +10 -0
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +52 -1
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +19 -0
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +25 -3
- package/apps/shared/webrtc/types.ts +71 -2
- package/dist/apps/meet/_CallControls.d.ts +24 -2
- package/dist/apps/meet/_CallControls.js +34 -3
- package/dist/apps/meet/_CallView.d.ts +28 -15
- package/dist/apps/meet/_CallView.js +50 -64
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +9 -2
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_icons.d.ts +4 -0
- package/dist/apps/meet/_icons.js +4 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +44 -1
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +4 -0
- package/dist/apps/shared/relay/RelayTransport.js +14 -0
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +20 -1
- package/dist/apps/shared/webrtc/types.d.ts +66 -2
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
|
|
7
|
+
* (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
|
|
8
|
+
export const SILENCE_THRESHOLD = 1e-4;
|
|
9
|
+
/** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
|
|
10
|
+
export const MIN_SILENT_RUN_SECONDS = 0.005;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
|
|
14
|
+
* some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
|
|
15
|
+
* not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
|
|
16
|
+
* full.
|
|
17
|
+
*/
|
|
18
|
+
export class SilenceMeter {
|
|
19
|
+
private totalSeconds = 0;
|
|
20
|
+
private silentSeconds = 0;
|
|
21
|
+
private run = 0;
|
|
22
|
+
private runRate = 0;
|
|
23
|
+
|
|
24
|
+
/** Adds one block of `sampleRate` Hz samples. */
|
|
25
|
+
feed(samples: Float32Array, sampleRate: number): void {
|
|
26
|
+
if (sampleRate !== this.runRate) {
|
|
27
|
+
this.endRun();
|
|
28
|
+
this.runRate = sampleRate;
|
|
29
|
+
}
|
|
30
|
+
for (const sample of samples) {
|
|
31
|
+
if (Math.abs(sample) < SILENCE_THRESHOLD) {
|
|
32
|
+
this.run += 1;
|
|
33
|
+
} else if (this.run > 0) {
|
|
34
|
+
this.endRun();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.totalSeconds += samples.length / sampleRate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Milliseconds of audio fed so far. */
|
|
41
|
+
get totalMs(): number {
|
|
42
|
+
return this.totalSeconds * 1000;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
|
|
46
|
+
get silentMs(): number {
|
|
47
|
+
return (this.silentSeconds + this.runSeconds(this.run)) * 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private runSeconds(run: number): number {
|
|
51
|
+
const seconds = this.runRate > 0 ? run / this.runRate : 0;
|
|
52
|
+
return seconds >= MIN_SILENT_RUN_SECONDS ? seconds : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private endRun(): void {
|
|
56
|
+
this.silentSeconds += this.runSeconds(this.run);
|
|
57
|
+
this.run = 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -134,6 +134,7 @@
|
|
|
134
134
|
* not something a meeting agrees on together.
|
|
135
135
|
*/
|
|
136
136
|
import {
|
|
137
|
+
type ConnectionDiagnostics,
|
|
137
138
|
type MediaTransport,
|
|
138
139
|
type MeshEvent,
|
|
139
140
|
type MeshParticipant,
|
|
@@ -141,6 +142,7 @@ import {
|
|
|
141
142
|
type RTCPeerConnectionFactory,
|
|
142
143
|
type RTCPeerConnectionLike,
|
|
143
144
|
type RTCRtpSenderLike,
|
|
145
|
+
type RelayDiagnostics,
|
|
144
146
|
type RelayTransportLike,
|
|
145
147
|
type SignalMessage,
|
|
146
148
|
type SignalingChannel,
|
|
@@ -184,8 +186,8 @@ interface PeerState extends MeshParticipant {
|
|
|
184
186
|
connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
185
187
|
/** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
|
|
186
188
|
disconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
187
|
-
/** Refreshes `diagnostics` (`MeshParticipant`)
|
|
188
|
-
*
|
|
189
|
+
/** Refreshes `diagnostics` (`MeshParticipant`) - from the `RTCPeerConnection` once it first connects, then (after
|
|
190
|
+
* a restart in `fallBack()`) from the relay's counters once the pair moves there; stopped when the pair ends. */
|
|
189
191
|
diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -663,7 +665,11 @@ export class MeshConnectionManager {
|
|
|
663
665
|
private async pollDiagnostics(peer: PeerState): Promise<void> {
|
|
664
666
|
let diagnostics: MeshParticipant["diagnostics"];
|
|
665
667
|
try {
|
|
666
|
-
|
|
668
|
+
const relay = this.options.relay;
|
|
669
|
+
diagnostics =
|
|
670
|
+
peer.transport === "websocket" && relay?.diagnostics
|
|
671
|
+
? fromRelayDiagnostics(relay.diagnostics(peer.uid))
|
|
672
|
+
: await peer.pc.collectDiagnostics();
|
|
667
673
|
} catch {
|
|
668
674
|
// Leaves whatever was last polled in place rather than blanking a momentary failure.
|
|
669
675
|
return;
|
|
@@ -744,6 +750,9 @@ export class MeshConnectionManager {
|
|
|
744
750
|
});
|
|
745
751
|
this.syncRelaySending();
|
|
746
752
|
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
753
|
+
if (relay.diagnostics) {
|
|
754
|
+
this.startDiagnosticsPolling(peer);
|
|
755
|
+
}
|
|
747
756
|
if (notify) {
|
|
748
757
|
this.send({ kind: "relay-fallback", to: peer.uid });
|
|
749
758
|
}
|
|
@@ -925,6 +934,19 @@ function toParticipant(peer: PeerState): MeshParticipant {
|
|
|
925
934
|
};
|
|
926
935
|
}
|
|
927
936
|
|
|
937
|
+
/** A relayed pair's diagnostics: the relay's own counters, with the part that maps onto RTP stats (round trip, frames
|
|
938
|
+
* lost, bytes each way) also filled into the ordinary fields, so the panel's ordinary rows show it too. The round trip
|
|
939
|
+
* is to the relay server, not to the peer - see `RelayDiagnostics.roundTripMs`. */
|
|
940
|
+
function fromRelayDiagnostics(relay: RelayDiagnostics): ConnectionDiagnostics {
|
|
941
|
+
const { send, receive } = relay;
|
|
942
|
+
return {
|
|
943
|
+
roundTripTimeSeconds: relay.roundTripMs === undefined ? undefined : relay.roundTripMs / 1000,
|
|
944
|
+
audio: { packetsLost: receive?.audio.framesLost, bytesSent: send.audio.bytesSent, bytesReceived: receive?.audio.bytesReceived },
|
|
945
|
+
video: { packetsLost: receive?.video.framesLost, bytesSent: send.video.bytesSent, bytesReceived: receive?.video.bytesReceived },
|
|
946
|
+
relay,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
928
950
|
function sameState(a: ParticipantState, b: ParticipantState): boolean {
|
|
929
951
|
return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
|
|
930
952
|
}
|
|
@@ -139,6 +139,71 @@ export interface ConnectionDiagnostics {
|
|
|
139
139
|
roundTripTimeSeconds?: number;
|
|
140
140
|
audio: RtpStreamDiagnostics;
|
|
141
141
|
video: RtpStreamDiagnostics;
|
|
142
|
+
/** The WebSocket relay's own counters, present only for a `"websocket"` pair - `audio`/`video` then carry the
|
|
143
|
+
* subset of them that maps onto RTP stats (frames lost, bytes each way). */
|
|
144
|
+
relay?: RelayDiagnostics;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What the relay's sending half did with one kind of frame, cumulative since the relay transport was created. */
|
|
148
|
+
export interface RelaySendStats {
|
|
149
|
+
framesSent: number;
|
|
150
|
+
/** Frames encoded but not sent: the socket was not ready, backed up, or refused a fragment. */
|
|
151
|
+
framesDropped: number;
|
|
152
|
+
bytesSent: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What the relay's sending half has done, cumulative since the relay transport was created. It is per browser, not
|
|
156
|
+
* per peer: every relayed peer receives the same stream. */
|
|
157
|
+
export interface RelaySendDiagnostics {
|
|
158
|
+
/** Milliseconds of microphone audio the capture path delivered, against `audioCaptureWallMs` - the wall-clock time
|
|
159
|
+
* it was capturing for. Well short of it means capture is losing whole blocks. */
|
|
160
|
+
audioCapturedMs: number;
|
|
161
|
+
audioCaptureWallMs: number;
|
|
162
|
+
/** How much of `audioCapturedMs` was digital silence (see `SilenceMeter`) - a live microphone always has some
|
|
163
|
+
* noise, so a large share means the capture path is handing the encoder zero-filled blocks. */
|
|
164
|
+
audioSilentMs: number;
|
|
165
|
+
/** Milliseconds of captured audio not encoded because the encoder was too far behind. */
|
|
166
|
+
audioEncoderSkippedMs: number;
|
|
167
|
+
audio: RelaySendStats;
|
|
168
|
+
video: RelaySendStats;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What the relay's receiving half got of one kind of frame from one peer, cumulative. */
|
|
172
|
+
export interface RelayReceiveStats {
|
|
173
|
+
framesReceived: number;
|
|
174
|
+
/** Frames the sequence numbers say never arrived complete - dropped by the sender, the server or in transit. */
|
|
175
|
+
framesLost: number;
|
|
176
|
+
bytesReceived: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What the relay's receiving half has done with one peer's media, cumulative since it started receiving from them. */
|
|
180
|
+
export interface RelayReceiveDiagnostics {
|
|
181
|
+
audio: RelayReceiveStats & {
|
|
182
|
+
/** Milliseconds of decoded audio scheduled for playback. */
|
|
183
|
+
playedMs: number;
|
|
184
|
+
/** How much of `playedMs` was digital silence - silence that arrived inside the packets, i.e. the sender sent
|
|
185
|
+
* it, as opposed to `gapMs`. */
|
|
186
|
+
silentMs: number;
|
|
187
|
+
/** Times playback ran dry and restarted, and the total silence that inserted. */
|
|
188
|
+
gaps: number;
|
|
189
|
+
gapMs: number;
|
|
190
|
+
/** Packets dropped on arrival because the decoder was backed up or playback was already too far ahead. */
|
|
191
|
+
droppedLate: number;
|
|
192
|
+
/** How far ahead of the clock audio is scheduled right now - the receive buffer's current depth. */
|
|
193
|
+
bufferedMs: number;
|
|
194
|
+
};
|
|
195
|
+
video: RelayReceiveStats;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One sample of the relay's counters for one peer. */
|
|
199
|
+
export interface RelayDiagnostics {
|
|
200
|
+
/** The latest round trip from this browser to the relay server, in milliseconds - not to the peer: the relay has
|
|
201
|
+
* no direct path between the two, and each end only knows its own leg. `undefined` before the first pong, or for
|
|
202
|
+
* a server too old to answer pings. */
|
|
203
|
+
roundTripMs?: number;
|
|
204
|
+
send: RelaySendDiagnostics;
|
|
205
|
+
/** `undefined` when nothing is being received from this peer. */
|
|
206
|
+
receive?: RelayReceiveDiagnostics;
|
|
142
207
|
}
|
|
143
208
|
|
|
144
209
|
/** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
|
|
@@ -206,6 +271,9 @@ export interface RelayTransportLike {
|
|
|
206
271
|
setSending(active: boolean): void;
|
|
207
272
|
/** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
|
|
208
273
|
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
|
|
274
|
+
/** The relay's counters as they stand for `peerId`, for the diagnostics panel. Optional so a test's fake relay
|
|
275
|
+
* needn't provide it; the mesh then shows nothing for a relayed pair. */
|
|
276
|
+
diagnostics?(peerId: string): RelayDiagnostics;
|
|
209
277
|
close(): void;
|
|
210
278
|
}
|
|
211
279
|
|
|
@@ -222,8 +290,9 @@ export interface MeshParticipant extends ParticipantState {
|
|
|
222
290
|
/** How this participant's media currently reaches the local one - see `MediaTransport`. */
|
|
223
291
|
transport: MediaTransport;
|
|
224
292
|
/** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
|
|
225
|
-
* first poll completes (shortly after `transport` first leaves `"connecting"
|
|
226
|
-
* a `"websocket"`-relayed participant
|
|
293
|
+
* first poll completes (shortly after `transport` first leaves `"connecting"`, or the pair moves to the relay).
|
|
294
|
+
* For a `"websocket"`-relayed participant they come from the relay's own counters (`relay`) rather than an
|
|
295
|
+
* `RTCPeerConnection`. */
|
|
227
296
|
diagnostics?: ConnectionDiagnostics;
|
|
228
297
|
}
|
|
229
298
|
|
|
@@ -10,11 +10,23 @@
|
|
|
10
10
|
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
11
11
|
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
12
12
|
* `_CallView.tsx`'s doc comment.
|
|
13
|
+
*
|
|
14
|
+
* ## The "…" menu
|
|
15
|
+
*
|
|
16
|
+
* Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
|
|
17
|
+
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
18
|
+
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
19
|
+
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
20
|
+
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
|
|
21
|
+
* "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
|
|
22
|
+
* grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
|
|
23
|
+
* where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
|
|
24
|
+
* Escape/outside click like every other one here).
|
|
13
25
|
*/
|
|
14
26
|
import React from "react";
|
|
15
27
|
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
16
28
|
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
17
|
-
import { type MeshParticipant } from "../shared/webrtc/types.js";
|
|
29
|
+
import { type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
|
|
18
30
|
export type CallViewMode = "grid" | "focus";
|
|
19
31
|
export interface CallControlsProps {
|
|
20
32
|
media: LocalMedia;
|
|
@@ -38,10 +50,20 @@ export interface CallControlsProps {
|
|
|
38
50
|
* doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
|
|
39
51
|
* doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
|
|
40
52
|
micLocked?: boolean;
|
|
53
|
+
/** Whether the "…" menu's "Settings" item is offered at all - see this module's doc comment on the menu. */
|
|
54
|
+
isHost: boolean;
|
|
55
|
+
transportMode: TransportMode;
|
|
56
|
+
onSetTransportMode: (mode: TransportMode) => void;
|
|
57
|
+
onOpenDiagnostics: () => void;
|
|
58
|
+
onOpenSettings: () => void;
|
|
59
|
+
/** Whether the local tile is currently hidden - the "…" menu's "Hide self"/"Show self" item reflects and
|
|
60
|
+
* toggles this; see `_CallView.tsx`'s doc comment on where the local tile appears. */
|
|
61
|
+
selfHidden: boolean;
|
|
62
|
+
onToggleSelfHidden: () => void;
|
|
41
63
|
onLeave: () => void;
|
|
42
64
|
}
|
|
43
65
|
/** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
|
|
44
66
|
export declare function LevelBars({ level }: {
|
|
45
67
|
level: number;
|
|
46
68
|
}): React.JSX.Element;
|
|
47
|
-
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
69
|
+
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
@@ -15,12 +15,31 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
15
15
|
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
16
16
|
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
17
17
|
* `_CallView.tsx`'s doc comment.
|
|
18
|
+
*
|
|
19
|
+
* ## The "…" menu
|
|
20
|
+
*
|
|
21
|
+
* Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
|
|
22
|
+
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
23
|
+
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
24
|
+
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
25
|
+
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
|
|
26
|
+
* "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
|
|
27
|
+
* grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
|
|
28
|
+
* where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
|
|
29
|
+
* Escape/outside click like every other one here).
|
|
18
30
|
*/
|
|
19
31
|
import { useEffect, useRef, useState } from "react";
|
|
20
32
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
21
33
|
import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
|
|
22
34
|
import EffectsPanel from "./_EffectsPanel.js";
|
|
23
|
-
import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
|
|
35
|
+
import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
|
|
36
|
+
/** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
|
|
37
|
+
const TRANSPORT_MODE_OPTIONS = [
|
|
38
|
+
{ mode: "auto", label: "Auto" },
|
|
39
|
+
{ mode: "p2p", label: "P2P" },
|
|
40
|
+
{ mode: "relay", label: "Relay" },
|
|
41
|
+
{ mode: "websocket", label: "WebSocket Relay" },
|
|
42
|
+
];
|
|
24
43
|
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";
|
|
25
44
|
const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
|
|
26
45
|
const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
|
|
@@ -29,7 +48,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
|
29
48
|
export function LevelBars({ level }) {
|
|
30
49
|
return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
|
|
31
50
|
}
|
|
32
|
-
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
|
|
51
|
+
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }) {
|
|
33
52
|
const [openMenu, setOpenMenu] = useState(null);
|
|
34
53
|
const barRef = useRef(null);
|
|
35
54
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
@@ -61,7 +80,19 @@ export default function CallControls({ media, participants, isPresenting, presen
|
|
|
61
80
|
return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, disabled: micLocked, title: micLocked ? "Only the current talking-stick holder can unmute." : undefined, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "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.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", 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.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
|
|
62
81
|
onReaction(emoji);
|
|
63
82
|
setOpenMenu(null);
|
|
64
|
-
}, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-
|
|
83
|
+
}, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": openMenu === "overflow", onClick: () => toggleMenu("overflow"), children: _jsx(OverflowIcon, {}) }), openMenu === "overflow" && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: [_jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
84
|
+
onOpenDiagnostics();
|
|
85
|
+
setOpenMenu(null);
|
|
86
|
+
}, children: "Diagnostics" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
87
|
+
onToggleSelfHidden();
|
|
88
|
+
setOpenMenu(null);
|
|
89
|
+
}, children: selfHidden ? "Show self" : "Hide self" }), _jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("p", { className: "px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60", children: "Connection method" }), TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": transportMode === mode, className: "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
90
|
+
onSetTransportMode(mode);
|
|
91
|
+
setOpenMenu(null);
|
|
92
|
+
}, children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: transportMode === mode ? "✓" : "" }), label] }, mode))), isHost && (_jsxs(_Fragment, { children: [_jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
93
|
+
onOpenSettings();
|
|
94
|
+
setOpenMenu(null);
|
|
95
|
+
}, children: "Settings" })] }))] }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
|
|
65
96
|
}
|
|
66
97
|
/** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
|
|
67
98
|
* one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
|
|
@@ -11,10 +11,24 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
13
13
|
* is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
|
|
14
|
-
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
15
|
+
*
|
|
16
|
+
* ## Where the local tile appears
|
|
17
|
+
*
|
|
18
|
+
* In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
|
|
19
|
+
* everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
|
|
20
|
+
* genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
|
|
21
|
+
* tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
|
|
22
|
+
* is reserved for focus/presentation layouts, where there's already a single large tile the local one would
|
|
23
|
+
* otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
|
|
24
|
+
* (see the `alone` branch of the `stage` computation).
|
|
25
|
+
*
|
|
26
|
+
* `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
|
|
27
|
+
* it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
|
|
28
|
+
* directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
|
|
29
|
+
* "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
|
|
30
|
+
* nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
|
|
31
|
+
* just blank it, so the local tile ignores `selfHidden` there and always shows.
|
|
18
32
|
*
|
|
19
33
|
* ## Who is heard
|
|
20
34
|
*
|
|
@@ -47,18 +61,17 @@
|
|
|
47
61
|
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
48
62
|
* the floor, until the host picks someone (or themselves) again.
|
|
49
63
|
*
|
|
50
|
-
* ##
|
|
64
|
+
* ## Diagnostics, connection method and settings
|
|
51
65
|
*
|
|
52
|
-
*
|
|
53
|
-
* `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
59
|
-
* waiting-room toggle) - moved out because none of those are
|
|
60
|
-
*
|
|
61
|
-
* close along with it.
|
|
66
|
+
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
67
|
+
* callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
68
|
+
* window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
|
|
69
|
+
* participant wants to keep open while they poke at the rest of the call, not something that closes the moment
|
|
70
|
+
* they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
|
|
71
|
+
* else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
|
|
72
|
+
* anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
73
|
+
* own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
|
|
74
|
+
* about *who* is in the call, which is what the drawer is for.
|
|
62
75
|
*/
|
|
63
76
|
import React from "react";
|
|
64
77
|
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|