Browse Source

feat(音频管理): 添加统一音频管理器实现单实例播放控制

refactor(组件): 优化MeaningPopup组件布局和样式结构

- 新增AudioManager类实现音频单例管理,支持播放控制、倍速调节和事件监听
- 重构MeaningPopup组件模板结构,使用scroll-view优化长内容滚动
- 调整样式布局提高组件可维护性
main
前端-胡立永 1 week ago
parent
commit
96a1492b40
4 changed files with 5432 additions and 5312 deletions
  1. +2868
    -3130
      subPages/home/AudioControls.vue
  2. +1891
    -2028
      subPages/home/book.vue
  3. +149
    -154
      subPages/home/components/MeaningPopup.vue
  4. +524
    -0
      utils/audioManager.js

+ 2868
- 3130
subPages/home/AudioControls.vue
File diff suppressed because it is too large
View File


+ 1891
- 2028
subPages/home/book.vue
File diff suppressed because it is too large
View File


+ 149
- 154
subPages/home/components/MeaningPopup.vue View File

@ -1,225 +1,220 @@
<template>
<uv-popup
mode="bottom"
ref="meaningPopup"
round="32rpx"
bg-color="#FFFFFF"
:overlay="true"
safeAreaInsetBottom
>
<view class="meaning-popup" v-if="currentWordMeaning">
<view class="meaning-header">
<view class="close-btn" @click="closeMeaningPopup">
<text class="close-text">关闭</text>
<uv-popup mode="bottom" ref="meaningPopup" round="32rpx" bg-color="#FFFFFF" :overlay="true" safeAreaInsetBottom>
<view class="meaning-popup" v-if="currentWordMeaning">
<view class="meaning-header">
<view class="close-btn" @click="closeMeaningPopup">
<text class="close-text">关闭</text>
</view>
<view class="meaning-title">释义</view>
<view style="width: 80rpx;"></view>
</view>
<scroll-view class="meaning-scroll-container" scroll-y="true" :show-scrollbar="false" :enhanced="true">
<view class="meaning-content">
<image v-if="currentWordMeaning.image" class="meaning-image" :src="currentWordMeaning.image"
mode="widthFix" />
<view class="word-info">
<view class="word-main">
<text class="word-text">{{ currentWordMeaning.word }}</text>
</view>
<view class="phonetic-container">
<uv-icon name="volume-fill" size="16" color="#007AFF" class="speaker-icon"
@click="repeatWordAudio" v-if="currentWordMeaning.phonetic" />
<text class="phonetic-text" v-if="currentWordMeaning.phonetic">{{ currentWordMeaning.phonetic }}</text>
</view>
<view class="word-meaning">
<text class="part-of-speech" v-if="currentWordMeaning.partOfSpeech">{{ currentWordMeaning.partOfSpeech }}</text>
<text class="meaning-text" v-if="currentWordMeaning.meaning">{{ currentWordMeaning.meaning }}</text>
</view>
</view>
<view class="knowledge-gain" v-if="currentWordMeaning.knowledgeGain">
<view class="knowledge-header">
<image src="/static/knowledge-icon.png" class="knowledge-icon" mode="aspectFill" />
<text class="knowledge-title">知识收获</text>
</view>
<text class="knowledge-content">{{ currentWordMeaning.knowledgeGain }}</text>
</view>
</view>
</scroll-view>
</view>
<view class="meaning-title">释义</view>
<view style="width: 80rpx;"></view>
</view>
<view class="meaning-content">
<image
v-if="currentWordMeaning.image"
class="meaning-image"
:src="currentWordMeaning.image"
mode="aspectFit"
/>
<view class="word-info">
<view class="word-main">
<text class="word-text">{{ currentWordMeaning.word }}</text>
</view>
<view class="phonetic-container">
<uv-icon
name="volume-fill"
size="16"
color="#007AFF"
class="speaker-icon"
@click="repeatWordAudio"
/>
<text class="phonetic-text">{{ currentWordMeaning.phonetic }}</text>
</view>
<view class="word-meaning">
<text class="part-of-speech">{{ currentWordMeaning.partOfSpeech }}</text>
<text class="meaning-text">{{ currentWordMeaning.meaning }}</text>
</view>
</view>
<view class="knowledge-gain">
<view class="knowledge-header">
<image src="/static/knowledge-icon.png" class="knowledge-icon" mode="aspectFill" />
<text class="knowledge-title">知识收获</text>
</view>
<text class="knowledge-content">{{ currentWordMeaning.knowledgeGain }}</text>
</view>
</view>
</view>
</uv-popup>
</uv-popup>
</template>
<script>
export default {
name: 'MeaningPopup',
props: {
currentWordMeaning: {
type: Object,
default: null
}
},
methods: {
open() {
if (this.$refs.meaningPopup) {
this.$refs.meaningPopup.open()
}
name: 'MeaningPopup',
props: {
currentWordMeaning: {
type: Object,
default: null
}
},
close() {
if (this.$refs.meaningPopup) {
this.$refs.meaningPopup.close()
}
},
closeMeaningPopup() {
this.$emit('close-meaning-popup')
this.close()
},
repeatWordAudio() {
this.$emit('repeat-word-audio')
methods: {
open() {
if (this.$refs.meaningPopup) {
this.$refs.meaningPopup.open()
}
},
close() {
if (this.$refs.meaningPopup) {
this.$refs.meaningPopup.close()
}
},
closeMeaningPopup() {
this.$emit('close-meaning-popup')
this.close()
},
repeatWordAudio() {
this.$emit('repeat-word-audio')
}
}
}
}
</script>
<style lang="scss" scoped>
/* 释义弹窗样式 */
.meaning-popup {
background-color: #FFFFFF;
overflow: hidden;
background-color: #FFFFFF;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 80vh;
}
.meaning-scroll-container {
flex: 1;
height: 60vh;
}
.meaning-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #EEEEEE;
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #EEEEEE;
flex-shrink: 0;
}
.close-btn {
width: 80rpx;
width: 80rpx;
}
.close-text {
font-family: PingFang SC;
font-size: 32rpx;
color: #8b8b8b;
font-family: PingFang SC;
font-size: 32rpx;
color: #8b8b8b;
}
.meaning-title {
font-family: PingFang SC;
font-weight: 500;
font-size: 34rpx;
color: #181818;
font-family: PingFang SC;
font-weight: 500;
font-size: 34rpx;
color: #181818;
}
.meaning-content {
padding: 32rpx;
padding: 32rpx;
}
.meaning-image {
width: 670rpx;
height: 268rpx;
border-radius: 24rpx;
margin-bottom: 32rpx;
width: 670rpx;
height: 268rpx;
border-radius: 24rpx;
margin-bottom: 32rpx;
}
.word-info {
margin-bottom: 32rpx;
margin-bottom: 32rpx;
}
.word-main {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
.word-text {
font-family: PingFang SC;
font-weight: 500;
font-size: 40rpx;
color: #181818;
font-family: PingFang SC;
font-weight: 500;
font-size: 40rpx;
color: #181818;
}
.phonetic-container {
display: flex;
gap: 24rpx;
align-items: center;
.speaker-icon {
cursor: pointer;
transition: opacity 0.2s ease;
padding: 8rpx;
border-radius: 8rpx;
&:hover {
opacity: 0.7;
background-color: rgba(0, 122, 255, 0.1);
display: flex;
gap: 24rpx;
align-items: center;
.speaker-icon {
cursor: pointer;
transition: opacity 0.2s ease;
padding: 8rpx;
border-radius: 8rpx;
&:hover {
opacity: 0.7;
background-color: rgba(0, 122, 255, 0.1);
}
}
.phonetic-text {
font-family: PingFang SC;
font-size: 28rpx;
color: #262626;
margin-bottom: 16rpx;
}
}
.phonetic-text {
font-family: PingFang SC;
font-size: 28rpx;
color: #262626;
margin-bottom: 16rpx;
}
}
.word-meaning {
display: flex;
gap: 16rpx;
display: flex;
gap: 16rpx;
}
.part-of-speech {
font-family: PingFang SC;
font-size: 28rpx;
color: #262626;
font-family: PingFang SC;
font-size: 28rpx;
color: #262626;
}
.meaning-text {
font-family: PingFang SC;
font-size: 28rpx;
color: #181818;
flex: 1;
font-family: PingFang SC;
font-size: 28rpx;
color: #181818;
flex: 1;
}
.knowledge-gain {
background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
border-radius: 24rpx;
padding: 32rpx 40rpx;
background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
border-radius: 24rpx;
padding: 32rpx 40rpx;
}
.knowledge-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
}
.knowledge-icon {
width: 48rpx;
height: 48rpx;
width: 48rpx;
height: 48rpx;
}
.knowledge-title {
font-family: PingFang SC;
font-weight: 600;
font-size: 30rpx;
color: #3B3D3D;
font-family: PingFang SC;
font-weight: 600;
font-size: 30rpx;
color: #3B3D3D;
}
.knowledge-content {
font-family: PingFang SC;
font-size: 28rpx;
color: #4f4f4f;
line-height: 48rpx;
font-family: PingFang SC;
font-size: 28rpx;
color: #4f4f4f;
line-height: 48rpx;
}
</style>

+ 524
- 0
utils/audioManager.js View File

@ -0,0 +1,524 @@
/**
* 统一音频管理器
* 实现单实例音频管理避免多个音频同时播放造成的冲突
*/
class AudioManager {
constructor() {
// 当前音频实例
this.currentAudio = null;
// 音频类型:'sentence' | 'word'
this.currentAudioType = null;
// 音频状态
this.isPlaying = false;
// 复用的音频实例池
this.audioInstance = null;
// 事件监听器
this.listeners = {
play: [],
pause: [],
ended: [],
error: [],
timeupdate: [],
canplay: []
};
// 播放速度支持检测
this.playbackRateSupported = true;
// 统一音频配置
this.globalPlaybackRate = 1.0; // 全局播放速度
this.globalVoiceId = ''; // 全局音色ID
this.speedOptions = [0.5, 0.8, 1.0, 1.25, 1.5, 2.0]; // 支持的播放速度选项
}
/**
* 创建HTML5 Audio实例并包装为uni-app兼容接口
*/
createHTML5Audio() {
const audio = new Audio();
// 包装为uni-app兼容的接口
const wrappedAudio = {
// 原生HTML5 Audio实例
_nativeAudio: audio,
// 基本属性
get src() { return audio.src; },
set src(value) { audio.src = value; },
get duration() { return audio.duration || 0; },
get currentTime() { return audio.currentTime || 0; },
get paused() { return audio.paused; },
// 支持倍速的关键属性
get playbackRate() { return audio.playbackRate; },
set playbackRate(value) {
try {
audio.playbackRate = value;
console.log(`🎵 HTML5 Audio倍速设置成功: ${value}x`);
} catch (error) {
console.error('❌ HTML5 Audio倍速设置失败:', error);
}
},
// 基本方法
play() {
return audio.play().catch(error => {
console.error('HTML5 Audio播放失败:', error);
});
},
pause() {
audio.pause();
},
stop() {
audio.pause();
audio.currentTime = 0;
},
seek(time) {
audio.currentTime = time;
},
destroy() {
audio.pause();
audio.src = '';
audio.load();
},
// 事件绑定方法
onCanplay(callback) {
audio.addEventListener('canplay', callback);
},
onPlay(callback) {
audio.addEventListener('play', callback);
},
onPause(callback) {
audio.addEventListener('pause', callback);
},
onEnded(callback) {
audio.addEventListener('ended', callback);
},
onTimeUpdate(callback) {
audio.addEventListener('timeupdate', callback);
},
onError(callback) {
// 包装错误事件,过滤掉非关键错误
const wrappedCallback = (error) => {
// 只在有src且音频正在播放时才传递错误事件
if (audio.src && audio.src.trim() !== '' && !audio.paused) {
callback(error);
} else {
console.log('🔇 HTML5 Audio错误(已忽略):', {
hasSrc: !!audio.src,
paused: audio.paused,
errorType: error.type || 'unknown'
});
}
};
audio.addEventListener('error', wrappedCallback);
},
// 移除事件监听
offCanplay(callback) {
audio.removeEventListener('canplay', callback);
},
offPlay(callback) {
audio.removeEventListener('play', callback);
},
offPause(callback) {
audio.removeEventListener('pause', callback);
},
offEnded(callback) {
audio.removeEventListener('ended', callback);
},
offTimeUpdate(callback) {
audio.removeEventListener('timeupdate', callback);
},
offError(callback) {
audio.removeEventListener('error', callback);
}
};
return wrappedAudio;
}
/**
* 创建音频实例
* 优先使用HTML5 Audio支持倍速降级到uni.createInnerAudioContext
* 复用已存在的音频实例以提高性能
*/
createAudioInstance() {
// 如果已有音频实例,直接复用
if (this.audioInstance) {
console.log('🔄 复用现有音频实例');
return this.audioInstance;
}
// 在H5环境下优先使用HTML5 Audio
// #ifdef H5
try {
this.audioInstance = this.createHTML5Audio();
console.log('🎵 创建新的HTML5 Audio实例');
} catch (error) {
console.warn('⚠️ HTML5 Audio创建失败,降级到uni音频:', error);
audio = uni.createInnerAudioContext();
this.playbackRateSupported = false;
}
// #endif
// 在非H5环境下使用uni音频
// #ifndef H5
this.audioInstance = uni.createInnerAudioContext();
this.playbackRateSupported = false;
console.log('🎵 创建新的uni音频实例');
// #endif
return this.audioInstance;
}
/**
* 停止当前音频
*/
stopCurrentAudio() {
if (this.currentAudio) {
console.log(`🛑 停止当前音频 (类型: ${this.currentAudioType})`);
try {
this.currentAudio.pause();
// 不销毁音频实例,保留以供复用
// this.currentAudio.destroy();
} catch (error) {
console.error('⚠️ 停止音频时出错:', error);
}
this.currentAudio = null;
this.currentAudioType = null;
this.isPlaying = false;
// 触发暂停事件
this.emit('pause');
}
}
/**
* 播放音频
* @param {string} audioUrl - 音频URL
* @param {string} audioType - 音频类型 ('sentence' | 'word')
* @param {Object} options - 播放选项
*/
async playAudio(audioUrl, audioType = 'word', options = {}) {
try {
console.log(`🎵 开始播放${audioType}音频:`, audioUrl);
// 停止当前播放的音频
this.stopCurrentAudio();
// 创建新的音频实例
const audio = this.createAudioInstance();
audio.src = audioUrl;
// 设置播放选项,优先使用全局播放速度
const playbackRate = options.playbackRate || this.globalPlaybackRate;
if (playbackRate && this.playbackRateSupported) {
audio.playbackRate = playbackRate;
console.log(`🎵 设置音频播放速度: ${playbackRate}x`);
}
// 绑定事件监听器
this.bindAudioEvents(audio, audioType);
// 保存当前音频实例
this.currentAudio = audio;
this.currentAudioType = audioType;
// 延迟播放,确保音频实例完全准备好
setTimeout(() => {
if (this.currentAudio === audio) {
try {
audio.play();
console.log(`${audioType}音频开始播放`);
} catch (playError) {
console.error('❌ 播放命令失败:', playError);
this.emit('error', playError);
}
}
}, 100);
return audio;
} catch (error) {
console.error('❌ 播放音频异常:', error);
this.emit('error', error);
throw error;
}
}
/**
* 绑定音频事件监听器
*/
bindAudioEvents(audio, audioType) {
// 播放开始
audio.onPlay(() => {
console.log(`🎵 ${audioType}音频播放开始`);
this.isPlaying = true;
this.emit('play', { audioType, audio });
});
// 播放暂停
audio.onPause(() => {
console.log(`⏸️ ${audioType}音频播放暂停`);
this.isPlaying = false;
this.emit('pause', { audioType, audio });
});
// 播放结束
audio.onEnded(() => {
console.log(`🏁 ${audioType}音频播放结束`);
this.isPlaying = false;
// 不销毁音频实例,保留以供复用
// 只清理当前播放状态
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioType = null;
}
this.emit('ended', { audioType, audio });
});
// 播放错误
audio.onError((error) => {
console.error(`${audioType}音频播放失败:`, error);
this.isPlaying = false;
// 发生错误时才销毁音频实例
try {
audio.destroy();
} catch (destroyError) {
console.error('⚠️ 销毁音频实例时出错:', destroyError);
}
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioType = null;
}
// 清理复用的音频实例引用
if (this.audioInstance === audio) {
this.audioInstance = null;
}
this.emit('error', { error, audioType, audio });
});
// 时间更新
audio.onTimeUpdate(() => {
this.emit('timeupdate', {
currentTime: audio.currentTime,
duration: audio.duration,
audioType,
audio
});
});
// 可以播放
audio.onCanplay(() => {
this.emit('canplay', { audioType, audio });
});
}
/**
* 暂停当前音频
*/
pause() {
if (this.currentAudio && this.isPlaying) {
this.currentAudio.pause();
}
}
/**
* 恢复播放
*/
resume() {
if (this.currentAudio && !this.isPlaying) {
this.currentAudio.play();
}
}
/**
* 设置播放速度
*/
setPlaybackRate(rate) {
if (this.currentAudio && this.playbackRateSupported) {
this.currentAudio.playbackRate = rate;
return true;
}
return false;
}
/**
* 设置全局播放速度
* @param {number} rate - 播放速度 (0.5 - 2.0)
*/
setGlobalPlaybackRate(rate) {
if (rate < 0.5 || rate > 2.0) {
console.warn('⚠️ 播放速度超出支持范围 (0.5-2.0):', rate);
return false;
}
this.globalPlaybackRate = rate;
console.log(`🎵 设置全局播放速度: ${rate}x`);
// 如果当前有音频在播放,立即应用新的播放速度
if (this.currentAudio && this.playbackRateSupported) {
try {
this.currentAudio.playbackRate = rate;
console.log(`✅ 当前音频播放速度已更新: ${rate}x`);
} catch (error) {
console.error('❌ 更新当前音频播放速度失败:', error);
}
}
return true;
}
/**
* 获取全局播放速度
*/
getGlobalPlaybackRate() {
return this.globalPlaybackRate;
}
/**
* 设置全局音色ID
* @param {string|number} voiceId - 音色ID
*/
setGlobalVoiceId(voiceId) {
this.globalVoiceId = String(voiceId);
console.log(`🎵 设置全局音色ID: ${this.globalVoiceId}`);
}
/**
* 获取全局音色ID
*/
getGlobalVoiceId() {
return this.globalVoiceId;
}
/**
* 获取支持的播放速度选项
*/
getSpeedOptions() {
return [...this.speedOptions];
}
/**
* 切换到下一个播放速度
*/
togglePlaybackRate() {
const currentIndex = this.speedOptions.indexOf(this.globalPlaybackRate);
const nextIndex = (currentIndex + 1) % this.speedOptions.length;
const nextRate = this.speedOptions[nextIndex];
this.setGlobalPlaybackRate(nextRate);
return nextRate;
}
/**
* 获取当前播放状态
*/
getPlaybackState() {
return {
isPlaying: this.isPlaying,
currentAudioType: this.currentAudioType,
hasAudio: !!this.currentAudio,
playbackRateSupported: this.playbackRateSupported,
currentTime: this.currentAudio ? this.currentAudio.currentTime : 0,
duration: this.currentAudio ? this.currentAudio.duration : 0
};
}
/**
* 添加事件监听器
*/
on(event, callback) {
if (this.listeners[event]) {
// 检查是否已经绑定过相同的回调函数,避免重复绑定
if (this.listeners[event].indexOf(callback) === -1) {
this.listeners[event].push(callback);
console.log(`🎵 添加事件监听器: ${event}, 当前监听器数量: ${this.listeners[event].length}`);
} else {
console.warn(`⚠️ 事件监听器已存在,跳过重复绑定: ${event}`);
}
}
}
/**
* 移除事件监听器
*/
off(event, callback) {
if (this.listeners[event]) {
const index = this.listeners[event].indexOf(callback);
if (index > -1) {
this.listeners[event].splice(index, 1);
}
}
}
/**
* 触发事件
*/
emit(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`事件监听器执行错误 (${event}):`, error);
}
});
}
}
/**
* 销毁音频管理器
*/
destroy() {
this.stopCurrentAudio();
// 销毁复用的音频实例
// if (this.audioInstance) {
// try {
// this.audioInstance.destroy();
// } catch (error) {
// console.error('⚠️ 销毁音频实例时出错:', error);
// }
// this.audioInstance = null;
// }
this.listeners = {
play: [],
pause: [],
ended: [],
error: [],
timeupdate: [],
canplay: []
};
}
}
// 创建全局单例
const audioManager = new AudioManager();
export default audioManager;

Loading…
Cancel
Save