@unityevolv/ofiskit-realtime-client 0.1.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.
@@ -0,0 +1,238 @@
1
+ import type { IceServer, SignalMessage } from '@unityevolv/ofiskit-realtime-core/protocol';
2
+ import type { ShareOptions } from './screen.js';
3
+ /**
4
+ * The RTC provider interface, client half.
5
+ *
6
+ * The other half is the server plugin in the core, and **both must exist** for a
7
+ * provider to be usable. This is the one the controls bar, the tiles and the
8
+ * indicators actually talk to: none of them imports a provider SDK, they consume
9
+ * the events below in one shape whichever provider is behind them.
10
+ *
11
+ * The built-in mesh adapter is the reference implementation and arrives with the
12
+ * signalling story. An external provider's adapter wraps that provider's SDK and
13
+ * talks to their servers; our socket carries only call state, never their media
14
+ * or signalling.
15
+ *
16
+ * No DOM anywhere except the media types, which React Native shims — which is
17
+ * why this lives in the platform-agnostic package and the tiles do not.
18
+ */
19
+ /** One thing that happened in the call, in the shape every provider reports. */
20
+ export type RtcEvent = {
21
+ type: 'participant.joined';
22
+ deviceId: string;
23
+ userId: string;
24
+ displayName: string;
25
+ } | {
26
+ type: 'participant.left';
27
+ deviceId: string;
28
+ }
29
+ /**
30
+ * A stream arrived from a peer.
31
+ *
32
+ * `source` separates a camera from a screen share, because they are laid out
33
+ * completely differently and a share that arrives as a camera tile is useless.
34
+ */
35
+ | {
36
+ type: 'track';
37
+ deviceId: string;
38
+ stream: MediaStream;
39
+ source: 'camera' | 'screen' | 'audio';
40
+ } | {
41
+ type: 'track.ended';
42
+ deviceId: string;
43
+ source: 'camera' | 'screen' | 'audio';
44
+ }
45
+ /** Your own microphone level, a few times a second and only while it changes. */
46
+ | {
47
+ type: 'speaking';
48
+ speaking: boolean;
49
+ level: number;
50
+ } | {
51
+ type: 'quality';
52
+ deviceId: string;
53
+ relayed: boolean;
54
+ packetLoss: number;
55
+ roundTripMs: number;
56
+ }
57
+ /** What you are publishing, reported after the adapter actually did it. */
58
+ | {
59
+ type: 'state';
60
+ muted: boolean;
61
+ cameraOn: boolean;
62
+ sharing: boolean;
63
+ }
64
+ /**
65
+ * A connection failed.
66
+ *
67
+ * `deviceId` present means one peer is unreachable and the rest of the call is
68
+ * fine, which is worth saying precisely: a three-way call with one unreachable
69
+ * person is not a total failure and must not be reported as one.
70
+ */
71
+ | {
72
+ type: 'failed';
73
+ deviceId?: string;
74
+ reason: string;
75
+ }
76
+ /** Your own video was reduced, so nobody has to wonder why they look blurry. */
77
+ | {
78
+ type: 'degraded';
79
+ videoDropped: boolean;
80
+ reason: string;
81
+ } | {
82
+ type: 'local';
83
+ stream: MediaStream | null;
84
+ source: 'camera' | 'screen';
85
+ };
86
+ export type RtcHandler = (event: RtcEvent) => void;
87
+ export interface JoinOptions {
88
+ callId: string;
89
+ /** Who this client is, as a call leg. A leg is a device, not a person. */
90
+ deviceId: string;
91
+ /** Whatever the server plugin issued. The adapter is the only thing that reads it. */
92
+ credentials: unknown;
93
+ iceServers: IceServer[];
94
+ /** Who is already here, so a new peer knows who to connect to. */
95
+ participants: Array<{
96
+ userId: string;
97
+ deviceId: string;
98
+ displayName: string;
99
+ }>;
100
+ audio: boolean;
101
+ video: boolean;
102
+ /** Chosen devices, from the device picker. */
103
+ audioDeviceId?: string;
104
+ videoDeviceId?: string;
105
+ }
106
+ /**
107
+ * How the adapter reaches the other side.
108
+ *
109
+ * Injected rather than imported, so the adapter does not know whether it is
110
+ * talking over our socket or a provider's own channel. The built-in adapter uses
111
+ * our socket; an external one would not need this at all.
112
+ */
113
+ export interface Signaller {
114
+ send(message: SignalMessage): void;
115
+ receive(handler: (message: SignalMessage & {
116
+ from: string;
117
+ }) => void): () => void;
118
+ }
119
+ export interface RtcClientAdapter {
120
+ join(options: JoinOptions): Promise<void>;
121
+ leave(): Promise<void>;
122
+ setMicrophone(on: boolean): Promise<void>;
123
+ setCamera(on: boolean): Promise<void>;
124
+ /**
125
+ * Start sharing, and say whether it started.
126
+ *
127
+ * False covers both the person closing the picker and a capture that failed, and
128
+ * the difference is not the caller's business: a failure has already been
129
+ * reported as a `failed` event, and a cancellation is not a failure at all.
130
+ *
131
+ * With no options the browser's own picker chooses what to share. A `sourceId`
132
+ * means the host drew the picker — a desktop app, where the browser has none —
133
+ * and the person has already chosen.
134
+ */
135
+ startScreenShare(options?: ShareOptions): Promise<boolean>;
136
+ stopScreenShare(): Promise<void>;
137
+ /**
138
+ * Which peers should send video.
139
+ *
140
+ * Only the visible tiles receive video; everybody else is audio-only. Paging
141
+ * the tile strip changes this set, and that is what bounds what each person
142
+ * downloads however big the call gets.
143
+ */
144
+ setVideoSubscriptions(deviceIds: string[]): void;
145
+ /** Swap microphone or camera mid-call, from the device picker. */
146
+ useDevices(devices: {
147
+ audioDeviceId?: string;
148
+ videoDeviceId?: string;
149
+ }): Promise<void>;
150
+ on(handler: RtcHandler): () => void;
151
+ }
152
+ /**
153
+ * Ceilings per stream, so four cameras plus a share stay inside what a home
154
+ * connection can upload.
155
+ *
156
+ * A mesh cannot use simulcast: the sender uploads a separate copy to every peer,
157
+ * so the **sender** has to do the adapting. These are the steps it moves between.
158
+ */
159
+ export declare const VIDEO_STEPS: readonly [{
160
+ readonly height: 720;
161
+ readonly maxBitrate: 1200000;
162
+ readonly maxFramerate: 30;
163
+ }, {
164
+ readonly height: 480;
165
+ readonly maxBitrate: 600000;
166
+ readonly maxFramerate: 30;
167
+ }, {
168
+ readonly height: 360;
169
+ readonly maxBitrate: 300000;
170
+ readonly maxFramerate: 24;
171
+ }, {
172
+ readonly height: 180;
173
+ readonly maxBitrate: 120000;
174
+ readonly maxFramerate: 15;
175
+ }];
176
+ /**
177
+ * The screen share's ceiling.
178
+ *
179
+ * Resolution is favoured over frame rate, because text has to stay readable and a
180
+ * slightly jerky slide is far better than a sharp one nobody can read.
181
+ */
182
+ export declare const SCREEN_CEILING: {
183
+ readonly maxBitrate: 1500000;
184
+ readonly maxFramerate: 8;
185
+ };
186
+ /** Audio is protected and never steps down. Video degrades first, always. */
187
+ export declare const AUDIO_BITRATE = 32000;
188
+ /**
189
+ * What counts as talking, and how long it goes on counting.
190
+ *
191
+ * The level is the root mean square of the waveform, which is volume — not the
192
+ * average across the frequency bins, which is volume divided by however much of
193
+ * the spectrum happens to be empty. A voice puts almost all of its energy below
194
+ * two kilohertz, so averaging it across twenty-four kilohertz of mostly silence
195
+ * gives a number an order of magnitude smaller than the sound actually is, and a
196
+ * threshold picked against that number is a threshold nobody normal crosses.
197
+ *
198
+ * `SPEAKING_HOLD_MS` is what stops the ring strobing. Speech is not continuous:
199
+ * there is a gap between every word and a longer one between sentences, and an
200
+ * indicator that follows the waveform exactly flickers all the way through a
201
+ * sentence. Holding it briefly after the level drops reads as "this is the person
202
+ * talking" rather than as a light fault, and it cuts what goes over the socket,
203
+ * because only a change is sent.
204
+ */
205
+ export declare const SPEAKING_LEVEL = 0.05;
206
+ export declare const SPEAKING_HOLD_MS = 1000;
207
+ /** How often the level is measured. Five times a second is under the eye's notice. */
208
+ export declare const LEVEL_INTERVAL_MS = 200;
209
+ /**
210
+ * How loud the waveform is, between 0 and 1.
211
+ *
212
+ * Takes the bytes an `AnalyserNode` gives for the time domain, where 128 is
213
+ * silence and the distance either side of it is the amplitude. Pure and exported
214
+ * so the one number the speaking indicator depends on can be tested without a
215
+ * browser, an audio context or a microphone.
216
+ */
217
+ export declare function rmsLevel(samples: Uint8Array): number;
218
+ /**
219
+ * Whether this device counts as talking, now.
220
+ *
221
+ * `loudAt` is when the level was last above the threshold. Muting wins over the
222
+ * hold: a muted microphone is silent, and an indicator that says otherwise for a
223
+ * second afterwards is the one mistake this indicator must never make.
224
+ */
225
+ export declare function speakingNow(state: {
226
+ muted: boolean;
227
+ loudAt: number;
228
+ now: number;
229
+ }): boolean;
230
+ /**
231
+ * How long to try before giving up.
232
+ *
233
+ * Bounded on purpose. Some networks block UDP and the relay ports outright, and
234
+ * on those the connection is never going to happen; an indefinite spinner just
235
+ * makes somebody wait longer to find that out.
236
+ */
237
+ export declare const CONNECT_TIMEOUT_MS = 15000;
238
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/rtc/adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Ceilings per stream, so four cameras plus a share stay inside what a home
3
+ * connection can upload.
4
+ *
5
+ * A mesh cannot use simulcast: the sender uploads a separate copy to every peer,
6
+ * so the **sender** has to do the adapting. These are the steps it moves between.
7
+ */
8
+ export const VIDEO_STEPS = [
9
+ { height: 720, maxBitrate: 1_200_000, maxFramerate: 30 },
10
+ { height: 480, maxBitrate: 600_000, maxFramerate: 30 },
11
+ { height: 360, maxBitrate: 300_000, maxFramerate: 24 },
12
+ { height: 180, maxBitrate: 120_000, maxFramerate: 15 },
13
+ ];
14
+ /**
15
+ * The screen share's ceiling.
16
+ *
17
+ * Resolution is favoured over frame rate, because text has to stay readable and a
18
+ * slightly jerky slide is far better than a sharp one nobody can read.
19
+ */
20
+ export const SCREEN_CEILING = { maxBitrate: 1_500_000, maxFramerate: 8 };
21
+ /** Audio is protected and never steps down. Video degrades first, always. */
22
+ export const AUDIO_BITRATE = 32_000;
23
+ /**
24
+ * What counts as talking, and how long it goes on counting.
25
+ *
26
+ * The level is the root mean square of the waveform, which is volume — not the
27
+ * average across the frequency bins, which is volume divided by however much of
28
+ * the spectrum happens to be empty. A voice puts almost all of its energy below
29
+ * two kilohertz, so averaging it across twenty-four kilohertz of mostly silence
30
+ * gives a number an order of magnitude smaller than the sound actually is, and a
31
+ * threshold picked against that number is a threshold nobody normal crosses.
32
+ *
33
+ * `SPEAKING_HOLD_MS` is what stops the ring strobing. Speech is not continuous:
34
+ * there is a gap between every word and a longer one between sentences, and an
35
+ * indicator that follows the waveform exactly flickers all the way through a
36
+ * sentence. Holding it briefly after the level drops reads as "this is the person
37
+ * talking" rather than as a light fault, and it cuts what goes over the socket,
38
+ * because only a change is sent.
39
+ */
40
+ export const SPEAKING_LEVEL = 0.05;
41
+ export const SPEAKING_HOLD_MS = 1_000;
42
+ /** How often the level is measured. Five times a second is under the eye's notice. */
43
+ export const LEVEL_INTERVAL_MS = 200;
44
+ /**
45
+ * How loud the waveform is, between 0 and 1.
46
+ *
47
+ * Takes the bytes an `AnalyserNode` gives for the time domain, where 128 is
48
+ * silence and the distance either side of it is the amplitude. Pure and exported
49
+ * so the one number the speaking indicator depends on can be tested without a
50
+ * browser, an audio context or a microphone.
51
+ */
52
+ export function rmsLevel(samples) {
53
+ if (samples.length === 0)
54
+ return 0;
55
+ let total = 0;
56
+ for (const sample of samples) {
57
+ const amplitude = (sample - 128) / 128;
58
+ total += amplitude * amplitude;
59
+ }
60
+ return Math.sqrt(total / samples.length);
61
+ }
62
+ /**
63
+ * Whether this device counts as talking, now.
64
+ *
65
+ * `loudAt` is when the level was last above the threshold. Muting wins over the
66
+ * hold: a muted microphone is silent, and an indicator that says otherwise for a
67
+ * second afterwards is the one mistake this indicator must never make.
68
+ */
69
+ export function speakingNow(state) {
70
+ return !state.muted && state.now - state.loudAt < SPEAKING_HOLD_MS;
71
+ }
72
+ /**
73
+ * How long to try before giving up.
74
+ *
75
+ * Bounded on purpose. Some networks block UDP and the relay ports outright, and
76
+ * on those the connection is never going to happen; an indefinite spinner just
77
+ * makes somebody wait longer to find that out.
78
+ */
79
+ export const CONNECT_TIMEOUT_MS = 15_000;
80
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../src/rtc/adapter.ts"],"names":[],"mappings":"AAmHA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,EAAE,EAAE;IACxD,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE;IACtD,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE;IACtD,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE;CAC9C,CAAA;AAEV;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC,EAAW,CAAA;AAEjF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAA;AAEnC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAA;AAClC,MAAM,CAAC,MAAM,gBAAgB,GAAG,KAAK,CAAA;AACrC,sFAAsF;AACtF,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAA;AAEpC;;;;;;;GAOG;AACH,MAAM,UAAU,QAAQ,CAAC,OAAmB;IAC1C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAA;IAClC,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,SAAS,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,GAAG,GAAG,CAAA;QACtC,KAAK,IAAI,SAAS,GAAG,SAAS,CAAA;IAChC,CAAC;IACD,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;AAC1C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,KAAsD;IAChF,OAAO,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC,MAAM,GAAG,gBAAgB,CAAA;AACpE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAA"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Choosing a microphone, a camera and a speaker, and coping when the browser
3
+ * says no.
4
+ *
5
+ * Almost all of the value here is in the failure cases. Picking a device is
6
+ * easy; telling somebody the difference between "you denied permission" and
7
+ * "Zoom still has your camera" is what stops a support ticket, and the browser
8
+ * will not do it for you.
9
+ */
10
+ export interface Devices {
11
+ microphones: MediaDeviceInfo[];
12
+ cameras: MediaDeviceInfo[];
13
+ speakers: MediaDeviceInfo[];
14
+ }
15
+ export interface DeviceChoice {
16
+ audioDeviceId?: string;
17
+ videoDeviceId?: string;
18
+ /** Output routing, so a call can go to headphones while alerts stay on speakers. */
19
+ speakerDeviceId?: string;
20
+ }
21
+ /**
22
+ * Where a choice is remembered.
23
+ *
24
+ * Injected rather than reached for, because this package has to run on a phone
25
+ * and `localStorage` does not exist there. The web app passes an adapter over
26
+ * localStorage; React Native would pass one over its own storage.
27
+ */
28
+ export interface DeviceStorage {
29
+ read(): DeviceChoice;
30
+ write(choice: DeviceChoice): void;
31
+ }
32
+ export declare function memoryDeviceStorage(initial?: DeviceChoice): DeviceStorage;
33
+ /**
34
+ * What is plugged in.
35
+ *
36
+ * Labels are empty until permission has been granted at least once, which is
37
+ * why the pre-join panel asks for permission before showing the list: a picker
38
+ * offering "Microphone 1" and "Microphone 2" is no use to anybody.
39
+ */
40
+ export declare function listDevices(): Promise<Devices>;
41
+ export interface PermissionOutcome {
42
+ granted: boolean;
43
+ /** Present when it failed: what happened, and what to do about it. */
44
+ problem?: string;
45
+ /** True when permission was granted but the device was unusable. */
46
+ inUseElsewhere?: boolean;
47
+ }
48
+ /**
49
+ * Ask for permission, and find out what actually happened.
50
+ *
51
+ * The awkward case this exists for: on some platforms a camera held by another
52
+ * application grants permission and then hands back a stream with no data in it
53
+ * at all. That looks identical to a working camera until somebody says they
54
+ * cannot see you, so a black stream is checked for explicitly and reported as
55
+ * the different problem it is.
56
+ */
57
+ export declare function requestPermission(want: {
58
+ audio: boolean;
59
+ video: boolean;
60
+ }, deviceChoice?: DeviceChoice): Promise<PermissionOutcome & {
61
+ stream?: MediaStream;
62
+ }>;
63
+ /**
64
+ * Watch for devices arriving and leaving.
65
+ *
66
+ * Unplugging a headset mid-call should switch to the built-in microphone and
67
+ * say so. A silent switch is worse than no switch: somebody carries on talking
68
+ * into a headset that is no longer connected.
69
+ */
70
+ export declare function watchDevices(onChange: (devices: Devices) => void): () => void;
71
+ /**
72
+ * Whether a remembered choice is still available.
73
+ *
74
+ * A USB headset that is unplugged should fall back to the operating system's
75
+ * default rather than failing with a constraint error nobody can interpret.
76
+ */
77
+ export declare function stillAvailable(choice: DeviceChoice, devices: Devices): DeviceChoice;
78
+ /** The name to show for a device that has lost its label. */
79
+ export declare function labelFor(device: MediaDeviceInfo, index: number): string;
80
+ //# sourceMappingURL=devices.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devices.d.ts","sourceRoot":"","sources":["../../src/rtc/devices.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,MAAM,WAAW,OAAO;IACtB,WAAW,EAAE,eAAe,EAAE,CAAA;IAC9B,OAAO,EAAE,eAAe,EAAE,CAAA;IAC1B,QAAQ,EAAE,eAAe,EAAE,CAAA;CAC5B;AAED,MAAM,WAAW,YAAY;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oFAAoF;IACpF,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,IAAI,YAAY,CAAA;IACpB,KAAK,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAA;CAClC;AAED,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,YAAiB,GAAG,aAAa,CAQ7E;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAOpD;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,OAAO,CAAA;IAChB,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,oEAAoE;IACpE,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EACxC,YAAY,GAAE,YAAiB,GAC9B,OAAO,CAAC,iBAAiB,GAAG;IAAE,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,CAAC,CAmCvD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAM7E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,GAAG,YAAY,CAWnF;AAED,6DAA6D;AAC7D,wBAAgB,QAAQ,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAKvE"}
@@ -0,0 +1,107 @@
1
+ import { describeMediaError } from './mesh.js';
2
+ export function memoryDeviceStorage(initial = {}) {
3
+ let held = initial;
4
+ return {
5
+ read: () => held,
6
+ write: (choice) => {
7
+ held = choice;
8
+ },
9
+ };
10
+ }
11
+ /**
12
+ * What is plugged in.
13
+ *
14
+ * Labels are empty until permission has been granted at least once, which is
15
+ * why the pre-join panel asks for permission before showing the list: a picker
16
+ * offering "Microphone 1" and "Microphone 2" is no use to anybody.
17
+ */
18
+ export async function listDevices() {
19
+ const all = await navigator.mediaDevices.enumerateDevices();
20
+ return {
21
+ microphones: all.filter((device) => device.kind === 'audioinput'),
22
+ cameras: all.filter((device) => device.kind === 'videoinput'),
23
+ speakers: all.filter((device) => device.kind === 'audiooutput'),
24
+ };
25
+ }
26
+ /**
27
+ * Ask for permission, and find out what actually happened.
28
+ *
29
+ * The awkward case this exists for: on some platforms a camera held by another
30
+ * application grants permission and then hands back a stream with no data in it
31
+ * at all. That looks identical to a working camera until somebody says they
32
+ * cannot see you, so a black stream is checked for explicitly and reported as
33
+ * the different problem it is.
34
+ */
35
+ export async function requestPermission(want, deviceChoice = {}) {
36
+ try {
37
+ const stream = await navigator.mediaDevices.getUserMedia({
38
+ audio: want.audio
39
+ ? {
40
+ ...(deviceChoice.audioDeviceId ? { deviceId: { exact: deviceChoice.audioDeviceId } } : {}),
41
+ echoCancellation: true,
42
+ noiseSuppression: true,
43
+ }
44
+ : false,
45
+ video: want.video
46
+ ? deviceChoice.videoDeviceId
47
+ ? { deviceId: { exact: deviceChoice.videoDeviceId } }
48
+ : true
49
+ : false,
50
+ });
51
+ const dead = stream.getTracks().filter((track) => track.readyState === 'ended' || track.muted);
52
+ if (dead.length > 0 && dead.length === stream.getTracks().length) {
53
+ for (const track of stream.getTracks())
54
+ track.stop();
55
+ return {
56
+ granted: true,
57
+ inUseElsewhere: true,
58
+ problem: 'Your camera or microphone is open in another app. Close it and try again — the permission is fine, the device is just busy.',
59
+ };
60
+ }
61
+ return { granted: true, stream };
62
+ }
63
+ catch (cause) {
64
+ return {
65
+ granted: false,
66
+ problem: describeMediaError(cause, want.video ? 'camera' : 'microphone'),
67
+ };
68
+ }
69
+ }
70
+ /**
71
+ * Watch for devices arriving and leaving.
72
+ *
73
+ * Unplugging a headset mid-call should switch to the built-in microphone and
74
+ * say so. A silent switch is worse than no switch: somebody carries on talking
75
+ * into a headset that is no longer connected.
76
+ */
77
+ export function watchDevices(onChange) {
78
+ const handler = () => {
79
+ void listDevices().then(onChange);
80
+ };
81
+ navigator.mediaDevices.addEventListener('devicechange', handler);
82
+ return () => navigator.mediaDevices.removeEventListener('devicechange', handler);
83
+ }
84
+ /**
85
+ * Whether a remembered choice is still available.
86
+ *
87
+ * A USB headset that is unplugged should fall back to the operating system's
88
+ * default rather than failing with a constraint error nobody can interpret.
89
+ */
90
+ export function stillAvailable(choice, devices) {
91
+ const has = (list, id) => id !== undefined && list.some((device) => device.deviceId === id);
92
+ return {
93
+ ...(has(devices.microphones, choice.audioDeviceId) ? { audioDeviceId: choice.audioDeviceId } : {}),
94
+ ...(has(devices.cameras, choice.videoDeviceId) ? { videoDeviceId: choice.videoDeviceId } : {}),
95
+ ...(has(devices.speakers, choice.speakerDeviceId)
96
+ ? { speakerDeviceId: choice.speakerDeviceId }
97
+ : {}),
98
+ };
99
+ }
100
+ /** The name to show for a device that has lost its label. */
101
+ export function labelFor(device, index) {
102
+ if (device.label)
103
+ return device.label;
104
+ const kind = device.kind === 'audioinput' ? 'Microphone' : device.kind === 'videoinput' ? 'Camera' : 'Speaker';
105
+ return `${kind} ${index + 1}`;
106
+ }
107
+ //# sourceMappingURL=devices.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devices.js","sourceRoot":"","sources":["../../src/rtc/devices.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import { type RtcClientAdapter, type Signaller } from './adapter.js';
2
+ export declare function meshAdapter(signaller: Signaller): RtcClientAdapter;
3
+ /**
4
+ * Turn a getUserMedia failure into something worth reading.
5
+ *
6
+ * Denied permission is the most common support question in any call product,
7
+ * and the browser's own message is no help at all. A device held by another app
8
+ * is a different problem with a different fix, and on some platforms it arrives
9
+ * as a silent black stream rather than an error, so the two must not be
10
+ * collapsed into "something went wrong".
11
+ */
12
+ export declare function describeMediaError(cause: unknown, device: 'microphone' | 'camera'): string;
13
+ //# sourceMappingURL=mesh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mesh.d.ts","sourceRoot":"","sources":["../../src/rtc/mesh.ts"],"names":[],"mappings":"AAEA,OAAO,EAUL,KAAK,gBAAgB,EAGrB,KAAK,SAAS,EACf,MAAM,cAAc,CAAA;AA8DrB,wBAAgB,WAAW,CAAC,SAAS,EAAE,SAAS,GAAG,gBAAgB,CA2vBlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,GAAG,QAAQ,GAAG,MAAM,CAkB1F"}