如果一个音乐应用没有播放,那么这款应用就是没有灵魂的,这也是进阶系列的重点之一。

本章主要讲解播放页的需求和逻辑处理,以及全局歌单的实现。

image-20210215160015418

# 播放页的整体思路

初始化应用的时候,没有播放入口(没有添加播放列表),需要添加播放列表才可播放。用户在进入到 album 歌单页面选择播放列表时进入到播放页面,并到列表存在 Vuex 中,歌曲列表都在全局,这样可以做到全局播放。并记录用户的行为数据,播放列表处在哪一首。

# 播放器的基本控制

  1. 播放:点击播放列表中的歌曲进行播放,播放时跳转播放界面,并显示歌曲信息。
  2. 停止:当歌曲正在播放时,点击歌曲播放界面下方的停止键,停止当前播放的歌曲。
  3. 上一曲:当前歌曲索引值减一,重新初始化加载音频数据,播放列表中上一首歌曲,如果索引值小于0,则索引值为列表长度减去一。
  4. 下一曲:当前歌曲索引值加一,重新初始化加载音频数据,播放列表中下一首歌曲,如果索引值大于列表长度,则索引值为0。
  5. 播放列表:点击弹出歌曲列表界面。
  6. 播放模式:随机,顺序,单曲,控制改变歌曲索引值,加载不同音频数据。
  7. 添加歌曲:插入播放列表中。
  8. 删除歌曲:从播放列表剔除歌曲。

# 音频 API

const bgAudioMannager = uni.getBackgroundAudioManager();   // 创建一个音频
bgAudioMannager.title = '致爱丽丝';     // 音频的标题(歌名)
bgAudioMannager.singer = '暂无';       // 音频的作者 (歌手)
bgAudioMannager.coverImgUrl = 'https://img-cdn-qiniu.dcloud.net.cn/uniapp/audio/music.jpg';  // 音频的封面(专辑海报)
bgAudioMannager.src = 'https://img-cdn-qiniu.dcloud.net.cn/uniapp/audio/music.mp3';   // 音频源 (mp3)
// 事件不用重复设定,只用在 onload 时初始化一次即可
backgroundAudioManager.onPlay()    // 背景音频播放事件,判定播放状态
backgroundAudioManager.onPause()   // 背景音频暂停事件,判定暂停状态
backgroundAudioManager.onTimeUpdate() // 背景音频播放进度更新事件,确定当前播放进度时间和底部滑块位置
backgroundAudioManager.onEnded()  // 背景音频自然播放结束事件,播放完自动加载下一首歌曲

有上面 backgroundAudioManager 对象的方法和属性,就可以创建一个简单的播放器了,但是一个播放器有更多的交互,更新歌单,对应播放的歌曲,界面交互等效果,下面就来介绍网易云的播放器的效果与交互。

# 页面及样式的处理

这一块的样式效果主要是海报的虚化填充,磁盘的旋转动画,拨条机械臂的动画,以及进度滑块的使用。

1.背景虚化

<view class="play-page" :style="bgStyle">
	<!-- 虚化蒙板 -->
	<view class="bg"></view>
	<!-- 内容层级在蒙板上 -->
	<view class="content"></view>
</view>
// 最底层元素负责背景渲染,背景为行内样式
.play-page {
	position: fixed;
	right: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background-position: center center;
	background-repeat: no-repeat;
	background-size: cover;
}
// filter 虚化最底层元素背景
.bg {
	position: fixed;
	z-index: 0;
	right: 0;
	left: 0;
	height: 100%;
	width: 100%;
	filter: blur(40rpx);
	background: inherit;
	z-index:-1;
	transform:scale(1.5);
}
  1. 磁盘转圈播放动画暂停以及重新开始旋转

主要的样式处理是磁盘转圈动画,以及拨条机械臂的滑动动画,监听音频播放停止事件,触发动画的运行。当点击开始按钮时候,就使用 css3 属性 transition 动画 transform 的旋转一定角度使得机械臂刚好落入到唱片磁盘内。当点击停止按钮时候,一方面控制唱片磁盘停止旋转,另一方面要操纵拨条机械臂匀速逆时针向上旋转离开唱片磁盘。 监听 playState 的变化,判定是否在播放状态。

