【音乐App】—— Vue2.0开发移动端音乐WebApp项目爬坑(三)

前言:在学习《慕课网音乐App》课程的过程中,第一次接触并实践了数据的跨域抓取和围绕音乐播放展开的不同功能,也是这个项目带给我最大的收获,前面的实践记录分别总结了:推荐页面开发歌手页面开发。这一篇主要梳理一下:音乐播放器的开发。项目github地址:https://github.com/66Web/ljq_vue_music,欢迎Star。


歌曲播放 歌词播放
一、播放器Vuex数据设计
  • 需求: 播放器可以通过歌手详情列表、歌单详情列表、排行榜、搜索结果多种组件打开,因此播放器数据一定是全局的

       state.js目录下:定义数据

import {playMode} from '@/common/js/config'

const state = {
        singer: {},
        playing: false, //播放状态
        fullScreen: false, //播放器展开方式:全屏或收起
        playlist: [], //播放列表(随机模式下与顺序列表不同)
        sequenceList: [], //顺序播放列表
        mode: playMode.sequence, //播放模式: 顺序、循环、随机
        currentIndex: -1 //当前播放歌曲的index(当前播放歌曲为playlist[index])
}

       common->js目录下:创建config.js配置项目相关

//播放器播放模式: 顺序、循环、随机
export const playMode = {
       sequence: 0, 
       loop: 1,
       random: 2
}

       getter.js目录下:数据映射(类似于计算属性)

export const playing = state => state.playing
export const fullScreen = state => state.fullScreen
export const playlist = state => state.playlist
export const sequenceList = state => state.sequenceList
export const mode = state => state.mode
export const currentIndex = state => state.currentIndex

export const currentSong = (state) => {
       return state.playlist[state.currentIndex] || {}
}
  • 组件中可以通过mapgetters拿到这些数据

       mutaion-type.js目录下:定义事件类型字符串常量

export const SET_PLAYING_STATE = 'SET_PLAYING_STATE'
export const SET_FULL_SCREEN = 'SET_FULL_SCREEN'
export const SET_PLAYLIST = 'SET_PLAYLIST'
export const SET_SEQUENCE_LIST = 'SET_SEQUENCE_LIST'
export const SET_PLAY_MODE = 'SET_PLAY_MODE'
export const SET_CURRENT_INDEX = 'SET_CURRENT_INDEX'
  • mutation中都是动作,前缀加SET、UPDATE等

       mutaion.js目录下:操作state

const mutations = {
       [types.SET_SINGER](state, singer){
                 state.singer = singer
       },
       [types.SET_PLAYING_STATE](state, flag){
                 state.playing = flag
       },
       [types.SET_FULL_SCREEN](state, flag){
                 state.fullScreen = flag
       },
       [types.SET_PLAYLIST](state, list){
                 state.playlist = list
        },
       [types.SET_SEQUENCE_LIST](state, list){
                 state.sequenceList = list
        },
       [types.SET_PLAY_MODE](state, mode){
                 state.mode = mode
        },
       [types.SET_CURRENT_INDEX](state, index){
                 state.currentIndex = index
       }
} 
二、播放器Vuex的相关应用

       components->player目录下:创建player.vue

  • 基础DOM:
    <div class="normal-player"></div>
  • 播放器
    <div class="mini-player"></div>

       App.vue中应用player组件:

  • 因为它不是任何一个路由相关组件,而是应用相关播放器,切换路由不会影响播放器的播放
    <player></player>

       player.vue中获取数据:控制播放器的显示隐藏

import {mapGetters} from 'vuex'

computed: {
     ...mapGetters([
         'fullScreen',
         'playlist'
    ])
}
  • 通过v-show判断播放列表有内容时,显示播放器,依据fullScreen控制显示不同的播放器
    <div class="player" v-show="playlist.length">
    <div class="normal-player" v-show="fullScreen">
  • 播放器
    </div>
        <div class="mini-player" v-show="!fullScreen"></div>
    </div>

       song-list.vue中添加点击播放事件:基础组件不写业务逻辑,只派发事件并传递相关数据

@click="selectItem(song, index)
selectItem(item, index){
     this.$emit('select', item, index)
}
  • 子组件行为,只依赖本身相关,不依赖外部调用组件的需求,传出的数据可以不都使用

       music-list.vue中监听select事件:

