【音乐App】—— Vue-music 项目学习笔记:播放器内置组件开发(二)

2023-05-20,,

前言:以下内容均为学习慕课网高级实战课程的实践爬坑笔记。

项目github地址:https://github.com/66Web/ljq_vue_music,欢迎Star。


播放模式切换 歌词滚动显示
一、播放器模式切换功能实现

按钮样式随模式改变而改变

动态绑定iconMode图标class:

<i :class="iconMode"></i>
import {playMode} from '@/common/js/config'

iconMode(){
return this.mode === playMode.sequence ? 'icon-sequence' : this.mode === playMode.loop ? 'icon-loop' : 'icon-random'
}

给按钮添加点击事件,通过mapGetters获取mode,通过mapMutaions修改:

<div class="icon i-left" @click="changeMode">
changeMode(){
const mode = (this.mode + 1) % 3
this.setPlayMode(mode)
} setPlayMode: 'SET_PLAY_MODE'

播放列表顺序随模式改变而改变

common->js目录下:创建util.js,提供工具函数

function getRandomInt(min, max){
return Math.floor(Math.random() * (max - min + 1) + min)
} //洗牌: 遍历arr, 从0-i 之间随机取一个数j,使arr[i]与arr[j]互换
export function shuffle(arr){
let _arr = arr.slice() //改变副本,不修改原数组 避免副作用
for(let i = 0; i<_arr.length; i++){
let j = getRandomInt(0, i)
let t = _arr[i]
_arr[i] = _arr[j]
_arr[j] = t
}
return _arr
}

通过mapGetters获取sequenceList,在changeMode()中判断mode,通过mapMutations修改playlist

changeMode(){
const mode = (this.mode + 1) % 3
this.setPlayMode(mode)
let list = null
if(mode === playMode.random){
list = shuffle(this.sequenceList)
}else{
list = this.sequenceList
} this.resetCurrentIndex(list)
this.setPlayList(list)
}

播放列表顺序改变后当前播放歌曲状态不变

findIndex找到当前歌曲id值index,通过mapMutations改变currentIndex,保证当前歌曲的id不变

resetCurrentIndex(list){
let index = list.findIndex((item) => { //es6语法 findIndex
return item.id === this.currentSong.id
})
this.setCurrentIndex(index)
}

坑:CurrentSong发生了改变,会触发watch中监听的操作,如果当前播放暂停,改变模式会自动播放
解决:添加判断,如果当前歌曲的id不变,认为CurrentSong没变,不执行任何操作

currentSong(newSong, oldSong) {
if(newSong.id === oldSong.id) {
return
}
this.$nextTick(() => { //确保DOM已存在
this.$refs.audio.play()
})
}

当前歌曲播放完毕时自动切换到下一首或重新播放

监听audio派发的ended事件:@ended="end"

end(){
if(this.mode === playMode.loop){
this.loop()
}else{
this.next()
}
},
loop(){
this.$refs.audio.currentTime = 0
this.$refs.audio.play()
}

“随机播放全部”按钮功能实现

music-list.vue中给按钮监听点击事件

@click="random"

actions.js中添加randomPlay action

import {playMode} from '@/common/js/config'
import {shuffle} from '@/common/js/util' export const randomPlay = function ({commit},{list}){
commit(types.SET_PLAY_MODE, playMode.random)
commit(types.SET_SEQUENCE_LIST, list)
let randomList = shuffle(list)
commit(types.SET_PLAYLIST, randomList)
commit(types.SET_CURRENT_INDEX, 0)
commit(types.SET_FULL_SCREEN, true)
commit(types.SET_PLAYING_STATE, true)
}

music-list.vue中定义random方法应用randomPlay

random(){
this.randomPlay({
list: this.songs
})
} ...mapActions([
'selectPlay',
'randomPlay'
])

坑:当点击了“随机播放全部”之后,再选择歌曲列表中指定的一首歌,播放的不是所选择的歌曲
原因:切换了随机播放之后,当前播放列表的顺序就不是歌曲列表的顺序了,但选择歌曲时传给currentIndex的index还是歌曲列表的index
解决:在actions.js中的selectPlay action中添加判断,如果是随机播放模式,将歌曲洗牌后存入播放列表,找到当前选择歌曲在播放列表中的index再传给currentIndex

