fix: StoryPlayer TTS 同步推进(speak onEnd)+ 跳过按钮常驻 + 竞态防护
This commit is contained in:
@@ -31,6 +31,7 @@ import { speak, stopSpeak } from '../utils/speech.js'
|
||||
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
|
||||
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
|
||||
const PAUSE_MS = 700 // 句间停顿
|
||||
const MAX_LINE_MS = 20000 // 单句最长等待(TTS 无声兜底,防卡死)
|
||||
|
||||
export default {
|
||||
name: 'StoryPlayer',
|
||||
@@ -42,7 +43,7 @@ export default {
|
||||
},
|
||||
emits: ['done'],
|
||||
data() {
|
||||
return { lineIndex: 0, charIndex: 0, speaking: false, moodTick: false, timers: [] }
|
||||
return { lineIndex: 0, charIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { hl: false, sp: false } }
|
||||
},
|
||||
computed: {
|
||||
moodEmoji() {
|
||||
@@ -76,23 +77,45 @@ export default {
|
||||
}
|
||||
this.lineIndex = i
|
||||
this.charIndex = 0
|
||||
this.ended = false
|
||||
this.lineDone = { hl: false, sp: false }
|
||||
this.moodTick = true
|
||||
this.after(300, () => { this.moodTick = false })
|
||||
this.speaking = true
|
||||
speak(line.text, '')
|
||||
this.after(350, () => { this.moodTick = false })
|
||||
uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
|
||||
const total = Array.from(line.text).length
|
||||
// 逐字高亮推进(纯视觉节奏)
|
||||
const step = setInterval(() => {
|
||||
this.charIndex++
|
||||
if (this.charIndex > total) {
|
||||
if (this.charIndex >= total) {
|
||||
clearInterval(step)
|
||||
this.speaking = false
|
||||
this.after(PAUSE_MS, () => this.playLine(i + 1))
|
||||
this.lineDone.hl = true
|
||||
this.tryNext(i)
|
||||
}
|
||||
}, PER_CHAR)
|
||||
this.timers.push(step)
|
||||
// 朗读读完才进下一句(TTS 慢时高亮等朗读);onEnd 与高亮都完成 → 停顿后下一句
|
||||
speak(line.text, '', () => {
|
||||
this.lineDone.sp = true
|
||||
this.tryNext(i)
|
||||
})
|
||||
// 兜底:TTS 无声/超时(20s)强制推进防卡死
|
||||
this.after(MAX_LINE_MS, () => this.forceNext(i))
|
||||
},
|
||||
tryNext(i) {
|
||||
if (this.ended || this.lineIndex !== i || !this.lineDone.hl || !this.lineDone.sp) return
|
||||
this.ended = true
|
||||
this.clearTimers()
|
||||
this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
|
||||
},
|
||||
forceNext(i) {
|
||||
if (this.ended || this.lineIndex !== i) return
|
||||
this.ended = true
|
||||
this.clearTimers()
|
||||
stopSpeak()
|
||||
this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
|
||||
},
|
||||
finish() {
|
||||
this.ended = true
|
||||
this.clearTimers()
|
||||
stopSpeak()
|
||||
this.$emit('done')
|
||||
@@ -103,7 +126,7 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
|
||||
.story-skip { position: absolute; top: 0; right: 0; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 2; }
|
||||
.story-skip { position: fixed; top: calc(140rpx + env(safe-area-inset-top)); right: 24rpx; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 20; }
|
||||
.story-char { position: fixed; right: 24rpx; bottom: 48rpx; z-index: 3; }
|
||||
.story-char-img { width: 140rpx; height: 140rpx; border-radius: 50%; background: #f8f2e8; box-shadow: 0 8rpx 24rpx rgba(91, 70, 54, 0.15); }
|
||||
.story-char-avatar { width: 140rpx; height: 140rpx; border-radius: 50%; color: #fff; font-size: 64rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
let innerAudio = null
|
||||
let uttering = false
|
||||
|
||||
function playFile(url) {
|
||||
function playFile(url, onEnd) {
|
||||
stopSpeak()
|
||||
if (!innerAudio) innerAudio = uni.createInnerAudioContext()
|
||||
innerAudio.stop()
|
||||
innerAudio.src = url
|
||||
if (onEnd) innerAudio.onEnded = () => onEnd()
|
||||
innerAudio.play()
|
||||
}
|
||||
|
||||
function speakBrowser(text) {
|
||||
function speakBrowser(text, onEnd) {
|
||||
stopSpeak()
|
||||
// H5 专用:浏览器原生语音,零成本朗读(小程序端后续接 TTS 文件)
|
||||
if (typeof window === 'undefined' || !window.speechSynthesis) return
|
||||
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
||||
if (onEnd) onEnd()
|
||||
return
|
||||
}
|
||||
const u = new SpeechSynthesisUtterance(text)
|
||||
u.lang = 'zh-CN'
|
||||
u.rate = 0.9
|
||||
u.onend = u.onerror = () => { uttering = false }
|
||||
u.onend = u.onerror = () => { uttering = false; if (onEnd) onEnd() }
|
||||
window.speechSynthesis.speak(u)
|
||||
uttering = true
|
||||
}
|
||||
|
||||
// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级
|
||||
export function speak(text, fileUrl) {
|
||||
// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级;onEnd 在读完时回调(无 TTS 能力时立即回调)
|
||||
export function speak(text, fileUrl, onEnd) {
|
||||
if (!text) return
|
||||
if (fileUrl) {
|
||||
playFile(fileUrl)
|
||||
playFile(fileUrl, onEnd)
|
||||
return
|
||||
}
|
||||
speakBrowser(text)
|
||||
speakBrowser(text, onEnd)
|
||||
}
|
||||
|
||||
export function stopSpeak() {
|
||||
|
||||
Reference in New Issue
Block a user