feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<view class="story">
|
||||
<view class="story-skip" @click="finish">跳过 ›</view>
|
||||
|
||||
<view v-if="character" class="story-char">
|
||||
<image v-if="character.image" class="story-char-img" :src="character.image" mode="aspectFit" />
|
||||
<view v-else class="story-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
|
||||
<view class="story-mood" :class="{ pop: moodTick }">{{ moodEmoji }}</view>
|
||||
</view>
|
||||
|
||||
<view class="story-lines">
|
||||
<view v-for="(line, i) in script" :key="i" class="story-line" :class="{ active: i === lineIndex, done: i < lineIndex }">
|
||||
<view v-if="line.speaker === '旁白'" class="bubble nar-bubble">
|
||||
<view class="bubble-name">📖 旁白</view>
|
||||
<RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
|
||||
</view>
|
||||
<view v-else class="bubble char-bubble">
|
||||
<view class="bubble-name">{{ line.speaker }}</view>
|
||||
<RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RubyText from './RubyText.vue'
|
||||
import { personVisual } from '../utils/visual.js'
|
||||
import { speak, stopSpeak } from '../utils/speech.js'
|
||||
|
||||
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
|
||||
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
|
||||
const PAUSE_MS = 700 // 句间停顿
|
||||
|
||||
export default {
|
||||
name: 'StoryPlayer',
|
||||
components: { RubyText },
|
||||
props: {
|
||||
script: { type: Array, default: () => [] }, // [{speaker, text, pinyin, emotion}]
|
||||
character: { type: Object, default: null }, // {name, image, id}
|
||||
showPinyin: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['done'],
|
||||
data() {
|
||||
return { lineIndex: 0, charIndex: 0, speaking: false, moodTick: false, timers: [] }
|
||||
},
|
||||
computed: {
|
||||
moodEmoji() {
|
||||
const line = this.script[this.lineIndex]
|
||||
return EMOJI[(line && line.emotion) || 'normal'] || EMOJI.normal
|
||||
},
|
||||
charColor() {
|
||||
return personVisual(this.character && this.character.id ? this.character.id : 0).color
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.playLine(0)
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.clearTimers()
|
||||
stopSpeak()
|
||||
},
|
||||
methods: {
|
||||
clearTimers() {
|
||||
this.timers.forEach(t => { clearTimeout(t); clearInterval(t) })
|
||||
this.timers = []
|
||||
},
|
||||
after(ms, fn) {
|
||||
this.timers.push(setTimeout(fn, ms))
|
||||
},
|
||||
playLine(i) {
|
||||
const line = this.script[i]
|
||||
if (!line) {
|
||||
this.$emit('done')
|
||||
return
|
||||
}
|
||||
this.lineIndex = i
|
||||
this.charIndex = 0
|
||||
this.moodTick = true
|
||||
this.after(300, () => { this.moodTick = false })
|
||||
this.speaking = true
|
||||
speak(line.text, '')
|
||||
uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
|
||||
const total = Array.from(line.text).length
|
||||
const step = setInterval(() => {
|
||||
this.charIndex++
|
||||
if (this.charIndex > total) {
|
||||
clearInterval(step)
|
||||
this.speaking = false
|
||||
this.after(PAUSE_MS, () => this.playLine(i + 1))
|
||||
}
|
||||
}, PER_CHAR)
|
||||
this.timers.push(step)
|
||||
},
|
||||
finish() {
|
||||
this.clearTimers()
|
||||
stopSpeak()
|
||||
this.$emit('done')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-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; }
|
||||
.story-mood { position: absolute; right: -10rpx; bottom: -10rpx; font-size: 44rpx; background: #fff; border-radius: 50%; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); }
|
||||
.story-mood.pop { animation: mood-pop 0.35s ease; }
|
||||
@keyframes mood-pop { 0% { transform: scale(0.3) rotate(-20deg); } 100% { transform: scale(1) rotate(0); } }
|
||||
.story-lines { display: flex; flex-direction: column; gap: 24rpx; padding-bottom: 24rpx; }
|
||||
.story-line { opacity: 0; transition: opacity 0.4s; }
|
||||
.story-line.active { opacity: 1; }
|
||||
.story-line.done { opacity: 0.55; }
|
||||
.bubble { border-radius: 24rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 16rpx rgba(91, 70, 54, 0.08); }
|
||||
.bubble-name { font-size: 24rpx; font-weight: 700; color: #ff6b35; margin-bottom: 8rpx; }
|
||||
.nar-bubble { background: #fff3d6; margin-left: 40rpx; margin-right: 40rpx; }
|
||||
.char-bubble { background: #ffffff; margin-left: 32rpx; margin-right: 8rpx; }
|
||||
.story-line.active .bubble { animation: bubble-in 0.35s ease; }
|
||||
@keyframes bubble-in { 0% { opacity: 0; transform: translateY(24rpx) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user