<!-- 磁盘盒 -->
<view class="play-poster" @click="openList(0)">
	<div class="poster-box" :class="playState ? '' : 'pause'">
		<image class="img" v-if="curSongItem.picUrl" :src="curSongItem.picUrl"></image>
	</div>
</view>
.play-poster {
	margin: 310rpx auto 0;
	.poster-box{
		display: flex;
		width: 616rpx;
		height:616rpx;
		margin: 0 auto;
		background: url(https://s3.music.126.net/mobile-new/img/disc-ip6.png?69796123ad7cfe95781ea38aac8f2d48=) center center no-repeat;
		background-size:100%;
		align-items: center;
		justify-content: center;
		animation: circling 20s linear infinite;
		// 播放停止下 animation 停止,旋转动画停止
		&.pause{
			animation-play-state: paused;
		}
	}
	.img {
		display: block;
		margin: 0 auto;
		width: 382rpx;
		height: 382rpx;
		border: solid 16rpx rgba(0, 0, 0, .15);
		border-radius: 50%;
	}
}
// 定义动画帧
@-webkit-keyframes circling {
	0% {
		transform: rotate(0);
	}
	100% {
		// 旋转一圈
		transform: rotate(1turn);
	}
}
  1. 拨条设置

拨条机械臂监听播放状态进行滑动动画,由于 transform 的 rotate 属性值的旋转动画在元素中心点,这里需要更改一下 transform-origin 变换基点让机械臂顶部旋转。

<view class="strip-box" :class="playState ? 'active' : ''">
	<image class="img" src="https://s3.music.126.net/mobile-new/img/needle-ip6.png?be4ebbeb6befadfcae75ce174e7db862="></image>
</view>
.strip-box{
	position: absolute;
	width:100%;
	top:142rpx;
	height: 329rpx;
	z-index: 100;
	transform: rotate(-30deg);
	// 更改旋转原点为上方中心点
	transform-origin: center 0;
	transition: transform 0.3s;
	&.active{
		transform: rotate(0deg);
	}
}
  1. 拖拽进度条设置

播放进度可以使用 Uniapp 中的 slider 滑动选择器,滑块的位置由 curPlayTime 当前播放时间决定,滑块的滑动长度(max 最大值)由单曲音频的播放总时长 playTime 决定。

<slider class="line" :value="curPlayTime" min="0" :max="playTime" @change="sliderChange" block-size="15" backgroundColor="rgba(255,255,255,.5)" activeColor="rgba(255,255,255,.5)" />

# 歌单(播放列表)从哪里来

在应用初始化的时候,播放列表暂时还没有,用户需要进入到 album 歌单页面选择播放列表时进入到播放页面,并到列表存在 Vuex 中。选定了播放列表后,进入到播放页。播放页拿到存储在 Vuex 的歌单数据,进行创建音频播放。

// 在 H5 中存在用户刷新应用的可能,这里就要记住用户行为,利用 H5 的 localstorage 的缓存优势。如果用户刷新了应用,重新进入的时候读取缓存中数据再赋值到 Vuex 中。
// store/index.js
let storePlayList = {
	curSong: {},       // 当前播发歌曲信息
	list: [],          // 播放列表
	playState: false   // 播放状态
}
try {  // 给H5端缓存住音乐数据
    const value = uni.getStorageSync('playList');
    if (value) {
		storePlayList = JSON.parse(value)
    }
} catch (e) {
    // error
}

const store = new Vuex.Store({
	state: {
		playList: storePlayList,
	},
	mutations: {
		// 设置播放列表
		storePlayList(state, payload) {
			// 用对象扩展的方式去传值,这样就不用所有值传值覆盖
			state.playList = {...state.playList, ...payload}

			// 给 H5 端缓存住音乐数据
			// #ifdef H5
			uni.setStorage({
			    key: 'playList',
			    data: JSON.stringify(payload),
			    success: function () {
			        console.log('success');
			    }
			});
			// #endif
		}
	}
})

歌单页设置播放列表

// pages/subpages/index/album.vue
methods: {
	// 设置歌单
	...mapMutations(['storePlayList']),
	setPlayList() {
		// 传递歌单信息到 store
		this.storePlayList({
			curSong: item,
			list: this.songList,
		})
		uni.navigateTo({
			url: '/pages/subpages/index/play'
		})
	}
}

播放页获取播放列表

computed: {
	...mapState(['playList']),
},

根据上面的音频 API 以及歌单在 Vuex 的存取,可以创建一个全局性的背景音乐播放器了。

# 创建音频

H5 使用 uni.createInnerAudioContext() 创建并返回内部 audio 上下文 innerAudioContext 对象, App 及小程序 使用获取全局唯一的背景音频管理器 backgroundAudioManager,也就是我们所说的后台播放。

  1. 后台播放的意思是将目前正在播放的播放器隐藏,但功能还在运行。后台播放的时候,播放界面是关闭(隐藏)的,对用户是不可见的,但是播放器的所有功能都是在正常运行的。这样操作的目的是方便用户播放音乐或者视频的时候的时候还可以正常操作其他软件。
  2. 比如听音乐可以开启后台播放,同时可以玩其他的软件、游戏微信都可以玩。

# 平台差异说明

uni.getBackgroundAudioManager() 获取全局唯一的背景音频管理器 backgroundAudioManager。

5+App H5 微信小程序 支付宝小程序 百度小程序 头条小程序 QQ小程序
√ x √ x √ √ √

uni.getBackgroundAudioManager()背景音频管理器 (opens new window) uni.createInnerAudioContext()音频组件控制 (opens new window)

在页面加载的时候创建一个音频:

onLoad() {
	// 播放列表等信息对应vuex存储信息(computed中已引入)
	this.audioList = this.playList.list
	this.curSong = this.playList.curSong

	// 创建一个音频
	// #ifndef H5
		backgroundAudioManager = uni.getBackgroundAudioManager()
	// #endif

	// #ifdef H5
	backgroundAudioManager = uni.createInnerAudioContext();
	// #endif

	// 设置音频对象的方法
	this.setPlay()

	console.log("播放新歌曲了")
	this.playInit(1)
},
computed: {
	// 从vuex中获取歌曲信息
	...mapState(['playList']),
},

# 播放状态的监听

methods: {
	// 设置音频对象的方法
	setPlay() {
		// 设置对应的歌曲索引
		let tempIndex = 0
		this.audioList.forEach((item, index) => {
			if (this.curSong.id == item.id) {
				return tempIndex = index
			}
		})
		this.curPlayIndex = tempIndex

		// 监听音频播放时,更改 playState 为 1 正在播放
		backgroundAudioManager.onPlay(() => { // 设置播放状态
			this.playState = 1
			// 传递歌单信息到 store
			this.storePlayList({
				playState: 1
			})
		})

		// 监听音频停止时,更改 playState 为 0 已经暂停,页面判定 playState 停止磁盘的动画
		backgroundAudioManager.onPause(() => {
			this.playState = 0

			// 传递歌单信息到 store
			this.storePlayList({
				playState: 0
			})
		})

		// 监听音频播放进度更新,更新 curPlayTime 当前播放时间,驱使播放滑块的运动
		backgroundAudioManager.onTimeUpdate((e) => {
			this.curPlayTime = Math.floor(backgroundAudioManager.currentTime)
		})

		// 监听音频自然播放结束,进行下一首播放,重新开始整个环节
		backgroundAudioManager.onEnded(() => {
			this.nextPlay()
		})
	},
}

# 获取歌曲信息以及链接

由于歌曲的信息存放在两个接口上,这里使用 async/await 以及 Promise.all 处理两个接口,促使两个接口请求完成才进行音频的设置,保证创建的音频在设置标题以及封面的时候不会报错。

methods: {
	// 获取数据 初始化播放数据
	async playInit(isInit) {
		let [res0, res1] = await Promise.all([
			// 两个API接口方法返回数据
		])

		this.curSongItem = {...res0, ...res1}

		// 传递歌单信息到 store
		this.storePlayList({
			curSong: {...this.curAudio, ...this.curSongItem, ...{ playModel: this.playModel }}
		})

		// 新的音频歌
		/*
			设置歌曲封面等信息
		*/
		console.log("新的音频歌", this.curSongItem)
		backgroundAudioManager.title = this.curSongItem.name
		backgroundAudioManager.singer = this.curSongItem.singer
		backgroundAudioManager.coverImgUrl = this.curSongItem.picUrl
		this.playTime = this.curSongItem.time
		// 设置了 src 之后会自动播放
		backgroundAudioManager.src = this.curSongItem.src
		// h5 设置autoplay自动播放
		backgroundAudioManager.autoplay = true

		// #ifdef H5
		// h5 的标题信息
		uni.setNavigationBarTitle({
			title: this.curSongItem.name + ' - ' + this.curSongItem.singer + ' - 单曲 - 网易云音乐'
		})
		// #endif
	})
}

