From fbbbb69471458d00e6d47191854f11d8a1872e22 Mon Sep 17 00:00:00 2001 From: hflllll Date: Wed, 15 Oct 2025 13:02:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=9F=B3=E9=A2=91=E6=8E=A7=E5=88=B6):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=9F=B3=E9=A2=91=E6=92=AD=E6=94=BE=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=BB=9A=E5=8A=A8=E5=88=B0=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加scroll-to-text事件处理逻辑,在音频播放时自动滚动到当前高亮的文本位置。包括防抖处理、元素定位计算和滚动优化,提升用户体验。同时完善音频数据有效性检查和错误处理,确保功能稳定性。 --- subPages/home/AudioControls.vue | 178 +++++++++++++++++++++++------- subPages/home/book.vue | 168 +++++++++++++++++++++++++++- subPages/home/components/CustomTabbar.vue | 7 +- 3 files changed, 312 insertions(+), 41 deletions(-) diff --git a/subPages/home/AudioControls.vue b/subPages/home/AudioControls.vue index 2db3513..f60c24f 100644 --- a/subPages/home/AudioControls.vue +++ b/subPages/home/AudioControls.vue @@ -401,6 +401,18 @@ export default { checkAndLoadPreloadedAudio() { // 只在需要加载音频的页面检查 if (!this.shouldLoadAudio) { + // 非文本页面,确保音频状态为空 + this.currentPageAudios = []; + this.totalTime = 0; + this.hasAudioData = false; + this.isAudioLoading = false; + + // 通知父组件音频状态变化 + this.$emit('audio-state-change', { + hasAudioData: false, + isLoading: false, + currentHighlightIndex: -1 + }); return; } @@ -414,10 +426,21 @@ export default { this.totalTime = cachedAudio.totalDuration || 0; this.hasAudioData = true; this.isAudioLoading = false; - + this.currentAudioIndex = 0; + this.currentTime = 0; + this.currentHighlightIndex = -1; + + console.log(`🎵 checkAndLoadPreloadedAudio: 从缓存加载音频,页面=${this.currentPage}, 音频数量=${this.currentPageAudios.length}`); + + // 通知父组件音频状态变化 + this.$emit('audio-state-change', { + hasAudioData: true, + isLoading: false, + currentHighlightIndex: -1 + }); } else { // 没有缓存:自动开始加载音频 - + console.log(`🎵 checkAndLoadPreloadedAudio: 无缓存,开始加载音频,页面=${this.currentPage}`); this.getCurrentPageAudio(); } }, @@ -955,37 +978,17 @@ export default { this.currentHighlightIndex = -1; this.playSpeed = 1.0; - // 如果不需要加载音频,直接重置为初始状态 - if (!this.shouldLoadAudio) { - - this.currentPageAudios = []; - this.totalTime = 0; - this.hasAudioData = false; - } else { - // 文本页面:检查是否有缓存的音频数据 - const pageKey = `${this.courseId}_${this.currentPage}_${this.localVoiceId}`; - const cachedAudio = this.audioCache[pageKey]; - - if (cachedAudio && cachedAudio.audios && cachedAudio.audios.length > 0) { - // 有缓存:直接显示控制栏 - this.currentPageAudios = cachedAudio.audios; - this.totalTime = cachedAudio.totalDuration || 0; - this.hasAudioData = true; - - } else { - // 没有缓存:重置为初始状态,但不立即加载(由checkAndLoadPreloadedAudio处理) - this.currentPageAudios = []; - this.totalTime = 0; - this.hasAudioData = false; - - } - } + // 页面切换时,始终清空当前音频数据,避免数据错乱 + // 音频数据的加载由checkAndLoadPreloadedAudio方法统一处理 + this.currentPageAudios = []; + this.totalTime = 0; + this.hasAudioData = false; // 通知父组件音频状态变化 this.$emit('audio-state-change', { - hasAudioData: this.hasAudioData, - isLoading: this.isAudioLoading, - currentHighlightIndex: this.currentHighlightIndex + hasAudioData: false, + isLoading: false, + currentHighlightIndex: -1 }); }, @@ -1206,13 +1209,37 @@ export default { return; } - if (this.currentPageAudios.length === 0) return; + // 检查音频数据有效性 + if (!this.currentPageAudios || this.currentPageAudios.length === 0) { + console.warn('🎵 playAudio: 没有音频数据'); + return; + } + + if (this.currentAudioIndex < 0 || this.currentAudioIndex >= this.currentPageAudios.length) { + console.error('🎵 playAudio: 音频索引无效', this.currentAudioIndex); + return; + } + + const currentAudioData = this.currentPageAudios[this.currentAudioIndex]; + if (!currentAudioData || !currentAudioData.url) { + console.error('🎵 playAudio: 音频数据无效', currentAudioData); + return; + } + + // 检查音频数据是否属于当前页面 + const audioCacheKey = `${this.courseId}_${this.currentPage}_${this.localVoiceId}`; + const currentPageCache = this.audioCache[audioCacheKey]; + + if (!currentPageCache || !currentPageCache.audios.includes(currentAudioData)) { + console.error('🎵 playAudio: 音频数据与当前页面不匹配,停止播放'); + return; + } // 🎯 全局音频管理:停止单词音频播放,确保只有一个音频播放 this.stopWordAudio(); // 如果没有当前音频实例或者需要切换音频 - if (!this.currentAudio || this.currentAudio.src !== this.currentPageAudios[this.currentAudioIndex].url) { + if (!this.currentAudio || this.currentAudio.src !== currentAudioData.url) { this.createAudioInstance(); // 创建实例后稍等一下再播放,确保音频准备就绪 setTimeout(() => { @@ -1632,6 +1659,32 @@ export default { // 创建音频实例 createAudioInstance() { + // 检查音频数据有效性 + if (!this.currentPageAudios || this.currentPageAudios.length === 0) { + console.error('🎵 createAudioInstance: 没有音频数据'); + return; + } + + if (this.currentAudioIndex < 0 || this.currentAudioIndex >= this.currentPageAudios.length) { + console.error('🎵 createAudioInstance: 音频索引无效', this.currentAudioIndex); + return; + } + + const currentAudioData = this.currentPageAudios[this.currentAudioIndex]; + if (!currentAudioData || !currentAudioData.url) { + console.error('🎵 createAudioInstance: 音频数据无效', currentAudioData); + return; + } + + // 检查音频数据是否属于当前页面 + const audioCacheKey = `${this.courseId}_${this.currentPage}_${this.localVoiceId}`; + const currentPageCache = this.audioCache[audioCacheKey]; + + if (!currentPageCache || !currentPageCache.audios.includes(currentAudioData)) { + console.error('🎵 createAudioInstance: 音频数据与当前页面不匹配'); + return; + } + // 销毁之前的音频实例 if (this.currentAudio) { this.currentAudio.pause(); @@ -1657,13 +1710,12 @@ export default { // audio = uni.createInnerAudioContext(); // } // } - const audioUrl = this.currentPageAudios[this.currentAudioIndex].url; + const audioUrl = currentAudioData.url; audio.src = audioUrl; - - - - console.log('🎵 创建音频实例 - 音频文本:', this.currentPageAudios[this.currentAudioIndex].text?.substring(0, 50) + '...'); + console.log('🎵 创建音频实例 - 页面:', this.currentPage, '音频索引:', this.currentAudioIndex); + console.log('🎵 创建音频实例 - 音频URL:', audioUrl); + console.log('🎵 创建音频实例 - 音频文本:', currentAudioData.text?.substring(0, 50) + '...'); // 在音頻可以播放時檢測playbackRate支持 audio.onCanplay(() => { @@ -1763,6 +1815,11 @@ export default { return; } + // 检查是否正在页面切换中,如果是则不更新高亮 + if (this.isPageChanging) { + return; + } + // 获取当前播放的音频数据 const currentAudio = this.currentPageAudios[this.currentAudioIndex]; if (!currentAudio) { @@ -1771,6 +1828,16 @@ export default { return; } + // 检查音频数据是否属于当前页面,防止页面切换时的数据错乱 + const audioCacheKey = `${this.courseId}_${this.currentPage}_${this.localVoiceId}`; + const currentPageCache = this.audioCache[audioCacheKey]; + + // 如果当前音频数据不属于当前页面,则不更新高亮 + if (!currentPageCache || !currentPageCache.audios.includes(currentAudio)) { + console.warn('🎵 updateHighlightIndex: 音频数据与当前页面不匹配,跳过高亮更新'); + return; + } + // 如果是分段音频,需要计算正确的高亮索引 if (currentAudio.isSegmented && typeof currentAudio.originalTextIndex !== 'undefined') { // 使用原始文本项的索引作为高亮索引 @@ -1784,6 +1851,9 @@ export default { // 使用辅助方法发送高亮变化事件 this.emitHighlightChange(this.currentHighlightIndex); + + // 发送滚动事件,让页面滚动到当前高亮的文本 + this.emitScrollToText(this.currentHighlightIndex); }, // 发送高亮变化事件的辅助方法 @@ -1814,6 +1884,40 @@ export default { this.$emit('highlight-change', highlightData); }, + // 发送滚动到文本事件的辅助方法 + emitScrollToText(highlightIndex = -1) { + if (highlightIndex === -1) { + return; + } + + // 获取当前播放的音频数据 + const audioData = this.currentPageAudios[this.currentAudioIndex]; + if (!audioData) { + return; + } + + // 检查音频数据是否属于当前页面,防止页面切换时的数据错乱 + const audioCacheKey = `${this.courseId}_${this.currentPage}_${this.localVoiceId}`; + const currentPageCache = this.audioCache[audioCacheKey]; + + // 如果当前音频数据不属于当前页面,则不发送滚动事件 + if (!currentPageCache || !currentPageCache.audios.includes(audioData)) { + console.warn('🎵 emitScrollToText: 音频数据与当前页面不匹配,跳过滚动事件'); + return; + } + + const scrollData = { + highlightIndex: audioData.originalTextIndex !== undefined ? audioData.originalTextIndex : highlightIndex, + isSegmented: audioData.isSegmented || false, + segmentIndex: audioData.segmentIndex || 0, + currentText: audioData.text || '', + currentPage: this.currentPage + }; + + // 发送滚动事件 + this.$emit('scroll-to-text', scrollData); + }, + // 音频播放结束处理 onAudioEnded() { if (this.currentAudioIndex < this.currentPageAudios.length - 1) { diff --git a/subPages/home/book.vue b/subPages/home/book.vue index 5079030..0b8b5b6 100644 --- a/subPages/home/book.vue +++ b/subPages/home/book.vue @@ -72,6 +72,7 @@ @click.stop="segment.isKeyword ? handleChineseKeywordClick(segment.keywordData) : null" user-select :style="item.style" + :id="`text-segment-${segmentIndex}`" >{{ segment.text }} @@ -81,7 +82,7 @@ - + { + this.performScrollToText(scrollData); + }, 100); // 100ms防抖延迟 + }, + + // 执行滚动到高亮文本的具体逻辑 + performScrollToText(scrollData) { + if (!scrollData || typeof scrollData.highlightIndex !== 'number' || scrollData.highlightIndex < 0) { + console.warn('滚动数据无效:', scrollData); + return; + } + + // 确保在当前页面 + if (scrollData.currentPage && scrollData.currentPage !== this.currentPage) { + console.warn('页面不匹配,跳过滚动:', { + scrollDataPage: scrollData.currentPage, + currentPage: this.currentPage + }); + return; + } + + // 如果正在滚动中,跳过本次滚动 + if (this.isScrolling) { + console.warn('正在滚动中,跳过本次滚动'); + return; + } + + // 构建元素选择器 + let selector = ''; + if (scrollData.isSegmented && typeof scrollData.segmentIndex === 'number') { + // 分段音频:使用分段索引 + selector = `#text-segment-${scrollData.segmentIndex}`; + } else { + // 普通音频:需要找到对应的文本元素 + // originalTextIndex是指向原始页面数据中的索引,需要映射到实际的DOM元素 + const targetItemIndex = this.findTextItemIndex(scrollData.highlightIndex); + if (targetItemIndex !== -1) { + selector = `#text-${targetItemIndex}`; + } else { + console.warn('无法找到对应的文本元素索引:', scrollData.highlightIndex); + selector = `#text-${scrollData.highlightIndex}`; // 备用方案 + } + } + + console.log('开始滚动到文本:', { selector, scrollData }); + + // 标记正在滚动 + this.isScrolling = true; + + // 等待DOM更新后再查找元素 + this.$nextTick(() => { + // 使用uni.createSelectorQuery获取元素位置 + const query = uni.createSelectorQuery().in(this); + + // 获取scroll-view容器的位置信息 + query.select('.scroll-container').boundingClientRect(); + // 获取目标元素的位置信息 + query.select(selector).boundingClientRect(); + + query.exec((res) => { + const scrollViewRect = res[0]; + const targetRect = res[1]; + + console.log('查询结果:', { + scrollViewRect: scrollViewRect ? '找到' : '未找到', + targetRect: targetRect ? '找到' : '未找到', + selector + }); + + if (scrollViewRect && targetRect) { + // 计算目标元素相对于scroll-view的位置 + const currentScrollTop = this.scrollTops[this.currentPage - 1] || 0; + const targetOffsetTop = targetRect.top - scrollViewRect.top + currentScrollTop; + + // 计算滚动位置,让目标元素在屏幕上方1/4处(更好的阅读体验) + const screenHeight = uni.getSystemInfoSync().windowHeight; + const targetScrollTop = targetOffsetTop - screenHeight / 4; + + // 更新scroll-view的滚动位置 + const finalScrollTop = Math.max(0, targetScrollTop); + + // 检查是否需要滚动(避免不必要的滚动) + const currentScroll = this.scrollTops[this.currentPage - 1] || 0; + const scrollDifference = Math.abs(finalScrollTop - currentScroll); + + if (scrollDifference > 30) { // 降低滚动阈值,提高响应性 + this.$set(this.scrollTops, this.currentPage - 1, finalScrollTop); + + // 滚动完成后重置状态 + setTimeout(() => { + this.isScrolling = false; + }, 300); // 稍微减少等待时间 + + console.log('✅ 滚动到高亮文本:', { + selector, + targetOffsetTop, + finalScrollTop, + currentPage: this.currentPage, + scrollDifference + }); + } else { + // 不需要滚动,立即重置状态 + this.isScrolling = false; + console.log('📍 目标已在视野内,无需滚动'); + } + } else { + console.error('❌ 未找到目标元素或scroll-view:', { + selector, + scrollViewFound: !!scrollViewRect, + targetFound: !!targetRect, + currentPage: this.currentPage, + highlightIndex: scrollData.highlightIndex + }); + this.isScrolling = false; + + // 尝试备用方案:直接滚动到页面顶部附近 + if (!targetRect) { + console.log('🔄 尝试备用滚动方案'); + const fallbackScrollTop = scrollData.highlightIndex * 100; // 简单估算位置 + this.$set(this.scrollTops, this.currentPage - 1, fallbackScrollTop); + setTimeout(() => { + this.isScrolling = false; + }, 300); + } + } + }); + }); + }, + + // 查找文本元素在页面中的实际索引 + findTextItemIndex(originalTextIndex) { + const currentPageData = this.bookPages[this.currentPage - 1]; + if (!currentPageData || !Array.isArray(currentPageData)) { + return -1; + } + + let textCount = 0; + for (let i = 0; i < currentPageData.length; i++) { + const item = currentPageData[i]; + if (item && item.type === 'text' && item.content) { + if (textCount === originalTextIndex) { + return i; // 返回在页面数组中的实际索引 + } + textCount++; + } + } + + return -1; // 未找到 + }, // 获取音色列表 拿第一个做默认的音色id async getVoiceList() { @@ -1600,6 +1760,12 @@ export default { uni.$off('selectVoice') + // 0. 清理滚动防抖定时器 + if (this.scrollDebounceTimer) { + clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = null; + } + // 1. 清理单词语音资源 if (this.currentWordAudio) { diff --git a/subPages/home/components/CustomTabbar.vue b/subPages/home/components/CustomTabbar.vue index cb14eb0..9b801e4 100644 --- a/subPages/home/components/CustomTabbar.vue +++ b/subPages/home/components/CustomTabbar.vue @@ -16,6 +16,7 @@ @next-page="nextPage" @audio-state-change="onAudioStateChange" @highlight-change="onHighlightChange" + @scroll-to-text="onScrollToText" @voice-change-complete="onVoiceChangeComplete" @voice-change-error="onVoiceChangeError" @page-data-needed="onPageDataNeeded" @@ -141,9 +142,9 @@ export default { onPageDataNeeded(pageNumber) { this.$emit('page-data-needed', pageNumber) }, - // onScrollToText(refName) { - // this.$emit('scroll-to-text', refName) - // } + onScrollToText(scrollData) { + this.$emit('scroll-to-text', scrollData) + } } }