crowdplaysdk 0.5.1 → 0.6.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.
package/README.md CHANGED
@@ -60,13 +60,39 @@ https://github.com/symbiateam/crowdplaysdk
60
60
 
61
61
  ## Setup (iOS)
62
62
 
63
- Requirements: iOS 17+, React Native 0.71 or newer, a real device to test
64
- (the Simulator has no camera or mic), and an **app key** from CrowdPlay.
63
+ Requirements: iOS 17+, React Native 0.71 or newer (Expo SDK 50 or newer
64
+ for the config plugin), a real device to test (the Simulator has no camera
65
+ or mic), and an **app key** from CrowdPlay.
65
66
  iOS only for now; calls to the module on other platforms throw with a
66
67
  clear message.
67
68
 
68
69
  **1. Install the package and wire the engine.**
69
70
 
71
+ Expo (managed, prebuild or EAS Build, including app builders such as X1):
72
+
73
+ ```bash
74
+ npm install crowdplaysdk
75
+ ```
76
+
77
+ then add the plugin to `app.json` and rebuild (`npx expo prebuild`, a
78
+ development build, or `eas build`):
79
+
80
+ ```json
81
+ "plugins": ["crowdplaysdk"]
82
+ ```
83
+
84
+ That line is the whole native setup. The plugin adds the microphone and
85
+ camera usage strings and the audio background mode to Info.plist, raises
86
+ the iOS deployment target to 17.0, and wires the compiled engine into the
87
+ Xcode project at prebuild time. Options: `["crowdplaysdk", { "audioOnly":
88
+ true }]` skips the camera string; `microphonePermission` and
89
+ `cameraPermission` set your own wording. Expo Go cannot load native
90
+ modules, so the call, the consent text and recording only work in a
91
+ development build or a TestFlight build; the JavaScript side shows
92
+ placeholders in Expo Go instead of crashing.
93
+
94
+ Bare React Native (no Expo):
95
+
70
96
  ```bash
71
97
  npm install crowdplaysdk
72
98
  cd ios && pod install
@@ -75,11 +101,14 @@ ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
75
101
  ```
76
102
 
77
103
  `wire.rb` is idempotent. It adds the compiled CrowdPlaySDK Swift package,
78
- copies the three bridge files into your app target, and points the Swift
79
- bridging header at the React headers. There is no CocoaPod of our own, so
80
- your Podfile stays untouched.
104
+ copies the three bridge files into your app target, points the Swift
105
+ bridging header at the React headers, raises the app target to iOS 17 and
106
+ keeps explicit Swift modules on for the app target (React Native 0.8x turns
107
+ them off project-wide on Xcode 26, which breaks importing the compiled
108
+ engine). There is no CocoaPod of our own, so your Podfile stays untouched.
81
109
 
82
- **2. Declare permissions in Info.plist.**
110
+ **2. Declare permissions in Info.plist** (bare React Native only; the Expo
111
+ plugin does this for you).
83
112
 
84
113
  ```
85
114
  NSMicrophoneUsageDescription = "Records your microphone during sessions."
@@ -87,8 +116,8 @@ NSCameraUsageDescription = "Records your camera during sessions."
87
116
  UIBackgroundModes = [audio]
88
117
  ```
89
118
 
90
- **3. Let uploads finish while the app is suspended** (recommended). In your
91
- AppDelegate:
119
+ **3. Let uploads finish while the app is suspended** (recommended, both
120
+ paths). In your AppDelegate:
92
121
 
