@facepe/widget-mobile-app 1.1.0 β†’ 1.2.0

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