@facepe/widget-mobile-app 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -176
- package/dist/FacePeMobileAvatar.d.ts +1 -6
- package/dist/FacePeMobileAvatar.js +667 -95
- package/dist/FacePeMobileProvider.d.ts +2 -2
- package/dist/types.d.ts +35 -7
- package/dist/useFacePeAvatar.d.ts +3 -1
- package/dist/useFacePeAvatar.js +42 -1
- package/package.json +13 -5
|
@@ -1,100 +1,595 @@
|
|
|
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 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.
|
|
9
|
+
function getLiveKitRN() {
|
|
10
|
+
try {
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let globalsRegistered = false;
|
|
47
|
+
function ensureLiveKitGlobals(LiveKitRN) {
|
|
48
|
+
if (globalsRegistered || !LiveKitRN)
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
if (typeof LiveKitRN.registerGlobals === 'function') {
|
|
52
|
+
LiveKitRN.registerGlobals();
|
|
53
|
+
globalsRegistered = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.warn('[FacePeMobileAvatar] registerGlobals warning:', err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, }, ref) => {
|
|
61
|
+
const { token, loading: tokenLoading, error: tokenError, apiBaseUrl, refreshToken } = useFacePeMobileContext();
|
|
62
|
+
const [status, setStatus] = useState('idle');
|
|
63
|
+
const [videoTrack, setVideoTrack] = useState(null);
|
|
64
|
+
const [isMicEnabled, setIsMicEnabled] = useState(enableMic);
|
|
65
|
+
const [isAvatarSpeaking, setIsAvatarSpeaking] = useState(false);
|
|
66
|
+
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
|
|
67
|
+
const [errorMessage, setErrorMessage] = useState(null);
|
|
68
|
+
const LiveKitRN = getLiveKitRN();
|
|
69
|
+
const hasLiveKit = !!LiveKitRN;
|
|
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);
|
|
74
|
+
const usageIdRef = useRef(null);
|
|
75
|
+
const mountedRef = useRef(true);
|
|
76
|
+
const isConnectingRef = useRef(false);
|
|
77
|
+
const tokenRef = useRef(token);
|
|
78
|
+
tokenRef.current = token;
|
|
79
|
+
// Helper: Safely emit status changes
|
|
80
|
+
const updateStatus = useCallback((newStatus) => {
|
|
81
|
+
if (!mountedRef.current)
|
|
82
|
+
return;
|
|
83
|
+
setStatus(newStatus);
|
|
84
|
+
onStatusChange?.(newStatus);
|
|
85
|
+
}, [onStatusChange]);
|
|
86
|
+
// Helper: Safely publish data channel packet
|
|
87
|
+
const sendData = (msg) => {
|
|
88
|
+
const room = roomRef.current;
|
|
89
|
+
if (!room || room.state !== 'connected')
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
const jsonStr = JSON.stringify(msg);
|
|
93
|
+
let payload;
|
|
94
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
95
|
+
payload = new TextEncoder().encode(jsonStr);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const arr = new Uint8Array(jsonStr.length);
|
|
99
|
+
for (let i = 0; i < jsonStr.length; i++) {
|
|
100
|
+
arr[i] = jsonStr.charCodeAt(i);
|
|
101
|
+
}
|
|
102
|
+
payload = arr;
|
|
103
|
+
}
|
|
104
|
+
room.localParticipant.publishData(payload, { reliable: true });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
console.warn('[FacePeMobileAvatar] Error publishing data packet:', err);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// Helper: Notify FacePe backend that session ended (for billing/usage)
|
|
111
|
+
const endUsage = async () => {
|
|
112
|
+
const uId = usageIdRef.current;
|
|
113
|
+
usageIdRef.current = null;
|
|
114
|
+
const currentToken = tokenRef.current;
|
|
115
|
+
if (!uId || !currentToken)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
const cleanBase = apiBaseUrl.replace(/\/+$/, '');
|
|
119
|
+
await fetch(`${cleanBase}/avatar-sessions/${uId}/end`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
Authorization: `Bearer ${currentToken}`,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// ignore background teardown error
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
// Start streaming session
|
|
132
|
+
const startStream = useCallback(async () => {
|
|
133
|
+
if (isConnectingRef.current || roomRef.current || streamClientRef.current)
|
|
134
|
+
return;
|
|
135
|
+
if (!hasLiveKit) {
|
|
136
|
+
const msg = 'Please install `@livekit/react-native` and `@livekit/react-native-webrtc` to enable native AI avatar streaming.';
|
|
137
|
+
setErrorMessage(msg);
|
|
138
|
+
updateStatus('error');
|
|
139
|
+
onError?.({ code: 'MISSING_DEPENDENCY', message: msg });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
isConnectingRef.current = true;
|
|
143
|
+
setErrorMessage(null);
|
|
144
|
+
updateStatus('connecting');
|
|
145
|
+
try {
|
|
146
|
+
ensureLiveKitGlobals(LiveKitRN);
|
|
147
|
+
// Ensure active SDK auth token
|
|
148
|
+
let currentToken = tokenRef.current;
|
|
149
|
+
if (!currentToken) {
|
|
150
|
+
currentToken = await refreshToken();
|
|
151
|
+
}
|
|
152
|
+
if (!currentToken) {
|
|
153
|
+
throw new Error('Authentication token not available. Please verify your token provider.');
|
|
154
|
+
}
|
|
155
|
+
const cleanBase = apiBaseUrl.replace(/\/+$/, '');
|
|
156
|
+
// Resolve target avatar ID
|
|
157
|
+
let targetAvatarId = avatarKey;
|
|
158
|
+
if (!targetAvatarId) {
|
|
159
|
+
// No avatar given: use the first one assigned to this token's user.
|
|
160
|
+
const listRes = await fetch(`${cleanBase}/avatars`, {
|
|
161
|
+
method: 'GET',
|
|
162
|
+
headers: {
|
|
163
|
+
Authorization: `Bearer ${currentToken}`,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
if (listRes.ok) {
|
|
167
|
+
const listData = await listRes.json();
|
|
168
|
+
const avatars = listData?.data?.avatars || [];
|
|
169
|
+
if (avatars.length > 0) {
|
|
170
|
+
targetAvatarId = avatars[0].id;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!targetAvatarId) {
|
|
175
|
+
throw new Error('No Avatar configured or assigned to this token.');
|
|
176
|
+
}
|
|
177
|
+
// Request live streaming session from FacePe SDK backend
|
|
178
|
+
const sessionResp = await fetch(`${cleanBase}/avatars/${targetAvatarId}/sessions`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: {
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
Authorization: `Bearer ${currentToken}`,
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({ language }),
|
|
185
|
+
});
|
|
186
|
+
if (!sessionResp.ok) {
|
|
187
|
+
const errBody = await sessionResp.json().catch(() => ({}));
|
|
188
|
+
const errMsg = errBody?.error?.message || errBody?.message || 'Failed to initialize avatar session';
|
|
189
|
+
throw new Error(errMsg);
|
|
190
|
+
}
|
|
191
|
+
const sessionPayload = await sessionResp.json();
|
|
192
|
+
const sessionInfo = sessionPayload?.data || sessionPayload;
|
|
193
|
+
usageIdRef.current = sessionInfo?.usageId || null;
|
|
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
|
+
}
|
|
248
|
+
if (!connection || connection.type !== 'room' || !connection.url || !connection.token) {
|
|
249
|
+
throw new Error('Avatar server did not return a valid room connection.');
|
|
250
|
+
}
|
|
251
|
+
// Initialize LiveKit Room natively
|
|
252
|
+
const room = new LiveKitRN.Room({
|
|
253
|
+
adaptiveStream: false, // Ensure full video frames arrive without adaptive downscaling
|
|
254
|
+
dynacast: false,
|
|
255
|
+
});
|
|
256
|
+
roomRef.current = room;
|
|
257
|
+
// Start Audio Session on iOS/Android
|
|
258
|
+
if (LiveKitRN.AudioSession?.startAudioSession) {
|
|
259
|
+
try {
|
|
260
|
+
await LiveKitRN.AudioSession.startAudioSession();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// non-fatal
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// 1. Remote video track subscription
|
|
267
|
+
room.on(LiveKitRN.RoomEvent.TrackSubscribed, (track) => {
|
|
268
|
+
if (!mountedRef.current)
|
|
269
|
+
return;
|
|
270
|
+
const isVideo = track.kind === 'video' ||
|
|
271
|
+
(LiveKitRN.Track?.Kind?.Video && track.kind === LiveKitRN.Track.Kind.Video);
|
|
272
|
+
if (isVideo) {
|
|
273
|
+
setVideoTrack(track);
|
|
274
|
+
updateStatus('connected');
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
room.on(LiveKitRN.RoomEvent.TrackUnsubscribed, (track) => {
|
|
278
|
+
if (!mountedRef.current)
|
|
279
|
+
return;
|
|
280
|
+
const isVideo = track.kind === 'video' ||
|
|
281
|
+
(LiveKitRN.Track?.Kind?.Video && track.kind === LiveKitRN.Track.Kind.Video);
|
|
282
|
+
if (isVideo) {
|
|
283
|
+
setVideoTrack(null);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
// 2. Active speaker detection
|
|
287
|
+
room.on(LiveKitRN.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
288
|
+
if (!mountedRef.current)
|
|
289
|
+
return;
|
|
290
|
+
const hasAssistant = speakers.some((p) => !p.isLocal);
|
|
291
|
+
const hasUser = speakers.some((p) => p.isLocal);
|
|
292
|
+
setIsAvatarSpeaking(hasAssistant);
|
|
293
|
+
if (hasAssistant)
|
|
294
|
+
onSpeakStart?.();
|
|
295
|
+
else
|
|
296
|
+
onSpeakEnd?.();
|
|
297
|
+
setIsUserSpeaking(hasUser);
|
|
298
|
+
if (hasUser)
|
|
299
|
+
onUserSpeakStart?.();
|
|
300
|
+
else
|
|
301
|
+
onUserSpeakEnd?.();
|
|
302
|
+
});
|
|
303
|
+
// 3. Bidirectional data-channel events (transcriptions, orders, interrupts)
|
|
304
|
+
room.on(LiveKitRN.RoomEvent.DataReceived, (payload) => {
|
|
305
|
+
if (!mountedRef.current)
|
|
306
|
+
return;
|
|
307
|
+
try {
|
|
308
|
+
let text = '';
|
|
309
|
+
if (typeof TextDecoder !== 'undefined') {
|
|
310
|
+
text = new TextDecoder().decode(payload);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
text = String.fromCharCode.apply(null, Array.from(payload));
|
|
314
|
+
}
|
|
315
|
+
const msg = JSON.parse(text);
|
|
316
|
+
const type = String(msg.type ?? msg.event_type ?? '');
|
|
317
|
+
// Speaking indicators
|
|
318
|
+
if (type === 'avatar.speak_started' || type === 'response.created') {
|
|
319
|
+
setIsAvatarSpeaking(true);
|
|
320
|
+
onSpeakStart?.();
|
|
321
|
+
}
|
|
322
|
+
else if (type === 'avatar.speak_ended' || type === 'response.done') {
|
|
323
|
+
setIsAvatarSpeaking(false);
|
|
324
|
+
onSpeakEnd?.();
|
|
325
|
+
}
|
|
326
|
+
if (type === 'user.speak_started' || type === 'input_audio_buffer.speech_started') {
|
|
327
|
+
setIsUserSpeaking(true);
|
|
328
|
+
onUserSpeakStart?.();
|
|
329
|
+
}
|
|
330
|
+
else if (type === 'user.speak_ended' || type === 'input_audio_buffer.speech_stopped') {
|
|
331
|
+
setIsUserSpeaking(false);
|
|
332
|
+
onUserSpeakEnd?.();
|
|
333
|
+
}
|
|
334
|
+
// Real-time transcriptions
|
|
335
|
+
if (type === 'avatar.transcription' || type === 'response.audio_transcript.done') {
|
|
336
|
+
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
337
|
+
if (str)
|
|
338
|
+
onTranscript?.({ payload: { role: 'assistant', text: String(str).trim() } });
|
|
339
|
+
}
|
|
340
|
+
else if (type === 'user.transcription' ||
|
|
341
|
+
type === 'conversation.item.input_audio_transcription.completed') {
|
|
342
|
+
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
343
|
+
if (str)
|
|
344
|
+
onTranscript?.({ payload: { role: 'user', text: String(str).trim() } });
|
|
345
|
+
}
|
|
346
|
+
// Order & checkout intents
|
|
347
|
+
if (type === 'order.update') {
|
|
348
|
+
const items = Array.isArray(msg.items) ? msg.items : msg.payload?.items || [];
|
|
349
|
+
onOrderUpdate?.({ items });
|
|
350
|
+
}
|
|
351
|
+
else if (type === 'order.checkout') {
|
|
352
|
+
onCheckout?.();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// ignore non-json data frames
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
// 4. Connection state transitions
|
|
360
|
+
room.on(LiveKitRN.RoomEvent.Disconnected, () => {
|
|
361
|
+
if (!mountedRef.current)
|
|
362
|
+
return;
|
|
363
|
+
setVideoTrack(null);
|
|
364
|
+
setIsAvatarSpeaking(false);
|
|
365
|
+
setIsUserSpeaking(false);
|
|
366
|
+
updateStatus('disconnected');
|
|
367
|
+
});
|
|
368
|
+
room.on(LiveKitRN.RoomEvent.Reconnecting, () => {
|
|
369
|
+
updateStatus('reconnecting');
|
|
370
|
+
});
|
|
371
|
+
room.on(LiveKitRN.RoomEvent.Reconnected, () => {
|
|
372
|
+
updateStatus('connected');
|
|
373
|
+
});
|
|
374
|
+
// Connect room over WebRTC
|
|
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
|
+
}
|
|
395
|
+
// Enable microphone capture if requested
|
|
396
|
+
if (enableMic) {
|
|
397
|
+
try {
|
|
398
|
+
await room.localParticipant.setMicrophoneEnabled(true);
|
|
399
|
+
setIsMicEnabled(true);
|
|
400
|
+
}
|
|
401
|
+
catch (micErr) {
|
|
402
|
+
console.warn('[FacePeMobileAvatar] Microphone setup warning:', micErr);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Configure server VAD and Whisper transcription
|
|
406
|
+
const sessionUpdate = {
|
|
407
|
+
type: 'session.update',
|
|
408
|
+
session: {
|
|
409
|
+
input_audio_transcription: { model: 'whisper-1' },
|
|
410
|
+
turn_detection: { type: 'server_vad' },
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
sendData(sessionUpdate);
|
|
414
|
+
// Fallback status if video arrives quickly
|
|
415
|
+
if (mountedRef.current && status !== 'connected') {
|
|
416
|
+
updateStatus('connected');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
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);
|
|
426
|
+
if (!mountedRef.current)
|
|
427
|
+
return;
|
|
428
|
+
const msg = err?.message || 'Connection failed';
|
|
429
|
+
setErrorMessage(msg);
|
|
430
|
+
updateStatus('error');
|
|
431
|
+
onError?.({ code: 'CONNECT_ERROR', message: msg });
|
|
432
|
+
await endUsage();
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
isConnectingRef.current = false;
|
|
436
|
+
}
|
|
437
|
+
}, [
|
|
438
|
+
hasLiveKit,
|
|
439
|
+
avatarKey,
|
|
440
|
+
language,
|
|
441
|
+
enableMic,
|
|
442
|
+
apiBaseUrl,
|
|
443
|
+
refreshToken,
|
|
444
|
+
updateStatus,
|
|
445
|
+
onSpeakStart,
|
|
446
|
+
onSpeakEnd,
|
|
447
|
+
onUserSpeakStart,
|
|
448
|
+
onUserSpeakEnd,
|
|
449
|
+
onTranscript,
|
|
450
|
+
onOrderUpdate,
|
|
451
|
+
onCheckout,
|
|
452
|
+
onError,
|
|
453
|
+
]);
|
|
454
|
+
// Stop streaming session
|
|
455
|
+
const stopStream = useCallback(async () => {
|
|
456
|
+
const room = roomRef.current;
|
|
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);
|
|
463
|
+
if (room) {
|
|
464
|
+
try {
|
|
465
|
+
if (room.localParticipant) {
|
|
466
|
+
await room.localParticipant.setMicrophoneEnabled(false);
|
|
467
|
+
}
|
|
468
|
+
await room.disconnect();
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// ignore
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
setVideoTrack(null);
|
|
475
|
+
setIsAvatarSpeaking(false);
|
|
476
|
+
setIsUserSpeaking(false);
|
|
477
|
+
updateStatus('idle');
|
|
478
|
+
await endUsage();
|
|
479
|
+
}, [updateStatus]);
|
|
480
|
+
// Imperative methods exposed via ref
|
|
24
481
|
useImperativeHandle(ref, () => ({
|
|
25
482
|
start: async () => {
|
|
26
|
-
|
|
483
|
+
await startStream();
|
|
27
484
|
},
|
|
28
485
|
stop: async () => {
|
|
29
|
-
|
|
486
|
+
await stopStream();
|
|
30
487
|
},
|
|
31
488
|
toggleMic: async () => {
|
|
32
|
-
|
|
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
|
+
}
|
|
499
|
+
const room = roomRef.current;
|
|
500
|
+
if (!room || !room.localParticipant)
|
|
501
|
+
return isMicEnabled;
|
|
502
|
+
const nextState = !isMicEnabled;
|
|
503
|
+
try {
|
|
504
|
+
await room.localParticipant.setMicrophoneEnabled(nextState);
|
|
505
|
+
setIsMicEnabled(nextState);
|
|
506
|
+
return nextState;
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
console.warn('[FacePeMobileAvatar] toggleMic error:', err);
|
|
510
|
+
return isMicEnabled;
|
|
511
|
+
}
|
|
33
512
|
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
40
521
|
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;
|
|
72
522
|
}
|
|
523
|
+
const room = roomRef.current;
|
|
524
|
+
if (!room || !room.localParticipant)
|
|
525
|
+
return;
|
|
526
|
+
try {
|
|
527
|
+
await room.localParticipant.setMicrophoneEnabled(enabled);
|
|
528
|
+
setIsMicEnabled(enabled);
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
console.warn('[FacePeMobileAvatar] setMicEnabled error:', err);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
speak: async (text) => {
|
|
535
|
+
if (streamClientRef.current) {
|
|
536
|
+
await streamClientRef.current.talk(text).catch(() => undefined);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
sendData({ event_type: 'avatar.speak_text', text });
|
|
540
|
+
},
|
|
541
|
+
interrupt: async () => {
|
|
542
|
+
if (streamClientRef.current)
|
|
543
|
+
streamClientRef.current.interruptPersona();
|
|
544
|
+
else
|
|
545
|
+
sendData({ event_type: 'avatar.interrupt' });
|
|
546
|
+
setIsAvatarSpeaking(false);
|
|
547
|
+
onSpeakEnd?.();
|
|
548
|
+
},
|
|
549
|
+
get status() {
|
|
550
|
+
return status;
|
|
551
|
+
},
|
|
552
|
+
get isMicEnabled() {
|
|
553
|
+
return isMicEnabled;
|
|
554
|
+
},
|
|
555
|
+
}), [startStream, stopStream, isMicEnabled, status, onSpeakEnd]);
|
|
556
|
+
// Auto-start on mount once token is resolved
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
mountedRef.current = true;
|
|
559
|
+
if (autoStart && token && !tokenLoading && !tokenError && status === 'idle') {
|
|
560
|
+
void startStream();
|
|
73
561
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return (_jsx(View, { style: [styles.container, styles.center, style], children: _jsxs(Text, { style: styles.errorText, children: ["Unable to connect to Avatar: ", error.message] }) }));
|
|
83
|
-
}
|
|
84
|
-
// Try dynamically requiring react-native-webview
|
|
85
|
-
let WebViewComponent = null;
|
|
86
|
-
try {
|
|
87
|
-
WebViewComponent = require('react-native-webview').WebView;
|
|
562
|
+
return () => {
|
|
563
|
+
mountedRef.current = false;
|
|
564
|
+
void stopStream();
|
|
565
|
+
};
|
|
566
|
+
}, [autoStart, token, tokenLoading, tokenError]);
|
|
567
|
+
// Handle token loading
|
|
568
|
+
if (tokenLoading) {
|
|
569
|
+
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" })] }));
|
|
88
570
|
}
|
|
89
|
-
|
|
90
|
-
|
|
571
|
+
// Handle token error
|
|
572
|
+
if (tokenError) {
|
|
573
|
+
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" }) })] }));
|
|
91
574
|
}
|
|
92
|
-
|
|
93
|
-
|
|
575
|
+
// If @livekit/react-native is not installed in the host app, render native guidance banner
|
|
576
|
+
if (!hasLiveKit) {
|
|
577
|
+
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" })] }));
|
|
94
578
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
579
|
+
// Native LiveKit VideoView component
|
|
580
|
+
const VideoViewComponent = LiveKitRN.VideoView;
|
|
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'
|
|
588
|
+
? 'Connecting to FacePe AI Avatar…'
|
|
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: [
|
|
590
|
+
styles.speakerDot,
|
|
591
|
+
isAvatarSpeaking ? styles.dotAvatar : styles.dotUser,
|
|
592
|
+
] }), _jsx(Text, { style: styles.speakerText, children: isAvatarSpeaking ? 'Avatar speaking' : 'Listening to you…' })] }))] }));
|
|
98
593
|
});
|
|
99
594
|
FacePeMobileAvatar.displayName = 'FacePeMobileAvatar';
|
|
100
595
|
const styles = StyleSheet.create({
|
|
@@ -102,12 +597,14 @@ const styles = StyleSheet.create({
|
|
|
102
597
|
width: '100%',
|
|
103
598
|
height: '100%',
|
|
104
599
|
backgroundColor: '#0F172A',
|
|
105
|
-
borderRadius:
|
|
600
|
+
borderRadius: 20,
|
|
106
601
|
overflow: 'hidden',
|
|
602
|
+
position: 'relative',
|
|
107
603
|
},
|
|
108
|
-
|
|
604
|
+
video: {
|
|
109
605
|
flex: 1,
|
|
110
|
-
|
|
606
|
+
width: '100%',
|
|
607
|
+
height: '100%',
|
|
111
608
|
},
|
|
112
609
|
center: {
|
|
113
610
|
justifyContent: 'center',
|
|
@@ -115,41 +612,116 @@ const styles = StyleSheet.create({
|
|
|
115
612
|
padding: 24,
|
|
116
613
|
},
|
|
117
614
|
loadingText: {
|
|
118
|
-
marginTop:
|
|
615
|
+
marginTop: 14,
|
|
119
616
|
color: '#94A3B8',
|
|
120
|
-
fontSize:
|
|
617
|
+
fontSize: 14,
|
|
121
618
|
fontWeight: '500',
|
|
122
619
|
},
|
|
123
|
-
|
|
620
|
+
errorTitle: {
|
|
124
621
|
color: '#EF4444',
|
|
622
|
+
fontSize: 16,
|
|
623
|
+
fontWeight: '700',
|
|
624
|
+
marginBottom: 6,
|
|
625
|
+
},
|
|
626
|
+
errorText: {
|
|
627
|
+
color: '#F87171',
|
|
125
628
|
fontSize: 13,
|
|
126
629
|
textAlign: 'center',
|
|
630
|
+
lineHeight: 18,
|
|
631
|
+
marginBottom: 16,
|
|
632
|
+
},
|
|
633
|
+
retryButton: {
|
|
634
|
+
backgroundColor: '#7C3AED',
|
|
635
|
+
paddingHorizontal: 20,
|
|
636
|
+
paddingVertical: 10,
|
|
637
|
+
borderRadius: 12,
|
|
638
|
+
},
|
|
639
|
+
retryText: {
|
|
640
|
+
color: '#FFFFFF',
|
|
641
|
+
fontWeight: '600',
|
|
642
|
+
fontSize: 13,
|
|
643
|
+
},
|
|
644
|
+
startButton: {
|
|
645
|
+
backgroundColor: '#7C3AED',
|
|
646
|
+
paddingHorizontal: 24,
|
|
647
|
+
paddingVertical: 14,
|
|
648
|
+
borderRadius: 14,
|
|
649
|
+
},
|
|
650
|
+
startText: {
|
|
651
|
+
color: '#FFFFFF',
|
|
652
|
+
fontWeight: '700',
|
|
653
|
+
fontSize: 15,
|
|
127
654
|
},
|
|
128
655
|
fallbackBox: {
|
|
129
656
|
backgroundColor: '#1E293B',
|
|
130
657
|
borderColor: '#334155',
|
|
131
658
|
borderWidth: 1,
|
|
132
659
|
},
|
|
660
|
+
badge: {
|
|
661
|
+
backgroundColor: 'rgba(124, 58, 237, 0.15)',
|
|
662
|
+
borderColor: '#7C3AED',
|
|
663
|
+
borderWidth: 1,
|
|
664
|
+
paddingHorizontal: 12,
|
|
665
|
+
paddingVertical: 4,
|
|
666
|
+
borderRadius: 20,
|
|
667
|
+
marginBottom: 12,
|
|
668
|
+
},
|
|
669
|
+
badgeText: {
|
|
670
|
+
color: '#A78BFA',
|
|
671
|
+
fontSize: 11,
|
|
672
|
+
fontWeight: '600',
|
|
673
|
+
},
|
|
133
674
|
noticeTitle: {
|
|
134
675
|
color: '#FFFFFF',
|
|
135
676
|
fontWeight: '700',
|
|
136
|
-
fontSize:
|
|
677
|
+
fontSize: 17,
|
|
137
678
|
marginBottom: 8,
|
|
138
679
|
},
|
|
139
680
|
noticeBody: {
|
|
140
681
|
color: '#94A3B8',
|
|
141
682
|
fontSize: 13,
|
|
142
683
|
textAlign: 'center',
|
|
143
|
-
lineHeight:
|
|
144
|
-
marginBottom:
|
|
684
|
+
lineHeight: 19,
|
|
685
|
+
marginBottom: 16,
|
|
145
686
|
},
|
|
146
687
|
codeSnippet: {
|
|
147
|
-
color: '#
|
|
688
|
+
color: '#C4B5FD',
|
|
148
689
|
backgroundColor: '#0F172A',
|
|
690
|
+
paddingHorizontal: 14,
|
|
691
|
+
paddingVertical: 10,
|
|
692
|
+
borderRadius: 10,
|
|
693
|
+
fontFamily: 'monospace',
|
|
694
|
+
fontSize: 12,
|
|
695
|
+
textAlign: 'center',
|
|
696
|
+
},
|
|
697
|
+
speakerPill: {
|
|
698
|
+
position: 'absolute',
|
|
699
|
+
top: 16,
|
|
700
|
+
alignSelf: 'center',
|
|
701
|
+
backgroundColor: 'rgba(15, 23, 42, 0.85)',
|
|
702
|
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
703
|
+
borderWidth: 1,
|
|
704
|
+
flexDirection: 'row',
|
|
705
|
+
alignItems: 'center',
|
|
149
706
|
paddingHorizontal: 12,
|
|
150
707
|
paddingVertical: 6,
|
|
151
|
-
borderRadius:
|
|
152
|
-
|
|
708
|
+
borderRadius: 20,
|
|
709
|
+
},
|
|
710
|
+
speakerDot: {
|
|
711
|
+
width: 8,
|
|
712
|
+
height: 8,
|
|
713
|
+
borderRadius: 4,
|
|
714
|
+
marginRight: 8,
|
|
715
|
+
},
|
|
716
|
+
dotAvatar: {
|
|
717
|
+
backgroundColor: '#10B981',
|
|
718
|
+
},
|
|
719
|
+
dotUser: {
|
|
720
|
+
backgroundColor: '#3B82F6',
|
|
721
|
+
},
|
|
722
|
+
speakerText: {
|
|
723
|
+
color: '#F8FAFC',
|
|
153
724
|
fontSize: 12,
|
|
725
|
+
fontWeight: '500',
|
|
154
726
|
},
|
|
155
727
|
});
|