@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.
Files changed (60) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +64 -3
  3. package/apps/meet/_CallView.tsx +244 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +341 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/deviceMedia.ts +22 -4
  10. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  11. package/apps/shared/media/useLocalMedia.ts +38 -10
  12. package/apps/shared/relay/AudioPlayer.ts +30 -4
  13. package/apps/shared/relay/relayEnv.ts +8 -1
  14. package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
  15. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  16. package/apps/shared/webrtc/types.ts +55 -7
  17. package/dist/apps/meet/[token].d.ts +4 -1
  18. package/dist/apps/meet/[token].js +121 -9
  19. package/dist/apps/meet/_CallControls.d.ts +9 -1
  20. package/dist/apps/meet/_CallControls.js +6 -5
  21. package/dist/apps/meet/_CallView.d.ts +28 -2
  22. package/dist/apps/meet/_CallView.js +155 -6
  23. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  24. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  25. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  26. package/dist/apps/meet/_ParticipantTile.js +9 -2
  27. package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
  28. package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
  29. package/dist/apps/meet/_icons.d.ts +3 -0
  30. package/dist/apps/meet/_icons.js +3 -0
  31. package/dist/apps/meet/_meetApi.d.ts +93 -1
  32. package/dist/apps/meet/_meetApi.js +82 -0
  33. package/dist/apps/shared/media/deviceMedia.d.ts +11 -2
  34. package/dist/apps/shared/media/deviceMedia.js +20 -4
  35. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  36. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  37. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  38. package/dist/apps/shared/media/useLocalMedia.js +32 -10
  39. package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
  40. package/dist/apps/shared/relay/AudioPlayer.js +31 -4
  41. package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
  42. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
  43. package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
  44. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  45. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  46. package/dist/apps/shared/webrtc/types.d.ts +54 -6
  47. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  48. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  49. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  50. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  51. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  52. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  53. package/dist/lib/util/PasswordUtils.js +52 -0
  54. package/dist/lib/util/PasswordUtils.js.map +1 -0
  55. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  56. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  57. package/dist/types/models/types.d.ts +23 -0
  58. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  59. package/dist/types/util/PasswordUtils.d.ts +17 -0
  60. package/package.json +1 -1
@@ -14,6 +14,19 @@ export interface PublicVideoMeeting {
14
14
  visibility: VideoMeetingVisibility;
15
15
  status: VideoMeetingStatus;
16
16
  hostDisplayName?: string;
17
+ /** The account uid that owns the meeting - the sole source of a "host" identity on the client. Absent when the
18
+ * server can't resolve one (an orphaned mailbox, or a server that predates this field), in which case no host
19
+ * controls are shown to anyone. */
20
+ hostUid?: string;
21
+ /** When `true`, a joining participant's microphone starts muted - see `useLocalMedia`'s `forceMuteOnJoin`
22
+ * option, which this is threaded into. Absent reads as `false`. */
23
+ forceMuteOnJoin?: boolean;
24
+ /** Whether this meeting currently requires a password - never the password or its hash, just whether one is
25
+ * set. Absent reads as `false`. */
26
+ hasPassword?: boolean;
27
+ /** Whether this meeting currently requires the host to admit each participant before they join - see
28
+ * `requestAdmission()`. Absent reads as `false`. */
29
+ waitingRoomEnabled?: boolean;
17
30
  }
18
31
  /**
19
32
  * Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingJoinResult`. `authenticated: true` means the visiting browser
@@ -42,6 +55,31 @@ export interface VideoMeetingJoinResult {
42
55
  * hosts them (`mail:videoconf:effects:assets_url`). Absent means the public CDN defaults. */
43
56
  effectsAssetsUrl?: string;
44
57
  }
58
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingPasswordRequiredResult` - returned by `joinMeeting()` in place
59
+ * of a `VideoMeetingJoinResult` when the meeting requires a password that hasn't been submitted yet. Carries only
60
+ * enough to show a password prompt (the meeting's title/host); nothing here grants anything. Narrow on
61
+ * `"requiresPassword" in result && result.requiresPassword`. */
62
+ export interface VideoMeetingPasswordRequiredResult {
63
+ meeting: PublicVideoMeeting;
64
+ requiresPassword: true;
65
+ }
66
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingAdmissionRequiredResult` - returned by `requestAdmission()`
67
+ * (and re-returned by `pollAdmission()`) while a `waitingRoomEnabled` meeting's admission request is still
68
+ * pending. Unlike `VideoMeetingPasswordRequiredResult`, this carries `selfUid` (and, for a guest, `token`) - a
69
+ * caller needs a stable, authenticated identity to poll with. `joinMeeting()` itself also answers this shape, with
70
+ * none of those fields, when a waiting room applies and no request has been filed yet. Narrow on
71
+ * `"requiresAdmission" in result`. */
72
+ export interface VideoMeetingAdmissionRequiredResult {
73
+ meeting: PublicVideoMeeting;
74
+ requiresAdmission: true;
75
+ authenticated?: boolean;
76
+ selfUid?: string;
77
+ token?: string;
78
+ expiresAt?: string;
79
+ }
80
+ /** `joinMeeting()`'s actual return type - see `VideoMeetingPasswordRequiredResult`'s and
81
+ * `VideoMeetingAdmissionRequiredResult`'s own doc comments. */
82
+ export type VideoMeetingJoinResponse = VideoMeetingJoinResult | VideoMeetingPasswordRequiredResult | VideoMeetingAdmissionRequiredResult;
45
83
  /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