93
122
  ```objc
94
123
  // AppDelegate.mm, with #import "<YourProjectName>-Swift.h" at the top
package/app.plugin.js ADDED
@@ -0,0 +1,186 @@
1
+ // Expo config plugin for crowdplaysdk (D-121).
2
+ //
3
+ // app.json: "plugins": ["crowdplaysdk"]
4
+ // or "plugins": [["crowdplaysdk", { "audioOnly": true }]]
5
+ //
6
+ // That line is the whole native setup for an Expo app (managed, prebuild or
7
+ // EAS Build). At `expo prebuild` time it:
8
+ // 1. adds the microphone (and camera) usage strings and the `audio`
9
+ // background mode to Info.plist, only where the app has not set them;
10
+ // 2. raises ios.deploymentTarget to 17.0 (the engine requires iOS 17);
11
+ // 3. runs ios/wire.rb against the generated Xcode project, exactly as a
12
+ // bare React Native builder would by hand: it adds the compiled
13
+ // CrowdPlaySDK Swift package (pinned to the engine release the bridge
14
+ // was compiled against), copies the three bridge files into the app
15
+ // target and points the bridging header at the React headers.
16
+ // Step 3 runs in Expo's `finalized` mod, after every other mod has written
17
+ // the pbxproj and Info.plist, so nothing in config-plugins rewrites the
18
+ // project afterwards; `pod install` (which runs later) integrates through
19
+ // the same Xcodeproj gem and keeps the package reference.
20
+ //
21
+ // Props (all optional):
22
+ // audioOnly true: no camera usage string is added
23
+ // microphonePermission the NSMicrophoneUsageDescription text
24
+ // cameraPermission the NSCameraUsageDescription text
25
+ // backgroundAudio false: do not add the `audio` background mode
26
+ //
27
+ // Requires Expo SDK 50 or newer (the `finalized` mod) and a Ruby with the
28
+ // xcodeproj gem, which every CocoaPods install provides; EAS Build images
29
+ // ship both. Expo Go cannot load native modules, so the call, the consent
30
+ // text and recording only work in a development build or a TestFlight
31
+ // build; the JavaScript side degrades instead of crashing there.
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const { spawnSync } = require('child_process');
37
+ const pkg = require('./package.json');
38
+
39
+ let configPlugins;
40
+ try {
41
+ configPlugins = require('expo/config-plugins');
42
+ } catch (e) {
43
+ configPlugins = require('@expo/config-plugins');
44
+ }
45
+ const {
46
+ withInfoPlist, withPodfileProperties, withFinalizedMod, createRunOncePlugin, IOSConfig,
47
+ } = configPlugins;
48
+
49
+ const MIC_DEFAULT = 'Records your microphone during sessions.';
50
+ const CAMERA_DEFAULT = 'Records your camera during sessions.';
51
+ const MIN_IOS = 17;
52
+ const REACT_IMPORTS = [
53
+ '#import <React/RCTBridgeModule.h>',
54
+ '#import <React/RCTEventEmitter.h>',
55
+ '#import <React/RCTViewManager.h>',
56
+ ];
57
+
58
+ function withCrowdPlayInfoPlist(config, props) {
59
+ return withInfoPlist(config, (config) => {
60
+ const plist = config.modResults;
61
+ if (!plist.NSMicrophoneUsageDescription) {
62
+ plist.NSMicrophoneUsageDescription = props.microphonePermission || MIC_DEFAULT;
63
+ }
64
+ if (props.audioOnly !== true && !plist.NSCameraUsageDescription) {
65
+ plist.NSCameraUsageDescription = props.cameraPermission || CAMERA_DEFAULT;
66
+ }
67
+ if (props.backgroundAudio !== false) {
68
+ if (!Array.isArray(plist.UIBackgroundModes)) plist.UIBackgroundModes = [];
69
+ if (!plist.UIBackgroundModes.includes('audio')) plist.UIBackgroundModes.push('audio');
70
+ }
71
+ return config;
72
+ });
73
+ }
74
+
75
+ function withCrowdPlayDeploymentTarget(config) {
76
+ return withPodfileProperties(config, (config) => {
77
+ const current = parseFloat(config.modResults['ios.deploymentTarget'] || '0');
78
+ if (!(current >= MIN_IOS)) config.modResults['ios.deploymentTarget'] = `${MIN_IOS}.0`;
79
+ return config;
80
+ });
81
+ }
82
+
83
+ /** The Xcode project name: Expo names the .xcodeproj after the app, and
84
+ * modRequest.projectName carries it; fall back to a glob when it does not. */
85
+ function resolveProjectName(modRequest) {
86
+ const { projectName, projectRoot, platformProjectRoot } = modRequest;
87
+ if (projectName && fs.existsSync(path.join(platformProjectRoot, `${projectName}.xcodeproj`))) {
88
+ return projectName;
89
+ }
90
+ const found = IOSConfig.Paths.getXcodeProjectPath(projectRoot);
91
+ return path.basename(found, '.xcodeproj');
92
+ }
93
+
94
+ /** A Ruby that can load the xcodeproj gem: the one on PATH, the one that
95
+ * runs `pod`, or Bundler's when the project has a Gemfile. */
96
+ function findRuby(platformProjectRoot) {
97
+ const candidates = [{ cmd: 'ruby', args: [] }];
98
+ const pod = spawnSync('which', ['pod'], { encoding: 'utf8' });
99
+ if (pod.status === 0) {
100
+ try {
101
+ const shebang = fs.readFileSync(pod.stdout.trim(), 'utf8').split('\n')[0];
102
+ const m = /^#!\s*(\S+)(?:\s+(\S+))?/.exec(shebang);
103
+ if (m) {
104
+ const cmd = m[1].endsWith('/env') ? m[2] : m[1];
105
+ if (cmd && cmd !== 'ruby') candidates.push({ cmd, args: [] });
106
+ }
107
+ } catch (e) { /* not a script; ignore */ }
108
+ }
109
+ if (fs.existsSync(path.join(platformProjectRoot, 'Gemfile')) ||
110
+ fs.existsSync(path.join(platformProjectRoot, '..', 'Gemfile'))) {
111
+ candidates.push({ cmd: 'bundle', args: ['exec', 'ruby'] });
112
+ }
113
+ for (const c of candidates) {
114
+ const probe = spawnSync(c.cmd, [...c.args, '-e', "require 'xcodeproj'"], {
115
+ cwd: platformProjectRoot, encoding: 'utf8',
116
+ });
117
+ if (probe.status === 0) return c;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /** wire.rb creates the bridging header only when the target has none. Expo's
123
+ * template already has one (a comment-only file), so add the React imports
124
+ * the bridge files need to whatever header the project points at. */
125
+ function ensureBridgingHeaderImports(platformProjectRoot, projectName) {
126
+ const pbxproj = path.join(platformProjectRoot, `${projectName}.xcodeproj`, 'project.pbxproj');
127
+ if (!fs.existsSync(pbxproj)) return;
128
+ const text = fs.readFileSync(pbxproj, 'utf8');
129
+ const headers = new Set();
130
+ const re = /SWIFT_OBJC_BRIDGING_HEADER\s*=\s*"?([^";]+)"?\s*;/g;
131
+ let m;
132
+ while ((m = re.exec(text)) !== null) headers.add(m[1].trim());
133
+ for (const rel of headers) {
134
+ const file = path.join(platformProjectRoot, rel);
135
+ if (!fs.existsSync(file)) continue;
136
+ const body = fs.readFileSync(file, 'utf8');
137
+ if (body.includes('React/RCTBridgeModule.h')) continue;
138
+ const sep = body.length === 0 || body.endsWith('\n') ? '' : '\n';
139
+ fs.writeFileSync(file, body + sep + REACT_IMPORTS.join('\n') + '\n');
140
+ }
141
+ }
142
+
143
+ function withCrowdPlayWire(config) {
144
+ return withFinalizedMod(config, ['ios', async (config) => {
145
+ const { introspect, platformProjectRoot } = config.modRequest;
146
+ if (introspect) return config;
147
+ const projectName = resolveProjectName(config.modRequest);
148
+ const ruby = findRuby(platformProjectRoot);
149
+ if (!ruby) {
150
+ throw new Error(
151
+ 'crowdplaysdk: no Ruby with the xcodeproj gem was found, so the CrowdPlay ' +
152
+ 'engine could not be wired into the Xcode project. The gem ships with every ' +
153
+ 'CocoaPods install; locally run `gem install xcodeproj --user-install` and ' +
154
+ 'prebuild again. EAS Build images already have it.',
155
+ );
156
+ }
157
+ const wire = path.join(__dirname, 'ios', 'wire.rb');
158
+ const r = spawnSync(ruby.cmd, [...ruby.args, wire, projectName], {
159
+ cwd: platformProjectRoot, encoding: 'utf8',
160
+ });
161
+ if (r.status !== 0) {
162
+ throw new Error(
163
+ `crowdplaysdk: ios/wire.rb failed (exit ${r.status}) while wiring the CrowdPlay ` +
164
+ `engine into ${projectName}.xcodeproj.\n${r.stdout || ''}${r.stderr || ''}`,
165
+ );
166
+ }
167
+ for (const line of (r.stdout || '').split('\n')) {
168
+ if (line.startsWith('+')) console.log(`crowdplaysdk: ${line}`);
169
+ }
170
+ ensureBridgingHeaderImports(platformProjectRoot, projectName);
171
+ return config;
172
+ }]);
173
+ }
174
+
175
+ const withCrowdPlay = (config, props) => {
176
+ const p = props && typeof props === 'object' ? props : {};
177
+ if (typeof withFinalizedMod !== 'function') {
178
+ throw new Error('crowdplaysdk: the Expo config plugin needs Expo SDK 50 or newer.');
179
+ }
180
+ config = withCrowdPlayInfoPlist(config, p);
181
+ config = withCrowdPlayDeploymentTarget(config);
182
+ config = withCrowdPlayWire(config);
183
+ return config;
184
+ };
185
+
186
+ module.exports = createRunOncePlugin(withCrowdPlay, pkg.name, pkg.version);
package/ios/wire.rb CHANGED
@@ -1,12 +1,15 @@
1
- # Wires crowdplaysdk into an iOS app project. Run from your app's
2
- # ios/ directory after `pod install`:
1
+ # Wires crowdplaysdk into an iOS app project. Expo apps never run this by
2
+ # hand: the package's config plugin (app.plugin.js) runs it during
3
+ # `expo prebuild`. Bare React Native apps run it from ios/ after
4
+ # `pod install`:
3
5
  #
4
6
  # ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
5
7
  #
6
8
  # What it does (idempotent; safe to re-run):
7
9
  # 1. adds the CrowdPlaySDK Swift package (compiled engine) to the project
8
10
  # 2. copies the three bridge files into your app group + target
9
- # 3. creates/points the Swift bridging header at the React headers
11
+ # 3. creates/points the Swift bridging header at the React headers, raises
12
+ # the app target to iOS 17 and keeps explicit Swift modules ON for it
10
13
  #
11
14
  # Requires the `xcodeproj` gem (ships with CocoaPods installs; otherwise
12
15
  # `gem install xcodeproj`).
@@ -22,10 +25,12 @@ rescue LoadError
22
25
  end
23
26
 
24
27
  SDK_URL = 'https://github.com/symbiateam/crowdplaysdk'
25
- # The ENGINE release this bridge was tested against. The npm package
26
- # version may be AHEAD of this (bridge/docs fixes ship without a new
27
- # engine binary); that is deliberate, not drift.
28
- SDK_VERSION = '0.4.0'
28
+ # The ENGINE release this bridge compiles against. The npm version may run
29
+ # ahead of it only while the bridge uses no newer engine symbols; since
30
+ # D-103 the bridge calls 0.5.0 APIs, and npm 0.5.0 to 0.5.2 shipped with
31
+ # this still at 0.4.0, which broke every install (D-121). publish_rn.sh now
32
+ # compiles the bridge against exactly this release before publishing.
33
+ SDK_VERSION = '0.5.2'
29
34
  BRIDGE_FILES = ['CrowdPlayRNModule.swift', 'CrowdPlayRNVideoView.swift', 'CrowdPlayReactNative.m'].freeze
30
35
 
31
36
  project_name = ARGV[0] or abort 'usage: ruby wire.rb <YourProjectName>'
@@ -73,6 +78,16 @@ target.build_configurations.each do |config|
73
78
  if current.nil? || current.to_f < 17.0
74
79
  config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'
75
80
  end
81
+ # React Native's pod post-install (react_native_pods.rb, RN 0.8x with the
82
+ # prebuilt React Native core) sets SWIFT_ENABLE_EXPLICIT_MODULES = NO at the
83
+ # PROJECT level for Xcode 26. With implicit modules the app target never
84
+ # receives the module map for _AtomicsShims, a C module of swift-atomics
85
+ # that the compiled engine's Swift interface imports, and every Swift file
86
+ # in the app fails with "missing required module '_AtomicsShims'" (measured
87
+ # on Expo SDK 57 / RN 0.86 / Xcode 26.6, D-121). A TARGET-level YES wins
88
+ # over the project-level NO, survives pod install, and only touches the app
89
+ # target; the same build passes with it.
90
+ config.build_settings['SWIFT_ENABLE_EXPLICIT_MODULES'] = 'YES'
76
91
  next if config.build_settings['SWIFT_OBJC_BRIDGING_HEADER']
77
92
 
78
93
  header_name = "#{project_name}-Bridging-Header.h"
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Drop-in consent screen. Shows CrowdPlay's locked consent wording (fetched from
3
3
  * the native SDK so it can never drift from what the manifest records), a link
4
- * to the full CrowdPlay Terms of Use and Privacy Notice, a switch, and a
4
+ * to the full CrowdPlay Terms of Use and Privacy Policy, a switch, and a
5
5
  * continue button that only enables once the participant agrees. Yields the
6
6
  * ConsentGrant that join() requires.
7
7
  *
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Drop-in consent screen. Shows CrowdPlay's locked consent wording (fetched from
4
4
  * the native SDK so it can never drift from what the manifest records), a link
5
- * to the full CrowdPlay Terms of Use and Privacy Notice, a switch, and a
5
+ * to the full CrowdPlay Terms of Use and Privacy Policy, a switch, and a
6
6
  * continue button that only enables once the participant agrees. Yields the
7
7
  * ConsentGrant that join() requires.
8
8
  *
@@ -70,21 +70,17 @@ function CrowdPlayConsentScreen({ onConsent, style }) {
70
70
  };
71
71
  }, []);
72
72
  return (<react_native_1.View style={[styles.container, style]}>
73
- <react_native_1.Text style={styles.title}>Recording consent</react_native_1.Text>
74
- {text === null ? (<react_native_1.ActivityIndicator />) : (<react_native_1.Text style={styles.body}>{text}</react_native_1.Text>)}
73
+ <react_native_1.Text style={styles.title}>Before you join</react_native_1.Text>
75
74
  <react_native_1.Pressable accessibilityRole="link" onPress={() => void react_native_1.Linking.openURL(index_1.CROWDPLAY_TERMS_URL)}>
76
- <react_native_1.Text style={styles.link}>Read the Terms of Use and Privacy Notice</react_native_1.Text>
75
+ <react_native_1.Text style={styles.link}>Read the Terms of Use and Privacy Policy</react_native_1.Text>
77
76
  </react_native_1.Pressable>
78
77
  <react_native_1.View style={styles.row}>
79
78
  <react_native_1.Switch value={agreed} onValueChange={(value) => {
80
79
  setAgreed(value);
81
80
  setAgreedAtMs(value ? Date.now() : null);
82
81
  }}/>
83
- <react_native_1.Text style={styles.agree}>I have read and agree to the CrowdPlay Terms of Use and Privacy Notice.</react_native_1.Text>
82
+ {text === null ? <react_native_1.ActivityIndicator /> : <react_native_1.Text style={styles.agree}>{text}</react_native_1.Text>}
84
83
  </react_native_1.View>
85
- <react_native_1.Text style={styles.note}>
86
- Recording starts automatically when you join, and ends when you leave.
87
- </react_native_1.Text>
88
84
  <react_native_1.Pressable accessibilityRole="button" disabled={!agreed || agreedAtMs === null} onPress={() => {
89
85
  if (agreedAtMs !== null)
90
86
  onConsent({ grantedAtMs: agreedAtMs });
package/lib/VideoView.js CHANGED
@@ -18,7 +18,24 @@ exports.CrowdPlayVideoView = CrowdPlayVideoView;
18
18
  const react_1 = __importDefault(require("react"));
19
19
  const react_native_1 = require("react-native");
20
20
  const NativeCrowdPlayVideoView = (0, react_native_1.requireNativeComponent)('CrowdPlayVideoView');
21
+ /** The native module and the native view ship in the same bridge files, so
22
+ * a missing module means a build without the engine: Expo Go, or a preview
23
+ * that never ran the config plugin. Rendering the native view there throws
24
+ * at render time; a labelled placeholder keeps the rest of the screen
25
+ * usable and says what is going on. */
26
+ function nativeAvailable() {
27
+ return react_native_1.NativeModules.CrowdPlayReactNative != null;
28
+ }
21
29
  function CrowdPlayVideoView(props) {
22
30
  const { participant, mirror, ...rest } = props;
31
+ if (!nativeAvailable()) {
32
+ return (<react_native_1.View {...rest} style={[placeholder.box, rest.style]}>
33
+ <react_native_1.Text style={placeholder.label}>CrowdPlay video needs a device build</react_native_1.Text>
34
+ </react_native_1.View>);
35
+ }
23
36
  return (<NativeCrowdPlayVideoView participant={participant} mirror={mirror ?? participant === 'local'} {...rest}/>);
24
37
  }
38
+ const placeholder = react_native_1.StyleSheet.create({
39
+ box: { backgroundColor: '#222222', alignItems: 'center', justifyContent: 'center', padding: 8 },
40
+ label: { color: '#dddddd', fontSize: 12, textAlign: 'center' },
41
+ });
@@ -17,6 +17,16 @@ export interface VoiceActivity {
17
17
  /** 0…1, smoothed (fast attack, slow release). */
18
18
  energy: number;
19
19
  }
20
+ /** Raw payload of the 'voiceActivity' event (the native fast stream that
21
+ * runs only while useVoiceActivity() or CrowdPlayVoiceView is mounted). */
22
+ export interface VoiceActivityEvent {
23
+ agentPresent: boolean;
24
+ agentSpeaking: boolean;
25
+ agentLevel: number;
26
+ localLevelDbfs: number | null;
27
+ localSpeaking: boolean;
28
+ connected: boolean;
29
+ }
20
30
  /** Live phase + energy from the call engine. Subscribing starts the native
21
31
  * fast stream; the last unmount stops it. */
22
32
  export declare function useVoiceActivity(): VoiceActivity;
package/lib/index.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * native layer requires the consent record structurally; there is no code
21
21
  * path that records without it.
22
22
  */
23
+ import type { VoiceActivityEvent, VoiceCaption } from './VoiceView';
23
24
  /** Live-call (Path A) quality. The RECORDING is always 1080p30 + 24-bit WAV
24
25
  * regardless; raising these trades upload + thermal headroom (which the
25
26
  * recording needs on long sessions) for a prettier live picture. Values are
@@ -191,6 +192,13 @@ export interface CrowdPlayEvents {
191
192
  name: string;
192
193
  argumentsJSON: string;
193
194
  };
195
+ /** Voice-AI activity (agent present/speaking, local level). Flows ONLY
196
+ * while the native fast stream is running, which useVoiceActivity() and
197
+ * CrowdPlayVoiceView start on mount and stop on unmount; prefer those. */
198
+ voiceActivity: VoiceActivityEvent;
199
+ /** The latest live transcript line from the voice agent; see
200
+ * useLatestCaption(). */
201
+ caption: VoiceCaption;
194
202
  }
195
203
  export type CrowdPlayEventName = keyof CrowdPlayEvents;
196
204
  declare const CrowdPlay: {
@@ -262,4 +270,4 @@ export default CrowdPlay;
262
270
  export { CrowdPlayConsentScreen } from './ConsentScreen';
263
271
  export { CrowdPlayVideoView } from './VideoView';
264
272
  export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
265
- export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase, VoiceCaption } from './VoiceView';
273
+ export type { CrowdPlayVoiceViewProps, VoiceActivity, VoiceActivityEvent, VoicePhase, VoiceCaption } from './VoiceView';
package/lib/index.js CHANGED
@@ -30,10 +30,11 @@ exports.CROWDPLAY_TERMS_URL = 'https://dashboard.crowdplay.ai/legal/terms.pdf';
30
30
  function native() {
31
31
  const module = react_native_1.NativeModules.CrowdPlayReactNative;
32
32
  if (!module) {
33
- throw new Error("crowdplaysdk: native module not found. iOS setup: add the CrowdPlaySDK " +
34
- "Swift package (https://github.com/symbiateam/crowdplaysdk) to your Xcode " +
35
- "project and add the two bridge files from node_modules/crowdplaysdk/ios " +
36
- "to your app target; see the package README. " +
33
+ throw new Error("crowdplaysdk: native module not found. Expo: add \"crowdplaysdk\" to " +
34
+ "\"plugins\" in app.json and rebuild (npx expo prebuild, a development " +
35
+ "build, or eas build); Expo Go cannot load native modules. Bare React " +
36
+ "Native: run ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName> " +
37
+ "from ios/ after pod install; see the package README. " +
37
38
  (react_native_1.Platform.OS !== 'ios' ? `(platform '${react_native_1.Platform.OS}' is not supported yet; iOS only.)` : ''));
38
39
  }
39
40
  return module;
package/llms.txt CHANGED
@@ -50,8 +50,8 @@ its video calls invisibly; recording is infrastructure, not identity:
50
50
 
51
51
  1. NEVER add, pin, import, or update LiveKit, WebRTC, or any
52
52
  react-native-webrtc package. The correct engine ships as a compiled
53
- binary that ios/wire.rb installs. Adding your own causes duplicate
54
- symbols or silently breaks capture.
53
+ binary that the Expo config plugin (or ios/wire.rb) installs. Adding
54
+ your own causes duplicate symbols or silently breaks capture.
55
55
  2. NEVER write AVAudioSession / AVAudioEngine / AVCaptureSession code (or
56
56
  add audio-session-touching RN packages like react-native-incall-manager)
57
57
  in an app using this SDK. The engine owns the microphone and camera.
@@ -67,6 +67,27 @@ its video calls invisibly; recording is infrastructure, not identity:
67
67
 
68
68
  ## Integration procedure
69
69
 
70
+ Steps 1 to 4 depend on the project type. Everything from step 5 on is the
71
+ same for both.
72
+
73
+ EXPO (managed, prebuild or EAS Build; app builders such as X1):
74
+ 1. npm install crowdplaysdk
75
+ 2. app.json: add "crowdplaysdk" to the "plugins" array, or
76
+ ["crowdplaysdk", { "audioOnly": true }] for an audio-only app.
77
+ That is the WHOLE native setup: at prebuild the plugin adds the
78
+ microphone and camera usage strings and the audio background mode to
79
+ Info.plist (only where the app has not set them), raises the iOS
80
+ deployment target to 17.0, and wires the compiled engine into the
81
+ Xcode project. Do not add those Info.plist keys or any native code
82
+ yourself. Expo SDK 50 or newer.
83
+ 3. Rebuild: npx expo prebuild, a development build, or eas build.
84
+ 4. Expo Go and any preview that never ran the plugin cannot load native
85
+ modules: there the consent text, the call and recording fail with a
86
+ clear error and the video view shows a placeholder. That is EXPECTED;
87
+ do not debug it and never remove the package or the plugin to make a
88
+ preview pass. Everything else works in preview.
89
+
90
+ BARE REACT NATIVE (no Expo):
70
91
  1. npm install crowdplaysdk
71
92
  2. cd ios && pod install (installs RN's own pods; CrowdPlay has none)
72
93
  3. gem install xcodeproj --user-install (one-time; wire.rb needs it
@@ -74,9 +95,12 @@ its video calls invisibly; recording is infrastructure, not identity:
74
95
  ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
75
96
  Idempotent. It: adds the CrowdPlaySDK binary Swift package to the Xcode
76
97
  project, copies the three native bridge files into the app target,
77
- creates/points the Swift bridging header at the React headers, and
78
- raises the iOS deployment target to 17.0 if the RN template left it
79
- lower. Rerun it any time; it never duplicates.
98
+ creates/points the Swift bridging header at the React headers, raises
99
+ the iOS deployment target to 17.0 if the RN template left it lower, and
100
+ sets SWIFT_ENABLE_EXPLICIT_MODULES = YES on the app target (React Native
101
+ 0.8x sets it to NO project-wide on Xcode 26, and with implicit modules
102
+ the compiled engine's interface cannot be imported). Rerun it any time;
103
+ it never duplicates. Never undo that setting to fix an unrelated error.
80
104
  4. Info.plist: NSMicrophoneUsageDescription, NSCameraUsageDescription,
81
105
  UIBackgroundModes = [audio]. A missing usage string makes iOS kill
82
106
  the app at first mic/camera access (uncatchable).
@@ -117,6 +141,11 @@ its video calls invisibly; recording is infrastructure, not identity:
117
141
  detail names its own fix. NOTE: on a fresh install the "permissions"
118
142
  check reports "not requested yet (NORMAL)"; iOS prompts at first join.
119
143
  That is not an integration bug; only a DENIED state needs action.
144
+ KNOWN ENGINE BUG: on engine 0.5.1 and 0.5.2 the "consent_terms" check
145
+ fails on every device (the engine's own hash bookkeeping, fixed in
146
+ engine 0.5.3 / crowdplaysdk 0.6.1). Nothing in your app causes it and
147
+ nothing in your app fixes it; the consent screen and the recorded
148
+ consent are correct. Every other check in that run is valid.
120
149
  11. Hand off (REQUIRED final message): the Simulator has no camera or
121
150
  mic, so nothing is proven until the app runs on a real iPhone. Do
122
151
  not end with "the implementation is complete"; your FINAL message
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "crowdplaysdk",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "CrowdPlay lossless conversation capture for React Native (iOS). Studio-grade per-participant recording during live video calls, delivered to CrowdPlay automatically.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "files": [
8
+ "app.plugin.js",
8
9
  "lib",
9
10
  "src",
10
11
  "ios",
@@ -29,12 +30,18 @@
29
30
  "recording",
30
31
  "video-call",
31
32
  "lossless",
32
- "crowdplay"
33
+ "crowdplay",
34
+ "expo"
33
35
  ],
34
36
  "peerDependencies": {
35
37
  "react": "*",
36
38
  "react-native": "*"
37
39
  },
40
+ "peerDependenciesMeta": {
41
+ "expo": {
42
+ "optional": true
43
+ }
44
+ },
38
45
  "devDependencies": {
39
46
  "@types/react": "^18.2.0",
40
47
  "react": "18.2.0",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Drop-in consent screen. Shows CrowdPlay's locked consent wording (fetched from
3
3
  * the native SDK so it can never drift from what the manifest records), a link
4
- * to the full CrowdPlay Terms of Use and Privacy Notice, a switch, and a
4
+ * to the full CrowdPlay Terms of Use and Privacy Policy, a switch, and a
5
5
  * continue button that only enables once the participant agrees. Yields the
6
6
  * ConsentGrant that join() requires.
7
7
  *
@@ -43,14 +43,9 @@ export function CrowdPlayConsentScreen({ onConsent, style }: CrowdPlayConsentScr
43
43
 
44
44
  return (
45
45
  <View style={[styles.container, style]}>
46
- <Text style={styles.title}>Recording consent</Text>
47
- {text === null ? (
48
- <ActivityIndicator />
49
- ) : (
50
- <Text style={styles.body}>{text}</Text>
51
- )}
46
+ <Text style={styles.title}>Before you join</Text>
52
47
  <Pressable accessibilityRole="link" onPress={() => void Linking.openURL(CROWDPLAY_TERMS_URL)}>
53
- <Text style={styles.link}>Read the Terms of Use and Privacy Notice</Text>
48
+ <Text style={styles.link}>Read the Terms of Use and Privacy Policy</Text>
54
49
  </Pressable>
55
50
  <View style={styles.row}>
56
51
  <Switch
@@ -60,11 +55,8 @@ export function CrowdPlayConsentScreen({ onConsent, style }: CrowdPlayConsentScr
60
55
  setAgreedAtMs(value ? Date.now() : null);
61
56
  }}
62
57
  />
63
- <Text style={styles.agree}>I have read and agree to the CrowdPlay Terms of Use and Privacy Notice.</Text>
58
+ {text === null ? <ActivityIndicator /> : <Text style={styles.agree}>{text}</Text>}
64
59
  </View>
65
- <Text style={styles.note}>
66
- Recording starts automatically when you join, and ends when you leave.
67
- </Text>
68
60
  <Pressable
69
61
  accessibilityRole="button"
70
62
  disabled={!agreed || agreedAtMs === null}
package/src/VideoView.tsx CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React from 'react';
14
- import { requireNativeComponent, type ViewProps } from 'react-native';
14
+ import { NativeModules, requireNativeComponent, StyleSheet, Text, View, type ViewProps } from 'react-native';
15
15
 
16
16
  export interface CrowdPlayVideoViewProps extends ViewProps {
17
17
  /** "local" for the self-view, or a remote participant's identity. */
@@ -23,8 +23,24 @@ export interface CrowdPlayVideoViewProps extends ViewProps {
23
23
 
24
24
  const NativeCrowdPlayVideoView = requireNativeComponent<CrowdPlayVideoViewProps>('CrowdPlayVideoView');
25
25
 
26
+ /** The native module and the native view ship in the same bridge files, so
27
+ * a missing module means a build without the engine: Expo Go, or a preview
28
+ * that never ran the config plugin. Rendering the native view there throws
29
+ * at render time; a labelled placeholder keeps the rest of the screen
30
+ * usable and says what is going on. */
31
+ function nativeAvailable(): boolean {
32
+ return NativeModules.CrowdPlayReactNative != null;
33
+ }
34
+
26
35
  export function CrowdPlayVideoView(props: CrowdPlayVideoViewProps): React.JSX.Element {
27
36
  const { participant, mirror, ...rest } = props;
37
+ if (!nativeAvailable()) {
38
+ return (
39
+ <View {...rest} style={[placeholder.box, rest.style]}>
40
+ <Text style={placeholder.label}>CrowdPlay video needs a device build</Text>
41
+ </View>
42
+ );
43
+ }
28
44
  return (
29
45
  <NativeCrowdPlayVideoView
30
46
  participant={participant}
@@ -33,3 +49,8 @@ export function CrowdPlayVideoView(props: CrowdPlayVideoViewProps): React.JSX.El
33
49
  />
34
50
  );
35
51
  }
52
+
53
+ const placeholder = StyleSheet.create({
54
+ box: { backgroundColor: '#222222', alignItems: 'center', justifyContent: 'center', padding: 8 },
55
+ label: { color: '#dddddd', fontSize: 12, textAlign: 'center' },
56
+ });
package/src/VoiceView.tsx CHANGED
@@ -22,7 +22,9 @@ export interface VoiceActivity {
22
22
  energy: number;
23
23
  }
24
24
 
25
- interface VoiceActivityEvent {
25
+ /** Raw payload of the 'voiceActivity' event (the native fast stream that
26
+ * runs only while useVoiceActivity() or CrowdPlayVoiceView is mounted). */
27
+ export interface VoiceActivityEvent {
26
28
  agentPresent: boolean;
27
29
  agentSpeaking: boolean;
28
30
  agentLevel: number;
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
25
+ import type { VoiceActivityEvent, VoiceCaption } from './VoiceView';
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Types
@@ -186,6 +187,13 @@ export interface CrowdPlayEvents {
186
187
  /** The voice AI asked the app to run a tool (D-103). Prefer
187
188
  * CrowdPlay.onAgentToolCall(), which answers for you. */
188
189
  agentToolCall: { callId: string; name: string; argumentsJSON: string };
190
+ /** Voice-AI activity (agent present/speaking, local level). Flows ONLY
191
+ * while the native fast stream is running, which useVoiceActivity() and
192
+ * CrowdPlayVoiceView start on mount and stop on unmount; prefer those. */
193
+ voiceActivity: VoiceActivityEvent;
194
+ /** The latest live transcript line from the voice agent; see
195
+ * useLatestCaption(). */
196
+ caption: VoiceCaption;
189
197
  }
190
198
 
191
199
  export type CrowdPlayEventName = keyof CrowdPlayEvents;
@@ -210,6 +218,9 @@ interface NativeCrowdPlay {
210
218
  setAudioOutput(output: string): void;
211
219
  snapshot(): Promise<RecordingSnapshot | null>;
212
220
  doctor(): Promise<DoctorCheck[]>;
221
+ /** Start/stop the 'voiceActivity' fast stream (useVoiceActivity owns this). */
222
+ startVoiceActivityUpdates(): void;
223
+ stopVoiceActivityUpdates(): void;
213
224
  addListener(eventName: string): void;
214
225
  removeListeners(count: number): void;
215
226
  }
@@ -218,10 +229,11 @@ function native(): NativeCrowdPlay {
218
229
  const module = NativeModules.CrowdPlayReactNative as NativeCrowdPlay | undefined;
219
230
  if (!module) {
220
231
  throw new Error(
221
- "crowdplaysdk: native module not found. iOS setup: add the CrowdPlaySDK " +
222
- "Swift package (https://github.com/symbiateam/crowdplaysdk) to your Xcode " +
223
- "project and add the two bridge files from node_modules/crowdplaysdk/ios " +
224
- "to your app target; see the package README. " +
232
+ "crowdplaysdk: native module not found. Expo: add \"crowdplaysdk\" to " +
233
+ "\"plugins\" in app.json and rebuild (npx expo prebuild, a development " +
234
+ "build, or eas build); Expo Go cannot load native modules. Bare React " +
235
+ "Native: run ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName> " +
236
+ "from ios/ after pod install; see the package README. " +
225
237
  (Platform.OS !== 'ios' ? `(platform '${Platform.OS}' is not supported yet; iOS only.)` : '')
226
238
  );
227
239
  }
@@ -408,4 +420,4 @@ export default CrowdPlay;
408
420
  export { CrowdPlayConsentScreen } from './ConsentScreen';
409
421
  export { CrowdPlayVideoView } from './VideoView';
410
422
  export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
411
- export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase, VoiceCaption } from './VoiceView';
423
+ export type { CrowdPlayVoiceViewProps, VoiceActivity, VoiceActivityEvent, VoicePhase, VoiceCaption } from './VoiceView';