最近在项目中需要实现一个类似音乐播放器的音频组件,用于播放达人问答内容。具体的功能如图
组件整体结构
先看一下组件的整体布局,主要分为三个部分:
- 左侧唱片区域 - 显示当前播放音频对应的专家头像,播放时会旋转
- 中间控制区域 - 包含音频信息、进度条和控制按钮
- 控制按钮区域 - 提供各种操作按钮
<view class="recorderBox flex">
<!-- 左侧唱片区域 -->
<view class="flex left">...</view>
<!-- 中间控制区域 -->
<view class="centerBox">...</view>
</view>
核心功能实现
1. 音频播放核心逻辑
使用 UniApp 提供的createInnerAudioContext
接口创建音频上下文,封装了播放、暂停、跳转等功能:
initAudio(audioUrl = this.audioUrl) {
if (this.audioContext) {
this.audioContext.destroy();
}
this.audioContext = uni.createInnerAudioContext();
this.audioContext.src = audioUrl;
this.audioContext.autoplay = false;
// 音频可播放时获取时长
this.audioContext.onCanplay(() => {
setTimeout(() => {
this.duration = this.audioContext?.duration || 0;
}, 1000);
});
// 音频播放结束处理
this.audioContext.onEnded(() => {
this.isPlaying = false;
this.currentProgress = 100;
this.stopTimeUpdate();
});
// 错误处理
this.audioContext.onError((err) => {
this.isPlaying = false;
this.stopTimeUpdate();
console.log('音频播放错误:', err);
});
}
2. 进度条与时间更新
进度条更新是播放器的核心功能之一,这里采用了定时器的方式更新进度,而不是使用onTimeUpdate
事件,因为后者在某些情况下会有精度问题:
startTimeUpdate() {
// 已经有定时器在运行则不重复创建
if (this.timeUpdateTimer) return;
// 记录开始时间
this.lastAudioTime = this.audioContext.currentTime || 0;
// 启动定时器
this.timeUpdateTimer = setInterval(() => {
if (!this.isPlaying || this.isDragging || !this.duration) return;
// 更新时间(使用定时器步进)
this.currentTime = this.lastAudioTime + (Date.now() - this.timeUpdateOffset) / 1000;
// 计算进度
if (this.currentTime < this.duration) {
this.currentProgress = (this.currentTime / this.duration) * 100;
} else {
// 播放结束
this.currentProgress = 100;
this.pause();
}
}, this.timeUpdateInterval);
// 记录启动时间戳
this.timeUpdateOffset = Date.now();
}
3. 进度条拖拽功能
实现了进度条的拖拽功能,允许用户手动调整播放进度:
startDrag(e) {
this.isDragging = true;
this.pause();
this.updateProgressContainerPosition();
this.touchStartX = e.touches[0].clientX;
this.startProgress = this.currentProgress;
},
onDrag(e) {
if (!this.isDragging || !this.progressContainerRect) return;
const deltaX = e.touches[0].clientX - this.touchStartX;
const progress = this.startProgress + (deltaX / this.progressContainerRect.width) * 100;
this.currentProgress = Math.max(0, Math.min(100, progress));
},
endDrag() {
if (!this.isDragging) return;
this.isDragging = false;
if (this.audioContext && this.duration > 0) {
this.audioContext.seek((this.currentProgress / 100) * this.duration);
// 更新实际时间
this.currentTime = (this.currentProgress / 100) * this.duration;
this.lastAudioTime = this.currentTime;
if (this.isPlaying) this.play();
}
}
5. 切换及列表功能
都是将事件抛出给父组件,父组件更新audioUrl的值给子组件即可
this.$emit('changeExpert')
组件生命周期管理
为了避免内存泄漏和不必要的资源消耗,需要妥善管理组件的生命周期:
// 组件挂载时初始化
mounted() {
this.$nextTick(() => {
this.updateProgressContainerPosition();
});
uni.onWindowResize(() => this.updateProgressContainerPosition());
},
// 组件销毁时清理
beforeDestroy() {
if (this.audioContext) {
this.audioContext.stop();
this.audioContext.destroy();
}
this.stopTimeUpdate();
uni.offWindowResize();
}
音频播放的隐藏坑:页面切换后的内存泄漏
在父组件的onHide
中,将传给播放器的audioUrl
设为空,不然切换页面音频依旧播放
onHide() {
this.audioUrl = ''; // 页面隐藏时清空音频地址
}
完整代码
<template>
<view class="">
<view class="recorderBox flex">
<!-- 左侧唱片区域 -->
<view class="flex left">
<swiper class="swiper" circular :autoplay="autoplay" :vertical="true" @touchstart="handleTouchStart"
@touchend="handleTouchEnd" :current="currentExpertIndex">
<swiper-item v-for="it,idx in askList" :key="idx">
<view class="circleFirst cen-flex" :class="{ 'slow-rotate': isPlaying }">
<image :src="it.avatar" class="avatar" mode="widthFix" />
</view>
</swiper-item>
</swiper>
<image src="/static/newDetails/15.png" class="pointer" mode="widthFix" :style="{
transformOrigin: 'top right',
transform: isPlaying ? 'rotate(0deg)' : 'rotate(-20deg)',
transition: 'transform 0.5s ease'
}" />
</view>
<view class="centerBox">
<view class="jl-flex top">
<view class="author" style="font-weight:bold;">达人:{{obj.nickName}}</view>
<view class="time">{{ formattedCurrentTime }} / {{ formattedDuration }}</view>
</view>
<view class="progress-container" ref="progressContainer" @touchstart.stop.prevent="startDrag"
@touchmove.stop.prevent="onDrag" @touchend.stop.prevent="endDrag"
@touchcancel.stop.prevent="endDrag">
<view class="progress-bg">
<view class="progress-bar" :style="{width: currentProgress + '%'}"></view>
<view class="progress-dot" :style="{left: currentProgress + '%'}"></view>
</view>
</view>
<view class="toolsBox alflex">
<view class="tool flexDirecColumn" @click="changeExpert">
<image src="/static/newDetails/12.png" mode="widthFix" />
<view class="name">切换</view>
</view>
<view class="tool flexDirecColumn" @click="seekToStart">
<image src="/static/newDetails/14.png" mode="widthFix" />
<view class="name">上一条</view>
</view>
<view class="tool flexDirecColumn" @click="playPause">
<image :src="isPlaying ? '/static/newDetails/7.png' : '/static/newDetails/19.png'"
mode="widthFix" />
<view class="name">{{ isPlaying ? '暂停' : '播放' }}</view>
</view>
<view class="tool flexDirecColumn" @click="seekForward">
<image src="/static/newDetails/16.png" mode="widthFix" />
<view class="name">下一条</view>
</view>
<view class="tool flexDirecColumn" @click="toList">
<image src="/static/newDetails/11.png" mode="widthFix" />
<view class="name">列表</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
props: {
obj: {
type: Object,
default: () => ({})
},
audioUrl: {
type: String,
default: ''
},
currentExpertIndex: {
default: ''
},
askList: {
type: Array,
default: () => ([])
}
},
data() {
return {
currentProgress: 0,
isDragging: false,
isPlaying: false,
audioContext: null,
duration: 0,
currentTime: 0,
progressContainerRect: null,
touchStartX: 0,
startProgress: 0,
timeUpdateTimer: null, // 定时器引用
lastAudioTime: 0, // 上次记录的音频时间
timeUpdateInterval: 1000, // 更新间隔(ms)
timeUpdateOffset: 0, // 时间偏移量
background: ['color1', 'color2', 'color3'],
indicatorDots: false,
autoplay: false,
interval: 2000,
touchStartY: 0, // 触摸开始的Y坐标
touchEndY: 0, // 触摸结束的Y坐标
threshold: 50 // 判断滑动的阈值(像素)
}
},
computed: {
formattedCurrentTime() {
return this.formatTime(this.currentTime);
},
formattedDuration() {
return this.formatTime(this.duration);
}
},
watch: {
audioUrl: {
handler(newUrl, oldUrl) {
console.log('播放器组件 音频地址更新 新地址:', newUrl, '旧地址:', oldUrl);
if (newUrl && newUrl !== oldUrl) {
this.resetAudio(newUrl);
}
},
immediate: true,
deep: true
},
// currentProgress: {
// handler(newVal) {
// if (this.audioContext && this.duration > 0 && !this.isDragging) {
// // this.audioContext.seek((newVal / 100) * this.duration);
// }
// },
// immediate: true
// },
},
methods: {
handleTouchStart(e) {
this.touchStartY = e.changedTouches[0].clientY;
},
handleTouchEnd(e) {
this.touchEndY = e.changedTouches[0].clientY;
const moveDistance = this.touchEndY - this.touchStartY;
if (moveDistance < -this.threshold) {
this.$emit('changeExpert')
} else if (moveDistance > this.threshold) {
this.$emit('changeExpert', 'prev');
}
},
initAudio(audioUrl = this.audioUrl) {
if (this.audioContext) {
this.audioContext.destroy();
}
console.log('播放器组件 初始化音频 音频地址:', audioUrl);
this.audioContext = uni.createInnerAudioContext();
this.audioContext.src = audioUrl;
this.audioContext.autoplay = false;
this.audioContext.onCanplay(() => {
setTimeout(() => {
this.duration = this.audioContext?.duration || 0;
console.log('播放器组件 音频可播放 时长:', this.duration);
}, 1000);
});
// 移除onTimeUpdate监听,改用定时器
this.audioContext.onEnded(() => {
this.isPlaying = false;
this.currentProgress = 100;
this.stopTimeUpdate();
console.log('播放器组件 音频播放结束');
});
this.audioContext.onError((err) => {
this.isPlaying = false;
this.stopTimeUpdate();
console.log('播放器组件 音频播放错误 错误信息:', err);
});
},
async resetAudio(newUrl) {
if (this.audioContext) {
this.audioContext.stop();
this.audioContext.offCanplay();
this.audioContext.offTimeUpdate();
this.audioContext.offEnded();
this.audioContext.offError();
this.audioContext = null;
}
this.currentTime = 0;
this.currentProgress = 0;
this.isPlaying = false;
this.lastAudioTime = 0;
this.timeUpdateOffset = 0;
this.stopTimeUpdate();
await this.initAudio(newUrl);
console.log('播放器组件 重置音频 新音频地址:', newUrl);
// this.play();
},
playPause() {
if (!this.audioContext) {
this.initAudio();
setTimeout(() => this.playPause(), 500);
return;
}
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
},
dqPlay() {
console.log('播放器组件 强制播放 音频上下文:', this.audioContext, '音频地址:', this.audioUrl);
this.audioContext.play();
this.isPlaying = true;
this.startTimeUpdate();
},
dpPause() {
this.audioContext.pause();
this.isPlaying = false;
this.stopTimeUpdate();
console.log('播放器组件 强制暂停');
},
play() {
this.audioContext.play();
this.isPlaying = true;
this.startTimeUpdate();
this.$emit('playAudio', true);
console.log('播放器组件 播放音频');
},
pause() {
this.audioContext.pause();
this.isPlaying = false;
this.stopTimeUpdate();
this.$emit('playAudio', false);
console.log('播放器组件 暂停音频');
},
formatTime(seconds) {
if (isNaN(seconds) || seconds <= 0) return '00:00';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
},
startDrag(e) {
this.isDragging = true;
this.pause();
this.updateProgressContainerPosition();
this.touchStartX = e.touches[0].clientX;
this.startProgress = this.currentProgress;
console.log('播放器组件 开始拖动进度条 起始进度:', this.startProgress);
},
onDrag(e) {
if (!this.isDragging || !this.progressContainerRect) return;
const deltaX = e.touches[0].clientX - this.touchStartX;
const progress = this.startProgress + (deltaX / this.progressContainerRect.width) * 100;
this.currentProgress = Math.max(0, Math.min(100, progress));
console.log('播放器组件 拖动进度条中 当前进度:', this.currentProgress);
},
endDrag() {
if (!this.isDragging) return;
this.isDragging = false;
if (this.audioContext && this.duration > 0) {
this.audioContext.seek((this.currentProgress / 100) * this.duration);
// 更新实际时间
this.currentTime = (this.currentProgress / 100) * this.duration;
this.lastAudioTime = this.currentTime;
if (this.isPlaying) this.play();
console.log('播放器组件 结束拖动进度条 最终进度:', this.currentProgress, '跳转时间:', this.currentTime);
}
},
updateProgressContainerPosition() {
if (this.$refs.progressContainer) {
uni.createSelectorQuery().in(this)
.select('.progress-container')
.boundingClientRect(rect => {
this.progressContainerRect = rect;
console.log('播放器组件 更新进度条容器位置 容器信息:', rect);
})
.exec();
}
},
// 定时器控制方法
startTimeUpdate() {
// 已经有定时器在运行则不重复创建
if (this.timeUpdateTimer) return;
// 记录开始时间
this.lastAudioTime = this.audioContext.currentTime || 0;
console.log('播放器组件 启动时间更新定时器 起始时间:', this.lastAudioTime);
// 启动定时器
this.timeUpdateTimer = setInterval(() => {
if (!this.isPlaying || this.isDragging || !this.duration) return;
// 更新时间(使用定时器步进,避免onTimeUpdate的精度问题)
this.currentTime = this.lastAudioTime + (Date.now() - this.timeUpdateOffset) / 1000;
// 计算进度
if (this.currentTime < this.duration) {
this.currentProgress = (this.currentTime / this.duration) * 100;
} else {
// 播放结束
this.currentProgress = 100;
this.pause();
}
// console.log('播放器组件 时间更新中 当前时间:', this.currentTime, '当前进度:', this.currentProgress);
}, this.timeUpdateInterval);
// 记录启动时间戳
this.timeUpdateOffset = Date.now();
},
stopTimeUpdate() {
if (this.timeUpdateTimer) {
clearInterval(this.timeUpdateTimer);
this.timeUpdateTimer = null;
console.log('播放器组件 停止时间更新定时器');
}
// 记录暂停时的实际音频时间
if (this.audioContext) {
this.lastAudioTime = this.audioContext.currentTime || 0;
console.log('播放器组件 记录暂停时音频时间 时间:', this.lastAudioTime);
}
},
changeExpert() {
this.$emit('changeExpert');
console.log('播放器组件 触发切换专家');
},
// 上一条
seekToStart() {
this.$emit('ToStart');
console.log('播放器组件 触发上一条');
},
// 下一条
seekForward() {
this.$emit('Forward');
console.log('播放器组件 触发下一条');
},
toList() {
this.$emit('toList');
console.log('播放器组件 触发显示列表');
},
toDestroy() {
if (this.audioContext) {
this.audioContext.stop();
this.audioContext.offCanplay();
this.audioContext.offTimeUpdate();
this.audioContext.offEnded();
this.audioContext.offError();
this.audioContext = null;
}
this.currentTime = 0;
this.currentProgress = 0;
this.isPlaying = false;
this.lastAudioTime = 0;
this.timeUpdateOffset = 0;
console.log('播放器组件 销毁音频上下文',this.audioUrl);
this.stopTimeUpdate();
this.audioUrl=''
},
},
mounted() {
this.$nextTick(() => {
this.updateProgressContainerPosition();
});
uni.onWindowResize(() => this.updateProgressContainerPosition());
console.log('播放器组件 挂载完成');
},
beforeDestroy() {
if (this.audioContext) {
this.audioContext.stop();
this.audioContext.destroy();
}
this.stopTimeUpdate();
uni.offWindowResize();
console.log('播放器组件 销毁完成');
}
}
</script>
<style lang="scss" scoped>
.recorderBox {
position: fixed;
bottom: 100rpx;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(360deg, #20164A 0%, rgba(85, 69, 164, 0.8) 100%);
width: 690rpx;
height: 220rpx;
border-radius: 300rpx;
padding: 10rpx 14rpx;
box-sizing: border-box;
display: flex;
align-items: center;
.left {
.circleFirst {
width: 168rpx;
height: 170rpx;
background: #1B1B1B;
// border: 6px solid #6F64A0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.avatar {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
}
}
.pointer {
width: 90rpx;
margin: 10rpx 0 0 -40rpx;
}
}
.centerBox {
color: #DBDBDB;
z-index: 10;
.top {
padding: 17rpx 0;
display: flex;
justify-content: space-between;
.author,
.time {
font-size: 24rpx;
}
.time {
transform: scale(0.8);
}
}
.toolsBox {
display: flex;
justify-content: center;
gap: 14rpx;
.tool {
display: flex;
flex-direction: column;
align-items: center;
image {
width: 60rpx;
margin-bottom: 9rpx;
}
.name {
font-size: 24rpx;
transform: scale(0.8);
white-space: nowrap;
}
}
}
}
// 进度条样式
.progress-container {
width: 100%;
position: relative;
height: 30rpx;
margin: 0 2px;
.progress-bg {
width: 100%;
height: 4rpx;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 3rpx;
.progress-bar {
height: 100%;
background-color: #fff;
border-radius: 3rpx;
transition: width 0.2s ease;
}
.progress-dot {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50rpx;
height: 50rpx;
background-color: transparent;
border-radius: 50%;
margin-left: -10rpx;
cursor: pointer;
transition: left 0.2s ease;
}
}
}
}
// 唱片旋转动画
@keyframes slowRotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.slow-rotate {
animation: slowRotation 10s linear infinite;
transform-origin: center;
}
</style>
<style>
.uni-margin-wrap {
width: 690rpx;
width: 100%;
}
.swiper {
width: 170rpx;
height: 170rpx;
border-radius: 50%;
border: 6px solid #6F64A0;
}
.swiper-item {
display: block;
width: 170rpx;
height: 170rpx;
background: #1B1B1B;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.swiper-list {
margin-top: 40rpx;
margin-bottom: 0;
}
.uni-common-mt {
margin-top: 60rpx;
position: relative;
}
.info {
position: absolute;
right: 20rpx;
}
.uni-padding-wrap {
width: 550rpx;
padding: 0 100rpx;
}
</style>