@facepe/widget-mobile-app 1.0.0 → 1.1.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 +160 -47
- package/dist/FacePeMobileAvatar.d.ts +1 -6
- package/dist/FacePeMobileAvatar.js +517 -96
- package/dist/types.d.ts +35 -7
- package/dist/useFacePeAvatar.d.ts +3 -1
- package/dist/useFacePeAvatar.js +42 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
# @facepe/widget-mobile-app
|
|
2
2
|
|
|
3
|
-
The official **FacePe AI
|
|
3
|
+
The official **FacePe AI Digital Human Live Streaming SDK** for **React Native** (supporting both **iOS** and **Android**).
|
|
4
4
|
|
|
5
|
-
Embed interactive, photorealistic, lip-synced 3D digital avatars directly inside your native mobile apps with
|
|
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
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## 📱 Features
|
|
10
10
|
|
|
11
|
-
- **⚡
|
|
12
|
-
- **🗣️ Two-Way
|
|
13
|
-
- **🍎 Full iOS Support**:
|
|
14
|
-
- **🤖 Full Android Support**:
|
|
15
|
-
- **💬 Real-Time
|
|
16
|
-
- **🛒 Conversational
|
|
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.
|
|
17
18
|
|
|
18
19
|
---
|
|
19
20
|
|
|
20
21
|
## 📦 Installation
|
|
21
22
|
|
|
22
|
-
In your React Native
|
|
23
|
+
In your React Native project:
|
|
23
24
|
|
|
24
25
|
```bash
|
|
25
|
-
npm install @facepe/widget-mobile-app react-native-
|
|
26
|
+
npm install @facepe/widget-mobile-app @livekit/react-native @livekit/react-native-webrtc
|
|
26
27
|
```
|
|
28
|
+
|
|
27
29
|
*(or using yarn / pnpm)*:
|
|
30
|
+
|
|
28
31
|
```bash
|
|
29
|
-
yarn add @facepe/widget-mobile-app react-native-
|
|
32
|
+
yarn add @facepe/widget-mobile-app @livekit/react-native @livekit/react-native-webrtc
|
|
30
33
|
```
|
|
31
34
|
|
|
32
35
|
---
|
|
@@ -35,13 +38,13 @@ yarn add @facepe/widget-mobile-app react-native-webview
|
|
|
35
38
|
|
|
36
39
|
### iOS Setup (`ios/Podfile` & `Info.plist`)
|
|
37
40
|
|
|
38
|
-
1. Open `ios/YourAppName/Info.plist` and add permission strings for microphone and
|
|
41
|
+
1. Open `ios/YourAppName/Info.plist` and add permission strings for microphone and audio:
|
|
39
42
|
|
|
40
43
|
```xml
|
|
41
44
|
<key>NSMicrophoneUsageDescription</key>
|
|
42
|
-
<string>We need access to your microphone to converse with the AI avatar.</string>
|
|
45
|
+
<string>We need access to your microphone to converse with the FacePe AI avatar.</string>
|
|
43
46
|
<key>NSCameraUsageDescription</key>
|
|
44
|
-
<string>
|
|
47
|
+
<string>Camera access for interactive features.</string>
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
2. Run CocoaPods:
|
|
@@ -60,6 +63,7 @@ Open `android/app/src/main/AndroidManifest.xml` and include permissions:
|
|
|
60
63
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
61
64
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
62
65
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
66
|
+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
63
67
|
```
|
|
64
68
|
|
|
65
69
|
---
|
|
@@ -68,10 +72,10 @@ Open `android/app/src/main/AndroidManifest.xml` and include permissions:
|
|
|
68
72
|
|
|
69
73
|
### 1. Server-side Token Endpoint
|
|
70
74
|
|
|
71
|
-
Exchange your `accessKey` and `secretKey` on your secure backend (Express / Next.js / Python / Go) so
|
|
75
|
+
Exchange your `accessKey` and `secretKey` on your secure backend (Node.js / Express / Next.js / Python / Go) so credentials are never exposed inside mobile apps:
|
|
72
76
|
|
|
73
77
|
```javascript
|
|
74
|
-
//
|
|
78
|
+
// Express server route: GET /api/facepe-token
|
|
75
79
|
app.get('/api/facepe-token', async (req, res) => {
|
|
76
80
|
const response = await fetch('https://api.facepe.ai/api/v1/sdk/token', {
|
|
77
81
|
method: 'POST',
|
|
@@ -92,17 +96,21 @@ app.get('/api/facepe-token', async (req, res) => {
|
|
|
92
96
|
### 2. React Native Component
|
|
93
97
|
|
|
94
98
|
```tsx
|
|
95
|
-
import React from 'react';
|
|
96
|
-
import { StyleSheet, View, SafeAreaView, Text } from 'react-native';
|
|
99
|
+
import React, { useRef } from 'react';
|
|
100
|
+
import { StyleSheet, View, SafeAreaView, Text, TouchableOpacity } from 'react-native';
|
|
97
101
|
import {
|
|
98
102
|
FacePeMobileProvider,
|
|
99
103
|
FacePeMobileAvatar,
|
|
104
|
+
FacePeMobileAvatarRef,
|
|
105
|
+
useFacePeAvatar,
|
|
100
106
|
} from '@facepe/widget-mobile-app';
|
|
101
107
|
|
|
102
108
|
export default function App() {
|
|
109
|
+
const avatarRef = useRef<FacePeMobileAvatarRef>(null);
|
|
110
|
+
|
|
103
111
|
return (
|
|
104
112
|
<SafeAreaView style={styles.container}>
|
|
105
|
-
<Text style={styles.title}>FacePe AI Digital Human</Text>
|
|
113
|
+
<Text style={styles.title}>FacePe AI Native Digital Human</Text>
|
|
106
114
|
|
|
107
115
|
<FacePeMobileProvider
|
|
108
116
|
apiBaseUrl="https://api.facepe.ai/api/v1/sdk"
|
|
@@ -111,41 +119,117 @@ export default function App() {
|
|
|
111
119
|
return await res.text();
|
|
112
120
|
}}
|
|
113
121
|
>
|
|
114
|
-
<
|
|
115
|
-
<FacePeMobileAvatar
|
|
116
|
-
language="en"
|
|
117
|
-
onTranscript={({ payload }) => {
|
|
118
|
-
console.log(`[${payload.role}]:`, payload.text);
|
|
119
|
-
}}
|
|
120
|
-
onSpeakStart={() => console.log('Avatar speaking')}
|
|
121
|
-
onSpeakEnd={() => console.log('Avatar stopped speaking')}
|
|
122
|
-
onOrderUpdate={({ items }) => console.log('Order update:', items)}
|
|
123
|
-
onCheckout={() => console.log('Checkout requested!')}
|
|
124
|
-
/>
|
|
125
|
-
</View>
|
|
122
|
+
<AvatarScreen avatarRef={avatarRef} />
|
|
126
123
|
</FacePeMobileProvider>
|
|
127
124
|
</SafeAreaView>
|
|
128
125
|
);
|
|
129
126
|
}
|
|
130
127
|
|
|
128
|
+
function AvatarScreen({ avatarRef }: { avatarRef: React.RefObject<FacePeMobileAvatarRef> }) {
|
|
129
|
+
const { transcripts, isAvatarSpeaking, avatarProps } = useFacePeAvatar();
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<View style={styles.content}>
|
|
133
|
+
<View style={styles.avatarContainer}>
|
|
134
|
+
<FacePeMobileAvatar
|
|
135
|
+
ref={avatarRef}
|
|
136
|
+
language="en"
|
|
137
|
+
objectFit="cover"
|
|
138
|
+
autoStart={true}
|
|
139
|
+
enableMic={true}
|
|
140
|
+
{...avatarProps}
|
|
141
|
+
/>
|
|
142
|
+
</View>
|
|
143
|
+
|
|
144
|
+
{/* Control bar */}
|
|
145
|
+
<View style={styles.controls}>
|
|
146
|
+
<TouchableOpacity
|
|
147
|
+
style={styles.button}
|
|
148
|
+
onPress={() => avatarRef.current?.toggleMic()}
|
|
149
|
+
>
|
|
150
|
+
<Text style={styles.buttonText}>Toggle Mic</Text>
|
|
151
|
+
</TouchableOpacity>
|
|
152
|
+
|
|
153
|
+
<TouchableOpacity
|
|
154
|
+
style={styles.button}
|
|
155
|
+
onPress={() => avatarRef.current?.interrupt()}
|
|
156
|
+
>
|
|
157
|
+
<Text style={styles.buttonText}>Interrupt</Text>
|
|
158
|
+
</TouchableOpacity>
|
|
159
|
+
</View>
|
|
160
|
+
|
|
161
|
+
{/* Real-time speech transcript log */}
|
|
162
|
+
<View style={styles.transcriptBox}>
|
|
163
|
+
{transcripts.slice(-3).map((item, idx) => (
|
|
164
|
+
<Text key={idx} style={styles.transcriptLine}>
|
|
165
|
+
<Text style={item.role === 'assistant' ? styles.avatarTag : styles.userTag}>
|
|
166
|
+
{item.role === 'assistant' ? 'Avatar: ' : 'You: '}
|
|
167
|
+
</Text>
|
|
168
|
+
{item.text}
|
|
169
|
+
</Text>
|
|
170
|
+
))}
|
|
171
|
+
</View>
|
|
172
|
+
</View>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
131
176
|
const styles = StyleSheet.create({
|
|
132
177
|
container: {
|
|
133
178
|
flex: 1,
|
|
134
179
|
backgroundColor: '#0F172A',
|
|
135
180
|
},
|
|
181
|
+
content: {
|
|
182
|
+
flex: 1,
|
|
183
|
+
padding: 16,
|
|
184
|
+
},
|
|
136
185
|
title: {
|
|
137
186
|
color: '#FFFFFF',
|
|
138
187
|
fontSize: 20,
|
|
139
188
|
fontWeight: 'bold',
|
|
140
189
|
textAlign: 'center',
|
|
141
|
-
marginVertical:
|
|
190
|
+
marginVertical: 12,
|
|
142
191
|
},
|
|
143
192
|
avatarContainer: {
|
|
144
193
|
flex: 1,
|
|
145
|
-
marginHorizontal: 16,
|
|
146
|
-
marginBottom: 24,
|
|
147
194
|
borderRadius: 24,
|
|
148
195
|
overflow: 'hidden',
|
|
196
|
+
backgroundColor: '#1E293B',
|
|
197
|
+
},
|
|
198
|
+
controls: {
|
|
199
|
+
flexDirection: 'row',
|
|
200
|
+
justifyContent: 'center',
|
|
201
|
+
gap: 12,
|
|
202
|
+
marginVertical: 14,
|
|
203
|
+
},
|
|
204
|
+
button: {
|
|
205
|
+
backgroundColor: '#7C3AED',
|
|
206
|
+
paddingHorizontal: 20,
|
|
207
|
+
paddingVertical: 10,
|
|
208
|
+
borderRadius: 12,
|
|
209
|
+
},
|
|
210
|
+
buttonText: {
|
|
211
|
+
color: '#FFFFFF',
|
|
212
|
+
fontWeight: '600',
|
|
213
|
+
fontSize: 14,
|
|
214
|
+
},
|
|
215
|
+
transcriptBox: {
|
|
216
|
+
backgroundColor: '#1E293B',
|
|
217
|
+
padding: 12,
|
|
218
|
+
borderRadius: 12,
|
|
219
|
+
minHeight: 80,
|
|
220
|
+
},
|
|
221
|
+
transcriptLine: {
|
|
222
|
+
color: '#E2E8F0',
|
|
223
|
+
fontSize: 13,
|
|
224
|
+
marginBottom: 4,
|
|
225
|
+
},
|
|
226
|
+
avatarTag: {
|
|
227
|
+
color: '#A78BFA',
|
|
228
|
+
fontWeight: '700',
|
|
229
|
+
},
|
|
230
|
+
userTag: {
|
|
231
|
+
color: '#60A5FA',
|
|
232
|
+
fontWeight: '700',
|
|
149
233
|
},
|
|
150
234
|
});
|
|
151
235
|
```
|
|
@@ -156,19 +240,48 @@ const styles = StyleSheet.create({
|
|
|
156
240
|
|
|
157
241
|
### `<FacePeMobileAvatar />`
|
|
158
242
|
|
|
159
|
-
| Prop | Type | Description |
|
|
160
|
-
| :--- | :--- | :--- |
|
|
161
|
-
| `avatarKey` | `string` |
|
|
162
|
-
| `language` | `string` | Spoken language (`'en'`, `'hi'`, `'es'`, `'ta'`, `'te'`, etc.). |
|
|
163
|
-
| `style` | `ViewStyle` | Style applied to container
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
243
|
+
| Prop | Type | Default | Description |
|
|
244
|
+
| :--- | :--- | :--- | :--- |
|
|
245
|
+
| `avatarKey` | `string` | *(optional)* | Specific avatar key or ID assigned to your company. |
|
|
246
|
+
| `language` | `string` | `'en'` | Spoken language code (`'en'`, `'hi'`, `'es'`, `'ta'`, `'te'`, etc.). |
|
|
247
|
+
| `style` | `ViewStyle` | `undefined` | Style applied to avatar container. |
|
|
248
|
+
| `videoStyle` | `ViewStyle` | `undefined` | Style applied to native video view. |
|
|
249
|
+
| `objectFit` | `'cover' \| 'contain'` | `'cover'` | Video aspect scaling mode. |
|
|
250
|
+
| `mirror` | `boolean` | `false` | Mirror video orientation. |
|
|
251
|
+
| `autoStart` | `boolean` | `true` | Automatically connect and start stream on mount. |
|
|
252
|
+
| `enableMic` | `boolean` | `true` | Automatically enable microphone on connect. |
|
|
253
|
+
| `onTranscript` | `function` | `undefined` | Fires when speech transcription arrives. |
|
|
254
|
+
| `onSpeakStart` | `function` | `undefined` | Fires when avatar starts speaking. |
|
|
255
|
+
| `onSpeakEnd` | `function` | `undefined` | Fires when avatar finishes speaking. |
|
|
256
|
+
| `onUserSpeakStart` | `function` | `undefined` | Fires when user voice activity begins. |
|
|
257
|
+
| `onUserSpeakEnd` | `function` | `undefined` | Fires when user voice activity ends. |
|
|
258
|
+
| `onOrderUpdate` | `function` | `undefined` | Fires when cart items or intents are recognized. |
|
|
259
|
+
| `onCheckout` | `function` | `undefined` | Fires when customer requests checkout / payment. |
|
|
260
|
+
| `onStatusChange` | `function` | `undefined` | Stream lifecycle status: `'idle' \| 'connecting' \| 'connected' \| 'reconnecting' \| 'disconnected' \| 'error'`. |
|
|
261
|
+
| `onError` | `function` | `undefined` | Stream error callback `{ code, message }`. |
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### Imperative Ref (`FacePeMobileAvatarRef`)
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
const avatarRef = useRef<FacePeMobileAvatarRef>(null);
|
|
269
|
+
|
|
270
|
+
// Start streaming session
|
|
271
|
+
await avatarRef.current?.start();
|
|
272
|
+
|
|
273
|
+
// Stop streaming session & finalize billing usage
|
|
274
|
+
await avatarRef.current?.stop();
|
|
275
|
+
|
|
276
|
+
// Toggle mic on/off (returns new state boolean)
|
|
277
|
+
const isNowMuted = await avatarRef.current?.toggleMic();
|
|
278
|
+
|
|
279
|
+
// Make avatar speak programmatically
|
|
280
|
+
await avatarRef.current?.speak("Hello! How can I assist you today?");
|
|
281
|
+
|
|
282
|
+
// Interrupt avatar speech immediately
|
|
283
|
+
await avatarRef.current?.interrupt();
|
|
284
|
+
```
|
|
172
285
|
|
|
173
286
|
---
|
|
174
287
|
|
|
@@ -1,8 +1,3 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { FacePeMobileAvatarProps } from './types';
|
|
3
|
-
export interface FacePeMobileAvatarRef {
|
|
4
|
-
start: () => Promise<void>;
|
|
5
|
-
stop: () => Promise<void>;
|
|
6
|
-
toggleMic: () => Promise<void>;
|
|
7
|
-
}
|
|
2
|
+
import type { FacePeMobileAvatarProps, FacePeMobileAvatarRef } from './types';
|
|
8
3
|
export declare const FacePeMobileAvatar: React.ForwardRefExoticComponent<FacePeMobileAvatarProps & React.RefAttributes<FacePeMobileAvatarRef>>;
|
|
@@ -1,100 +1,445 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
|
3
|
-
import { View, StyleSheet, ActivityIndicator, Text } from 'react-native';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback, } from 'react';
|
|
3
|
+
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity } from 'react-native';
|
|
4
4
|
import { useFacePeMobileContext } from './FacePeMobileProvider';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
5
|
+
// Resolve @livekit/react-native dynamically at runtime so bare/Expo projects can run smoothly
|
|
6
|
+
function getLiveKitRN() {
|
|
7
|
+
try {
|
|
8
|
+
return require('@livekit/react-native');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
let globalsRegistered = false;
|
|
15
|
+
function ensureLiveKitGlobals(LiveKitRN) {
|
|
16
|
+
if (globalsRegistered || !LiveKitRN)
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
if (typeof LiveKitRN.registerGlobals === 'function') {
|
|
20
|
+
LiveKitRN.registerGlobals();
|
|
21
|
+
globalsRegistered = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.warn('[FacePeMobileAvatar] registerGlobals warning:', err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
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) => {
|
|
29
|
+
const { token, loading: tokenLoading, error: tokenError, apiBaseUrl, refreshToken } = useFacePeMobileContext();
|
|
30
|
+
const [status, setStatus] = useState('idle');
|
|
31
|
+
const [videoTrack, setVideoTrack] = useState(null);
|
|
32
|
+
const [isMicEnabled, setIsMicEnabled] = useState(enableMic);
|
|
33
|
+
const [isAvatarSpeaking, setIsAvatarSpeaking] = useState(false);
|
|
34
|
+
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
|
|
35
|
+
const [errorMessage, setErrorMessage] = useState(null);
|
|
36
|
+
const LiveKitRN = getLiveKitRN();
|
|
37
|
+
const hasLiveKit = !!LiveKitRN;
|
|
38
|
+
const roomRef = useRef(null);
|
|
39
|
+
const usageIdRef = useRef(null);
|
|
40
|
+
const mountedRef = useRef(true);
|
|
41
|
+
const isConnectingRef = useRef(false);
|
|
42
|
+
const tokenRef = useRef(token);
|
|
43
|
+
tokenRef.current = token;
|
|
44
|
+
// Helper: Safely emit status changes
|
|
45
|
+
const updateStatus = useCallback((newStatus) => {
|
|
46
|
+
if (!mountedRef.current)
|
|
47
|
+
return;
|
|
48
|
+
setStatus(newStatus);
|
|
49
|
+
onStatusChange?.(newStatus);
|
|
50
|
+
}, [onStatusChange]);
|
|
51
|
+
// Helper: Safely publish data channel packet
|
|
52
|
+
const sendData = (msg) => {
|
|
53
|
+
const room = roomRef.current;
|
|
54
|
+
if (!room || room.state !== 'connected')
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
const jsonStr = JSON.stringify(msg);
|
|
58
|
+
let payload;
|
|
59
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
60
|
+
payload = new TextEncoder().encode(jsonStr);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const arr = new Uint8Array(jsonStr.length);
|
|
64
|
+
for (let i = 0; i < jsonStr.length; i++) {
|
|
65
|
+
arr[i] = jsonStr.charCodeAt(i);
|
|
66
|
+
}
|
|
67
|
+
payload = arr;
|
|
68
|
+
}
|
|
69
|
+
room.localParticipant.publishData(payload, { reliable: true });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.warn('[FacePeMobileAvatar] Error publishing data packet:', err);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
// Helper: Notify FacePe backend that session ended (for billing/usage)
|
|
76
|
+
const endUsage = async () => {
|
|
77
|
+
const uId = usageIdRef.current;
|
|
78
|
+
usageIdRef.current = null;
|
|
79
|
+
const currentToken = tokenRef.current;
|
|
80
|
+
if (!uId || !currentToken)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
const cleanBase = apiBaseUrl.replace(/\/+$/, '');
|
|
84
|
+
await fetch(`${cleanBase}/avatar-sessions/${uId}/end`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
Authorization: `Bearer ${currentToken}`,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// ignore background teardown error
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
// Start streaming session
|
|
97
|
+
const startStream = useCallback(async () => {
|
|
98
|
+
if (isConnectingRef.current || roomRef.current)
|
|
99
|
+
return;
|
|
100
|
+
if (!hasLiveKit) {
|
|
101
|
+
const msg = 'Please install `@livekit/react-native` and `@livekit/react-native-webrtc` to enable native AI avatar streaming.';
|
|
102
|
+
setErrorMessage(msg);
|
|
103
|
+
updateStatus('error');
|
|
104
|
+
onError?.({ code: 'MISSING_DEPENDENCY', message: msg });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
isConnectingRef.current = true;
|
|
108
|
+
setErrorMessage(null);
|
|
109
|
+
updateStatus('connecting');
|
|
110
|
+
try {
|
|
111
|
+
ensureLiveKitGlobals(LiveKitRN);
|
|
112
|
+
// Ensure active SDK auth token
|
|
113
|
+
let currentToken = tokenRef.current;
|
|
114
|
+
if (!currentToken) {
|
|
115
|
+
currentToken = await refreshToken();
|
|
116
|
+
}
|
|
117
|
+
if (!currentToken) {
|
|
118
|
+
throw new Error('Authentication token not available. Please verify your token provider.');
|
|
119
|
+
}
|
|
120
|
+
const cleanBase = apiBaseUrl.replace(/\/+$/, '');
|
|
121
|
+
// Resolve target avatar ID
|
|
122
|
+
let targetAvatarId = avatarKey;
|
|
123
|
+
if (!targetAvatarId) {
|
|
124
|
+
// If no avatar key passed, retrieve assigned avatars from /sdk/session
|
|
125
|
+
const sessionRes = await fetch(`${cleanBase}/session`, {
|
|
126
|
+
method: 'GET',
|
|
127
|
+
headers: {
|
|
128
|
+
Authorization: `Bearer ${currentToken}`,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (sessionRes.ok) {
|
|
132
|
+
const sessionData = await sessionRes.json();
|
|
133
|
+
const avatars = sessionData?.data?.avatars || sessionData?.avatars || [];
|
|
134
|
+
if (avatars.length > 0) {
|
|
135
|
+
targetAvatarId = avatars[0].id || avatars[0].key;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!targetAvatarId) {
|
|
140
|
+
throw new Error('No Avatar configured or assigned to this token.');
|
|
141
|
+
}
|
|
142
|
+
// Request live streaming session from FacePe SDK backend
|
|
143
|
+
const sessionResp = await fetch(`${cleanBase}/avatars/${targetAvatarId}/sessions`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: {
|
|
146
|
+
'Content-Type': 'application/json',
|
|
147
|
+
Authorization: `Bearer ${currentToken}`,
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({ language }),
|
|
150
|
+
});
|
|
151
|
+
if (!sessionResp.ok) {
|
|
152
|
+
const errBody = await sessionResp.json().catch(() => ({}));
|
|
153
|
+
const errMsg = errBody?.error?.message || errBody?.message || 'Failed to initialize avatar session';
|
|
154
|
+
throw new Error(errMsg);
|
|
155
|
+
}
|
|
156
|
+
const sessionPayload = await sessionResp.json();
|
|
157
|
+
const sessionInfo = sessionPayload?.data || sessionPayload;
|
|
158
|
+
usageIdRef.current = sessionInfo?.usageId || null;
|
|
159
|
+
const connection = sessionInfo?.connection;
|
|
160
|
+
if (!connection || connection.type !== 'room' || !connection.url || !connection.token) {
|
|
161
|
+
throw new Error('Avatar server did not return a valid LiveKit room connection.');
|
|
162
|
+
}
|
|
163
|
+
// Initialize LiveKit Room natively
|
|
164
|
+
const room = new LiveKitRN.Room({
|
|
165
|
+
adaptiveStream: true,
|
|
166
|
+
dynacast: true,
|
|
167
|
+
});
|
|
168
|
+
roomRef.current = room;
|
|
169
|
+
// Start Audio Session on iOS/Android
|
|
170
|
+
if (LiveKitRN.AudioSession?.startAudioSession) {
|
|
171
|
+
try {
|
|
172
|
+
await LiveKitRN.AudioSession.startAudioSession();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// non-fatal
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 1. Remote video track subscription
|
|
179
|
+
room.on(LiveKitRN.RoomEvent.TrackSubscribed, (track) => {
|
|
180
|
+
if (!mountedRef.current)
|
|
181
|
+
return;
|
|
182
|
+
const isVideo = track.kind === 'video' ||
|
|
183
|
+
(LiveKitRN.Track?.Kind?.Video && track.kind === LiveKitRN.Track.Kind.Video);
|
|
184
|
+
if (isVideo) {
|
|
185
|
+
setVideoTrack(track);
|
|
186
|
+
updateStatus('connected');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
room.on(LiveKitRN.RoomEvent.TrackUnsubscribed, (track) => {
|
|
190
|
+
if (!mountedRef.current)
|
|
191
|
+
return;
|
|
192
|
+
const isVideo = track.kind === 'video' ||
|
|
193
|
+
(LiveKitRN.Track?.Kind?.Video && track.kind === LiveKitRN.Track.Kind.Video);
|
|
194
|
+
if (isVideo) {
|
|
195
|
+
setVideoTrack(null);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// 2. Active speaker detection
|
|
199
|
+
room.on(LiveKitRN.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
200
|
+
if (!mountedRef.current)
|
|
201
|
+
return;
|
|
202
|
+
const hasAssistant = speakers.some((p) => !p.isLocal);
|
|
203
|
+
const hasUser = speakers.some((p) => p.isLocal);
|
|
204
|
+
setIsAvatarSpeaking(hasAssistant);
|
|
205
|
+
if (hasAssistant)
|
|
206
|
+
onSpeakStart?.();
|
|
207
|
+
else
|
|
208
|
+
onSpeakEnd?.();
|
|
209
|
+
setIsUserSpeaking(hasUser);
|
|
210
|
+
if (hasUser)
|
|
211
|
+
onUserSpeakStart?.();
|
|
212
|
+
else
|
|
213
|
+
onUserSpeakEnd?.();
|
|
214
|
+
});
|
|
215
|
+
// 3. Bidirectional data-channel events (transcriptions, orders, interrupts)
|
|
216
|
+
room.on(LiveKitRN.RoomEvent.DataReceived, (payload) => {
|
|
217
|
+
if (!mountedRef.current)
|
|
218
|
+
return;
|
|
219
|
+
try {
|
|
220
|
+
let text = '';
|
|
221
|
+
if (typeof TextDecoder !== 'undefined') {
|
|
222
|
+
text = new TextDecoder().decode(payload);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
text = String.fromCharCode.apply(null, Array.from(payload));
|
|
226
|
+
}
|
|
227
|
+
const msg = JSON.parse(text);
|
|
228
|
+
const type = String(msg.type ?? msg.event_type ?? '');
|
|
229
|
+
// Speaking indicators
|
|
230
|
+
if (type === 'avatar.speak_started' || type === 'response.created') {
|
|
231
|
+
setIsAvatarSpeaking(true);
|
|
232
|
+
onSpeakStart?.();
|
|
233
|
+
}
|
|
234
|
+
else if (type === 'avatar.speak_ended' || type === 'response.done') {
|
|
235
|
+
setIsAvatarSpeaking(false);
|
|
236
|
+
onSpeakEnd?.();
|
|
237
|
+
}
|
|
238
|
+
if (type === 'user.speak_started' || type === 'input_audio_buffer.speech_started') {
|
|
239
|
+
setIsUserSpeaking(true);
|
|
240
|
+
onUserSpeakStart?.();
|
|
241
|
+
}
|
|
242
|
+
else if (type === 'user.speak_ended' || type === 'input_audio_buffer.speech_stopped') {
|
|
243
|
+
setIsUserSpeaking(false);
|
|
244
|
+
onUserSpeakEnd?.();
|
|
245
|
+
}
|
|
246
|
+
// Real-time transcriptions
|
|
247
|
+
if (type === 'avatar.transcription' || type === 'response.audio_transcript.done') {
|
|
248
|
+
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
249
|
+
if (str)
|
|
250
|
+
onTranscript?.({ payload: { role: 'assistant', text: String(str).trim() } });
|
|
251
|
+
}
|
|
252
|
+
else if (type === 'user.transcription' ||
|
|
253
|
+
type === 'conversation.item.input_audio_transcription.completed') {
|
|
254
|
+
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
255
|
+
if (str)
|
|
256
|
+
onTranscript?.({ payload: { role: 'user', text: String(str).trim() } });
|
|
257
|
+
}
|
|
258
|
+
// Order & checkout intents
|
|
259
|
+
if (type === 'order.update') {
|
|
260
|
+
const items = Array.isArray(msg.items) ? msg.items : msg.payload?.items || [];
|
|
261
|
+
onOrderUpdate?.({ items });
|
|
262
|
+
}
|
|
263
|
+
else if (type === 'order.checkout') {
|
|
264
|
+
onCheckout?.();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// ignore non-json data frames
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// 4. Connection state transitions
|
|
272
|
+
room.on(LiveKitRN.RoomEvent.Disconnected, () => {
|
|
273
|
+
if (!mountedRef.current)
|
|
274
|
+
return;
|
|
275
|
+
setVideoTrack(null);
|
|
276
|
+
setIsAvatarSpeaking(false);
|
|
277
|
+
setIsUserSpeaking(false);
|
|
278
|
+
updateStatus('disconnected');
|
|
279
|
+
});
|
|
280
|
+
room.on(LiveKitRN.RoomEvent.Reconnecting, () => {
|
|
281
|
+
updateStatus('reconnecting');
|
|
282
|
+
});
|
|
283
|
+
room.on(LiveKitRN.RoomEvent.Reconnected, () => {
|
|
284
|
+
updateStatus('connected');
|
|
285
|
+
});
|
|
286
|
+
// Connect room over WebRTC
|
|
287
|
+
await room.connect(connection.url, connection.token, { autoSubscribe: true });
|
|
288
|
+
// Enable microphone capture if requested
|
|
289
|
+
if (enableMic) {
|
|
290
|
+
try {
|
|
291
|
+
await room.localParticipant.setMicrophoneEnabled(true);
|
|
292
|
+
setIsMicEnabled(true);
|
|
293
|
+
}
|
|
294
|
+
catch (micErr) {
|
|
295
|
+
console.warn('[FacePeMobileAvatar] Microphone setup warning:', micErr);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Configure server VAD and Whisper transcription
|
|
299
|
+
const sessionUpdate = {
|
|
300
|
+
type: 'session.update',
|
|
301
|
+
session: {
|
|
302
|
+
input_audio_transcription: { model: 'whisper-1' },
|
|
303
|
+
turn_detection: { type: 'server_vad' },
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
sendData(sessionUpdate);
|
|
307
|
+
// Fallback status if video arrives quickly
|
|
308
|
+
if (mountedRef.current && status !== 'connected') {
|
|
309
|
+
updateStatus('connected');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (!mountedRef.current)
|
|
314
|
+
return;
|
|
315
|
+
const msg = err?.message || 'Connection failed';
|
|
316
|
+
setErrorMessage(msg);
|
|
317
|
+
updateStatus('error');
|
|
318
|
+
onError?.({ code: 'CONNECT_ERROR', message: msg });
|
|
319
|
+
await endUsage();
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
isConnectingRef.current = false;
|
|
323
|
+
}
|
|
324
|
+
}, [
|
|
325
|
+
hasLiveKit,
|
|
326
|
+
avatarKey,
|
|
327
|
+
language,
|
|
328
|
+
enableMic,
|
|
329
|
+
apiBaseUrl,
|
|
330
|
+
refreshToken,
|
|
331
|
+
updateStatus,
|
|
332
|
+
onSpeakStart,
|
|
333
|
+
onSpeakEnd,
|
|
334
|
+
onUserSpeakStart,
|
|
335
|
+
onUserSpeakEnd,
|
|
336
|
+
onTranscript,
|
|
337
|
+
onOrderUpdate,
|
|
338
|
+
onCheckout,
|
|
339
|
+
onError,
|
|
340
|
+
]);
|
|
341
|
+
// Stop streaming session
|
|
342
|
+
const stopStream = useCallback(async () => {
|
|
343
|
+
const room = roomRef.current;
|
|
344
|
+
roomRef.current = null;
|
|
345
|
+
if (room) {
|
|
346
|
+
try {
|
|
347
|
+
if (room.localParticipant) {
|
|
348
|
+
await room.localParticipant.setMicrophoneEnabled(false);
|
|
349
|
+
}
|
|
350
|
+
await room.disconnect();
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// ignore
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
setVideoTrack(null);
|
|
357
|
+
setIsAvatarSpeaking(false);
|
|
358
|
+
setIsUserSpeaking(false);
|
|
359
|
+
updateStatus('idle');
|
|
360
|
+
await endUsage();
|
|
361
|
+
}, [updateStatus]);
|
|
362
|
+
// Imperative methods exposed via ref
|
|
24
363
|
useImperativeHandle(ref, () => ({
|
|
25
364
|
start: async () => {
|
|
26
|
-
|
|
365
|
+
await startStream();
|
|
27
366
|
},
|
|
28
367
|
stop: async () => {
|
|
29
|
-
|
|
368
|
+
await stopStream();
|
|
30
369
|
},
|
|
31
370
|
toggleMic: async () => {
|
|
32
|
-
|
|
371
|
+
const room = roomRef.current;
|
|
372
|
+
if (!room || !room.localParticipant)
|
|
373
|
+
return isMicEnabled;
|
|
374
|
+
const nextState = !isMicEnabled;
|
|
375
|
+
try {
|
|
376
|
+
await room.localParticipant.setMicrophoneEnabled(nextState);
|
|
377
|
+
setIsMicEnabled(nextState);
|
|
378
|
+
return nextState;
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.warn('[FacePeMobileAvatar] toggleMic error:', err);
|
|
382
|
+
return isMicEnabled;
|
|
383
|
+
}
|
|
33
384
|
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const raw = event?.nativeEvent?.data;
|
|
39
|
-
if (!raw)
|
|
385
|
+
setMicEnabled: async (enabled) => {
|
|
386
|
+
const room = roomRef.current;
|
|
387
|
+
if (!room || !room.localParticipant)
|
|
40
388
|
return;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
setStreamReady(true);
|
|
45
|
-
break;
|
|
46
|
-
case 'avatar:transcript':
|
|
47
|
-
onTranscript?.({ payload: msg.payload });
|
|
48
|
-
break;
|
|
49
|
-
case 'avatar:speak:start':
|
|
50
|
-
onSpeakStart?.();
|
|
51
|
-
break;
|
|
52
|
-
case 'avatar:speak:end':
|
|
53
|
-
onSpeakEnd?.();
|
|
54
|
-
break;
|
|
55
|
-
case 'user:speak:start':
|
|
56
|
-
onUserSpeakStart?.();
|
|
57
|
-
break;
|
|
58
|
-
case 'user:speak:end':
|
|
59
|
-
onUserSpeakEnd?.();
|
|
60
|
-
break;
|
|
61
|
-
case 'order:update':
|
|
62
|
-
onOrderUpdate?.({ items: msg.payload?.items || [] });
|
|
63
|
-
break;
|
|
64
|
-
case 'order:checkout':
|
|
65
|
-
onCheckout?.();
|
|
66
|
-
break;
|
|
67
|
-
case 'avatar:error':
|
|
68
|
-
onError?.({ code: msg.code || 'UNKNOWN', message: msg.message || 'Stream error' });
|
|
69
|
-
break;
|
|
70
|
-
default:
|
|
71
|
-
break;
|
|
389
|
+
try {
|
|
390
|
+
await room.localParticipant.setMicrophoneEnabled(enabled);
|
|
391
|
+
setIsMicEnabled(enabled);
|
|
72
392
|
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
console.warn('[FacePeMobileAvatar] setMicEnabled error:', err);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
speak: async (text) => {
|
|
398
|
+
sendData({ event_type: 'avatar.speak_text', text });
|
|
399
|
+
},
|
|
400
|
+
interrupt: async () => {
|
|
401
|
+
sendData({ event_type: 'avatar.interrupt' });
|
|
402
|
+
setIsAvatarSpeaking(false);
|
|
403
|
+
onSpeakEnd?.();
|
|
404
|
+
},
|
|
405
|
+
get status() {
|
|
406
|
+
return status;
|
|
407
|
+
},
|
|
408
|
+
get isMicEnabled() {
|
|
409
|
+
return isMicEnabled;
|
|
410
|
+
},
|
|
411
|
+
}), [startStream, stopStream, isMicEnabled, status, onSpeakEnd]);
|
|
412
|
+
// Auto-start on mount once token is resolved
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
mountedRef.current = true;
|
|
415
|
+
if (autoStart && token && !tokenLoading && !tokenError && status === 'idle') {
|
|
416
|
+
void startStream();
|
|
73
417
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
418
|
+
return () => {
|
|
419
|
+
mountedRef.current = false;
|
|
420
|
+
void stopStream();
|
|
421
|
+
};
|
|
422
|
+
}, [autoStart, token, tokenLoading, tokenError]);
|
|
423
|
+
// Handle token loading
|
|
424
|
+
if (tokenLoading) {
|
|
425
|
+
return (_jsxs(View, { style: [styles.container, styles.center, style], children: [_jsx(ActivityIndicator, { size: "large", color: "#8B5CF6" }), _jsx(Text, { style: styles.loadingText, children: "Authenticating with FacePe AI\u2026" })] }));
|
|
80
426
|
}
|
|
81
|
-
|
|
82
|
-
|
|
427
|
+
// Handle token error
|
|
428
|
+
if (tokenError) {
|
|
429
|
+
return (_jsxs(View, { style: [styles.container, styles.center, style], children: [_jsx(Text, { style: styles.errorTitle, children: "Authentication Failed" }), _jsx(Text, { style: styles.errorText, children: tokenError.message }), _jsx(TouchableOpacity, { style: styles.retryButton, onPress: () => void refreshToken(true), children: _jsx(Text, { style: styles.retryText, children: "Retry" }) })] }));
|
|
83
430
|
}
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
WebViewComponent = require('react-native-webview').WebView;
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
// WebView not installed in project
|
|
431
|
+
// If @livekit/react-native is not installed in the host app, render native guidance banner
|
|
432
|
+
if (!hasLiveKit) {
|
|
433
|
+
return (_jsxs(View, { style: [styles.container, styles.center, styles.fallbackBox, style], children: [_jsx(View, { style: styles.badge, children: _jsx(Text, { style: styles.badgeText, children: "Native Streaming Required" }) }), _jsx(Text, { style: styles.noticeTitle, children: "FacePe Mobile SDK" }), _jsx(Text, { style: styles.noticeBody, children: "To stream interactive 3D digital avatars natively without webviews, please install LiveKit WebRTC peer dependencies:" }), _jsx(Text, { style: styles.codeSnippet, children: "npm install @livekit/react-native @livekit/react-native-webrtc" })] }));
|
|
91
434
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
435
|
+
// Native LiveKit VideoView component
|
|
436
|
+
const VideoViewComponent = LiveKitRN.VideoView;
|
|
437
|
+
return (_jsxs(View, { style: [styles.container, style], children: [videoTrack && VideoViewComponent ? (_jsx(VideoViewComponent, { videoTrack: videoTrack, style: [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
|
+
? 'Connecting to FacePe AI Avatar…'
|
|
439
|
+
: '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
|
+
styles.speakerDot,
|
|
441
|
+
isAvatarSpeaking ? styles.dotAvatar : styles.dotUser,
|
|
442
|
+
] }), _jsx(Text, { style: styles.speakerText, children: isAvatarSpeaking ? 'Avatar speaking' : 'Listening to you…' })] }))] }));
|
|
98
443
|
});
|
|
99
444
|
FacePeMobileAvatar.displayName = 'FacePeMobileAvatar';
|
|
100
445
|
const styles = StyleSheet.create({
|
|
@@ -102,12 +447,13 @@ const styles = StyleSheet.create({
|
|
|
102
447
|
width: '100%',
|
|
103
448
|
height: '100%',
|
|
104
449
|
backgroundColor: '#0F172A',
|
|
105
|
-
borderRadius:
|
|
450
|
+
borderRadius: 20,
|
|
106
451
|
overflow: 'hidden',
|
|
452
|
+
position: 'relative',
|
|
107
453
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
454
|
+
video: {
|
|
455
|
+
width: '100%',
|
|
456
|
+
height: '100%',
|
|
111
457
|
},
|
|
112
458
|
center: {
|
|
113
459
|
justifyContent: 'center',
|
|
@@ -115,41 +461,116 @@ const styles = StyleSheet.create({
|
|
|
115
461
|
padding: 24,
|
|
116
462
|
},
|
|
117
463
|
loadingText: {
|
|
118
|
-
marginTop:
|
|
464
|
+
marginTop: 14,
|
|
119
465
|
color: '#94A3B8',
|
|
120
|
-
fontSize:
|
|
466
|
+
fontSize: 14,
|
|
121
467
|
fontWeight: '500',
|
|
122
468
|
},
|
|
123
|
-
|
|
469
|
+
errorTitle: {
|
|
124
470
|
color: '#EF4444',
|
|
471
|
+
fontSize: 16,
|
|
472
|
+
fontWeight: '700',
|
|
473
|
+
marginBottom: 6,
|
|
474
|
+
},
|
|
475
|
+
errorText: {
|
|
476
|
+
color: '#F87171',
|
|
125
477
|
fontSize: 13,
|
|
126
478
|
textAlign: 'center',
|
|
479
|
+
lineHeight: 18,
|
|
480
|
+
marginBottom: 16,
|
|
481
|
+
},
|
|
482
|
+
retryButton: {
|
|
483
|
+
backgroundColor: '#7C3AED',
|
|
484
|
+
paddingHorizontal: 20,
|
|
485
|
+
paddingVertical: 10,
|
|
486
|
+
borderRadius: 12,
|
|
487
|
+
},
|
|
488
|
+
retryText: {
|
|
489
|
+
color: '#FFFFFF',
|
|
490
|
+
fontWeight: '600',
|
|
491
|
+
fontSize: 13,
|
|
492
|
+
},
|
|
493
|
+
startButton: {
|
|
494
|
+
backgroundColor: '#7C3AED',
|
|
495
|
+
paddingHorizontal: 24,
|
|
496
|
+
paddingVertical: 14,
|
|
497
|
+
borderRadius: 14,
|
|
498
|
+
},
|
|
499
|
+
startText: {
|
|
500
|
+
color: '#FFFFFF',
|
|
501
|
+
fontWeight: '700',
|
|
502
|
+
fontSize: 15,
|
|
127
503
|
},
|
|
128
504
|
fallbackBox: {
|
|
129
505
|
backgroundColor: '#1E293B',
|
|
130
506
|
borderColor: '#334155',
|
|
131
507
|
borderWidth: 1,
|
|
132
508
|
},
|
|
509
|
+
badge: {
|
|
510
|
+
backgroundColor: 'rgba(124, 58, 237, 0.15)',
|
|
511
|
+
borderColor: '#7C3AED',
|
|
512
|
+
borderWidth: 1,
|
|
513
|
+
paddingHorizontal: 12,
|
|
514
|
+
paddingVertical: 4,
|
|
515
|
+
borderRadius: 20,
|
|
516
|
+
marginBottom: 12,
|
|
517
|
+
},
|
|
518
|
+
badgeText: {
|
|
519
|
+
color: '#A78BFA',
|
|
520
|
+
fontSize: 11,
|
|
521
|
+
fontWeight: '600',
|
|
522
|
+
},
|
|
133
523
|
noticeTitle: {
|
|
134
524
|
color: '#FFFFFF',
|
|
135
525
|
fontWeight: '700',
|
|
136
|
-
fontSize:
|
|
526
|
+
fontSize: 17,
|
|
137
527
|
marginBottom: 8,
|
|
138
528
|
},
|
|
139
529
|
noticeBody: {
|
|
140
530
|
color: '#94A3B8',
|
|
141
531
|
fontSize: 13,
|
|
142
532
|
textAlign: 'center',
|
|
143
|
-
lineHeight:
|
|
144
|
-
marginBottom:
|
|
533
|
+
lineHeight: 19,
|
|
534
|
+
marginBottom: 16,
|
|
145
535
|
},
|
|
146
536
|
codeSnippet: {
|
|
147
|
-
color: '#
|
|
537
|
+
color: '#C4B5FD',
|
|
148
538
|
backgroundColor: '#0F172A',
|
|
539
|
+
paddingHorizontal: 14,
|
|
540
|
+
paddingVertical: 10,
|
|
541
|
+
borderRadius: 10,
|
|
542
|
+
fontFamily: 'monospace',
|
|
543
|
+
fontSize: 12,
|
|
544
|
+
textAlign: 'center',
|
|
545
|
+
},
|
|
546
|
+
speakerPill: {
|
|
547
|
+
position: 'absolute',
|
|
548
|
+
top: 16,
|
|
549
|
+
alignSelf: 'center',
|
|
550
|
+
backgroundColor: 'rgba(15, 23, 42, 0.85)',
|
|
551
|
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
552
|
+
borderWidth: 1,
|
|
553
|
+
flexDirection: 'row',
|
|
554
|
+
alignItems: 'center',
|
|
149
555
|
paddingHorizontal: 12,
|
|
150
556
|
paddingVertical: 6,
|
|
151
|
-
borderRadius:
|
|
152
|
-
|
|
557
|
+
borderRadius: 20,
|
|
558
|
+
},
|
|
559
|
+
speakerDot: {
|
|
560
|
+
width: 8,
|
|
561
|
+
height: 8,
|
|
562
|
+
borderRadius: 4,
|
|
563
|
+
marginRight: 8,
|
|
564
|
+
},
|
|
565
|
+
dotAvatar: {
|
|
566
|
+
backgroundColor: '#10B981',
|
|
567
|
+
},
|
|
568
|
+
dotUser: {
|
|
569
|
+
backgroundColor: '#3B82F6',
|
|
570
|
+
},
|
|
571
|
+
speakerText: {
|
|
572
|
+
color: '#F8FAFC',
|
|
153
573
|
fontSize: 12,
|
|
574
|
+
fontWeight: '500',
|
|
154
575
|
},
|
|
155
576
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type AvatarGender = 'female' | 'male';
|
|
2
|
-
export type
|
|
2
|
+
export type FacePeConnectionStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
|
|
3
3
|
export interface FacePeMobileTheme {
|
|
4
4
|
primaryColor?: string;
|
|
5
5
|
backgroundColor?: string;
|
|
@@ -23,11 +23,9 @@ export interface FacePeMobileConfig {
|
|
|
23
23
|
avatarKey?: string;
|
|
24
24
|
/** Default language code (e.g., 'en', 'es', 'hi') */
|
|
25
25
|
language?: string;
|
|
26
|
-
/** Integration mode: 'auto', 'native', or 'webview' */
|
|
27
|
-
mode?: StreamMode;
|
|
28
26
|
/** Theme styling overrides */
|
|
29
27
|
theme?: FacePeMobileTheme;
|
|
30
|
-
/**
|
|
28
|
+
/** Auto start streaming when component mounts (default: true) */
|
|
31
29
|
autoStart?: boolean;
|
|
32
30
|
}
|
|
33
31
|
export interface TokenProviderParams {
|
|
@@ -35,12 +33,22 @@ export interface TokenProviderParams {
|
|
|
35
33
|
}
|
|
36
34
|
export type TokenProvider = (params: TokenProviderParams) => Promise<string>;
|
|
37
35
|
export interface FacePeMobileAvatarProps {
|
|
38
|
-
/**
|
|
36
|
+
/** Specific avatar identifier or key override */
|
|
39
37
|
avatarKey?: string;
|
|
40
38
|
/** Language to converse in ('en', 'es', 'hi', etc.) */
|
|
41
39
|
language?: string;
|
|
42
|
-
/** Custom style
|
|
40
|
+
/** Custom container style */
|
|
43
41
|
style?: any;
|
|
42
|
+
/** Custom video style */
|
|
43
|
+
videoStyle?: any;
|
|
44
|
+
/** Video scaling: 'cover' or 'contain' (default: 'cover') */
|
|
45
|
+
objectFit?: 'cover' | 'contain';
|
|
46
|
+
/** Whether to mirror the video stream (default: false) */
|
|
47
|
+
mirror?: boolean;
|
|
48
|
+
/** Auto start streaming when component mounts (default: true) */
|
|
49
|
+
autoStart?: boolean;
|
|
50
|
+
/** Whether microphone should be automatically enabled on stream start (default: true) */
|
|
51
|
+
enableMic?: boolean;
|
|
44
52
|
/** Called when real-time speech transcription arrives */
|
|
45
53
|
onTranscript?: (event: {
|
|
46
54
|
payload: TranscriptPayload;
|
|
@@ -49,7 +57,7 @@ export interface FacePeMobileAvatarProps {
|
|
|
49
57
|
onSpeakStart?: () => void;
|
|
50
58
|
/** Called when avatar finishes speaking */
|
|
51
59
|
onSpeakEnd?: () => void;
|
|
52
|
-
/** Called when user starts speaking (detected by VAD) */
|
|
60
|
+
/** Called when user starts speaking (detected by server VAD) */
|
|
53
61
|
onUserSpeakStart?: () => void;
|
|
54
62
|
/** Called when user finishes speaking */
|
|
55
63
|
onUserSpeakEnd?: () => void;
|
|
@@ -59,9 +67,29 @@ export interface FacePeMobileAvatarProps {
|
|
|
59
67
|
}) => void;
|
|
60
68
|
/** Called when customer says checkout / pay */
|
|
61
69
|
onCheckout?: () => void;
|
|
70
|
+
/** Called when stream connection status changes */
|
|
71
|
+
onStatusChange?: (status: FacePeConnectionStatus) => void;
|
|
62
72
|
/** Called when an error occurs */
|
|
63
73
|
onError?: (event: {
|
|
64
74
|
code: string;
|
|
65
75
|
message: string;
|
|
66
76
|
}) => void;
|
|
67
77
|
}
|
|
78
|
+
export interface FacePeMobileAvatarRef {
|
|
79
|
+
/** Start avatar streaming session */
|
|
80
|
+
start: () => Promise<void>;
|
|
81
|
+
/** Stop streaming and close session */
|
|
82
|
+
stop: () => Promise<void>;
|
|
83
|
+
/** Toggle user microphone on/off, returning new enabled state */
|
|
84
|
+
toggleMic: () => Promise<boolean>;
|
|
85
|
+
/** Explicitly set microphone state */
|
|
86
|
+
setMicEnabled: (enabled: boolean) => Promise<void>;
|
|
87
|
+
/** Programmatically make avatar speak a phrase */
|
|
88
|
+
speak: (text: string) => Promise<void>;
|
|
89
|
+
/** Interrupt avatar speech immediately */
|
|
90
|
+
interrupt: () => Promise<void>;
|
|
91
|
+
/** Current connection status */
|
|
92
|
+
readonly status: FacePeConnectionStatus;
|
|
93
|
+
/** Whether microphone is currently enabled */
|
|
94
|
+
readonly isMicEnabled: boolean;
|
|
95
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type { TranscriptPayload, OrderItem } from './types';
|
|
1
|
+
import type { TranscriptPayload, OrderItem, FacePeConnectionStatus, FacePeMobileAvatarProps } from './types';
|
|
2
2
|
export interface UseFacePeAvatarResult {
|
|
3
3
|
token: string | null;
|
|
4
4
|
loading: boolean;
|
|
5
5
|
error: Error | null;
|
|
6
|
+
status: FacePeConnectionStatus;
|
|
6
7
|
transcripts: TranscriptPayload[];
|
|
7
8
|
isAvatarSpeaking: boolean;
|
|
8
9
|
isUserSpeaking: boolean;
|
|
9
10
|
orderItems: OrderItem[];
|
|
10
11
|
refreshToken: (force?: boolean) => Promise<string | null>;
|
|
11
12
|
clearTranscripts: () => void;
|
|
13
|
+
avatarProps: Pick<FacePeMobileAvatarProps, 'onTranscript' | 'onSpeakStart' | 'onSpeakEnd' | 'onUserSpeakStart' | 'onUserSpeakEnd' | 'onOrderUpdate' | 'onStatusChange'>;
|
|
12
14
|
}
|
|
13
15
|
export declare function useFacePeAvatar(): UseFacePeAvatarResult;
|
package/dist/useFacePeAvatar.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
2
|
import { useFacePeMobileContext } from './FacePeMobileProvider';
|
|
3
3
|
export function useFacePeAvatar() {
|
|
4
4
|
const { token, loading, error, refreshToken } = useFacePeMobileContext();
|
|
5
|
+
const [status, setStatus] = useState('idle');
|
|
5
6
|
const [transcripts, setTranscripts] = useState([]);
|
|
6
7
|
const [isAvatarSpeaking, setIsAvatarSpeaking] = useState(false);
|
|
7
8
|
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
|
|
@@ -9,15 +10,55 @@ export function useFacePeAvatar() {
|
|
|
9
10
|
const clearTranscripts = useCallback(() => {
|
|
10
11
|
setTranscripts([]);
|
|
11
12
|
}, []);
|
|
13
|
+
const handleTranscript = useCallback((event) => {
|
|
14
|
+
setTranscripts((prev) => [...prev, event.payload]);
|
|
15
|
+
}, []);
|
|
16
|
+
const handleSpeakStart = useCallback(() => {
|
|
17
|
+
setIsAvatarSpeaking(true);
|
|
18
|
+
}, []);
|
|
19
|
+
const handleSpeakEnd = useCallback(() => {
|
|
20
|
+
setIsAvatarSpeaking(false);
|
|
21
|
+
}, []);
|
|
22
|
+
const handleUserSpeakStart = useCallback(() => {
|
|
23
|
+
setIsUserSpeaking(true);
|
|
24
|
+
}, []);
|
|
25
|
+
const handleUserSpeakEnd = useCallback(() => {
|
|
26
|
+
setIsUserSpeaking(false);
|
|
27
|
+
}, []);
|
|
28
|
+
const handleOrderUpdate = useCallback((event) => {
|
|
29
|
+
setOrderItems(event.items);
|
|
30
|
+
}, []);
|
|
31
|
+
const handleStatusChange = useCallback((newStatus) => {
|
|
32
|
+
setStatus(newStatus);
|
|
33
|
+
}, []);
|
|
34
|
+
const avatarProps = useMemo(() => ({
|
|
35
|
+
onTranscript: handleTranscript,
|
|
36
|
+
onSpeakStart: handleSpeakStart,
|
|
37
|
+
onSpeakEnd: handleSpeakEnd,
|
|
38
|
+
onUserSpeakStart: handleUserSpeakStart,
|
|
39
|
+
onUserSpeakEnd: handleUserSpeakEnd,
|
|
40
|
+
onOrderUpdate: handleOrderUpdate,
|
|
41
|
+
onStatusChange: handleStatusChange,
|
|
42
|
+
}), [
|
|
43
|
+
handleTranscript,
|
|
44
|
+
handleSpeakStart,
|
|
45
|
+
handleSpeakEnd,
|
|
46
|
+
handleUserSpeakStart,
|
|
47
|
+
handleUserSpeakEnd,
|
|
48
|
+
handleOrderUpdate,
|
|
49
|
+
handleStatusChange,
|
|
50
|
+
]);
|
|
12
51
|
return {
|
|
13
52
|
token,
|
|
14
53
|
loading,
|
|
15
54
|
error,
|
|
55
|
+
status,
|
|
16
56
|
transcripts,
|
|
17
57
|
isAvatarSpeaking,
|
|
18
58
|
isUserSpeaking,
|
|
19
59
|
orderItems,
|
|
20
60
|
refreshToken,
|
|
21
61
|
clearTranscripts,
|
|
62
|
+
avatarProps,
|
|
22
63
|
};
|
|
23
64
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@facepe/widget-mobile-app",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "FacePe AI 3D Video Avatar Live Streaming SDK for React Native (iOS & Android)",
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"ios",
|
|
22
22
|
"android",
|
|
23
23
|
"webrtc",
|
|
24
|
-
"streaming"
|
|
24
|
+
"streaming",
|
|
25
|
+
"digital-human"
|
|
25
26
|
],
|
|
26
27
|
"author": "FacePe AI",
|
|
27
28
|
"license": "MIT",
|
|
@@ -30,6 +31,8 @@
|
|
|
30
31
|
"build": "tsc"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
34
|
+
"@livekit/react-native": ">=2.0.0",
|
|
35
|
+
"@livekit/react-native-webrtc": ">=124.0.0",
|
|
33
36
|
"react": ">=18.0.0",
|
|
34
37
|
"react-native": ">=0.70.0"
|
|
35
38
|
},
|
|
@@ -39,9 +42,6 @@
|
|
|
39
42
|
},
|
|
40
43
|
"@livekit/react-native-webrtc": {
|
|
41
44
|
"optional": true
|
|
42
|
-
},
|
|
43
|
-
"react-native-webview": {
|
|
44
|
-
"optional": true
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|