function findIndex(list, song){
return list.findIndex((item) => {
return item.id === song.id
})
} export const selectPlay = function ({commit, state}, {list, index}) {
//commit方法提交mutation
commit(types.SET_SEQUENCE_LIST, list)
if(state.mode === playMode.random) {
let randomList = shuffle(list)
commit(types.SET_PLAYLIST, randomList)
index = findIndex(randomList, list[index])
}else{
commit(types.SET_PLAYLIST, list)
}
commit(types.SET_CURRENT_INDEX, index)
commit(types.SET_FULL_SCREEN, true)
commit(types.SET_PLAYING_STATE, true)
}
二、播放器歌词数据抓取

src->api目录下:创建song.js

import {commonParams} from './config'
import axios from 'axios' export function getLyric(mid){
const url = '/api/lyric' const data = Object.assign({}, commonParams, {
songmid: mid,
pcachetime: +new Date(),
platform: 'yqq',
hostUin: 0,
needNewCode: 0,
g_tk: 5381, //会变化,以实时数据为准
format: 'json' //规定为json请求
}) return axios.get(url, {
params: data
}).then((res) => {
return Promise.resolve(res.data)
})
}

webpack.dev.config.js中通过node强制改变请求头

app.get('/api/lyric', function(req, res){
var url="https://szc.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" axios.get(url, {
headers: { //通过node请求QQ接口,发送http请求时,修改referer和host
referer: 'https://y.qq.com/',
host: 'c.y.qq.com'
},
params: req.query //把前端传过来的params,全部给QQ的url
}).then((response) => {
res.json(response.data)
}).catch((e) => {
console.log(e)
})
})

common->js->song.js中将获取数据的方法封装到class类

getLyric() {
getLyric(this.mid).then((res) => {
if(res.retcode === ERR_OK){
this.lyric = res.lyric
//console.log(this.lyric)
}
})
}

player.vue中调用getLyric()测试

currentSong(newSong, oldSong) {
if(newSong.id === oldSong.id) {
return
}
this.$nextTick(() => { //确保DOM已存在
this.$refs.audio.play()
this.currentSong.getLyric()//测试
})
}

因为请求后QQ返回的仍然是一个jsonp, 需要在后端中做一点处理

webpack.dev.config.js中通过正则表达式,将接收到的jsonp文件转换为json格式

var ret = response.data
if (typeof ret === 'string') {
var reg = /^\w+\(({[^()]+})\)$/
// 以单词a-z,A-Z开头,一个或多个
// \(\)转义括号以()开头结尾
// ()是用来分组
// 【^()】不以左括号/右括号的字符+多个
// {}大括号也要匹配到
var matches = ret.match(reg)
if (matches) {
ret = JSON.parse(matches[1])
// 对匹配到的分组的内容进行转换
}
}
res.json(ret)

注意:后端配置后都需要重新启动!!!

三、播放器歌词数据解析

js-base64 code解码

    安装js-base64依赖:

    npm install js-base64 --save

    common->js->song.js中:

    import {Base64} from 'js-base64'
    this.lyric = Base64.decode(res.lyric)//解码 得到字符串

解析字符串

    安装 第三方库 lyric-parser:

    npm install lyric-parser --save 

    优化getLyric:如果已经有歌词,不再请求

    getLyric() {
    if(this.lyric){
    return Promise.resolve()
    } return new Promise((resolve, reject) => {
    getLyric(this.mid).then((res) => {
    if(res.retcode === ERR_OK){
    this.lyric = Base64.decode(res.lyric)//解码 得到字符串
    // console.log(this.lyric)
    resolve(this.lyric)
    }else{
    reject('no lyric')
    }
    })
    })
    }

player.vue中使用lyric-parser,并在data中维护一个数据currentLyric

import Lyric from 'lyric-parser'

