feat: add script tts player UI
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user