播放成功后,设置的音频标题、歌手名、封面图就是我们的手机在锁屏时看到的画面。

img

当加载的时候获取 backgroundAudioManager 对象的 duration 时长可能存在为 NaN 或者为空的情况,这里加个定时器一直访问 duration 属性,直到拿到音频时长后销毁定时器。

// 获取音频时长 playTime
let timer = setInterval(() => {
	this.playTime = Math.floor(backgroundAudioManager.duration || 0)
	if (this.playTime) {
		clearInterval(timer)
	}
}, 100)

# 播放进度滑块的拖拽

滑动手动更改时触发 slider 组件的 change 事件,拿到滑块所处的时间节点,并让音频跳转到指定位置并播放。

sliderChange(e) {
	this.curPlayTime = e.detail.value
	backgroundAudioManager.seek(this.curPlayTime)
	// seek 跳转到指定位置,单位 s
},

# 播放暂停的控制

音频的播放暂停比较简单,直接调用 pause/play 方法即可实现控制,上面进行了音频播放暂停的监听,进而更改 playState 播放状态,从而改变页面动画。

play() {
	if (this.playState) {
		//暂停
		backgroundAudioManager.pause()
	} else {
		//播放
		backgroundAudioManager.play()
	}
},

# 上一首下一首控制

上一首下一首的功能主要是改变播放列表的 curPlayIndex 索引值,改变 curPlayIndex 索引值从而改变音频播放不同的音乐。

