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.
- package/Examples/App.tsx +164 -0
- package/Examples/tsconfig.json +9 -0
- package/README.md +143 -0
- package/ios/CrowdPlayRNModule.swift +324 -0
- package/ios/CrowdPlayRNVideoView.swift +76 -0
- package/ios/CrowdPlayReactNative.m +42 -0
- package/ios/wire.rb +81 -0
- package/lib/ConsentScreen.d.ts +20 -0
- package/lib/ConsentScreen.js +110 -0
- package/lib/VideoView.d.ts +21 -0
- package/lib/VideoView.js +24 -0
- package/lib/index.d.ts +189 -0
- package/lib/index.js +145 -0
- package/llms.txt +224 -0
- package/package.json +44 -0
- package/src/ConsentScreen.tsx +93 -0
- package/src/VideoView.tsx +35 -0
- package/src/index.ts +294 -0
package/Examples/App.tsx
ADDED
|
@@ -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
|
+
});
|
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
|
+
}
|