@facepe/widget-mobile-app 1.5.0 → 1.5.2

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
@@ -80,7 +80,14 @@ Exchange your `accessKey` and `secretKey` on your secure backend (Node.js / Expr
80
80
 
81
81
  ```javascript
82
82
  // Express server route: GET /api/facepe-token
83
+ // A token lasts 2 hours (expiresIn, in seconds): keep one and fetch a new one a
84
+ // minute before it expires, or when the app asks with ?refresh=1 (after a 401).
85
+ let cached = null; // { token, until }
86
+
83
87
  app.get('/api/facepe-token', async (req, res) => {
88
+ if (cached && cached.until > Date.now() && req.query.refresh !== '1') {
89
+ return res.send(cached.token);
90
+ }
84
91
  const response = await fetch('https://api.facepe.ai/api/v1/sdk/token', {
85
92
  method: 'POST',
86
93
  headers: { 'Content-Type': 'application/json' },
@@ -91,10 +98,16 @@ app.get('/api/facepe-token', async (req, res) => {
91
98
  }),
92
99
  });
93
100
  const data = await response.json();
94
- res.send(data.data.token);
101
+ if (!response.ok) return res.status(response.status).json(data.error);
102
+ cached = { token: data.data.token, until: Date.now() + (data.data.expiresIn - 60) * 1000 };
103
+ res.send(cached.token);
95
104
  });
96
105
  ```
97
106
 
107
+ **Tokens last 2 hours.** The SDK renews them by itself: it calls `getToken({ forceRefresh: true })`
108
+ about 5 minutes before the token expires, and again (then retries once) if FacePe answers
109
+ `401 SESSION_EXPIRED`. Renewal is silent: a conversation in progress is not interrupted.
110
+
98
111
  ---
99
112
 
100
113
  ### 2. React Native Component
@@ -118,8 +131,9 @@ export default function App() {
118
131
 
119
132
  <FacePeMobileProvider
120
133
  apiBaseUrl="https://api.facepe.ai/api/v1/sdk"
121
- getToken={async () => {
122
- const res = await fetch('https://your-backend.com/api/facepe-token');
134
+ getToken={async ({ forceRefresh }) => {
135
+ // Tokens last 2 hours: forceRefresh asks your server for a new one.
136
+ const res = await fetch('https://your-backend.com/api/facepe-token' + (forceRefresh ? '?refresh=1' : ''));
123
137
  return await res.text();
124
138
  }}
125
139
  >
@@ -253,6 +267,7 @@ const styles = StyleSheet.create({
253
267
  | `videoStyle` | `ViewStyle` | `undefined` | Style applied to native video view. |
254
268
  | `objectFit` | `'cover' \| 'contain'` | `'cover'` | Video aspect scaling mode. |
255
269
  | `mirror` | `boolean` | `false` | Mirror video orientation. |
270
+ | `showSpeakingIndicator` | `boolean` | `true` | The "Avatar speaking" / "Listening to you…" label on the video. Set `false` to show it yourself with `onSpeakStart`/`onSpeakEnd` and `onUserSpeakStart`/`onUserSpeakEnd`. |
256
271
  | `autoStart` | `boolean` | `true` | Automatically connect and start stream on mount. |
257
272
  | `posterUrl` | `string \| null` | avatar's portrait | Picture shown until the video starts. Defaults to the avatar's portrait from FacePe; pass `null` for none. |
258
273
  | `enableMic` | `boolean` | `true` | Automatically enable microphone on connect. |
@@ -67,14 +67,26 @@ const joinNumberParts = (text) => text.replace(/\d+(?:[ \t]*[-\u2010\u2011\u2013
67
67
  return digits.length >= 7 ? digits : m;
68
68
  });
69
69
  /**
70
- * Calls the gateway's agent route; a FacePe server from before agents has
71
- * only the avatar route (404), so that one is tried next.
70
+ * A gateway call with the app's token. Answered 401 (the token expired or was
71
+ * revoked), it asks the app for a fresh token once and tries again. A server
72
+ * from before agents has only /avatars…, tried when /agents… is missing.
72
73
  */
73
- async function gatewayFetch(base, agentPath, avatarPath, init) {
74
- const res = await fetch(`${base}${agentPath}`, init);
75
- return res.status === 404 ? fetch(`${base}${avatarPath}`, init) : res;
74
+ async function gatewayFetch(base, agentPath, avatarPath, init, tokenFor) {
75
+ const send = async (path, force) => {
76
+ const headers = { ...(init.headers ?? {}) };
77
+ const token = await tokenFor(force);
78
+ if (token)
79
+ headers.Authorization = `Bearer ${token}`;
80
+ return fetch(`${base}${path}`, { ...init, headers });
81
+ };
82
+ const attempt = async (force) => {
83
+ const res = await send(agentPath, force);
84
+ return res.status === 404 ? send(avatarPath, force) : res;
85
+ };
86
+ const res = await attempt(false);
87
+ return res.status === 401 ? attempt(true) : res;
76
88
  }
77
- export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, onConversationEnd, posterUrl, }, ref) => {
89
+ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, showSpeakingIndicator = true, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, onConversationEnd, posterUrl, }, ref) => {
78
90
  const { token, loading: tokenLoading, error: tokenError, apiBaseUrl, refreshToken } = useFacePeMobileContext();
79
91
  // The agent to talk to (`avatarKey` is its name from before agents).
80
92
  const chosenAgent = agentId ?? avatarKey;
@@ -87,9 +99,7 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
87
99
  let cancelled = false;
88
100
  void (async () => {
89
101
  try {
90
- const res = await gatewayFetch(apiBaseUrl.replace(/\/+$/, ''), '/agents', '/avatars', {
91
- headers: { Authorization: `Bearer ${token}` },
92
- });
102
+ const res = await gatewayFetch(apiBaseUrl.replace(/\/+$/, ''), '/agents', '/avatars', {}, tokenFor);
93
103
  if (!res.ok)
94
104
  return;
95
105
  const data = (await res.json())?.data;
@@ -151,15 +161,14 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
151
161
  const transcript = conv.lines.map(({ role, text, at }) => ({ role, text, at }));
152
162
  void (async () => {
153
163
  let form = null;
154
- const token = tokenRef.current;
155
- if (conv.conversationId && transcript.length > 0 && token) {
164
+ if (conv.conversationId && transcript.length > 0) {
156
165
  try {
157
166
  const id = encodeURIComponent(conv.conversationId);
158
167
  const res = await gatewayFetch(apiBaseUrl.replace(/\/+$/, ''), `/agent-sessions/${id}/form`, `/avatar-sessions/${id}/form`, {
159
168
  method: 'POST',
160
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
169
+ headers: { 'Content-Type': 'application/json' },
161
170
  body: JSON.stringify({ transcript: transcript.map(({ role, text }) => ({ role, text })) }),
162
- });
171
+ }, tokenFor);
163
172
  if (res.ok)
164
173
  form = (await res.json())?.data?.form ?? null;
165
174
  }
@@ -192,6 +201,10 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
192
201
  const isConnectingRef = useRef(false);
193
202
  const tokenRef = useRef(token);
194
203
  tokenRef.current = token;
204
+ // The app's token for gateway calls, or (force) a fresh one after a 401.
205
+ const refreshRef = useRef(refreshToken);
206
+ refreshRef.current = refreshToken;
207
+ const tokenFor = useCallback(async (force) => (force ? refreshRef.current(true) : tokenRef.current ?? refreshRef.current()), []);
195
208
  // Helper: Safely emit status changes
196
209
  const updateStatus = useCallback((newStatus) => {
197
210
  if (!mountedRef.current)
@@ -227,18 +240,14 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
227
240
  const endUsage = async () => {
228
241
  const uId = usageIdRef.current;
229
242
  usageIdRef.current = null;
230
- const currentToken = tokenRef.current;
231
- if (!uId || !currentToken)
243
+ if (!uId)
232
244
  return;
233
245
  try {
234
246
  const cleanBase = apiBaseUrl.replace(/\/+$/, '');
235
247
  await gatewayFetch(cleanBase, `/agent-sessions/${uId}/end`, `/avatar-sessions/${uId}/end`, {
236
248
  method: 'POST',
237
- headers: {
238
- 'Content-Type': 'application/json',
239
- Authorization: `Bearer ${currentToken}`,
240
- },
241
- });
249
+ headers: { 'Content-Type': 'application/json' },
250
+ }, tokenFor);
242
251
  }
243
252
  catch {
244
253
  // ignore background teardown error
@@ -273,12 +282,7 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
273
282
  let targetAgentId = chosenAgent;
274
283
  if (!targetAgentId) {
275
284
  // No agent given: use the first one assigned to this token's user.
276
- const listRes = await gatewayFetch(cleanBase, '/agents', '/avatars', {
277
- method: 'GET',
278
- headers: {
279
- Authorization: `Bearer ${currentToken}`,
280
- },
281
- });
285
+ const listRes = await gatewayFetch(cleanBase, '/agents', '/avatars', { method: 'GET' }, tokenFor);
282
286
  if (listRes.ok) {
283
287
  const listData = (await listRes.json())?.data;
284
288
  const agents = listData?.agents || listData?.avatars || [];
@@ -294,12 +298,9 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
294
298
  const agentPath = encodeURIComponent(targetAgentId);
295
299
  const sessionResp = await gatewayFetch(cleanBase, `/agents/${agentPath}/sessions`, `/avatars/${agentPath}/sessions`, {
296
300
  method: 'POST',
297
- headers: {
298
- 'Content-Type': 'application/json',
299
- Authorization: `Bearer ${currentToken}`,
300
- },
301
+ headers: { 'Content-Type': 'application/json' },
301
302
  body: JSON.stringify({ language }),
302
- });
303
+ }, tokenFor);
303
304
  if (!sessionResp.ok) {
304
305
  const errBody = await sessionResp.json().catch(() => ({}));
305
306
  const errMsg = errBody?.error?.message || errBody?.message || 'Failed to start the conversation';
@@ -722,7 +723,7 @@ export const FacePeMobileAvatar = forwardRef(({ agentId, avatarKey, language = '
722
723
  // size — the video got no height and stayed invisible.
723
724
  style: StyleSheet.flatten([styles.video, videoStyle]), objectFit: objectFit, mirror: mirror })) : (_jsxs(View, { style: [styles.container, styles.center, !!poster && (status === 'idle' || status === 'disconnected') && styles.overPoster], children: [poster && _jsx(Image, { source: { uri: poster }, style: StyleSheet.absoluteFill, resizeMode: "cover" }), poster && status !== 'idle' && status !== 'disconnected' && _jsx(View, { style: [StyleSheet.absoluteFill, styles.dim] }), status === 'connecting' || status === 'reconnecting' || status === 'connected' ? (
724
725
  // connected: the stream is up and the first video frame is on its way.
725
- _jsxs(_Fragment, { children: [_jsx(ActivityIndicator, { size: "large", color: "#8B5CF6" }), _jsx(Text, { style: styles.loadingText, children: status === 'reconnecting' ? 'Reconnecting stream…' : 'Connecting to FacePe AI Avatar…' })] })) : 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: [
726
+ _jsxs(_Fragment, { children: [_jsx(ActivityIndicator, { size: "large", color: "#8B5CF6" }), _jsx(Text, { style: styles.loadingText, children: status === 'reconnecting' ? 'Reconnecting stream…' : 'Connecting to FacePe AI Avatar…' })] })) : 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" }) }))] })), showSpeakingIndicator && status === 'connected' && (isAvatarSpeaking || isUserSpeaking) && (_jsxs(View, { style: styles.speakerPill, children: [_jsx(View, { style: [
726
727
  styles.speakerDot,
727
728
  isAvatarSpeaking ? styles.dotAvatar : styles.dotUser,
728
729
  ] }), _jsx(Text, { style: styles.speakerText, children: isAvatarSpeaking ? 'Avatar speaking' : 'Listening to you…' })] }))] }));
@@ -1,25 +1,50 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useState, useEffect, useRef, useMemo } from 'react';
3
3
  const FacePeContext = createContext(null);
4
+ /** When a token runs out (its JWT `exp`, in seconds), or null if it doesn't say. */
5
+ function expiryOf(token) {
6
+ try {
7
+ const part = token.split('.')[1] ?? '';
8
+ const b64 = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '=');
9
+ const exp = JSON.parse(globalThis.atob(b64))?.exp;
10
+ return typeof exp === 'number' ? exp : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** Fetch a new token this long before the current one runs out. */
17
+ const REFRESH_EARLY_MS = 5 * 60 * 1000;
4
18
  export function FacePeMobileProvider({ children, getToken, apiBaseUrl = 'https://api.facepe.ai/api/v1/sdk', config = {}, }) {
5
19
  const [token, setToken] = useState(null);
6
20
  const [loading, setLoading] = useState(true);
7
21
  const [error, setError] = useState(null);
8
22
  const tokenPromiseRef = useRef(null);
23
+ const tokenRef = useRef(null);
24
+ // A refresh with a token already in hand is silent: no "loading" (which would
25
+ // replace a live call with the sign-in screen), and on failure the current
26
+ // token stays — the next call's 401 asks again.
9
27
  const refreshToken = async (force = false) => {
10
28
  if (tokenPromiseRef.current && !force) {
11
29
  return tokenPromiseRef.current;
12
30
  }
13
- setLoading(true);
14
- setError(null);
31
+ const silent = tokenRef.current !== null;
32
+ if (!silent) {
33
+ setLoading(true);
34
+ setError(null);
35
+ }
15
36
  const promise = (async () => {
16
37
  try {
17
38
  const fetched = await getToken({ forceRefresh: force });
39
+ tokenRef.current = fetched;
18
40
  setToken(fetched);
41
+ setError(null);
19
42
  setLoading(false);
20
43
  return fetched;
21
44
  }
22
45
  catch (err) {
46
+ if (silent)
47
+ return null;
23
48
  const normalized = err instanceof Error ? err : new Error(String(err));
24
49
  setError(normalized);
25
50
  setLoading(false);
@@ -35,6 +60,19 @@ export function FacePeMobileProvider({ children, getToken, apiBaseUrl = 'https:/
35
60
  useEffect(() => {
36
61
  void refreshToken();
37
62
  }, []);
63
+ // Tokens expire (2 hours by default): fetch a new one a little before this one
64
+ // runs out, so a conversation never starts on a token about to expire. A 401
65
+ // on any call also fetches a new one.
66
+ useEffect(() => {
67
+ if (!token)
68
+ return;
69
+ const exp = expiryOf(token);
70
+ if (!exp)
71
+ return;
72
+ const wait = Math.min(Math.max(10000, exp * 1000 - Date.now() - REFRESH_EARLY_MS), 2 ** 31 - 1);
73
+ const timer = setTimeout(() => void refreshToken(true), wait);
74
+ return () => clearTimeout(timer);
75
+ }, [token]);
38
76
  const value = useMemo(() => ({
39
77
  config,
40
78
  token,
package/dist/types.d.ts CHANGED
@@ -111,6 +111,12 @@ export interface FacePeMobileAvatarProps {
111
111
  objectFit?: 'cover' | 'contain';
112
112
  /** Whether to mirror the video stream (default: false) */
113
113
  mirror?: boolean;
114
+ /**
115
+ * Show the "Avatar speaking" / "Listening to you…" label on the video
116
+ * (default: true). Turn it off to show who is talking yourself, with
117
+ * onSpeakStart/onSpeakEnd and onUserSpeakStart/onUserSpeakEnd.
118
+ */
119
+ showSpeakingIndicator?: boolean;
114
120
  /** Auto start streaming when component mounts (default: true) */
115
121
  autoStart?: boolean;
116
122
  /** Whether microphone should be automatically enabled on stream start (default: true) */
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported in conversation records. Keep in step with package.json. */
2
- export declare const SDK_VERSION = "1.5.0";
2
+ export declare const SDK_VERSION = "1.5.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported in conversation records. Keep in step with package.json. */
2
- export const SDK_VERSION = '1.5.0';
2
+ export const SDK_VERSION = '1.5.2';
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "@facepe/widget-mobile-app",
3
- "version": "1.5.0",
4
- "description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
5
- "main": "./dist/index.js",
6
- "module": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "files": [
9
- "dist",
10
- "README.md"
11
- ],
12
- "publishConfig": {
13
- "access": "public"
14
- },
15
- "keywords": [
16
- "facepe",
17
- "avatar",
18
- "ai-avatar",
19
- "livekit",
20
- "react-native",
21
- "ios",
22
- "android",
23
- "webrtc",
24
- "streaming",
25
- "digital-human"
26
- ],
27
- "author": "FacePe AI",
28
- "license": "MIT",
29
- "scripts": {
30
- "lint": "tsc --noEmit",
31
- "build": "tsc"
32
- },
33
- "peerDependencies": {
34
- "@livekit/react-native": ">=2.0.0",
35
- "@livekit/react-native-webrtc": ">=124.0.0",
36
- "livekit-client": ">=2.0.0",
37
- "react": ">=18.0.0",
38
- "react-native": ">=0.70.0",
39
- "@anam-ai/js-sdk": ">=4.27.0"
40
- },
41
- "peerDependenciesMeta": {
42
- "@livekit/react-native": {
43
- "optional": true
44
- },
45
- "@livekit/react-native-webrtc": {
46
- "optional": true
47
- },
48
- "livekit-client": {
49
- "optional": true
50
- },
51
- "@anam-ai/js-sdk": {
52
- "optional": true
53
- }
54
- },
55
- "devDependencies": {
56
- "@types/react": "^18.3.3",
57
- "react": "18.3.1",
58
- "react-native": "0.74.0",
59
- "typescript": "^5.4.5"
60
- }
61
- }
1
+ {
2
+ "name": "@facepe/widget-mobile-app",
3
+ "version": "1.5.2",
4
+ "description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "keywords": [
16
+ "facepe",
17
+ "avatar",
18
+ "ai-avatar",
19
+ "livekit",
20
+ "react-native",
21
+ "ios",
22
+ "android",
23
+ "webrtc",
24
+ "streaming",
25
+ "digital-human"
26
+ ],
27
+ "author": "FacePe AI",
28
+ "license": "MIT",
29
+ "scripts": {
30
+ "lint": "tsc --noEmit",
31
+ "build": "tsc"
32
+ },
33
+ "peerDependencies": {
34
+ "@livekit/react-native": ">=2.0.0",
35
+ "@livekit/react-native-webrtc": ">=124.0.0",
36
+ "livekit-client": ">=2.0.0",
37
+ "react": ">=18.0.0",
38
+ "react-native": ">=0.70.0",
39
+ "@anam-ai/js-sdk": ">=4.27.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@livekit/react-native": {
43
+ "optional": true
44
+ },
45
+ "@livekit/react-native-webrtc": {
46
+ "optional": true
47
+ },
48
+ "livekit-client": {
49
+ "optional": true
50
+ },
51
+ "@anam-ai/js-sdk": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/react": "^18.3.3",
57
+ "react": "18.3.1",
58
+ "react-native": "0.74.0",
59
+ "typescript": "^5.4.5"
60
+ }
61
+ }