From 2da96064afecf4329eec5984d6bc81fefef34b4d Mon Sep 17 00:00:00 2001 From: xinlxinli Date: Mon, 17 Aug 2026 21:33:39 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90Web=E3=80=91update=20Web/vue3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Web/example/vite-vue3-ts/package.json | 4 +- Web/roomkit/vue3/CHANGELOG.md | 6 + Web/roomkit/vue3/CHANGELOG.zh-CN.md | 6 + .../vue3/RoomKit/adapter/conference.ts | 4 +- Web/roomkit/vue3/RoomKit/adapter/type.ts | 5 + .../AIToolsButton/AISettingsDialog.vue | 148 ----- .../RealtimeMessageListPanel.vue | 16 - .../components/AIToolsButton/index.vue | 227 ------- .../AIToolsButtonH5/AISettingsPopup.vue | 282 -------- .../components/AIToolsButtonH5/index.vue | 278 -------- .../components/ASRTools/SubtitleEmptyCard.vue | 119 ++++ .../ASRTools/caption/CaptionStreamText.vue | 157 +++++ .../components/ASRTools/caption/Subtitle.vue | 555 ++++++++++++++++ .../ASRTools/caption/SubtitleDock.vue | 615 ++++++++++++++++++ .../ASRTools/caption/SubtitleDockH5.vue | 313 +++++++++ .../ASRTools/caption/useCaptionDockChrome.ts | 66 ++ .../ASRTools/caption/useCaptionDockResize.ts | 187 ++++++ .../ASRTools/caption/useSubtitleIdle.ts | 127 ++++ .../components/RealtimeMessageList.vue | 212 ------ .../ASRTools/components/Subtitle.vue | 379 ----------- .../RoomKit/components/ASRTools/constants.ts | 110 ++++ .../components/ASRTools/entry/ASRButton.vue | 89 +++ .../components/ASRTools/entry/ASRButtonH5.vue | 127 ++++ .../ASRTools/entry/ASREntryMenu.vue | 193 ++++++ .../entry/ASRRegistrar.vue} | 13 +- .../entry/ASRRegistrarH5.vue} | 8 +- .../ASRTools/entry/ASRStartConfirmDialog.vue | 147 +++++ .../vue3/RoomKit/components/ASRTools/index.ts | 62 +- .../ASRTools/record/RealtimeMessageList.vue | 483 ++++++++++++++ .../ASRTools/record/RecordPanel.vue | 500 ++++++++++++++ .../ASRTools/record/RecordSearchBar.vue | 131 ++++ .../settings/LanguagePickerSheetH5.vue | 108 +++ .../ASRTools/settings/LanguageSelect.vue | 360 ++++++++++ .../settings/SubtitleSettingsPopover.vue | 419 ++++++++++++ .../settings/SubtitleSettingsSheetH5.vue | 242 +++++++ .../ASRTools/settings/useSubtitleSettings.ts | 105 +++ .../ASRTools/styles/asrTooltip.scss | 45 ++ .../components/ASRTools/useASRToolsState.ts | 395 +++++++++++ .../ASRTools/useSubtitleViewState.ts | 142 ++++ .../ASRTools/utils/captionStream.ts | 171 +++++ .../components/ASRTools/utils/display.ts | 83 ++- .../RoomKit/components/CallButton/index.vue | 161 +++-- .../components/CustomWidgetRenderer/index.vue | 81 ++- .../LeaveRoomButton/StandardLeave.vue | 10 +- .../components/LeaveRoomButtonH5/index.vue | 10 +- .../RoomBarrage/BarrageRegistrar.vue | 2 +- .../SettingButton/SettingsRegistrar.vue | 2 +- .../SettingButtonH5/SettingsRegistrarH5.vue | 2 +- .../RoomKit/components/base/vClickOutside.ts | 20 +- Web/roomkit/vue3/RoomKit/components/index.ts | 18 +- .../vue3/RoomKit/hooks/useASRToolsState.ts | 182 ------ .../vue3/RoomKit/hooks/useDraggableDock.ts | 246 ++++++- .../vue3/RoomKit/hooks/useSubtitlesState.ts | 40 -- Web/roomkit/vue3/RoomKit/i18n/en-US/index.ts | 111 ++-- Web/roomkit/vue3/RoomKit/i18n/zh-CN/index.ts | 111 ++-- Web/roomkit/vue3/RoomKit/utils/asrSettings.ts | 46 -- .../views/ConferenceMainView/index.vue | 6 +- .../views/ConferenceMainViewH5/index.vue | 6 +- Web/roomkit/vue3/package.json | 6 +- 59 files changed, 6589 insertions(+), 2110 deletions(-) delete mode 100644 Web/roomkit/vue3/RoomKit/components/AIToolsButton/AISettingsDialog.vue delete mode 100644 Web/roomkit/vue3/RoomKit/components/AIToolsButton/RealtimeMessageListPanel.vue delete mode 100644 Web/roomkit/vue3/RoomKit/components/AIToolsButton/index.vue delete mode 100644 Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/AISettingsPopup.vue delete mode 100644 Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/index.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/SubtitleEmptyCard.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/CaptionStreamText.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/Subtitle.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDock.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDockH5.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockChrome.ts create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockResize.ts create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useSubtitleIdle.ts delete mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/components/RealtimeMessageList.vue delete mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/components/Subtitle.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButton.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButtonH5.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASREntryMenu.vue rename Web/roomkit/vue3/RoomKit/components/{AIToolsButton/AIToolsRegistrar.vue => ASRTools/entry/ASRRegistrar.vue} (63%) rename Web/roomkit/vue3/RoomKit/components/{AIToolsButtonH5/AIToolsRegistrarH5.vue => ASRTools/entry/ASRRegistrarH5.vue} (65%) create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRStartConfirmDialog.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/record/RealtimeMessageList.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordPanel.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordSearchBar.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguagePickerSheetH5.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguageSelect.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsPopover.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsSheetH5.vue create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/settings/useSubtitleSettings.ts create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/styles/asrTooltip.scss create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/useASRToolsState.ts create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/useSubtitleViewState.ts create mode 100644 Web/roomkit/vue3/RoomKit/components/ASRTools/utils/captionStream.ts delete mode 100644 Web/roomkit/vue3/RoomKit/hooks/useASRToolsState.ts delete mode 100644 Web/roomkit/vue3/RoomKit/hooks/useSubtitlesState.ts delete mode 100644 Web/roomkit/vue3/RoomKit/utils/asrSettings.ts diff --git a/Web/example/vite-vue3-ts/package.json b/Web/example/vite-vue3-ts/package.json index 9e0b82d98..05e9cd6c0 100644 --- a/Web/example/vite-vue3-ts/package.json +++ b/Web/example/vite-vue3-ts/package.json @@ -9,11 +9,11 @@ "lint": "./node_modules/.bin/eslint ./src --no-error-on-unmatched-pattern" }, "dependencies": { - "@tencentcloud/roomkit-web-vue3": "6.1.5", + "@tencentcloud/roomkit-web-vue3": "6.1.6", "@tencentcloud/uikit-base-component-vue3": "1.4.9", "@tencentcloud/uikit-base-widget-vue3": "1.1.3", "@tencentcloud/universal-api": "^2.4.0", - "tuikit-atomicx-vue3": "6.5.3", + "tuikit-atomicx-vue3": "6.5.5", "vue": "^3.2.25", "vue-router": "^4.0.14" }, diff --git a/Web/roomkit/vue3/CHANGELOG.md b/Web/roomkit/vue3/CHANGELOG.md index 3f69fc8fb..79f858a9a 100644 --- a/Web/roomkit/vue3/CHANGELOG.md +++ b/Web/roomkit/vue3/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [6.1.6] - 2026-08-17 + +### Changed + +- Optimize real-time subtitle interaction. + ## [6.1.5] - 2026-08-09 ### Added diff --git a/Web/roomkit/vue3/CHANGELOG.zh-CN.md b/Web/roomkit/vue3/CHANGELOG.zh-CN.md index aea0f6c95..fb4865882 100644 --- a/Web/roomkit/vue3/CHANGELOG.zh-CN.md +++ b/Web/roomkit/vue3/CHANGELOG.zh-CN.md @@ -4,6 +4,12 @@ --- +## [6.1.6] - 2026-08-17 + +### 优化 + +- 优化实时字幕交互 + ## [6.1.5] - 2026-08-09 ### 新增 diff --git a/Web/roomkit/vue3/RoomKit/adapter/conference.ts b/Web/roomkit/vue3/RoomKit/adapter/conference.ts index 8a1aa8c0b..ac6c41b16 100644 --- a/Web/roomkit/vue3/RoomKit/adapter/conference.ts +++ b/Web/roomkit/vue3/RoomKit/adapter/conference.ts @@ -252,7 +252,9 @@ class Conference extends ConferenceDeprecated implements IConference { const widgets = this.registeredWidgets.filter((w) => { const resolved = this.resolveZone(w.zone, platform); if (resolved === undefined) { - return false; + // Panel-only widgets have no toolbar zone. Include them when listing + // everything (side panels), but never when filtering a toolbar zone. + return !zone; } if (zone) { return resolved === zone; diff --git a/Web/roomkit/vue3/RoomKit/adapter/type.ts b/Web/roomkit/vue3/RoomKit/adapter/type.ts index ebdac18f2..b2e388c83 100644 --- a/Web/roomkit/vue3/RoomKit/adapter/type.ts +++ b/Web/roomkit/vue3/RoomKit/adapter/type.ts @@ -101,6 +101,11 @@ interface BaseWidget { id: string; zone?: WidgetZoneConfig; order?: number; + /** + * Whether this widget can collapse into the More menu when the toolbar + * is too narrow. Defaults to true. Set false to keep it always visible. + */ + overflow?: boolean; panel?: { title: string | (() => string); component: Component; diff --git a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/AISettingsDialog.vue b/Web/roomkit/vue3/RoomKit/components/AIToolsButton/AISettingsDialog.vue deleted file mode 100644 index a710fe504..000000000 --- a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/AISettingsDialog.vue +++ /dev/null @@ -1,148 +0,0 @@ - - - - - diff --git a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/RealtimeMessageListPanel.vue b/Web/roomkit/vue3/RoomKit/components/AIToolsButton/RealtimeMessageListPanel.vue deleted file mode 100644 index 8d7185db2..000000000 --- a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/RealtimeMessageListPanel.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/index.vue b/Web/roomkit/vue3/RoomKit/components/AIToolsButton/index.vue deleted file mode 100644 index 92d1f7136..000000000 --- a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/index.vue +++ /dev/null @@ -1,227 +0,0 @@ - - - - - diff --git a/Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/AISettingsPopup.vue b/Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/AISettingsPopup.vue deleted file mode 100644 index b81d8653b..000000000 --- a/Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/AISettingsPopup.vue +++ /dev/null @@ -1,282 +0,0 @@ - - - - - diff --git a/Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/index.vue b/Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/index.vue deleted file mode 100644 index c7348020a..000000000 --- a/Web/roomkit/vue3/RoomKit/components/AIToolsButtonH5/index.vue +++ /dev/null @@ -1,278 +0,0 @@ - - - - - diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/SubtitleEmptyCard.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/SubtitleEmptyCard.vue new file mode 100644 index 000000000..05d9d5421 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/SubtitleEmptyCard.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/CaptionStreamText.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/CaptionStreamText.vue new file mode 100644 index 000000000..9c3cb8e2d --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/CaptionStreamText.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/Subtitle.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/Subtitle.vue new file mode 100644 index 000000000..941d4ec6d --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/Subtitle.vue @@ -0,0 +1,555 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDock.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDock.vue new file mode 100644 index 000000000..0c3427702 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDock.vue @@ -0,0 +1,615 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDockH5.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDockH5.vue new file mode 100644 index 000000000..441dee33b --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/SubtitleDockH5.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockChrome.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockChrome.ts new file mode 100644 index 000000000..218db1725 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockChrome.ts @@ -0,0 +1,66 @@ +import { computed, watch } from 'vue'; +import type { Ref } from 'vue'; +import { useASRToolsState } from '../useASRToolsState'; +import { useSubtitleViewState } from '../useSubtitleViewState'; +import { useSubtitleIdle } from './useSubtitleIdle'; + +/** + * Shared overlay chrome for the PC and H5 caption docks: stopped hint, + * action visibility, and the idle fade that dismisses the status line. + */ +export function useCaptionDockChrome(isOverlayHeld: Ref) { + const { + capViewOpen, + emptyDismissed, + enableHintVisible, + hasCaptionMessages, + } = useSubtitleViewState(); + const { asrOn, asrEmptyVariant } = useASRToolsState(); + + const isStoppedHint = computed( + () => !asrOn.value && asrEmptyVariant.value === 'member-stopped', + ); + const showTranscriptAction = computed( + () => asrOn.value && !enableHintVisible.value, + ); + const showHideAction = computed( + () => asrOn.value && !enableHintVisible.value && !isStoppedHint.value, + ); + const hasVisibleContent = computed( + () => enableHintVisible.value || hasCaptionMessages.value || isOverlayHeld.value || isStoppedHint.value, + ); + const showDockActions = computed( + () => asrOn.value && !enableHintVisible.value && !isStoppedHint.value, + ); + + const { isIdle, wake } = useSubtitleIdle( + computed(() => (asrOn.value || isStoppedHint.value) && capViewOpen.value && !isOverlayHeld.value), + hasVisibleContent, + ); + + watch(isStoppedHint, (stopped) => { + if (stopped) { + wake(); + } + }); + + watch(isIdle, (idle) => { + if (!idle || !isStoppedHint.value) { + return; + } + window.setTimeout(() => { + if (isIdle.value && isStoppedHint.value) { + emptyDismissed.value = true; + } + }, 200); + }); + + return { + isStoppedHint, + showTranscriptAction, + showHideAction, + showDockActions, + isIdle, + wake, + }; +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockResize.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockResize.ts new file mode 100644 index 000000000..eeae4eb0a --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useCaptionDockResize.ts @@ -0,0 +1,187 @@ +import { onBeforeUnmount, ref } from 'vue'; +import type { Ref } from 'vue'; +import type { DraggableDockPosition } from '../../../hooks/useDraggableDock'; +import { CAPTION_DOCK_EDGE_PADDING, MIN_CAPTION_DOCK_WIDTH } from '../constants'; +import type { CaptionDockFrozenBox } from '../useSubtitleViewState'; + +const RESIZE_THRESHOLD = 4; + +export type CaptionDockResizeEdge = 'left' | 'right'; + +export function clampCaptionDockWidth( + width: number, + containerWidth: number, + maxWidth = Number.POSITIVE_INFINITY, +): number { + const containerMax = Math.max(0, containerWidth - CAPTION_DOCK_EDGE_PADDING * 2); + const max = Math.max(0, Math.min(containerMax, maxWidth)); + const min = Math.min(MIN_CAPTION_DOCK_WIDTH, max); + return Math.min(Math.max(width, min), max); +} + +/** Keep a centered dock's translateX(-50%) anchor inside the container. */ +export function clampCaptionDockCenterX( + centerX: number, + width: number, + containerWidth: number, +): number { + const half = width / 2; + const min = CAPTION_DOCK_EDGE_PADDING + half; + const max = containerWidth - CAPTION_DOCK_EDGE_PADDING - half; + if (max < min) { + return containerWidth / 2; + } + return Math.min(Math.max(centerX, min), max); +} + +/** + * Re-fit a box captured before `.room-container` shrank (records panel or + * browser resize). Width is clamped first so the center can stay in bounds. + */ +export function clampCaptionDockFrozenBox( + box: CaptionDockFrozenBox, + containerWidth: number, +): CaptionDockFrozenBox { + const width = clampCaptionDockWidth(box.width, containerWidth); + return { + width, + centerX: clampCaptionDockCenterX(box.centerX, width, containerWidth), + }; +} + +/** + * PC-only caption-dock width resize. Centered docks grow from the middle; + * a dragged dock keeps the opposite edge pinned. + */ +export function useCaptionDockResize(options: { + containerRef: Ref; + dockRef: Ref; + dockWidth: Ref; + position: Ref; + dockFrozenBox: Ref; + onResizeStart?: () => void; + onResizeEnd?: () => void; +}) { + const isResizing = ref(false); + const containerWidth = ref(0); + const resize = { + pointerId: -1, + edge: 'right' as CaptionDockResizeEdge, + startClientX: 0, + startWidth: 0, + startX: 0, + moved: false, + }; + + function measureContainer() { + containerWidth.value = options.containerRef.value?.clientWidth ?? 0; + } + + function commitWidth(nextWidth: number, nextX?: number) { + options.dockWidth.value = nextWidth; + if (nextX !== undefined && options.position.value) { + options.position.value = { + ...options.position.value, + x: nextX, + }; + } + if (options.dockFrozenBox.value) { + options.dockFrozenBox.value = { + ...options.dockFrozenBox.value, + width: nextWidth, + }; + } + } + + function onPointerMove(event: PointerEvent) { + if (!isResizing.value || event.pointerId !== resize.pointerId) { + return; + } + const container = options.containerRef.value; + if (!container) { + return; + } + const deltaX = event.clientX - resize.startClientX; + resize.moved ||= Math.abs(deltaX) >= RESIZE_THRESHOLD; + if (!resize.moved) { + return; + } + event.preventDefault(); + const boxWidth = container.clientWidth; + const position = options.position.value; + + if (!position) { + const signed = resize.edge === 'right' ? deltaX : -deltaX; + commitWidth(clampCaptionDockWidth(resize.startWidth + signed * 2, boxWidth)); + return; + } + + if (resize.edge === 'right') { + const maxWidth = boxWidth - CAPTION_DOCK_EDGE_PADDING - resize.startX; + commitWidth(clampCaptionDockWidth(resize.startWidth + deltaX, boxWidth, maxWidth)); + return; + } + + const right = resize.startX + resize.startWidth; + const maxWidth = right - CAPTION_DOCK_EDGE_PADDING; + const nextWidth = clampCaptionDockWidth(resize.startWidth - deltaX, boxWidth, maxWidth); + commitWidth(nextWidth, right - nextWidth); + } + + function onPointerUp(event: PointerEvent) { + if (event.pointerId !== resize.pointerId) { + return; + } + isResizing.value = false; + resize.pointerId = -1; + try { + options.dockRef.value?.releasePointerCapture?.(event.pointerId); + } catch { + // Pointer already released on cancel. + } + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + options.onResizeEnd?.(); + } + + function startResize(event: PointerEvent, edge: CaptionDockResizeEdge) { + const container = options.containerRef.value; + const dock = options.dockRef.value; + if (event.button !== 0 || !container || !dock) { + return; + } + event.preventDefault(); + event.stopPropagation(); + options.onResizeStart?.(); + measureContainer(); + const containerRect = container.getBoundingClientRect(); + const dockRect = dock.getBoundingClientRect(); + Object.assign(resize, { + pointerId: event.pointerId, + edge, + startClientX: event.clientX, + startWidth: dock.offsetWidth, + startX: dockRect.left - containerRect.left, + moved: false, + }); + isResizing.value = true; + dock.setPointerCapture?.(event.pointerId); + window.addEventListener('pointermove', onPointerMove, { passive: false }); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerUp); + } + + onBeforeUnmount(() => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + }); + + return { + isResizing, + containerWidth, + measureContainer, + startResize, + }; +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useSubtitleIdle.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useSubtitleIdle.ts new file mode 100644 index 000000000..958f87cea --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/caption/useSubtitleIdle.ts @@ -0,0 +1,127 @@ +import { onUnmounted, watch } from 'vue'; +import type { Ref } from 'vue'; +import { RealtimeTranscriberEvent, useAITranscriberState } from 'tuikit-atomicx-vue3/room'; +import { SUBTITLE_IDLE_MS } from '../constants'; +import { useSubtitleViewState } from '../useSubtitleViewState'; + +/** + * Shared across dock remounts. Opening the record panel recalculates toolbar + * overflow, which destroys and recreates the ASR button (and its teleported + * dock). A per-instance timer would restart and flash the faded captions back. + */ +let idleTimer: ReturnType | null = null; + +/** + * Dims the caption dock while nobody is speaking so it stops competing with the + * video for attention, and wakes it on the next transcript or user interaction. + * + * An empty overlay (no hint, no speaker lines) idles immediately. The one-shot + * enable hint still uses the idle delay so it can be read after the user opens + * captions. Idling only changes opacity; it never touches `capViewOpen`. + */ +export function useSubtitleIdle(enabled: Ref, hasVisibleContent: Ref) { + const { isIdle, enableHintVisible } = useSubtitleViewState(); + const { subscribeEvent, unsubscribeEvent } = useAITranscriberState(); + + const clearIdleTimer = () => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + }; + + const wake = () => { + isIdle.value = false; + clearIdleTimer(); + if (enabled.value) { + idleTimer = setTimeout(() => { + idleTimer = null; + isIdle.value = true; + // The intro has been shown; an empty overlay must not bring it back. + enableHintVisible.value = false; + }, SUBTITLE_IDLE_MS); + } + }; + + const syncIdleState = () => { + if (!enabled.value) { + return; + } + if (enableHintVisible.value) { + // A new intro must restart the idle clock even if a previous cycle left + // the dock idle or a stale timer still running. + wake(); + return; + } + if (!hasVisibleContent.value) { + clearIdleTimer(); + isIdle.value = true; + return; + } + // Remount while captions are already running: keep the current idle + // state instead of waking. Only arm idle when nothing is in flight. + if (!isIdle.value && !idleTimer) { + wake(); + } + }; + + const handleTranscriberMessage = () => wake(); + + watch( + enabled, + (on) => { + if (on) { + subscribeEvent( + RealtimeTranscriberEvent.onReceiveTranscriberMessage, + handleTranscriberMessage, + ); + syncIdleState(); + return; + } + unsubscribeEvent( + RealtimeTranscriberEvent.onReceiveTranscriberMessage, + handleTranscriberMessage, + ); + clearIdleTimer(); + isIdle.value = false; + }, + { immediate: true }, + ); + + // Hint or speaker lines just appeared: always bring the dock back. The remount + // skip above must not apply here — an empty overlay idles immediately, and + // that idle must not swallow the intro that lands a tick later. + watch(hasVisibleContent, (visible, wasVisible) => { + if (!enabled.value) { + return; + } + if (!visible) { + clearIdleTimer(); + isIdle.value = true; + return; + } + if (!wasVisible) { + wake(); + return; + } + syncIdleState(); + }); + + watch(enableHintVisible, (show) => { + if (show && enabled.value) { + wake(); + } + }, { flush: 'sync', immediate: true }); + + onUnmounted(() => { + unsubscribeEvent( + RealtimeTranscriberEvent.onReceiveTranscriberMessage, + handleTranscriberMessage, + ); + // The timer is module-level so a leftover callback would still fire after + // this dock is gone and kill the next intro a moment after it appears. + clearIdleTimer(); + }); + + return { isIdle, wake }; +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/components/RealtimeMessageList.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/components/RealtimeMessageList.vue deleted file mode 100644 index 4c0e1009e..000000000 --- a/Web/roomkit/vue3/RoomKit/components/ASRTools/components/RealtimeMessageList.vue +++ /dev/null @@ -1,212 +0,0 @@ - - - - - diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/components/Subtitle.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/components/Subtitle.vue deleted file mode 100644 index f6dc80201..000000000 --- a/Web/roomkit/vue3/RoomKit/components/ASRTools/components/Subtitle.vue +++ /dev/null @@ -1,379 +0,0 @@ - - - - - diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/constants.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/constants.ts index 40de65b6f..80d9edc32 100644 --- a/Web/roomkit/vue3/RoomKit/components/ASRTools/constants.ts +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/constants.ts @@ -1 +1,111 @@ +import type { ASRSettingsOption } from 'tuikit-atomicx-vue3/room'; + +/** A speaker's caption line is dropped this long after its last update. */ export const SUBTITLE_CLEAR_DELAY_MS = 5000; + +/** Upper bound of speakers rendered side by side in the caption dock. */ +export const MAX_CONCURRENT_SPEAKERS = 2; + +/** Caption lines rendered per speaker: one speaker may wrap, two may not. */ +export const MAX_LINES_SINGLE_SPEAKER = 2; +export const MAX_LINES_MULTI_SPEAKER = 1; +/** Enter/leave duration for a concurrent speaker row. */ +export const SPEAKER_ROW_TRANSITION_MS = 280; + +/** + * Soft cap on concatenated caption text kept per speaker. The overlay still + * clips to `MAX_LINES_*`; this only bounds the streaming DOM. + */ +export const MAX_CAPTION_BUFFER_CHARS = 240; + +/** Trailing graphemes that fade while a caption is still streaming. */ +export const STREAM_FADE_CHARS = 6; +export const STREAM_FADE_MIN_OPACITY = 0.2; +/** Stagger between newly appended graphemes when a chunk arrives at once. */ +export const STREAM_CHAR_REVEAL_MS = 30; + +export const DEFAULT_SOURCE_LANGUAGE = 'zh'; +export const DEFAULT_TARGET_LANGUAGE = 'en'; + +/** + * RoomKit-level caption display mode. + * + * Deliberately not `SubtitleDisplayMode` from atomicx: that enum has no + * "source only" value. RoomKit always renders the source as the primary line + * and optionally shows the translation as a secondary line. + */ +export type CaptionDisplayMode = 'bilingual' | 'source-only'; + +/** + * Which placeholder to show while transcription is off. + * `member-stopped` is distinguished from `member-waiting` so a participant who + * lost a running feature gets an explanation rather than a waiting prompt. + */ +export type ASREmptyVariant = 'host-idle' | 'member-waiting' | 'member-stopped'; + +export type CaptionFontSize = 's' | 'm' | 'l'; + +export const DEFAULT_CAPTION_FONT_SIZE: CaptionFontSize = 's'; + +/** Floor for PC caption-dock width so the action strip and avatar still fit. */ +export const MIN_CAPTION_DOCK_WIDTH = 360; +/** Inset the dock keeps from `.room-container` edges while resizing. */ +export const CAPTION_DOCK_EDGE_PADDING = 16; + +export const CAPTION_FONT_SIZE_PC: Record = { + s: 13, + m: 16, + l: 20, +}; + +export const CAPTION_FONT_SIZE_H5: Record = { + s: 12, + m: 14, + l: 18, +}; + +/** Language options shown before the "show more" fold in language selectors. */ +export const LANGUAGE_COLLAPSE_COUNT = 5; + +/** The caption dock dims itself after this long without any new speech. */ +export const SUBTITLE_IDLE_MS = 5000; + +export const createSourceLanguageOptions = (t: (key: string) => string): ASRSettingsOption[] => [ + { label: t('AITools.SourceLanguageChineseEnglish'), value: 'zh' }, + { label: t('AITools.LanguageEnglish'), value: 'en' }, + { label: t('AITools.LanguageCantonese'), value: 'zh-yue' }, + { label: t('AITools.LanguageVietnamese'), value: 'vi' }, + { label: t('AITools.LanguageJapanese'), value: 'ja' }, + { label: t('AITools.LanguageKorean'), value: 'ko' }, + { label: t('AITools.LanguageIndonesian'), value: 'id' }, + { label: t('AITools.LanguageThai'), value: 'th' }, + { label: t('AITools.LanguagePortuguese'), value: 'pt' }, + { label: t('AITools.LanguageTurkish'), value: 'tr' }, + { label: t('AITools.LanguageArabic'), value: 'ar' }, + { label: t('AITools.LanguageSpanish'), value: 'es' }, + { label: t('AITools.LanguageHindi'), value: 'hi' }, + { label: t('AITools.LanguageFrench'), value: 'fr' }, + { label: t('AITools.LanguageMalay'), value: 'ms' }, + { label: t('AITools.LanguageFilipino'), value: 'fil' }, + { label: t('AITools.LanguageGerman'), value: 'de' }, + { label: t('AITools.LanguageItalian'), value: 'it' }, + { label: t('AITools.LanguageRussian'), value: 'ru' }, +]; + +export const createTargetLanguageOptions = (t: (key: string) => string): ASRSettingsOption[] => [ + { label: t('AITools.LanguageChineseSimplified'), value: 'zh' }, + { label: t('AITools.LanguageEnglish'), value: 'en' }, + { label: t('AITools.LanguageVietnamese'), value: 'vi' }, + { label: t('AITools.LanguageJapanese'), value: 'ja' }, + { label: t('AITools.LanguageKorean'), value: 'ko' }, + { label: t('AITools.LanguageIndonesian'), value: 'id' }, + { label: t('AITools.LanguageThai'), value: 'th' }, + { label: t('AITools.LanguagePortuguese'), value: 'pt' }, + { label: t('AITools.LanguageArabic'), value: 'ar' }, + { label: t('AITools.LanguageSpanish'), value: 'es' }, + { label: t('AITools.LanguageFrench'), value: 'fr' }, + { label: t('AITools.LanguageMalay'), value: 'ms' }, + { label: t('AITools.LanguageGerman'), value: 'de' }, + { label: t('AITools.LanguageItalian'), value: 'it' }, + { label: t('AITools.LanguageRussian'), value: 'ru' }, +]; diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButton.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButton.vue new file mode 100644 index 000000000..d43627741 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButton.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButtonH5.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButtonH5.vue new file mode 100644 index 000000000..4242e0d9d --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRButtonH5.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASREntryMenu.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASREntryMenu.vue new file mode 100644 index 000000000..e127ec22b --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASREntryMenu.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/AIToolsRegistrar.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRRegistrar.vue similarity index 63% rename from Web/roomkit/vue3/RoomKit/components/AIToolsButton/AIToolsRegistrar.vue rename to Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRRegistrar.vue index a9a88436c..44f64e6ab 100644 --- a/Web/roomkit/vue3/RoomKit/components/AIToolsButton/AIToolsRegistrar.vue +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/entry/ASRRegistrar.vue @@ -1,10 +1,10 @@ + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/index.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/index.ts index e25280f01..8c617e53e 100644 --- a/Web/roomkit/vue3/RoomKit/components/ASRTools/index.ts +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/index.ts @@ -1,58 +1,8 @@ /** - * @module ASRToolsComponent - * @description 语音识别工具组件 - * - * 用于在视频会议中显示实时字幕和转录消息列表的语音识别组件。 + * Public ASR widgets and registrars. + * Caption overlay, record panel, and settings stay inside their feature folders. */ - -/** - * 实时字幕组件 - * @component Subtitle - * @description 实时字幕组件,用于在语音识别过程中显示说话人姓名和实时转录的文本内容。 - * 字幕会在语音识别完成 3 秒后自动消失。 - * - * @features - * - 实时显示语音识别内容 - * - 语音识别完成后自动清除字幕 - * - 支持多说话人同时显示 - * - 响应式布局,支持最大宽度限制 - * - * @example - * - * - * - */ - -/** - * 实时消息列表组件 - * @component RealtimeMessageList - * @description 实时消息列表组件,用于显示已完成的语音识别消息列表,消息按说话人和时间间隔自动分组。 - * 消息按时间顺序排列,并自动滚动显示最新内容。 - * - * @features - * - 按时间顺序显示已完成的语音识别消息 - * - 按说话人和时间间隔(60 秒)自动分组 - * - 智能自动滚动,尊重用户手动滚动行为 - * - 显示每组消息的时间戳 - * - 自定义滚动条样式 - * - * @example - * - * - * - */ -import RealtimeMessageListComponent from './components/RealtimeMessageList.vue'; -import SubtitleComponent from './components/Subtitle.vue'; - -const Subtitle = SubtitleComponent; -const RealtimeMessageList = RealtimeMessageListComponent; - -export { Subtitle, RealtimeMessageList }; +export { default as ASRButton } from './entry/ASRButton.vue'; +export { default as ASRButtonH5 } from './entry/ASRButtonH5.vue'; +export { default as ASRRegistrar } from './entry/ASRRegistrar.vue'; +export { default as ASRRegistrarH5 } from './entry/ASRRegistrarH5.vue'; diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RealtimeMessageList.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RealtimeMessageList.vue new file mode 100644 index 000000000..4a12774ec --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RealtimeMessageList.vue @@ -0,0 +1,483 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordPanel.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordPanel.vue new file mode 100644 index 000000000..a999fae3d --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordPanel.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordSearchBar.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordSearchBar.vue new file mode 100644 index 000000000..fe4f5398f --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/record/RecordSearchBar.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguagePickerSheetH5.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguagePickerSheetH5.vue new file mode 100644 index 000000000..f6749a072 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguagePickerSheetH5.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguageSelect.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguageSelect.vue new file mode 100644 index 000000000..bda78e1a0 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/LanguageSelect.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsPopover.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsPopover.vue new file mode 100644 index 000000000..4cbff04cb --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsPopover.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsSheetH5.vue b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsSheetH5.vue new file mode 100644 index 000000000..145b912c8 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/SubtitleSettingsSheetH5.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/useSubtitleSettings.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/useSubtitleSettings.ts new file mode 100644 index 000000000..bc86eaa1e --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/settings/useSubtitleSettings.ts @@ -0,0 +1,105 @@ +import { computed, ref } from 'vue'; +import { useUIKit } from '@tencentcloud/uikit-base-component-vue3'; +import { + createSourceLanguageOptions, + createTargetLanguageOptions, + type CaptionFontSize, +} from '../constants'; +import { useASRToolsState } from '../useASRToolsState'; +import { useSubtitleViewState } from '../useSubtitleViewState'; + +export type SubtitleSettingsPickerType = 'source' | 'target'; + +/** + * Shared language / font / bilingual controls for the PC popover and H5 sheet. + */ +export function useSubtitleSettings() { + const { t } = useUIKit(); + const { + sourceLanguage, + targetLanguage, + isTransNone, + canManageASR, + confirmSaveSettings, + } = useASRToolsState(); + const { showTrans, fontSize } = useSubtitleViewState(); + + const pickerType = ref(); + + const sourceLanguageOptions = computed(() => createSourceLanguageOptions(t)); + const targetLanguageOptions = computed(() => createTargetLanguageOptions(t)); + + const sourceLanguageLabel = computed( + () => sourceLanguageOptions.value.find(option => option.value === sourceLanguage.value)?.label ?? '', + ); + const targetLanguageLabel = computed( + () => targetLanguageOptions.value.find(option => option.value === targetLanguage.value)?.label ?? '', + ); + + const fontSizeOptions = computed<{ value: CaptionFontSize; label: string }[]>(() => [ + { value: 's', label: t('AITools.FontSizeSmall') }, + { value: 'm', label: t('AITools.FontSizeMedium') }, + { value: 'l', label: t('AITools.FontSizeLarge') }, + ]); + + const transNoneHint = computed(() => ( + canManageASR.value ? t('AITools.TransNoneHostHint') : t('AITools.TransNoneMemberHint') + )); + + const pickerValue = computed(() => ( + pickerType.value === 'source' ? sourceLanguage.value : targetLanguage.value + )); + const pickerOptions = computed(() => ( + pickerType.value === 'source' ? sourceLanguageOptions.value : targetLanguageOptions.value + )); + + function applySourceLanguage(value: string) { + confirmSaveSettings({ sourceLanguage: value }); + } + + function applyTargetLanguage(value: string) { + confirmSaveSettings({ targetLanguage: value }); + } + + function applyPickedLanguage(value: string) { + const type = pickerType.value; + pickerType.value = undefined; + if (!type) { + return; + } + if (type === 'source') { + applySourceLanguage(value); + return; + } + applyTargetLanguage(value); + } + + function openPicker(type: SubtitleSettingsPickerType) { + if (!canManageASR.value) { + return; + } + pickerType.value = type; + } + + return { + sourceLanguage, + targetLanguage, + isTransNone, + canManageASR, + showTrans, + fontSize, + pickerType, + sourceLanguageOptions, + targetLanguageOptions, + sourceLanguageLabel, + targetLanguageLabel, + fontSizeOptions, + transNoneHint, + pickerValue, + pickerOptions, + applySourceLanguage, + applyTargetLanguage, + applyPickedLanguage, + openPicker, + }; +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/styles/asrTooltip.scss b/Web/roomkit/vue3/RoomKit/components/ASRTools/styles/asrTooltip.scss new file mode 100644 index 000000000..7ca616548 --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/styles/asrTooltip.scss @@ -0,0 +1,45 @@ +// Replaces native `title` so hover hints appear after ~100ms instead of ~1s. +@mixin asr-tooltip { + @media (hover: hover) { + &::before { + position: absolute; + top: calc(100% + 6px); + left: 50%; + z-index: 4; + padding: 4px 8px; + color: var(--text-color-primary); + font-family: inherit; + font-size: 12px; + font-weight: 400; + line-height: 18px; + white-space: nowrap; + pointer-events: none; + content: attr(data-tooltip); + background: var(--bg-color-operate); + border-radius: 6px; + box-shadow: + 0 1px 2px var(--uikit-color-black-7), + 0 8px 16px var(--uikit-color-black-6); + opacity: 0; + visibility: hidden; + transform: translateX(-50%); + transition: opacity 0.12s ease, visibility 0s linear 0.12s; + } + + &:hover::before, + &:focus-visible::before { + opacity: 1; + visibility: visible; + transition: opacity 0.12s ease 0.1s, visibility 0s linear 0.1s; + } + + // An open control must not keep a hover hint; an empty `::before` still + // paints a white nub over the panel that just opened. + &:not([data-tooltip])::before, + &[data-tooltip=""]::before, + &[aria-pressed="true"]::before { + content: none; + display: none; + } + } +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/useASRToolsState.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/useASRToolsState.ts new file mode 100644 index 000000000..5e4766e8e --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/useASRToolsState.ts @@ -0,0 +1,395 @@ +import { computed, ref, watch } from 'vue'; +import { TUIErrorCode } from '@tencentcloud/tuiroom-engine-js'; +import { TUIMessageBox, TUIToast, useUIKit } from '@tencentcloud/uikit-base-component-vue3'; +import { useAITranscriberState, useRoomParticipantState, useRoomState, RealtimeTranscriberEvent } from 'tuikit-atomicx-vue3/room'; +import { + DEFAULT_SOURCE_LANGUAGE, + DEFAULT_TARGET_LANGUAGE, + type ASREmptyVariant, +} from './constants'; +import { useSubtitleViewState } from './useSubtitleViewState'; + +/** + * Transcription messages already in flight when the owner stops transcription + * must not flip the state back on. Within this window after an authoritative + * "off" signal, message-based inference is ignored. + */ +const MESSAGE_INFERENCE_GRACE_MS = 3000; + +const sourceLanguage = ref(DEFAULT_SOURCE_LANGUAGE); +const targetLanguage = ref(DEFAULT_TARGET_LANGUAGE); +const asrOn = ref(false); +const asrEverOn = ref(false); +const isRestarting = ref(false); +const isStoppingASR = ref(false); +const startConfirmVisible = ref(false); +const stopConfirmVisible = ref(false); +const showCaptionsOnStart = ref(true); +/** Where the start confirm was opened from; drives title/copy and the captions checkbox. */ +const startConfirmSource = ref<'toggle' | 'subtitle' | 'record'>('toggle'); + +let lastAuthoritativeOffAt = 0; + +const { + startRealtimeTranscriber, + stopRealtimeTranscriber, + updateRealTimeTranscriber, + subscribeEvent, +} = useAITranscriberState(); +const { t } = useUIKit(); +const { currentRoom } = useRoomState(); +const { localParticipant } = useRoomParticipantState(); +const { showTrans, capViewOpen, emptyDismissed, presentCaptionHint } = useSubtitleViewState(); + +const resolveSDKSourceLanguage = (value: string) => value || 'auto'; +const canManageASR = computed(() => currentRoom.value?.roomOwner?.userId === localParticipant.value?.userId); +const isTransNone = computed(() => !targetLanguage.value); + +/** + * Effective caption rendering mode. Falling back to `source-only` (rather than + * to a translation-only view) keeps captions readable when translation is off. + */ +const captionDisplayMode = computed(() => (showTrans.value && !isTransNone.value ? 'bilingual' : 'source-only')); + +/** + * Empty-state copy depends on both the role and whether transcription ever ran: + * a member who never saw it running is waiting, one who did needs to be told it + * was stopped. + */ +const asrEmptyVariant = computed(() => { + if (canManageASR.value) { + return 'host-idle'; + } + return asrEverOn.value ? 'member-stopped' : 'member-waiting'; +}); + +/** + * Overlay while transcription is off: only members who were already watching + * captions keep the dock, so they can see the stopped status line. + */ +const isCaptionEmptyVisible = computed(() => ( + !asrOn.value + && capViewOpen.value + && !emptyDismissed.value + && asrEmptyVariant.value === 'member-stopped' +)); + +const isCaptionShown = computed(() => (asrOn.value ? capViewOpen.value : isCaptionEmptyVisible.value)); + +const setASROn = (next: boolean) => { + const changed = asrOn.value !== next; + asrOn.value = next; + + if (next) { + asrEverOn.value = true; + } else { + lastAuthoritativeOffAt = Date.now(); + } + + if (!changed) { + return; + } + + // A real state change re-presents the truth: a dismissed card must not hide + // the fact that transcription just started or stopped. Whether captions are + // shown stays each participant's own choice — never opened on their behalf. + emptyDismissed.value = false; +}; + +const resetASRToolsState = () => { + sourceLanguage.value = DEFAULT_SOURCE_LANGUAGE; + targetLanguage.value = DEFAULT_TARGET_LANGUAGE; + asrOn.value = false; + asrEverOn.value = false; + isRestarting.value = false; + isStoppingASR.value = false; + startConfirmVisible.value = false; + stopConfirmVisible.value = false; + showCaptionsOnStart.value = true; + startConfirmSource.value = 'toggle'; + lastAuthoritativeOffAt = 0; +}; + +const extractErrorCode = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined; + } + + const candidate = error as Record; + const rawCode = candidate.code ?? candidate.errorCode ?? candidate.errCode; + + if (typeof rawCode === 'number' && Number.isFinite(rawCode)) { + return rawCode; + } + + if (typeof rawCode === 'string') { + const parsed = Number(rawCode); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + if (typeof candidate.message === 'string') { + const match = candidate.message.match(/-?\d{3,6}/); + if (match) { + const parsed = Number(match[0]); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + + return undefined; +}; + +const getASRStartErrorMessage = (error: unknown) => { + const errorCode = extractErrorCode(error); + + switch (errorCode) { + case TUIErrorCode.ERR_REQUIRE_PAYMENT: + return t('AITools.StartFailedPackageRequired'); + default: + return t('AITools.StartFailed'); + } +}; + +// Source 1: authoritative SDK lifecycle events. Late joiners also receive +// `onRealtimeTranscriberStarted` when a transcriber is already running. +subscribeEvent(RealtimeTranscriberEvent.onRealtimeTranscriberStarted, () => { + if (isRestarting.value) { + return; + } + setASROn(true); +}); + +subscribeEvent(RealtimeTranscriberEvent.onRealtimeTranscriberStopped, () => { + if (isRestarting.value) { + return; + } + setASROn(false); +}); + +// Source 2: inference from incoming transcription messages. Covers the +// window where the started event has not arrived yet but speech is flowing. +subscribeEvent(RealtimeTranscriberEvent.onReceiveTranscriberMessage, () => { + if (asrOn.value || isRestarting.value) { + return; + } + if (Date.now() - lastAuthoritativeOffAt < MESSAGE_INFERENCE_GRACE_MS) { + return; + } + setASROn(true); +}); + +watch(() => currentRoom.value?.roomId, (roomId, previousRoomId) => { + if (roomId === previousRoomId) { + return; + } + + if (!roomId) { + resetASRToolsState(); + return; + } + + // Switching rooms must drop the previous room's ASR state. The first enter + // must not: `onRealtimeTranscriberStarted` can arrive while `enterRoom` is + // still writing `currentRoom.roomId`, and a reset here would wipe it. + if (previousRoomId) { + resetASRToolsState(); + } +}, { immediate: true }); + +const stopASR = async (options?: { suppressError?: boolean; resetState?: boolean }) => { + if (!asrOn.value || isStoppingASR.value) { + if (options?.resetState) { + setASROn(false); + } + return; + } + + try { + isStoppingASR.value = true; + await stopRealtimeTranscriber(); + setASROn(false); + // The host who deliberately stopped does not need a card explaining it. + capViewOpen.value = false; + } catch (error) { + if (!options?.suppressError) { + console.error('[useASRToolsState] failed to stop ASR:', error); + throw error; + } + } finally { + if (options?.resetState) { + setASROn(false); + } + isStoppingASR.value = false; + } +}; + +watch( + canManageASR, + (canManage, previousCanManage) => { + if (previousCanManage && !canManage && asrOn.value) { + stopASR({ suppressError: true, resetState: true }); + } + }, +); + +const startASR = async (options?: { showCaptions?: boolean }) => { + if (!canManageASR.value || asrOn.value) { + return; + } + + try { + await startRealtimeTranscriber({ + sourceLanguage: resolveSDKSourceLanguage(sourceLanguage.value), + translationLanguages: targetLanguage.value ? [targetLanguage.value] : [], + }); + setASROn(true); + // Captions are a local view. Only open them when the host opted in on + // the start confirm — never as a hidden side effect of starting the room service. + if (options?.showCaptions) { + presentCaptionHint(); + } + } catch (error) { + console.error('[useASRToolsState] failed to start ASR:', error); + TUIToast.error({ message: getASRStartErrorMessage(error) }); + throw error; + } +}; + +/** + * Owner-only transcription config. Local caption preferences (`showTrans`, + * `fontSize`) are written straight to `useSubtitleViewState` and deliberately + * do not pass through here — they never touch the transcriber. + */ +const saveSettings = async (options: { + sourceLanguage?: string; + targetLanguage?: string; +}) => { + const isOwner = canManageASR.value; + const nextSourceLanguage = isOwner ? (options.sourceLanguage ?? sourceLanguage.value) : sourceLanguage.value; + const nextTargetLanguage = isOwner ? (options.targetLanguage ?? targetLanguage.value) : targetLanguage.value; + const transcriberConfigChanged = isOwner && ( + nextSourceLanguage !== sourceLanguage.value + || nextTargetLanguage !== targetLanguage.value + ); + + sourceLanguage.value = nextSourceLanguage; + targetLanguage.value = nextTargetLanguage; + + if (!transcriberConfigChanged || !asrOn.value) { + return; + } + + // `updateRealTimeTranscriber` restarts the robot (stop then start), which + // emits a stopped event. Without this guard the stopped handler would tear + // down captions and panels for everyone on every language change. + isRestarting.value = true; + try { + await updateRealTimeTranscriber({ + sourceLanguage: resolveSDKSourceLanguage(sourceLanguage.value), + translationLanguages: targetLanguage.value ? [targetLanguage.value] : [], + }); + } finally { + isRestarting.value = false; + } +}; + +/** + * Starting and stopping transcription affects every participant, so both are + * gated behind a confirmation. The start dialog also asks whether to open + * the local caption overlay — that choice is not implied by starting the service. + */ +const confirmStartASR = (source: 'toggle' | 'subtitle' | 'record' = 'toggle') => { + startConfirmSource.value = source; + // Clicking "show captions" already means the host wants the overlay. + showCaptionsOnStart.value = source !== 'record'; + startConfirmVisible.value = true; +}; + +const submitStartASR = async () => { + startConfirmVisible.value = false; + const showCaptions = startConfirmSource.value === 'subtitle' || showCaptionsOnStart.value; + await startASR({ showCaptions }).catch(() => undefined); +}; + +const cancelStartASR = () => { + startConfirmVisible.value = false; +}; + +const confirmStopASR = () => { + stopConfirmVisible.value = true; +}; + +const submitStopASR = async () => { + stopConfirmVisible.value = false; + await stopASR({ suppressError: true }); +}; + +const cancelStopASR = () => { + stopConfirmVisible.value = false; +}; + +/** + * Language changes restart the transcriber for the whole room, so they use + * the same confirmation gate as start / stop. + */ +const confirmSaveSettings = (options: { + sourceLanguage?: string; + targetLanguage?: string; +}) => { + if (!canManageASR.value) { + return; + } + + const isSource = options.sourceLanguage !== undefined; + const nextValue = isSource ? options.sourceLanguage : options.targetLanguage; + const currentValue = isSource ? sourceLanguage.value : targetLanguage.value; + if (nextValue === currentValue) { + return; + } + + TUIMessageBox.confirm({ + title: t(isSource ? 'AITools.ConfirmChangeSourceTitle' : 'AITools.ConfirmChangeTargetTitle'), + content: t(isSource ? 'AITools.ConfirmChangeSourceDesc' : 'AITools.ConfirmChangeTargetDesc'), + confirmText: t('AITools.ConfirmChangeOk'), + cancelText: t('AITools.Cancel'), + callback: async (action) => { + if (action !== 'confirm') { + return; + } + try { + await saveSettings(options); + } catch (error) { + console.error('[useASRToolsState] failed to update transcription settings:', error); + TUIToast.error({ message: t('AITools.SaveSettingsFailed') }); + } + }, + }); +}; + +export function useASRToolsState() { + return { + sourceLanguage, + targetLanguage, + isTransNone, + asrOn, + canManageASR, + captionDisplayMode, + asrEmptyVariant, + isCaptionShown, + stopASR, + startConfirmVisible, + stopConfirmVisible, + startConfirmSource, + showCaptionsOnStart, + confirmStartASR, + submitStartASR, + cancelStartASR, + confirmStopASR, + submitStopASR, + cancelStopASR, + confirmSaveSettings, + }; +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/useSubtitleViewState.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/useSubtitleViewState.ts new file mode 100644 index 000000000..0eb543bda --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/useSubtitleViewState.ts @@ -0,0 +1,142 @@ +import { ref, watch } from 'vue'; +import type { Ref } from 'vue'; +import { TUIToast, useUIKit } from '@tencentcloud/uikit-base-component-vue3'; +import { useRoomState } from 'tuikit-atomicx-vue3/room'; +import { DEFAULT_CAPTION_FONT_SIZE, type CaptionFontSize } from './constants'; + +export interface CaptionDockPosition { + x: number; + y: number; + /** Restored after remount so a bottom-pinned dock keeps growing upward. */ + pinnedToBottom?: boolean; + /** Sticky gap from the container bottom; not reduced when the toolbar hides. */ + bottomOffset?: number; +} + +export interface CaptionDockFrozenBox { + /** Visual center X in `.room-container` coordinates; paired with translateX(-50%). */ + centerX: number; + width: number; +} + +interface UseSubtitleViewStateReturn { + capViewOpen: Ref; + recViewOpen: Ref; + emptyDismissed: Ref; + showTrans: Ref; + fontSize: Ref; + dockPosition: Ref; + /** User-chosen PC overlay width in px; `null` keeps the CSS 50% default. */ + dockWidth: Ref; + dockFrozenBox: Ref; + isIdle: Ref; + enableHintVisible: Ref; + hasCaptionMessages: Ref; + presentCaptionHint: () => void; + hideCaptionOverlay: () => void; +} + +/** + * Local, per-participant view state for AI captions. + * + * Everything here is deliberately separate from the room-wide transcription + * state in `useASRToolsState`: the owner controls whether transcription runs, + * while each participant independently controls what they see. + * + * The refs live at module level so that they survive the component re-creation + * caused by the overflow recalculation in CustomWidgetRenderer (visible <-> + * overflow transitions destroy and re-mount the ASR entry button). + */ +const capViewOpen = ref(false); +/** H5 records sheet. PC derives the same flag from the side-panel widget id. */ +const recViewOpen = ref(false); +const emptyDismissed = ref(false); +/** Local bilingual overlay; off by default so captions start as source-only. */ +const showTrans = ref(false); +const fontSize = ref(DEFAULT_CAPTION_FONT_SIZE); +const dockPosition = ref(null); +const dockWidth = ref(null); +/** + * Pixel center/width captured before the records panel shrinks `.room-container`. + * Lives at module level so a toolbar-overflow remount does not lose it and + * re-center the dock under the cursor. + */ +const dockFrozenBox = ref(null); +const isIdle = ref(false); +/** One-shot "captions are on" hint, shown only when the user opens the overlay. */ +const enableHintVisible = ref(false); +/** True while at least one speaker line is still on the overlay. */ +const hasCaptionMessages = ref(false); + +const resetSubtitleViewState = () => { + capViewOpen.value = false; + recViewOpen.value = false; + emptyDismissed.value = false; + showTrans.value = false; + fontSize.value = DEFAULT_CAPTION_FONT_SIZE; + dockPosition.value = null; + dockWidth.value = null; + dockFrozenBox.value = null; + isIdle.value = false; + enableHintVisible.value = false; + hasCaptionMessages.value = false; +}; + +const { t } = useUIKit(); +const { currentRoom } = useRoomState(); + +/** + * Show the one-shot "captions are on" intro. Used both when the overlay first + * opens and when the user clicks to show it again after it has faded idle. + */ +const presentCaptionHint = () => { + // Drop leftover idle from a previous start/stop cycle before the dock + // mounts, otherwise the intro is either invisible or killed by a stale timer. + isIdle.value = false; + enableHintVisible.value = true; + capViewOpen.value = true; + TUIToast.info({ message: t('ASRTools.CaptionVisibleOnlyToSelf') }); +}; + +const hideCaptionOverlay = () => { + if (!capViewOpen.value) { + return; + } + capViewOpen.value = false; + TUIToast.info({ message: t('ASRTools.CaptionClosed') }); +}; + +watch(capViewOpen, (open) => { + if (!open) { + enableHintVisible.value = false; + hasCaptionMessages.value = false; + isIdle.value = false; + } +}, { flush: 'sync' }); + +watch( + () => currentRoom.value?.roomId, + (roomId, previousRoomId) => { + if (roomId !== previousRoomId) { + resetSubtitleViewState(); + } + }, +); + +export function useSubtitleViewState(): UseSubtitleViewStateReturn { + return { + capViewOpen, + recViewOpen, + emptyDismissed, + showTrans, + fontSize, + dockPosition, + dockWidth, + dockFrozenBox, + isIdle, + enableHintVisible, + hasCaptionMessages, + presentCaptionHint, + hideCaptionOverlay, + }; +} diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/captionStream.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/captionStream.ts new file mode 100644 index 000000000..f5f48643b --- /dev/null +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/captionStream.ts @@ -0,0 +1,171 @@ +export interface StreamCursor { + enterFrom: number; + baseIndex: number; +} + +export interface StreamChar { + key: string; + ch: string; + opacity: number; + isEnter: boolean; +} + +export interface StreamWord { + key: string; + text: string; + nowrap: boolean; + isPlain: boolean; + chars: StreamChar[]; +} + +export const splitGraphemes = (value: string): string[] => { + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)] + .map(part => part.segment); + } + return Array.from(value); +}; + +export const splitWordSegments = (value: string): Array<{ text: string; nowrap: boolean }> => { + if (!value) { + return []; + } + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + return [...new Intl.Segmenter(undefined, { granularity: 'word' }).segment(value)] + .map(part => ({ + text: part.segment, + nowrap: Boolean(part.isWordLike), + })); + } + return value.split(/(\s+)/).filter(Boolean).map(text => ({ + text, + nowrap: !/^\s+$/.test(text), + })); +}; + +export const getCommonPrefixLength = (prev: string[], next: string[]): number => { + const limit = Math.min(prev.length, next.length); + let index = 0; + while (index < limit && prev[index] === next[index]) { + index += 1; + } + return index; +}; + +export const getWindowOverlap = (prev: string[], next: string[]): number => { + const maxOverlap = Math.min(prev.length, next.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + let matched = true; + for (let index = 0; index < overlap; index += 1) { + if (prev[prev.length - overlap + index] !== next[index]) { + matched = false; + break; + } + } + if (matched) { + return overlap; + } + } + return 0; +}; + +/** + * Shared prefix is already on screen; never reveal rewritten tail as if it + * had already been typed, and never run past the new string. + */ +export const clampRevealedCount = ( + revealedCount: number, + enterFrom: number, + length: number, +): number => Math.max(0, Math.min(revealedCount, enterFrom, length)); + +export const resolveStreamUpdate = ( + prev: { streamKey: string; text: string; baseIndex: number }, + next: { streamKey: string; text: string }, +): StreamCursor => { + if (prev.streamKey !== next.streamKey) { + return { enterFrom: 0, baseIndex: 0 }; + } + const previous = splitGraphemes(prev.text); + const following = splitGraphemes(next.text); + const prefix = getCommonPrefixLength(previous, following); + if (prefix > 0) { + return { enterFrom: prefix, baseIndex: prev.baseIndex }; + } + const overlap = getWindowOverlap(previous, following); + if (overlap > 0) { + return { + enterFrom: overlap, + baseIndex: prev.baseIndex + previous.length - overlap, + }; + } + return { enterFrom: 0, baseIndex: prev.baseIndex }; +}; + +export const getStreamCharOpacity = ( + index: number, + revealedLength: number, + isCompleted: boolean, + fadeChars: number, + fadeMinOpacity: number, +): number => { + if (isCompleted || revealedLength === 0) { + return 1; + } + const fadeStart = Math.max(0, revealedLength - fadeChars); + if (index < fadeStart) { + return 1; + } + const fadeSpan = Math.max(1, fadeChars - 1); + const progress = (index - fadeStart) / fadeSpan; + return 1 - progress * (1 - fadeMinOpacity); +}; + +export const buildStreamWords = (options: { + graphemes: string[]; + revealedCount: number; + streamKey: string; + baseIndex: number; + enterFrom: number; + isCompleted: boolean; + settledKeys: Set; + fadeChars: number; + fadeMinOpacity: number; +}): StreamWord[] => { + const visible = options.graphemes.slice(0, Math.max(0, options.revealedCount)); + const segments = splitWordSegments(visible.join('')); + const words: StreamWord[] = []; + let index = 0; + + segments.forEach((segment) => { + const startIndex = index; + const chars: StreamChar[] = splitGraphemes(segment.text).map((ch) => { + const charIndex = index; + index += 1; + const key = `${options.streamKey}:${options.baseIndex + charIndex}:${ch}`; + return { + key, + ch, + opacity: getStreamCharOpacity( + charIndex, + visible.length, + options.isCompleted, + options.fadeChars, + options.fadeMinOpacity, + ), + isEnter: !options.isCompleted + && charIndex >= options.enterFrom + && !options.settledKeys.has(key), + }; + }); + words.push({ + key: `${options.streamKey}:${options.baseIndex + startIndex}`, + text: segment.text, + nowrap: segment.nowrap, + isPlain: chars.every(char => !char.isEnter && char.opacity === 1), + chars, + }); + }); + + return words; +}; diff --git a/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/display.ts b/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/display.ts index eedd885ca..471a0b508 100644 --- a/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/display.ts +++ b/Web/roomkit/vue3/RoomKit/components/ASRTools/utils/display.ts @@ -1,17 +1,21 @@ import { useRoomParticipantState } from 'tuikit-atomicx-vue3/room'; -import type { SubtitleDisplayMode, TranscriberLanguage, TranscriberMessage, TranslationText } from 'tuikit-atomicx-vue3/room'; +import type { CaptionDisplayMode } from '../constants'; +import type { TranscriberLanguage, TranscriberMessage, TranslationText } from 'tuikit-atomicx-vue3/room'; const { participantList } = useRoomParticipantState(); const normalizePlainText = (value: string | number | boolean | null | undefined) => String(value ?? '').trim(); const getSourceText = (message: TranscriberMessage) => normalizePlainText(message.sourceText); const getTranslationTextValue = (translation: TranslationText) => normalizePlainText(translation.text); +const getParticipant = (userId: string) => participantList.value.find(p => p.userId === userId); export const getDisplayName = (userId: string) => { - const participant = participantList.value.find(p => p.userId === userId); + const participant = getParticipant(userId); return participant?.nameCard || participant?.userName || participant?.userId || userId; }; +export const getDisplayAvatar = (userId: string) => getParticipant(userId)?.avatarUrl || ''; + export const hasDisplayableText = (value: string | number | boolean | null | undefined) => !!normalizePlainText(value); const getTranslationLanguage = (value: unknown): string => { @@ -62,16 +66,73 @@ export const getTranslationText = ( return pickTranslationText(normalizeTranslations(message.translationTexts), targetLanguage); }; -export const hasTranslationText = (message: TranscriberMessage) => normalizeTranslations(message.translationTexts).length > 0; +/** + * Concatenate consecutive caption turns without forcing a visual jump. + * CJK and already-punctuated tails stay glued; Latin words get a space. + */ +export const joinCaptionText = (left: string, right: string): string => { + if (!left) { + return right; + } + if (!right) { + return left; + } + const glueWithSpace = !/[\s\u3400-\u9fff。!?、,,;;::]$/.test(left) + && !/^[\s\u3400-\u9fff]/.test(right); + return glueWithSpace ? `${left} ${right}` : `${left}${right}`; +}; -export const getMessageDisplayLines = ( +/** + * Overlay and record both read source first, then translation. `translation` + * is empty when `mode` is `source-only` or the utterance has no target text. + * The caption overlay always requests `bilingual` so hide can animate the + * existing slot closed instead of deleting the string and snapping height. + */ +export const getRecordLines = ( message: TranscriberMessage, targetLanguage: TranscriberLanguage | '', - displayMode: SubtitleDisplayMode, -) => { - const sourceText = getSourceText(message); - const translationText = getTranslationText(message, targetLanguage); - return displayMode === 'translation' - ? [translationText].filter(Boolean) - : [sourceText, translationText].filter(Boolean); + mode: CaptionDisplayMode, +): { source: string; translation: string } => { + const source = getSourceText(message); + if (mode === 'source-only') { + return { source, translation: '' }; + } + return { source, translation: getTranslationText(message, targetLanguage) }; +}; + +export interface HighlightSegment { + text: string; + isMatch: boolean; +} + +/** + * Splits text around every case-insensitive occurrence of `keyword`, so search + * hits can be rendered as `` elements. Returning segments instead of an + * HTML string keeps Vue's escaping in charge — no manual escaping needed. + */ +export const splitHighlightSegments = (text: string, keyword: string): HighlightSegment[] => { + const needle = keyword.trim().toLowerCase(); + if (!needle || !text) { + return [{ text, isMatch: false }]; + } + + const haystack = text.toLowerCase(); + const segments: HighlightSegment[] = []; + let cursor = 0; + let hit = haystack.indexOf(needle); + + while (hit !== -1) { + if (hit > cursor) { + segments.push({ text: text.slice(cursor, hit), isMatch: false }); + } + segments.push({ text: text.slice(hit, hit + needle.length), isMatch: true }); + cursor = hit + needle.length; + hit = haystack.indexOf(needle, cursor); + } + + if (cursor < text.length) { + segments.push({ text: text.slice(cursor), isMatch: false }); + } + + return segments; }; diff --git a/Web/roomkit/vue3/RoomKit/components/CallButton/index.vue b/Web/roomkit/vue3/RoomKit/components/CallButton/index.vue index 4d567d1df..0614854b5 100644 --- a/Web/roomkit/vue3/RoomKit/components/CallButton/index.vue +++ b/Web/roomkit/vue3/RoomKit/components/CallButton/index.vue @@ -1,28 +1,39 @@