46
84
  * either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
47
85
  * existing session already grants them signaling access (`authenticated: true` - see
@@ -50,4 +88,58 @@ export interface VideoMeetingJoinResult {
50
88
  * private/public token shape for what it's being used against - the caller (`[token].tsx`) shows a single,
51
89
  * friendly "this meeting link isn't valid" state for all of those rather than trying to distinguish them, matching
52
90
  * `BaseVideoMeetingRoute.requireMeetingByToken()`'s own explicit "never leak which almost-matched" posture. */
53
- export declare function joinMeeting(token: string): Promise<VideoMeetingJoinResult>;
91
+ export declare function joinMeeting(token: string): Promise<VideoMeetingJoinResponse>;
92
+ /** Submits a password for a meeting `joinMeeting()` reported `requiresPassword` for. On success, returns an
93
+ * ordinary granted `VideoMeetingJoinResult` - the same shape and same meaning as `joinMeeting()`'s own non-password
94
+ * case. Rejects with `ApiRequestError` (status `403`) for a wrong or missing password. */
95
+ export declare function verifyMeetingPassword(token: string, password: string): Promise<VideoMeetingJoinResult>;
96
+ /** Sets, replaces, or removes (`password: null`) the meeting's join password - host only, enforced the same way
97
+ * as every other owner-side video-meeting update. Hashed server-side; the plaintext is never stored or returned. */
98
+ export declare function setMeetingPassword(meetingUid: string, password: string | null): Promise<void>;
99
+ /** Files an admission request for a meeting `joinMeeting()` reported `requiresAdmission` for - `password` only
100
+ * when `meeting.hasPassword` is also set (both are checked together on this one call, never in two steps). On
101
+ * success returns `VideoMeetingAdmissionRequiredResult` with a `selfUid` (and, for a guest, `token`) to poll with
102
+ * via `pollAdmission()` - nothing is granted yet. Rejects with `ApiRequestError` (status `403`) for a wrong
103
+ * password, or `400` if `meeting.hasPassword` and no password was given. */
104
+ export declare function requestAdmission(token: string, body: {
105
+ name: string;
106
+ password?: string;
107
+ }): Promise<VideoMeetingAdmissionRequiredResult>;
108
+ /** Polls whether the host has responded to a pending admission request - authenticated as whichever identity
109
+ * `requestAdmission()` (or a previous poll) returned: `guestToken`, when given, is sent as an `Authorization`
110
+ * header (a guest has no session cookie of their own); omitted for an already-authenticated real caller, whose
111
+ * existing session cookie already identifies them. Resolves to a granted `VideoMeetingJoinResult` once admitted,
112
+ * or re-resolves to `VideoMeetingAdmissionRequiredResult` while still pending. Rejects with `ApiRequestError`
113
+ * (status `403`) once denied, or `404` if this identity never requested admission (or already polled past a final
114
+ * answer). */
115
+ export declare function pollAdmission(token: string, guestToken?: string): Promise<VideoMeetingJoinResult | VideoMeetingAdmissionRequiredResult>;
116
+ /** One pending admission request, as `listWaitingParticipants()` returns it. */
117
+ export interface WaitingParticipant {
118
+ uid: string;
119
+ name: string;
120
+ requestedAt: string;
121
+ }
122
+ /** Lists a meeting's pending admission requests, oldest first - host only. */
123
+ export declare function listWaitingParticipants(meetingUid: string): Promise<WaitingParticipant[]>;
124
+ /** Admits a pending admission request - host only. The requester's own next `pollAdmission()` call completes
125
+ * their join; this alone does not notify them of anything. A no-op if `uid` has no pending request. */
126
+ export declare function admitParticipant(meetingUid: string, uid: string): Promise<void>;
127
+ /** Denies a pending admission request - host only. The requester's own next `pollAdmission()` call then rejects
128
+ * with a 403. A no-op if `uid` has no pending request. */
129
+ export declare function denyParticipant(meetingUid: string, uid: string): Promise<void>;
130
+ /** Sets whether the meeting requires the host to admit each participant (`PublicVideoMeeting.waitingRoomEnabled`) -
131
+ * host only, enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who
132
+ * joins (or reloads the join link and joins again) after this call resolves. */
133
+ export declare function setWaitingRoomEnabled(meetingUid: string, waitingRoomEnabled: boolean): Promise<void>;
134
+ /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
135
+ * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
136
+ * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
137
+ * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
138
+ * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
139
+ * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
140
+ export declare function kickParticipant(meetingUid: string, uid: string): Promise<void>;
141
+ /** Sets whether a newly joining participant starts muted (`PublicVideoMeeting.forceMuteOnJoin`) - host only,
142
+ * enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who joins (or
143
+ * reloads the join link and joins again) after this call resolves; it does not retroactively mute anyone already
144
+ * in the call - the host's own "Mute" button (see `kickParticipant()`) is the tool for that. */
145
+ export declare function setForceMuteOnJoin(meetingUid: string, forceMuteOnJoin: boolean): Promise<void>;
@@ -21,3 +21,85 @@ import { apiFetch } from "@rapidmx/web-client/lib/util/api.js";
21
21
  export function joinMeeting(token) {
22
22
  return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}`);
23
23
  }
24
+ /** Submits a password for a meeting `joinMeeting()` reported `requiresPassword` for. On success, returns an
25
+ * ordinary granted `VideoMeetingJoinResult` - the same shape and same meaning as `joinMeeting()`'s own non-password
26
+ * case. Rejects with `ApiRequestError` (status `403`) for a wrong or missing password. */
27
+ export function verifyMeetingPassword(token, password) {
28
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/verify`, {
29
+ method: "POST",
30
+ body: JSON.stringify({ password }),
31
+ });
32
+ }
33
+ /** Sets, replaces, or removes (`password: null`) the meeting's join password - host only, enforced the same way
34
+ * as every other owner-side video-meeting update. Hashed server-side; the plaintext is never stored or returned. */
35
+ export function setMeetingPassword(meetingUid, password) {
36
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
37
+ method: "PUT",
38
+ body: JSON.stringify({ password }),
39
+ });
40
+ }
41
+ /** Files an admission request for a meeting `joinMeeting()` reported `requiresAdmission` for - `password` only
42
+ * when `meeting.hasPassword` is also set (both are checked together on this one call, never in two steps). On
43
+ * success returns `VideoMeetingAdmissionRequiredResult` with a `selfUid` (and, for a guest, `token`) to poll with
44
+ * via `pollAdmission()` - nothing is granted yet. Rejects with `ApiRequestError` (status `403`) for a wrong
45
+ * password, or `400` if `meeting.hasPassword` and no password was given. */
46
+ export function requestAdmission(token, body) {
47
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/verify`, {
48
+ method: "POST",
49
+ body: JSON.stringify(body),
50
+ });
51
+ }
52
+ /** Polls whether the host has responded to a pending admission request - authenticated as whichever identity
53
+ * `requestAdmission()` (or a previous poll) returned: `guestToken`, when given, is sent as an `Authorization`
54
+ * header (a guest has no session cookie of their own); omitted for an already-authenticated real caller, whose
55
+ * existing session cookie already identifies them. Resolves to a granted `VideoMeetingJoinResult` once admitted,
56
+ * or re-resolves to `VideoMeetingAdmissionRequiredResult` while still pending. Rejects with `ApiRequestError`
57
+ * (status `403`) once denied, or `404` if this identity never requested admission (or already polled past a final
58
+ * answer). */
59
+ export function pollAdmission(token, guestToken) {
60
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/status`, {
61
+ headers: guestToken ? { Authorization: `jwt ${guestToken}` } : undefined,
62
+ });
63
+ }
64
+ /** Lists a meeting's pending admission requests, oldest first - host only. */
65
+ export function listWaitingParticipants(meetingUid) {
66
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/waiting`);
67
+ }
68
+ /** Admits a pending admission request - host only. The requester's own next `pollAdmission()` call completes
69
+ * their join; this alone does not notify them of anything. A no-op if `uid` has no pending request. */
70
+ export function admitParticipant(meetingUid, uid) {
71
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/admit/${encodeURIComponent(uid)}`, { method: "POST" });
72
+ }
73
+ /** Denies a pending admission request - host only. The requester's own next `pollAdmission()` call then rejects
74
+ * with a 403. A no-op if `uid` has no pending request. */
75
+ export function denyParticipant(meetingUid, uid) {
76
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/deny/${encodeURIComponent(uid)}`, { method: "POST" });
77
+ }
78
+ /** Sets whether the meeting requires the host to admit each participant (`PublicVideoMeeting.waitingRoomEnabled`) -
79
+ * host only, enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who
80
+ * joins (or reloads the join link and joins again) after this call resolves. */
81
+ export function setWaitingRoomEnabled(meetingUid, waitingRoomEnabled) {
82
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
83
+ method: "PUT",
84
+ body: JSON.stringify({ waitingRoomEnabled }),
85
+ });
86
+ }
87
+ /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
88
+ * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
89
+ * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
90
+ * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
91
+ * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
92
+ * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
93
+ export function kickParticipant(meetingUid, uid) {
94
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/kick/${encodeURIComponent(uid)}`, { method: "POST" });
95
+ }
96
+ /** Sets whether a newly joining participant starts muted (`PublicVideoMeeting.forceMuteOnJoin`) - host only,
97
+ * enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who joins (or
98
+ * reloads the join link and joins again) after this call resolves; it does not retroactively mute anyone already
99
+ * in the call - the host's own "Mute" button (see `kickParticipant()`) is the tool for that. */
100
+ export function setForceMuteOnJoin(meetingUid, forceMuteOnJoin) {
101
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
102
+ method: "PUT",
103
+ body: JSON.stringify({ forceMuteOnJoin }),
104
+ });
105
+ }
@@ -36,10 +36,19 @@ export type MediaResult<T> = {
36
36
  export declare function defaultMediaDevices(): MediaDevicesLike | undefined;
37
37
  /** Whether `devices.getUserMedia` is actually callable - the one capability every caller here needs at minimum. */
38
38
  export declare function isMediaDevicesSupported(devices?: MediaDevicesLike | undefined): boolean;
39
+ /** A "no such device" error naming exactly what was missing: just the microphone, just the camera, or - when
40
+ * both were asked for (or a caller doesn't say, e.g. `listDevices()`/`requestDisplayMedia()`'s own failures, which
41
+ * have no single "kind" to name) - both. Exported so `useLocalMedia.ts`'s `acquireBoth()` can build the same
42
+ * "both missing" message after finding out separately that neither device exists, rather than only being able to
43
+ * report whichever one it happened to ask about last. */
44
+ export declare function notFoundError(wantsAudio: boolean, wantsVideo: boolean): MediaAccessError;
39
45
  /** Maps a `getUserMedia()`/`getDisplayMedia()`/`enumerateDevices()` rejection to a friendly, already-complete
40
46
  * message - the handful of `DOMException` names a browser actually raises for these calls, matched by name rather
41
- * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). */
42
- export declare function classifyMediaError(err: unknown): MediaAccessError;
47
+ * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). `requested` is the
48
+ * constraints the failed call actually asked for, so a "not found" error can name just the camera or just the
49
+ * microphone rather than always blaming both - omitted (by `listDevices()`/`requestDisplayMedia()`, neither of
50
+ * which asks for one kind specifically) it's treated as having asked for both. */
51
+ export declare function classifyMediaError(err: unknown, requested?: MediaStreamConstraints): MediaAccessError;
43
52
  export interface DeviceLists {
44
53
  cameras: MediaDeviceInfo[];
45
54
  microphones: MediaDeviceInfo[];
@@ -29,10 +29,26 @@ const UNSUPPORTED_ERROR = {
29
29
  kind: "unsupported",
30
30
  message: "This browser (or this page's connection) doesn't support camera/microphone access. Try a modern browser over HTTPS.",
31
31
  };
32
+ /** A "no such device" error naming exactly what was missing: just the microphone, just the camera, or - when
33
+ * both were asked for (or a caller doesn't say, e.g. `listDevices()`/`requestDisplayMedia()`'s own failures, which
34
+ * have no single "kind" to name) - both. Exported so `useLocalMedia.ts`'s `acquireBoth()` can build the same
35
+ * "both missing" message after finding out separately that neither device exists, rather than only being able to
36
+ * report whichever one it happened to ask about last. */
37
+ export function notFoundError(wantsAudio, wantsVideo) {
38
+ const message = wantsAudio && !wantsVideo
39
+ ? "No microphone was found on this device."
40
+ : wantsVideo && !wantsAudio
41
+ ? "No camera was found on this device."
42
+ : "No camera or microphone was found on this device.";
43
+ return { kind: "not-found", message };
44
+ }
32
45
  /** Maps a `getUserMedia()`/`getDisplayMedia()`/`enumerateDevices()` rejection to a friendly, already-complete
33
46
  * message - the handful of `DOMException` names a browser actually raises for these calls, matched by name rather
34
- * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). */
35
- export function classifyMediaError(err) {
47
+ * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). `requested` is the
48
+ * constraints the failed call actually asked for, so a "not found" error can name just the camera or just the
49
+ * microphone rather than always blaming both - omitted (by `listDevices()`/`requestDisplayMedia()`, neither of
50
+ * which asks for one kind specifically) it's treated as having asked for both. */
51
+ export function classifyMediaError(err, requested) {
36
52
  const name = err instanceof Error ? err.name : "";
37
53
  switch (name) {
38
54
  case "NotAllowedError":
@@ -40,7 +56,7 @@ export function classifyMediaError(err) {
40
56
  return { kind: "permission-denied", message: "Camera/microphone access was denied. Allow access in your browser and try again." };
41
57
  case "NotFoundError":
42
58
  case "OverconstrainedError":
43
- return { kind: "not-found", message: "No camera or microphone was found on this device." };
59
+ return notFoundError(requested ? !!requested.audio : true, requested ? !!requested.video : true);
44
60
  default:
45
61
  return { kind: "unknown", message: "Could not access your camera or microphone. Please try again." };
46
62
  }
@@ -78,7 +94,7 @@ export async function requestUserMedia(constraints, devices = defaultMediaDevice
78
94
  return { ok: true, value: await devices.getUserMedia(constraints) };
79
95
  }
80
96
  catch (err) {
81
- return { ok: false, error: classifyMediaError(err) };
97
+ return { ok: false, error: classifyMediaError(err, constraints) };
82
98
  }
83
99
  }
84
100
  /** Requests a screen/window/tab share via the browser's own picker. Audio is never requested (this design shares
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Rotates and/or flips a screen-share track before it's sent, so everyone - the presenter included, since their own
3
+ * preview is this same processor's output - sees it the right way round, rather than however the browser's own
4
+ * window/screen capture happened to hand it back. Window capture in particular has a known, driver/compositor-
5
+ * dependent history of occasionally returning upside-down or sideways frames for a specific captured window; this
6
+ * is the presenter's own manual correction for that, not an automatic fix (there's no reliable way to detect it
7
+ * from pixels alone).
8
+ *
9
+ * Reuses `VideoFilterProcessor.ts`'s own shape (a hidden `<video>` playing the source track, a canvas redrawn on a
10
+ * timer, `captureStream()` exposing the result) but far simpler: no models to load, no per-frame throttling to
11
+ * worry about, just a transform applied on each draw. Entirely independent of the camera pipeline - `useLocalMedia`'s
12
+ * filters never see the screen-share track, and this never touches the camera's.
13
+ */
14
+ /** A rotation in degrees clockwise. */
15
+ export type ScreenRotation = 0 | 90 | 180 | 270;
16
+ export interface ScreenTransformState {
17
+ rotation: ScreenRotation;
18
+ /** Mirrored left-right, applied before the rotation (so "flip, then turn" - the usual photo-editing order). */
19
+ flipped: boolean;
20
+ }
21
+ export declare const NO_SCREEN_TRANSFORM: ScreenTransformState;
22
+ /** The next rotation clockwise, wrapping back to 0 after 270. */
23
+ export declare function rotateClockwise(state: ScreenTransformState): ScreenTransformState;
24
+ export interface ScreenTransformDeps {
25
+ createCanvas(): HTMLCanvasElement;
26
+ createVideo(): HTMLVideoElement;
27
+ now(): number;
28
+ }
29
+ export interface ScreenTransformOptions {
30
+ /** The screen-share track. Only ever read - stopping it stays with whoever owns it (`_CallView.tsx`). */
31
+ source: MediaStreamTrack;
32
+ state?: ScreenTransformState;
33
+ frameRate?: number;
34
+ deps?: Partial<ScreenTransformDeps>;
35
+ }
36
+ export declare class ScreenTransformProcessor {
37
+ /** The transformed video - what to send in place of the screen track's own. */
38
+ readonly track: MediaStreamTrack;
39
+ private readonly deps;
40
+ private readonly interval;
41
+ private readonly video;
42
+ private readonly canvas;
43
+ private readonly ctx;
44
+ private state;
45
+ private stopped;
46
+ private timer;
47
+ constructor(options: ScreenTransformOptions);
48
+ /** Changes the rotation/flip without interrupting the video. */
49
+ setState(state: ScreenTransformState): void;
50
+ stop(): void;
51
+ private tick;
52
+ private render;
53
+ }
@@ -0,0 +1,100 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Rotates and/or flips a screen-share track before it's sent, so everyone - the presenter included, since their own
7
+ * preview is this same processor's output - sees it the right way round, rather than however the browser's own
8
+ * window/screen capture happened to hand it back. Window capture in particular has a known, driver/compositor-
9
+ * dependent history of occasionally returning upside-down or sideways frames for a specific captured window; this
10
+ * is the presenter's own manual correction for that, not an automatic fix (there's no reliable way to detect it
11
+ * from pixels alone).
12
+ *
13
+ * Reuses `VideoFilterProcessor.ts`'s own shape (a hidden `<video>` playing the source track, a canvas redrawn on a
14
+ * timer, `captureStream()` exposing the result) but far simpler: no models to load, no per-frame throttling to
15
+ * worry about, just a transform applied on each draw. Entirely independent of the camera pipeline - `useLocalMedia`'s
16
+ * filters never see the screen-share track, and this never touches the camera's.
17
+ */
18
+ export const NO_SCREEN_TRANSFORM = { rotation: 0, flipped: false };
19
+ /** The next rotation clockwise, wrapping back to 0 after 270. */
20
+ export function rotateClockwise(state) {
21
+ const next = { 0: 90, 90: 180, 180: 270, 270: 0 };
22
+ return { ...state, rotation: next[state.rotation] };
23
+ }
24
+ /** Screen share doesn't need the camera's 30fps - matches `VIDEO_FPS`, what the relay's own `VideoSender` already
25
+ * encodes a shared screen at. */
26
+ const DEFAULT_FRAME_RATE = 15;
27
+ const DEFAULT_DEPS = {
28
+ createCanvas: () => document.createElement("canvas"),
29
+ createVideo: () => document.createElement("video"),
30
+ now: () => performance.now(),
31
+ };
32
+ export class ScreenTransformProcessor {
33
+ constructor(options) {
34
+ this.stopped = false;
35
+ this.tick = () => {
36
+ if (this.stopped) {
37
+ return;
38
+ }
39
+ const started = this.deps.now();
40
+ if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
41
+ this.render();
42
+ }
43
+ this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
44
+ };
45
+ this.deps = { ...DEFAULT_DEPS, ...options.deps };
46
+ this.state = options.state ?? NO_SCREEN_TRANSFORM;
47
+ const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
48
+ this.interval = 1000 / frameRate;
49
+ this.canvas = this.deps.createCanvas();
50
+ const ctx = this.canvas.getContext("2d");
51
+ if (!ctx) {
52
+ throw new Error("This browser can't rotate the shared screen.");
53
+ }
54
+ this.ctx = ctx;
55
+ this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
56
+ this.video = this.deps.createVideo();
57
+ this.video.muted = true;
58
+ this.video.playsInline = true;
59
+ this.video.srcObject = new MediaStream([options.source]);
60
+ void this.video.play().catch(() => undefined);
61
+ this.tick();
62
+ }
63
+ /** Changes the rotation/flip without interrupting the video. */
64
+ setState(state) {
65
+ this.state = state;
66
+ }
67
+ stop() {
68
+ this.stopped = true;
69
+ clearTimeout(this.timer);
70
+ this.video.pause();
71
+ this.video.srcObject = null;
72
+ this.track.stop();
73
+ }
74
+ render() {
75
+ const { video, canvas, ctx, state } = this;
76
+ const sourceWidth = video.videoWidth;
77
+ const sourceHeight = video.videoHeight;
78
+ // A quarter turn swaps which way is "wide" - the canvas (and so the track everyone actually receives) must
79
+ // match, or the picture is squashed into the untransformed aspect ratio.
80
+ const swapped = state.rotation === 90 || state.rotation === 270;
81
+ const width = swapped ? sourceHeight : sourceWidth;
82
+ const height = swapped ? sourceWidth : sourceHeight;
83
+ if (canvas.width !== width || canvas.height !== height) {
84
+ canvas.width = width;
85
+ canvas.height = height;
86
+ }
87
+ if (state.rotation === 0 && !state.flipped) {
88
+ ctx.drawImage(video, 0, 0, sourceWidth, sourceHeight);
89
+ return;
90
+ }
91
+ ctx.save();
92
+ ctx.translate(width / 2, height / 2);
93
+ ctx.rotate((state.rotation * Math.PI) / 180);
94
+ if (state.flipped) {
95
+ ctx.scale(-1, 1);
96
+ }
97
+ ctx.drawImage(video, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight);
98
+ ctx.restore();
99
+ }
100
+ }
@@ -49,5 +49,9 @@ export interface LocalMedia {
49
49
  export interface LocalMediaOptions {
50
50
  /** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
51
51
  effectsAssetsUrl?: string;
52
+ /** The host's `VideoMeeting.forceMuteOnJoin` setting - when `true`, this call's microphone starts muted
53
+ * regardless of what was remembered from a previous call, overriding (but never overwriting) that preference -
54
+ * see `ensurePrefs()`. `undefined`/`false` changes nothing. */
55
+ forceMuteOnJoin?: boolean;
52
56
  }
53
- export declare function useLocalMedia({ effectsAssetsUrl }?: LocalMediaOptions): LocalMedia;
57
+ export declare function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin }?: LocalMediaOptions): LocalMedia;
@@ -32,7 +32,7 @@
32
32
  * Nothing here runs during SSR: browser APIs are only touched from effects and event handlers.
