@vivix-ai/ivi-frontend-sdk 0.3.6 → 0.3.8

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
@@ -380,13 +380,15 @@ function useIviSubtitles(
380
380
  ): IviSubtitleItem[];
381
381
  ```
382
382
 
383
- 基于 runtime 的 conversation / response 事件维护字幕队列,不负责渲染,也不按时间自动消失。默认从 conversation 条目聚合字幕;当 `roles` 包含 `"model"` 且开启 `useModelStreamingTranscript` 时,model 字幕改为使用 `response.output_audio_transcript.*` 事件,`response.output_audio_transcript.done` 只更新文本,只有 `response.done` 才表示这一轮 model 字幕结束。
383
+ 基于 runtime 的 conversation / response 事件维护字幕队列,不负责渲染。字幕进入完成态后默认 3000ms 自动移除,可通过 `hideCompletedAfterMs={false}` 关闭,或通过 `onSubtitleCompleted` 自定义保留/删除策略。默认从 conversation 条目聚合字幕;当 `roles` 包含 `"model"` 且开启 `useModelStreamingTranscript` 时,model 字幕改为使用 `response.output_audio_transcript.*` 事件,`response.output_audio_transcript.done` 只更新文本,只有 `response.done` 才表示这一轮 model 字幕结束。
384
384
 
385
385
  | Option | 类型 | 默认值 | 说明 |
386
386
  |--------|------|--------|------|
387
387
  | `roles` | `IviSubtitleRole \| IviSubtitleRole[]` | `"user"` | 要收集的发言人角色 |
388
388
  | `maxItems` | `number` | `2` | 最多保留的字幕条数,超过后清理最旧条目 |
389
389
  | `useModelStreamingTranscript` | `boolean` | `false` | 当 `roles` 包含 `"model"` 时,model 字幕是否改用 `response.output_audio_transcript.*` 事件 |
390
+ | `hideCompletedAfterMs` | `number \| false` | `3000` | 字幕进入完成态后多久自动移除;传 `false` 保留到被 `maxItems` 裁剪 |
391
+ | `onSubtitleCompleted` | `(item, context) => "keep" \| "remove" \| { removeAfterMs } \| void \| Promise<...>` | — | 自定义完成态清理策略,优先于 `hideCompletedAfterMs` |
390
392
 
391
393
  `IviSubtitleItem` 包含 `id`、`role`、`lifecycle`、`status`、`text`、`transcript`、`displayText`、`timestamp`、`updatedAt`、`content`、`item`、`source`、`responseId`、`itemId`。
392
394
 
@@ -395,7 +397,10 @@ function CustomSubtitles({ runtime }: { runtime: IviRuntimeCoordinator | null })
395
397
  const subtitles = useIviSubtitles(runtime, {
396
398
  roles: ["user", "model"],
397
399
  maxItems: 5,
398
- useModelStreamingTranscript: true
400
+ useModelStreamingTranscript: true,
401
+ onSubtitleCompleted: (item) => (
402
+ item.role === "model" ? { removeAfterMs: 4000 } : { removeAfterMs: 2500 }
403
+ )
399
404
  });
400
405
 
401
406
  return (
@@ -466,7 +471,7 @@ function useApplyVolumeToSlot(
466
471
  | `showVolumeControl` | `boolean` | — | 是否显示音量控制浮层 |
467
472
  | `volumeControlProps` | — | — | 音量控制自定义配置 |
468
473
  | `showSubtitle` | `boolean` | — | 是否显示字幕浮层 |
469
- | `subtitleProps` | `Omit<IVISubtitleOverlayProps, "runtime">` | — | 字幕自定义配置,可传 `roles`、`maxItems`、`useModelStreamingTranscript` 等 |
474
+ | `subtitleProps` | `Omit<IVISubtitleOverlayProps, "runtime">` | — | 字幕自定义配置,可传 `roles`、`maxItems`、`useModelStreamingTranscript`、`hideCompletedAfterMs`、`onSubtitleCompleted` 等 |
470
475
  | `trtcPlayerProps` | — | — | TRTC 播放器自定义配置 |
471
476
  | `livekitPlayerProps` | — | — | LiveKit 播放器自定义配置 |
472
477
  | `videoProps` / `imageProps` | — | — | 透传给原生 `<video>` / `<img>` |
@@ -577,6 +582,8 @@ npm install xgplayer xgplayer-flv
577
582
  | `maxItems` | `number` | `2` | 最多保留的字幕条数,超过后清理最旧条目 |
578
583
  | `maxVisible` | `number` | — | 已废弃,兼容旧字段;请使用 `maxItems` |
579
584
  | `useModelStreamingTranscript` | `boolean` | `false` | 当 `roles` 包含 `"model"` 时,model 字幕是否改用 `response.output_audio_transcript.*` 事件 |
585
+ | `hideCompletedAfterMs` | `number \| false` | `3000` | 字幕进入完成态后多久自动移除;传 `false` 关闭自动移除 |
586
+ | `onSubtitleCompleted` | `(item, context) => "keep" \| "remove" \| { removeAfterMs } \| void \| Promise<...>` | — | 自定义完成态清理策略,优先于 `hideCompletedAfterMs` |
580
587
  | `subtitleStyle` | `IVISubtitleOverlayStyle` | — | 样式配置 |
581
588
  | `className` / `style` | — | — | 样式 |
582
589
 
package/dist/index.cjs CHANGED
@@ -1107,6 +1107,8 @@ var ConversationManager = class {
1107
1107
 
1108
1108
  // src/runtime/managers/trtc-source-manager.ts
1109
1109
  var TAG = "[IVI-TRTC]";
1110
+ var TRTC_VIEW_ATTR = "data-ivi-trtc-view";
1111
+ var TRTC_MEDIA_STYLE_ID = "ivi-trtc-media-style";
1110
1112
  var DEFAULT_DENOISER_OPTIONS = {
1111
1113
  enabled: true,
1112
1114
  mode: "normal"
@@ -1268,6 +1270,8 @@ var TrtcSourceManager = class {
1268
1270
  if (!session) {
1269
1271
  throw new Error(`TRTC source session not found: ${sourceId}`);
1270
1272
  }
1273
+ ensureTrtcMediaStyleSheet();
1274
+ container.setAttribute(TRTC_VIEW_ATTR, "");
1271
1275
  session.views.set(viewId, {
1272
1276
  sourceId,
1273
1277
  container,
@@ -1290,6 +1294,7 @@ var TrtcSourceManager = class {
1290
1294
  }
1291
1295
  const binding = session.views.get(viewId);
1292
1296
  if (binding?.sourceId === sourceId) {
1297
+ binding.container.removeAttribute(TRTC_VIEW_ATTR);
1293
1298
  session.views.delete(viewId);
1294
1299
  }
1295
1300
  void this.applyAudioPolicy(session);
@@ -1311,6 +1316,7 @@ var TrtcSourceManager = class {
1311
1316
  this.sourceSessionKeys.delete(sourceId);
1312
1317
  session.views.forEach((binding, viewId) => {
1313
1318
  if (binding.sourceId === sourceId) {
1319
+ binding.container.removeAttribute(TRTC_VIEW_ATTR);
1314
1320
  session.views.delete(viewId);
1315
1321
  }
1316
1322
  });
@@ -1630,6 +1636,38 @@ var TrtcSourceManager = class {
1630
1636
  this.onTrtcEvent?.(event);
1631
1637
  }
1632
1638
  };
1639
+ function ensureTrtcMediaStyleSheet() {
1640
+ if (typeof document === "undefined") {
1641
+ return;
1642
+ }
1643
+ if (document.getElementById(TRTC_MEDIA_STYLE_ID)) {
1644
+ return;
1645
+ }
1646
+ const style = document.createElement("style");
1647
+ style.id = TRTC_MEDIA_STYLE_ID;
1648
+ style.textContent = `
1649
+ [${TRTC_VIEW_ATTR}] [id^="player_"],
1650
+ [${TRTC_VIEW_ATTR}] [id^="video_"],
1651
+ [${TRTC_VIEW_ATTR}] [id^="audio_"],
1652
+ [${TRTC_VIEW_ATTR}] video,
1653
+ [${TRTC_VIEW_ATTR}] canvas {
1654
+ width: 100% !important;
1655
+ height: 100% !important;
1656
+ max-width: 100% !important;
1657
+ max-height: 100% !important;
1658
+ background: transparent !important;
1659
+ background-color: transparent !important;
1660
+ background-image: none !important;
1661
+ }
1662
+
1663
+ [${TRTC_VIEW_ATTR}] video,
1664
+ [${TRTC_VIEW_ATTR}] canvas {
1665
+ object-fit: contain !important;
1666
+ display: block !important;
1667
+ }
1668
+ `;
1669
+ document.head.appendChild(style);
1670
+ }
1633
1671
  function isRuntimeTrtcSource(source) {
1634
1672
  return source.status === "ready" && source.playback?.type === "trtc" && typeof source.playback.trtc === "object" && source.playback.trtc !== null;
1635
1673
  }
@@ -1721,6 +1759,9 @@ function enforceContainMedia(container) {
1721
1759
  element.style.setProperty("max-height", "100%", "important");
1722
1760
  element.style.setProperty("object-fit", "contain", "important");
1723
1761
  element.style.setProperty("display", "block", "important");
1762
+ element.style.setProperty("background", "transparent", "important");
1763
+ element.style.setProperty("background-color", "transparent", "important");
1764
+ element.style.setProperty("background-image", "none", "important");
1724
1765
  });
1725
1766
  }
1726
1767
 
@@ -3080,9 +3121,12 @@ function useApplyVolumeToSlot(containerRef, volume, enabled, activeSourceId) {
3080
3121
  return () => observer.disconnect();
3081
3122
  }, [containerRef, volume, enabled, activeSourceId]);
3082
3123
  }
3124
+ var DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS = 3e3;
3083
3125
  function useIviSubtitles(runtime, options = {}) {
3084
3126
  const roles = options.roles ?? "user";
3085
3127
  const maxItems = normalizeMaxItems(options.maxItems);
3128
+ const hideCompletedAfterMs = normalizeHideCompletedAfterMs(options.hideCompletedAfterMs);
3129
+ const onSubtitleCompleted = options.onSubtitleCompleted;
3086
3130
  const roleKey = Array.isArray(roles) ? roles.join("\0") : roles;
3087
3131
  const roleSet = react.useMemo(
3088
3132
  () => new Set(roleKey.split("\0")),
@@ -3092,9 +3136,14 @@ function useIviSubtitles(runtime, options = {}) {
3092
3136
  const [subtitles, setSubtitles] = react.useState([]);
3093
3137
  const seenIdsRef = react.useRef(/* @__PURE__ */ new Set());
3094
3138
  const initializedRef = react.useRef(false);
3139
+ const completedSubtitleIdsRef = react.useRef(/* @__PURE__ */ new Set());
3140
+ const completionControllersRef = react.useRef(/* @__PURE__ */ new Map());
3141
+ const completionPolicyRef = react.useRef(null);
3095
3142
  react.useEffect(() => {
3096
3143
  seenIdsRef.current = /* @__PURE__ */ new Set();
3097
3144
  initializedRef.current = false;
3145
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3146
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3098
3147
  setSubtitles([]);
3099
3148
  if (!runtime) {
3100
3149
  return;
@@ -3150,6 +3199,8 @@ function useIviSubtitles(runtime, options = {}) {
3150
3199
  if (event.type === "session.ended") {
3151
3200
  seenIdsRef.current = /* @__PURE__ */ new Set();
3152
3201
  initializedRef.current = false;
3202
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3203
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3153
3204
  setSubtitles([]);
3154
3205
  return;
3155
3206
  }
@@ -3167,6 +3218,78 @@ function useIviSubtitles(runtime, options = {}) {
3167
3218
  syncConversations(state.conversations);
3168
3219
  });
3169
3220
  }, [runtime, roleSet, maxItems, useModelStreamingTranscript]);
3221
+ react.useEffect(() => {
3222
+ return () => {
3223
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3224
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3225
+ };
3226
+ }, []);
3227
+ react.useEffect(() => {
3228
+ const previousPolicy = completionPolicyRef.current;
3229
+ const policyChanged = Boolean(
3230
+ previousPolicy && (previousPolicy.hideCompletedAfterMs !== hideCompletedAfterMs || previousPolicy.onSubtitleCompleted !== onSubtitleCompleted)
3231
+ );
3232
+ completionPolicyRef.current = { hideCompletedAfterMs, onSubtitleCompleted };
3233
+ if (policyChanged) {
3234
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3235
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3236
+ }
3237
+ const subtitleById = new Map(subtitles.map((item) => [item.id, item]));
3238
+ const completedIds = completedSubtitleIdsRef.current;
3239
+ const controllers = completionControllersRef.current;
3240
+ for (const [id, controller] of Array.from(controllers)) {
3241
+ const item = subtitleById.get(id);
3242
+ if (!item || !isCompletedSubtitle(item)) {
3243
+ controller.abort();
3244
+ controllers.delete(id);
3245
+ completedIds.delete(id);
3246
+ }
3247
+ }
3248
+ for (const id of Array.from(completedIds)) {
3249
+ const item = subtitleById.get(id);
3250
+ if (!item || !isCompletedSubtitle(item)) {
3251
+ completedIds.delete(id);
3252
+ }
3253
+ }
3254
+ for (const item of subtitles) {
3255
+ if (!isCompletedSubtitle(item) || completedIds.has(item.id)) {
3256
+ continue;
3257
+ }
3258
+ completedIds.add(item.id);
3259
+ const controller = new AbortController();
3260
+ controllers.set(item.id, controller);
3261
+ const context = {
3262
+ entries: subtitles,
3263
+ reason: getSubtitleCompletedReason(item),
3264
+ signal: controller.signal
3265
+ };
3266
+ let decisionResult;
3267
+ try {
3268
+ decisionResult = onSubtitleCompleted ? onSubtitleCompleted(item, context) : getDefaultCompletedSubtitleDecision(hideCompletedAfterMs);
3269
+ } catch {
3270
+ decisionResult = "keep";
3271
+ }
3272
+ void Promise.resolve(decisionResult).then((decision) => {
3273
+ applyCompletedSubtitleDecision(
3274
+ item.id,
3275
+ decision,
3276
+ controller,
3277
+ controllers,
3278
+ completedIds,
3279
+ (id) => setSubtitles((previous) => removeSubtitleById(previous, id))
3280
+ );
3281
+ }).catch(() => {
3282
+ applyCompletedSubtitleDecision(
3283
+ item.id,
3284
+ "keep",
3285
+ controller,
3286
+ controllers,
3287
+ completedIds,
3288
+ (id) => setSubtitles((previous) => removeSubtitleById(previous, id))
3289
+ );
3290
+ });
3291
+ }
3292
+ }, [subtitles, hideCompletedAfterMs, onSubtitleCompleted]);
3170
3293
  return subtitles;
3171
3294
  }
3172
3295
  function normalizeMaxItems(maxItems) {
@@ -3178,6 +3301,35 @@ function normalizeMaxItems(maxItems) {
3178
3301
  }
3179
3302
  return Math.max(0, Math.floor(maxItems));
3180
3303
  }
3304
+ function normalizeHideCompletedAfterMs(hideCompletedAfterMs) {
3305
+ if (hideCompletedAfterMs === false) {
3306
+ return false;
3307
+ }
3308
+ if (hideCompletedAfterMs === void 0 || !Number.isFinite(hideCompletedAfterMs)) {
3309
+ return DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS;
3310
+ }
3311
+ return Math.max(0, Math.floor(hideCompletedAfterMs));
3312
+ }
3313
+ function getDefaultCompletedSubtitleDecision(hideCompletedAfterMs) {
3314
+ if (hideCompletedAfterMs === false) {
3315
+ return "keep";
3316
+ }
3317
+ return hideCompletedAfterMs === 0 ? "remove" : { removeAfterMs: hideCompletedAfterMs };
3318
+ }
3319
+ function normalizeCompletedSubtitleDecision(decision) {
3320
+ if (decision === "keep" || decision === "remove") {
3321
+ return decision;
3322
+ }
3323
+ if (decision && typeof decision === "object" && "removeAfterMs" in decision) {
3324
+ const removeAfterMs = Number(decision.removeAfterMs);
3325
+ if (!Number.isFinite(removeAfterMs)) {
3326
+ return "keep";
3327
+ }
3328
+ const normalized = Math.max(0, Math.floor(removeAfterMs));
3329
+ return normalized === 0 ? "remove" : { removeAfterMs: normalized };
3330
+ }
3331
+ return "keep";
3332
+ }
3181
3333
  function shouldUseConversationRole(role, roleSet, useModelStreamingTranscript) {
3182
3334
  if (!roleSet.has(role)) {
3183
3335
  return false;
@@ -3191,6 +3343,54 @@ function trimSubtitleItems(items, maxItems) {
3191
3343
  const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp);
3192
3344
  return sorted.length > maxItems ? sorted.slice(sorted.length - maxItems) : sorted;
3193
3345
  }
3346
+ function isCompletedSubtitle(item) {
3347
+ return item.lifecycle === "done" || item.status === "completed" || item.status === "incomplete";
3348
+ }
3349
+ function getSubtitleCompletedReason(item) {
3350
+ return item.source === "response_audio_transcript" || item.role === "model" ? "response_done" : "conversation_done";
3351
+ }
3352
+ function abortCompletedSubtitleTasks(controllers) {
3353
+ for (const controller of controllers.values()) {
3354
+ controller.abort();
3355
+ }
3356
+ controllers.clear();
3357
+ }
3358
+ function applyCompletedSubtitleDecision(itemId, decision, controller, controllers, completedIds, removeSubtitle) {
3359
+ if (controller.signal.aborted) {
3360
+ return;
3361
+ }
3362
+ const normalizedDecision = normalizeCompletedSubtitleDecision(decision);
3363
+ if (normalizedDecision === "keep") {
3364
+ controllers.delete(itemId);
3365
+ return;
3366
+ }
3367
+ const remove = () => {
3368
+ if (controller.signal.aborted) {
3369
+ return;
3370
+ }
3371
+ controllers.delete(itemId);
3372
+ completedIds.delete(itemId);
3373
+ removeSubtitle(itemId);
3374
+ };
3375
+ if (normalizedDecision === "remove") {
3376
+ remove();
3377
+ return;
3378
+ }
3379
+ const timeout = setTimeout(remove, normalizedDecision.removeAfterMs);
3380
+ controller.signal.addEventListener(
3381
+ "abort",
3382
+ () => {
3383
+ clearTimeout(timeout);
3384
+ },
3385
+ { once: true }
3386
+ );
3387
+ }
3388
+ function removeSubtitleById(items, itemId) {
3389
+ if (!items.some((item) => item.id === itemId)) {
3390
+ return items;
3391
+ }
3392
+ return items.filter((item) => item.id !== itemId);
3393
+ }
3194
3394
  function getDisplayText(item) {
3195
3395
  return item.text || item.transcript;
3196
3396
  }
@@ -3316,6 +3516,8 @@ function IVISubtitleOverlay(props) {
3316
3516
  maxItems,
3317
3517
  maxVisible,
3318
3518
  useModelStreamingTranscript,
3519
+ hideCompletedAfterMs,
3520
+ onSubtitleCompleted,
3319
3521
  subtitleStyle,
3320
3522
  className,
3321
3523
  style
@@ -3323,7 +3525,9 @@ function IVISubtitleOverlay(props) {
3323
3525
  const entries = useIviSubtitles(runtime, {
3324
3526
  roles,
3325
3527
  maxItems: maxItems ?? maxVisible,
3326
- useModelStreamingTranscript
3528
+ useModelStreamingTranscript,
3529
+ hideCompletedAfterMs,
3530
+ onSubtitleCompleted
3327
3531
  });
3328
3532
  if (entries.length === 0) return null;
3329
3533
  const fontFamily = subtitleStyle?.fontFamily ?? "system-ui, -apple-system, sans-serif";
@@ -3455,7 +3659,6 @@ function IVITrtcPlayer(props) {
3455
3659
  height: "100%",
3456
3660
  minWidth: 0,
3457
3661
  minHeight: 0,
3458
- backgroundColor: "#000",
3459
3662
  position: "relative",
3460
3663
  ...style
3461
3664
  },
@@ -4414,6 +4617,7 @@ function getClientLogTag(category) {
4414
4617
  return "[IVI-CLIENT]";
4415
4618
  }
4416
4619
 
4620
+ exports.DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS = DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS;
4417
4621
  exports.EMPTY_RUNTIME_STATE = EMPTY_RUNTIME_STATE;
4418
4622
  exports.IVILivekitPlayer = IVILivekitPlayer;
4419
4623
  exports.IVIStageView = IVIStageView;