Browse Source

feat(audio): 实现音频循环模式切换功能

新增顺序/单句/单页三种循环模式
优化音频控制组件循环逻辑
添加字幕句子级别转换工具函数
main
前端-胡立永 3 days ago
parent
commit
7840c1778d
8 changed files with 231 additions and 57 deletions
  1. +1
    -1
      config/index.js
  2. +1
    -1
      manifest.json
  3. +33
    -6
      subPages/home/AudioControls.vue
  4. +107
    -10
      subPages/home/articleDetail.vue
  5. +12
    -39
      subPages/home/book.vue
  6. +4
    -0
      subPages/home/components/CustomTabbar.vue
  7. +11
    -0
      subPages/home/示例.html
  8. +62
    -0
      utils/audioUtils.js

+ 1
- 1
config/index.js View File

@ -19,7 +19,7 @@ const config = {
// 网络全局配置 // 网络全局配置
netConfig: { netConfig: {
development: { development: {
baseURL: 'http://127.0.0.1:8003/englishread-admin/appletApi',
baseURL: 'http://127.0.0.1:8002/englishread-admin/appletApi',
// baseURL: 'http://h5.xzaiyp.top/englishread-admin/appletApi', // baseURL: 'http://h5.xzaiyp.top/englishread-admin/appletApi',
}, },
testing: { testing: {


+ 1
- 1
manifest.json View File

@ -51,7 +51,7 @@
/* H5 */ /* H5 */
"h5" : { "h5" : {
"devServer" : { "devServer" : {
"port" : 8002,
"port" : 8003,
"disableHostCheck" : true "disableHostCheck" : true
} }
}, },


+ 33
- 6
subPages/home/AudioControls.vue View File

@ -31,8 +31,8 @@
<view class="audio-controls-row"> <view class="audio-controls-row">
<view class="control-btn" @click="toggleLoop"> <view class="control-btn" @click="toggleLoop">
<uv-icon name="reload" size="20" :color="isLoop ? '#06DADC' : '#999'"></uv-icon>
<text class="control-text">循环</text>
<uv-icon name="reload" size="20" :color="loopMode !== 'sequential' ? '#06DADC' : '#999'"></uv-icon>
<text class="control-text">{{ loopModeLabel }}</text>
</view> </view>
<view class="control-btn" @click="$emit('previous-page')"> <view class="control-btn" @click="$emit('previous-page')">
@ -126,7 +126,7 @@ export default {
totalTime: 0, totalTime: 0,
sliderValue: 0, // sliderValue: 0, //
isDragging: false, // isDragging: false, //
isLoop: false,
loopMode: 'sequential',
playSpeed: 1.0, playSpeed: 1.0,
speedOptions: [0.5, 0.8, 1.0, 1.25, 1.5, 2.0], // uni-app speedOptions: [0.5, 0.8, 1.0, 1.25, 1.5, 2.0], // uni-app
playbackRateSupported: true, // playbackRateSupported: true, //
@ -208,6 +208,18 @@ export default {
} }
} }
return false; return false;
},
//
loopModeLabel() {
switch (this.loopMode) {
case 'sentence':
return '单句';
case 'page':
return '单页';
default:
return '顺序';
}
} }
}, },
watch: { watch: {
@ -1673,6 +1685,13 @@ export default {
return; return;
} }
//
if (this.loopMode === 'sentence') {
this.playAudio();
this.isProcessingEnded = false;
return;
}
if (this.currentAudioIndex < this.currentPageAudios.length - 1) { if (this.currentAudioIndex < this.currentPageAudios.length - 1) {
// //
let currentAudioData = this.currentPageAudios[this.currentAudioIndex]; let currentAudioData = this.currentPageAudios[this.currentAudioIndex];
@ -1747,7 +1766,7 @@ export default {
handlePlaybackComplete() { handlePlaybackComplete() {
console.log('🎵 handlePlaybackComplete: 所有音频播放完毕'); console.log('🎵 handlePlaybackComplete: 所有音频播放完毕');
if (this.isLoop) {
if (this.loopMode === 'page') {
// - // -
console.log('🎵 handlePlaybackComplete: 循环播放,查找第一个非导语音频'); console.log('🎵 handlePlaybackComplete: 循环播放,查找第一个非导语音频');
const firstNonLeadIndex = this.findFirstNonLeadAudio(); const firstNonLeadIndex = this.findFirstNonLeadAudio();
@ -1865,7 +1884,15 @@ export default {
// }, // },
toggleLoop() { toggleLoop() {
this.isLoop = !this.isLoop;
const modes = ['sequential', 'sentence', 'page'];
const idx = modes.indexOf(this.loopMode);
this.loopMode = modes[(idx + 1) % modes.length];
this.$emit('loop-mode-change', this.loopMode);
uni.showToast({
title: `播放模式:${this.loopModeLabel}`,
icon: 'none',
duration: 800
});
}, },
toggleSpeed() { toggleSpeed() {
@ -2323,7 +2350,7 @@ export default {
this.preloadedPages = new Set(); this.preloadedPages = new Set();
// 5. // 5.
this.isLoop = false;
this.loopMode = 'sequential';
this.playSpeed = 1.0; this.playSpeed = 1.0;
this.playbackRateSupported = true; this.playbackRateSupported = true;


+ 107
- 10
subPages/home/articleDetail.vue View File

@ -19,7 +19,7 @@
<!-- 文章内容 --> <!-- 文章内容 -->
<view class="article-content"> <view class="article-content">
<uv-parse :content="articleData.content"/>
<uv-parse :content="content"/>
</view> </view>
</view> </view>
@ -35,11 +35,17 @@
:color="isPlaying ? '#ff6b6b' : '#4CAF50'"></uv-icon> :color="isPlaying ? '#ff6b6b' : '#4CAF50'"></uv-icon>
</view> </view>
<!-- 底部字幕展示 -->
<!-- <view v-if="hasAudio && subtitlesJson.length > 0 && currentSubtitleText" class="subtitle-container">
<text class="subtitle-text">{{ currentSubtitleText }}</text>
</view> -->
</view> </view>
</template> </template>
<script> <script>
import audioManager from '@/utils/audioManager.js' import audioManager from '@/utils/audioManager.js'
import { convertToSentences } from '@/utils/audioUtils.js'
export default { export default {
data() { data() {
@ -49,10 +55,18 @@ export default {
loading: true, loading: true,
isPlaying: false, isPlaying: false,
audioUrl: '', audioUrl: '',
hasAudio: false
hasAudio: false,
subtitlesJson: [],
//
currentSentenceIndex: -1,
currentSubtitleText: '',
} }
}, },
computed: {
content(){
return this.articleData.content
},
},
onLoad(options) { onLoad(options) {
if (options.id) { if (options.id) {
this.articleId = options.id this.articleId = options.id
@ -76,6 +90,7 @@ export default {
audioManager.on('pause', this.onAudioPause) audioManager.on('pause', this.onAudioPause)
audioManager.on('ended', this.onAudioEnded) audioManager.on('ended', this.onAudioEnded)
audioManager.on('error', this.onAudioError) audioManager.on('error', this.onAudioError)
audioManager.on('timeupdate', this.onAudioTimeUpdate)
}, },
// //
@ -84,6 +99,7 @@ export default {
audioManager.off('pause', this.onAudioPause) audioManager.off('pause', this.onAudioPause)
audioManager.off('ended', this.onAudioEnded) audioManager.off('ended', this.onAudioEnded)
audioManager.off('error', this.onAudioError) audioManager.off('error', this.onAudioError)
audioManager.off('timeupdate', this.onAudioTimeUpdate)
}, },
// //
@ -107,6 +123,9 @@ export default {
if (data.audioType === 'article') { if (data.audioType === 'article') {
this.isPlaying = false this.isPlaying = false
console.log('文章音频播放结束') console.log('文章音频播放结束')
//
this.currentSentenceIndex = -1
this.currentSubtitleText = ''
} }
}, },
@ -115,6 +134,9 @@ export default {
if (data.audioType === 'article') { if (data.audioType === 'article') {
this.isPlaying = false this.isPlaying = false
console.error('文章音频播放错误:', data.error) console.error('文章音频播放错误:', data.error)
//
this.currentSentenceIndex = -1
this.currentSubtitleText = ''
uni.showToast({ uni.showToast({
title: '音频播放失败', title: '音频播放失败',
icon: 'none' icon: 'none'
@ -122,11 +144,78 @@ export default {
} }
}, },
//
onAudioTimeUpdate({ audioType, currentTime }) {
if (audioType !== 'article') return
if (!this.subtitlesJson || this.subtitlesJson.length === 0) return
// audioManager.currentTime
const curMs = Math.floor(currentTime * 1000)
const sentences = this.subtitlesJson
// 使
if (this.currentSentenceIndex >= 0) {
const cur = sentences[this.currentSentenceIndex]
// 80ms
if (curMs >= cur.beginTime && curMs <= cur.endTime + 80) {
this.currentSubtitleText = cur.text
return
}
}
//
let idx = -1
for (let i = 0; i < sentences.length; i++) {
const s = sentences[i]
if (curMs >= s.beginTime && curMs <= s.endTime + 80) {
idx = i
break
}
}
this.currentSentenceIndex = idx
this.currentSubtitleText = idx >= 0 ? sentences[idx].text : ''
},
// //
goBack() { goBack() {
uni.navigateBack() uni.navigateBack()
}, },
changeSubtitlesJson(articleData) {
if (articleData.subtitlesJson) {
try {
//[{"EndTime":480,"BeginTime":250,"Text":"","BeginIndex":0,"EndIndex":1,"Phoneme":"yv3"}]
let json = JSON.parse(articleData.subtitlesJson)
if (!Array.isArray(json) || json.length === 0) {
this.subtitlesJson = []
return
}
//
const sentences = convertToSentences(json)
this.subtitlesJson = sentences
console.log('转换后的句子数据:', sentences)
} catch (error) {
console.error('解析字幕数据失败:', error)
this.subtitlesJson = []
}
} else {
this.subtitlesJson = []
}
},
chooseAudio(key = '501008') {
let articleData = this.articleData?.audios?.[key]
if (articleData) {
// URL
this.audioUrl = articleData.audioId
//
this.changeSubtitlesJson(articleData)
this.hasAudio = true
}
},
// //
async getArticleDetail() { async getArticleDetail() {
try { try {
@ -141,12 +230,7 @@ export default {
// #endif // #endif
// //
if (res.result.audios && Object.keys(res.result.audios).length > 0) {
// URL
const audioKeys = Object.keys(res.result.audios)
this.audioUrl = res.result.audios['501008'] || res.result.audios[audioKeys[0]]
this.hasAudio = true
}
this.chooseAudio()
} else { } else {
uni.showToast({ uni.showToast({
title: res.message || '获取文章详情失败', title: res.message || '获取文章详情失败',
@ -222,6 +306,9 @@ export default {
audioManager.stopCurrentAudio() audioManager.stopCurrentAudio()
this.isPlaying = false this.isPlaying = false
console.log('停止文章音频') console.log('停止文章音频')
//
this.currentSentenceIndex = -1
this.currentSubtitleText = ''
} catch (error) { } catch (error) {
console.error('停止音频失败:', error) console.error('停止音频失败:', error)
} }
@ -252,6 +339,16 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.subtitle-container{
position: fixed;
bottom: 220rpx;
left: 5%;
width: 90%;
text-align: center;
background-color: bisque;
border-radius: 10rpx;
}
.article-detail { .article-detail {
min-height: 100vh; min-height: 100vh;
background: #fff; background: #fff;


+ 12
- 39
subPages/home/book.vue View File

@ -1,4 +1,4 @@
<template>
<template>
<view class="book-container"> <view class="book-container">
<!-- 条件编译 --> <!-- 条件编译 -->
@ -119,7 +119,7 @@
@toggle-sound="toggleSound" @go-to-page="goToPage" @previous-page="previousPage" @next-page="nextPage" @toggle-sound="toggleSound" @go-to-page="goToPage" @previous-page="previousPage" @next-page="nextPage"
@audio-state-change="onAudioStateChange" @highlight-change="onHighlightChange" @audio-state-change="onAudioStateChange" @highlight-change="onHighlightChange"
@scroll-to-text="onScrollToText" @voice-change-complete="onVoiceChangeComplete" @scroll-to-text="onScrollToText" @voice-change-complete="onVoiceChangeComplete"
@voice-change-error="onVoiceChangeError" @page-data-needed="onPageDataNeeded" ref="customTabbar" />
@voice-change-error="onVoiceChangeError" @page-data-needed="onPageDataNeeded" @loop-mode-change="onLoopModeChange" ref="customTabbar" />
<!-- 课程选择弹出窗 --> <!-- 课程选择弹出窗 -->
<CoursePopup :style="{ zIndex: 10000 }" :course-list="courseList" :current-course="currentCourse" <CoursePopup :style="{ zIndex: 10000 }" :course-list="courseList" :current-course="currentCourse"
@ -164,6 +164,7 @@ export default {
showNavbar: true, showNavbar: true,
currentPage: 1, // currentPage: 1, //
currentCourse: 1, // currentCourse: 1, //
audioLoopMode: 'sequential', // //
currentWordMeaning: null, // currentWordMeaning: null, //
isReversed: false, // isReversed: false, //
// - AudioControls // - AudioControls
@ -476,6 +477,15 @@ export default {
} }
}, },
//
onLoopModeChange(mode) {
this.audioLoopMode = mode;
const labels = { sequential: '顺序', sentence: '单句', page: '单页' };
const label = labels[mode] || '顺序';
//
uni.showToast({ title: `播放模式:${label}`, icon: 'none', duration: 800 });
},
// //
onVoiceChangeComplete(data) { onVoiceChangeComplete(data) {
@ -524,36 +534,14 @@ export default {
// //
handleTextClick(textContent, item, pageIndex) { handleTextClick(textContent, item, pageIndex) {
// console.log('🎯 ===== =====');
// console.log('📝 :', textContent);
// console.log('📄 textContent:', typeof textContent);
// console.log(' textContentundefined:', textContent === undefined);
// console.log('📦 item:', item);
// console.log('📝 item.content:', item ? item.content : 'item');
// console.log('📖 :', this.currentPage);
// console.log('👆 :', pageIndex);
// console.log('📊 :', this.currentPageType);
// console.log('📄 :', this.isTextPage);
// console.log('📋 :', this.bookPages[this.currentPage - 1]);
// console.log('📏 :', this.bookPages[this.currentPage - 1] ? this.bookPages[this.currentPage - 1].length : '');
//
// console.log('🎵 ===== =====');
// console.log(' isWordAudioPlaying:', this.isWordAudioPlaying);
// console.log(' currentWordAudio:', !!this.currentWordAudio);
// console.log(' currentWordMeaning:', !!this.currentWordMeaning);
if (this.isWordAudioPlaying) { if (this.isWordAudioPlaying) {
// console.log(' ');
// console.log('🔄 ...');
this.isWordAudioPlaying = false; this.isWordAudioPlaying = false;
// console.log(' ');
} }
// //
if (pageIndex !== undefined && pageIndex !== this.currentPage - 1) { if (pageIndex !== undefined && pageIndex !== this.currentPage - 1) {
console.warn('⚠️ 点击的不是当前页面,忽略点击事件'); console.warn('⚠️ 点击的不是当前页面,忽略点击事件');
// console.log(` : ${this.currentPage - 1}, : ${pageIndex}`);
return; return;
} }
@ -606,11 +594,6 @@ export default {
// //
// type'1' // type'1'
// 线 // 线
if (this.currentPageType === '1') { if (this.currentPageType === '1') {
@ -700,24 +683,14 @@ export default {
return; return;
} }
// AudioControls
const success = audioControls.playSpecificAudio(textContent); const success = audioControls.playSpecificAudio(textContent);
// console.log('🎵 playSpecificAudio :', success);
if (success) { if (success) {
// console.log(' '); // console.log(' ');
} else { } else {
console.error('❌ 播放指定音频段落失败'); console.error('❌ 播放指定音频段落失败');
// console.log('💡 :');
// console.log(' 1. ');
// console.log(' 2. ');
// console.log(' 3. ');
// console.log(' 4. ');
} }
// console.log('🎯 ===== =====');
}, },
onHighlightChange(highlightData) { onHighlightChange(highlightData) {


+ 4
- 0
subPages/home/components/CustomTabbar.vue View File

@ -19,6 +19,7 @@
@scroll-to-text="onScrollToText" @scroll-to-text="onScrollToText"
@voice-change-complete="onVoiceChangeComplete" @voice-change-complete="onVoiceChangeComplete"
@voice-change-error="onVoiceChangeError" @voice-change-error="onVoiceChangeError"
@loop-mode-change="onLoopModeChange"
@page-data-needed="onPageDataNeeded" @page-data-needed="onPageDataNeeded"
ref="audioControls" ref="audioControls"
/> />
@ -144,6 +145,9 @@ export default {
}, },
onScrollToText(scrollData) { onScrollToText(scrollData) {
this.$emit('scroll-to-text', scrollData) this.$emit('scroll-to-text', scrollData)
},
onLoopModeChange(mode) {
this.$emit('loop-mode-change', mode)
} }
} }
} }


+ 11
- 0
subPages/home/示例.html View File

@ -0,0 +1,11 @@
<p class="MsoNormal"><span style="font-family: 宋体;">语言从来不是孤立的符号,而是思维的具象载体。当我们试图传递观点时,母语总会成为第一选择</span>&mdash;&mdash;这份&ldquo;自然&rdquo;,源于它与我们思维模式的深度绑定。可若连母语都无法说清想表达的内容,本质并非语言能力不足,而是对事物的理解本就模糊,思维尚未完成对信息的梳理与重构。唯有当我们能用母语简洁提炼核心时,&ldquo;真正的理解&rdquo;才真正开始,而这,正是凝语境二语习得中&ldquo;可理解输入&rdquo;的关键内核,其深度远超传统理论框架。</p>
<p class="MsoNormal">&nbsp;</p>
<p class="MsoNormal"><span style="font-family: 宋体;">这种以</span>&ldquo;理解&rdquo;为起点的学习逻辑,能让记忆自然沉淀:模糊的外部信息终将淡忘,但内化为认知一部分的理解,会成为思维的固有能力。更重要的是,凝语境在此基础上搭建了&ldquo;语言与想象&rdquo;的桥梁,让抽象的语言逻辑变得可感知、易掌握,为儿童双语思维铺就了更顺畅的路径。</p>
<p class="MsoNormal">&nbsp;</p>
<p class="MsoNormal"><span style="font-family: 宋体;">儿童的成长中藏着两大</span>&ldquo;语言学习天赋&rdquo;:一是成人难以复刻的想象力,能让抽象的语言与鲜活的场景相连;二是人类语言自然习得机制,这一能力在儿童阶段体现得最为显著&mdash;&mdash;就像他们无需刻意背诵,便能自然掌握母语的发音、语法与表达逻辑。凝语境早期双语思维启蒙把握这一黄金时机,一边还原母语习得的自然路径,让孩子像学说话一样轻松接触英语;一边以想象力为纽带,将语言符号与生活体验、思维认知深度绑定,推动孩子从&ldquo;被动听&rdquo;转向&ldquo;主动讲&rdquo;,为语言、思维与主动性成长同时插上翅膀。</p>
<p class="MsoNormal">&nbsp;</p>
<p class="MsoNormal"><span style="font-family: 宋体;">我们的方案设置了明确的进阶标准:必须当孩子能用第一人称完整讲述当前内容,才能进入下一部分。这与传统</span>&ldquo;问答式口语练习&rdquo;完全不同&mdash;&mdash;传统对答是&ldquo;被动回应问题&rdquo;,孩子只需机械套用句式;而第一人称讲述是&ldquo;主动输出自我认知&rdquo;,孩子需要整合场景、想象与语言,在画面提示、跟读理解的过程中,逐步形成&ldquo;自己能说清&rdquo;的能力。当孩子完全沉浸在这种语境中,语言会从&ldquo;刻意回忆&rdquo;转向&ldquo;自然脱口而出&rdquo;<span style="font-family: 宋体;"></span></p>
<p class="MsoNormal">&nbsp;</p>
<p class="MsoNormal"><span style="font-family: 宋体;">联合国教科文组织数据显示,全球</span>40%<span style="font-family: 宋体;">的儿童、</span><span style="font-family: Calibri;">13%</span><span style="font-family: 宋体;">的高等教育人群受阅读理解问题困扰,其根源并非词汇量或内容难度,而是思维局限。一旦陷入阅读理解障碍,不仅会直接导致学科成绩难以提升&mdash;&mdash;比如无法精准解读数学题干、理解语文阅读主旨,更会固化&ldquo;被动接收&rdquo;的思维模式:孩子习惯等待信息投喂,缺乏主动探索与表达的意识,既影响学习效率,更限制成长潜力。</span></p>
<p class="MsoNormal">&nbsp;</p>
<p class="MsoNormal"><span style="font-family: 宋体;">而凝语境早期双语思维启蒙,正是从根源规避这一问题:通过双语联动激活孩子的想象空间,让他们在理解语言时,能自然联想出多元画面,而非局限于单一信息;通过</span>&ldquo;主动讲述语境&rdquo;培养表达勇气,让孩子从&ldquo;不敢说&rdquo;&ldquo;愿意说&rdquo;再到&ldquo;善于说&rdquo;;在想象力与主动性的双重加持下,语言学习不再是机械记忆,而是思维活力与主动品格的同步锻炼。这种从&ldquo;思维&rdquo;&ldquo;语言&rdquo;再到&ldquo;主动性&rdquo;的正向循环,不仅能提升双语能力,更能培养良好的理解力、表达欲与想象力,为孩子扫清<span style="font-family: 宋体;">未来教育中成绩</span><span style="font-family: 宋体;">提升的阻碍,打破思维与行为的固化局限,为长期成长筑牢根基。</span></p>

+ 62
- 0
utils/audioUtils.js View File

@ -216,4 +216,66 @@ export function isAudioDataForCurrentPage(audioData, expectedCacheKey) {
return false; return false;
} }
return audioData.cacheKey === expectedCacheKey; return audioData.cacheKey === expectedCacheKey;
}
/**
* 将字级别的字幕数据转换为句子级别
* @param {Array} wordData - 字级别的字幕数据数组
* @returns {Array} 句子级别的字幕数据数组
*/
export function convertToSentences(wordData) {
if (!wordData || wordData.length === 0) return []
const sentences = []
let currentSentence = {
text: '',
beginTime: null,
endTime: null,
words: []
}
// 句子结束标点符号(中英文)
const sentenceEndMarks = /[。!?;.!?;…]/
for (let i = 0; i < wordData.length; i++) {
const word = wordData[i]
// 设置句子开始时间
if (currentSentence.beginTime === null) {
currentSentence.beginTime = word.BeginTime
}
// 添加文字到当前句子
currentSentence.text += word.Text
currentSentence.words.push(word)
currentSentence.endTime = word.EndTime
// 检查是否是句子结束
const isLastWord = i === wordData.length - 1
const isEndOfSentence = sentenceEndMarks.test(word.Text)
// 如果遇到句子结束标点或者是最后一个字,结束当前句子
if (isEndOfSentence || isLastWord) {
// 只有当句子有内容时才添加
if (currentSentence.text) {
sentences.push({
text: currentSentence.text,
beginTime: currentSentence.beginTime,
endTime: currentSentence.endTime,
duration: currentSentence.endTime - currentSentence.beginTime,
wordCount: currentSentence.words.length
})
}
// 重置当前句子
currentSentence = {
text: '',
beginTime: null,
endTime: null,
words: []
}
}
}
return sentences
} }

Loading…
Cancel
Save