33
33
  */
34
34
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
35
- import { defaultMediaDevices, isMediaDevicesSupported, listDevices, requestUserMedia, } from "./deviceMedia.js";
35
+ import { defaultMediaDevices, isMediaDevicesSupported, listDevices, notFoundError, requestUserMedia, } from "./deviceMedia.js";
36
36
  import { startLevelMeter } from "./levelMeter.js";
37
37
  import { loadPicture, prepareBackgroundImage } from "./filters/backgroundImage.js";
38
38
  import { NO_FILTERS, filtersActive } from "./filters/filterTypes.js";
@@ -55,11 +55,29 @@ function statusFor(error) {
55
55
  return "error";
56
56
  }
57
57
  }
58
+ /** After asking for the camera and the microphone separately (`acquireBoth()`'s fallback, one at a time rather
59
+ * than together), the one error to show for both attempts together - naming both devices when both are actually
60
+ * missing, rather than just whichever of the two happened to be asked about last (which would otherwise say "no
61
+ * camera" even when the microphone is *also* missing, or say nothing at all when only the microphone is missing,
62
+ * since the camera's own success would otherwise clear the error the microphone's failure had just set). `null`
63
+ * when both succeeded. */
64
+ function combineErrors(audio, video) {
65
+ if (!audio || !video) {
66
+ return audio ?? video;
67
+ }
68
+ if (audio.kind === "not-found" && video.kind === "not-found") {
69
+ return notFoundError(true, true);
70
+ }
71
+ // Different reasons - unusual (e.g. permission denied for one, missing hardware for the other). Permission is
72
+ // the more actionable of the two, so it's what's shown; otherwise there's no single message that honestly
73
+ // describes both, so this just picks one.
74
+ return audio.kind === "permission-denied" ? audio : video;
75
+ }
58
76
  /** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
59
77
  function preferring(base, preferredId) {
60
78
  return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
61
79
  }
62
- export function useLocalMedia({ effectsAssetsUrl } = {}) {
80
+ export function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin } = {}) {
63
81
  const [supported, setSupported] = useState(true);
64
82
  const [requesting, setRequesting] = useState(false);
65
83
  const [error, setError] = useState(null);
@@ -96,7 +114,7 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
96
114
  }
97
115
  const prefs = loadPreferences();
98
116
  prefsRef.current = prefs;
99
- if (prefs.micEnabled === false) {
117
+ if (prefs.micEnabled === false || forceMuteOnJoin) {
100
118
  micEnabledRef.current = false;
101
119
  setMicEnabled(false);
102
120
  }
@@ -111,7 +129,7 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
111
129
  }, () => undefined);
112
130
  }
113
131
  return prefs;
114
- }, [applyBackground, applyFilters]);
132
+ }, [applyBackground, applyFilters, forceMuteOnJoin]);
115
133
  /** Saves a change to the remembered settings. */
