@facepe/widget-mobile-app 1.1.0 β 1.2.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 +293 -289
- package/dist/FacePeMobileAvatar.js +165 -14
- package/dist/FacePeMobileProvider.d.ts +2 -2
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -1,289 +1,293 @@
|
|
|
1
|
-
# @facepe/widget-mobile-app
|
|
2
|
-
|
|
3
|
-
The official **FacePe AI Digital Human Live Streaming SDK** for **React Native** (supporting both **iOS** and **Android**).
|
|
4
|
-
|
|
5
|
-
Embed interactive, photorealistic, lip-synced 3D digital avatars directly inside your native mobile apps with pure WebRTC media streaming, native microphone capture, bidirectional voice dialog, and live data events β **without webviews**.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## π± Features
|
|
10
|
-
|
|
11
|
-
- **β‘ Native WebRTC Streaming**: Pure native media streaming powered by `@livekit/react-native` (no WebViews or embedded iframes).
|
|
12
|
-
- **π£οΈ Natural Two-Way Voice Dialog**: Real-time microphone capture with hardware echo cancellation, noise suppression, and server VAD.
|
|
13
|
-
- **π Full iOS Support**: Hardware-accelerated video decoding on iOS 13.0+ (iPhone & iPad).
|
|
14
|
-
- **π€ Full Android Support**: Hardware-accelerated video decoding on Android 7.0+ (API 24+).
|
|
15
|
-
- **π¬ Real-Time Transcriptions**: Bidirectional captions for avatar speech and user speech.
|
|
16
|
-
- **π Conversational Commerce**: Automatic detection of cart items, orders, and payment intents.
|
|
17
|
-
- **ποΈ Imperative Controls**: Ref methods to start/stop stream, mute/unmute mic, speak text, or interrupt speech.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## π¦ Installation
|
|
22
|
-
|
|
23
|
-
In your React Native project:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
npm install @facepe/widget-mobile-app @livekit/react-native @livekit/react-native-webrtc
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
*(or using yarn / pnpm)*:
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
yarn add @facepe/widget-mobile-app @livekit/react-native @livekit/react-native-webrtc
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<uses-permission android:name="android.permission.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
style={styles.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
flex: 1,
|
|
183
|
-
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
},
|
|
230
|
-
|
|
231
|
-
color: '#
|
|
232
|
-
fontWeight: '700',
|
|
233
|
-
},
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
|
248
|
-
|
|
|
249
|
-
| `
|
|
250
|
-
| `
|
|
251
|
-
| `
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
255
|
-
| `
|
|
256
|
-
| `
|
|
257
|
-
| `
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
261
|
-
| `
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
1
|
+
# @facepe/widget-mobile-app
|
|
2
|
+
|
|
3
|
+
The official **FacePe AI Digital Human Live Streaming SDK** for **React Native** (supporting both **iOS** and **Android**).
|
|
4
|
+
|
|
5
|
+
Embed interactive, photorealistic, lip-synced 3D digital avatars directly inside your native mobile apps with pure WebRTC media streaming, native microphone capture, bidirectional voice dialog, and live data events β **without webviews**.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## π± Features
|
|
10
|
+
|
|
11
|
+
- **β‘ Native WebRTC Streaming**: Pure native media streaming powered by `@livekit/react-native` (no WebViews or embedded iframes).
|
|
12
|
+
- **π£οΈ Natural Two-Way Voice Dialog**: Real-time microphone capture with hardware echo cancellation, noise suppression, and server VAD.
|
|
13
|
+
- **π Full iOS Support**: Hardware-accelerated video decoding on iOS 13.0+ (iPhone & iPad).
|
|
14
|
+
- **π€ Full Android Support**: Hardware-accelerated video decoding on Android 7.0+ (API 24+).
|
|
15
|
+
- **π¬ Real-Time Transcriptions**: Bidirectional captions for avatar speech and user speech.
|
|
16
|
+
- **π Conversational Commerce**: Automatic detection of cart items, orders, and payment intents.
|
|
17
|
+
- **ποΈ Imperative Controls**: Ref methods to start/stop stream, mute/unmute mic, speak text, or interrupt speech.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## π¦ Installation
|
|
22
|
+
|
|
23
|
+
In your React Native project:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @facepe/widget-mobile-app @livekit/react-native @livekit/react-native-webrtc livekit-client @anam-ai/js-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
*(or using yarn / pnpm)*:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
yarn add @facepe/widget-mobile-app @livekit/react-native @livekit/react-native-webrtc livekit-client @anam-ai/js-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
All avatars play natively (no WebView). The streaming libraries are loaded
|
|
36
|
+
only when a conversation starts. The app needs a development build (Expo Go
|
|
37
|
+
cannot load the native WebRTC module).
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## π οΈ Platform Permissions Setup
|
|
42
|
+
|
|
43
|
+
### iOS Setup (`ios/Podfile` & `Info.plist`)
|
|
44
|
+
|
|
45
|
+
1. Open `ios/YourAppName/Info.plist` and add permission strings for microphone and audio:
|
|
46
|
+
|
|
47
|
+
```xml
|
|
48
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
49
|
+
<string>We need access to your microphone to converse with the FacePe AI avatar.</string>
|
|
50
|
+
<key>NSCameraUsageDescription</key>
|
|
51
|
+
<string>Camera access for interactive features.</string>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
2. Run CocoaPods:
|
|
55
|
+
```bash
|
|
56
|
+
cd ios && pod install && cd ..
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### Android Setup (`android/app/src/main/AndroidManifest.xml`)
|
|
62
|
+
|
|
63
|
+
Open `android/app/src/main/AndroidManifest.xml` and include permissions:
|
|
64
|
+
|
|
65
|
+
```xml
|
|
66
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
67
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
68
|
+
<uses-permission android:name="android.permission.CAMERA" />
|
|
69
|
+
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
70
|
+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## π Quick Start Example
|
|
76
|
+
|
|
77
|
+
### 1. Server-side Token Endpoint
|
|
78
|
+
|
|
79
|
+
Exchange your `accessKey` and `secretKey` on your secure backend (Node.js / Express / Next.js / Python / Go) so credentials are never exposed inside mobile apps:
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
// Express server route: GET /api/facepe-token
|
|
83
|
+
app.get('/api/facepe-token', async (req, res) => {
|
|
84
|
+
const response = await fetch('https://api.facepe.ai/api/v1/sdk/token', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
accessKey: process.env.FACEPE_SDK_ACCESS_KEY,
|
|
89
|
+
secretKey: process.env.FACEPE_SDK_SECRET_KEY,
|
|
90
|
+
channel: 'sdk',
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
res.send(data.data.token);
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### 2. React Native Component
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import React, { useRef } from 'react';
|
|
104
|
+
import { StyleSheet, View, SafeAreaView, Text, TouchableOpacity } from 'react-native';
|
|
105
|
+
import {
|
|
106
|
+
FacePeMobileProvider,
|
|
107
|
+
FacePeMobileAvatar,
|
|
108
|
+
FacePeMobileAvatarRef,
|
|
109
|
+
useFacePeAvatar,
|
|
110
|
+
} from '@facepe/widget-mobile-app';
|
|
111
|
+
|
|
112
|
+
export default function App() {
|
|
113
|
+
const avatarRef = useRef<FacePeMobileAvatarRef>(null);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<SafeAreaView style={styles.container}>
|
|
117
|
+
<Text style={styles.title}>FacePe AI Native Digital Human</Text>
|
|
118
|
+
|
|
119
|
+
<FacePeMobileProvider
|
|
120
|
+
apiBaseUrl="https://api.facepe.ai/api/v1/sdk"
|
|
121
|
+
getToken={async () => {
|
|
122
|
+
const res = await fetch('https://your-backend.com/api/facepe-token');
|
|
123
|
+
return await res.text();
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<AvatarScreen avatarRef={avatarRef} />
|
|
127
|
+
</FacePeMobileProvider>
|
|
128
|
+
</SafeAreaView>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function AvatarScreen({ avatarRef }: { avatarRef: React.RefObject<FacePeMobileAvatarRef> }) {
|
|
133
|
+
const { transcripts, isAvatarSpeaking, avatarProps } = useFacePeAvatar();
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<View style={styles.content}>
|
|
137
|
+
<View style={styles.avatarContainer}>
|
|
138
|
+
<FacePeMobileAvatar
|
|
139
|
+
ref={avatarRef}
|
|
140
|
+
language="en"
|
|
141
|
+
objectFit="cover"
|
|
142
|
+
autoStart={true}
|
|
143
|
+
enableMic={true}
|
|
144
|
+
{...avatarProps}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
|
|
148
|
+
{/* Control bar */}
|
|
149
|
+
<View style={styles.controls}>
|
|
150
|
+
<TouchableOpacity
|
|
151
|
+
style={styles.button}
|
|
152
|
+
onPress={() => avatarRef.current?.toggleMic()}
|
|
153
|
+
>
|
|
154
|
+
<Text style={styles.buttonText}>Toggle Mic</Text>
|
|
155
|
+
</TouchableOpacity>
|
|
156
|
+
|
|
157
|
+
<TouchableOpacity
|
|
158
|
+
style={styles.button}
|
|
159
|
+
onPress={() => avatarRef.current?.interrupt()}
|
|
160
|
+
>
|
|
161
|
+
<Text style={styles.buttonText}>Interrupt</Text>
|
|
162
|
+
</TouchableOpacity>
|
|
163
|
+
</View>
|
|
164
|
+
|
|
165
|
+
{/* Real-time speech transcript log */}
|
|
166
|
+
<View style={styles.transcriptBox}>
|
|
167
|
+
{transcripts.slice(-3).map((item, idx) => (
|
|
168
|
+
<Text key={idx} style={styles.transcriptLine}>
|
|
169
|
+
<Text style={item.role === 'assistant' ? styles.avatarTag : styles.userTag}>
|
|
170
|
+
{item.role === 'assistant' ? 'Avatar: ' : 'You: '}
|
|
171
|
+
</Text>
|
|
172
|
+
{item.text}
|
|
173
|
+
</Text>
|
|
174
|
+
))}
|
|
175
|
+
</View>
|
|
176
|
+
</View>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const styles = StyleSheet.create({
|
|
181
|
+
container: {
|
|
182
|
+
flex: 1,
|
|
183
|
+
backgroundColor: '#0F172A',
|
|
184
|
+
},
|
|
185
|
+
content: {
|
|
186
|
+
flex: 1,
|
|
187
|
+
padding: 16,
|
|
188
|
+
},
|
|
189
|
+
title: {
|
|
190
|
+
color: '#FFFFFF',
|
|
191
|
+
fontSize: 20,
|
|
192
|
+
fontWeight: 'bold',
|
|
193
|
+
textAlign: 'center',
|
|
194
|
+
marginVertical: 12,
|
|
195
|
+
},
|
|
196
|
+
avatarContainer: {
|
|
197
|
+
flex: 1,
|
|
198
|
+
borderRadius: 24,
|
|
199
|
+
overflow: 'hidden',
|
|
200
|
+
backgroundColor: '#1E293B',
|
|
201
|
+
},
|
|
202
|
+
controls: {
|
|
203
|
+
flexDirection: 'row',
|
|
204
|
+
justifyContent: 'center',
|
|
205
|
+
gap: 12,
|
|
206
|
+
marginVertical: 14,
|
|
207
|
+
},
|
|
208
|
+
button: {
|
|
209
|
+
backgroundColor: '#7C3AED',
|
|
210
|
+
paddingHorizontal: 20,
|
|
211
|
+
paddingVertical: 10,
|
|
212
|
+
borderRadius: 12,
|
|
213
|
+
},
|
|
214
|
+
buttonText: {
|
|
215
|
+
color: '#FFFFFF',
|
|
216
|
+
fontWeight: '600',
|
|
217
|
+
fontSize: 14,
|
|
218
|
+
},
|
|
219
|
+
transcriptBox: {
|
|
220
|
+
backgroundColor: '#1E293B',
|
|
221
|
+
padding: 12,
|
|
222
|
+
borderRadius: 12,
|
|
223
|
+
minHeight: 80,
|
|
224
|
+
},
|
|
225
|
+
transcriptLine: {
|
|
226
|
+
color: '#E2E8F0',
|
|
227
|
+
fontSize: 13,
|
|
228
|
+
marginBottom: 4,
|
|
229
|
+
},
|
|
230
|
+
avatarTag: {
|
|
231
|
+
color: '#A78BFA',
|
|
232
|
+
fontWeight: '700',
|
|
233
|
+
},
|
|
234
|
+
userTag: {
|
|
235
|
+
color: '#60A5FA',
|
|
236
|
+
fontWeight: '700',
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## βοΈ Props & Configuration
|
|
244
|
+
|
|
245
|
+
### `<FacePeMobileAvatar />`
|
|
246
|
+
|
|
247
|
+
| Prop | Type | Default | Description |
|
|
248
|
+
| :--- | :--- | :--- | :--- |
|
|
249
|
+
| `avatarKey` | `string` | *(optional)* | Specific avatar key or ID assigned to your company. |
|
|
250
|
+
| `language` | `string` | `'en'` | Spoken language code (`'en'`, `'hi'`, `'es'`, `'ta'`, `'te'`, etc.). |
|
|
251
|
+
| `style` | `ViewStyle` | `undefined` | Style applied to avatar container. |
|
|
252
|
+
| `videoStyle` | `ViewStyle` | `undefined` | Style applied to native video view. |
|
|
253
|
+
| `objectFit` | `'cover' \| 'contain'` | `'cover'` | Video aspect scaling mode. |
|
|
254
|
+
| `mirror` | `boolean` | `false` | Mirror video orientation. |
|
|
255
|
+
| `autoStart` | `boolean` | `true` | Automatically connect and start stream on mount. |
|
|
256
|
+
| `enableMic` | `boolean` | `true` | Automatically enable microphone on connect. |
|
|
257
|
+
| `onTranscript` | `function` | `undefined` | Fires when speech transcription arrives. |
|
|
258
|
+
| `onSpeakStart` | `function` | `undefined` | Fires when avatar starts speaking. |
|
|
259
|
+
| `onSpeakEnd` | `function` | `undefined` | Fires when avatar finishes speaking. |
|
|
260
|
+
| `onUserSpeakStart` | `function` | `undefined` | Fires when user voice activity begins. |
|
|
261
|
+
| `onUserSpeakEnd` | `function` | `undefined` | Fires when user voice activity ends. |
|
|
262
|
+
| `onOrderUpdate` | `function` | `undefined` | Fires when cart items or intents are recognized. |
|
|
263
|
+
| `onCheckout` | `function` | `undefined` | Fires when customer requests checkout / payment. |
|
|
264
|
+
| `onStatusChange` | `function` | `undefined` | Stream lifecycle status: `'idle' \| 'connecting' \| 'connected' \| 'reconnecting' \| 'disconnected' \| 'error'`. |
|
|
265
|
+
| `onError` | `function` | `undefined` | Stream error callback `{ code, message }`. |
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### Imperative Ref (`FacePeMobileAvatarRef`)
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
const avatarRef = useRef<FacePeMobileAvatarRef>(null);
|
|
273
|
+
|
|
274
|
+
// Start streaming session
|
|
275
|
+
await avatarRef.current?.start();
|
|
276
|
+
|
|
277
|
+
// Stop streaming session & finalize billing usage
|
|
278
|
+
await avatarRef.current?.stop();
|
|
279
|
+
|
|
280
|
+
// Toggle mic on/off (returns new state boolean)
|
|
281
|
+
const isNowMuted = await avatarRef.current?.toggleMic();
|
|
282
|
+
|
|
283
|
+
// Make avatar speak programmatically
|
|
284
|
+
await avatarRef.current?.speak("Hello! How can I assist you today?");
|
|
285
|
+
|
|
286
|
+
// Interrupt avatar speech immediately
|
|
287
|
+
await avatarRef.current?.interrupt();
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## π License
|
|
293
|
+
MIT Β© FacePe AI
|
|
@@ -2,10 +2,42 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback, } from 'react';
|
|
3
3
|
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity } from 'react-native';
|
|
4
4
|
import { useFacePeMobileContext } from './FacePeMobileProvider';
|
|
5
|
-
// Resolve
|
|
5
|
+
// Resolve LiveKit dynamically at runtime so bare/Expo projects can run smoothly.
|
|
6
|
+
// @livekit/react-native brings the native parts (registerGlobals, AudioSession,
|
|
7
|
+
// VideoView); Room, RoomEvent and Track come from livekit-client, which it
|
|
8
|
+
// does not re-export.
|
|
6
9
|
function getLiveKitRN() {
|
|
7
10
|
try {
|
|
8
|
-
|
|
11
|
+
const native = require('@livekit/react-native');
|
|
12
|
+
const client = require('livekit-client');
|
|
13
|
+
return { ...native, Room: client.Room, RoomEvent: client.RoomEvent, Track: client.Track };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// "stream" connections (hosted avatars) are played natively with the stream
|
|
20
|
+
// SDK's `stream()`, which returns plain MediaStreams β shown in the native
|
|
21
|
+
// RTCView, no WebView. It needs crypto.randomUUID, which React Native lacks.
|
|
22
|
+
function getStreamSdk() {
|
|
23
|
+
try {
|
|
24
|
+
const g = globalThis;
|
|
25
|
+
g.crypto = g.crypto || {};
|
|
26
|
+
if (typeof g.crypto.randomUUID !== 'function') {
|
|
27
|
+
g.crypto.randomUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
28
|
+
const r = (Math.random() * 16) | 0;
|
|
29
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return require('@anam-ai/js-sdk');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function getRTCView() {
|
|
39
|
+
try {
|
|
40
|
+
return require('@livekit/react-native-webrtc').RTCView;
|
|
9
41
|
}
|
|
10
42
|
catch {
|
|
11
43
|
return null;
|
|
@@ -36,6 +68,9 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
36
68
|
const LiveKitRN = getLiveKitRN();
|
|
37
69
|
const hasLiveKit = !!LiveKitRN;
|
|
38
70
|
const roomRef = useRef(null);
|
|
71
|
+
// A "stream" connection: the stream SDK client, and its video for RTCView.
|
|
72
|
+
const streamClientRef = useRef(null);
|
|
73
|
+
const [streamURL, setStreamURL] = useState(null);
|
|
39
74
|
const usageIdRef = useRef(null);
|
|
40
75
|
const mountedRef = useRef(true);
|
|
41
76
|
const isConnectingRef = useRef(false);
|
|
@@ -95,7 +130,7 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
95
130
|
};
|
|
96
131
|
// Start streaming session
|
|
97
132
|
const startStream = useCallback(async () => {
|
|
98
|
-
if (isConnectingRef.current || roomRef.current)
|
|
133
|
+
if (isConnectingRef.current || roomRef.current || streamClientRef.current)
|
|
99
134
|
return;
|
|
100
135
|
if (!hasLiveKit) {
|
|
101
136
|
const msg = 'Please install `@livekit/react-native` and `@livekit/react-native-webrtc` to enable native AI avatar streaming.';
|
|
@@ -121,18 +156,18 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
121
156
|
// Resolve target avatar ID
|
|
122
157
|
let targetAvatarId = avatarKey;
|
|
123
158
|
if (!targetAvatarId) {
|
|
124
|
-
//
|
|
125
|
-
const
|
|
159
|
+
// No avatar given: use the first one assigned to this token's user.
|
|
160
|
+
const listRes = await fetch(`${cleanBase}/avatars`, {
|
|
126
161
|
method: 'GET',
|
|
127
162
|
headers: {
|
|
128
163
|
Authorization: `Bearer ${currentToken}`,
|
|
129
164
|
},
|
|
130
165
|
});
|
|
131
|
-
if (
|
|
132
|
-
const
|
|
133
|
-
const avatars =
|
|
166
|
+
if (listRes.ok) {
|
|
167
|
+
const listData = await listRes.json();
|
|
168
|
+
const avatars = listData?.data?.avatars || [];
|
|
134
169
|
if (avatars.length > 0) {
|
|
135
|
-
targetAvatarId = avatars[0].id
|
|
170
|
+
targetAvatarId = avatars[0].id;
|
|
136
171
|
}
|
|
137
172
|
}
|
|
138
173
|
}
|
|
@@ -157,13 +192,66 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
157
192
|
const sessionInfo = sessionPayload?.data || sessionPayload;
|
|
158
193
|
usageIdRef.current = sessionInfo?.usageId || null;
|
|
159
194
|
const connection = sessionInfo?.connection;
|
|
195
|
+
if (connection?.type === 'stream') {
|
|
196
|
+
// Hosted avatar: the stream SDK gives plain MediaStreams (video +
|
|
197
|
+
// the avatar's voice) and captures the microphone itself.
|
|
198
|
+
const StreamSdk = getStreamSdk();
|
|
199
|
+
if (!StreamSdk || !getRTCView() || !connection.token) {
|
|
200
|
+
throw new Error('This avatar needs `@anam-ai/js-sdk` in the app (npm install @anam-ai/js-sdk).');
|
|
201
|
+
}
|
|
202
|
+
const client = StreamSdk.createClient(connection.token);
|
|
203
|
+
streamClientRef.current = client;
|
|
204
|
+
const ev = StreamSdk.AnamEvent;
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
client.addListener(ev.MESSAGE_HISTORY_UPDATED, (messages) => {
|
|
207
|
+
if (!mountedRef.current)
|
|
208
|
+
return;
|
|
209
|
+
for (const m of messages ?? []) {
|
|
210
|
+
const text = String(m?.content ?? '').trim();
|
|
211
|
+
const key = `${m?.id}:${text}`;
|
|
212
|
+
if (!text || seen.has(key))
|
|
213
|
+
continue;
|
|
214
|
+
seen.add(key);
|
|
215
|
+
onTranscript?.({ payload: { role: String(m.role).includes('user') ? 'user' : 'assistant', text, isFinal: true } });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
client.addListener(ev.USER_SPEECH_STARTED, () => { setIsUserSpeaking(true); onUserSpeakStart?.(); });
|
|
219
|
+
client.addListener(ev.USER_SPEECH_ENDED, () => { setIsUserSpeaking(false); onUserSpeakEnd?.(); });
|
|
220
|
+
client.addListener(ev.CONNECTION_CLOSED, () => {
|
|
221
|
+
if (!mountedRef.current || streamClientRef.current !== client)
|
|
222
|
+
return;
|
|
223
|
+
streamClientRef.current = null;
|
|
224
|
+
setStreamURL(null);
|
|
225
|
+
setIsUserSpeaking(false);
|
|
226
|
+
updateStatus('disconnected');
|
|
227
|
+
void endUsage();
|
|
228
|
+
});
|
|
229
|
+
const streams = await client.stream();
|
|
230
|
+
const video = streams.find((s) => s?.getVideoTracks?.().length > 0) ?? streams[0];
|
|
231
|
+
if (!video)
|
|
232
|
+
throw new Error('The avatar did not send a video stream.');
|
|
233
|
+
if (!mountedRef.current) {
|
|
234
|
+
await client.stopStreaming().catch(() => undefined);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
setStreamURL(video.toURL());
|
|
238
|
+
setIsMicEnabled(true);
|
|
239
|
+
updateStatus('connected');
|
|
240
|
+
if (!enableMic) {
|
|
241
|
+
client.muteInputAudio();
|
|
242
|
+
setIsMicEnabled(false);
|
|
243
|
+
}
|
|
244
|
+
if (connection.greeting)
|
|
245
|
+
client.talk(String(connection.greeting)).catch(() => undefined);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
160
248
|
if (!connection || connection.type !== 'room' || !connection.url || !connection.token) {
|
|
161
|
-
throw new Error('Avatar server did not return a valid
|
|
249
|
+
throw new Error('Avatar server did not return a valid room connection.');
|
|
162
250
|
}
|
|
163
251
|
// Initialize LiveKit Room natively
|
|
164
252
|
const room = new LiveKitRN.Room({
|
|
165
|
-
adaptiveStream:
|
|
166
|
-
dynacast:
|
|
253
|
+
adaptiveStream: false, // Ensure full video frames arrive without adaptive downscaling
|
|
254
|
+
dynacast: false,
|
|
167
255
|
});
|
|
168
256
|
roomRef.current = room;
|
|
169
257
|
// Start Audio Session on iOS/Android
|
|
@@ -285,6 +373,25 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
285
373
|
});
|
|
286
374
|
// Connect room over WebRTC
|
|
287
375
|
await room.connect(connection.url, connection.token, { autoSubscribe: true });
|
|
376
|
+
// Check already subscribed remote tracks in case track arrived before/during connection
|
|
377
|
+
try {
|
|
378
|
+
room.remoteParticipants?.forEach?.((participant) => {
|
|
379
|
+
participant.trackPublications?.forEach?.((pub) => {
|
|
380
|
+
if (pub?.track) {
|
|
381
|
+
const isVid = pub.track.kind === 'video' ||
|
|
382
|
+
pub.kind === 'video' ||
|
|
383
|
+
(LiveKitRN.Track?.Kind?.Video && pub.track.kind === LiveKitRN.Track.Kind.Video);
|
|
384
|
+
if (isVid && mountedRef.current) {
|
|
385
|
+
setVideoTrack(pub.track);
|
|
386
|
+
updateStatus('connected');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// ignore inspection error
|
|
394
|
+
}
|
|
288
395
|
// Enable microphone capture if requested
|
|
289
396
|
if (enableMic) {
|
|
290
397
|
try {
|
|
@@ -310,6 +417,12 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
310
417
|
}
|
|
311
418
|
}
|
|
312
419
|
catch (err) {
|
|
420
|
+
// A failed hosted-avatar start: close its connection too.
|
|
421
|
+
const failedClient = streamClientRef.current;
|
|
422
|
+
streamClientRef.current = null;
|
|
423
|
+
setStreamURL(null);
|
|
424
|
+
if (failedClient)
|
|
425
|
+
await failedClient.stopStreaming().catch(() => undefined);
|
|
313
426
|
if (!mountedRef.current)
|
|
314
427
|
return;
|
|
315
428
|
const msg = err?.message || 'Connection failed';
|
|
@@ -342,6 +455,11 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
342
455
|
const stopStream = useCallback(async () => {
|
|
343
456
|
const room = roomRef.current;
|
|
344
457
|
roomRef.current = null;
|
|
458
|
+
const streamClient = streamClientRef.current;
|
|
459
|
+
streamClientRef.current = null;
|
|
460
|
+
setStreamURL(null);
|
|
461
|
+
if (streamClient)
|
|
462
|
+
await streamClient.stopStreaming().catch(() => undefined);
|
|
345
463
|
if (room) {
|
|
346
464
|
try {
|
|
347
465
|
if (room.localParticipant) {
|
|
@@ -368,6 +486,16 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
368
486
|
await stopStream();
|
|
369
487
|
},
|
|
370
488
|
toggleMic: async () => {
|
|
489
|
+
const streamClient = streamClientRef.current;
|
|
490
|
+
if (streamClient) {
|
|
491
|
+
const next = !isMicEnabled;
|
|
492
|
+
if (next)
|
|
493
|
+
streamClient.unmuteInputAudio();
|
|
494
|
+
else
|
|
495
|
+
streamClient.muteInputAudio();
|
|
496
|
+
setIsMicEnabled(next);
|
|
497
|
+
return next;
|
|
498
|
+
}
|
|
371
499
|
const room = roomRef.current;
|
|
372
500
|
if (!room || !room.localParticipant)
|
|
373
501
|
return isMicEnabled;
|
|
@@ -383,6 +511,15 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
383
511
|
}
|
|
384
512
|
},
|
|
385
513
|
setMicEnabled: async (enabled) => {
|
|
514
|
+
const streamClient = streamClientRef.current;
|
|
515
|
+
if (streamClient) {
|
|
516
|
+
if (enabled)
|
|
517
|
+
streamClient.unmuteInputAudio();
|
|
518
|
+
else
|
|
519
|
+
streamClient.muteInputAudio();
|
|
520
|
+
setIsMicEnabled(enabled);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
386
523
|
const room = roomRef.current;
|
|
387
524
|
if (!room || !room.localParticipant)
|
|
388
525
|
return;
|
|
@@ -395,10 +532,17 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
395
532
|
}
|
|
396
533
|
},
|
|
397
534
|
speak: async (text) => {
|
|
535
|
+
if (streamClientRef.current) {
|
|
536
|
+
await streamClientRef.current.talk(text).catch(() => undefined);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
398
539
|
sendData({ event_type: 'avatar.speak_text', text });
|
|
399
540
|
},
|
|
400
541
|
interrupt: async () => {
|
|
401
|
-
|
|
542
|
+
if (streamClientRef.current)
|
|
543
|
+
streamClientRef.current.interruptPersona();
|
|
544
|
+
else
|
|
545
|
+
sendData({ event_type: 'avatar.interrupt' });
|
|
402
546
|
setIsAvatarSpeaking(false);
|
|
403
547
|
onSpeakEnd?.();
|
|
404
548
|
},
|
|
@@ -434,7 +578,13 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
434
578
|
}
|
|
435
579
|
// Native LiveKit VideoView component
|
|
436
580
|
const VideoViewComponent = LiveKitRN.VideoView;
|
|
437
|
-
|
|
581
|
+
// Native WebRTC view, for a hosted avatar's MediaStream
|
|
582
|
+
const RTCViewComponent = streamURL ? getRTCView() : null;
|
|
583
|
+
return (_jsxs(View, { style: [styles.container, style], children: [streamURL && RTCViewComponent ? (_jsx(RTCViewComponent, { streamURL: streamURL, style: StyleSheet.flatten([styles.video, videoStyle]), objectFit: objectFit, mirror: mirror, zOrder: 0 })) : videoTrack && VideoViewComponent ? (_jsx(VideoViewComponent, { track: videoTrack, videoTrack: videoTrack,
|
|
584
|
+
// A plain object, not an array: VideoView spreads `style` into its
|
|
585
|
+
// container ({ ...style }), and spreading an array loses every
|
|
586
|
+
// size β the video got no height and stayed invisible.
|
|
587
|
+
style: StyleSheet.flatten([styles.video, videoStyle]), objectFit: objectFit, mirror: mirror })) : (_jsx(View, { style: [styles.container, styles.center], children: status === 'connecting' || status === 'reconnecting' ? (_jsxs(_Fragment, { children: [_jsx(ActivityIndicator, { size: "large", color: "#8B5CF6" }), _jsx(Text, { style: styles.loadingText, children: status === 'connecting'
|
|
438
588
|
? 'Connecting to FacePe AI Avatarβ¦'
|
|
439
589
|
: 'Reconnecting streamβ¦' })] })) : status === 'error' ? (_jsxs(_Fragment, { children: [_jsx(Text, { style: styles.errorTitle, children: "Connection Failed" }), _jsx(Text, { style: styles.errorText, children: errorMessage || 'Unable to connect to stream' }), _jsx(TouchableOpacity, { style: styles.retryButton, onPress: () => void startStream(), children: _jsx(Text, { style: styles.retryText, children: "Reconnect" }) })] })) : (_jsx(TouchableOpacity, { style: styles.startButton, onPress: () => void startStream(), children: _jsx(Text, { style: styles.startText, children: "Start Avatar Session" }) })) })), status === 'connected' && (isAvatarSpeaking || isUserSpeaking) && (_jsxs(View, { style: styles.speakerPill, children: [_jsx(View, { style: [
|
|
440
590
|
styles.speakerDot,
|
|
@@ -452,6 +602,7 @@ const styles = StyleSheet.create({
|
|
|
452
602
|
position: 'relative',
|
|
453
603
|
},
|
|
454
604
|
video: {
|
|
605
|
+
flex: 1,
|
|
455
606
|
width: '100%',
|
|
456
607
|
height: '100%',
|
|
457
608
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
2
|
import type { FacePeMobileConfig, TokenProvider } from './types';
|
|
3
3
|
interface FacePeContextValue {
|
|
4
4
|
config: FacePeMobileConfig;
|
|
@@ -14,6 +14,6 @@ export interface FacePeMobileProviderProps {
|
|
|
14
14
|
apiBaseUrl?: string;
|
|
15
15
|
config?: FacePeMobileConfig;
|
|
16
16
|
}
|
|
17
|
-
export declare function FacePeMobileProvider({ children, getToken, apiBaseUrl, config, }: FacePeMobileProviderProps):
|
|
17
|
+
export declare function FacePeMobileProvider({ children, getToken, apiBaseUrl, config, }: FacePeMobileProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
18
18
|
export declare function useFacePeMobileContext(): FacePeContextValue;
|
|
19
19
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@facepe/widget-mobile-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@livekit/react-native": ">=2.0.0",
|
|
35
35
|
"@livekit/react-native-webrtc": ">=124.0.0",
|
|
36
|
+
"livekit-client": ">=2.0.0",
|
|
36
37
|
"react": ">=18.0.0",
|
|
37
|
-
"react-native": ">=0.70.0"
|
|
38
|
+
"react-native": ">=0.70.0",
|
|
39
|
+
"@anam-ai/js-sdk": ">=4.27.0"
|
|
38
40
|
},
|
|
39
41
|
"peerDependenciesMeta": {
|
|
40
42
|
"@livekit/react-native": {
|
|
@@ -42,6 +44,12 @@
|
|
|
42
44
|
},
|
|
43
45
|
"@livekit/react-native-webrtc": {
|
|
44
46
|
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"livekit-client": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@anam-ai/js-sdk": {
|
|
52
|
+
"optional": true
|
|
45
53
|
}
|
|
46
54
|
},
|
|
47
55
|
"devDependencies": {
|