@livo-tv/sdk 1.11.0 → 1.12.0-rc.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.
package/README.md CHANGED
@@ -1,17 +1,75 @@
1
1
  # @livo-tv/sdk
2
2
 
3
- Embed the Livo **player** (native React) and **studio** (native room or themed iframe) in a third-party app.
3
+ Embed Livo **in-DOM** on your origin. Native React is the partner path: `LivoPlayer`, `LivoHostStudio`, and `LivoGuestStudio`. Do not iframe `player.livo.tv`.
4
4
 
5
5
  ```tsx
6
- import { LivoPlayer, LivoStudio, playerUrl } from "@livo-tv/sdk";
7
- import { LivoHostStudio, LivoGuestStudio } from "@livo-tv/sdk/studio";
6
+ import { LivoPlayer, applyEmbedTheme, type EmbedTheme } from "@livo-tv/sdk";
7
+ import {
8
+ LivoHostStudio,
9
+ LivoGuestStudio,
10
+ StudioWindowGate,
11
+ studioRoomLabels,
12
+ } from "@livo-tv/sdk/studio";
8
13
  import "@livo-tv/sdk/styles.css";
9
14
 
10
- <LivoPlayer streamId="s1" theme={{ primary: "#2563eb", mode: "dark" }} />
11
- <LivoStudio guestToken="..." theme={{ primary: "#2563eb" }} />
15
+ const theme: EmbedTheme = {
16
+ accent: "#0F6FF5",
17
+ background: "#0b1220",
18
+ foreground: "#f8fafc",
19
+ radius: "8px",
20
+ fontFamily: "Inter, sans-serif",
21
+ mode: "dark",
22
+ };
23
+
24
+ <LivoPlayer
25
+ streamId="s1"
26
+ theme={theme}
27
+ onPlaybackState={(state) => {
28
+ /* loading | waiting | playing | paused | ended | error */
29
+ }}
30
+ onError={(error) => {
31
+ /* not_found | playback */
32
+ }}
33
+ />;
12
34
  ```
13
35
 
14
- `@livo-tv/sdk`, `@livo-tv/sdk/studio`, and `@livo-tv/sdk/condo` (`LivoCommunity`) are client entries (the published bundles start with `"use client"`). Import them from `"use client"` files in Next.js App Router — do not import those entries from a Server Component page. Tailwind hosts must `@source` `node_modules/@livo-tv/sdk/dist/*.{js,cjs,mjs}` like `@livo-tv/blocks`. RealtimeKit is an optional peer (`@cloudflare/realtimekit`). Root `<LivoStudio>` remains the iframe fallback.
36
+ `@livo-tv/sdk`, `@livo-tv/sdk/studio`, and `@livo-tv/sdk/condo` (`LivoCommunity`) are client entries (the published bundles start with `"use client"`). Import them from `"use client"` files in Next.js App Router — do not import those entries from a Server Component page. Tailwind hosts must `@source` `node_modules/@livo-tv/sdk/dist/*.{js,cjs,mjs}` like `@livo-tv/blocks`.
37
+
38
+ Root `<LivoStudio>` still iframes `app.livo.tv` and is **not** the partner contract. Native studio has no runtime dependency on `player.livo.tv` (no `postMessage` bridge, no hard-coded player origin). The hosted-player URL helpers (`playerUrl`) stay available for Livo's own player app.
39
+
40
+ ## Native studio (RealtimeKit)
41
+
42
+ Peers (install in the host app):
43
+
44
+ - `@cloudflare/realtimekit` (optional peer, lazy-imported)
45
+ - `react` / `react-dom`
46
+ - `hls.js` for `<LivoPlayer>`
47
+
48
+ Send this header on the route that mounts the studio (camera, mic, and screen share):
49
+
50
+ ```
51
+ Permissions-Policy: camera=(self), microphone=(self), display-capture=(self)
52
+ ```
53
+
54
+ Wrap the studio in `StudioWindowGate` with a **stable** `lockKey` (stream id or host token), not the partner pathname. The lock uses `BroadcastChannel` and is origin-scoped, so `/dashboard/studio` and `/events/123/studio` on the same origin still see each other.
55
+
56
+ ```tsx
57
+ <StudioWindowGate lockKey={`host:${streamId}`} labels={gateLabels}>
58
+ <LivoHostStudio
59
+ token={hostToken}
60
+ apiUrl="https://api.livo.tv"
61
+ labels={studioRoomLabels((key) => key)}
62
+ embed
63
+ theme={theme}
64
+ />
65
+ </StudioWindowGate>
66
+ ```
67
+
68
+ `theme` is an `EmbedTheme` object (or a hosted encoded string). `applyEmbedTheme` writes CSS variables onto **the studio container**, not `document.documentElement`, so a partner shell keeps its own chrome.
69
+
70
+ Omit `playerOrigin` in native embeds. The watch-share control only appears when you pass a player origin; native partners should share their own watch URL.
71
+
72
+ Regression page: `pnpm example:embed` serves `examples/partner-embed` on `http://localhost:3003`.
15
73
 