116
134
  const remember = useCallback((patch) => {
117
135
  prefsRef.current = { ...ensurePrefs(), ...patch };
@@ -172,13 +190,13 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
172
190
  setError(result.error);
173
191
  setStatus((prev) => ({ ...prev, [kind]: statusFor(result.error) }));
174
192
  }
175
- return false;
193
+ return result.error;
176
194
  }
177
195
  adoptStream(result.value);
178
196
  if (mountedRef.current) {
179
197
  setError(null);
180
198
  }
181
- return true;
199
+ return null;
182
200
  }, [adoptStream, ensurePrefs]);
183
201
  /** Asks for the camera and the microphone in a single prompt. */
184
202
  const acquireBoth = useCallback(async () => {
@@ -206,9 +224,13 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
206
224
  }
207
225
  else {
208
226
  // No camera, no microphone, or the two together couldn't be satisfied: ask for each on its own, so one
209
- // missing device doesn't cost the participant the other.
210
- await acquire("audio");
211
- await acquire("video");
227
+ // missing device doesn't cost the participant the other - then combine what each found into one
228
+ // message (see `combineErrors()`) rather than showing whichever of the two ran last.
229
+ const audioError = await acquire("audio");
230
+ const videoError = await acquire("video");
231
+ if (mountedRef.current) {
232
+ setError(combineErrors(audioError, videoError));
233
+ }
212
234
  }
213
235
  }, [acquire, adoptStream, ensurePrefs]);
