基于 UniApp 的音频播放器组件实现方案

最近在项目中需要实现一个类似音乐播放器的音频组件,用于播放达人问答内容。具体的功能如图

组件整体结构

先看一下组件的整体布局,主要分为三个部分:

  1. 左侧唱片区域 - 显示当前播放音频对应的专家头像,播放时会旋转
  2. 中间控制区域 - 包含音频信息、进度条和控制按钮
  3. 控制按钮区域 - 提供各种操作按钮
<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>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值