<song-list :songs="songs" @select="selectItem"></song-list>
  • 设置数据,提交mutations: 需要在一个动作中多次修改mutations,在actions.js中封装
    import * as types from './mutation-types'
    
    export const selectPlay = function ({commit, state}, {list, index}) {
           //commit方法提交mutation
           commit(types.SET_SEQUENCE_LIST, list)
           commit(types.SET_PLAYLIST, list)
           commit(types.SET_CURRENT_INDEX, index)
           commit(types.SET_FULL_SCREEN, true)
           commit(types.SET_PLAYING_STATE, true)
    }
  • music-list.vue中代理actions,并在methods中调用:
    import {mapActions} from 'vuex'
    
    selectItem(item, index){
          this.selectPlay({
                list: this.songs,
                index
          })
    }
    ...mapActions([
          'selectPlay'
    ])
三、播放器基器基础样式及歌曲数据的应用
  • 通过mapGetter获取到currentSong数据填入到DOM中
  • 点击切换播放器展开收起,需要修改 fullScreen:
    import {mapGetters, mapMutations} from 'vuex'
    
    methods: {
        back() {
            //错误做法: this.fullScreen = false
            //正确做法: 通过mapMutations写入 
            this.setFullScreen(false)
        },
        open() {
            this.setFullScreen(true)
       },
       ...mapMutations({
            setFullScreen: 'SET_FULL_SCREEN'
       })
    }
四、播放器展开收起动画

       需求:normal-player背景图片渐隐渐现,展开时头部标题从顶部下落,底部按钮从底部回弹,收起时相反

  • 实现:动画使用<transition>,回弹效果使用贝塞尔曲线
  1. normal-player设置动画<transition name="normal">
    .normal-enter-active, &.normal-leave-active
         transition: all 0.4s
         .top, .bottom
               transition: all 0.4s cubic-bezier(0.86, 0.18, 0.82, 1.32)
    &.normal-enter, &.normal-leave-to
         opacity: 0
         .top
               transform: translate3d(0, -100px, 0)
         .bottom
               transform: translate3d(0, 100px, 0)
  2. mini-player设置动画<transition name="mini">

    &.mini-enter-active, &.mini-leave-active
       transition: all 0.4s
    &.mini-enter, &.mini-leave-to
       opacity: 0

       需求:展开时,mini-player的专辑图片从原始位置飞入CD图片位置,同时有一个放大缩小效果, 对应顶部和底部的回弹;收起时,normal-player的CD图片从原始位置直接落入mini-player的专辑图片位置

  • 实现:Vue提供了javascript事件钩子,在相关的钩子中定义CSS3动画即可:
  • 利用第三方库:create-keyframe-animation 使用js编写CSS3动画
  1. 安装: 
    npm install create-keyframe-animation --save
    

    引入:

    import animations from 'create-keyframe-animation'
  2. 使用JavaScript事件钩子:

    <transition name="normal" @enter="enter" @after-enter="afterEnter" @leave="leave" @after-leave="afterLeave">
  3. methods中封装函数_getPosAndScale获取初始位置及缩放尺寸: (计算以中心点为准)

    _getPosAndScale(){ 
          const targetWidth = 40 //mini-player icon宽度
          const width = window.innerWidth * 0.8 //cd-wrapper宽度
          const paddingLeft = 40 
          const paddingTop = 80
          const paddingBottom = 30 //mini-player icon中心距底部位置
          const scale = targetWidth / width
          const x = -(window.innerWidth / 2 - paddingLeft) //X轴方向移动的距离
          const y = window.innerHeight - paddingTop - width / 2 - paddingBottom
          return {
                x,
                y, 
                scale
          }
    }
  4. 给cd-wrapper添加引用:
    <div class="cd-wrapper" ref="cdWrapper">
  5. 定义事件钩子方法:
    //事件钩子:创建CSS3动画
    enter(el, done){
           const {x, y, scale} = this._getPosAndScale()
    
           let animation = {
                   0: {
                      transform: `translate3d(${x}px, ${y}px, 0) scale(${scale})`
                   },
                   60: {
                      transform: `translate3d(0, 0, 0) scale(1.1)`
                   }, 
                   100: {
                      transform: `translate3d(0, 0, 0) scale(1)`
                   }
           }
    
           animations.registerAnimation({
                   name: 'move',
                   animation,
                   presets: {
                      duration: 400,
                      easing: 'linear'
                   }
           })
    
            animations.runAnimation(this.$refs.cdWrapper, 'move', done)
    },
    afterEnter() {
            animations.unregisterAnimation('move')
            this.$refs.cdWrapper.style.animation = ''
    },
    leave(el, done){
            this.$refs.cdWrapper.style.transition = 'all 0.4s'
            const {x, y, scale} = this._getPosAndScale()
            this.$refs.cdWrapper.style[transform] = `translate3d(${x}px, ${y}px, 0) scale(${scale})`
            this.$refs.cdWrapper.addEventListener('transitionend', done)
    },
    afterLeave(){
            this.$refs.cdWrapper.style.transition = ''
            this.$refs.cdWrapper.style[transform] = ''
    }
  6. transform属性使用prefix自动添加前缀:
    import {prefixStyle} from '@/common/js/dom'
    const transform = prefixStyle('transform')
