@voltkit-rtc/react-native 0.1.0 → 0.1.2

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 (34) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +9 -7
  3. package/lib/commonjs/local-media.js +52 -10
  4. package/lib/commonjs/local-media.js.map +1 -1
  5. package/lib/commonjs/native-session.js +61 -10
  6. package/lib/commonjs/native-session.js.map +1 -1
  7. package/lib/commonjs/native.js +1 -1
  8. package/lib/commonjs/platform/common.js +1 -1
  9. package/lib/commonjs/provider.js +3 -1
  10. package/lib/commonjs/provider.js.map +1 -1
  11. package/lib/module/local-media.js +52 -10
  12. package/lib/module/local-media.js.map +1 -1
  13. package/lib/module/native-session.js +61 -10
  14. package/lib/module/native-session.js.map +1 -1
  15. package/lib/module/native.js +1 -1
  16. package/lib/module/platform/common.js +1 -1
  17. package/lib/module/provider.js +3 -1
  18. package/lib/module/provider.js.map +1 -1
  19. package/lib/typescript/commonjs/native-session.d.ts +1 -1
  20. package/lib/typescript/commonjs/native-session.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/native.d.ts +1 -1
  22. package/lib/typescript/commonjs/platform/common.d.ts +1 -1
  23. package/lib/typescript/commonjs/provider.d.ts.map +1 -1
  24. package/lib/typescript/module/native-session.d.ts +1 -1
  25. package/lib/typescript/module/native-session.d.ts.map +1 -1
  26. package/lib/typescript/module/native.d.ts +1 -1
  27. package/lib/typescript/module/platform/common.d.ts +1 -1
  28. package/lib/typescript/module/provider.d.ts.map +1 -1
  29. package/package.json +2 -2
  30. package/src/local-media.ts +49 -11
  31. package/src/native-session.tsx +59 -13
  32. package/src/native.ts +1 -1
  33. package/src/platform/common.ts +1 -1
  34. package/src/provider.tsx +3 -1
@@ -307,13 +307,28 @@ async function publishDevices(client: StreamClient, options: LocalPublishOptions
307
307
  if (publishing.has(client) || localStream(client)?.getTracks().some((track) => track.readyState === 'live')) {
308
308
  throw new RpcError('source-already-published', 'already publishing: unpublish() first');
309
309
  }
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
310
  publishing.add(client);
311
+ // Stop wins over a start still under way (0.1.2): unpublish() -- the user's Stop -- while the permission dialog is up
312
+ // 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`.
314
+ let stopped = false;
315
+ const unwatch = client.on('localStreamChanged', (local) => {
316
+ if (local === null) stopped = true;
317
+ });
318
+ const stoppedError = () => new RpcError('unpublished', 'unpublish() ran while the camera and microphone were opening');
316
319
  try {
320
+ let permissions = client.mediaPermissions;
321
+ if ((audio !== false && !permissions.audio) || (video !== false && !permissions.video)) {
322
+ // A hint from the join (client 0.9.14): the controller is asked before refusing. Null: it cannot say, and the
323
+ // client's publish lets it decide.
324
+ const fresh = await refreshPermissions(client, permissions);
325
+ permissions = fresh ?? { ...permissions, audio: true, video: true };
326
+ if (stopped) throw stoppedError();
327
+ }
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');
330
+ if (!registerVoltkitGlobals()) throw webrtcMissing();
331
+
317
332
  const kinds: MediaPermissionKind[] = [];
318
333
  if (video !== false) kinds.push('camera');
319
334
  if (audio !== false) kinds.push('microphone');
@@ -323,27 +338,50 @@ async function publishDevices(client: StreamClient, options: LocalPublishOptions
323
338
  if (status !== 'granted') throw permissionRefused(kind, status);
324
339
  }
325
340
  // 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.
341
+ // room closed), or the user tapped Stop. Nothing opens a device then.
342
+ if (stopped) throw stoppedError();
327
343
  if (client.connectionState !== 'connected') throw new RpcError('not-connected', 'the session ended while the permissions were being asked for');
328
344
  const constraints = captureConstraints(options, permissions);
329
345
  const platform = reactNativePlatform();
330
346
  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.
347
+ // Released here, the stream before its tracks (platform/streams.ts): the client kept none of it.
348
+ const release = () => {
336
349
  const book = streamBook();
337
350
  const tracks = (stream as unknown as StreamLike).getTracks();
338
351
  book.release(stream as unknown as StreamLike);
339
352
  for (const track of tracks) book.stop(track as TrackLike);
353
+ };
354
+ if (stopped) {
355
+ release();
356
+ throw stoppedError();
357
+ }
358
+ try {
359
+ // With the constraints, a reconnect that finds the devices gone opens them again.
360
+ await client.publish(stream, constraints, publishOptions);
361
+ } catch (error) {
362
+ release();
340
363
  throw error;
341
364
  }
342
365
  } finally {
366
+ if (typeof unwatch === 'function') unwatch();
343
367
  publishing.delete(client);
344
368
  }
345
369
  }
