fix(stage-ui,stage-tamagotchi): minor animation fix, and caption auto fade out

This commit is contained in:
Neko Ayaka
2026-05-17 18:37:36 +08:00
parent b4360c1090
commit c459e647d0
6 changed files with 398 additions and 53 deletions
@@ -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()
}
})
})
@@ -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<UseCaptionItemsOptions>
/**
* 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<CaptionItem[]>([])
const expiryTimers = new Map<CaptionItem['id'], ReturnType<typeof setTimeout>>()
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,
}
}
@@ -1,29 +1,57 @@
<script setup lang="ts">
import { defineInvoke } from '@moeru/eventa'
import { useElectronEventaContext, useElectronMouseAroundWindowBorder, useElectronMouseInWindow } from '@proj-airi/electron-vueuse'
import { createFadeAnimator, PoppinText } from '@proj-airi/stage-ui/components'
import { refDebounced, useBroadcastChannel } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { captionGetIsFollowingWindow, captionIsFollowingWindowChanged } from '../../shared/eventa'
import { useCaptionItems } from '../composables/useCaptionItems'
/** Keep stale captions from lingering after the last broadcast update. */
const CAPTION_TEXT_EXPIRY_MS = 10_000
const attached = ref(true)
const speakerText = ref('')
const assistantText = ref('')
const { isOutside: isOutsideWindow } = useElectronMouseInWindow()
const isOutsideWindowFor250Ms = refDebounced(isOutsideWindow, 250)
const shouldFadeOnCursorWithin = computed(() => !isOutsideWindowFor250Ms.value)
const { isNearAnyBorder: isAroundWindowBorder } = useElectronMouseAroundWindowBorder({ threshold: 30 })
const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
// Broadcast channel for captions
type CaptionChannelEvent
= | { type: 'caption-speaker', text: string }
| { type: 'caption-assistant', text: string }
type CaptionChannelEvent = | { type: 'caption-speaker', text: string } | { type: 'caption-assistant', text: string }
const { data } = useBroadcastChannel<CaptionChannelEvent, CaptionChannelEvent>({ name: 'airi-caption-overlay' })
const { items: captionItems, add: addCaptionItem, dispose: disposeCaptionItems } = useCaptionItems({ ttlMs: CAPTION_TEXT_EXPIRY_MS })
const context = useElectronEventaContext()
const getAttached = defineInvoke(context.value, captionGetIsFollowingWindow)
const captionAnimatorByType = {
'caption-speaker': createFadeAnimator({ duration: 180 }),
'caption-assistant': createFadeAnimator({ duration: 180 }),
} satisfies Record<CaptionChannelEvent['type'], ReturnType<typeof createFadeAnimator>>
const captionTypes = [
'caption-speaker',
'caption-assistant',
] satisfies CaptionChannelEvent['type'][]
function toCaptionTextSegments(type: CaptionChannelEvent['type']) {
return captionItems.value
.filter(item => item.type === type)
.map((item, index) => ({
key: item.id,
text: index === 0 ? item.text : ` ${item.text}`,
}))
}
const captionTextByType = computed(() => ({
'caption-speaker': toCaptionTextSegments('caption-speaker'),
'caption-assistant': toCaptionTextSegments('caption-assistant'),
}))
onMounted(async () => {
try {
const isAttached = await getAttached()
@@ -44,15 +72,19 @@ onMounted(async () => {
if (!event)
return
if (event.type === 'caption-speaker') {
speakerText.value = event.text
addCaptionItem(event)
}
else if (event.type === 'caption-assistant') {
assistantText.value = event.text
addCaptionItem(event)
}
}, { immediate: true })
}
catch {}
})
onUnmounted(() => {
disposeCaptionItems()
})
</script>
<template>
@@ -74,17 +106,20 @@ onMounted(async () => {
<div class="max-w-[80vw] flex flex-col gap-1">
<div
v-if="speakerText"
class="rounded-md px-2 py-1 text-[1.1rem] text-neutral-50 font-medium text-shadow-lg text-shadow-color-neutral-900/60"
v-for="type in captionTypes"
v-show="captionTextByType[type].length > 0"
:key="type"
:class="[
type === 'caption-speaker' ? 'rounded-md px-2 py-1 text-[1.1rem] text-neutral-50 font-medium text-shadow-lg text-shadow-color-neutral-900/60' : '',
type === 'caption-assistant' ? 'rounded-md px-2 py-1 text-[1.35rem] text-primary-50 font-semibold text-stroke-4 text-stroke-primary-300/50 text-shadow-lg text-shadow-color-primary-700/50' : '',
]"
:style="type === 'caption-assistant' ? { paintOrder: 'stroke fill' } : undefined"
>
{{ speakerText }}
</div>
<div
v-if="assistantText"
class="rounded-md px-2 py-1 text-[1.35rem] text-primary-50 font-semibold text-stroke-4 text-stroke-primary-300/50 text-shadow-lg text-shadow-color-primary-700/50"
:style="{ paintOrder: 'stroke fill' }"
>
{{ assistantText }}
<PoppinText
:text="captionTextByType[type]"
:animator="captionAnimatorByType[type]"
:text-class="type === 'caption-assistant' ? 'color-neutral-50! align-middle' : type === 'caption-speaker' ? 'color-neutral-50! align-middle' : ''"
/>
</div>
</div>
</div>
@@ -1,3 +1,4 @@
export { default as ColorPalette } from './ColorPalette.vue'
export * from './poppin-text/animators'
export { default as PoppinText } from './poppin-text/PoppinText.web.vue'
export { default as PoppingSubtitles } from './PoppingSubtitles.web.vue'
@@ -0,0 +1,75 @@
import type { Animator } from './animators'
import { describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import PoppinText from './PoppinText.web.vue'
type AnimatorMock = ReturnType<typeof vi.fn<Animator>>
async function mountPoppinText(params: {
text: ReturnType<typeof ref<Array<{ key: string, text: string }>>>
animator: AnimatorMock
}) {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp({
render: () => h(PoppinText, {
text: params.text.value,
animator: params.animator,
}),
})
app.mount(host)
await nextTick()
return {
app,
host,
}
}
describe('poppin text', () => {
it('animates only newly appended keyed text segments', async () => {
const text = ref([{ key: 'first', text: 'Hi' }])
const animator = vi.fn<Animator>()
const { app, host } = await mountPoppinText({ text, animator })
expect(animator.mock.calls.map(([elements]) => elements.length)).toEqual([2])
text.value = [
{ key: 'first', text: 'Hi' },
{ key: 'second', text: '!' },
]
await nextTick()
await nextTick()
expect(host.textContent).toBe('Hi!')
expect(animator.mock.calls.map(([elements]) => elements.length)).toEqual([2, 1])
app.unmount()
host.remove()
})
it('does not reanimate remaining keyed text segments when earlier segments are removed', async () => {
const text = ref([
{ key: 'first', text: 'Hi' },
{ key: 'second', text: '!' },
])
const animator = vi.fn<Animator>()
const { app, host } = await mountPoppinText({ text, animator })
expect(animator.mock.calls.map(([elements]) => elements.length)).toEqual([3])
text.value = [{ key: 'second', text: '!' }]
await nextTick()
await nextTick()
expect(host.textContent).toBe('!')
expect(animator.mock.calls.map(([elements]) => elements.length)).toEqual([3])
app.unmount()
host.remove()
})
})
@@ -6,12 +6,22 @@ import type { Animator } from './animators'
import { readGraphemeClusters } from 'clustr'
import { onMounted, ref, shallowRef, watch } from 'vue'
interface PoppinTextSegment {
key: string | number
text: string
}
interface PoppinTextTarget {
id: string
grapheme: string
}
const props = defineProps<{
/**
* A plain string or a ReadableStream of bytes from text in UTF-8 encoding.
* A plain string, keyed text segments, or a ReadableStream of bytes from text in UTF-8 encoding.
* If a stream is provided, the stream **SHOULD NOT** be reused. (i.e. You should not set a same stream twice.)
*/
text?: string | ReadableStream<Uint8Array>
text?: string | PoppinTextSegment[] | ReadableStream<Uint8Array>
textClass?: string | string[]
animator?: Animator
}>()
@@ -20,68 +30,139 @@ const emits = defineEmits<{
(e: 'textSplit', grapheme: string): void
}>()
const targets = ref<string[]>([])
const targets = ref<PoppinTextTarget[]>([])
const abortController = shallowRef<AbortController>()
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
const animatedTargetIds = new Set<PoppinTextTarget['id']>()
let plainTextGeneration = 0
let streamTextGeneration = 0
function readTextTargets(text: string, namespace: string): PoppinTextTarget[] {
return Array.from(segmenter.segment(text), (seg, index) => ({
id: `${namespace}:${index}`,
grapheme: seg.segment,
}))
}
function readSegmentTargets(segments: PoppinTextSegment[]): PoppinTextTarget[] {
return segments.flatMap(segment =>
Array.from(segmenter.segment(segment.text), (seg, index) => ({
id: `segment:${segment.key}:${index}`,
grapheme: seg.segment,
})),
)
}
watch(() => props.text, async (text) => {
if (!text)
if (!text) {
animatedTargetIds.clear()
targets.value = []
return
if (typeof text === 'string') {
targets.value = Array.from(segmenter.segment(text), seg => seg.segment)
}
else {
abortController.value?.abort()
abortController.value = new AbortController()
try {
targets.value = []
for await (const cluster of readGraphemeClusters(text.getReader(), { signal: abortController.value.signal })) {
targets.value.push(cluster)
emits('textSplit', cluster)
}
if (typeof text === 'string') {
const nextTargets = readTextTargets(text, `text:${plainTextGeneration}`)
const appendsToPreviousText = targets.value.length <= nextTargets.length
&& targets.value.every((target, index) => target.grapheme === nextTargets[index]?.grapheme)
if (!appendsToPreviousText) {
plainTextGeneration += 1
animatedTargetIds.clear()
targets.value = readTextTargets(text, `text:${plainTextGeneration}`)
return
}
catch (error) {
if (error instanceof Error && error.message === 'Aborted') {
console.warn('Text reading aborted')
}
else {
console.error('Error reading text:', error)
}
targets.value = nextTargets
return
}
if (Array.isArray(text)) {
targets.value = readSegmentTargets(text)
return
}
abortController.value?.abort()
abortController.value = new AbortController()
try {
streamTextGeneration += 1
animatedTargetIds.clear()
targets.value = []
for await (const cluster of readGraphemeClusters(text.getReader(), { signal: abortController.value.signal })) {
targets.value.push({
id: `stream:${streamTextGeneration}:${targets.value.length}`,
grapheme: cluster,
})
emits('textSplit', cluster)
}
}
catch (error) {
if (error instanceof Error && error.message === 'Aborted') {
console.warn('Text reading aborted')
}
else {
console.error('Error reading text:', error)
}
}
}, { immediate: true })
const elements = ref<HTMLElement[]>([])
const animatorCleanupFn = shallowRef<() => void>()
const activeAnimator = shallowRef<Animator>()
onMounted(() => {
animatorCleanupFn.value = props.animator?.(elements.value)
animatorCleanupFn.value = props.animator?.(elements.value.slice())
activeAnimator.value = props.animator
targets.value.forEach(target => animatedTargetIds.add(target.id))
})
const lastAnimatedIndex = ref(-1)
watch([targets, () => props.animator], ([targets, animator]) => {
if (typeof props.text === 'string') {
const animatorChanged = activeAnimator.value !== animator
const targetIds = new Set(targets.map(target => target.id))
for (const id of animatedTargetIds) {
if (!targetIds.has(id))
animatedTargetIds.delete(id)
}
if (animatorChanged) {
animatorCleanupFn.value?.()
animatorCleanupFn.value = animator?.(elements.value)
animatedTargetIds.clear()
}
else {
animator?.(elements.value.slice(lastAnimatedIndex.value, targets.length))
lastAnimatedIndex.value = targets.length
const targetElements = elements.value.filter((_, index) => {
const target = targets[index]
return target && !animatedTargetIds.has(target.id)
})
const cleanup = targetElements.length > 0 ? animator?.(targetElements) : undefined
if (cleanup) {
animatorCleanupFn.value = cleanup
}
targets.forEach(target => animatedTargetIds.add(target.id))
activeAnimator.value = animator
}, { deep: true, flush: 'post' }) // <- Ensure post-update refs
</script>
<template>
<div>
<span
v-for="(grapheme, index) in targets"
:key="index"
v-for="target in targets"
:key="target.id"
ref="elements"
class="inline-block color-primary-400 dark:color-primary-100"
:class="[...(typeof props.textClass === 'string' ? [props.textClass] : (props.textClass || []))]"
class="inline-block whitespace-pre-wrap color-primary-400 dark:color-primary-100"
:class="[
...(
typeof props.textClass === 'string'
? [props.textClass]
: (props.textClass || [])
),
]"
>
{{ grapheme }}
{{ target.grapheme }}
</span>
</div>
</template>