@voltkit-rtc/react-native 0.1.2 → 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,49 +307,64 @@ 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
- publishing.add(client);
320
+ const start = {};
321
+ publishing.set(client, start);
322
+ const ended = () => {
323
+ if (publishing.get(client) === start) publishing.delete(client);
324
+ };
311
325
  // Stop wins over a start still under way (0.1.2): unpublish() -- the user's Stop -- while the permission dialog is up
312
326
  // or the camera is opening says so with localStreamChanged(null). No device opens after it, what opened is released,
313
- // nothing is published, and this start rejects with `unpublished`.
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).
314
329
  let stopped = false;
315
330
  const unwatch = client.on('localStreamChanged', (local) => {
316
- if (local === null) stopped = true;
331
+ if (local !== null) return;
332
+ stopped = true;
333
+ ended();
317
334
  });
318
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');
319
337
  try {
320
338
  let permissions = client.mediaPermissions;
321
- if ((audio !== false && !permissions.audio) || (video !== false && !permissions.video)) {
339
+ if ((wantAudio && !permissions.audio) || (wantVideo && !permissions.video)) {
322
340
  // A hint from the join (client 0.9.14): the controller is asked before refusing. Null: it cannot say, and the
323
341
  // client's publish lets it decide.
324
342
  const fresh = await refreshPermissions(client, permissions);
325
343
  permissions = fresh ?? { ...permissions, audio: true, video: true };
326
344
  if (stopped) throw stoppedError();
327
345
  }
328
- if (audio !== false && !permissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
329
- if (video !== false && !permissions.video) throw new RpcError('media-disabled', 'video is switched off for this project');
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');
330
348
  if (!registerVoltkitGlobals()) throw webrtcMissing();
331
349
 
332
350
  const kinds: MediaPermissionKind[] = [];
333
- if (video !== false) kinds.push('camera');
334
- if (audio !== false) kinds.push('microphone');
351
+ if (wantVideo) kinds.push('camera');
352
+ if (wantAudio) kinds.push('microphone');
335
353
  const statuses = await requestMediaPermissions(kinds);
336
354
  for (const kind of kinds) {
337
355
  const status = statuses[kind];
338
356
  if (status !== 'granted') throw permissionRefused(kind, status);
339
357
  }
340
358
  // The dialog stays up as long as the user thinks: the call may be over by now (the screen unmounted, a kick, the
341
- // room closed), or the user tapped Stop. Nothing opens a device then.
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.
342
361
  if (stopped) throw stoppedError();
343
362
  if (client.connectionState !== 'connected') throw new RpcError('not-connected', 'the session ended while the permissions were being asked for');
344
- 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);
345
365
  const platform = reactNativePlatform();
346
366
  const stream = (await platform.getUserMedia!(constraints)) as unknown as ClientStream;
347
- // 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.
348
368
  const release = () => {
349
369
  const book = streamBook();
350
370
  const tracks = (stream as unknown as StreamLike).getTracks();
@@ -355,16 +375,30 @@ async function publishDevices(client: StreamClient, options: LocalPublishOptions
355
375
  release();
356
376
  throw stoppedError();
357
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
+ }
358
383
  try {
359
384
  // With the constraints, a reconnect that finds the devices gone opens them again.
360
385
  await client.publish(stream, constraints, publishOptions);
361
386
  } catch (error) {
362
- release();
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
+ }
363
397
  throw error;
364
398
  }
365
399
  } finally {
366
400
  if (typeof unwatch === 'function') unwatch();
367
- publishing.delete(client);
401
+ ended();
368
402
  }
369
403
  }
370
404
 
@@ -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.2';
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.2`. */
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]);