@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.
@@ -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
- 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 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
- webViewRef.current?.injectJavaScript?.('window.FacePeBridge && window.FacePeBridge.start()');
483
+ await startStream();
27
484
  },
28
485
  stop: async () => {
29
- webViewRef.current?.injectJavaScript?.('window.FacePeBridge && window.FacePeBridge.stop()');
486
+ await stopStream();
30
487
  },
31
488
  toggleMic: async () => {
32
- webViewRef.current?.injectJavaScript?.('window.FacePeBridge && window.FacePeBridge.toggleMic()');
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
- // 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)
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
- 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" })] }));
80
- }
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] }) }));
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
- catch {
90
- // WebView not installed in project
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
- 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" })] }));
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
- 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 }) }));
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: 16,
600
+ borderRadius: 20,
106
601
  overflow: 'hidden',
602
+ position: 'relative',
107
603
  },
108
- webview: {
604
+ video: {
109
605
  flex: 1,
110
- backgroundColor: 'transparent',
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: 12,
615
+ marginTop: 14,
119
616
  color: '#94A3B8',
120
- fontSize: 13,
617
+ fontSize: 14,
121
618
  fontWeight: '500',
122
619
  },
123
- errorText: {
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: 16,
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: 18,
144
- marginBottom: 12,
684
+ lineHeight: 19,
685
+ marginBottom: 16,
145
686
  },
146
687
  codeSnippet: {
147
- color: '#A78BFA',
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: 8,
152
- fontFamily: 'monospace',
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
  });