//获取解析后的歌词
getLyric() {
this.currentSong.getLyric().then((lyric) => {
this.currentLyric = new Lyric(lyric)//实例化lyric对象
console.log(this.currentLyric)
})
}

在watch的currentSong()中调用:this.getLyric()

四、播放器歌词滚动列表实现

显示歌词

player.vue中添加DOM结构

<div class="middle-r" ref="lyricList">
<div class="lyric-wrapper">
<div v-if="currentLyric">
<p ref="lyricLine"
class="text"
v-for="(line, index) in currentLyric.lines" :key="index"
:class="{'current': currentLineNum === index}">
{{line.txt}}
</p>
</div>
</div>
</div>

歌词随歌曲播放高亮显示

在data中维护数据

currentLineNum: 0

初始化lyric对象时传入handleLyric方法,得到当前currentLingNum值,判断如果歌曲播放,调用Lyric的play()

//获取解析后的歌词
getLyric() {
this.currentSong.getLyric().then((lyric) => {
//实例化lyric对象
this.currentLyric = new Lyric(lyric, this.handleLyric)
// console.log(this.currentLyric)
if(this.playing){
this.currentLyric.play()
}
})
},
handleLyric({lineNum, txt}){
this.currentLineNum = lineNum
}

动态绑定current样式,高亮显示index为currentLineNum值的歌词

:class="{'current': currentLineNum === index}"

歌词实现滚动,歌曲播放时当前歌词滚动到中间显示

引用并注册scroll组件

import Scroll from '@/base/scroll/scroll'

使用<scroll>替换<div>,同时传入currentLyric和currentLyric.lines作为data

<scroll class="middle-r" ref="lyricList" :data="currentLyric && currentLyric.lines">

在handleLyric()中添加判断

    当歌词lineNum大于5时,触发滚动,滚动到当前元素往前偏移第5个的位置;否则滚动到顶部

    handleLyric({lineNum, txt}){
    this.currentLineNum = lineNum
    if(lineNum > 5){
    let lineEl = this.$refs.lyricLine[lineNum - 5] //保证歌词在中间位置滚动
    this.$refs.lyricList.scrollToElement(lineEl, 1000)
    }else{
    this.$refs.lyricList.scrollTo(0, 0, 1000)//滚动到顶部
    }
    }

    此时,如果手动将歌词滚动到其它位置,歌曲播放的当前歌词还是会滚动到中间

五、播放器歌词左右滑动

需求:两个点按钮对应CD页面和歌词页面,可切换

实现:data中维护数据currentShow,动态绑定active class:

currentShow: 'cd'
<div class="dot-wrapper">
<span class="dot" :class="{'active': currentShow === 'cd'}"></span>
<span class="dot" :class="{'active': currentShow === 'lyric'}"></span>
</div>

需求:切换歌词页面时,歌词向左滑,CD有一个渐隐效果;反之右滑,CD渐现

