微信小程序的官方文档中,已经有 audio 组件。
<audio poster="{
{poster}}" name="{
{name}}" author="{
{author}}" src="{
{src}}" id="myAudio" controls loop>audio>
<button type="primary" bindtap="audioPlay">播放button>
<button type="primary" bindtap="audioPause">暂停button>
<button type="primary" bindtap="audio14">设置当前播放时间为14秒button>
<button type="primary" bindtap="audioStart">回到开头button>
// audio.js
Page({
onReady: function (e) {
// 使用 wx.createAudioContext 获取 audio 上下文 context
this.audioCtx = wx.createAudioContext('myAudio')
},
data: {
poster: 'http://y.gtimg.cn/music/photo_new/T002R300x300M000003rsKF44GyaSk.jpg?max_age=2592000',
name: '此时此刻',
author: '许巍',
src: 'http://ws.stream.qqmusic.qq.com/M500001VfvsJ21xFqb.mp3?guid=ffffffff82def4af4b12b3cd9337d5e7&uin=346897220&vkey=6292F51E1E384E06DCBDC9AB7C49FD713D632D313AC4858BACB8DDD29067D3C601481D36E62053BF8DFEAF74C0A5CCFADD6471160CAF3E6A&fromtag=46',
},
audioPlay: function () {
this.audioCtx.play()
},
audioPause: function () {
this.audioCtx.pause()
},
audio14: function () {
this.audioCtx.seek(14)
},
audioStart: function () {
this.audioCtx.seek(0)
}
})
嗯,该有的大部分功能,其实官方已经都告诉我们了。但是我们凭着不折腾不死心的快落。自己封装一个好看一点点的。
大概的功能就是点击歌单列表中某一首歌曲,同时传给封装的组件这首歌的歌曲id和位于歌单中 index。
id,通过 id 请求歌曲的详细信息
index,来切换上下歌曲
(既然我们实现的是播放器组件,那么歌单啥的,以及数据id和index如何传入,那就不是我们要关心的事情了)
理论存在,实践开始
pages 目录下,新建一个 player
页面样式大致分为三个部分。
播放(暂停)按钮,前进(后退)按钮
歌词组件(由于笔者使用的是 github 上开源的 api,这个歌词接口炸了。。。),所以我的歌词组件不能用了。当然在文章中一起解释。如果有歌词的小伙伴就可以正常使用了。
player.wxml
<view class="player-container" style="background:url({
{picUrl}}) center/cover no-repeat">view>
<view class="player-mask">view>
<view class="player-info">
<view class="player-disc {
{isPlaying?'play': ''}}" bind:tap="onChangeLyricShow" hidden="{
{isLyricShow}}">
<image class="player-img rotation {
{isPlaying?'':'rotation-paused'}}" src="{
{picUrl}}">image>
view>
<x-lyric class="lyric" isLyricShow="{
{!isLyricShow}}" bind:tap="onChangeLyricShow" lyric="{
{lyric}}" />
<view class="progress-bar">
<x-progress-bar bind:musicEnd="onNext" bind:timeUpdate="timeUpdate" bind:musicPlay="onPlay" bind:musicPause="onPause" isSame="{
{isSame}}" />
view>
<view class="control">
<text class="iconfont icon-047caozuo_shangyishou" bind:tap="onPrev">text>
<text class="iconfont {
{ isPlaying ? 'icon-icon-' : 'icon-icon-1' }}" bind:tap="togglePlaying">text>
<text class="iconfont icon-49xiayishou" bind:tap="onNext">text>
view>
view>
player.js
// pages/player/player.js
let musiclist = [] // 音乐列表
let nowPlayingIndex = 0 // 现在播放的歌曲在歌曲列表中的 index,(这个是为了实现切换上一首,下一首)
// 获取全局位移的背景音频管理器
const backgroundAudioManger = wx.getBackgroundAudioManager()
// app 全局对象
const app = getApp()
Page({
/**
* 页面的初始数据
*/
data: {
picUrl: '', // 封面图片(大转盘中间的那个)
isPlaying: false, // 是否这在播放
isLyricShow: false, // 歌词是否展示
lyric: '', // 歌词信息
isSame: false, // 是否是同一首歌曲
},
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
// 获取传入的 index,(一般都是点击歌曲列表中的某一首歌曲进去播放页面的)
nowPlayingIndex = options.index
// 从缓存当中读取音乐列表(当然你也可以选择不这么做)
musiclist = wx.getStorageSync('musiclist')
// 通过传入歌曲 ID,获取歌曲的详细信息
this._loadMusicDetail(options.musicId)
},
_loadMusicDetail(musicId) {
// 在其他页面保存当前的歌曲 ID,(有点类似于 vuex,redux 的作用)
if (musicId == app.getPlayMusicId()) {
// 如果是当前正在播放的歌曲
this.setData({
isSame: true
})
} else {
this.setData({
isSame: false
})
}
// 如果不是当前正在播放的歌曲,直接暂停
if (!this.data.isSame) {
backgroundAudioManger.stop()
}
// 获取当前正在播放的音乐
let music = musiclist[nowPlayingIndex]
// 设置导航栏的 title 为音乐的名称
wx.setNavigationBarTitle({
title: music.name,
})
// 赋值封面图片的信息,设置正在播放为 false
this.setData({
picUrl: music.al.picUrl,
isPlaying: false
})
// 向 app 这个全局对象的 setPlayMusicId 方法设置 musicId
app.setPlayMusicId(musicId)
// 刚进入时,进行 loading 动画加载
wx.showLoading({
title: '歌曲加载中',
})
// 调用 云函数 music,传入 musicId,使用云函数向后端发起请求
wx.cloud.callFunction({
name: 'music',
data: {
musicId,
$url: 'musicUrl',
}
}).then((res) => {
// 解析返回数据
let result = JSON.parse(res.result)
// 处理异常
if (result.data[0].url == null) {
wx.showToast({
title: '无权限播放',
})
return
}
// 对于歌曲不同,进行重新的信息赋值操作
if (!this.data.isSame) {
// 必填项
backgroundAudioManger.src = result.data[0].url
backgroundAudioManger.title = music.name
// 必填项
backgroundAudioManger.coverImgUrl = music.al.picUrl
backgroundAudioManger.singer = music.ar[0].name
backgroundAudioManger.epname = music.al.name
// 保存播放历史
this.savePlayHistory()
}
this.setData({
isPlaying: true
})
wx.hideLoading()
// 加载歌词
wx.cloud.callFunction({
name: 'music',
data: {
musicId,
$url: 'lyric',
}
}).then((res) => {
let lyric = '暂无歌词'
const lrc = JSON.parse(res.result).lrc
if (lrc) {
lyric = lrc.lyric
}
// 设置歌词
this.setData({
lyric
})
})
})
},
// 切换播放状态
togglePlaying() {
// 正在播放,则调用暂停函数
if (this.data.isPlaying) {
backgroundAudioManger.pause()
} else {
// 暂停播放,则调用播放函数
backgroundAudioManger.play()
}
this.setData({
isPlaying: !this.data.isPlaying
})
},
// 切换上一首
onPrev() {
// 相对应的 index 减小
nowPlayingIndex--
// 如果 index < 0,证明已经切换到第一首了,下一首是最后一首,实现循环播放
if (nowPlayingIndex < 0) {
nowPlayingIndex = musiclist.length - 1
}
// 调用函数,重新加载歌曲信息
this._loadMusicDetail(musiclist[nowPlayingIndex].id)
},
// 下一首
onNext() {
// 相对应的 index 增加
nowPlayingIndex++
// 如果 index = musiclist.length,证明已经切换到最后一首,下一首是第一首,实现循环播放
if (nowPlayingIndex === musiclist.length) {
nowPlayingIndex = 0
}
// 调用函数,重新加载歌曲信息
this._loadMusicDetail(musiclist[nowPlayingIndex].id)
},
// 决定是展示封面还是歌词组件
onChangeLyricShow() {
this.setData({
isLyricShow: !this.data.isLyricShow
})
},
// 获取歌词组件,更新当前的时间,实现歌词与进度条的联动
timeUpdate(event) {
this.selectComponent('.lyric').update(event.detail.currentTime)
},
// 设置播放状态
onPlay() {
this.setData({
isPlaying: true,
})
},
onPause() {
this.setData({
isPlaying: false,
})
},
// 保存播放历史
savePlayHistory() {
// 当前正在播放的歌曲
const music = musiclist[nowPlayingIndex]
// 获取全局对象存储的 openid
const openid = app.globalData.openid
// 获取缓存的 history
const history = wx.getStorageSync(openid)
let bHave = false
for (let i = 0, len = history.length; i < len; i++) {
// 遍历历史记录(如果历史当中已存在,则不添加,否则向其中添加一条记录)
if (history[i].id == music.id) {
bHave = true
break
}
}
if (!bHave) {
history.unshift(music)
// 设置缓存
wx.setStorage({
key: openid,
data: history,
})
}
}
})
做到这一步,不出意外的话,恭喜你,拥有了一个可以播放音乐的组件,同时可以实现前进,后退,还可以获取播放的历史信息。
progress-bar.wxml
进度条组件,使用微信小程序自带的 moveable-area
组件
<view class="container">
<text class="time">{
{showTime.currentTime}}text>
<view class="control">
<movable-area class="movable-area">
<movable-view direction="horizontal" class="movable-view"
damping="1000"
x="{
{movableDis}}"
bindchange="onChange"
bindtouchend="onTouchEnd"
/>
movable-area>
<progress
stroke-width="4"
backgroundColor="#fff"
activeColor="#d2568c"
percent="{
{progress}}"
>progress>
view>
<text class="time">{
{showTime.totalTime}}text>
view>
progress-bar.js
// components/progress-bar/progress-bar.js
let movableAreaWidth = 0
let movableViewWidth = 0
const backgroundAudioManager = wx.getBackgroundAudioManager()
// 当前的秒数
let currentSec = -1
// 进度
let duration = 0
// 是否移动
let isMoving = false
Component({
/**
* 组件的属性列表
*/
properties: {
// 是否是同一首(父组件调用此组件时,应该传入数据)
isSame: Boolean
},
/**
* 组件的初始数据
*/
data: {
showTime: {
// 当前时间
currentTime: '00:00',
// 总共时间
totalTime: '00:00'
},
// 移动距离
movableDis: 0,
// 进度
progress: 0
},
// 组件的生命周期,dom 渲染之后执行
lifetimes: {
ready() {
if (this.properties.isSame && this.data.showTime.totalTime == '00:00') {
// 设置时间
this._setTime()
}
// 获取移动的距离
this._getMovableDis()
// 获取背景音乐
this._bindBGMEvent()
}
},
/**
* 组件的方法列表
*/
methods: {
// 当用户拖动进度条的时候触发的函数
onChange(event) {
if (event.detail.source == 'touch') {
this.data.progress = event.detail.x / (movableAreaWidth - movableViewWidth) * 100
this.data.movableDis = event.detail.x
isMoving = true
}
},
// 用户拖动进度条结束时,重新给背景音乐的各项赋值
onTouchEnd() {
const currentTimeFmt = this._dateFormat(Math.floor(backgroundAudioManager.currentTime))
this.setData({
progress: this.data.progress,
movableDis: this.data.movableDis,
['showTime.currentTime']: `${
currentTimeFmt.min} : ${
currentTimeFmt.sec}`
})
// 获取用户拖动进度条对应的时间,然后赋值给这个 API,音乐就可以跳转到相对应的时间
backgroundAudioManager.seek(duration * this.data.progress / 100)
isMoving = false
},
// 获取移动的距离
_getMovableDis() {
const query = this.createSelectorQuery()
// 获取元素的宽度
// 获取 movable-view,movable-area的宽度
query.select('.movable-area').boundingClientRect()
query.select('.movable-view').boundingClientRect()
query.exec((rect) => {
movableAreaWidth = rect[0].width
movableViewWidth = rect[1].width
})
},
// 获取背景音乐
_bindBGMEvent() {
backgroundAudioManager.onPlay(() => {
console.log('onPlay')
isMoving = false
this.triggerEvent('musicPlay')
})
backgroundAudioManager.onStop(() => {
console.log('onStop')
})
backgroundAudioManager.onPause(() => {
console.log('Pause')
this.triggerEvent('musicPause')
})
backgroundAudioManager.onWaiting(() => {
console.log('onWaiting')
})
backgroundAudioManager.onCanplay(() => {
console.log('onCanplay')
console.log(backgroundAudioManager.duration)
// 音乐播放
if (typeof backgroundAudioManager.duration != 'undefined') {
this._setTime()
} else {
setTimeout(() => {
this._setTime()
}, 1000)
}
})
backgroundAudioManager.onTimeUpdate(() => {
if (!isMoving) {
const currentTime = backgroundAudioManager.currentTime
const duration = backgroundAudioManager.duration
// 播放时间优化(progress的进度条)
const sec = currentTime.toString().split('.')[0]
if (sec != currentSec) {
const currentTimeFmt = this._dateFormat(currentTime)
this.setData({
movableDis: (movableAreaWidth - movableViewWidth) * currentTime / duration,
progress: currentTime / duration * 100,
['showTime.currentTime']: `${
currentTimeFmt.min} : ${
currentTimeFmt.sec}`
})
currentSec = sec
// 联动歌词
this.triggerEvent('timeUpdate', {
currentTime
})
}
}
})
// 一首歌曲播放完成会触发这个函数,把这个事件抛出给 父组件
// 告诉父组件这首歌曲已经播放完成
backgroundAudioManager.onEnded(() => {
console.log("onEnded")
this.triggerEvent('musicEnd')
})
backgroundAudioManager.onError((res) => {
console.error(res.errMsg)
console.error(res.errCode)
wx.showToast({
title: '错误:' + res.errCode,
})
})
},
_setTime() {
// 获取音乐播放的总时长,获取的时间是毫秒级的,需要格式化
duration = backgroundAudioManager.duration
// 格式化时间
const durationFmt = this._dateFormat(duration)
// 向 data 中的数据进行赋值
this.setData({
['showTime.totalTime']: `${
durationFmt.min}:${
durationFmt.sec}`
})
},
// 格式化时间
_dateFormat(sec) {
const min = Math.floor(sec / 60)
sec = Math.floor(sec % 60)
return {
'min': this._pares0(min),
'sec': this._pares0(sec)
}
},
_pares0(sec) {
return sec < 10 ? '0' + sec : sec
}
}
})
lyric.wxml
<scroll-view hidden="{
{isLyricShow}}" class="lyric-scroll" scroll-y scroll-top="{
{scrollTop}}" scroll-with-animation="true">
<view class="lyric-panel">
<block wx:for="{
{lrcList}}" wx:key="item">
<view class="lyric {
{index==nowLyricIndex?'hightlight-lyric': ''}}">{
{item.lrc}}view>
block>
view>
scroll-view>
lyric.js
// components/lyric/lyric.js
let lyricHeight = 0
Component({
/**S
* 组件的属性列表
*/
properties: {
// 是否显示歌词,(调用的父组件应该将相应的值传给子组件)
isLyricShow: {
type: Boolean,
value: false
},
// 歌词信息
lyric: String
},
observers: {
lyric(lrc) {
if (lrc === '暂无歌词') {
this.setData({
lrcList: [
{
lrc,
time: 0
}
],
nowLyricIndex: -1
})
} else {
// 解析歌词
this._parseLyric(lrc)
}
}
},
/**
* 组件的初始数据
*/
data: {
// 歌词列表
lrcList: [],
// 播放的歌词函数
nowLyricIndex: 0,
// 距离顶部的高度(歌词是需要进行上下滚动的,因此需要一个值,来记录当前距离顶部的值)
scrollTop: 0
},
lifetimes: {
ready() {
wx.getSystemInfo({
success(res) {
lyricHeight = res.screenWidth / 750 * 64
},
})
}
},
/**
* 组件的方法列表
*/
methods: {
// 与组件之间进行歌词联动
update(currentTime) {
// 歌词列表
let lrcList = this.data.lrcList
if (lrcList.length === 0) {
return
}
// 当没有歌词的时候,不高亮任何一句歌词
if (currentTime > lrcList[lrcList.length - 1].time) {
if (this.data.nowLyricIndex != -1) {
this.setData({
nowLyricIndex: -1,
scrollTop: lrcList.length * lyricHeight
})
}
}
for(let i = 0, len = lrcList.length; i < len ; i++) {
if (currentTime <= lrcList[i].time) {
this.setData({
nowLyricIndex: i -1,
scrollTop: (i - 1) * lyricHeight
})
break
}
}
},
// 歌词解析
_parseLyric(sLyric) {
let line = sLyric.split('\n')
let _lrcList = []
line.forEach((elem) => {
let time = elem.match(/\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]/g)
if (time != null) {
let lrc = elem.split(time)[1]
let timeReg = time[0].match(/(\d{2,}):(\d{2})(?:\.(\d{2,3}))?/)
let time2Seconds = parseInt(timeReg[1]) * 60 + parseInt(timeReg[2]) + parseInt(timeReg[3]) / 1000
_lrcList.push({
lrc,
time: time2Seconds
})
}
})
this.setData({
lrcList: _lrcList
})
}
}
})
走到这一步,不出意外的话,你的页面就可以显示出来了。
当然了,前面一直提到的 app
全局对象,需要在根目录下 app.js
中进行定义。
//app.js
App({
onLaunch: function () {
if (!wx.cloud) {
console.error('请使用 2.2.3 或以上的基础库以使用云能力')
} else {
wx.cloud.init({
env: '',
traceUser: true,
})
}
this.globalData = {
}
this.userInfo = {
}
this.userMessage = []
this.getOpenid()
this.globalData = {
playingMusicId: -1,
openid: -1
}
},
setPlayMusicId(musicId) {
this.globalData.playingMusicId = musicId
},
getPlayMusicId() {
return this.globalData.playingMusicId
},
getOpenid() {
wx.cloud.callFunction({
name: 'login'
}).then((res) => {
console.log('res')
const openid = res.result.openid
this.globalData.openid = openid
if (wx.getStorageSync(openid) == '') {
console.log('执行了')
wx.setStorageSync(openid, [])
}
})
},
})
这就是播放器组件的全部了。
云函数的内容也很简单了。不过本文不再提及。播放器组件用到的 musicUrl
,与 lyric
云函数如下
// 云函数入口文件
const cloud = require('wx-server-sdk')
const TcbRouter = require('tcb-router')
const rp = require('request-promise')
// 这个接口地址是 github 上开源的免费音乐接口(感谢作者的开源,棒棒哒~(。≧3≦)ノ⌒☆)
const BASE_URL = 'http://musicapi.leanapp.cn/'
cloud.init()
// 云函数入口函数
exports.main = async (event, context) => {
const app = new TcbRouter({
event
})
app.router('musicUrl', async (ctx, next) => {
console.log(event.musicId)
ctx.body = await rp(BASE_URL + 'music/url?id=' + parseInt(event.musicId)).then((res) => {
return res
})
})
app.router('lyric', async (ctx, next) => {
console.log(event.musicId)
ctx.body = await rp(BASE_URL + '/lyric?id=' + parseInt(event.musicId)).then((res) => {
return res
})
})
return app.serve()
}
结束语:
可能你照着上面的代码会运行不出来。这很正常,笔者的这篇文章是项目完成之后大概 8 个月之后,为了面试而总结的。其中的细节可能遗漏了,各位请见谅。需要完整代码的,可以访问我的 gitee,获取详细代码。✔️