diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts new file mode 100644 index 000000000..4ebf6b090 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' + +import { useCaptionItems } from './useCaptionItems' + +describe('useCaptionItems', () => { + it('expires each caption event without cancelling earlier events of the same type', () => { + vi.useFakeTimers() + + try { + const captions = useCaptionItems({ ttlMs: 1000 }) + + captions.add({ type: 'caption-speaker', text: 'first' }) + vi.advanceTimersByTime(500) + captions.add({ type: 'caption-speaker', text: 'second' }) + + expect(captions.items.value.map(item => item.text)).toEqual(['first', 'second']) + + vi.advanceTimersByTime(500) + + expect(captions.items.value.map(item => item.text)).toEqual(['second']) + + vi.advanceTimersByTime(500) + + expect(captions.items.value).toEqual([]) + } + finally { + vi.useRealTimers() + } + }) + + it('clears caption items of the matching type when an empty event arrives', () => { + vi.useFakeTimers() + + try { + const captions = useCaptionItems({ ttlMs: 1000 }) + + captions.add({ type: 'caption-speaker', text: 'speaker' }) + captions.add({ type: 'caption-assistant', text: 'assistant' }) + captions.add({ type: 'caption-speaker', text: '' }) + + expect(captions.items.value.map(item => item.text)).toEqual(['assistant']) + + vi.advanceTimersByTime(1000) + + expect(captions.items.value).toEqual([]) + } + finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts new file mode 100644 index 000000000..f19b3a278 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts @@ -0,0 +1,102 @@ +import { readonly, shallowRef } from 'vue' + +export type CaptionChannelEvent + = | { type: 'caption-speaker', text: string } + | { type: 'caption-assistant', text: string } + +export interface CaptionItem { + /** Stable render key and timer owner for one broadcast caption event. */ + id: number + /** Caption source, used for styling and explicit type-level clears. */ + type: CaptionChannelEvent['type'] + /** Text payload rendered by the overlay. */ + text: string +} + +export interface UseCaptionItemsOptions { + /** + * How long one caption event should stay visible before removing itself. + * + * @default 5000 + */ + ttlMs?: number +} + +const defaultCaptionItemsOptions = { + ttlMs: 5_000, +} satisfies Required + +/** + * Manages caption overlay items with per-event expiry. + * + * Use when: + * - Broadcast caption updates should age out independently. + * - Empty caption events should clear only the matching caption source. + * + * Expects: + * - Callers pass plain caption broadcast events. + * - Callers call `dispose()` when the owner outlives Vue component cleanup. + * + * Returns: + * - Readonly caption items plus actions for adding events and clearing timers. + */ +export function useCaptionItems(options: UseCaptionItemsOptions = {}) { + const { ttlMs } = { ...defaultCaptionItemsOptions, ...options } + const items = shallowRef([]) + const expiryTimers = new Map>() + let nextId = 1 + + function clearTimer(id: CaptionItem['id']) { + const timer = expiryTimers.get(id) + if (!timer) + return + + clearTimeout(timer) + expiryTimers.delete(id) + } + + function remove(id: CaptionItem['id']) { + clearTimer(id) + items.value = items.value.filter(item => item.id !== id) + } + + function clearType(type: CaptionChannelEvent['type']) { + const matchedItems = items.value.filter(item => item.type === type) + for (const item of matchedItems) { + clearTimer(item.id) + } + items.value = items.value.filter(item => item.type !== type) + } + + function add(event: CaptionChannelEvent) { + if (!event.text.trim()) { + clearType(event.type) + return + } + + const item: CaptionItem = { + id: nextId++, + type: event.type, + text: event.text, + } + items.value = [...items.value, item] + expiryTimers.set(item.id, setTimeout(() => { + remove(item.id) + }, ttlMs)) + } + + function dispose() { + for (const timer of expiryTimers.values()) { + clearTimeout(timer) + } + expiryTimers.clear() + items.value = [] + } + + return { + items: readonly(items), + add, + clearType, + dispose, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/caption.vue b/apps/stage-tamagotchi/src/renderer/pages/caption.vue index cea305f13..7fcec6c0f 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/caption.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/caption.vue @@ -1,29 +1,57 @@