// 下一首
nextPlay(type) {
	// 顺序播放
	if (this.playModel == 0) {
		if (!type) { // 默认下一首
			this.curPlayIndex = this.curPlayIndex >= (this.audioList.length - 1) ? 0 : this.curPlayIndex + 1
		} else {
			this.curPlayIndex = (this.curPlayIndex < 1) ? this.audioList.length - 1 : this.curPlayIndex - 1
		}

	}
	// 随机播放
	if (this.playModel == 1) {
		this.curPlayIndex = Math.floor(Math.random() * 10) % this.audioList.length;
	}
	// 单曲循环保持当前索引不变

	// 再次初始化播放
	this.playInit()
},
// 上一首
prevPlay() {
	this.nextPlay(1)
},

# 设置播放模式

播放模式主要三种,0,1,2 三种值依次改变即可,改变并 toast 提示,页面根据 playModel 值展示对应图标。

setPlayModel() {
	this.playModel = this.playModel > 1 ? 0 : this.playModel + 1
	uni.showToast({
		icon: 'none',
		title: ['列表循环', '随机播放', '单曲循环'][this.playModel]
	})
},

# 设置全局歌单信息

在操作歌曲的时候就需要引入 storePlayList 操作歌曲,重新存储歌单信息到 Vuex。

methods: {
	// 设置歌单
	...mapMutations(['storePlayList']),
}

比如在歌单列表删除某一个曲子。

img

// 删除单曲
removeItem(data) {
	// 过滤当前歌曲
	this.audioList = this.audioList.filter(item => {
		return data.id != item.id
	})

	// 新列表更新到store
	this.storePlayList({
		list: this.audioList
	})
	// 如果删的是当前歌曲
	if (data.id == this.playList.curSong.id && this.audioList.length > 0) {
		// 重新初始化音频信息,定位到下一首,索引保持不变
		this.playInit()
	}
},

# 离开页面清空触发事件,防止二次进入事件累计

destroyed() {
	backgroundAudioManager.onPlay()
	backgroundAudioManager.onPause()
	backgroundAudioManager.onTimeUpdate()
	backgroundAudioManager.onEnded()
},

上面的介绍已经基本实现了播放器的基本控制,由于平台的限制,平台都有管控,需在 manifest 中填写申请。

  • ios app平台,背景播放需在 manifest.json -> app-plus -> distribute -> ios 节点添加 "UIBackgroundModes":["audio"] 才能保证音乐可以后台播放(打包成 ipa 生效);
  • 小程序平台,需在 manifest.json 对应的小程序节点下,填写"requiredBackgroundModes": ["audio"]。发布小程序时平台会审核;
  • Android app 端默认不会在通知栏出现音量控制,如需此功能,需要在插件市场单独下载原生插件,插件:通知栏+音乐控制 (opens new window)。
  • 由于自动播放网页中的音频或视频,会给用户带来一些困扰或者不必要的流量消耗,所以苹果系统和安卓系统通常都会禁止自动播放和使用 JS 的触发播放,必须由用户来触发才可以播放。

