@voltkit-rtc/react-native 0.1.1 → 0.1.3
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/CHANGELOG.md +53 -0
- package/README.md +8 -8
- package/lib/commonjs/local-media.js +98 -20
- package/lib/commonjs/local-media.js.map +1 -1
- package/lib/commonjs/native-session.js +41 -11
- package/lib/commonjs/native-session.js.map +1 -1
- package/lib/commonjs/native.js +1 -1
- package/lib/commonjs/platform/common.js +1 -1
- package/lib/commonjs/provider.js +9 -1
- package/lib/commonjs/provider.js.map +1 -1
- package/lib/module/local-media.js +98 -20
- package/lib/module/local-media.js.map +1 -1
- package/lib/module/native-session.js +41 -11
- package/lib/module/native-session.js.map +1 -1
- package/lib/module/native.js +1 -1
- package/lib/module/platform/common.js +1 -1
- package/lib/module/provider.js +9 -1
- package/lib/module/provider.js.map +1 -1
- package/lib/typescript/commonjs/native-session.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +1 -1
- package/lib/typescript/commonjs/platform/common.d.ts +1 -1
- package/lib/typescript/commonjs/provider.d.ts.map +1 -1
- package/lib/typescript/module/native-session.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +1 -1
- package/lib/typescript/module/platform/common.d.ts +1 -1
- package/lib/typescript/module/provider.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/local-media.ts +93 -21
- package/src/native-session.tsx +38 -9
- package/src/native.ts +1 -1
- package/src/platform/common.ts +1 -1
- package/src/provider.tsx +9 -1
package/src/local-media.ts
CHANGED
|
@@ -293,8 +293,13 @@ function localActions(client: StreamClient | null): LocalActions {
|
|
|
293
293
|
|
|
294
294
|
// ----------------------------------------------------------------------------------------------------- publish
|
|
295
295
|
|
|
296
|
-
/**
|
|
297
|
-
const publishing = new
|
|
296
|
+
/** The publish() between asking for the permissions and publishing, per client: the one start under way. */
|
|
297
|
+
const publishing = new WeakMap<StreamClient, object>();
|
|
298
|
+
|
|
299
|
+
const mayPublish = (client: StreamClient): boolean => {
|
|
300
|
+
const role = client.info?.role;
|
|
301
|
+
return role === 'host' || role === 'cohost';
|
|
302
|
+
};
|
|
298
303
|
|
|
299
304
|
async function publishDevices(client: StreamClient, options: LocalPublishOptions): Promise<void> {
|
|
300
305
|
// facingMode is a capture choice, not a publish option.
|
|
@@ -302,45 +307,112 @@ async function publishDevices(client: StreamClient, options: LocalPublishOptions
|
|
|
302
307
|
if (audio === false && video === false) throw new TypeError('publish: ask for audio, video or both');
|
|
303
308
|
// Everything that would refuse the publish is checked before a permission is asked for or a device opens.
|
|
304
309
|
if (client.connectionState !== 'connected') throw new RpcError('not-connected', 'connect before publishing');
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
310
|
+
if (!mayPublish(client)) throw new RpcError('forbidden', 'only hosts and cohosts can publish');
|
|
311
|
+
// What is on air goes on as it is, and only the rest is asked for and opened (0.1.3, decision D2): a retry after the
|
|
312
|
+
// server took the microphone and refused the camera sends the camera alone. Everything asked for on air already, or
|
|
313
|
+
// a start under way: unpublish first.
|
|
314
|
+
const onAir = (kind: 'audio' | 'video') => localStream(client)?.getTracks().some((track) => track.kind === kind && track.readyState === 'live') ?? false;
|
|
315
|
+
const wantAudio = audio !== false && !onAir('audio');
|
|
316
|
+
const wantVideo = video !== false && !onAir('video');
|
|
317
|
+
if (publishing.has(client) || (!wantAudio && !wantVideo)) {
|
|
308
318
|
throw new RpcError('source-already-published', 'already publishing: unpublish() first');
|
|
309
319
|
}
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
320
|
+
const start = {};
|
|
321
|
+
publishing.set(client, start);
|
|
322
|
+
const ended = () => {
|
|
323
|
+
if (publishing.get(client) === start) publishing.delete(client);
|
|
324
|
+
};
|
|
325
|
+
// Stop wins over a start still under way (0.1.2): unpublish() -- the user's Stop -- while the permission dialog is up
|
|
326
|
+
// or the camera is opening says so with localStreamChanged(null). No device opens after it, what opened is released,
|
|
327
|
+
// nothing is published, and this start rejects with `unpublished`. It is over from that moment: a Go live right
|
|
328
|
+
// after the Stop is the user's last word and its own start, not this one's double tap (RN012-3).
|
|
329
|
+
let stopped = false;
|
|
330
|
+
const unwatch = client.on('localStreamChanged', (local) => {
|
|
331
|
+
if (local !== null) return;
|
|
332
|
+
stopped = true;
|
|
333
|
+
ended();
|
|
334
|
+
});
|
|
335
|
+
const stoppedError = () => new RpcError('unpublished', 'unpublish() ran while the camera and microphone were opening');
|
|
336
|
+
const demotedError = () => new RpcError('forbidden', 'the role changed while the camera and microphone were opening: only hosts and cohosts can publish');
|
|
316
337
|
try {
|
|
338
|
+
let permissions = client.mediaPermissions;
|
|
339
|
+
if ((wantAudio && !permissions.audio) || (wantVideo && !permissions.video)) {
|
|
340
|
+
// A hint from the join (client 0.9.14): the controller is asked before refusing. Null: it cannot say, and the
|
|
341
|
+
// client's publish lets it decide.
|
|
342
|
+
const fresh = await refreshPermissions(client, permissions);
|
|
343
|
+
permissions = fresh ?? { ...permissions, audio: true, video: true };
|
|
344
|
+
if (stopped) throw stoppedError();
|
|
345
|
+
}
|
|
346
|
+
if (wantAudio && !permissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
347
|
+
if (wantVideo && !permissions.video) throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
348
|
+
if (!registerVoltkitGlobals()) throw webrtcMissing();
|
|
349
|
+
|
|
317
350
|
const kinds: MediaPermissionKind[] = [];
|
|
318
|
-
if (
|
|
319
|
-
if (
|
|
351
|
+
if (wantVideo) kinds.push('camera');
|
|
352
|
+
if (wantAudio) kinds.push('microphone');
|
|
320
353
|
const statuses = await requestMediaPermissions(kinds);
|
|
321
354
|
for (const kind of kinds) {
|
|
322
355
|
const status = statuses[kind];
|
|
323
356
|
if (status !== 'granted') throw permissionRefused(kind, status);
|
|
324
357
|
}
|
|
325
358
|
// The dialog stays up as long as the user thinks: the call may be over by now (the screen unmounted, a kick, the
|
|
326
|
-
// room closed)
|
|
359
|
+
// room closed), the user tapped Stop, or a demotion came (RN012-2: a viewer's camera light never comes on). Nothing
|
|
360
|
+
// opens a device then.
|
|
361
|
+
if (stopped) throw stoppedError();
|
|
327
362
|
if (client.connectionState !== 'connected') throw new RpcError('not-connected', 'the session ended while the permissions were being asked for');
|
|
328
|
-
|
|
363
|
+
if (!mayPublish(client)) throw demotedError();
|
|
364
|
+
const constraints = captureConstraints({ ...options, audio: wantAudio ? audio : false, video: wantVideo ? video : false }, permissions);
|
|
329
365
|
const platform = reactNativePlatform();
|
|
330
366
|
const stream = (await platform.getUserMedia!(constraints)) as unknown as ClientStream;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
await client.publish(stream, constraints, publishOptions);
|
|
334
|
-
} catch (error) {
|
|
335
|
-
// Released here, the stream before its tracks (platform/streams.ts): the client kept none of it.
|
|
367
|
+
// Released here, the stream before its tracks (platform/streams.ts), while the client kept none of it.
|
|
368
|
+
const release = () => {
|
|
336
369
|
const book = streamBook();
|
|
337
370
|
const tracks = (stream as unknown as StreamLike).getTracks();
|
|
338
371
|
book.release(stream as unknown as StreamLike);
|
|
339
372
|
for (const track of tracks) book.stop(track as TrackLike);
|
|
373
|
+
};
|
|
374
|
+
if (stopped) {
|
|
375
|
+
release();
|
|
376
|
+
throw stoppedError();
|
|
377
|
+
}
|
|
378
|
+
// Demoted while the devices were opening: closed again at once, nothing is published.
|
|
379
|
+
if (!mayPublish(client)) {
|
|
380
|
+
release();
|
|
381
|
+
throw demotedError();
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
// With the constraints, a reconnect that finds the devices gone opens them again.
|
|
385
|
+
await client.publish(stream, constraints, publishOptions);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Only what the client let go of. A microphone it kept on air (the server took it, then refused the camera)
|
|
388
|
+
// stays live, in a stream the client holds now: stopped here, it stayed on air as a dead track with micEnabled
|
|
389
|
+
// saying true (the R012-2 class), and the stream is never released under a track the client keeps. By kind:
|
|
390
|
+
// behind a video processor `local` holds the picture, not the camera.
|
|
391
|
+
const kept = localStream(client)?.getTracks().filter((track) => track.readyState === 'live').map((track) => track.kind) ?? [];
|
|
392
|
+
if (kept.length === 0) release();
|
|
393
|
+
else {
|
|
394
|
+
const book = streamBook();
|
|
395
|
+
for (const track of (stream as unknown as StreamLike).getTracks()) if (!kept.includes(track.kind)) book.stop(track);
|
|
396
|
+
}
|
|
340
397
|
throw error;
|
|
341
398
|
}
|
|
342
399
|
} finally {
|
|
343
|
-
|
|
400
|
+
if (typeof unwatch === 'function') unwatch();
|
|
401
|
+
ended();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The project's permissions as the controller says now (client 0.9.14), or null when it cannot say. A client older
|
|
407
|
+
* than 0.9.14 cannot ask: its hint stands. A failed question leaves the hint standing too.
|
|
408
|
+
*/
|
|
409
|
+
async function refreshPermissions(client: StreamClient, hint: MediaPermissions): Promise<MediaPermissions | null> {
|
|
410
|
+
const refresh = (client as { refreshMediaPermissions?: () => Promise<MediaPermissions | null> }).refreshMediaPermissions;
|
|
411
|
+
if (typeof refresh !== 'function') return hint;
|
|
412
|
+
try {
|
|
413
|
+
return await refresh.call(client);
|
|
414
|
+
} catch {
|
|
415
|
+
return hint;
|
|
344
416
|
}
|
|
345
417
|
}
|
|
346
418
|
|
package/src/native-session.tsx
CHANGED
|
@@ -83,11 +83,35 @@ class CallServices {
|
|
|
83
83
|
private watching: (() => void) | null = null;
|
|
84
84
|
private state: CallServiceState | null = null;
|
|
85
85
|
private readonly listeners = new Set<() => void>();
|
|
86
|
+
/** A call gone: decided after this turn of the JavaScript thread (see `set`). */
|
|
87
|
+
private settling: Promise<void> | null = null;
|
|
86
88
|
|
|
87
89
|
set(key: object, want: (() => Want) | null, report: (error: Error) => void = () => undefined): void {
|
|
88
|
-
if (want)
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
if (want) {
|
|
91
|
+
this.wants.set(key, { want, report });
|
|
92
|
+
this.reconcile();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.wants.delete(key);
|
|
96
|
+
// React replaces a screen's provider by another's in one commit: the old effect's cleanup first, the new effect
|
|
97
|
+
// right after. Decided at once, the stop would be on its way to Android when the new provider registers the same
|
|
98
|
+
// live client, and the service would bounce (RN012-1); in the background, where Android lets it stop but not start
|
|
99
|
+
// again, the call would then be cut. So a call gone is decided once the turn is over.
|
|
100
|
+
this.settling ??= Promise.resolve().then(() => {
|
|
101
|
+
this.settling = null;
|
|
102
|
+
this.reconcile();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Each live call told in turn: an app's listener that throws (RN012-5) or a close that throws never silences the next call. */
|
|
107
|
+
private each(wants: Want[], tell: (want: Want) => void, context: string): void {
|
|
108
|
+
for (const want of wants) {
|
|
109
|
+
try {
|
|
110
|
+
tell(want);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logError(context, err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
/** Something a call publishes may have changed: decide again. */
|
|
@@ -172,7 +196,8 @@ class CallServices {
|
|
|
172
196
|
private run(module: Spec, what: 'start' | 'update' | 'stop', options: NativeCallServiceOptions | null): void {
|
|
173
197
|
this.inFlight = what;
|
|
174
198
|
const key = options ? JSON.stringify(options) : null;
|
|
175
|
-
|
|
199
|
+
// Called now; a module that throws instead of rejecting (its bridge gone) is a rejection all the same.
|
|
200
|
+
const call = (async () => (what === 'stop' ? module.stopCallService() : what === 'start' ? module.startCallService(options!) : module.updateCallService(options!)))();
|
|
176
201
|
call.then(
|
|
177
202
|
(status) => {
|
|
178
203
|
this.running = status.running;
|
|
@@ -187,7 +212,7 @@ class CallServices {
|
|
|
187
212
|
}
|
|
188
213
|
this.failed = key;
|
|
189
214
|
const error = callServiceFailed(describeError(err));
|
|
190
|
-
|
|
215
|
+
this.each(this.live(), (want) => want.report(error), 'onError threw');
|
|
191
216
|
},
|
|
192
217
|
).finally(() => {
|
|
193
218
|
this.inFlight = null;
|
|
@@ -232,22 +257,26 @@ class CallServices {
|
|
|
232
257
|
this.publish({ running: true, types: [...types] });
|
|
233
258
|
return;
|
|
234
259
|
case 'stopped':
|
|
260
|
+
// The service's real state, whatever stopped it: a stop of ours, asked when no call was live and run by Android
|
|
261
|
+
// later, when a call may be live again (a screen's provider replaced by another holding the same client,
|
|
262
|
+
// RN012-1), or something outside (an app's own stopService). The calls decide again: a live call gets it back
|
|
263
|
+
// now while the app is visible, as after a join; in the background it waits for the front, as every start does.
|
|
264
|
+
// Until 0.1.2 a live call here was held as a failure until the next front, and the first background stay would
|
|
265
|
+
// have cut the call.
|
|
235
266
|
this.running = false;
|
|
236
267
|
this.applied = null;
|
|
237
|
-
// Stopped from outside while a call is live: started again when the app next comes to the front, not now.
|
|
238
|
-
if (this.live().length > 0) this.failed = JSON.stringify(merged(this.live()));
|
|
239
268
|
this.publish({ running: false, types: [] });
|
|
240
269
|
this.reconcile();
|
|
241
270
|
return;
|
|
242
271
|
case 'leave':
|
|
243
272
|
case 'taskRemoved':
|
|
244
|
-
|
|
273
|
+
this.each(this.live(), (want) => want.leave(), 'a call could not be closed');
|
|
245
274
|
return;
|
|
246
275
|
case 'failed': {
|
|
247
276
|
// VoltkitCallService.runWith tells `failed` and then rejects the start or update: the rejection reports it (RN010-2).
|
|
248
277
|
if (this.inFlight === 'start' || this.inFlight === 'update') return;
|
|
249
278
|
const error = callServiceFailed(message || 'Android refused it');
|
|
250
|
-
|
|
279
|
+
this.each(this.live(), (want) => want.report(error), 'onError threw');
|
|
251
280
|
return;
|
|
252
281
|
}
|
|
253
282
|
default:
|
package/src/native.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { iosUnsupported, logError, nativeMissing } from './errors';
|
|
|
10
10
|
import type { AudioDeviceType, DeviceKind, MediaDevice } from '@voltkit-rtc/client';
|
|
11
11
|
|
|
12
12
|
/** The version of this package: what getDiagnostics() reports, and what the native side was built from. */
|
|
13
|
-
export const SDK_VERSION = '0.1.
|
|
13
|
+
export const SDK_VERSION = '0.1.3';
|
|
14
14
|
|
|
15
15
|
/** The native module, or null where there is none (iOS, Expo Go, an app not rebuilt). */
|
|
16
16
|
export function nativeModule(): Spec | null {
|
package/src/platform/common.ts
CHANGED
|
@@ -89,7 +89,7 @@ export function localStream(client: { readonly local: unknown }): MediaStreamLik
|
|
|
89
89
|
return (client.local as MediaStreamLike | null | undefined) ?? null;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** getDiagnostics().userAgent: `ReactNative android 37; react-native-webrtc 155.0.1; @voltkit-rtc/react-native 0.1.
|
|
92
|
+
/** getDiagnostics().userAgent: `ReactNative android 37; react-native-webrtc 155.0.1; @voltkit-rtc/react-native 0.1.3`. */
|
|
93
93
|
export function describePlatform(): string {
|
|
94
94
|
return `ReactNative ${Platform.OS} ${String(Platform.Version)}; react-native-webrtc ${webrtcVersion() ?? 'unknown'}; @voltkit-rtc/react-native ${SDK_VERSION}`;
|
|
95
95
|
}
|
package/src/provider.tsx
CHANGED
|
@@ -56,8 +56,16 @@ export function VoltkitProvider({
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
const wanted = Boolean(client || (wsUrl && token));
|
|
59
|
+
// Said once per ask: StrictMode runs an effect twice on mount (development only), and onError heard
|
|
60
|
+
// webrtc-missing twice (RN012-4). Asked for a client anew (a new token after none), it is said again.
|
|
61
|
+
const said = useRef(false);
|
|
59
62
|
useEffect(() => {
|
|
60
|
-
if (!wanted)
|
|
63
|
+
if (!wanted) {
|
|
64
|
+
said.current = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (said.current) return;
|
|
68
|
+
said.current = true;
|
|
61
69
|
if (!webrtc) errors.current?.(webrtcMissing());
|
|
62
70
|
else if (Platform.OS === 'android' && !nativeModule()) errors.current?.(nativeMissing('Background calls, audio routing and native timers'));
|
|
63
71
|
}, [webrtc, wanted]);
|