feat(stage-tamagotchi): universal TTS stop button in controls island (#2072)

Co-authored-by: RainbowBird <git@luoling.moe>
This commit is contained in:
Anfi1
2026-07-23 00:21:58 +08:00
committed by GitHub
co-authored by RainbowBird
parent 5228f94123
commit 42925e58f5
17 changed files with 189 additions and 3 deletions
@@ -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()
})
})
@@ -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.