img

功能已经基本满足播放的功能了,重新选歌单进入播放,随机、顺序、单曲的播放模式。有了这些功能就是一个基本的播放器了。

但是在我们第一次点击 album 歌单列表中的某首歌的时候,会进入 play 播放页,初始化创建了一个音频,歌曲开始播放。当我们 play 播放页,歌曲依然会继续播放(后台播放)。

但此时由于退出当前路由,页面的数据也已被销毁,当我们再次进入播放界面相当于重新进行页面加载了,但是音频已经创建,因此我们只需把当前音频的信息再次赋值到页面数据上。

这一点的区别就要从 Uniapp 的框架说起了。一般用 mvvm 框架去做,都会把 play 播放那一块放到应用最外面,整个应用实现共享播放界面。

如果是 vue 框架的话就会有一个主入口页面 app.vue ,示例代码:

<template>
<div>
<!-- 页面路由 -->
<router-view/>
<!-- 播放器组件 -->
<player></player>
</div>
</template>

在进入页面的时候做一下判定,判定音频是否创建,如果有音频存在就不二次创建,不然会出现多个音频在播放。如果进入到页面播放的歌曲是当前全局的歌曲,就继续播放否则重新设置页面信息(歌名,封面等信息)。

onLoad() {
	// 播放列表
	this.audioList = this.playList.list
	this.curSong = this.playList.curSong
	if (backgroundAudioManager) {  // 如果实例化了音频

	} else {
		// #ifdef H5
		backgroundAudioManager = uni.createInnerAudioContext();
		// #endif

		// 设置后台播放
		// #ifndef H5
		backgroundAudioManager = uni.getBackgroundAudioManager()
		// #endif
	}
	console.log(this.curSong.src)
	// 重新进入页面的时候判定后台音频是否为当前音频
	if (this.curSong.src && backgroundAudioManager.src && (this.curSong.src == backgroundAudioManager.src)) {
		// 维持当前歌曲
	} else {
		let isH5 = false
		// #ifdef H5
		isH5 = true
		// #endif

		if (isH5 && this.curSong.src) { // H5 利用 locastorage 优势,页面刷新后播放当前储存下的歌曲
			this.playTime = this.curSong.time
			backgroundAudioManager.src = this.curSong.src
			// h5 设置autoplay自动播放
			if (this.playList.playState) {
				backgroundAudioManager.autoplay = true
			}
		} else {
			// 播放新歌  新歌分两种情况: 1,下一首原有列表的歌  2,上个页面传递的歌曲
			console.log("播放新歌曲了")
			this.playInit(1)
			return false
		}

	}

	// 重新设置歌曲信息
	this.playTime = this.curSong.time   // 歌曲总时长
	this.curPlayTime = backgroundAudioManager.currentTime   // 歌曲当前播放时间
	this.playState = !backgroundAudioManager.paused  // 播放状态
	this.playModel = this.curSong.playModel || 0    // 播放模式
	this.curSongItem.picUrl = this.curSong.picUrl  // 设置封面

	// #ifdef H5
	this.playState = this.playList.playState
	uni.setNavigationBarTitle({
		title: this.curSong.name + ' - ' + this.curSong.singer + ' - 单曲 - 网易云音乐'
	})
	// #endif

	// 设置音频对象的方法
	this.setPlay()
}

经过处理后,没次进入就像全局组件一样,原理就是进入到播放页面,重新把 Vuex 存储的信息再次赋值到页面上,保持与全局歌曲信息一致。

# 总结

  1. 播放器的关键在音频管理对象,通过操作音频管理对象的方法实现播放/暂停/上下首的功能;
  2. 播放列表从歌单页面传递到 Vuex,播放页再从中获取;
  3. 各端的兼容不一样,需要做配置处理;
  4. 注意 H5 不能自动播放,需要人为触发。
  5. 本章代码 uni-course-player (opens new window)。
阅读全文