@burdenoff/website-sdk 2026.1028.1 → 2026.1029.1

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.
Files changed (44) hide show
  1. package/README.md +159 -1
  2. package/dist/avatar-live/index.d.mts +11 -0
  3. package/dist/avatar-live/index.d.ts +11 -0
  4. package/dist/avatar-live/index.js +120 -0
  5. package/dist/avatar-live/index.js.map +1 -0
  6. package/dist/avatar-live/index.mjs +118 -0
  7. package/dist/avatar-live/index.mjs.map +1 -0
  8. package/dist/avatar-live-view/index.d.mts +42 -0
  9. package/dist/avatar-live-view/index.d.ts +42 -0
  10. package/dist/avatar-live-view/index.js +1422 -0
  11. package/dist/avatar-live-view/index.js.map +1 -0
  12. package/dist/avatar-live-view/index.mjs +1420 -0
  13. package/dist/avatar-live-view/index.mjs.map +1 -0
  14. package/dist/components/explore.d.mts +2 -2
  15. package/dist/components/explore.d.ts +2 -2
  16. package/dist/components/explore.js +11 -4
  17. package/dist/components/explore.js.map +1 -1
  18. package/dist/components/explore.mjs +11 -4
  19. package/dist/components/explore.mjs.map +1 -1
  20. package/dist/{explore-BEgq1_0O.d.mts → explore-DUTNYUOm.d.mts} +1 -1
  21. package/dist/{explore-CHiP9u-4.d.ts → explore-DjQE7Xia.d.ts} +1 -1
  22. package/dist/explore-avatar-live-config-B171o1Yo.d.ts +283 -0
  23. package/dist/explore-avatar-live-config-BGsGwbcq.d.mts +283 -0
  24. package/dist/{explore-types-_9UJu6EA.d.mts → explore-types-Bb2Ih3d8.d.mts} +1 -1
  25. package/dist/{explore-types-_9UJu6EA.d.ts → explore-types-Bb2Ih3d8.d.ts} +1 -1
  26. package/dist/hooks/index.d.mts +3 -2
  27. package/dist/hooks/index.d.ts +3 -2
  28. package/dist/hooks/index.js +6 -1
  29. package/dist/hooks/index.js.map +1 -1
  30. package/dist/hooks/index.mjs +6 -1
  31. package/dist/hooks/index.mjs.map +1 -1
  32. package/dist/index.d.mts +16 -253
  33. package/dist/index.d.ts +16 -253
  34. package/dist/index.js +214 -36
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +214 -36
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/types-CndbmBK-.d.mts +68 -0
  39. package/dist/types-CndbmBK-.d.ts +68 -0
  40. package/dist/{use-progressive-reveal-C0hkdu_i.d.ts → use-explore-chat-DunjG-3p.d.ts} +10 -36
  41. package/dist/{use-progressive-reveal-BFPWbEXy.d.mts → use-explore-chat-zRuoAin6.d.mts} +10 -36
  42. package/dist/use-progressive-reveal-ppicEgzZ.d.mts +35 -0
  43. package/dist/use-progressive-reveal-ppicEgzZ.d.ts +35 -0
  44. package/package.json +15 -3
package/README.md CHANGED
@@ -243,6 +243,162 @@ a product-scoped panel still share one conversation. Without a `productSlug`,
243
243
  development build logs a warning). The default, `scope="portfolio"`, renders
244
244
  exactly what it always has.
245
245
 
