@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 CHANGED
@@ -1,32 +1,35 @@
1
1
  # @facepe/widget-mobile-app
2
2
 
3
- The official **FacePe AI 3D Avatar Live Streaming SDK** for **React Native** (supporting both **iOS** and **Android**).
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 real-time speech recognition, natural audio dialog, and event callbacks.
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
- - **⚡ Low-Latency Live Video & Audio**: Real-time WebRTC media streaming for avatar rendering.
12
- - **🗣️ Two-Way Audio Conversation**: Bidirectional voice talk with interrupt handling & VAD.
13
- - **🍎 Full iOS Support**: Compatible with iOS 13.0+ (iPhone & iPad).
14
- - **🤖 Full Android Support**: Compatible with Android 7.0+ (API 24+).
15
- - **💬 Real-Time Transcripts**: Captions and user transcripts via `onTranscript`.
16
- - **🛒 Conversational Ordering**: Extract order items and payment intents directly in your app.
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 / Expo project:
23
+ In your React Native project:
23
24
 
24
25
  ```bash
25
- npm install @facepe/widget-mobile-app react-native-webview
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-webview
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 camera:
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>We need access to your camera for interactive features.</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 secrets are never exposed on mobile:
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
- // POST /api/facepe-token
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
- <View style={styles.avatarContainer}>
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: 16,
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` | Optional specific avatar key assigned to your company. |
162
- | `language` | `string` | Spoken language (`'en'`, `'hi'`, `'es'`, `'ta'`, `'te'`, etc.). |
163
- | `style` | `ViewStyle` | Style applied to container element. |
164
- | `onTranscript` | `function` | Fires whenever the user or avatar utters a phrase. |
165
- | `onSpeakStart` | `function` | Fires when the avatar begins vocal response. |
166
- | `onSpeakEnd` | `function` | Fires when the avatar stops speaking. |
167
- | `onUserSpeakStart` | `function` | Fires when the user starts speaking (voice activity). |
168
- | `onUserSpeakEnd` | `function` | Fires when the user pauses / stops speaking. |
169
- | `onOrderUpdate` | `function` | Fires when items are detected in conversation. |
170
- | `onCheckout` | `function` | Fires when customer signals intent to pay. |
171
- | `onError` | `function` | Error callback `{ code, message }`. |
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
- export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', style, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onError, }, ref) => {
6
- const { token, loading, error, apiBaseUrl } = useFacePeMobileContext();
7
- const [streamReady, setStreamReady] = useState(false);
8
- const webViewRef = useRef(null);
9
- // Build the mobile streaming URL
10
- const streamUrl = (() => {
11
- if (!token)
12
- return '';
13
- const base = apiBaseUrl.replace(/\/sdk$/, '').replace(/\/api\/v1$/, '');
14
- const search = new URLSearchParams({
15
- token,
16
- embedded: '1',
17
- mobile: '1',
18
- lang: language,
19
- });
20
- if (avatarKey)
21
- search.set('avatar', avatarKey);
22
- return `${base}/?${search.toString()}`;
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
- webViewRef.current?.injectJavaScript?.('window.FacePeBridge && window.FacePeBridge.start()');
365
+ await startStream();
27
366
  },
28
367
  stop: async () => {
29
- webViewRef.current?.injectJavaScript?.('window.FacePeBridge && window.FacePeBridge.stop()');
368
+ await stopStream();
30
369
  },
31
370
  toggleMic: async () => {
32
- webViewRef.current?.injectJavaScript?.('window.FacePeBridge && window.FacePeBridge.toggleMic()');
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
- // Handle incoming messages from bridge (transcripts, order updates, speaking states)
36
- const handleBridgeMessage = (event) => {
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
- const msg = JSON.parse(raw);
42
- switch (msg.type) {
43
- case 'avatar:ready':
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
- catch {
75
- // ignore parse error
76
- }
77
- };
78
- if (loading) {
79
- return (_jsxs(View, { style: [styles.container, styles.center, style], children: [_jsx(ActivityIndicator, { size: "large", color: "#7c3aed" }), _jsx(Text, { style: styles.loadingText, children: "Connecting to FacePe AI Avatar\u2026" })] }));
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
- if (error) {
82
- return (_jsx(View, { style: [styles.container, styles.center, style], children: _jsxs(Text, { style: styles.errorText, children: ["Unable to connect to Avatar: ", error.message] }) }));
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
- // Try dynamically requiring react-native-webview
85
- let WebViewComponent = null;
86
- try {
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
- if (!WebViewComponent) {
93
- return (_jsxs(View, { style: [styles.container, styles.center, styles.fallbackBox, style], children: [_jsx(Text, { style: styles.noticeTitle, children: "FacePe Mobile SDK" }), _jsx(Text, { style: styles.noticeBody, children: "Please install `react-native-webview` or `@livekit/react-native` in your project to render the live stream:" }), _jsx(Text, { style: styles.codeSnippet, children: "npm install react-native-webview" })] }));
94
- }
95
- return (_jsx(View, { style: [styles.container, style], children: _jsx(WebViewComponent, { ref: webViewRef, source: { uri: streamUrl }, style: styles.webview, onMessage: handleBridgeMessage, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, javaScriptEnabled: true, domStorageEnabled: true, cacheEnabled: true,
96
- // iOS WebRTC permissions
97
- originWhitelist: ['*'], allowsAirPlayForMediaPlayback: false, bounces: false, scrollEnabled: false }) }));
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: 16,
450
+ borderRadius: 20,
106
451
  overflow: 'hidden',
452
+ position: 'relative',
107
453
  },
108
- webview: {
109
- flex: 1,
110
- backgroundColor: 'transparent',
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: 12,
464
+ marginTop: 14,
119
465
  color: '#94A3B8',
120
- fontSize: 13,
466
+ fontSize: 14,
121
467
  fontWeight: '500',
122
468
  },
123
- errorText: {
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: 16,
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: 18,
144
- marginBottom: 12,
533
+ lineHeight: 19,
534
+ marginBottom: 16,
145
535
  },
146
536
  codeSnippet: {
147
- color: '#A78BFA',
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: 8,
152
- fontFamily: 'monospace',
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 StreamMode = 'native' | 'webview' | 'auto';
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
- /** Optional audio autoplay unblocking flag */
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
- /** Optional avatar key override */
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 object */
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;
@@ -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.0.0",
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": {