feat: add script tts player UI

This commit is contained in:
2026-05-17 10:10:01 +08:00
parent 917b0e5848
commit 6542912d93
3 changed files with 351 additions and 0 deletions
@@ -0,0 +1,319 @@
<template>
<view class="script-audio-player">
<button
class="audio-button"
:class="{ playing, failed: task?.status === 'failed' }"
:disabled="loading"
@click="handleClick"
>
<text class="audio-icon">{{ iconText }}</text>
<text class="audio-label">{{ buttonText }}</text>
</button>
<text v-if="statusText" class="audio-status">{{ statusText }}</text>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
const props = defineProps({
scriptId: { type: String, required: true }
})
const PAGE_PATH = '/pages/main/ScriptDetailView'
const analyticsModules = import.meta.glob('../services/analytics.js', { eager: true })
const analyticsService = analyticsModules['../services/analytics.js']?.default || analyticsModules['../services/analytics.js']
const task = ref(null)
const loading = ref(false)
const playing = ref(false)
const statusText = ref('')
let audio = null
let timer = null
const readResponseData = (response) => response?.data ?? response ?? null
const safeTrack = (eventName, payload = {}) => {
const uniAnalytics = typeof uni !== 'undefined' ? uni.$analytics : null
const analytics = analyticsService || globalThis?.analytics || uniAnalytics
const track = analytics?.track
if (typeof track !== 'function') return
try {
track(eventName, payload, { eventType: 'tts', pagePath: PAGE_PATH })
} catch (error) {
console.warn('[TTS] analytics track failed', error)
}
}
const buttonText = computed(() => {
if (loading.value) return '正在生成'
if (task.value?.status === 'success') return playing.value ? '暂停朗读' : '播放朗读'
if (task.value?.status === 'failed') return '重试朗读'
return '生成朗读'
})
const iconText = 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 markFailed = (message) => {
loading.value = false
statusText.value = message || '朗读暂时不可用'
uni.showToast({ title: statusText.value, icon: 'none' })
}
const pollTask = (id) => {
clearTimer()
timer = setInterval(async () => {
try {
const response = await getTtsTask(id)
const nextTask = readResponseData(response)
setTask(nextTask)
if (nextTask?.status === 'success' || nextTask?.status === 'failed') {
loading.value = false
clearTimer()
statusText.value = nextTask.status === 'failed' ? (nextTask.errorMessage || '朗读生成失败') : ''
safeTrack(nextTask.status === 'success' ? 'script_tts_success' : 'script_tts_error', {
script_id: props.scriptId,
task_id: id,
error: nextTask?.errorMessage || ''
})
}
} catch (error) {
clearTimer()
safeTrack('script_tts_error', {
script_id: props.scriptId,
task_id: id,
error: error?.message || error?.errMsg || 'poll failed'
})
markFailed('朗读状态获取失败')
}
}, 2500)
}
const generate = async () => {
loading.value = true
statusText.value = ''
safeTrack('script_tts_request', { script_id: props.scriptId })
try {
const response = await createTtsTask({ sourceId: props.scriptId })
const nextTask = readResponseData(response)
setTask(nextTask)
if (nextTask?.status === 'success') {
loading.value = false
safeTrack('script_tts_success', {
script_id: props.scriptId,
task_id: nextTask.id
})
return
}
if (nextTask?.status === 'failed') {
safeTrack('script_tts_error', {
script_id: props.scriptId,
task_id: nextTask.id,
error: nextTask.errorMessage || ''
})
markFailed(nextTask.errorMessage || '朗读生成失败')
return
}
if (nextTask?.id) {
pollTask(nextTask.id)
return
}
markFailed('朗读任务创建失败')
} catch (error) {
safeTrack('script_tts_error', {
script_id: props.scriptId,
error: error?.message || error?.errMsg || 'create failed'
})
markFailed('朗读任务创建失败')
}
}
const play = () => {
if (!task.value?.audioUrl) return
if (!audio) {
audio = uni.createInnerAudioContext()
audio.src = task.value.audioUrl
audio.autoplay = false
audio.onPlay(() => {
playing.value = true
safeTrack('script_tts_play', {
script_id: props.scriptId,
task_id: task.value?.id
})
})
audio.onPause(() => {
playing.value = false
safeTrack('script_tts_pause', {
script_id: props.scriptId,
task_id: task.value?.id
})
})
audio.onEnded(() => {
playing.value = false
safeTrack('script_tts_complete', {
script_id: props.scriptId,
task_id: task.value?.id
})
})
audio.onError((error) => {
playing.value = false
safeTrack('script_tts_error', {
script_id: props.scriptId,
task_id: task.value?.id,
error: error?.errMsg || 'play failed'
})
markFailed('音频播放失败')
})
}
if (playing.value) {
audio.pause()
} else {
audio.play()
}
}
const handleClick = async () => {
if (loading.value) return
if (task.value?.status === 'success') {
play()
return
}
await generate()
}
const loadExisting = async () => {
clearTimer()
stopAudio()
setTask(null)
statusText.value = ''
if (!props.scriptId) return
try {
const response = await getTtsTaskBySource({ sourceId: props.scriptId })
const existingTask = readResponseData(response)
setTask(existingTask)
if (existingTask?.status === 'pending' || existingTask?.status === 'processing') {
loading.value = true
pollTask(existingTask.id)
}
} catch (error) {
console.warn('[TTS] existing task lookup failed', error)
}
}
watch(() => props.scriptId, loadExisting, { immediate: true })
onUnmounted(() => {
clearTimer()
stopAudio()
})
</script>
<style scoped>
.script-audio-player {
margin-top: 24rpx;
}
.audio-button {
height: 76rpx;
border-radius: 999rpx;
padding: 0 26rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
color: #fff;
font-size: 25rpx;
font-weight: 800;
line-height: 1;
background: linear-gradient(135deg, #24c6dc, #7f5af0);
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22);
}
.audio-button::after {
border: 0;
}
.audio-button[disabled] {
color: rgba(255, 255, 255, 0.78);
opacity: 0.78;
}
.audio-button.playing {
background: linear-gradient(135deg, #11c97f, #24c6dc);
}
.audio-button.failed {
background: linear-gradient(135deg, #ff6b6b, #a855ff);
}
.audio-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(5, 6, 21, 0.86);
font-size: 20rpx;
font-weight: 900;
background: rgba(255, 255, 255, 0.86);
}
.audio-label {
min-width: 104rpx;
text-align: center;
}
.audio-status {
display: block;
margin-top: 12rpx;
color: rgba(223, 211, 245, 0.7);
font-size: 22rpx;
line-height: 1.45;
text-align: center;
}
</style>
@@ -28,6 +28,7 @@
<text class="stat-label">字数</text>
</view>
</view>
<ScriptAudioPlayer v-if="script?.id" :script-id="script.id" />
</view>
<view class="tabs kos-card">
@@ -61,6 +62,7 @@
import { computed, onMounted, ref } from 'vue'
import { useAppStore } from '../../stores/app.js'
import Markdown from '../../components/Markdown.vue'
import ScriptAudioPlayer from '../../components/ScriptAudioPlayer.vue'
const store = useAppStore()
const statusBarHeight = ref(20)
+30
View File
@@ -0,0 +1,30 @@
import { get, post } from './request.js'
const DEFAULT_SOURCE_TYPE = 'epic_script'
const DEFAULT_VOICE = 'default_zh_female'
export const createTtsTask = ({
sourceType = DEFAULT_SOURCE_TYPE,
sourceId,
voice = DEFAULT_VOICE
}) => {
return post('/tts/tasks', { sourceType, sourceId, voice })
}
export const getTtsTask = (id) => {
return get(`/tts/tasks/${id}`)
}
export const getTtsTaskBySource = ({
sourceType = DEFAULT_SOURCE_TYPE,
sourceId,
voice = DEFAULT_VOICE
}) => {
return get('/tts/tasks/by-source', { sourceType, sourceId, voice })
}
export default {
createTtsTask,
getTtsTask,
getTtsTaskBySource
}