fix(stage-ui,stage-tamagotchi): minor animation fix, and caption auto fade out
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user