16
74
  Community (condo) — comments on every video, live Q&A only while a stream or webinar is `preview` / `public`. After the event, Q&A stays closed. Comments stay closed unless the organizer sets `commentsAfterEnd`.
17
75
 
@@ -20,12 +78,10 @@ Community (condo) — comments on every video, live Q&A only while a stream or w
20
78
  **Custom layout:** mount `<LivoCommunity>` next to a theater-only player, or call `createCommunityClient` / the public HTTP API for a fully custom UI. Pass a public `apiUrl` for viewers (`mode="viewer"`, polls). Pass JWT / `X-Api-Key` headers and `mode="control"` for settings, pin/hide/delete, and Q&A highlight/dismiss/answer. Unregistered posters send `displayName`, optional `picture` URL, and `guestId`. Supply `displayName` (and optional `picture`) to skip the name field; otherwise the composer asks once and stores `livo-community-identity`. Hosted iframes use `?name=` / `?avatar=` when the built-in panel is on.
21
79
 
22
80
  ```tsx
23
- import { LivoPlayer, playerUrl } from "@livo-tv/sdk";
81
+ import { LivoPlayer } from "@livo-tv/sdk";
24
82
  import { LivoCommunity } from "@livo-tv/sdk/condo";
25
83
  import "@livo-tv/sdk/styles.css";
26
84
 
27
- <iframe src={playerUrl(stream.id, { community: false })} />
28
-
29
85
  <LivoPlayer streamId={stream.id} />
30
86
 
31
87
  <LivoCommunity
@@ -60,12 +116,20 @@ Server (Node / Workers):
60
116
  import { createLivoServerClient } from "@livo-tv/sdk/server";
61
117
 
62
118
  const livo = createLivoServerClient({ apiKey: process.env.LIVO_API_KEY! });
63
- const { hostUrl, guestUrl, hostToken, guestToken } = await livo.mintHostSession(
64
- streamId,
65
- {
66
- displayName: "Host",
67
- },
68
- );
119
+ const { vodId, uploadId, partSize } = await livo.createVod({
120
+ filename: "talk.mp4",
121
+ contentType: "video/mp4",
122
+ byteSize: 12_000_000,
123
+ sha256: "64-lowercase-hex",
124
+ });
125
+ await livo.completeVodUpload(vodId, {
126
+ parts: [{ partNumber: 1, etag: '"etag-from-put"' }],
127
+ });
128
+ const { hostToken, guestToken } = await livo.mintHostSession(streamId, {
129
+ displayName: "Host",
130
+ });
69
131
  ```
70
132
 
133
+ Browser part `PUT`s go to the presigned R2 URL. Bucket CORS is `PUT *` with `ETag` exposed; mint the URL with `lk_` on your server.
134
+
71
135
  GitHub `livo-tv/sdk` + npm OIDC publish are a human handoff (same as `@livo-tv/blocks`).