五、播放器歌曲播放功能实现 -- H5 audio

       歌曲的播放

  • 添加H5 <audio>实现
    <audio :src="currentSong.url" ref="audio"></audio>
  • 在watch中监听currentSong的变化,播放歌曲
    watch: {
        currentSong() {
            this.$nextTick(() => { //确保DOM已存在
                this.$refs.audio.play()
            })
        }
    }
  • 给按钮添加点击事件,控制播放暂停
    <i class="icon-play" @click="togglePlaying"></i>
  1. 通过mapGetters获得playing播放状态
  2. 通过mapMutations定义setPlayingState方法修改mutation:
    setPlayingState: 'SET_PLAYING_STATE'
  3. 定义togglePlaying()修改mutation: 传递!playing为payload参数
    togglePlaying(){
          this.setPlayingState(!this.playing)
    }
  • 在watch中监听playing的变化,执行播放器的播放或暂停:
    playing(newPlaying){
           const audio = this.$refs.audio
           this.$nextTick(() => { //确保DOM已存在
                  newPlaying ? audio.play() : audio.pause()
           })
    }
  • 坑:调用audio标签的play()或pause(),都必须是在DOM audio已经存在的情况下,否则就会报错
  • 解决: 在this.$nextTick(() => { })中调用

       图标样式随播放暂停改变

  • 动态绑定class属性playIcon,替换掉原原来的icon-play:
    <i :class="playIcon" @click="togglePlaying"></i>
    playIcon() {
        return this.playing ? 'icon-pause' : 'icon-play'
    }

       CD 旋转动画效果

  • 动态绑定class属性cdCls:
    <div class="cd" :class="cdCls">
    cdCls() {
         return this.playing ? 'play' : 'pause'
    }
  • CSS样式:
    &.play
        animation: rotate 20s linear infinite
    &.pause
        animation-play-state: paused
    
    @keyframes rotate
       0%
          transform: rotate(0)
       100%
           ransform: rotate(360deg)
    View Code
六、播放器歌曲前进后退功能实现
  • 给按钮添加点击事件
    <i class="icon-prev" @click="prev"></i>
    <i class="icon-next" @click="next"></i>
  • 通过mapGetters获得currentIndex当前歌曲index
  • 通过mapMutations定义setCurrentIndex方法修改mutation:
    setCurrentIndex: 'SET_CURRENT_INDEX'
  • 定义prev()和next()修改mutation: 限制index边界
    next() {
        let index = this.currentIndex + 1
        if(index === this.playlist.length){
           index = 0
        }
        this.setCurrentIndex(index)
    },
    prev() {
        let index = this.currentIndex - 1
        if(index === -1){
           index = this.playlist.length - 1
        }
        this.setCurrentIndex(index)
    }
  • 坑:前进或后退后会自动开始播放,但播放按钮的样式没有改变
  • 解决:添加判断,如果当前是暂停状态, 切换为播放
    if(!this.playing){
        this.togglePlaying()
    }
  • 坑:切换太快会出现报错:Uncaught (in promise) DOMException: The play() request was interrupted by a new load request
  • 原因:切换太快 audio数据还没有加载好
  • 解决:audio有两个事件:
  1. 当歌曲地址请求到时,会派发canplay事件
  2. 当没有请求到或请求错误时,会派发error事件
    <audio :src="currentSong.url" ref="audio" @canplay="ready" @error="error"></audio>
  3. 在data中维护一个标志位数据songReady,通过ready方法控制只有歌曲数据请求好后,才可以播放
    data() {
        return {
           songReady: false
       }
    }
    
    ready() {
           this.songReady = true
    }
  4. 在prev()、next()和togglePlaying中添加判断,当歌曲数据还没有请求好的时候,不播放:
    if(!this.songReady){
       return
    }

    其中prev()和next()中歌曲发生改变了之后,重置songReady为false,便于下一次ready():

    this.songReady = false
  • 坑:当没有网络,或切换歌曲的url有问题时,songReady就一直为false,所有播放的逻辑就执行不了了
  • 解决: error()中也使songReady为true,这样既可以保证播放功能的正常使用,也可以保证快速点击时不报错
  • 优化: 给按钮添加disable的样式
    <div class="icon i-left" :class="disableCls">
    <div class="icon i-center" :class="disableCls">
    <div class="icon i-right" :class="disableCls">
    disableCls() {
         return this.songReady ? '' : 'disable'
    }
    &.disable
        color: $color-theme-d