实现:【移动端滑动套路】—— touchstart、touchmove、touchend事件 touch空对象

    created()中创建touch空对象:因为touch只存取数据,不需要添加gettter和setter监听

    created(){
    this.touch = {}
    }

    <div class="middle">绑定touch事件:一定记得阻止浏览器默认事件

    <div class="middle" @touchstart.prevent="middleTouchStart"
    @touchmove.prevent="middleTouchMove"
    @touchend="middleTouchEnd">

    实现touch事件的回调函数:touchstart和touchmove的回调函数中要传入event,touchstart中定义初始化标志位initiated

    //歌词滑动
    middleTouchStart(e){
    this.touch.initiated = true //初始化标志位
    const touch = e.touches[0]
    this.touch.startX = touch.pageX
    this.touch.startY = touch.pageY
    },
    middleTouchMove(e){
    if(!this.touch.initiated){
    return
    }
    const touch = e.touches[0]
    const deltaX = touch.pageX - this.touch.startX
    const deltaY = touch.pageY - this.touch.startY
    //维护deltaY原因:歌词本身Y轴滚动,当|deltaY| > |deltaX|时,不滑动歌词
    if(Math.abs(deltaY) > Math.abs(deltaX)){
    return
    }
    const left = this.currentShow === 'cd' ? 0 : -window.innerWidth
    const offsetWidth = Math.min(0, Math.max(-window.innerWidth, left + deltaX))
    this.touch.percent = Math.abs(offsetWidth / window.innerWidth) //滑入歌词offsetWidth = 0 + deltaX(负值) 歌词滑出offsetWidth = -innerWidth + delta(正值)
    this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px, 0, 0)`
    this.$refs.lyricList.$el.style[transitionDuration] = 0
    this.$refs.middleL.style.opacity = 1 - this.touch.percent //透明度随percent改变
    this.$refs.middleL.style[transitionDuration] = 0
    },
    middleTouchEnd(){
    //优化:手动滑入滑出10%时,歌词自动滑过
    let offsetWidth
    let opacity
    if(this.currentShow === 'cd'){
    if(this.touch.percent > 0.1){
    offsetWidth = -window.innerWidth
    opacity = 0
    this.currentShow = 'lyric'
    }else{
    offsetWidth = 0
    opacity = 1
    }
    }else{
    if(this.touch.percent < 0.9){
    offsetWidth = 0
    opacity = 1
    this.currentShow = 'cd'
    }else{
    offsetWidth = -window.innerWidth
    opacity = 0
    }
    }
    const time = 300
    this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px, 0, 0)`
    this.$refs.lyricList.$el.style[transitionDuration] = `${time}ms`
    this.$refs.middleL.style.opacity = opacity
    this.$refs.middleL.style[transitionDuration] = `${time}ms`
    }

坑:

    使用 <scroll class="middle-r" ref="lyricList">的引用改变其style是:this.$refs.lyricList.$el.style
    使用 <div class="middle-l" ref="middleL">的引用改变其style是:this.$refs.middleL.style
六、播放器歌词剩余功能

坑:切换歌曲后,歌词会闪动
原因:每次都会重新实例化Layric,但前一首的Layric中的定时器还在,造成干扰
解决:在Watch的currentSong()中添加判断,切换歌曲后,如果实例化新的Layric之前有currentLyric,清空其中的定时器

if(this.currentLyric){
this.currentLyric.stop() //切换歌曲后,清空前一首歌歌词Layric实例中的定时器
}

坑:歌曲暂停播放后,歌词还会继续跳动,并没有被暂停
解决:在togglePlaying()中判断如果存在currentLyric,就调用currentLyric的togglePlay()切换歌词的播放暂停

if(this.currentLyric){
this.currentLyric.togglePlay()//歌词切换播放暂停
}

坑:单曲循环播放模式下,歌曲播放完毕后,歌词并没有返回到一开始
解决:在loop()中判断如果存在currentLyric,就调用currentLyric的seek()将歌词偏移到最开始

if(this.currentLyric){
this.currentLyric.seek(0) //歌词偏移到一开始
}

坑:拖动进度条改变歌曲播放进度后,歌词没有随之改变到对应位置
解决:在onProgressBarChange()中判断如果存在currentLyric,就调用seek()将歌词偏移到currentTime*1000位置处

const currentTime = this.currentSong.duration * percent

if(this.currentLyric){
this.currentLyric.seek(currentTime * 1000)//偏移歌词到拖动时间的对应位置
}

需求:CD页展示当前播放的歌词

    添加DOM结构:

    <div class="playing-lyric-wrapper">
    <div class="playing-lyric">{{playingLyric}}</div>
    </div>

    data中维护数据

    playingLyric: ''

    在回调函数handleLyric()中改变当前歌词:

    this.playingLyric = txt

考虑异常情况:如果getLyric()请求失败,做一些清理的操作

getLyric() {
this.currentSong.getLyric().then((lyric) => {
//实例化lyric对象
this.currentLyric = new Lyric(lyric, this.handleLyric)
// console.log(this.currentLyric)
if(this.playing){
this.currentLyric.play()
}
}).catch(() => {
//请求失败,清理数据
this.currentLyric = null
this.playingLyric = ''
this.currentLineNum = 0
})
}