346
370
 
371
+ /**
372
+ * The project's permissions as the controller says now (client 0.9.14), or null when it cannot say. A client older
373
+ * than 0.9.14 cannot ask: its hint stands. A failed question leaves the hint standing too.
374
+ */
375
+ async function refreshPermissions(client: StreamClient, hint: MediaPermissions): Promise<MediaPermissions | null> {
376
+ const refresh = (client as { refreshMediaPermissions?: () => Promise<MediaPermissions | null> }).refreshMediaPermissions;
377
+ if (typeof refresh !== 'function') return hint;
378
+ try {
379
+ return await refresh.call(client);
380
+ } catch {
381
+ return hint;
382
+ }
383
+ }
384
+
347
385
  /** Each quality's 16:9 frame, as the SDK sends it. */
348
386
  const FRAMES: Record<VideoQuality, { width: number; height: number }> = {
349
387
  '1080p': { width: 1920, height: 1080 },
@@ -13,7 +13,7 @@
13
13
  * Failures reach the provider's onError as `media-unavailable` with `{ feature: 'callService' }`.
14
14
  */
15
15
  import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
16
- import type { StreamClient } from '@voltkit-rtc/client';
16
+ import type { ClientOptions, StreamClient } from '@voltkit-rtc/client';
17
17
  import { useVoltkitClient } from '@voltkit-rtc/react';
18
18
  import type { CallServiceOptions as NativeCallServiceOptions, CallServiceStatus, Spec } from './specs/NativeVoltkitRtc';
19
19
  import { listen, nativeConstants, nativeModule } from './native';
@@ -48,6 +48,7 @@ interface Want {
48
48
  micPublished: boolean;
49
49
  cameraPublished: boolean;
50
50
  sharing: boolean;
51
+ /** The client switches its camera off in the background, so the service needs no camera type. */
51
52
  pauseCamera: boolean;
52
53
  text: CallServiceText;
53
54
  leave(): void;
@@ -66,10 +67,14 @@ export interface CallServiceState {
66
67
  const NOT_DECLARED: CallServiceState = Object.freeze({ declared: false, running: false, types: Object.freeze([]) as unknown as string[] });
67
68
 
68
69
  class CallServices {
69
- /** What each call wants, read afresh at every decision: a call's state is its client's, which emits no event for some changes. */
70
- private readonly wants = new Map<object, () => Want>();
70
+ /**
71
+ * What each call wants, read afresh at every decision: a call's state is its client's, which emits no event for some
72
+ * changes. With how to tell the call when its state cannot be read at all.
73
+ */
74
+ private readonly wants = new Map<object, { want: () => Want; report: (error: Error) => void }>();
71
75
  private running = false;
72
- private busy = false;
76
+ /** The call to the native module under way: a `failed` event during a start or update is its rejection's twin. */
77
+ private inFlight: 'start' | 'update' | 'stop' | null = null;
73
78
  private again = false;
74
79
  /** The options the service runs with, as sent. */
75
80
  private applied: string | null = null;
@@ -79,8 +84,8 @@ class CallServices {
79
84
  private state: CallServiceState | null = null;
80
85
  private readonly listeners = new Set<() => void>();
81
86
 
82
- set(key: object, want: (() => Want) | null): void {
83
- if (want) this.wants.set(key, want);
87
+ set(key: object, want: (() => Want) | null, report: (error: Error) => void = () => undefined): void {
88
+ if (want) this.wants.set(key, { want, report });
84
89
  else this.wants.delete(key);
85
90
  this.reconcile();
86
91
  }
@@ -120,7 +125,26 @@ class CallServices {
120
125
  }
121
126
 
122
127
  private live(): Want[] {
123
- return [...this.wants.values()].map((want) => want()).filter((w) => w.live);
128
+ const live: Want[] = [];
129
+ for (const [key, { want, report }] of [...this.wants]) {
130
+ let w: Want;
131
+ try {
132
+ w = want();
133
+ } catch (err) {
134
+ // A client whose state cannot be read (a wrapper that throws on a read): this call is left out of the service
135
+ // for good, said once, and never keeps the other calls from theirs (the verifier's F3 on 0.1.1).
136
+ this.wants.delete(key);
137
+ logError('a call is left out of the call service: its client\'s state could not be read', err);
138
+ try {
139
+ report(callServiceFailed(`this call is left out, its client's state could not be read: ${describeError(err)}`));
140
+ } catch (reportErr) {
141
+ logError('onError threw', reportErr);
142
+ }
143
+ continue;
144
+ }
145
+ if (w.live) live.push(w);
146
+ }
147
+ return live;
124
148
  }
125
149
 
126
150
  private reconcile(): void {
@@ -128,7 +152,7 @@ class CallServices {
128
152
  if (!module || !nativeConstants()?.callServiceDeclared) return;
129
153
  const live = this.live();
130
154
  this.watch(module, live.length > 0 || this.running);
131
- if (this.busy) {
155
+ if (this.inFlight) {
132
156
  this.again = true;
133
157
  return;
134
158
  }
@@ -146,7 +170,7 @@ class CallServices {
146
170
  }
147
171
 
148
172
  private run(module: Spec, what: 'start' | 'update' | 'stop', options: NativeCallServiceOptions | null): void {
149
- this.busy = true;
173
+ this.inFlight = what;
150
174
  const key = options ? JSON.stringify(options) : null;
151
175
  const call = what === 'stop' ? module.stopCallService() : what === 'start' ? module.startCallService(options!) : module.updateCallService(options!);
152
176
  call.then(
@@ -166,7 +190,7 @@ class CallServices {
166
190
  for (const want of this.live()) want.report(error);
167
191
  },
168
192
  ).finally(() => {
169
- this.busy = false;
193
+ this.inFlight = null;
170
194
  if (this.again) {
171
195
  this.again = false;
172
196
  this.reconcile();
@@ -220,6 +244,8 @@ class CallServices {
220
244
  for (const want of this.live()) want.leave();
221
245
  return;
222
246
  case 'failed': {
247
+ // VoltkitCallService.runWith tells `failed` and then rejects the start or update: the rejection reports it (RN010-2).
248
+ if (this.inFlight === 'start' || this.inFlight === 'update') return;
223
249
  const error = callServiceFailed(message || 'Android refused it');
224
250
  for (const want of this.live()) want.report(error);
225
251
  return;
@@ -265,7 +291,7 @@ export function useCallService(): CallServiceState {
265
291
  export interface NativeSessionProps {
266
292
  audio: AudioSessionOptions | false | undefined;
267
293
  callService: boolean | CallServiceText | undefined;
268
- /** The client's pauseCameraInBackground, as it was made. */
294
+ /** The provider's background.pauseCamera: for a client whose own options cannot be read (see pausesCameraInBackground). */
269
295
  pauseCamera: boolean;
270
296
  onError: { readonly current: ((error: Error) => void) | undefined };
271
297
  }
@@ -305,7 +331,8 @@ export function NativeSession({ audio, callService, pauseCamera, onError }: Nati
305
331
  useEffect(() => {
306
332
  if (!client || !wantsService || !nativeModule()) return;
307
333
  const key = {};
308
- callServices.set(key, () => wantOf(client, latest.current, (error) => onError.current?.(error)));
334
+ const report = (error: Error) => onError.current?.(error);
335
+ callServices.set(key, () => wantOf(client, latest.current, report), report);
309
336
  const update = () => callServices.changed();
310
337
  // publish() and a camera switch emit no event of their own: useLocalMedia's calls say so through the local session.
311
338
  const offs = [
@@ -330,9 +357,28 @@ function wantOf(client: StreamClient, latest: { text: CallServiceText; pauseCame
330
357
  micPublished: (local?.getAudioTracks().length ?? 0) > 0,
331
358
  cameraPublished: (local?.getVideoTracks().length ?? 0) > 0,
332
359
  sharing: localSessionOf(client).screen !== null,
333
- pauseCamera: latest.pauseCamera,
360
+ pauseCamera: pausesCameraInBackground(client) ?? latest.pauseCamera,
334
361
  text: latest.text,
335
362
  leave: () => client.close(),
336
363
  report,
337
364
  };
338
365
  }
366
+
367
+ /**
368
+ * Whether the client switches its camera off in the background, from its own options: the provider's `background` only
369
+ * describes a client the provider made, and an app-made one (`createClient`, the `client` prop) keeps its camera on with
370
+ * `pauseCamera: false` while the service was asked for no camera type, so Android cut the camera (RN010-1). The client
371
+ * pauses only with `pauseCameraInBackground === true` (left out, the camera runs on). Null when the options cannot be
372
+ * read (missing, not an object, or a read that throws: a wrapped or stubbed client): the provider's setting stands in.
373
+ */
374
+ function pausesCameraInBackground(client: StreamClient): boolean | null {
375
+ let options: unknown;
376
+ try {
377
+ options = (client as unknown as { options?: unknown }).options;
378
+ } catch {
379
+ // A wrapper that forbids the read (a logging or sealing Proxy): not an error, the provider's setting stands in.
380
+ return null;
381
+ }
382
+ if (!options || typeof options !== 'object') return null;
383
+ return (options as ClientOptions).pauseCameraInBackground === true;
384
+ }
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.0';
13
+ export const SDK_VERSION = '0.1.2';
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.0`. */
92
+ /** getDiagnostics().userAgent: `ReactNative android 37; react-native-webrtc 155.0.1; @voltkit-rtc/react-native 0.1.2`. */
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
@@ -68,7 +68,9 @@ export function VoltkitProvider({
68
68
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
69
  [webrtc, wsUrl, token],
70
70
  );
71
- const pauseCamera = options?.pauseCameraInBackground !== false;
71
+ // The call service reads the setting from the client it runs for (an app-made client has its own); this stands in
72
+ // when the client's options cannot be read.
73
+ const pauseCamera = options?.pauseCameraInBackground === true;
72
74
 
73
75
  return (
74
76
  <ReactVoltkitProvider