diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts new file mode 100644 index 000000000..6505a9a89 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts @@ -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: '
', inheritAttrs: false }, + TooltipProvider: { template: '
' }, + TooltipRoot: { template: '
' }, + TooltipTrigger: { template: '
' }, +})) + +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() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue new file mode 100644 index 000000000..5d2c7ee02 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue index 093ade735..70ca86bab 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue @@ -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() { + +
diff --git a/packages/i18n/src/locales/en/tamagotchi/stage.yaml b/packages/i18n/src/locales/en/tamagotchi/stage.yaml index 4be6cfefc..f3cf31c41 100644 --- a/packages/i18n/src/locales/en/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/es/tamagotchi/stage.yaml b/packages/i18n/src/locales/es/tamagotchi/stage.yaml index b1778330f..c4a0fdb79 100644 --- a/packages/i18n/src/locales/es/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/es/tamagotchi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/fr/tamagotchi/stage.yaml b/packages/i18n/src/locales/fr/tamagotchi/stage.yaml index 2c06e8203..958269c93 100644 --- a/packages/i18n/src/locales/fr/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/fr/tamagotchi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/ja/tamagotchi/stage.yaml b/packages/i18n/src/locales/ja/tamagotchi/stage.yaml index 3500b2567..8ab6efdac 100644 --- a/packages/i18n/src/locales/ja/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/ja/tamagotchi/stage.yaml @@ -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': ピン留め diff --git a/packages/i18n/src/locales/ko/tamagotchi/stage.yaml b/packages/i18n/src/locales/ko/tamagotchi/stage.yaml index bf6b21f39..61bb7cdf9 100644 --- a/packages/i18n/src/locales/ko/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/ko/tamagotchi/stage.yaml @@ -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': 맨 위에 고정 diff --git a/packages/i18n/src/locales/ru/tamagotchi/stage.yaml b/packages/i18n/src/locales/ru/tamagotchi/stage.yaml index 89d1fcfb1..cfc186516 100644 --- a/packages/i18n/src/locales/ru/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/ru/tamagotchi/stage.yaml @@ -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': Прикрепить на верх diff --git a/packages/i18n/src/locales/vi/tamagotchi/stage.yaml b/packages/i18n/src/locales/vi/tamagotchi/stage.yaml index e7f30a394..504f2d44f 100644 --- a/packages/i18n/src/locales/vi/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/vi/tamagotchi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml index b680e6d73..4ab0d56a6 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml @@ -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': 置顶窗口 diff --git a/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml index de405e107..c0dac0eaf 100644 --- a/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml @@ -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': 置頂 diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts index 776a6ef1a..c53336d52 100644 --- a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts +++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts @@ -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', + }) + }) }) diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts index 8c64955d4..305b1acd3 100644 --- a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts +++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts @@ -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, } } diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index b0e04b928..87327f4a8 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -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', { diff --git a/packages/stage-ui/src/stores/speech-output-control.test.ts b/packages/stage-ui/src/stores/speech-output-control.test.ts index 11105f33f..e4635a08a 100644 --- a/packages/stage-ui/src/stores/speech-output-control.test.ts +++ b/packages/stage-ui/src/stores/speech-output-control.test.ts @@ -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', + }) + }) }) diff --git a/packages/stage-ui/src/stores/speech-output-control.ts b/packages/stage-ui/src/stores/speech-output-control.ts index 3e2a8004e..eee9a60df 100644 --- a/packages/stage-ui/src/stores/speech-output-control.ts +++ b/packages/stage-ui/src/stores/speech-output-control.ts @@ -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.