214
236
  const requestAccess = useCallback(async (kind) => {
@@ -265,7 +287,7 @@ export function useLocalMedia({ effectsAssetsUrl } = {}) {
265
287
  setTracks(tracksRef.current);
266
288
  }
267
289
  }
268
- if (await acquire(kind, deviceId)) {
290
+ if ((await acquire(kind, deviceId)) === null) {
269
291
  remember(kind === "audio" ? { microphoneId: deviceId } : { cameraId: deviceId });
270
292
  }
271
293
  await refreshDevices();
@@ -10,14 +10,26 @@ export declare const JITTER_BUFFER_SECONDS = 0.08;
10
10
  export declare const MAX_AHEAD_SECONDS = 0.4;
11
11
  /** Skip a packet instead of queueing it when the decoder is this far behind. */
12
12
  export declare const AUDIO_MAX_DECODE_QUEUE = 16;
13
+ /** The length of the fade-in `play()` gives a block that doesn't pick up exactly where the last one left off (the
14
+ * first block, or the one after a gap) - long enough to mask the click such a discontinuity would otherwise
15
+ * produce, short enough that it is never heard as an actual fade. */
16
+ export declare const DISCONTINUITY_FADE_SECONDS = 0.004;
13
17
  /**
14
18
  * Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
15
19
  *
16
20
  * Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
17
21
  * `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
18
22
  * join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
19
- * Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
20
- * the decoder is created lazily and discarded on any error, and replaced by the next packet.
23
+ * Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step
24
+ * *in terms of content* - but the shared decoder (reused across packets so it keeps its normal state between them,
25
+ * not recreated per packet) is never told a packet was skipped, so its internal prediction state no longer matches
26
+ * what it decodes next. Audibly this can be a click right at that seam, not just silence where the lost packet
27
+ * would have been - the same is true of the very first block (nothing at all came before it) and of the block
28
+ * after a stall long enough to need the jitter buffer's full headroom again. `play()` gives exactly those blocks a
29
+ * few milliseconds' fade-in (`DISCONTINUITY_FADE_SECONDS`) through a `GainNode` to mask it; an ordinary block that
30
+ * picks up exactly where the last one ended gets none - fading *every* block would itself be audible, a faint
31
+ * tremolo at the packet rate. The decoder is created lazily and discarded on any error, and replaced by the next
32
+ * packet.
21
33
  */
22
34
  export declare class AudioPlayer {
23
35
  private readonly env;
@@ -14,14 +14,26 @@ export const JITTER_BUFFER_SECONDS = 0.08;
14
14
  export const MAX_AHEAD_SECONDS = 0.4;
15
15
  /** Skip a packet instead of queueing it when the decoder is this far behind. */
16
16
  export const AUDIO_MAX_DECODE_QUEUE = 16;
17
+ /** The length of the fade-in `play()` gives a block that doesn't pick up exactly where the last one left off (the
18
+ * first block, or the one after a gap) - long enough to mask the click such a discontinuity would otherwise
19
+ * produce, short enough that it is never heard as an actual fade. */
20
+ export const DISCONTINUITY_FADE_SECONDS = 0.004;
17
21
  /**
18
22
  * Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
19
23
  *
20
24
  * Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
21
25
  * `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
22
26
  * join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
23
- * Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
24
- * the decoder is created lazily and discarded on any error, and replaced by the next packet.
27
+ * Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step
28
+ * *in terms of content* - but the shared decoder (reused across packets so it keeps its normal state between them,
29
+ * not recreated per packet) is never told a packet was skipped, so its internal prediction state no longer matches
30
+ * what it decodes next. Audibly this can be a click right at that seam, not just silence where the lost packet
31
+ * would have been - the same is true of the very first block (nothing at all came before it) and of the block
32
+ * after a stall long enough to need the jitter buffer's full headroom again. `play()` gives exactly those blocks a
33
+ * few milliseconds' fade-in (`DISCONTINUITY_FADE_SECONDS`) through a `GainNode` to mask it; an ordinary block that
34
+ * picks up exactly where the last one ended gets none - fading *every* block would itself be audible, a faint
35
+ * tremolo at the packet rate. The decoder is created lazily and discarded on any error, and replaced by the next
36
+ * packet.
25
37
  */
26
38
  export class AudioPlayer {
27
39
  /** `destination` is the node whose stream the peer's `MediaStream` carries. */
@@ -87,9 +99,24 @@ export class AudioPlayer {
87
99
  buffer.copyToChannel(pcm, 0);
88
100
  const source = this.ctx.createBufferSource();
89
101
  source.buffer = buffer;
90
- source.connect(this.destination);
91
- source.onended = () => source.disconnect();
92
102
  const start = Math.max(now + JITTER_BUFFER_SECONDS, this.nextTime);
103
+ if (start > this.nextTime) {
104
+ // A gap, not a continuation - the decoder's state no longer matches what it's about to decode (see
105
+ // this class's doc comment), so fade this block in rather than let it start abruptly.
106
+ const gain = this.ctx.createGain();
107
+ gain.gain.setValueAtTime(0, start);
108
+ gain.gain.linearRampToValueAtTime(1, start + DISCONTINUITY_FADE_SECONDS);
109
+ source.connect(gain);
110
+ gain.connect(this.destination);
111
+ source.onended = () => {
112
+ source.disconnect();
113
+ gain.disconnect();
114
+ };
115
+ }
116
+ else {
117
+ source.connect(this.destination);
118
+ source.onended = () => source.disconnect();
119
+ }
93
120
  source.start(start);
94
121
  this.nextTime = start + buffer.duration;
95
122
  }
@@ -69,6 +69,11 @@ export interface ScriptProcessorLike extends AudioNodeLike {
69
69
  export interface GainLike extends AudioNodeLike {
70
70
  gain: {
71
71
  value: number;
72
+ /** `AudioParam.setValueAtTime()` - used to schedule a fade (`AudioPlayer`'s discontinuity fade-in), not
73
+ * needed for `AudioSender`'s own static, unscheduled mute (a plain `.value = 0` assignment). */
74
+ setValueAtTime(value: number, startTime: number): unknown;
75
+ /** `AudioParam.linearRampToValueAtTime()`. */
76
+ linearRampToValueAtTime(value: number, endTime: number): unknown;
72
77
  };
73
78
  }
74
79
  export interface AudioBufferLike {