Browse Source

refactor: route DialogueChatView click-playback through useAudioPlayer

Replace view-local currentAudio / stopCurrentPlayback /
playingMessageId with the new player composable. togglePlay
now dispatches student blobs and AI text to player.play, and
handleStartRecording / handleRestart call player.stop so
recording always interrupts audio. WebSocket path now attaches
the recorded blob to its student message (engine.attachStudentBlob)
so click-replay works in WS mode.

Auto-play of new AI replies is restored in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 2 tuần trước cách đây
mục cha
commit
f9b153a3e3
1 tập tin đã thay đổi với 14 bổ sung53 xóa
  1. 14 53
      src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

+ 14 - 53
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -49,7 +49,7 @@
             <!-- 音频条 -->
             <div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
               <button class="play-btn play-ai" @click="togglePlay(message.id)">
-                <svg v-if="playingMessageId !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
+                <svg v-if="player.playingId.value !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
                   <polygon points="5 3 19 12 5 21 5 3" />
                 </svg>
                 <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
@@ -105,7 +105,7 @@
               />
             </div>
             <button class="play-btn play-student" @click="togglePlay(message.id)">
-              <svg v-if="playingMessageId !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
+              <svg v-if="player.playingId.value !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
                 <polygon points="5 3 19 12 5 21 5 3" />
               </svg>
               <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
@@ -427,6 +427,7 @@ import type { PropType } from 'vue'
 import type { PreviewChatMessage, BadgeAchievement, DialogueReport, SessionStartInfo, TaskHint } from '@/types/englishSpeaking'
 import { useDialogueEngine } from '../composables/useDialogueEngine'
 import { useAudioRecorder } from '../composables/useAudioRecorder'
+import { useAudioPlayer } from '../composables/useAudioPlayer'
 import TaskHintModal from './TaskHintModal.vue'
 import { createDialogueApi } from '../services/llmService'
 
@@ -485,6 +486,7 @@ const BADGE_CONFIG: Record<string, BadgeAchievement> = {
 
 const engine = useDialogueEngine()
 const recorder = useAudioRecorder()
+const player = useAudioPlayer()
 
 // ─────────────────────────────────────────────
 // Local UI State
@@ -492,7 +494,6 @@ const recorder = useAudioRecorder()
 
 const chatContainerRef = ref<HTMLDivElement>()
 const expandedMessageId = ref<string | null>(null)
-const playingMessageId = ref<string | null>(null)
 const showHintModal = ref(false)
 const taskHint = ref<TaskHint | null>(null)
 const taskHintLoading = ref(false)
@@ -509,9 +510,6 @@ const phonemeDetail = ref<{
 const silenceHintText = ref('')
 const showIdleHint = ref(false)
 let idleHintTimer: ReturnType<typeof setTimeout> | null = null
-let currentAudio: HTMLAudioElement | null = null
-let currentAudioUrl: string | null = null
-
 
 // 徽章
 const showBadge = ref<BadgeAchievement | null>(null)
@@ -607,6 +605,7 @@ let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
 
 async function handleStartRecording() {
   if (!engine.canRecord.value || recorder.isRecording.value) return
+  player.stop()
   try {
     await recorder.startRecording()
     // 启动流式会话(立即 push UI 占位消息 + 打开 WebSocket)
@@ -644,7 +643,7 @@ async function handleFinishRecording() {
     const blob = await recorder.stopRecording()
     recorder.onChunk.value = null
     if (ctl) {
-      // 走 WebSocket 流式路径。WS 失败时,不自动降级 HTTP —— 让错误泡泡的"重试"按钮走人工路径。
+      engine.attachStudentBlob(ctl.studentMsgId, blob)
       ctl.finish()
     } else {
       // 没启动流式(异常情况)→ 直接走旧 HTTP 路径
@@ -713,59 +712,21 @@ async function loadTaskHint() {
   }
 }
 
-function stopCurrentPlayback() {
-  if (currentAudio) {
-    currentAudio.pause()
-    currentAudio = null
-  }
-  if (currentAudioUrl) {
-    URL.revokeObjectURL(currentAudioUrl)
-    currentAudioUrl = null
-  }
-  if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
-  playingMessageId.value = null
-}
-
 function togglePlay(id: string) {
-  // 已在播放 → 停止
-  if (playingMessageId.value === id) {
-    stopCurrentPlayback()
+  // Same id is currently playing or loading → stop.
+  if (player.playingId.value === id || player.loadingId.value === id) {
+    player.stop()
     return
   }
-  stopCurrentPlayback()
 
   const msg = engine.messages.value.find(m => m.id === id)
   if (!msg) return
 
-  playingMessageId.value = id
-
   if (msg.role === 'student' && msg.audioBlob) {
-    // 学生消息:播放录音 Blob
-    currentAudioUrl = URL.createObjectURL(msg.audioBlob)
-    currentAudio = new Audio(currentAudioUrl)
-    const clearIfSame = () => {
-      if (playingMessageId.value === id) stopCurrentPlayback()
-    }
-    currentAudio.onended = clearIfSame
-    currentAudio.onerror = clearIfSame
-    currentAudio.play().catch((err) => {
-      console.error('[audio] play failed:', err)
-      clearIfSame()
-    })
-  } else if (msg.role === 'ai' && msg.content && typeof speechSynthesis !== 'undefined') {
-    // AI 消息:用浏览器 TTS 重读
-    const utter = new SpeechSynthesisUtterance(msg.content)
-    utter.lang = 'en-US'
-    utter.rate = 0.9
-    const clearIfSame = () => {
-      if (playingMessageId.value === id) playingMessageId.value = null
-    }
-    utter.onend = clearIfSame
-    utter.onerror = clearIfSame
-    speechSynthesis.speak(utter)
-  } else {
-    // 无可播内容,直接复位
-    playingMessageId.value = null
+    player.play(id, { kind: 'blob', blob: msg.audioBlob })
+  }
+  else if (msg.role === 'ai' && msg.content) {
+    player.play(id, { kind: 'tts', text: msg.content })
   }
 }
 
@@ -801,6 +762,7 @@ async function fetchReportSafe(): Promise<DialogueReport | null> {
 function handleRestart() {
   showExitConfirm.value = false
   engine.abort()
+  player.stop()
   emit('restart')
 }
 
@@ -926,7 +888,6 @@ onMounted(() => {
 onUnmounted(() => {
   if (idleHintTimer) clearTimeout(idleHintTimer)
   if (badgeTimer) clearTimeout(badgeTimer)
-  stopCurrentPlayback()
 })
 </script>