246
+ #### Live avatar (opt-in, receive-only)
247
+
248
+ The site-wide `ExplorePanel`'s Avatar view shows the avatar's picture, "Live
249
+ avatar coming soon" and "Continue in Chat". A site can opt in to a live avatar
250
+ there instead: an AI avatar that reads the conversation's first answer aloud,
251
+ which the visitor watches and listens to. Their browser only receives: it never
252
+ publishes audio, video or data, and it sends the avatar nothing to say.
253
+
254
+ ```tsx
255
+ <ExploreChatProvider enabled={started} productSlug="healthybowl" shareToken={shareToken}>
256
+ <ExplorePanel
257
+ open={open}
258
+ onOpenChange={setOpen}
259
+ productSlug="healthybowl"
260
+ scope="product"
261
+ examplePrompts={suggestedQuestions}
262
+ avatar={{
263
+ src: '/avatar.webp',
264
+ live: {
265
+ site: 'healthybowl',
266
+ load: () => import('@burdenoff/website-sdk/avatar-live'),
267
+ turnstileSiteKey: TURNSTILE_SITE_KEY,
268
+ manifestVersion: 'hb-en-1', // the version of `suggestedQuestions`
269
+ },
270
+ }}
271
+ />
272
+ </ExploreChatProvider>
273
+ ```
274
+
275
+ - **Opt-in, and inert without it.** Without `avatar.live` the panel renders
276
+ and behaves exactly as before and asks no server anything. With it, nothing
277
+ happens on page load: the first request is made when the visitor opens the
278
+ Avatar view.
279
+ - **A chunk of its own, so a site that does not use it does not carry it.** The
280
+ live avatar's view, its state machine and its words in six locales are a
281
+ separate file in the package (`dist/avatar-live-view/`, not a public entry:
282
+ it is in no `exports`), which the panel loads with a dynamic `import()` the
283
+ first time the Avatar view opens on a site that passes `avatar.live`. The
284
+ root entry carries only the few lines that decide whether it is wanted and
285
+ what the Avatar view shows until it arrives (the same picture, and the same
286
+ bytes in a server render or a prerendered page, as before). A site that never
287
+ passes `avatar.live` never downloads it: its bundle grows by under a kilobyte
288
+ gzipped, instead of the roughly twelve the view weighs. Your bundler needs
289
+ to split dynamic imports, as Vite, webpack and Rollup do. If the file cannot
290
+ be fetched (offline, or a deploy has replaced it), or has not arrived after
291
+ eight seconds, the Avatar view shows what it showed before the live avatar,
292
+ also while it asks again, which it does each time the view opens. Whether
293
+ that fetches the file again is the browser's call: Safari and Firefox fetch a
294
+ module that failed once again, but Chromium-based browsers (Chrome, Edge,
295
+ Chrome on Android) keep the failed fetch for as long as the page lives, so
296
+ there the Avatar view stays as it was until the page is reloaded. A file that
297
+ arrives after the eight seconds is used if the visitor is still on the Avatar
298
+ view. `bun run build` checks the layout.
299
+ - **Install `livekit-client` (^2.22.3) on that site only.** It is an optional
300
+ peer dependency of this package, used by the separate
301
+ `@burdenoff/website-sdk/avatar-live` entry and nowhere else. Pass that entry
302
+ as `live.load`, as above, so your bundler splits it into its own chunk; the
303
+ panel calls it only after the visitor presses Start. The package's own build
304
+ checks that no other entry refers to the library.
305
+ - **Needs the panel under an `ExploreChatProvider`.** The live avatar reads the
306
+ provider's conversation. Without a provider, on a shared (read-only)
307
+ conversation, in a locale whose strings have not been reviewed yet (English
308
+ only, for now), or with an incomplete `live`, the Avatar view stays as it was.
309
+ - **One answer: the first, to a suggested question.** The avatar service
310
+ accepts a conversation only when it holds exactly two messages: the first user
311
+ message (a suggested question) and the first assistant answer, completed. The
312
+ view applies the same rule before it offers anything. With no question asked
313
+ yet, or its answer still on its way, it asks the visitor to choose a suggested
314
+ question in Chat first. After a failed first answer, or once the chat has gone
315
+ past that first question and answer (a second question, or a failed answer
316
+ asked again without Retry), it asks the visitor to start a new chat and choose
317
+ a suggested question. Whether the first question really is one of the
318
+ suggested ones is the service's to judge (`not_eligible`).
319
+ - **What the visitor sees.** Opening the Avatar view asks the gateway (the
320
+ origin of your configured `gatewayUrl`, at `/api/web-avatar/v1/availability`)
321
+ whether the live avatar is offered for this site and locale. When it is and
322
+ the conversation has its first answer, a consent card explains what happens
323
+ and links to `/privacy#live-avatar`; with no answer yet, the view asks the
324
+ visitor to choose a suggested question in Chat first. Start runs a
325
+ Cloudflare Turnstile check (action `avatar_start`), creates the session and
326
+ joins its room: the video, the captions, an "AI avatar · not a real person"
327
+ badge, a countdown, Stop and Continue in Chat. Where a browser holds sound
328
+ back (Safari, iOS), a "Tap to turn on sound" button appears. A refusal (busy,
329
+ limits, region, language, paused, a question the avatar cannot read) is one
330
+ short sentence and "Continue in Chat". A network or CORS failure, or a route
331
+ that is not there, shows the view as it was before.
332
+ - **Ending.** Stop, switching to Chat, closing the panel, hiding the page, and
333
+ the live avatar going off under a session (the panel's locale changing to one
334
+ whose strings have not been reviewed) all end the session with a `keepalive`
335
+ request and leave the room. The end is a simple CORS request: its body is a
336
+ string and the SDK sets no `content-type`, so the browser sends it as
337
+ `text/plain;charset=UTF-8` (which the gateway accepts for this route), and on
338
+ production nothing else is added, so no preflight has to complete while the
339
+ page unloads. The server ends sessions on its own deadlines as well.
340
+ - **Consent copy is versioned.** The panel shows a consent card only when it
341
+ holds reviewed copy for exactly the version the server names (the current
342
+ English copy is `web-consent-2026-11`); otherwise the view stays as it was.
343
+ - **Requests.** All three go to the gateway without credentials. The create
344
+ request carries `content-type: application/json`, `idempotency-key` (a UUID
345
+ v4) and `x-web-avatar-bot-token` (the Turnstile token, which the gateway
346
+ verifies and does not forward); the availability request and the end request
347
+ carry no header of the SDK's own. The conversation token is sent in the create
348
+ body only. Neither it, nor the session's end secret, nor the room token, nor
349
+ the bot check's token is ever written into the page, the console or storage by
350
+ the live avatar (the chat keeps its own copy of the conversation in
351
+ `localStorage`, as it always has).
352
+ - **Bot-check binding (web API contract v1.1, additive).** The Turnstile widget
353
+ is rendered with `cData` set to
354
+ `base64url_nopad(SHA-256(UTF-8("boff-web-avatar-cdata/v1\n" + site + "\n" + locale + "\n" + conversationToken + "\n" + messageId)))`,
355
+ made with WebCrypto from the exact values the create body then carries, before
356
+ the widget is rendered, so the gateway can check that the token was minted for
357
+ this request. The action stays `avatar_start`, and nothing else in v1
358
+ changes. A browser without WebCrypto (a page that is not a secure context)
359
+ cannot start the live avatar: Start loads neither the room module nor the bot
360
+ check's script there, and shows the view as it was. Pinned vector: site
361
+ `healthybowl`, locale `en`, conversation token
362
+ `ct_vector_0123456789abcdefghijklmnopqrstuvwx`, message id
363
+ `11111111-2222-4333-8444-555555555555` give
364
+ `si4b6TQ3lxnlbdqG3IzgjbHrnlD8SMo1E0of1c6uZFY`.
365
+ - **Alpha builds.** On the alpha gateway hosts the configured Cloudflare Access
366
+ headers (`cf-access-client-id`, `cf-access-client-secret`) are added to all
367
+ three requests, as on every SDK request, so each of them is a preflighted
368
+ request there. An alpha build that sends them relies on the gateway's web
369
+ preflight allowing exactly those two headers on non-production hosts. A
370
+ production build sets no Access headers, and its end request makes no
371
+ preflight.
372
+ - **Your page's headers.** A site with a Content Security Policy must allow:
373
+ - `script-src` and `frame-src`: `https://challenges.cloudflare.com` (the
374
+ Turnstile bot check);
375
+ - `connect-src`, the gateway origin (the three requests);
376
+ - `connect-src`, the media room: `wss://<project>.livekit.cloud`. For a
377
+ LiveKit Cloud room the media library also asks
378
+ `https://<project>.livekit.cloud/settings/regions` (sending the room token as
379
+ a Bearer header) and, when the first host cannot be reached, fails over to
380
+ the regional hosts and uses the room's `/rtc/validate` check. So allow
381
+ `https://<project>.livekit.cloud` too, and the regional hosts, in practice
382
+ `wss://*.livekit.cloud` and `https://*.livekit.cloud`. Under a strict
383
+ `connect-src` those are only console warnings, but region failover is lost.
384
+ A self-hosted room needs its own `wss://` origin, and the `https://` origin
385
+ of the same host for the validation check.
386
+ - **Not verified in this package.** Every test here uses fakes: nothing reaches
387
+ Turnstile, the gateway, the avatar service or the media room. Left to the
388
+ alpha test: real playback on iPhone Safari and Android Chrome; that
389
+ `page_hide` ends actually arrive from those browsers; and that the receive-only
390
+ token has `canPublish=false` and `canPublishData=false`, and a worker RPC
391
+ addressed to the viewer is dropped without disconnecting it (the media client
392
+ answers incoming RPC requests on its own, so "never publishes" holds only as
393
+ far as the token's permissions do).
394
+
395
+ Testing hooks (`data-boff-explore`): `avatar-live` (with `data-phase`),
396
+ `avatar-live-status`, `avatar-live-consent`, `avatar-live-privacy`,
397
+ `avatar-live-start`, `avatar-live-not-now`, `avatar-live-stop`,
398
+ `avatar-live-video`, `avatar-live-audio`, `avatar-live-badge`,
399
+ `avatar-live-captions`, `avatar-live-countdown`, `avatar-live-sound`,
400
+ `avatar-live-continue`, `avatar-live-back`, `avatar-live-bot-check`.
401
+
246
402
  **`ExploreCta` props**: `variant` (`'header' \| 'mobile' \| 'floating'`, required),
247
403
  `href` (default `/explore`), `label` (default `Ask AI`), `className`,
248
404
  `onNavigate`, `onClick`, `currentPath`. The `floating` variant hides itself on
@@ -265,7 +421,9 @@ with it. This is separate from the v2 checkbox used by `ContactPage`.
265
421
  **Headless use.** `useExploreChat(options)` exposes the whole state machine —
266
422
  `{ phase, catalog, messages, activity, remainingToday, error, focusProduct,
267
423
  setFocusProduct, send, stop, retry, reset, canSend }` — if you want to build a
268
- different shell around it.
424
+ different shell around it. `getConversationToken()` returns the visitor's
425
+ conversation token at the moment of the call (a function, so it is never
426
+ rendered or serialised with the rest).
269
427
 
270
428
  **Inline concept illustrations.** An answer may embed images from the product
271
429
  sites' concept pages (`![alt](url)`, followed by the backend's own caption
@@ -0,0 +1,11 @@
1
+ import { R as ReceiveOnlyJoinOptions, a as ReceiveOnlySession } from '../types-CndbmBK-.mjs';
2
+ export { A as AvatarLiveModule, b as ReceiveOnlyEvent } from '../types-CndbmBK-.mjs';
3
+
4
+ /**
5
+ * Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice
6
+ * in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel
7
+ * then ends the session).
8
+ */
9
+ declare function joinReceiveOnly(options: ReceiveOnlyJoinOptions): Promise<ReceiveOnlySession>;
10
+
11
+ export { ReceiveOnlyJoinOptions, ReceiveOnlySession, joinReceiveOnly };
@@ -0,0 +1,11 @@
1
+ import { R as ReceiveOnlyJoinOptions, a as ReceiveOnlySession } from '../types-CndbmBK-.js';
2
+ export { A as AvatarLiveModule, b as ReceiveOnlyEvent } from '../types-CndbmBK-.js';
3
+
4
+ /**
5
+ * Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice
6
+ * in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel
7
+ * then ends the session).
8
+ */
9
+ declare function joinReceiveOnly(options: ReceiveOnlyJoinOptions): Promise<ReceiveOnlySession>;
10
+
11
+ export { ReceiveOnlyJoinOptions, ReceiveOnlySession, joinReceiveOnly };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ var livekitClient = require('livekit-client');
4
+
5
+ var CLOSED_BY_SERVER = /* @__PURE__ */ new Set([
6
+ livekitClient.DisconnectReason.ROOM_DELETED,
7
+ livekitClient.DisconnectReason.ROOM_CLOSED,
8
+ livekitClient.DisconnectReason.PARTICIPANT_REMOVED
9
+ ]);
10
+ function isMedia(publication) {
11
+ return publication.kind === livekitClient.Track.Kind.Video || publication.kind === livekitClient.Track.Kind.Audio;
12
+ }
13
+ async function joinReceiveOnly(options) {
14
+ const { url, token, workerIdentity, video, audio, onEvent } = options;
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(url);
18
+ } catch {
19
+ throw new Error("live avatar: the room address is not a URL");
20
+ }
21
+ if (parsed.protocol !== "wss:") {
22
+ throw new Error("live avatar: the room address must be wss:");
23
+ }
24
+ if (!token || !workerIdentity) {
25
+ throw new Error("live avatar: the room token or speaker is missing");
26
+ }
27
+ const room = new livekitClient.Room({
28
+ // One small video, always shown while the session lasts: no quality switching, no layer pausing.
29
+ adaptiveStream: false,
30
+ dynacast: false,
31
+ // Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide
32
+ // handler, which also asks the server to end the session: either may run first, and both are safe to repeat.
33
+ disconnectOnPageLeave: true
34
+ });
35
+ let leaving = false;
36
+ const isSpeaker = (participant) => participant.identity === workerIdentity;
37
+ const consider = (publication, participant) => {
38
+ if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;
39
+ if (!publication.isSubscribed) publication.setSubscribed(true);
40
+ };
41
+ const reportPlayback = () => {
42
+ if (leaving) return;
43
+ onEvent({
44
+ type: "playback-blocked",
45
+ blocked: !room.canPlaybackAudio || !room.canPlaybackVideo
46
+ });
47
+ };
48
+ room.on(livekitClient.RoomEvent.TrackPublished, consider).on(
49
+ livekitClient.RoomEvent.TrackSubscribed,
50
+ (track, publication, participant) => {
51
+ if (leaving) return;
52
+ if (!isSpeaker(participant) || !isMedia(publication)) {
53
+ publication.setSubscribed(false);
54
+ return;
55
+ }
56
+ if (track.kind === livekitClient.Track.Kind.Video) {
57
+ track.attach(video);
58
+ onEvent({ type: "speaker-track", kind: "video" });
59
+ } else {
60
+ track.attach(audio);
61
+ onEvent({ type: "speaker-track", kind: "audio" });
62
+ }
63
+ }
64
+ ).on(livekitClient.RoomEvent.TrackUnsubscribed, (track) => {
65
+ track.detach();
66
+ }).on(livekitClient.RoomEvent.ParticipantDisconnected, (participant) => {
67
+ if (!leaving && isSpeaker(participant)) {
68
+ onEvent({ type: "speaker-left" });
69
+ }
70
+ }).on(livekitClient.RoomEvent.AudioPlaybackStatusChanged, reportPlayback).on(livekitClient.RoomEvent.VideoPlaybackStatusChanged, reportPlayback).on(livekitClient.RoomEvent.Disconnected, (reason) => {
71
+ if (leaving) return;
72
+ onEvent({
73
+ type: "disconnected",
74
+ byServer: reason !== void 0 && CLOSED_BY_SERVER.has(reason)
75
+ });
76
+ });
77
+ const detachAll = () => {
78
+ for (const participant of room.remoteParticipants.values()) {
79
+ for (const publication of participant.trackPublications.values()) {
80
+ publication.track?.detach();
81
+ }
82
+ }
83
+ video.srcObject = null;
84
+ audio.srcObject = null;
85
+ };
86
+ try {
87
+ await room.connect(url, token, { autoSubscribe: false });
88
+ } catch (error) {
89
+ leaving = true;
90
+ room.removeAllListeners();
91
+ detachAll();
92
+ throw error;
93
+ }
94
+ for (const participant of room.remoteParticipants.values()) {
95
+ for (const publication of participant.trackPublications.values()) {
96
+ consider(publication, participant);
97
+ }
98
+ }
99
+ reportPlayback();
100
+ return {
101
+ enablePlayback: async () => {
102
+ await Promise.allSettled([room.startAudio(), room.startVideo()]);
103
+ reportPlayback();
104
+ },
105
+ leave: async () => {
106
+ if (leaving) return;
107
+ leaving = true;
108
+ detachAll();
109
+ try {
110
+ await room.disconnect();
111
+ } finally {
112
+ room.removeAllListeners();
113
+ }
114
+ }
115
+ };
116
+ }
117
+
118
+ exports.joinReceiveOnly = joinReceiveOnly;
119
+ //# sourceMappingURL=index.js.map
120
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/avatar-live/receive-only.ts"],"names":["DisconnectReason","Track","Room","RoomEvent"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * BOFF-9797. The live avatar's room, joined to watch and listen only.\n *\n * A visitor's browser on a product website holds a subscribe-only room token: it can receive the avatar worker's\n * video and voice and do nothing else. This adapter keeps to that from its side too:\n *\n * - **Nothing is subscribed automatically** (`autoSubscribe: false`). Only the tracks of `workerIdentity`, the avatar\n * worker the create answer named, are subscribed, and only their video and audio. Any other participant's track\n * (the server's own speaker included) is never asked for, and is dropped again should the server send it anyway.\n * - **Nothing is published.** No microphone, no camera, no data, no RPC method: this file never calls a publishing\n * API, and the token would refuse one.\n * - **Safari and iOS** hold sound back until a gesture. The room reports it (`playback-blocked`) and the panel offers\n * \"Tap to turn on sound\", whose tap calls `enablePlayback`.\n *\n * Written for the website from the media library's public API: nothing here is copied from the product apps.\n */\nimport {\n DisconnectReason,\n Room,\n RoomEvent,\n Track,\n type RemoteParticipant,\n type RemoteTrack,\n type RemoteTrackPublication,\n} from \"livekit-client\";\n\nimport type { ReceiveOnlyJoinOptions, ReceiveOnlySession } from \"./types\";\n\n/** The reasons a room closes because the server closed it, rather than because the connection was lost. */\nconst CLOSED_BY_SERVER: ReadonlySet<DisconnectReason> = new Set([\n DisconnectReason.ROOM_DELETED,\n DisconnectReason.ROOM_CLOSED,\n DisconnectReason.PARTICIPANT_REMOVED,\n]);\n\nfunction isMedia(publication: RemoteTrackPublication): boolean {\n return (\n publication.kind === Track.Kind.Video ||\n publication.kind === Track.Kind.Audio\n );\n}\n\n/**\n * Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice\n * in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel\n * then ends the session).\n */\nexport async function joinReceiveOnly(\n options: ReceiveOnlyJoinOptions,\n): Promise<ReceiveOnlySession> {\n const { url, token, workerIdentity, video, audio, onEvent } = options;\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"live avatar: the room address is not a URL\");\n }\n if (parsed.protocol !== \"wss:\") {\n throw new Error(\"live avatar: the room address must be wss:\");\n }\n if (!token || !workerIdentity) {\n throw new Error(\"live avatar: the room token or speaker is missing\");\n }\n\n const room = new Room({\n // One small video, always shown while the session lasts: no quality switching, no layer pausing.\n adaptiveStream: false,\n dynacast: false,\n // Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide\n // handler, which also asks the server to end the session: either may run first, and both are safe to repeat.\n disconnectOnPageLeave: true,\n });\n let leaving = false;\n\n const isSpeaker = (participant: RemoteParticipant) =>\n participant.identity === workerIdentity;\n\n /** Subscribe to `publication` when it is the speaker's video or audio; leave everything else alone. */\n const consider = (\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;\n if (!publication.isSubscribed) publication.setSubscribed(true);\n };\n\n const reportPlayback = () => {\n if (leaving) return;\n onEvent({\n type: \"playback-blocked\",\n blocked: !room.canPlaybackAudio || !room.canPlaybackVideo,\n });\n };\n\n room\n .on(RoomEvent.TrackPublished, consider)\n .on(\n RoomEvent.TrackSubscribed,\n (\n track: RemoteTrack,\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving) return;\n if (!isSpeaker(participant) || !isMedia(publication)) {\n // Never asked for: drop it rather than play it.\n publication.setSubscribed(false);\n return;\n }\n if (track.kind === Track.Kind.Video) {\n track.attach(video);\n onEvent({ type: \"speaker-track\", kind: \"video\" });\n } else {\n track.attach(audio);\n onEvent({ type: \"speaker-track\", kind: \"audio\" });\n }\n },\n )\n .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach();\n })\n .on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {\n if (!leaving && isSpeaker(participant)) {\n onEvent({ type: \"speaker-left\" });\n }\n })\n .on(RoomEvent.AudioPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.VideoPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {\n if (leaving) return;\n onEvent({\n type: \"disconnected\",\n byServer: reason !== undefined && CLOSED_BY_SERVER.has(reason),\n });\n });\n\n const detachAll = () => {\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n publication.track?.detach();\n }\n }\n video.srcObject = null;\n audio.srcObject = null;\n };\n\n try {\n await room.connect(url, token, { autoSubscribe: false });\n } catch (error) {\n leaving = true;\n room.removeAllListeners();\n detachAll();\n throw error;\n }\n\n // Whoever was in the room before this viewer joined: their tracks were published before the listener above.\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n consider(publication, participant);\n }\n }\n reportPlayback();\n\n return {\n enablePlayback: async () => {\n // Both started inside the visitor's gesture, before anything is awaited: Safari allows playback only there.\n // A refusal leaves the room as it was, and the report below keeps the button offered.\n await Promise.allSettled([room.startAudio(), room.startVideo()]);\n reportPlayback();\n },\n leave: async () => {\n if (leaving) return;\n leaving = true;\n detachAll();\n try {\n await room.disconnect();\n } finally {\n room.removeAllListeners();\n }\n },\n };\n}\n"]}
@@ -0,0 +1,118 @@
1
+ import { DisconnectReason, Room, RoomEvent, Track } from 'livekit-client';
2
+
3
+ var CLOSED_BY_SERVER = /* @__PURE__ */ new Set([
4
+ DisconnectReason.ROOM_DELETED,
5
+ DisconnectReason.ROOM_CLOSED,
6
+ DisconnectReason.PARTICIPANT_REMOVED
7
+ ]);
8
+ function isMedia(publication) {
9
+ return publication.kind === Track.Kind.Video || publication.kind === Track.Kind.Audio;
10
+ }
11
+ async function joinReceiveOnly(options) {
12
+ const { url, token, workerIdentity, video, audio, onEvent } = options;
13
+ let parsed;
14
+ try {
15
+ parsed = new URL(url);
16
+ } catch {
17
+ throw new Error("live avatar: the room address is not a URL");
18
+ }
19
+ if (parsed.protocol !== "wss:") {
20
+ throw new Error("live avatar: the room address must be wss:");
21
+ }
22
+ if (!token || !workerIdentity) {
23
+ throw new Error("live avatar: the room token or speaker is missing");
24
+ }
25
+ const room = new Room({
26
+ // One small video, always shown while the session lasts: no quality switching, no layer pausing.
27
+ adaptiveStream: false,
28
+ dynacast: false,
29
+ // Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide
30
+ // handler, which also asks the server to end the session: either may run first, and both are safe to repeat.
31
+ disconnectOnPageLeave: true
32
+ });
33
+ let leaving = false;
34
+ const isSpeaker = (participant) => participant.identity === workerIdentity;
35
+ const consider = (publication, participant) => {
36
+ if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;
37
+ if (!publication.isSubscribed) publication.setSubscribed(true);
38
+ };
39
+ const reportPlayback = () => {
40
+ if (leaving) return;
41
+ onEvent({
42
+ type: "playback-blocked",
43
+ blocked: !room.canPlaybackAudio || !room.canPlaybackVideo
44
+ });
45
+ };
46
+ room.on(RoomEvent.TrackPublished, consider).on(
47
+ RoomEvent.TrackSubscribed,
48
+ (track, publication, participant) => {
49
+ if (leaving) return;
50
+ if (!isSpeaker(participant) || !isMedia(publication)) {
51
+ publication.setSubscribed(false);
52
+ return;
53
+ }
54
+ if (track.kind === Track.Kind.Video) {
55
+ track.attach(video);
56
+ onEvent({ type: "speaker-track", kind: "video" });
57
+ } else {
58
+ track.attach(audio);
59
+ onEvent({ type: "speaker-track", kind: "audio" });
60
+ }
61
+ }
62
+ ).on(RoomEvent.TrackUnsubscribed, (track) => {
63
+ track.detach();
64
+ }).on(RoomEvent.ParticipantDisconnected, (participant) => {
65
+ if (!leaving && isSpeaker(participant)) {
66
+ onEvent({ type: "speaker-left" });
67
+ }
68
+ }).on(RoomEvent.AudioPlaybackStatusChanged, reportPlayback).on(RoomEvent.VideoPlaybackStatusChanged, reportPlayback).on(RoomEvent.Disconnected, (reason) => {
69
+ if (leaving) return;
70
+ onEvent({
71
+ type: "disconnected",
72
+ byServer: reason !== void 0 && CLOSED_BY_SERVER.has(reason)
73
+ });
74
+ });
75
+ const detachAll = () => {
76
+ for (const participant of room.remoteParticipants.values()) {
77
+ for (const publication of participant.trackPublications.values()) {
78
+ publication.track?.detach();
79
+ }
80
+ }
81
+ video.srcObject = null;
82
+ audio.srcObject = null;
83
+ };
84
+ try {
85
+ await room.connect(url, token, { autoSubscribe: false });
86
+ } catch (error) {
87
+ leaving = true;
88
+ room.removeAllListeners();
89
+ detachAll();
90
+ throw error;
91
+ }
92
+ for (const participant of room.remoteParticipants.values()) {
93
+ for (const publication of participant.trackPublications.values()) {
94
+ consider(publication, participant);
95
+ }
96
+ }
97
+ reportPlayback();
98
+ return {
99
+ enablePlayback: async () => {
100
+ await Promise.allSettled([room.startAudio(), room.startVideo()]);
101
+ reportPlayback();
102
+ },
103
+ leave: async () => {
104
+ if (leaving) return;
105
+ leaving = true;
106
+ detachAll();
107
+ try {
108
+ await room.disconnect();
109
+ } finally {
110
+ room.removeAllListeners();
111
+ }
112
+ }
113
+ };
114
+ }
115
+
116
+ export { joinReceiveOnly };
117
+ //# sourceMappingURL=index.mjs.map
118
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/avatar-live/receive-only.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * BOFF-9797. The live avatar's room, joined to watch and listen only.\n *\n * A visitor's browser on a product website holds a subscribe-only room token: it can receive the avatar worker's\n * video and voice and do nothing else. This adapter keeps to that from its side too:\n *\n * - **Nothing is subscribed automatically** (`autoSubscribe: false`). Only the tracks of `workerIdentity`, the avatar\n * worker the create answer named, are subscribed, and only their video and audio. Any other participant's track\n * (the server's own speaker included) is never asked for, and is dropped again should the server send it anyway.\n * - **Nothing is published.** No microphone, no camera, no data, no RPC method: this file never calls a publishing\n * API, and the token would refuse one.\n * - **Safari and iOS** hold sound back until a gesture. The room reports it (`playback-blocked`) and the panel offers\n * \"Tap to turn on sound\", whose tap calls `enablePlayback`.\n *\n * Written for the website from the media library's public API: nothing here is copied from the product apps.\n */\nimport {\n DisconnectReason,\n Room,\n RoomEvent,\n Track,\n type RemoteParticipant,\n type RemoteTrack,\n type RemoteTrackPublication,\n} from \"livekit-client\";\n\nimport type { ReceiveOnlyJoinOptions, ReceiveOnlySession } from \"./types\";\n\n/** The reasons a room closes because the server closed it, rather than because the connection was lost. */\nconst CLOSED_BY_SERVER: ReadonlySet<DisconnectReason> = new Set([\n DisconnectReason.ROOM_DELETED,\n DisconnectReason.ROOM_CLOSED,\n DisconnectReason.PARTICIPANT_REMOVED,\n]);\n\nfunction isMedia(publication: RemoteTrackPublication): boolean {\n return (\n publication.kind === Track.Kind.Video ||\n publication.kind === Track.Kind.Audio\n );\n}\n\n/**\n * Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice\n * in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel\n * then ends the session).\n */\nexport async function joinReceiveOnly(\n options: ReceiveOnlyJoinOptions,\n): Promise<ReceiveOnlySession> {\n const { url, token, workerIdentity, video, audio, onEvent } = options;\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"live avatar: the room address is not a URL\");\n }\n if (parsed.protocol !== \"wss:\") {\n throw new Error(\"live avatar: the room address must be wss:\");\n }\n if (!token || !workerIdentity) {\n throw new Error(\"live avatar: the room token or speaker is missing\");\n }\n\n const room = new Room({\n // One small video, always shown while the session lasts: no quality switching, no layer pausing.\n adaptiveStream: false,\n dynacast: false,\n // Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide\n // handler, which also asks the server to end the session: either may run first, and both are safe to repeat.\n disconnectOnPageLeave: true,\n });\n let leaving = false;\n\n const isSpeaker = (participant: RemoteParticipant) =>\n participant.identity === workerIdentity;\n\n /** Subscribe to `publication` when it is the speaker's video or audio; leave everything else alone. */\n const consider = (\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;\n if (!publication.isSubscribed) publication.setSubscribed(true);\n };\n\n const reportPlayback = () => {\n if (leaving) return;\n onEvent({\n type: \"playback-blocked\",\n blocked: !room.canPlaybackAudio || !room.canPlaybackVideo,\n });\n };\n\n room\n .on(RoomEvent.TrackPublished, consider)\n .on(\n RoomEvent.TrackSubscribed,\n (\n track: RemoteTrack,\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving) return;\n if (!isSpeaker(participant) || !isMedia(publication)) {\n // Never asked for: drop it rather than play it.\n publication.setSubscribed(false);\n return;\n }\n if (track.kind === Track.Kind.Video) {\n track.attach(video);\n onEvent({ type: \"speaker-track\", kind: \"video\" });\n } else {\n track.attach(audio);\n onEvent({ type: \"speaker-track\", kind: \"audio\" });\n }\n },\n )\n .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach();\n })\n .on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {\n if (!leaving && isSpeaker(participant)) {\n onEvent({ type: \"speaker-left\" });\n }\n })\n .on(RoomEvent.AudioPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.VideoPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {\n if (leaving) return;\n onEvent({\n type: \"disconnected\",\n byServer: reason !== undefined && CLOSED_BY_SERVER.has(reason),\n });\n });\n\n const detachAll = () => {\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n publication.track?.detach();\n }\n }\n video.srcObject = null;\n audio.srcObject = null;\n };\n\n try {\n await room.connect(url, token, { autoSubscribe: false });\n } catch (error) {\n leaving = true;\n room.removeAllListeners();\n detachAll();\n throw error;\n }\n\n // Whoever was in the room before this viewer joined: their tracks were published before the listener above.\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n consider(publication, participant);\n }\n }\n reportPlayback();\n\n return {\n enablePlayback: async () => {\n // Both started inside the visitor's gesture, before anything is awaited: Safari allows playback only there.\n // A refusal leaves the room as it was, and the report below keeps the button offered.\n await Promise.allSettled([room.startAudio(), room.startVideo()]);\n reportPlayback();\n },\n leave: async () => {\n if (leaving) return;\n leaving = true;\n detachAll();\n try {\n await room.disconnect();\n } finally {\n room.removeAllListeners();\n }\n },\n };\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-BGsGwbcq.mjs';
4
+ import '../types-CndbmBK-.mjs';
5
+ import '../use-explore-chat-zRuoAin6.mjs';
6
+ import '../explore-types-Bb2Ih3d8.mjs';
7
+
8
+ interface AvatarLiveViewProps {
9
+ live: ExplorePanelAvatarLive;
10
+ /** The Avatar view is showing: the panel is open and its Avatar tab is selected. */
11
+ active: boolean;
12
+ locale: ExploreLocale;
13
+ /** The product the panel speaks for, when it is one product's own; null on the portfolio's. */
14
+ productName: string | null;
15
+ /** The approved still picture. */
16
+ still: {
17
+ src: string;
18
+ alt: string;
19
+ };
20
+ /** The Avatar tab as it was before the live avatar: shown whenever the live avatar is not offered or not reachable. */
21
+ fallback: ReactNode;
22
+ /** Back to the Chat tab. Leaving the Avatar tab ends a session, like any other way out of it. */
23
+ onContinueInChat: () => void;
24
+ /** "Continue in Chat" in the panel's language: the panel's own word for it, so this chunk carries none of the panel's. */
25
+ continueInChat: string;
26
+ /**
27
+ * Nothing to ask the server about: an incomplete config, no gateway, no shared conversation to read (no provider, or a
28
+ * read-only shared one), or a locale whose strings have not been reviewed. The tab stays as it was. Decided by
29
+ * `ExploreAvatarLive`, which also decides whether this view is loaded at all; it can change while this view is
30
+ * mounted (the panel's locale switching), and what is under way then ends.
31
+ */
32
+ off: boolean;
33
+ /** {@link avatarApiBase} of the SDK's gateway, null when there is none to use. */
34
+ base: string | null;
35
+ /** Headers every request carries: the alpha gateway's access headers, and nothing on production. */
36
+ headers: Readonly<Record<string, string>>;
37
+ /** The panel's conversation, null when there is no provider. */
38
+ conversation: AvatarConversation | null;
39
+ }
40
+ declare function AvatarLiveView({ live, active, locale, productName, still, fallback, onContinueInChat, continueInChat, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
41
+
42
+ export { AvatarLiveView, type AvatarLiveViewProps };
@@ -0,0 +1,42 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-B171o1Yo.js';
4
+ import '../types-CndbmBK-.js';
5
+ import '../use-explore-chat-DunjG-3p.js';
6
+ import '../explore-types-Bb2Ih3d8.js';
7
+
8
+ interface AvatarLiveViewProps {
9
+ live: ExplorePanelAvatarLive;
10
+ /** The Avatar view is showing: the panel is open and its Avatar tab is selected. */
11
+ active: boolean;
12
+ locale: ExploreLocale;
13
+ /** The product the panel speaks for, when it is one product's own; null on the portfolio's. */
14
+ productName: string | null;
15
+ /** The approved still picture. */
16
+ still: {
17
+ src: string;
18
+ alt: string;
19
+ };
20
+ /** The Avatar tab as it was before the live avatar: shown whenever the live avatar is not offered or not reachable. */
21
+ fallback: ReactNode;
22
+ /** Back to the Chat tab. Leaving the Avatar tab ends a session, like any other way out of it. */
23
+ onContinueInChat: () => void;
24
+ /** "Continue in Chat" in the panel's language: the panel's own word for it, so this chunk carries none of the panel's. */
25
+ continueInChat: string;
26
+ /**
27
+ * Nothing to ask the server about: an incomplete config, no gateway, no shared conversation to read (no provider, or a
28
+ * read-only shared one), or a locale whose strings have not been reviewed. The tab stays as it was. Decided by
29
+ * `ExploreAvatarLive`, which also decides whether this view is loaded at all; it can change while this view is
30
+ * mounted (the panel's locale switching), and what is under way then ends.
31
+ */
32
+ off: boolean;
33
+ /** {@link avatarApiBase} of the SDK's gateway, null when there is none to use. */
34
+ base: string | null;
35
+ /** Headers every request carries: the alpha gateway's access headers, and nothing on production. */
36
+ headers: Readonly<Record<string, string>>;
37
+ /** The panel's conversation, null when there is no provider. */
38
+ conversation: AvatarConversation | null;
39
+ }
40
+ declare function AvatarLiveView({ live, active, locale, productName, still, fallback, onContinueInChat, continueInChat, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
41
+
42
+ export { AvatarLiveView, type AvatarLiveViewProps };