package/dist/index.cjs CHANGED
@@ -1008,6 +1008,7 @@ function sanitizeEmbedTheme(value) {
1008
1008
  const row = value;
1009
1009
  const theme = {};
1010
1010
  const primary = sanitizeColor(row.primary);
1011
+ const accent = sanitizeColor(row.accent);
1011
1012
  const background = sanitizeColor(row.background);
1012
1013
  const foreground = sanitizeColor(row.foreground);
1013
1014
  if (typeof row.radius === "string" && RADIUS.test(row.radius.trim())) {
@@ -1017,6 +1018,7 @@ function sanitizeEmbedTheme(value) {
1017
1018
  theme.fontFamily = row.fontFamily.trim();
1018
1019
  }
1019
1020
  if (primary) theme.primary = primary;
1021
+ if (accent) theme.accent = accent;
1020
1022
  if (background) theme.background = background;
1021
1023
  if (foreground) theme.foreground = foreground;
1022
1024
  if (row.mode === "light" || row.mode === "dark") theme.mode = row.mode;
@@ -1031,9 +1033,17 @@ function encodeEmbedTheme(theme) {
1031
1033
  }
1032
1034
  function embedThemeCssVars(theme) {
1033
1035
  const vars = {};
1036
+ const accent = theme.accent ?? theme.primary;
1034
1037
  if (theme.primary) {
1035
1038
  vars["--livo-primary"] = theme.primary;
1036
1039
  vars["--primary"] = theme.primary;
1040
+ } else if (accent) {
1041
+ vars["--livo-primary"] = accent;
1042
+ vars["--primary"] = accent;
1043
+ }
1044
+ if (accent) {
1045
+ vars["--livo-accent"] = accent;
1046
+ vars["--accent"] = accent;
1037
1047
  }
1038
1048
  if (theme.background) {
1039
1049
  vars["--livo-background"] = theme.background;
@@ -1062,8 +1072,12 @@ function decodeEmbedTheme(raw) {
1062
1072
  return null;
1063
1073
  }
1064
1074
  }
1075
+ function resolveEmbedThemeInput(value) {
1076
+ if (typeof value === "string") return decodeEmbedTheme(value);
1077
+ return sanitizeEmbedTheme(value ?? null);
1078
+ }
1065
1079
  function applyEmbedTheme(root, theme) {
1066
- if (!theme) return;
1080
+ if (!root || !theme) return;
1067
1081
  if (theme.mode === "dark") root.classList.add("dark");
1068
1082
  if (theme.mode === "light") root.classList.remove("dark");
1069
1083
  for (const [key, value] of Object.entries(embedThemeCssVars(theme))) {
@@ -1183,6 +1197,7 @@ function LivoPlayer({
1183
1197
  waitingLabels,
1184
1198
  className,
1185
1199
  onStatusChange,
1200
+ onPlaybackState,
1186
1201
  onProgress,
1187
1202
  onEnded,
1188
1203
  onError,
@@ -1224,6 +1239,7 @@ function LivoPlayer({
1224
1239
  if (cancelled) return;
1225
1240
  if (!next) {
1226
1241
  onError?.({ code: "not_found" });
1242
+ onPlaybackState?.("error");
1227
1243
  return;
1228
1244
  }
1229
1245
  setStream(next);
@@ -1237,6 +1253,7 @@ function LivoPlayer({
1237
1253
  if (cancelled) return;
1238
1254
  if (!next) {
1239
1255
  onError?.({ code: "not_found" });
1256
+ onPlaybackState?.("error");
1240
1257
  return;
1241
1258
  }
1242
1259
  setVod(next);
@@ -1250,7 +1267,15 @@ function LivoPlayer({
1250
1267
  cancelled = true;
1251
1268
  window.clearInterval(timer);
1252
1269
  };
1253
- }, [apiUrl, onEnded, onError, onStatusChange, streamId, vodId]);
1270
+ }, [
1271
+ apiUrl,
1272
+ onEnded,
1273
+ onError,
1274
+ onPlaybackState,
1275
+ onStatusChange,
1276
+ streamId,
1277
+ vodId
1278
+ ]);
1254
1279
  react.useEffect(() => {
1255
1280
  if (!streamId) return;
1256
1281
  const sessionId = crypto.randomUUID();
@@ -1277,6 +1302,15 @@ function LivoPlayer({
1277
1302
  () => safeTheme ? embedThemeCssVars(safeTheme) : void 0,
1278
1303
  [safeTheme]
1279
1304
  );
1305
+ react.useEffect(() => {
1306
+ if (!playbackUrl) {
1307
+ onPlaybackState?.(
1308
+ stream?.status === "ended" ? "ended" : resolved ? "waiting" : "loading"
1309
+ );
1310
+ return;
1311
+ }
1312
+ onPlaybackState?.("playing");
1313
+ }, [onPlaybackState, playbackUrl, resolved, stream?.status]);
1280
1314
  const flags = stream?.community ?? vod?.community;
1281
1315
  const communityTargetId = streamId ?? vodId;
1282
1316
  const communityKind = streamId ? "stream" : "vod";
@@ -1358,6 +1392,9 @@ function LivoPlayer({
1358
1392
  captionsEnabled: stream ? Boolean(stream.captionsUrl) : Boolean(vod?.captionsEnabled),
1359
1393
  liveCaptionsBaseUrl: stream?.status === "public" ? stream.captionsUrl ?? void 0 : void 0,
1360
1394
  onFirstPlay: () => beacon?.track({ type: "view" }),
1395
+ onPlayingChange: (playing) => {
1396
+ onPlaybackState?.(playing ? "playing" : "paused");
1397
+ },
1361
1398
  onPlaybackProgress: (currentTime, duration) => {
1362
1399
  beacon?.track({ type: "progress", currentTime, duration });
1363
1400
  onProgress?.({
@@ -1377,6 +1414,7 @@ function LivoPlayer({
1377
1414
  errorCode: error && typeof error === "object" && "kind" in error ? String(error.kind) : "playback"
1378
1415
  });
1379
1416
  onError?.({ code: "playback" });
1417
+ onPlaybackState?.("error");
1380
1418
  }
1381
1419
  }
1382
1420
  )
@@ -1495,6 +1533,7 @@ exports.decodeEmbedTheme = decodeEmbedTheme;
1495
1533
  exports.embedShowsCommunity = embedShowsCommunity;
1496
1534
  exports.encodeEmbedTheme = encodeEmbedTheme;
1497
1535
  exports.playerUrl = playerUrl;
1536
+ exports.resolveEmbedThemeInput = resolveEmbedThemeInput;
1498
1537
  exports.sanitizeEmbedTheme = sanitizeEmbedTheme;
1499
1538
  exports.studioGuestUrl = studioGuestUrl;
1500
1539
  exports.studioHostUrl = studioHostUrl;