@facepe/widget-mobile-app 1.1.0 → 1.3.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 +344 -289
- package/dist/FacePeMobileAvatar.js +234 -22
- package/dist/FacePeMobileProvider.d.ts +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +61 -53
package/README.md
CHANGED
|
@@ -1,289 +1,344 @@
|
|
|
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
|
+
## 🗂️ Conversation data (`onConversationEnd`)
|
|
293
|
+
|
|
294
|
+
When a conversation ends, the SDK hands your app the **whole conversation** in
|
|
295
|
+
one record. It stays in your app — the SDK sends it nowhere; you decide whether
|
|
296
|
+
to show it, save it on your server, or ignore it.
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
<FacePeMobileAvatar
|
|
300
|
+
onConversationEnd={(conversation) => {
|
|
301
|
+
console.log(conversation.durationSec, conversation.transcript.length);
|
|
302
|
+
// e.g. save it on your own server:
|
|
303
|
+
// fetch('https://your-server.example.com/conversations', { method: 'POST', body: JSON.stringify(conversation) });
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
It is called once per conversation: after `stop()`, when the stream drops, or
|
|
309
|
+
when the component unmounts.
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"conversationId": "cm_usage_123",
|
|
314
|
+
"avatar": { "id": "cm_avatar_456", "name": "Dharun" },
|
|
315
|
+
"language": "en",
|
|
316
|
+
"platform": "android",
|
|
317
|
+
"sdkVersion": "1.3.0",
|
|
318
|
+
"startedAt": "2026-10-12T11:40:02.000Z",
|
|
319
|
+
"endedAt": "2026-10-12T11:43:17.000Z",
|
|
320
|
+
"durationSec": 195,
|
|
321
|
+
"endReason": "ended",
|
|
322
|
+
"transcript": [
|
|
323
|
+
{ "role": "assistant", "text": "Welcome! How can I help you today?", "at": "2026-10-12T11:40:05.000Z" },
|
|
324
|
+
{ "role": "user", "text": "Two cheeseburgers please.", "at": "2026-10-12T11:40:11.000Z" }
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
| Field | Meaning |
|
|
330
|
+
|---|---|
|
|
331
|
+
| `conversationId` | The conversation's FacePe id (its usage record) |
|
|
332
|
+
| `avatar` | `{ id, name }` of the avatar |
|
|
333
|
+
| `language` | The language requested for the conversation |
|
|
334
|
+
| `platform` | `web`, `android` or `ios` |
|
|
335
|
+
| `sdkVersion` | The SDK version that recorded it |
|
|
336
|
+
| `startedAt`, `endedAt` | ISO 8601 times |
|
|
337
|
+
| `durationSec` | Length in seconds |
|
|
338
|
+
| `endReason` | `ended` (the user or your code ended it), `disconnected` (the stream dropped), `unmounted` (the component went away) |
|
|
339
|
+
| `transcript` | Every line in order: `{ role: "user" \| "assistant", text, at }` |
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 📄 License
|
|
344
|
+
MIT © FacePe AI
|
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback, } from 'react';
|
|
3
|
-
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity, Platform } from 'react-native';
|
|
4
|
+
import { SDK_VERSION } from './version';
|
|
4
5
|
import { useFacePeMobileContext } from './FacePeMobileProvider';
|
|
5
|
-
// Resolve
|
|
6
|
+
// Resolve LiveKit dynamically at runtime so bare/Expo projects can run smoothly.
|
|
7
|
+
// @livekit/react-native brings the native parts (registerGlobals, AudioSession,
|
|
8
|
+
// VideoView); Room, RoomEvent and Track come from livekit-client, which it
|
|
9
|
+
// does not re-export.
|
|
6
10
|
function getLiveKitRN() {
|
|
7
11
|
try {
|
|
8
|
-
|
|
12
|
+
const native = require('@livekit/react-native');
|
|
13
|
+
const client = require('livekit-client');
|
|
14
|
+
return { ...native, Room: client.Room, RoomEvent: client.RoomEvent, Track: client.Track };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// "stream" connections (hosted avatars) are played natively with the stream
|
|
21
|
+
// SDK's `stream()`, which returns plain MediaStreams — shown in the native
|
|
22
|
+
// RTCView, no WebView. It needs crypto.randomUUID, which React Native lacks.
|
|
23
|
+
function getStreamSdk() {
|
|
24
|
+
try {
|
|
25
|
+
const g = globalThis;
|
|
26
|
+
g.crypto = g.crypto || {};
|
|
27
|
+
if (typeof g.crypto.randomUUID !== 'function') {
|
|
28
|
+
g.crypto.randomUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
29
|
+
const r = (Math.random() * 16) | 0;
|
|
30
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return require('@anam-ai/js-sdk');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function getRTCView() {
|
|
40
|
+
try {
|
|
41
|
+
return require('@livekit/react-native-webrtc').RTCView;
|
|
9
42
|
}
|
|
10
43
|
catch {
|
|
11
44
|
return null;
|
|
@@ -25,7 +58,7 @@ function ensureLiveKitGlobals(LiveKitRN) {
|
|
|
25
58
|
console.warn('[FacePeMobileAvatar] registerGlobals warning:', err);
|
|
26
59
|
}
|
|
27
60
|
}
|
|
28
|
-
export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, }, ref) => {
|
|
61
|
+
export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, onConversationEnd, }, ref) => {
|
|
29
62
|
const { token, loading: tokenLoading, error: tokenError, apiBaseUrl, refreshToken } = useFacePeMobileContext();
|
|
30
63
|
const [status, setStatus] = useState('idle');
|
|
31
64
|
const [videoTrack, setVideoTrack] = useState(null);
|
|
@@ -36,7 +69,54 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
36
69
|
const LiveKitRN = getLiveKitRN();
|
|
37
70
|
const hasLiveKit = !!LiveKitRN;
|
|
38
71
|
const roomRef = useRef(null);
|
|
72
|
+
// A "stream" connection: the stream SDK client, and its video for RTCView.
|
|
73
|
+
const streamClientRef = useRef(null);
|
|
74
|
+
const [streamURL, setStreamURL] = useState(null);
|
|
39
75
|
const usageIdRef = useRef(null);
|
|
76
|
+
// The conversation as it happens, for the onConversationEnd record. Lines
|
|
77
|
+
// that carry an id (a line that grows while spoken) replace their earlier version.
|
|
78
|
+
const conversationRef = useRef(null);
|
|
79
|
+
const onConversationEndRef = useRef(onConversationEnd);
|
|
80
|
+
onConversationEndRef.current = onConversationEnd;
|
|
81
|
+
const onTranscriptRef = useRef(onTranscript);
|
|
82
|
+
onTranscriptRef.current = onTranscript;
|
|
83
|
+
// A transcript line: recorded for the conversation record, and passed on.
|
|
84
|
+
const emitLine = (role, text, id) => {
|
|
85
|
+
const conv = conversationRef.current;
|
|
86
|
+
if (conv && text) {
|
|
87
|
+
const same = id ? conv.lines.find((l) => l.id === id) : undefined;
|
|
88
|
+
if (same)
|
|
89
|
+
same.text = text;
|
|
90
|
+
else
|
|
91
|
+
conv.lines.push({ id, role, text, at: new Date().toISOString() });
|
|
92
|
+
}
|
|
93
|
+
onTranscriptRef.current?.({ payload: { role, text, isFinal: true } });
|
|
94
|
+
};
|
|
95
|
+
// Hands the whole conversation to the app, once.
|
|
96
|
+
const finishConversation = (endReason) => {
|
|
97
|
+
const conv = conversationRef.current;
|
|
98
|
+
conversationRef.current = null;
|
|
99
|
+
if (!conv)
|
|
100
|
+
return;
|
|
101
|
+
const endedAt = new Date();
|
|
102
|
+
try {
|
|
103
|
+
onConversationEndRef.current?.({
|
|
104
|
+
conversationId: conv.conversationId,
|
|
105
|
+
avatar: conv.avatar,
|
|
106
|
+
language: conv.language,
|
|
107
|
+
platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web',
|
|
108
|
+
sdkVersion: SDK_VERSION,
|
|
109
|
+
startedAt: conv.startedAt.toISOString(),
|
|
110
|
+
endedAt: endedAt.toISOString(),
|
|
111
|
+
durationSec: Math.max(0, Math.round((endedAt.getTime() - conv.startedAt.getTime()) / 1000)),
|
|
112
|
+
endReason,
|
|
113
|
+
transcript: conv.lines.map(({ role, text, at }) => ({ role, text, at })),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.warn('[FacePeMobileAvatar] onConversationEnd error:', err);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
40
120
|
const mountedRef = useRef(true);
|
|
41
121
|
const isConnectingRef = useRef(false);
|
|
42
122
|
const tokenRef = useRef(token);
|
|
@@ -95,7 +175,7 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
95
175
|
};
|
|
96
176
|
// Start streaming session
|
|
97
177
|
const startStream = useCallback(async () => {
|
|
98
|
-
if (isConnectingRef.current || roomRef.current)
|
|
178
|
+
if (isConnectingRef.current || roomRef.current || streamClientRef.current)
|
|
99
179
|
return;
|
|
100
180
|
if (!hasLiveKit) {
|
|
101
181
|
const msg = 'Please install `@livekit/react-native` and `@livekit/react-native-webrtc` to enable native AI avatar streaming.';
|
|
@@ -121,18 +201,18 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
121
201
|
// Resolve target avatar ID
|
|
122
202
|
let targetAvatarId = avatarKey;
|
|
123
203
|
if (!targetAvatarId) {
|
|
124
|
-
//
|
|
125
|
-
const
|
|
204
|
+
// No avatar given: use the first one assigned to this token's user.
|
|
205
|
+
const listRes = await fetch(`${cleanBase}/avatars`, {
|
|
126
206
|
method: 'GET',
|
|
127
207
|
headers: {
|
|
128
208
|
Authorization: `Bearer ${currentToken}`,
|
|
129
209
|
},
|
|
130
210
|
});
|
|
131
|
-
if (
|
|
132
|
-
const
|
|
133
|
-
const avatars =
|
|
211
|
+
if (listRes.ok) {
|
|
212
|
+
const listData = await listRes.json();
|
|
213
|
+
const avatars = listData?.data?.avatars || [];
|
|
134
214
|
if (avatars.length > 0) {
|
|
135
|
-
targetAvatarId = avatars[0].id
|
|
215
|
+
targetAvatarId = avatars[0].id;
|
|
136
216
|
}
|
|
137
217
|
}
|
|
138
218
|
}
|
|
@@ -156,14 +236,75 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
156
236
|
const sessionPayload = await sessionResp.json();
|
|
157
237
|
const sessionInfo = sessionPayload?.data || sessionPayload;
|
|
158
238
|
usageIdRef.current = sessionInfo?.usageId || null;
|
|
239
|
+
conversationRef.current = {
|
|
240
|
+
conversationId: usageIdRef.current,
|
|
241
|
+
avatar: { id: String(sessionInfo?.avatar?.id ?? targetAvatarId), name: String(sessionInfo?.avatar?.name ?? '') },
|
|
242
|
+
language: language ?? null,
|
|
243
|
+
startedAt: new Date(),
|
|
244
|
+
lines: [],
|
|
245
|
+
};
|
|
159
246
|
const connection = sessionInfo?.connection;
|
|
247
|
+
if (connection?.type === 'stream') {
|
|
248
|
+
// Hosted avatar: the stream SDK gives plain MediaStreams (video +
|
|
249
|
+
// the avatar's voice) and captures the microphone itself.
|
|
250
|
+
const StreamSdk = getStreamSdk();
|
|
251
|
+
if (!StreamSdk || !getRTCView() || !connection.token) {
|
|
252
|
+
throw new Error('This avatar needs `@anam-ai/js-sdk` in the app (npm install @anam-ai/js-sdk).');
|
|
253
|
+
}
|
|
254
|
+
const client = StreamSdk.createClient(connection.token);
|
|
255
|
+
streamClientRef.current = client;
|
|
256
|
+
const ev = StreamSdk.AnamEvent;
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
client.addListener(ev.MESSAGE_HISTORY_UPDATED, (messages) => {
|
|
259
|
+
if (!mountedRef.current)
|
|
260
|
+
return;
|
|
261
|
+
for (const m of messages ?? []) {
|
|
262
|
+
const text = String(m?.content ?? '').trim();
|
|
263
|
+
const key = `${m?.id}:${text}`;
|
|
264
|
+
if (!text || seen.has(key))
|
|
265
|
+
continue;
|
|
266
|
+
seen.add(key);
|
|
267
|
+
emitLine(String(m.role).includes('user') ? 'user' : 'assistant', text, m?.id ? String(m.id) : undefined);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
client.addListener(ev.USER_SPEECH_STARTED, () => { setIsUserSpeaking(true); onUserSpeakStart?.(); });
|
|
271
|
+
client.addListener(ev.USER_SPEECH_ENDED, () => { setIsUserSpeaking(false); onUserSpeakEnd?.(); });
|
|
272
|
+
client.addListener(ev.CONNECTION_CLOSED, () => {
|
|
273
|
+
if (!mountedRef.current || streamClientRef.current !== client)
|
|
274
|
+
return;
|
|
275
|
+
streamClientRef.current = null;
|
|
276
|
+
setStreamURL(null);
|
|
277
|
+
setIsUserSpeaking(false);
|
|
278
|
+
updateStatus('disconnected');
|
|
279
|
+
finishConversation('disconnected');
|
|
280
|
+
void endUsage();
|
|
281
|
+
});
|
|
282
|
+
const streams = await client.stream();
|
|
283
|
+
const video = streams.find((s) => s?.getVideoTracks?.().length > 0) ?? streams[0];
|
|
284
|
+
if (!video)
|
|
285
|
+
throw new Error('The avatar did not send a video stream.');
|
|
286
|
+
if (!mountedRef.current) {
|
|
287
|
+
await client.stopStreaming().catch(() => undefined);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
setStreamURL(video.toURL());
|
|
291
|
+
setIsMicEnabled(true);
|
|
292
|
+
updateStatus('connected');
|
|
293
|
+
if (!enableMic) {
|
|
294
|
+
client.muteInputAudio();
|
|
295
|
+
setIsMicEnabled(false);
|
|
296
|
+
}
|
|
297
|
+
if (connection.greeting)
|
|
298
|
+
client.talk(String(connection.greeting)).catch(() => undefined);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
160
301
|
if (!connection || connection.type !== 'room' || !connection.url || !connection.token) {
|
|
161
|
-
throw new Error('Avatar server did not return a valid
|
|
302
|
+
throw new Error('Avatar server did not return a valid room connection.');
|
|
162
303
|
}
|
|
163
304
|
// Initialize LiveKit Room natively
|
|
164
305
|
const room = new LiveKitRN.Room({
|
|
165
|
-
adaptiveStream:
|
|
166
|
-
dynacast:
|
|
306
|
+
adaptiveStream: false, // Ensure full video frames arrive without adaptive downscaling
|
|
307
|
+
dynacast: false,
|
|
167
308
|
});
|
|
168
309
|
roomRef.current = room;
|
|
169
310
|
// Start Audio Session on iOS/Android
|
|
@@ -247,13 +388,13 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
247
388
|
if (type === 'avatar.transcription' || type === 'response.audio_transcript.done') {
|
|
248
389
|
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
249
390
|
if (str)
|
|
250
|
-
|
|
391
|
+
emitLine('assistant', String(str).trim());
|
|
251
392
|
}
|
|
252
393
|
else if (type === 'user.transcription' ||
|
|
253
394
|
type === 'conversation.item.input_audio_transcription.completed') {
|
|
254
395
|
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
255
396
|
if (str)
|
|
256
|
-
|
|
397
|
+
emitLine('user', String(str).trim());
|
|
257
398
|
}
|
|
258
399
|
// Order & checkout intents
|
|
259
400
|
if (type === 'order.update') {
|
|
@@ -270,12 +411,17 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
270
411
|
});
|
|
271
412
|
// 4. Connection state transitions
|
|
272
413
|
room.on(LiveKitRN.RoomEvent.Disconnected, () => {
|
|
273
|
-
if (!mountedRef.current)
|
|
274
|
-
return;
|
|
414
|
+
if (!mountedRef.current || roomRef.current !== room)
|
|
415
|
+
return; // stop() handles its own end
|
|
416
|
+
roomRef.current = null;
|
|
275
417
|
setVideoTrack(null);
|
|
276
418
|
setIsAvatarSpeaking(false);
|
|
277
419
|
setIsUserSpeaking(false);
|
|
278
420
|
updateStatus('disconnected');
|
|
421
|
+
// The stream dropped: close the conversation now, so it is not
|
|
422
|
+
// left open (and billed) until the server's 30-minute limit.
|
|
423
|
+
finishConversation('disconnected');
|
|
424
|
+
void endUsage();
|
|
279
425
|
});
|
|
280
426
|
room.on(LiveKitRN.RoomEvent.Reconnecting, () => {
|
|
281
427
|
updateStatus('reconnecting');
|
|
@@ -285,6 +431,25 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
285
431
|
});
|
|
286
432
|
// Connect room over WebRTC
|
|
287
433
|
await room.connect(connection.url, connection.token, { autoSubscribe: true });
|
|
434
|
+
// Check already subscribed remote tracks in case track arrived before/during connection
|
|
435
|
+
try {
|
|
436
|
+
room.remoteParticipants?.forEach?.((participant) => {
|
|
437
|
+
participant.trackPublications?.forEach?.((pub) => {
|
|
438
|
+
if (pub?.track) {
|
|
439
|
+
const isVid = pub.track.kind === 'video' ||
|
|
440
|
+
pub.kind === 'video' ||
|
|
441
|
+
(LiveKitRN.Track?.Kind?.Video && pub.track.kind === LiveKitRN.Track.Kind.Video);
|
|
442
|
+
if (isVid && mountedRef.current) {
|
|
443
|
+
setVideoTrack(pub.track);
|
|
444
|
+
updateStatus('connected');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// ignore inspection error
|
|
452
|
+
}
|
|
288
453
|
// Enable microphone capture if requested
|
|
289
454
|
if (enableMic) {
|
|
290
455
|
try {
|
|
@@ -310,6 +475,14 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
310
475
|
}
|
|
311
476
|
}
|
|
312
477
|
catch (err) {
|
|
478
|
+
// A failed hosted-avatar start: close its connection too.
|
|
479
|
+
const failedClient = streamClientRef.current;
|
|
480
|
+
streamClientRef.current = null;
|
|
481
|
+
setStreamURL(null);
|
|
482
|
+
if (failedClient)
|
|
483
|
+
await failedClient.stopStreaming().catch(() => undefined);
|
|
484
|
+
// Opened on the server but never got going: still hand over the record.
|
|
485
|
+
finishConversation('disconnected');
|
|
313
486
|
if (!mountedRef.current)
|
|
314
487
|
return;
|
|
315
488
|
const msg = err?.message || 'Connection failed';
|
|
@@ -339,9 +512,15 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
339
512
|
onError,
|
|
340
513
|
]);
|
|
341
514
|
// Stop streaming session
|
|
342
|
-
const stopStream = useCallback(async () => {
|
|
515
|
+
const stopStream = useCallback(async (reason = 'ended') => {
|
|
516
|
+
finishConversation(reason);
|
|
343
517
|
const room = roomRef.current;
|
|
344
518
|
roomRef.current = null;
|
|
519
|
+
const streamClient = streamClientRef.current;
|
|
520
|
+
streamClientRef.current = null;
|
|
521
|
+
setStreamURL(null);
|
|
522
|
+
if (streamClient)
|
|
523
|
+
await streamClient.stopStreaming().catch(() => undefined);
|
|
345
524
|
if (room) {
|
|
346
525
|
try {
|
|
347
526
|
if (room.localParticipant) {
|
|
@@ -368,6 +547,16 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
368
547
|
await stopStream();
|
|
369
548
|
},
|
|
370
549
|
toggleMic: async () => {
|
|
550
|
+
const streamClient = streamClientRef.current;
|
|
551
|
+
if (streamClient) {
|
|
552
|
+
const next = !isMicEnabled;
|
|
553
|
+
if (next)
|
|
554
|
+
streamClient.unmuteInputAudio();
|
|
555
|
+
else
|
|
556
|
+
streamClient.muteInputAudio();
|
|
557
|
+
setIsMicEnabled(next);
|
|
558
|
+
return next;
|
|
559
|
+
}
|
|
371
560
|
const room = roomRef.current;
|
|
372
561
|
if (!room || !room.localParticipant)
|
|
373
562
|
return isMicEnabled;
|
|
@@ -383,6 +572,15 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
383
572
|
}
|
|
384
573
|
},
|
|
385
574
|
setMicEnabled: async (enabled) => {
|
|
575
|
+
const streamClient = streamClientRef.current;
|
|
576
|
+
if (streamClient) {
|
|
577
|
+
if (enabled)
|
|
578
|
+
streamClient.unmuteInputAudio();
|
|
579
|
+
else
|
|
580
|
+
streamClient.muteInputAudio();
|
|
581
|
+
setIsMicEnabled(enabled);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
386
584
|
const room = roomRef.current;
|
|
387
585
|
if (!room || !room.localParticipant)
|
|
388
586
|
return;
|
|
@@ -395,10 +593,17 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
395
593
|
}
|
|
396
594
|
},
|
|
397
595
|
speak: async (text) => {
|
|
596
|
+
if (streamClientRef.current) {
|
|
597
|
+
await streamClientRef.current.talk(text).catch(() => undefined);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
398
600
|
sendData({ event_type: 'avatar.speak_text', text });
|
|
399
601
|
},
|
|
400
602
|
interrupt: async () => {
|
|
401
|
-
|
|
603
|
+
if (streamClientRef.current)
|
|
604
|
+
streamClientRef.current.interruptPersona();
|
|
605
|
+
else
|
|
606
|
+
sendData({ event_type: 'avatar.interrupt' });
|
|
402
607
|
setIsAvatarSpeaking(false);
|
|
403
608
|
onSpeakEnd?.();
|
|
404
609
|
},
|
|
@@ -417,7 +622,7 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
417
622
|
}
|
|
418
623
|
return () => {
|
|
419
624
|
mountedRef.current = false;
|
|
420
|
-
void stopStream();
|
|
625
|
+
void stopStream('unmounted');
|
|
421
626
|
};
|
|
422
627
|
}, [autoStart, token, tokenLoading, tokenError]);
|
|
423
628
|
// Handle token loading
|
|
@@ -434,7 +639,13 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
434
639
|
}
|
|
435
640
|
// Native LiveKit VideoView component
|
|
436
641
|
const VideoViewComponent = LiveKitRN.VideoView;
|
|
437
|
-
|
|
642
|
+
// Native WebRTC view, for a hosted avatar's MediaStream
|
|
643
|
+
const RTCViewComponent = streamURL ? getRTCView() : null;
|
|
644
|
+
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,
|
|
645
|
+
// A plain object, not an array: VideoView spreads `style` into its
|
|
646
|
+
// container ({ ...style }), and spreading an array loses every
|
|
647
|
+
// size — the video got no height and stayed invisible.
|
|
648
|
+
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
649
|
? 'Connecting to FacePe AI Avatar…'
|
|
439
650
|
: '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
651
|
styles.speakerDot,
|
|
@@ -452,6 +663,7 @@ const styles = StyleSheet.create({
|
|
|
452
663
|
position: 'relative',
|
|
453
664
|
},
|
|
454
665
|
video: {
|
|
666
|
+
flex: 1,
|
|
455
667
|
width: '100%',
|
|
456
668
|
height: '100%',
|
|
457
669
|
},
|
|
@@ -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/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,37 @@ export interface TranscriptPayload {
|
|
|
11
11
|
text: string;
|
|
12
12
|
isFinal?: boolean;
|
|
13
13
|
}
|
|
14
|
+
/** One line of a finished conversation, with the time it was said (ISO 8601). */
|
|
15
|
+
export interface FacePeConversationLine {
|
|
16
|
+
role: 'user' | 'assistant';
|
|
17
|
+
text: string;
|
|
18
|
+
at: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Everything about one conversation, given once when it ends
|
|
22
|
+
* (`onConversationEnd`). It stays in your app: the SDK sends it nowhere — you
|
|
23
|
+
* decide whether to show, keep or send it.
|
|
24
|
+
*/
|
|
25
|
+
export interface FacePeConversation {
|
|
26
|
+
/** The conversation's id (its FacePe usage record), or null if none was opened. */
|
|
27
|
+
conversationId: string | null;
|
|
28
|
+
avatar: {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
/** The language requested for the conversation (ISO 639-1). */
|
|
33
|
+
language: string | null;
|
|
34
|
+
platform: 'android' | 'ios' | 'web';
|
|
35
|
+
sdkVersion: string;
|
|
36
|
+
/** When it started and ended (ISO 8601). */
|
|
37
|
+
startedAt: string;
|
|
38
|
+
endedAt: string;
|
|
39
|
+
durationSec: number;
|
|
40
|
+
/** `ended`: by `stop()`; `disconnected`: the stream dropped; `unmounted`: the component went away. */
|
|
41
|
+
endReason: 'ended' | 'disconnected' | 'unmounted';
|
|
42
|
+
/** Every line, in order (a line that grew while being spoken appears once, complete). */
|
|
43
|
+
transcript: FacePeConversationLine[];
|
|
44
|
+
}
|
|
14
45
|
export interface OrderItem {
|
|
15
46
|
name: string;
|
|
16
47
|
qty: number;
|
|
@@ -69,6 +100,8 @@ export interface FacePeMobileAvatarProps {
|
|
|
69
100
|
onCheckout?: () => void;
|
|
70
101
|
/** Called when stream connection status changes */
|
|
71
102
|
onStatusChange?: (status: FacePeConnectionStatus) => void;
|
|
103
|
+
/** Called once when a conversation ends, with the whole conversation (stays in your app) */
|
|
104
|
+
onConversationEnd?: (conversation: FacePeConversation) => void;
|
|
72
105
|
/** Called when an error occurs */
|
|
73
106
|
onError?: (event: {
|
|
74
107
|
code: string;
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,53 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@facepe/widget-mobile-app",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
|
|
5
|
-
"main": "./dist/index.js",
|
|
6
|
-
"module": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"README.md"
|
|
11
|
-
],
|
|
12
|
-
"publishConfig": {
|
|
13
|
-
"access": "public"
|
|
14
|
-
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
"facepe",
|
|
17
|
-
"avatar",
|
|
18
|
-
"ai-avatar",
|
|
19
|
-
"livekit",
|
|
20
|
-
"react-native",
|
|
21
|
-
"ios",
|
|
22
|
-
"android",
|
|
23
|
-
"webrtc",
|
|
24
|
-
"streaming",
|
|
25
|
-
"digital-human"
|
|
26
|
-
],
|
|
27
|
-
"author": "FacePe AI",
|
|
28
|
-
"license": "MIT",
|
|
29
|
-
"scripts": {
|
|
30
|
-
"lint": "tsc --noEmit",
|
|
31
|
-
"build": "tsc"
|
|
32
|
-
},
|
|
33
|
-
"peerDependencies": {
|
|
34
|
-
"@livekit/react-native": ">=2.0.0",
|
|
35
|
-
"@livekit/react-native-webrtc": ">=124.0.0",
|
|
36
|
-
"
|
|
37
|
-
"react
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@facepe/widget-mobile-app",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"facepe",
|
|
17
|
+
"avatar",
|
|
18
|
+
"ai-avatar",
|
|
19
|
+
"livekit",
|
|
20
|
+
"react-native",
|
|
21
|
+
"ios",
|
|
22
|
+
"android",
|
|
23
|
+
"webrtc",
|
|
24
|
+
"streaming",
|
|
25
|
+
"digital-human"
|
|
26
|
+
],
|
|
27
|
+
"author": "FacePe AI",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"lint": "tsc --noEmit",
|
|
31
|
+
"build": "tsc"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@livekit/react-native": ">=2.0.0",
|
|
35
|
+
"@livekit/react-native-webrtc": ">=124.0.0",
|
|
36
|
+
"livekit-client": ">=2.0.0",
|
|
37
|
+
"react": ">=18.0.0",
|
|
38
|
+
"react-native": ">=0.70.0",
|
|
39
|
+
"@anam-ai/js-sdk": ">=4.27.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@livekit/react-native": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@livekit/react-native-webrtc": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"livekit-client": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@anam-ai/js-sdk": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/react": "^18.3.3",
|
|
57
|
+
"react": "18.3.1",
|
|
58
|
+
"react-native": "0.74.0",
|
|
59
|
+
"typescript": "^5.4.5"
|
|
60
|
+
}
|
|
61
|
+
}
|