Files
happy-life-star/mini-program/src/composables/useTtsPlayer.js
T

283 lines
7.4 KiB
JavaScript

import { computed, onUnmounted, ref } from 'vue'
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
import analytics from '../services/analytics.js'
const readResponseData = (response) => {
if (!response) return null
if (Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data ?? null
}
return response
}
export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) => {
const task = ref(null)
const loading = ref(false)
const playing = ref(false)
const statusText = ref('')
let audio = null
let timer = null
let prewarmPromise = null
let prewarmSourceId = ''
const buttonText = computed(() => {
if (loading.value) return '正在生成朗读'
if (task.value?.status === 'success') return playing.value ? '暂停朗读' : '播放朗读'
if (task.value?.status === 'failed') return '重试朗读'
return '语音播放'
})
const clearTimer = () => {
if (timer) clearInterval(timer)
timer = null
}
const stopAudio = () => {
if (!audio) return
audio.stop()
audio.destroy()
audio = null
playing.value = false
}
const setTask = (nextTask) => {
task.value = nextTask
if (audio && nextTask?.audioUrl && audio.src !== nextTask.audioUrl) {
stopAudio()
}
}
const track = (eventName, sourceId, payload = {}) => {
analytics.track(eventName, {
source_id: sourceId || '',
task_id: task.value?.id || '',
...payload
}, { eventType: 'tts', pagePath })
}
const markFailed = (message) => {
loading.value = false
statusText.value = message || '朗读暂时不可用'
uni.showToast({ title: statusText.value, icon: 'none' })
}
const pollTask = (taskId, sourceId) => {
clearTimer()
timer = setInterval(async () => {
try {
const response = await getTtsTask(taskId)
const nextTask = readResponseData(response)
setTask(nextTask)
if (nextTask?.status === 'success') {
loading.value = false
statusText.value = ''
clearTimer()
track('script_tts_success', sourceId, { task_id: nextTask.id })
play(sourceId)
return
}
if (nextTask?.status === 'failed') {
clearTimer()
track('script_tts_error', sourceId, { error: nextTask.errorMessage || '' })
markFailed(nextTask.errorMessage || '朗读生成失败')
}
} catch (error) {
clearTimer()
track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'poll failed' })
markFailed('朗读状态获取失败')
}
}, 2500)
}
const play = (sourceId) => {
if (!task.value?.audioUrl) return
if (!audio) {
audio = uni.createInnerAudioContext()
audio.src = task.value.audioUrl
audio.autoplay = false
audio.onPlay(() => {
playing.value = true
track('script_tts_play', sourceId)
})
audio.onPause(() => {
playing.value = false
track('script_tts_pause', sourceId)
})
audio.onEnded(() => {
playing.value = false
track('script_tts_complete', sourceId)
})
audio.onError((error) => {
playing.value = false
track('script_tts_error', sourceId, { error: error?.errMsg || 'play failed' })
markFailed('音频播放失败')
})
}
if (playing.value) {
audio.pause()
} else {
audio.play()
}
}
const createAndPoll = async (sourceId) => {
loading.value = true
statusText.value = ''
track('script_tts_request', sourceId)
try {
const response = await createTtsTask({ sourceType, sourceId })
const nextTask = readResponseData(response)
setTask(nextTask)
if (nextTask?.status === 'success') {
loading.value = false
track('script_tts_success', sourceId, { task_id: nextTask.id })
play(sourceId)
return
}
if (nextTask?.status === 'failed') {
track('script_tts_error', sourceId, { error: nextTask.errorMessage || '' })
markFailed(nextTask.errorMessage || '朗读生成失败')
return
}
if (nextTask?.id) {
pollTask(nextTask.id, sourceId)
return
}
markFailed('朗读任务创建失败')
} catch (error) {
track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'create failed' })
markFailed(error?.message || '朗读任务创建失败')
}
}
const prewarmSource = async (sourceId) => {
if (!sourceId) return null
if (task.value?.status === 'success' && task.value?.sourceId === sourceId) {
return task.value
}
if (prewarmPromise && prewarmSourceId === sourceId) {
return prewarmPromise
}
prewarmSourceId = sourceId
prewarmPromise = (async () => {
try {
const existingResponse = await getTtsTaskBySource({ sourceType, sourceId })
const existingTask = readResponseData(existingResponse)
if (existingTask?.id) {
setTask(existingTask)
}
if (existingTask?.status === 'success' || existingTask?.status === 'pending' || existingTask?.status === 'processing') {
return existingTask
}
} catch (error) {
// Old scripts may not have a task yet; create one silently below.
}
try {
const createResponse = await createTtsTask({ sourceType, sourceId })
const nextTask = readResponseData(createResponse)
if (nextTask?.id) {
setTask(nextTask)
}
return nextTask
} catch (error) {
track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'prewarm failed' })
return null
} finally {
prewarmPromise = null
prewarmSourceId = ''
}
})()
return prewarmPromise
}
const playSource = async (sourceId) => {
if (!sourceId) {
uni.showToast({ title: '生成保存后可播放', icon: 'none' })
return
}
if (loading.value) return
if (prewarmPromise && prewarmSourceId === sourceId) {
const warmedTask = await prewarmPromise
if (warmedTask?.status === 'success') {
setTask(warmedTask)
play(sourceId)
return
}
if ((warmedTask?.status === 'pending' || warmedTask?.status === 'processing') && warmedTask?.id) {
setTask(warmedTask)
loading.value = true
pollTask(warmedTask.id, sourceId)
return
}
}
if (task.value?.status === 'success') {
play(sourceId)
return
}
try {
const response = await getTtsTaskBySource({ sourceType, sourceId })
const existingTask = readResponseData(response)
setTask(existingTask)
if (existingTask?.status === 'success') {
play(sourceId)
return
}
if (existingTask?.status === 'pending' || existingTask?.status === 'processing') {
loading.value = true
pollTask(existingTask.id, sourceId)
return
}
} catch (error) {
// Missing existing task should fall through and create a new one.
}
await createAndPoll(sourceId)
}
const reset = () => {
clearTimer()
stopAudio()
setTask(null)
statusText.value = ''
loading.value = false
prewarmPromise = null
prewarmSourceId = ''
}
onUnmounted(reset)
return {
task,
loading,
playing,
statusText,
buttonText,
prewarmSource,
playSource,
reset
}
}
export default useTtsPlayer