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

时间:2023-03-08 16:49:33

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

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


【音乐App】—— Vue-music 项目学习笔记:播放器内置组件开发(二) 【音乐App】—— Vue-music 项目学习笔记:播放器内置组件开发(二)
播放模式切换 歌词滚动显示
一、播放器模式切换功能实现

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

  • 动态绑定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解码
  1. 安装js-base64依赖:
    npm install js-base64 --save
  2. common->js->song.js中:
    import {Base64} from 'js-base64'
    this.lyric = Base64.decode(res.lyric)//解码 得到字符串

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

  • 解析字符串
  1. 安装 第三方库 lyric-parser:
    npm install lyric-parser --save 
  2. 优化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()

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

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

显示歌词

  • 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()中添加判断
  1. 当歌词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)//滚动到顶部
    }
    }
  2. 此时,如果手动将歌词滚动到其它位置,歌曲播放的当前歌词还是会滚动到中间
五、播放器歌词左右滑动

需求:两个点按钮对应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空对象
  1. created()中创建touch空对象:因为touch只存取数据,不需要添加gettter和setter监听
    created(){
    this.touch = {}
    }
  2. <div class="middle">绑定touch事件:一定记得阻止浏览器默认事件
    <div class="middle" @touchstart.prevent="middleTouchStart"
    @touchmove.prevent="middleTouchMove"
    @touchend="middleTouchEnd">
  3. 实现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`
    }
  • 坑:
  1. 使用 <scroll class="middle-r" ref="lyricList">的引用改变其style是:this.$refs.lyricList.$el.style
  2. 使用 <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页展示当前播放的歌词
  1. 添加DOM结构:
    <div class="playing-lyric-wrapper">
    <div class="playing-lyric">{{playingLyric}}</div>
    </div>
  2. data中维护数据

    playingLyric: ''
  3. 在回调函数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等属性与父组件相应内容进行合并。相当于在引入后,父组件的各种属性方法都被扩充了。
  1. 单纯组件引用:父组件 + 子组件 >>> 父组件 + 子组件
  2. mixins:父组件 + 子组件 >>> new父组件
  • 值得注意的是,在使用mixins时,父组件和子组件同时拥有着子组件内的各种属性方法,但这并不意味着他们同时共享、同时处理这些变量,两者之间除了合并,是不会进行任何通信的
  • 具体使用以及内容合并策略请参照官方API及其他技术贴等
  1. https://cn.vuejs.org/v2/guide/mixins.html
  2. 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重新计算
    }

注:项目来自慕课网