@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.
@@ -293,8 +293,13 @@ function localActions(client: StreamClient | null): LocalActions {
293
293
 
294
294
  // ----------------------------------------------------------------------------------------------------- publish
295
295
 
296
- /** Clients with a publish() between asking for permissions and publishing. */
297
- const publishing = new WeakSet<StreamClient>();
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
- const role = client.info?.role;
306
- if (role !== 'host' && role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can publish');
307
- if (publishing.has(client) || localStream(client)?.getTracks().some((track) => track.readyState === 'live')) {
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 permissions = client.mediaPermissions;
311
- if (audio !== false && !permissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
312
- if (video !== false && !permissions.video) throw new RpcError('media-disabled', 'video is switched off for this project');
313
- if (!registerVoltkitGlobals()) throw webrtcMissing();
314
-
315
- publishing.add(client);
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 (video !== false) kinds.push('camera');
319
- if (audio !== false) kinds.push('microphone');
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). Nothing opens a device for a session that is gone.
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
- const constraints = captureConstraints(options, permissions);
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
- try {
332
- // With the constraints, a reconnect that finds the devices gone opens them again.
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
- publishing.delete(client);
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
 
@@ -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) this.wants.set(key, { want, report });
89
- else this.wants.delete(key);
90
- this.reconcile();
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
- const call = what === 'stop' ? module.stopCallService() : what === 'start' ? module.startCallService(options!) : module.updateCallService(options!);
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
- for (const want of this.live()) want.report(error);
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
- for (const want of this.live()) want.leave();
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
- for (const want of this.live()) want.report(error);
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.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 {
@@ -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.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) return;
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]);