1. 程式人生 > >【音樂App】—— Vue2.0開發移動端音樂WebApp專案爬坑(三)

【音樂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'
          }