feat(stage-tamagotchi): universal TTS stop button in controls island (#2072)
Co-authored-by: RainbowBird <git@luoling.moe>
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
|
||||
import ControlsIslandStopSpeaking from './controls-island-stop-speaking.vue'
|
||||
|
||||
const nowSpeakingRef = { value: false }
|
||||
const stopAllSpeakingMock = vi.fn()
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
|
||||
useSpeakingStore: () => ({
|
||||
nowSpeaking: nowSpeakingRef,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-layouts/composables/useStopSpeakingButton', () => ({
|
||||
useStopSpeakingButton: () => ({
|
||||
stopAllSpeaking: stopAllSpeakingMock,
|
||||
showStopSpeakingButton: nowSpeakingRef,
|
||||
stopSpeakingFromChat: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store: object) => store,
|
||||
}))
|
||||
|
||||
vi.mock('reka-ui', () => ({
|
||||
TooltipContent: { template: '<div><slot /></div>', inheritAttrs: false },
|
||||
TooltipProvider: { template: '<div><slot /></div>' },
|
||||
TooltipRoot: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
}))
|
||||
|
||||
describe('controlsIslandStopSpeaking', () => {
|
||||
function mountComponent() {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({
|
||||
render: () => h(ControlsIslandStopSpeaking, {
|
||||
buttonStyle: 'p-2',
|
||||
iconClass: 'size-5',
|
||||
}),
|
||||
})
|
||||
app.mount(host)
|
||||
return { host, app }
|
||||
}
|
||||
|
||||
it('renders idle state when not speaking', async () => {
|
||||
nowSpeakingRef.value = false
|
||||
const { host, app } = mountComponent()
|
||||
await nextTick()
|
||||
expect(host.querySelectorAll('button').length).toBeGreaterThan(0)
|
||||
app.unmount()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('renders active state when speaking', async () => {
|
||||
nowSpeakingRef.value = true
|
||||
const { host, app } = mountComponent()
|
||||
await nextTick()
|
||||
expect(host.querySelectorAll('button').length).toBeGreaterThan(0)
|
||||
app.unmount()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('calls stopAllSpeaking on click', async () => {
|
||||
stopAllSpeakingMock.mockClear()
|
||||
nowSpeakingRef.value = false
|
||||
const { host, app } = mountComponent()
|
||||
await nextTick()
|
||||
const button = host.querySelector('button')
|
||||
expect(button).toBeTruthy()
|
||||
button!.click()
|
||||
expect(stopAllSpeakingMock).toHaveBeenCalledTimes(1)
|
||||
app.unmount()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton'
|
||||
import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ControlButtonTooltip from './control-button-tooltip.vue'
|
||||
import ControlButton from './control-button.vue'
|
||||
|
||||
defineProps<{
|
||||
buttonStyle: string
|
||||
iconClass: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { nowSpeaking } = storeToRefs(useSpeakingStore())
|
||||
const { stopAllSpeaking } = useStopSpeakingButton()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButton :button-style @click="stopAllSpeaking()">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="nowSpeaking"
|
||||
key="active"
|
||||
:class="iconClass"
|
||||
i-carbon:face-activated
|
||||
text-red-500
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
key="idle"
|
||||
:class="iconClass"
|
||||
i-carbon:face-neutral
|
||||
text="neutral-800 dark:neutral-300"
|
||||
/>
|
||||
</Transition>
|
||||
</ControlButton>
|
||||
<template #tooltip>
|
||||
{{ nowSpeaking ? t('tamagotchi.stage.controls-island.stop-speaking') : t('tamagotchi.stage.controls-island.speaker-idle') }}
|
||||
</template>
|
||||
</ControlButtonTooltip>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ import ControlsIslandAuthButton from './controls-island-auth-button.vue'
|
||||
import ControlsIslandFadeOnHover from './controls-island-fade-on-hover.vue'
|
||||
import ControlsIslandHearingConfig from './controls-island-hearing-config.vue'
|
||||
import ControlsIslandProfilePicker from './controls-island-profile-picker.vue'
|
||||
import ControlsIslandStopSpeaking from './controls-island-stop-speaking.vue'
|
||||
import IndicatorMicVolume from './indicator-mic-volume.vue'
|
||||
|
||||
import {
|
||||
@@ -272,6 +273,11 @@ function resetMainWindowPosition() {
|
||||
</template>
|
||||
</ControlButtonTooltip>
|
||||
|
||||
<ControlsIslandStopSpeaking
|
||||
:button-style="adjustStyleClasses.button"
|
||||
:icon-class="adjustStyleClasses.icon"
|
||||
/>
|
||||
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButton :button-style="adjustStyleClasses.button" cursor-move :class="{ 'drag-region': isLinux }" @mousedown="startDraggingWindow?.()">
|
||||
<div i-ph:arrows-out-cardinal :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': Open hearing Controls
|
||||
'drag-to-move-window': Drag to move window
|
||||
'stop-speaking': Stop speaking
|
||||
'speaker-idle': Speaker
|
||||
'switch-to-light-mode': Switch to light mode
|
||||
'switch-to-dark-mode': Switch to dark mode
|
||||
'pin-on-top': Pin on top
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': Abrir controles de audición
|
||||
'drag-to-move-window': Arrastra para mover la ventana
|
||||
'stop-speaking': Detener voz
|
||||
'speaker-idle': Altavoz
|
||||
'switch-to-light-mode': Cambiar a modo claro
|
||||
'switch-to-dark-mode': Cambiar a modo oscuro
|
||||
'pin-on-top': Anclar Arriba
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': Ouvrir les contrôles d'écoute
|
||||
'drag-to-move-window': Glisser pour déplacer la fenêtre
|
||||
'stop-speaking': Arrêter la parole
|
||||
'speaker-idle': Haut-parleur
|
||||
'switch-to-light-mode': Passer au mode clair
|
||||
'switch-to-dark-mode': Passer au mode sombre
|
||||
'pin-on-top': Épingler en haut
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': 聴覚コントロールを開く
|
||||
'drag-to-move-window': ウィンドウをドラッグして移動
|
||||
'stop-speaking': 発話停止
|
||||
'speaker-idle': スピーカー
|
||||
'switch-to-light-mode': ライトモードに切り替え
|
||||
'switch-to-dark-mode': ダークモードに切り替え
|
||||
'pin-on-top': ピン留め
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': 듣기 제어 열기
|
||||
'drag-to-move-window': 드레그하여 창 이동
|
||||
'stop-speaking': 말하기 중지
|
||||
'speaker-idle': 스피커
|
||||
'switch-to-light-mode': 라이트 모드로 전환
|
||||
'switch-to-dark-mode': 다크 모드로 전환
|
||||
'pin-on-top': 맨 위에 고정
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Переместить в центр экрана
|
||||
'open-hearing-controls': Управление слухом
|
||||
'drag-to-move-window': Перетащите окно
|
||||
'stop-speaking': Остановить воспроизведение
|
||||
'speaker-idle': Динамик
|
||||
'switch-to-light-mode': Переключить на светлую тему
|
||||
'switch-to-dark-mode': Переключить на темную тему
|
||||
'pin-on-top': Прикрепить на верх
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': Kiểm soát thính giác mở
|
||||
'drag-to-move-window': Kéo để di chuyển cửa sổ
|
||||
'stop-speaking': Dừng nói
|
||||
'speaker-idle': Loa
|
||||
'switch-to-light-mode': Chuyển sang chế độ sáng
|
||||
'switch-to-dark-mode': Chuyển sang chế độ tối
|
||||
'pin-on-top': Ghim lên trên
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': 打开听力控制
|
||||
'drag-to-move-window': 拖动以移动窗口
|
||||
'stop-speaking': 停止说话
|
||||
'speaker-idle': 扬声器
|
||||
'switch-to-light-mode': 切换到亮色模式
|
||||
'switch-to-dark-mode': 切换到暗色模式
|
||||
'pin-on-top': 置顶窗口
|
||||
|
||||
@@ -18,6 +18,8 @@ docs:
|
||||
'center-main-window': Move to screen center
|
||||
'open-hearing-controls': 開啟聽力控制
|
||||
'drag-to-move-window': 長按以移動視窗
|
||||
'stop-speaking': 停止說話
|
||||
'speaker-idle': 揚聲器
|
||||
'switch-to-light-mode': 切換至亮色模式
|
||||
'switch-to-dark-mode': 切換至深色模式
|
||||
'pin-on-top': 置頂
|
||||
|
||||
@@ -55,4 +55,18 @@ describe('useStopSpeakingButton', () => {
|
||||
reason: 'manual-chat',
|
||||
})
|
||||
})
|
||||
|
||||
it('requests a manual-all stop without touching chat input state', () => {
|
||||
requestStopSpeakingMock.mockClear()
|
||||
trackTtsStopClickedMock.mockClear()
|
||||
|
||||
const { stopAllSpeaking } = useStopSpeakingButton()
|
||||
|
||||
stopAllSpeaking()
|
||||
|
||||
expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-all')
|
||||
expect(trackTtsStopClickedMock).toHaveBeenCalledWith({
|
||||
reason: 'manual-all',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import { computed } from 'vue'
|
||||
* - A Stage instance is mounted and consumes speech output stop requests.
|
||||
*
|
||||
* Returns:
|
||||
* - Visibility state for the button and a click handler for manual chat stops.
|
||||
* - Visibility state for the button and click handlers for manual stops.
|
||||
*/
|
||||
export function useStopSpeakingButton() {
|
||||
const { nowSpeaking } = storeToRefs(useSpeakingStore())
|
||||
@@ -28,8 +28,14 @@ export function useStopSpeakingButton() {
|
||||
speechOutputControlStore.requestStopSpeaking('manual-chat')
|
||||
}
|
||||
|
||||
function stopAllSpeaking() {
|
||||
trackTtsStopClicked({ reason: 'manual-all' })
|
||||
speechOutputControlStore.requestStopSpeaking('manual-all')
|
||||
}
|
||||
|
||||
return {
|
||||
showStopSpeakingButton,
|
||||
stopSpeakingFromChat,
|
||||
stopAllSpeaking,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SpeechOutputStopReason } from '../stores/speech-output-control'
|
||||
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
@@ -643,7 +645,7 @@ export function useAnalytics() {
|
||||
|
||||
// ─── Conversation action events ─────────────────────────────────────
|
||||
|
||||
function trackTtsStopClicked(properties: { reason: 'manual-chat' }) {
|
||||
function trackTtsStopClicked(properties: { reason: SpeechOutputStopReason }) {
|
||||
if (!canCapture())
|
||||
return
|
||||
posthog.capture('tts_stop_clicked', {
|
||||
|
||||
@@ -27,4 +27,15 @@ describe('speech output control store', () => {
|
||||
reason: 'manual-chat',
|
||||
})
|
||||
})
|
||||
|
||||
it('records manual-all stop requests with monotonic sequence numbers', () => {
|
||||
const store = useSpeechOutputControlStore()
|
||||
|
||||
store.requestStopSpeaking('manual-all')
|
||||
|
||||
expect(store.latestStopRequest).toEqual({
|
||||
id: 1,
|
||||
reason: 'manual-all',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type SpeechOutputStopReason = 'manual-chat'
|
||||
export type SpeechOutputStopReason = 'manual-chat' | 'manual-all'
|
||||
|
||||
/**
|
||||
* Represents a user-requested stop-speaking command for the stage output host.
|
||||
|
||||
Reference in New Issue
Block a user