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>
|
<text class="stat-label">字数</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<ScriptAudioPlayer v-if="script?.id" :script-id="script.id" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tabs kos-card">
|
<view class="tabs kos-card">
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import Markdown from '../../components/Markdown.vue'
|
import Markdown from '../../components/Markdown.vue'
|
||||||
|
import ScriptAudioPlayer from '../../components/ScriptAudioPlayer.vue'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const statusBarHeight = ref(20)
|
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