考虑特殊情况:如果播放列表只有一首歌,next()中添加判断,使歌曲单曲循环播放;prev()同理

next() {
if(!this.songReady){
return
}
if(this.playlist.length === 1){ //只有一首歌,单曲循环
this.loop()
}else{
let index = this.currentIndex + 1
if(index === this.playlist.length){
index = 0
}
this.setCurrentIndex(index)
if(!this.playing){
this.togglePlaying()
}
this.songReady = false
}
}

优化:因为手机微信运行时从后台切换到前台时不执行js,要保证歌曲重新播放,使用setTimeout替换nextTick

setTimeout(() => { //确保DOM已存在
this.$refs.audio.play()
// this.currentSong.getLyric()//测试歌词
this.getLyric()
}, 1000)
七、播放器底部播放器适配+mixin的应用

问题:播放器收缩为mini-player之后,播放器占据列表后的一定空间,导致BScroll计算的高度不对,滚动区域受到影响
mixin的适用情况:当多种组件都需要一种相同的逻辑时,引用mixin处可以将其中的代码添加到组件中

mixin详解

vue中提供了一种混合机制--mixins,用来更高效的实现组件内容的复用
组件在引用之后相当于在父组件内开辟了一块单独的空间,来根据父组件props过来的值进行相应的操作,但本质上两者还是泾渭分明,相对独立。
而mixins则是在引入组件之后,则是将组件内部的内容如data等方法、method等属性与父组件相应内容进行合并。相当于在引入后,父组件的各种属性方法都被扩充了。

    单纯组件引用:父组件 + 子组件 >>> 父组件 + 子组件
    mixins:父组件 + 子组件 >>> new父组件

值得注意的是,在使用mixins时,父组件和子组件同时拥有着子组件内的各种属性方法,但这并不意味着他们同时共享、同时处理这些变量,两者之间除了合并,是不会进行任何通信的
具体使用以及内容合并策略请参照官方API及其他技术贴等

    https://cn.vuejs.org/v2/guide/mixins.html
    http://www.deboy.cn/Vue-mixins-advance-tips.html

——转载自【木子墨博客】

common->js目录下:创建mixin.js

import {mapGetters} from 'vuex'

export const playlistMixin = {
computed:{
...mapGetters([
'playlist'
])
},
mounted() {
this.handlePlaylist(this.playlist)
},
activated() { //<keep-alive>组件切换过来时会触发activated
this.handlePlaylist(this.playlist)
},
watch:{
playlist(newVal){
this.handlePlaylist(newVal)
}
},
methods: { //组件中定义handlePlaylist,就会覆盖这个,否则就会抛出异常
handlePlaylist(){
throw new Error('component must implement handlePlaylist method')
}
}
}

music-list.vue中应用mixin

import {playlistMixin} from '@/common/js/mixin'
mixins: [playlistMixin]

定义handlePlaylist方法,判断如果有playlist,改变改变list的bottom并强制scroll重新计算

handlePlaylist(playlist){
const bottom = playlist.length > 0 ? '60px' : ''
this.$refs.list.$el.style.bottom = bottom //底部播放器适配
this.$refs.list.refresh() //强制scroll重新计算
}

singer.vue中同上:需要在listview.vue中暴露一个refresh方法后,再在singer.vue中调用

refresh() {
this.$refs.listview.refresh()
} handlePlaylist(playlist) {
const bottom = playlist.length > 0 ? '60px' : ''
this.$refs.singer.style.bottom = bottom //底部播放器适配
this.$refs.list.refresh() //强制scroll重新计算
}

recommend.vue中同上:

handlePlaylist(playlist){
const bottom = playlist.length > 0 ? '60px' : ''
this.$refs.recommend.style.bottom = bottom //底部播放器适配
this.$refs.scroll.refresh() //强制scroll重新计算
}

注:项目来自慕课网

【音乐App】—— Vue-music 项目学习笔记:播放器内置组件开发(二)的相关教程结束。

《【音乐App】—— Vue-music 项目学习笔记:播放器内置组件开发(二).doc》

下载本文的Word格式文档,以方便收藏与打印。