crowdplaysdk 0.2.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.
@@ -0,0 +1,164 @@
1
+ // Complete crowdplaysdk reference integration in one file:
2
+ // consent -> join -> video tiles -> live warnings -> upload progress.
3
+ //
4
+ // Use: create a React Native app, install crowdplaysdk (README steps
5
+ // 1-5), replace App.tsx with this file, paste your app key below.
6
+
7
+ import React, { useEffect, useRef, useState } from 'react';
8
+ import {
9
+ Button, FlatList, SafeAreaView, StyleSheet, Text, TextInput, View,
10
+ } from 'react-native';
11
+ import CrowdPlay, { CrowdPlayConsentScreen, CrowdPlayVideoView } from 'crowdplaysdk';
12
+ import type {
13
+ ConsentGrant, Participant, Phase, RecordingSnapshot, UploadProgress,
14
+ } from 'crowdplaysdk';
15
+
16
+ type AudioRoute = { currentOutputName: string; detectedOutputs: string[]; headphonesConnected: boolean };
17
+
18
+ CrowdPlay.configure({
19
+ serverUrl: 'https://dashboard.crowdplay.ai',
20
+ appKey: 'PASTE_YOUR_APP_KEY', // from https://dashboard.crowdplay.ai
21
+ });
22
+
23
+ export default function App(): React.JSX.Element {
24
+ const [consent, setConsent] = useState<ConsentGrant | null>(null);
25
+ const [phase, setPhase] = useState<Phase>('idle');
26
+ const [phaseError, setPhaseError] = useState<string | undefined>();
27
+ const [recordingError, setRecordingError] = useState<string | null>(null);
28
+ const [participants, setParticipants] = useState<Participant[]>([]);
29
+ const [warnings, setWarnings] = useState<Record<string, string>>({});
30
+ const [uploads, setUploads] = useState<UploadProgress[]>([]);
31
+ const [onWifi, setOnWifi] = useState(true);
32
+ const [snapshot, setSnapshot] = useState<RecordingSnapshot | null>(null);
33
+ const [route, setRoute] = useState<AudioRoute | null>(null);
34
+ const [onSpeaker, setOnSpeaker] = useState(false);
35
+ const [name, setName] = useState('');
36
+ const [room, setRoom] = useState('');
37
+ const nameRef = useRef('');
38
+ nameRef.current = name;
39
+
40
+ useEffect(() => {
41
+ const subs = [
42
+ CrowdPlay.addListener('phase', (p) => { setPhase(p.phase); setPhaseError(p.error); }),
43
+ CrowdPlay.addListener('recording', (r) => setRecordingError(r.error ?? null)),
44
+ CrowdPlay.addListener('participants', (p) => setParticipants(p.participants)),
45
+ CrowdPlay.addListener('uploads', (u) => { setUploads(u.sessions); setOnWifi(u.onWifi); }),
46
+ CrowdPlay.addListener('audioRoute', (r) => setRoute(r)),
47
+ CrowdPlay.addListener('warning', (w) =>
48
+ setWarnings((prev) => {
49
+ const next = { ...prev };
50
+ if (w.active) next[w.kind] = w.message; else delete next[w.kind];
51
+ return next;
52
+ })),
53
+ ];
54
+ const poll = setInterval(async () => setSnapshot(await CrowdPlay.snapshot()), 1000);
55
+ return () => { subs.forEach((s) => s.remove()); clearInterval(poll); };
56
+ }, []);
57
+
58
+ if (!consent) {
59
+ // REQUIRED before joining: join() throws without a ConsentGrant.
60
+ return <CrowdPlayConsentScreen onConsent={setConsent} />;
61
+ }
62
+
63
+ if (phase !== 'connected') {
64
+ return (
65
+ <SafeAreaView style={styles.screen}>
66
+ <Text style={styles.title}>Join a session</Text>
67
+ <TextInput style={styles.input} placeholder="Your name"
68
+ value={name} onChangeText={setName} autoCapitalize="none" />
69
+ <TextInput style={styles.input} placeholder="Room code"
70
+ value={room} onChangeText={setRoom} autoCapitalize="none"
71
+ onFocus={() => CrowdPlay.warmUp()} />
72
+ <Button
73
+ title={phase === 'connecting' ? 'Connecting…' : 'Join'}
74
+ disabled={phase === 'connecting' || !name || !room}
75
+ onPress={() => CrowdPlay.join({ displayName: name, roomCode: room, consent })
76
+ .catch((e) => setPhaseError(String(e)))} />
77
+ {phaseError ? <Text style={styles.error}>{phaseError}</Text> : null}
78
+ {uploads.map((u) => (
79
+ <Text key={u.sessionId} style={styles.upload}>
80
+ {u.sessionId}: {(u.fraction * 100).toFixed(0)}%
81
+ {u.isComplete ? ' done' : onWifi ? '' : ' (video waits for WiFi)'}
82
+ {u.failed > 0 ? ' [retry available]' : ''}
83
+ </Text>
84
+ ))}
85
+ {uploads.some((u) => u.failed > 0) ? (
86
+ <Button title="Retry uploads" onPress={() => CrowdPlay.retryUploads()} />
87
+ ) : null}
88
+ </SafeAreaView>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <SafeAreaView style={styles.screen}>
94
+ {/* The session is NOT being recorded when this is non-null — always show it. */}
95
+ {recordingError ? (
96
+ <View style={styles.banner}>
97
+ <Text style={styles.bannerText}>NOT RECORDING: {recordingError}</Text>
98
+ <Button title="Retry recording" onPress={() => CrowdPlay.retryRecording()} />
99
+ </View>
100
+ ) : (
101
+ <Text style={styles.rec}>
102
+ ● REC {snapshot ? `${Math.floor(snapshot.seconds / 60)}:${String(Math.floor(snapshot.seconds % 60)).padStart(2, '0')}` : ''}
103
+ {snapshot ? ` mic ${snapshot.inputLevelDbfs.toFixed(0)} dBFS` : ''}
104
+ </Text>
105
+ )}
106
+ {Object.entries(warnings).map(([kind, message]) => (
107
+ <Text key={kind} style={styles.warning}>{message}</Text>
108
+ ))}
109
+
110
+ <View style={styles.tiles}>
111
+ <CrowdPlayVideoView participant="local" style={styles.selfTile} />
112
+ <FlatList
113
+ data={participants}
114
+ keyExtractor={(p) => p.identity}
115
+ renderItem={({ item }) => (
116
+ <View style={styles.tile}>
117
+ <CrowdPlayVideoView participant={item.identity} style={styles.remoteVideo} />
118
+ <Text style={styles.tileName}>{item.name || item.identity}</Text>
119
+ </View>
120
+ )}
121
+ />
122
+ </View>
123
+
124
+ {/* Audio output menu (D-079): standard in every build — shows the
125
+ device by name and lets the user flip to Speaker and back. */}
126
+ <View style={styles.controls}>
127
+ <Text>{route?.currentOutputName ?? 'audio output'}</Text>
128
+ <Button
129
+ title={onSpeaker ? 'Use device' : 'Use speaker'}
130
+ onPress={() => {
131
+ CrowdPlay.setAudioOutput(onSpeaker ? 'automatic' : 'speaker');
132
+ setOnSpeaker(!onSpeaker);
133
+ }} />
134
+ </View>
135
+ <View style={styles.controls}>
136
+ <Button title="Mute" onPress={() => CrowdPlay.setMicMuted(true)} />
137
+ <Button title="Unmute" onPress={() => CrowdPlay.setMicMuted(false)} />
138
+ <Button title="Leave" onPress={() => CrowdPlay.leave()} />
139
+ </View>
140
+ <Text style={styles.hint}>
141
+ Keep the app open after leaving until uploads reach 100%.
142
+ </Text>
143
+ </SafeAreaView>
144
+ );
145
+ }
146
+
147
+ const styles = StyleSheet.create({
148
+ screen: { flex: 1, padding: 16, gap: 8 },
149
+ title: { fontSize: 22, fontWeight: '600' },
150
+ input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 10 },
151
+ error: { color: '#c00' },
152
+ upload: { fontVariant: ['tabular-nums'] },
153
+ banner: { backgroundColor: '#c00', padding: 10, borderRadius: 8 },
154
+ bannerText: { color: '#fff', fontWeight: '700' },
155
+ rec: { color: '#c00', fontWeight: '700' },
156
+ warning: { backgroundColor: '#fa0', padding: 6, borderRadius: 6 },
157
+ tiles: { flex: 1 },
158
+ selfTile: { width: 110, height: 150, alignSelf: 'flex-end' },
159
+ tile: { marginVertical: 6 },
160
+ remoteVideo: { width: '100%', aspectRatio: 3 / 4 },
161
+ tileName: { textAlign: 'center' },
162
+ controls: { flexDirection: 'row', justifyContent: 'space-around' },
163
+ hint: { textAlign: 'center', color: '#888', fontSize: 12 },
164
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "paths": { "crowdplaysdk": ["../src/index"] },
6
+ "baseUrl": "."
7
+ },
8
+ "include": ["App.tsx"]
9
+ }
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # crowdplaysdk
2
+
3
+ CrowdPlay's lossless conversation capture for **React Native (iOS)**. Your app
4
+ hosts a live video call; every participant's audio (**48 kHz / 24-bit
5
+ unprocessed WAV**) and video (**1080p30 HEVC**) is recorded **locally, at
6
+ full quality**, clock-aligned across participants, and delivered to CrowdPlay
7
+ automatically — uploads retry, resume, and survive crashes.
8
+
9
+ **No media ever crosses the JS bridge.** The engine is the same compiled
10
+ native core (CrowdPlaySDK) our reference app runs; React Native drives it with
11
+ control calls and receives state events. Recording quality is identical to
12
+ a fully native app.
13
+
14
+ ## Non-technical? Paste this to your AI agent
15
+
16
+ ```
17
+ I'm building this app: <YOUR APP IDEA — e.g., a tutoring app where
18
+ students and tutors meet over video>
19
+
20
+ Build it as a React Native iOS app. For the video-call feature, use the
21
+ crowdplaysdk package.
22
+
23
+ What the app is about — read carefully:
24
+ - The app's name, screens, and flows are about MY IDEA above. The SDK
25
+ records calls in the background for CrowdPlay; recording is NOT the
26
+ app's purpose and must not be presented as it. It appears in the UI in
27
+ the SDK's consent screen before joining a call,
28
+ a small REC indicator during the call, join/leave, and a small
29
+ audio-output menu.
30
+ - Keep the SDK integration minimal: consent → join/leave → call screen.
31
+ The call screen MUST include a small audio-output menu showing the
32
+ current output device by name (from the 'audioRoute' event) with a
33
+ switch between the device and Speaker (CrowdPlay.setAudioOutput). Do
34
+ NOT build upload screens, diagnostics screens, or SDK settings.
35
+ Uploads happen automatically in the background.
36
+
37
+ - npm install crowdplaysdk
38
+ - In ios/: pod install, then run
39
+ ruby node_modules/crowdplaysdk/ios/wire.rb <MyProjectName>
40
+ (adds the compiled CrowdPlay engine + bridge to the Xcode project)
41
+ - Info.plist: NSMicrophoneUsageDescription, NSCameraUsageDescription,
42
+ UIBackgroundModes = [audio]
43
+ - My CrowdPlay app key: <APP KEY from the CrowdPlay dashboard>
44
+ - Follow node_modules/crowdplaysdk/llms.txt exactly: configure at
45
+ startup, the CrowdPlayConsentScreen before joining (join() throws
46
+ without a ConsentGrant), CrowdPlayVideoView tiles from the participants
47
+ event, and surface the warning events on the call screen.
48
+ - Never add, pin or import LiveKit/WebRTC yourself, and never write your
49
+ own AVAudioSession/AVCaptureSession code — the engine owns the mic and
50
+ camera.
51
+ - When done, run CrowdPlay.doctor() as a development check (not an app
52
+ screen) and fix anything actionable.
53
+ - Your FINAL message must walk me through testing on my real iPhone,
54
+ step by step (the Simulator has no camera or mic, so nothing is proven
55
+ until then): plugging it in, signing/Developer Mode, running from
56
+ Xcode, allowing permissions, doing a ~1-minute test call, and checking
57
+ my session at https://dashboard.crowdplay.ai. Do not just say the
58
+ implementation is complete.
59
+ ```
60
+
61
+ ## Manual setup (iOS)
62
+
63
+ 1. `npm install crowdplaysdk`
64
+ 2. `cd ios && pod install`
65
+ 3. `ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>` —
66
+ idempotent; adds the CrowdPlaySDK Swift package (the compiled engine), copies
67
+ the three bridge files into your app target, and points the Swift
68
+ bridging header at the React headers. (No CocoaPod of our own: the
69
+ engine ships as a binary Swift package, which keeps your Podfile
70
+ untouched.)
71
+ 4. Info.plist: `NSMicrophoneUsageDescription`, `NSCameraUsageDescription`,
72
+ `UIBackgroundModes = [audio]`.
73
+ 5. Recommended, for uploads that finish while your app is suspended — in
74
+ your AppDelegate:
75
+
76
+ ```objc
77
+ // AppDelegate.mm — add #import "<YourProjectName>-Swift.h" at the top
78
+ - (void)application:(UIApplication *)application
79
+ handleEventsForBackgroundURLSession:(NSString *)identifier
80
+ completionHandler:(void (^)(void))completionHandler {
81
+ [CrowdPlayRNBackground handleWithCompletionHandler:completionHandler];
82
+ }
83
+ ```
84
+
85
+ (Swift AppDelegates call `CrowdPlaySDK.handleBackgroundURLSessionEvents(completionHandler:)`.)
86
+
87
+ ## Usage
88
+
89
+ ```tsx
90
+ import CrowdPlay, { CrowdPlayConsentScreen, CrowdPlayVideoView } from 'crowdplaysdk';
91
+
92
+ CrowdPlay.configure({ serverUrl: 'https://…', appKey: 'liva_pk_…' });
93
+
94
+ // 1. Consent (REQUIRED — join() throws without it):
95
+ <CrowdPlayConsentScreen onConsent={(grant) => setConsent(grant)} />
96
+
97
+ // 2. Join — recording starts automatically:
98
+ await CrowdPlay.join({ displayName, roomCode, consent });
99
+
100
+ // 3. Render the call:
101
+ <CrowdPlayVideoView participant="local" style={…} /> // self view
102
+ {participants.map(p => <CrowdPlayVideoView participant={p.identity} … />)}
103
+
104
+ // 4. Controls:
105
+ await CrowdPlay.setMicMuted(true); // mutes the call AND records silence
106
+ await CrowdPlay.setCameraEnabled(false); // black frames, timeline continuous
107
+ await CrowdPlay.leave(); // stops recording; uploads continue
108
+
109
+ // State: subscribe to events
110
+ CrowdPlay.addListener('phase', …) // idle/connecting/connected/failed
111
+ CrowdPlay.addListener('participants', …) // who to render tiles for
112
+ CrowdPlay.addListener('uploads', …) // per-session progress
113
+ CrowdPlay.addListener('warning', …) // micSilent / clipping / crossTalk — SHOW THESE
114
+ CrowdPlay.addListener('recording', …) // error ⇒ NOT capturing; offer retryRecording()
115
+ CrowdPlay.addListener('audioRoute', …) // current output device — drive the output menu
116
+ CrowdPlay.setAudioOutput('speaker') // or 'automatic' (the connected device)
117
+ CrowdPlay.snapshot() // poll 1 Hz for REC timer + input level
118
+ CrowdPlay.doctor() // integration self-check
119
+ ```
120
+
121
+ The complete example lives in this repo's `Examples` (one file, ~170
122
+ lines): consent → join → tiles → warnings → uploads.
123
+
124
+ ## Accessing recordings
125
+
126
+ Your app key doubles as your data credential — see the CrowdPlay dashboard
127
+ (browse + download per session) or the REST endpoints (`GET /sessions`,
128
+ `GET /sessions/<id>/files`) documented in the CrowdPlaySDK README.
129
+
130
+ ## Requirements & limits
131
+
132
+ - iOS 17+, React Native ≥ 0.71. **iOS only for now** — no Android engine
133
+ yet, calls to the module on other platforms throw with a clear message.
134
+ - Real device required to record (the Simulator has no camera/mic).
135
+ - Keep the screen awake during sessions; force-quitting the app pauses
136
+ uploads until next launch (they resume automatically).
137
+ - **Slow join + glitchy first seconds = bad radio, not a bug.** Congested
138
+ 2.4 GHz WiFi plus Bluetooth headphones (they share the antenna) slows the
139
+ call connect and can chop the first seconds of audio while the Bluetooth
140
+ link settles; capture self-heals and the rest of the session is
141
+ unaffected. Prefer a good network and wired headphones, and evaluate
142
+ quality with the app launched from the home screen — Metro/debugger
143
+ overhead can reproduce the same symptoms.
@@ -0,0 +1,324 @@
1
+ import Combine
2
+ import Foundation
3
+ import CrowdPlaySDK
4
+ #if canImport(React)
5
+ import React
6
+ #endif
7
+
8
+ /// Shared native state for the RN bridge: ONE CallEngine, plus the Combine
9
+ /// bindings and polling that translate its published state into RN events.
10
+ /// All UI-facing state in CrowdPlaySDK is MainActor, so everything here is too;
11
+ /// bridge methods hop onto it with Task { @MainActor }.
12
+ @MainActor
13
+ final class CrowdPlayRNCore {
14
+ static let shared = CrowdPlayRNCore()
15
+
16
+ lazy var engine = CallEngine()
17
+ /// Remembered so retryRecording() can restart with the same identity.
18
+ var displayName = ""
19
+
20
+ private weak var emitter: CrowdPlayRNModule?
21
+ private var cancellables: Set<AnyCancellable> = []
22
+ private var pollTimer: Timer?
23
+ private var lastParticipantsKey = ""
24
+ private var lastAudioRouteKey = ""
25
+ private var activeWarnings: Set<String> = []
26
+
27
+ func bind(_ module: CrowdPlayRNModule) {
28
+ emitter = module
29
+ guard cancellables.isEmpty else { return } // bind once; emitter may rebind
30
+
31
+ engine.$phase.sink { [weak self] phase in
32
+ var body: [String: Any] = ["phase": Self.name(of: phase)]
33
+ if case let .failed(message) = phase { body["error"] = message }
34
+ self?.send("crowdplay:phase", body)
35
+ }.store(in: &cancellables)
36
+
37
+ engine.$isRecording
38
+ .combineLatest(engine.$recordingStartError)
39
+ .sink { [weak self] isRecording, error in
40
+ var body: [String: Any] = ["isRecording": isRecording]
41
+ if let error { body["error"] = error }
42
+ self?.send("crowdplay:recording", body)
43
+ }.store(in: &cancellables)
44
+
45
+ engine.$uploads
46
+ .combineLatest(engine.$uploadsOnWiFi)
47
+ .sink { [weak self] uploads, onWifi in
48
+ self?.send("crowdplay:uploads", [
49
+ "onWifi": onWifi,
50
+ "sessions": uploads.map(Self.uploadDict),
51
+ ])
52
+ }.store(in: &cancellables)
53
+
54
+ // Participants + live capture warnings on a 1 s tick. Polling is
55
+ // deliberate: it is idempotent, survives reconnects, and cannot
56
+ // miss a transition the way delegate wiring can.
57
+ pollTimer?.invalidate()
58
+ pollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
59
+ Task { @MainActor in CrowdPlayRNCore.shared.pollTick() }
60
+ }
61
+ }
62
+
63
+ func unbind() {
64
+ emitter = nil
65
+ }
66
+
67
+ private func pollTick() {
68
+ guard emitter != nil else { return }
69
+
70
+ let participants = engine.room.remoteParticipants.values
71
+ .map { participant -> [String: Any] in
72
+ let identity = participant.identity?.stringValue ?? ""
73
+ return ["identity": identity,
74
+ "name": participant.name?.isEmpty == false ? participant.name! : identity]
75
+ }
76
+ .sorted { ($0["identity"] as? String ?? "") < ($1["identity"] as? String ?? "") }
77
+ let key = participants.map { $0["identity"] as? String ?? "" }.joined(separator: ",")
78
+ if key != lastParticipantsKey {
79
+ lastParticipantsKey = key
80
+ send("crowdplay:participants", ["participants": participants])
81
+ }
82
+
83
+ // Audio route (D-079): the current output device by NAME plus the
84
+ // connected candidates, so every app can show a LivaCapture-style
85
+ // output menu. Emitted only on change.
86
+ let routeKey = "\(engine.currentOutputName)|\(engine.detectedOutputs.joined(separator: ","))|\(engine.headphonesConnected)"
87
+ if routeKey != lastAudioRouteKey {
88
+ lastAudioRouteKey = routeKey
89
+ send("crowdplay:audioRoute", [
90
+ "currentOutputName": engine.currentOutputName,
91
+ "detectedOutputs": engine.detectedOutputs,
92
+ "headphonesConnected": engine.headphonesConnected,
93
+ ])
94
+ }
95
+
96
+ let snapshot = engine.recordingSnapshot()
97
+ updateWarning("audioStalled",
98
+ active: snapshot?.audioStalled == true,
99
+ message: "Audio capture stalled — the engine is being restarted. "
100
+ + "If this persists, leave and rejoin.")
101
+ updateWarning("micSilent",
102
+ active: snapshot?.inputSilent == true && !engine.isMicMuted,
103
+ message: "No sound is reaching the microphone — check the headset "
104
+ + "plug or switch to the phone microphone.")
105
+ updateWarning("clipping",
106
+ active: (snapshot?.fullScaleSamples ?? 0) > 100,
107
+ message: "The microphone is clipping — audio is distorting. Move "
108
+ + "the mic away or lower the input.")
109
+ updateWarning("crossTalk",
110
+ active: engine.crossTalkRisk,
111
+ message: "Playing through the loudspeaker — the other person's "
112
+ + "voice is being recorded into this microphone. Use headphones.")
113
+ updateWarning("micPolicy",
114
+ active: engine.micPolicyViolation != nil,
115
+ message: engine.micPolicyViolation ?? "")
116
+ }
117
+
118
+ private func updateWarning(_ kind: String, active: Bool, message: String) {
119
+ let was = activeWarnings.contains(kind)
120
+ guard was != active else { return }
121
+ if active { activeWarnings.insert(kind) } else { activeWarnings.remove(kind) }
122
+ send("crowdplay:warning", ["kind": kind, "active": active, "message": message])
123
+ }
124
+
125
+ private func send(_ name: String, _ body: [String: Any]) {
126
+ emitter?.sendEvent(withName: name, body: body)
127
+ }
128
+
129
+ private static func name(of phase: CallEngine.Phase) -> String {
130
+ switch phase {
131
+ case .idle: "idle"
132
+ case .connecting: "connecting"
133
+ case .connected: "connected"
134
+ case .failed: "failed"
135
+ }
136
+ }
137
+
138
+ static func kindDict(_ counts: UploadManager.Progress.KindCounts) -> [String: Any] {
139
+ ["total": counts.total, "uploaded": counts.uploaded, "failed": counts.failed,
140
+ "bytesTotal": counts.bytesTotal, "bytesUploaded": counts.bytesUploaded]
141
+ }
142
+
143
+ static func uploadDict(_ progress: UploadManager.Progress) -> [String: Any] {
144
+ ["sessionId": progress.sessionId,
145
+ "fraction": progress.fraction,
146
+ "isComplete": progress.isComplete,
147
+ "failed": progress.failed,
148
+ "audio": kindDict(progress.cellularEligible),
149
+ "video": kindDict(progress.video)]
150
+ }
151
+ }
152
+
153
+ /// ObjC-callable hook for background upload completion. From an ObjC
154
+ /// AppDelegate (via the app's generated -Swift.h header):
155
+ ///
156
+ /// [CrowdPlayRNBackground handleWithCompletionHandler:completionHandler];
157
+ ///
158
+ /// iOS eventually stops delivering background upload events to apps that
159
+ /// skip this — uploads still finish, just less promptly.
160
+ @objc(CrowdPlayRNBackground)
161
+ public final class CrowdPlayRNBackground: NSObject {
162
+ @objc public static func handle(completionHandler: @escaping () -> Void) {
163
+ CrowdPlay.handleBackgroundURLSessionEvents(completionHandler: completionHandler)
164
+ }
165
+ }
166
+
167
+ /// The RN-visible module. Registered from CrowdPlayReactNative.m; every method
168
+ /// hops to the MainActor core.
169
+ @objc(CrowdPlayReactNative)
170
+ public final class CrowdPlayRNModule: RCTEventEmitter {
171
+ public override static func requiresMainQueueSetup() -> Bool { true }
172
+
173
+ public override func supportedEvents() -> [String]! {
174
+ ["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute"]
175
+ }
176
+
177
+ public override func startObserving() {
178
+ Task { @MainActor in CrowdPlayRNCore.shared.bind(self) }
179
+ }
180
+
181
+ public override func stopObserving() {
182
+ Task { @MainActor in CrowdPlayRNCore.shared.unbind() }
183
+ }
184
+
185
+ @objc public func configure(_ config: NSDictionary) {
186
+ Task { @MainActor in
187
+ guard let urlString = config["serverUrl"] as? String,
188
+ let url = URL(string: urlString),
189
+ let appKey = config["appKey"] as? String
190
+ else {
191
+ print("[crowdplay-rn] configure ignored: serverUrl/appKey missing")
192
+ return
193
+ }
194
+ var configuration = CrowdPlayConfiguration(serverURL: url, appKey: appKey)
195
+ if let cellular = config["videoUploadsOnCellular"] as? Bool {
196
+ configuration.videoUploadsOnCellular = cellular
197
+ }
198
+ if let duringCall = config["uploadDuringCall"] as? Bool {
199
+ configuration.uploadDuringCall = duringCall
200
+ }
201
+ if let quality = config["callQuality"] as? [String: Any] {
202
+ var callQuality = CallQuality()
203
+ if let v = quality["videoWidth"] as? Int { callQuality.videoWidth = v }
204
+ if let v = quality["videoHeight"] as? Int { callQuality.videoHeight = v }
205
+ if let v = quality["videoFps"] as? Int { callQuality.videoFps = v }
206
+ if let v = quality["videoBitrate"] as? Int { callQuality.videoBitrate = v }
207
+ if let v = quality["audioBitrate"] as? Int { callQuality.audioBitrate = v }
208
+ configuration.callQuality = callQuality
209
+ }
210
+ CrowdPlay.configure(configuration)
211
+ }
212
+ }
213
+
214
+ @objc public func consentText(_ resolve: @escaping RCTPromiseResolveBlock,
215
+ rejecter _: @escaping RCTPromiseRejectBlock) {
216
+ resolve(CrowdPlayConsent.text)
217
+ }
218
+
219
+ @objc public func join(_ displayName: NSString, roomCode: NSString,
220
+ consentGrantedAtMs: NSNumber,
221
+ resolver: @escaping RCTPromiseResolveBlock,
222
+ rejecter: @escaping RCTPromiseRejectBlock) {
223
+ Task { @MainActor in
224
+ let core = CrowdPlayRNCore.shared
225
+ core.displayName = displayName as String
226
+ // The consent gate, native side: a ConsentRecord is REQUIRED by
227
+ // the engine's join signature; the RN layer's timestamp becomes
228
+ // the manifest's consent evidence, hashed against the locked
229
+ // wording (CrowdPlayConsent.text) this SDK shipped.
230
+ let consent = ConsentRecord(
231
+ givenAt: Date(timeIntervalSince1970: consentGrantedAtMs.doubleValue / 1000)
232
+ )
233
+ await core.engine.join(displayName: displayName as String,
234
+ roomCode: roomCode as String,
235
+ consent: consent)
236
+ if case let .failed(message) = core.engine.phase {
237
+ rejecter("join_failed", message, nil)
238
+ } else {
239
+ resolver(nil)
240
+ }
241
+ }
242
+ }
243
+
244
+ @objc public func leave(_ resolve: @escaping RCTPromiseResolveBlock,
245
+ rejecter _: @escaping RCTPromiseRejectBlock) {
246
+ Task { @MainActor in
247
+ await CrowdPlayRNCore.shared.engine.leave()
248
+ resolve(nil)
249
+ }
250
+ }
251
+
252
+ /// D-079: audio-output routing. "speaker" forces the loudspeaker;
253
+ /// "automatic" routes to the connected device (AirPods, wired, …).
254
+ @objc public func setAudioOutput(_ output: NSString) {
255
+ Task { @MainActor in
256
+ CrowdPlayRNCore.shared.engine.selectOutput(output == "speaker" ? .speaker : .automatic)
257
+ }
258
+ }
259
+
260
+ @objc public func setMicMuted(_ muted: Bool,
261
+ resolver: @escaping RCTPromiseResolveBlock,
262
+ rejecter _: @escaping RCTPromiseRejectBlock) {
263
+ Task { @MainActor in
264
+ let engine = CrowdPlayRNCore.shared.engine
265
+ if engine.isMicMuted != muted {
266
+ await engine.toggleMute()
267
+ }
268
+ resolver(engine.isMicMuted)
269
+ }
270
+ }
271
+
272
+ @objc public func setCameraEnabled(_ enabled: Bool,
273
+ resolver: @escaping RCTPromiseResolveBlock,
274
+ rejecter _: @escaping RCTPromiseRejectBlock) {
275
+ Task { @MainActor in
276
+ let engine = CrowdPlayRNCore.shared.engine
277
+ if engine.isCameraEnabled != enabled {
278
+ engine.toggleCamera()
279
+ }
280
+ resolver(engine.isCameraEnabled)
281
+ }
282
+ }
283
+
284
+ @objc public func retryRecording() {
285
+ Task { @MainActor in
286
+ let core = CrowdPlayRNCore.shared
287
+ await core.engine.retryRecording(displayName: core.displayName)
288
+ }
289
+ }
290
+
291
+ @objc public func retryUploads() {
292
+ Task { @MainActor in CrowdPlayRNCore.shared.engine.retryUploads() }
293
+ }
294
+
295
+ @objc public func warmUp() {
296
+ Task { @MainActor in CrowdPlay.warmUp() }
297
+ }
298
+
299
+ @objc public func snapshot(_ resolve: @escaping RCTPromiseResolveBlock,
300
+ rejecter _: @escaping RCTPromiseRejectBlock) {
301
+ Task { @MainActor in
302
+ guard let snapshot = CrowdPlayRNCore.shared.engine.recordingSnapshot() else {
303
+ resolve(nil)
304
+ return
305
+ }
306
+ resolve([
307
+ "seconds": snapshot.seconds,
308
+ "segmentsClosed": snapshot.segmentsClosed,
309
+ "droppedSamples": snapshot.droppedSamples,
310
+ "inputLevelDbfs": snapshot.inputLevelDbfs,
311
+ "inputSilent": snapshot.inputSilent,
312
+ "fullScaleSamples": snapshot.fullScaleSamples,
313
+ ] as [String: Any])
314
+ }
315
+ }
316
+
317
+ @objc public func doctor(_ resolve: @escaping RCTPromiseResolveBlock,
318
+ rejecter _: @escaping RCTPromiseRejectBlock) {
319
+ Task { @MainActor in
320
+ let checks = await CrowdPlay.doctor()
321
+ resolve(checks.map { ["id": $0.id, "passed": $0.passed, "detail": $0.detail] })
322
+ }
323
+ }
324
+ }