七、播放器播放时间获取
  • data中维护currentTime当前播放时间:currentTime: 0 (audio的可读写属性
  • audio中监听时间更新事件:
    @timeupdate="updateTime"
  • methods中定义updateTime()获取当前时间的时间戳,并封装format函数格式化:
    //获取播放时间
    updateTime(e) {
         this.currentTime = e.target.currentTime //时间戳
    },
    format(interval){
         interval = interval | 0 //向下取整
         const minute = interval / 60 | 0
         const second = this._pad(interval % 60)
         return `${minute}:${second}`
    }
  • 坑:秒一开始显示个位只有一位数字,体验不好
  • 解决:定义_pad() 用0补位
    _pad(num, n = 2){  //用0补位,补2位字符串长度
       let len = num.toString().length
       while(len < n){
               num = '0' + num
               len++
      }
       return num
    }
  • 格式化后的数据填入DOM,显示当前播放时间和总时间:
    <span class="time time-l">{{format(currentTime)}}</span>
    <span class="time time-r">{{format(currentSong.duration)}}</span>
八、播放器progress-bar进度条组件实现
  • base->progress-bar目录下:创建progress-bar.vue

       需求:进度条和小球随着播放时间的变化而变化

  • 实现:从父组件接收props参数:进度比percent(player.vue中通过计算属性得到)
  • watch中监听percent,通过计算进度条总长度和偏移量,动态设置进度条的width和小球的transform
    const progressBtnWidth = 16 //通过样式设置得到
    
    props: {
        percent: {
             type: Number,
             default: 0
       }
    },
    watch: {
       percent(newPercent) {
           if(newPercent >= 0){
              const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
              const offsetWidth = newPercent * barWidth
              this.$refs.progress.style.width = `${offsetWidth}px` //进度条偏移
              this.$refs.progressBtn.style[transform] = `translate3d(${offsetWidth}px, 0, 0)` //小球偏移
          }
      }
    }

       需求:拖动进度条控制歌曲播放进度

  • 实现:监听touchstart、touchmove、touchend事件,阻止浏览器默认行为
    <div class="progress-btn-wrapper" ref="progressBtn"
           @touchstart.prevent="progressTouchStart"
           @touchmove.prevent="progressTouchMove"
           @touchend="progressTouchEnd">
  • created()中创建touch空对象,用于挂载共享数据;(在created中定义是因为touch不需要进行监听)
    created(){
         this.touch = {}
    }
  • methods中定义3个方法,通过计算拖动偏移量得到进度条总偏移量,并派发事件给父组件:
    progressTouchStart(e) {
         this.touch.initiated = true //标志位 表示初始化
         this.touch.startX = e.touches[0].pageX //当前拖动点X轴位置
         this.touch.left = this.$refs.progress.clientWidth //当前进度条位置
    },
    progressTouchMove(e) {
         if(!this.touch.initiated){
            return
         }
         const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
         const deltaX = e.touches[0].pageX - this.touch.startX //拖动偏移量
         const offsetWidth = Math.min(barWidth, Math.max(0, this.touch.left + 
    deltaX))
         this._offset(offsetWidth)
    },
    progressTouchEnd() {
         this.touch.initiated = false
         this._triggerPercent()
    },
    _triggerPercent(){
         const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
         const percent = this.$refs.progress.clientWidth / barWidth
         this.$emit('percentChange', percent)
    },
    _offset(offsetWidth){
         this.$refs.progress.style.width = `${offsetWidth}px` //进度条偏移
         this.$refs.progressBtn.style[transform] = `translate3d(${offsetWidth}px, 0, 0)` //小球偏移
    }
  • watch中添加条件设置拖动时,进度条不随歌曲当前进度而变化:
    watch: {
        percent(newPercent) {
            if(newPercent >= 0 && !this.touch.initiated){
               const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
               const offsetWidth = newPercent * barWidth
               this._offset(offsetWidth)
            }
        }
    }
  • player.vue组件中监听percentChange事件,将改变后的播放时间写入currentTime,并设置改变后自动播放:
    @percentChange="onProgressBarChange"
    onProgressBarChange(percent) {
        this.$refs.audio.currentTime = this.currentSong.duration * percent
        if(!this.playing){
            this.togglePlaying()
        }
    }

       需求:点击进度条任意位置,改变歌曲播放进度

  • 实现:添加点击事件,通过react.left计算得到偏移量,设置进度条偏移,并派发事件改变歌曲播放时间
    <div class="progress-bar" ref="progressBar" @click="progressClick">
    progressClick(e) {
         const rect = this.$refs.progressBar.getBoundingClientRect()
         const offsetWidth = e.pageX - rect.left
         this._offset(offsetWidth)
         this._triggerPercent()
    }
九、播放器progress-circle圆形进度条实现 -- SVG
  • base->progress-circle目录下:创建progress-circle.vue
  • progress-circle.vue中使用SVG实现圆:
    <div class="progress-circle">
           <!-- viewBox 视口位置 与半径、宽高相关 stroke-dasharray 描边虚线 周长2πr stroke-dashoffset 描边偏移 未描边部分-->
          <svg :width="radius" :height="radius" viewBox="0 0 100 100" version="1.1"
                xmlns="http://www.w3.org/2000/svg">
                <circle class="progress-backgroud" r="50" cx="50" cy="50" fill="transparent"/>
                <circle class="progress-bar" r="50" cx="50" cy="50" fill="transparent" 
                        :stroke-dasharray="dashArray" :stroke-dashoffset="dashOffset"/>
          </svg>
          <slot></slot>
    </div>
  • progress-circle.vue需要从父组件接收props参数:视口半径、当前歌曲进度百分比
    props: {
        radius: {
           type: Number,
           default: 100
        },
        percent: {
           type: Number,
           default: 0
        }
    }
  • player.vue中使圆形进度条包裹mini-player的播放按钮,并传入半径和百分比:

    <progress-circle :radius="radius" :percent="percent"><!-- radius: 32 -->
          <i :class="miniIcon" @click.stop.prevent="togglePlaying" class="icon-mini"></i>
    </progress-circle>
  • progress-circle.vue中维护数据dashArray,并使用computed计算出当前进度对应的偏移量:
    data() {
        return {
           dashArray: Math.PI * 100 //圆周长 描边总长
        }
    },
    computed: {
        dashOffset() { 
           return (1 - this.percent) * this.dashArray //描边偏移量
        }
    }
十、播放器模式切换功能实现

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

  • 动态绑定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() —— lyric-parser的API
    //获取解析后的歌词
    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' //默认显示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() //歌词切换播放暂停 -- lyric-parser的API
    }

       坑:单曲循环播放模式下,歌曲播放完毕后,歌词并没有返回到一开始

  • 解决:在loop()中判断如果存在currentLyric,就调用currentLyric的seek()将歌词偏移到最开始
    if(this.currentLyric){
       this.currentLyric.seek(0)  //歌词偏移到一开始 -- lyric-parser的API
    }

       坑:拖动进度条改变歌曲播放进度后,歌词没有随之改变到对应位置

  • 解决:在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处可以将其中的代码添加到组件中

     【vue中提供了一种混合机制--mixins,用来更高效的实现组件内容的复用】

  • 组件在引用之后相当于在父组件内开辟了一块单独的空间,来根据父组件props过来的值进行相应的操作,但本质上两者还是泾渭分明,相对独立。
  • 而mixins则是在引入组件之后,则是将组件内部的内容如data等方法、method等属性与父组件相应内容进行合并。相当于在引入后,父组件的各种属性方法都被扩充了。
  • 单纯组件引用: 父组件 + 子组件 >>> 父组件 + 子组件
  • 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重新计算
    }

注:项目来自慕课网,本文整理只作学习

猜你喜欢

转载自www.cnblogs.com/ljq66/p/10095776.html