vue移动端音乐----播放器组件的开发

多个组件都可以打开播放器,所以我们要控制这个播放器的数据,一定是全局的,所以我们把数据通过vuex来管理 

1.  Vuex数据设计,很多歌单都可以打开播放器,打开歌单缩小时,会出页面最下方出现小型的播放器,所以播放器的数据一定是全局的,所以把这个数据通过vuex来管理
在state.js中添加播放器的状态,并在common->config.js中添加播放器的播放模式(顺序,随机,循环)

首先,在state.js中添加与播放器相关的属性:

import {playMode} from 'common/js/config'
const state = {
    singer: {},
    playing: false, // 播放状态
    fullScreen: false, // 全屏
    playlist: [], // 正在播放的播放器列表,播放器后退会出现播放列表
    sequenceList: [], // 顺序播放时的播放器列表
    mode: playMode.sequence, // 顺序播放
    currentIndex: -1 // 当前播放的歌曲的索引,前进后退播放修改currentIndex
}

export default state

其中mode的类型放在配置文件中,js->config.js

export const playMode = {
  sequence: 0,
  loop: 1,
  random: 2
}

2. 添加完state中的初始状态,可以由初始状态计算而来的数据放在getters.js中,getters是对状态的一种映射

export const singer = state => state.singer // 使用getter取到组件里的数据

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] || {}
}

3. 操作state要写在mutations.js中,但是要先写mutation-types.js,做字符串映射

// 定义mutation的常量
export const SET_SINGER = 'SET_SINGER'

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-types.js去写mutation.js修改state数据

import * as types from './mutation-types'

const mutations = { // mutation相关的修改方法
    [types.SET_SINGER](state, singer) { // 当前状态树的state,提交mutation时传的payload
        state.singer = singer
    },
    [types.SET_PLAYING_STATE](state, flag) { // boolean用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
    }
}

export default mutations

 4. actions主要有两种操作,一个是异步操作,一个是mutation的一个封装,即我们可以通过一个action修改多个mutations

5.新建player.vue组件,分为nomal-player和mini-player,播放器组件放在哪里??? 

新建player.vue组件,创建初始架构,并将这个组件放在app.vue下,因为他不是任何一个路由相关的东西,只是一个应用相关的播放器,在任何路由下都可以去播放,切换路由也不会影响播放器的播放,所以将它定义到app.vue下,在app.vue中注册并添E:\my-vue\my-music-vue\src\common\js\dom.js加,

别忘了import和component组件

刷新之后播放器默认展开,盖住了原来的界面,所以我们要在app.vue中控制一下播放器的隐藏,回到player组件,用vuex的相关数据控制播放器器的展示;首先要从Vuex取一些数据到组件里面,在player.vue中去import我们想要的数据,控制播放器的出现,默认不出现,在play.vue中;

import {mapGetters} from 'vuex'
export default {
    computed: {
        ...mapGetters([
            'fullScreen',
            'playlist'
        ])
    }
}

整个播放器的显示,要求playlist.length > 0, normal的显示要求fullScreen(全屏显示),mini-player为!fullScreen

 
 
 
    

这样播放器就不会默认展示了,我们希望在点击歌曲的时候才会弹出播放器,回到song-list.vue添加事件

  • {{song.name}}

    {{getDesc(song)}}

然后去methods中实现点击参数selectitem     

        selectItem(item, index) { // 只派发一个函数,告知父组件music-list.vue我已被点击
            this.$emit('select', item, index)
        }

然后回到父组件music-list.vue添加监听事件,

      

在methods中定义selectItem,接受item和index两个参数,当我们点击列表的时候我们要设置playList或者是sequencdList,其次根据我们点击的索引,我们可以设置currentIndex;点击的时候歌曲就要播放了,playstate也是要设置的,我们要默认展开全屏的播放器,fullScreen也是要设置的

所以,在selectItem中设置有关播放器的数据就需要提交mutations,如果在一个动作中多次去改变mutation,会封装一个action,则回到actions.js,定义一个动作,selectPlay选择播放

import * as types from './mutation-types'

// commit方法和state属性,可以拿到state,第二个参数是一个payload,告知列表和索引
export const selectPlay = function ({commit, state}, {list, index}) {
    // 提交mutations,设置list,默认是顺序播放
    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,并在监听函数selectItem中调用修改播放器状态

import {mapActions} from 'vuex'
 ...mapActions([
        'selectPlay'
      ])

在music-list.vue中继续编写监听函数selectItem;

    selectItem(item, index) { // 在song-list.vue中点击歌曲传过来的song和item
        this.selectPlay({ // 完成action的调用
            list: this.songs, // 整个songs都要传进去
            index
        })

action调用以后,action就要逻辑执行,mutation就会改变,然后通过player.vue中的mapGetter映射到player.vue(播放器)中,再传递到DOM中,这样就完成了对播放器播放状态的改变

接着 ,填充play组件的DOM区域,先写样式,再填充数据,先通过mapGetter把currentSong映射到组件里面

 computed: {
        ...mapGetters([
            'fullScreen',
            'playlist',
            'currentSong',
            ])
          }

然后将currentSong相关的数据填充到DOM结构中,如下:

 
    

然后在看min-player部分,因为min-player是需要把fullScreen设为false的,也就是我们点击左上角缩小按钮的时候,进行缩小

这里我们不能直接将fullScreen设置为false,应该通过mutation进行改变,首先引入mapMutations,

import {mapGetters, mapMutations} from 'vuex'

在methods中定义...mapMutations

        ...mapMutations({
           setFullScreen: 'SET_FULL_SCREEN' // 对应到mutation-types
        })

在mapMutations中取到SET_FULL_SCREEN的映射之后,在回到back中调用setFullScreen

        back() {
            // 此处通过mutation改变fullScreen,引入mapMutations,通过...mapMutations取得映射
            this.setFullScreen(false)

        }

点击小型播放器的时候可以打开全屏

open() {
            this.setFullScreen(true)
        }

播放器展开收起动画的添加,用transition去包裹这两个区块

 
 &.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)

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

当大的播放器缩小时,我们希望中间的图片能有一个从中间往左下角飞入的一个样式,利用vue给我提供的一个js的一个钩子,在钩子里创建一个csss3的一个animition,首先给nomal添加几个事件

 
    

对应钩子函数实现响应的动画效果即可,在methods中定义钩子函数

import animations from 'create-keyframe-animation'

引入了第三方库,我们想到首先我们要获取图片的位置,所以定义一个函数计算位置,是中心点到中心点的偏移

定义函数计算大小图片之间的缩放比例和x,y之间的偏移量

 _getPosAndScale() {
            const targetWidth = 40 // min播放器的宽度
            const paddingLeft = 40 // min图片的圆心距离左侧有40的偏移
            const paddingBottom = 30  // min图片的圆心到底部偏移为30px
            const paddingTop = 80 // 大图片到屏幕顶部的高度
            const width = window.innerWidth * 0.8 // cd-wrapper的宽度
            const scale = targetWidth / width // 初始的缩放比例
            const x = -(window.innerWidth / 2 - paddingLeft) // 初始的x,y坐标的偏移量
            const y = window.innerHeight - paddingTop - width / 2 - paddingBottom
            return {
                x,
                y,
                scale
            }

        }

定义动画:

        enter(el, done) { // DOM, 动作,done执行完机会跳入下一个
            // 首先要获取图片在animal播放器的具体位置
            const {x, y, scale} = this._getPosAndScale()
            // 定义animation
            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)`
                }
            }
            // 注册animation
            animations.registerAnimation({
                name: 'move', // 动画名称
                animation,
                presets: { // 预设字段
                    duration: 400,
                    easing: 'linear'
                }
            })
            // 运行animation,绑定dom
            animations.runAnimation(this.$refs.cdWrapper, 'move', done)
        },
        afterEnter() { // 清空animation
            animations.unregisterAnimation('move')
            this.$refs.cdWrapper.style.animation = ''
        },
        leave(el, done) { // leave执行完之后就会跳入afterLeave
            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})`
            // 监听事件transitionend,执行完之后就执行afterLeave
            this.$refs.cdWrapper.addEventListener('transitionend', done)
        }

歌曲的播放功能,主要是利用html5的audio标签实现的,指定url和play方法


    
  

我们在currentSong发生改变的时候去调用play函数,在watch中观测currentSong的变化,必须要保证url获取到之后去调用play函数。所以我们可以加一个延时

watch:{
    currentSong() {
        this.$nextTick.(() => {
            this.$refs.audio.play()
        })
    }
}

现在音乐可以播放了,但是我们无法控制音乐,现在我们来控制音乐的暂停

           

添加点击事件控制音乐的暂停,我们在state中定义了playing来控制音乐的播放暂停,点击歌曲列表的时候回提交一个action,在action中有一个mutation,提交了一个commit(types.SET_PLAYING_STATE, true);所以在我们点击歌曲列表的时候playing的状态就为true了,在play.vue中,我们可以通过maapGetter获取当前player的状态

      computed: {
        ...mapGetters([
            'fullScreen',
            'playlist',
            'currentSong',
            'playing', // 获取播放的状态
            
        ]),

这里的playing就映射到getters.js中的export const playing = state => state.playing,所以我们在组件中就可以通过this.playing来获取当前的playing的状态,并通过一个mutation来改变我们当前的状态,再通过mapMutation中设置一个setPlayingState: 'SET_PLAYING_STATE',映射到mutation-type中的'SET_PLAYING_STATE'状态

        ...mapMutations({
           setFullScreen: 'SET_FULL_SCREEN', // 对应到mutation-types
           setPlayingState: 'SET_PLAYING_STATE',
        })
    },

然后就可以通过调用this.setPlayingState方法,去切换playing的状态

        togglePlaying() {
            this.setPlayingState(!this.playing)
        }

仅仅设置paying是不能设置我们播放器的停止的,因为真正控制音乐播放的还是播放器,所以说我们还要去watch playing的状态,通过playing状态的改变来控制播放器的paly或是pause,不要忘了添加nextTick

        playing(newPlaying) {
            this.$nextTick(() => {
                const audio = this.$refs.audio
                newPlaying ? audio.play() : audio.pause() 
            })
        }
    },

我们是实现了点击之后播放暂停音乐的功能,但是暂停键和播放键的样式并没有改变

 playIcon() {
            return this.playing ? 'icon-pause' : 'icon-play' 
        }

对应到DOM中的播放按钮

      

并将min-play中的图标也更新,别忘了添加点击事件togglePlaying

miniIcon() {
            return this.playing ? 'icon-pause-mini' : 'icon-play-mini'
        },
  

点击小窗口暂停的时候nomal播放器也弹出来了,这是因为子元素的点击事件会冒泡到父元素上,父元素的点击事件打开了播放器,所以要添加@click.stop = "togglePlaying"

接下来实现cd的旋转

        img
          border-radius: 50%
          &.play
            animation: rotate 10s linear infinite
          &.pause
            animation-play-state: paused

之后在大小播放器的图片上添加class

  
      cdCls() {
            return this.playing ? 'play' : 'play_pause'
        },

接下来实现前进后退功能

我们在vuex中有一个state是currentIndex:-1;表示当前播放歌曲的索引,当我们点击歌曲列表的时候,派发一个action,action里面有一个mutation的提交,修改了currentIndex(commit(types.SET_CURRENT_INDEX, index)),curentIndex触发了currentSong的变化,currentSong的audio的src发生变化,然后watch中就观察到currentSong的变化,然后去调用play()进行播放,就改变了歌曲,我们只需要给icon-pre和icon-next绑定两个事件,改变currentIndex的值就可以实现歌曲的前进后退了

   
  

首先在mapGetter中拿到currentIndex的值

   computed: {
        ...mapGetters([
            'fullScreen',
            'playlist',
            'currentSong',
            'playing', // 获取播放的状态
            'currentIndex'
        ]),

在next函数中改变currentIndex的状态

       next () {
            let index = this.currentIndex + 1
            if(index === this.playlist.length) { // 最后一首歌的情况
                index = 0
            }
        }

改变了状态之后,要通过一个mapMutaions定义的setCurrentIndex将值的变化传递给state

        ...mapMutations({
           setFullScreen: 'SET_FULL_SCREEN', // 对应到mutation-types
           setPlayingState: 'SET_PLAYING_STATE',
           setCurrentIndex: 'SET_CURRENT_INDEX'// 相当于提交一个mutatation去改变我们的currentINdex
        })

之后,回到index函数中调用setCurrentIndex函数,将修改后的index传递给state

        next () {
           
            let index = this.currentIndex + 1
            if(index === this.playlist.length) { // 最后一首歌的情况
                index = 0
            }
            this.setCurrentIndex(index) // 提交mutation,修改index
        },

prev的逻辑与之类似

        prev() {
           
            let index = this.currentIndex - 1
            if(index === -1) {
                index = this.playlist.length 
            }
            this.setCurrentIndex(index)
            
        },

点击暂停之后,切换到下一首歌,歌曲播放了,但是按钮图标却没有发生变化,在prev和next中添加

 if (!this.playing) { // 暂停的时候通过toggleplaing是改变playing的状态,让其变成播放状态
                this.togglePlaying()
            }

当快速切换下一首的时候,会出现之前DOMException的情况,其实audio中有两个事件,一个是canplay事件,歌曲从加载到播放会派发一个事件叫canplay,当歌曲发生错误请求不到地址的时候会派发一个函数@error;

    

之后当歌曲ready的时候,才能点下一首歌,不然就不能点下一首歌,我们可以使用一个标志位来控制,首先我们在data中添加一个变量叫songReady

   data() {
        return {
            songReady: false
        }
    }

然后添加一个ready函数,使其songReady为true时才可出发canPlay

ready() {
            this.songReady = true
        }

写好songReady之后,回到next和prev函数中控制歌曲的播放

       next () {
            if (!this.songReady) {
                return
            }
            let index = this.currentIndex + 1
            if(index === this.playlist.length) { // 最后一首歌的情况
                index = 0
            }
            this.setCurrentIndex(index) // 修改index
            if (!this.playing) { // 暂停的时候通过toggleplaing是改变playing的状态,让其变成播放状态
                this.togglePlaying()
            }
            this.songReady = false // 重置,下一首还没准备好呢
        },
        prev() {
            if (!this.songReady) {
                return
            }
            let index = this.currentIndex - 1
            if(index === -1) {
                index = this.playlist.length 
            }
            this.setCurrentIndex(index)
             if (!this.playing) { // 暂停的时候通过toggleplaing是改变playing的状态,让其变成播放状态
                this.togglePlaying()
            }
            this.songReady = false // 重置,下一首还没准备好呢
        },

顺便定义一下error,当url失败时执行一下songReady逻辑,以免之后的点击播放不能使用

 error() {
            // 当歌曲加载失败时出发error函数
             this.songReady = true
        },

当按钮不能点击的时候,我们给按钮一个disable的属性,让其

disableCls() {
            return this.songReady ? '' : 'disable'
        }

并将disable属性用到三个icon按钮上

            

播放器的进度:左侧为歌曲播放的时间,右侧为总时间,中间是进度条

 
{{format(currentTime)}}
进度条组件
{{format(currentSong.duration)}}

现在data中定义表示当前时间的变量

 data() {
        return {
            songReady: false,
            currentTime: 0
        }
    },

当歌曲在播放的时候audio标签会派发一个事件,timeupdate

在methods中定义这个updateTime

 updateTime(e) { // target就是audio标签,有currentTime属性
            this.currentTime = e.target.currentTime
        }
        

我们不可以直接将currentTime直接写到DOM中,他是一个时间戳,不能直接写到界面上

 format(interval) {
            interval = interval | 0 // 一个正数的向下取整
            const minute = interval / 60 | 0
            const second = this._pad(interval % 60)
            return `${minute}:${second}`
        }

其中this._pad是对秒数进行补零

        _pad(num, n = 2) { // 设计补位,补0,默认是两位数
            let len = num.toString().length
            while(len < n) {
                num = '0' + num
                len++
            }
            return num
        }

回到DOM中调用这个函数,当前时间

{{format(currentTime)}}

总时间

 {{format(currentSong.duration)}}

接下来实现进度条组件和圆形进度条组件,下面在base下编写进度条的基础组件,progress-bar.vue

将组件引入到player组件中,并填充到DOM里

添加css样式后基本样式有了,接下来就是实现进度条位置的移动,进度中应该从父类player中接受一个百分比的属性,所以在progress-bar中,添加props

props: {
      percent: {
        type: Number,
        default: 0
      }
    }

然后去watch,percent是从外部组件传递过来的一个不断改变的值

 const progressBtnWidth = 16 // 原点按钮的长度
watch: {
      percent(newPercent) {
        if (newPercent >= 0 && !this.touch.initiated) { // 没有在拖动的时候采取改变percent
         // 整个进度条的宽度,要减去按钮的宽度,通过常量定义
          const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
         // 偏移的宽度
          const offsetWidth = newPercent * barWidth
         //  设置progress的宽度,即进度条走过的长度
          this._offset(offsetWidth)
        }
      }
    }

定义offset函数,实现进度的移动和btn的偏移

import {prefixStyle} from 'common/js/dom' 
const transform = prefixStyle('transform')
_offset(offsetWidth) {
        // 已经走过的进度条的长度
        this.$refs.progress.style.width = `${offsetWidth}px`
        // 小球按钮的长度
        this.$refs.progressBtn.style[transform] = `translate3d(${offsetWidth}px,0,0)`
      }

回到父组件player中计算persent的值,计算属性

        percent() {
            return this.currentTime / this.currentSong.duration
        },

然后将percent传递给子组件

 

接下来实现进度条的拖拽和点击,首先在btn上添加几个点击事件,不要忘了添加prevent去阻止浏览器的默认行为

      

我们需要一个实力上的touch target对象来维护不同点击事件中的通信

created() {
      this.touch = {} // 在不同的回调函数中将共享数据挂载到touch对象上
    }

去methods中定义这三个事件函数

      progressTouchStart(e) {
        this.touch.initiated = true // 已经被初始化了
        this.touch.startX = e.touches[0].pageX // 横向坐标
        this.touch.left = this.$refs.progress.clientWidth // 进度条的初始偏移量,进度条已经走过的长度
      },
      progressTouchMove(e) {
        if (!this.touch.initiated) {
          return
        }
        const deltaX = e.touches[0].pageX - this.touch.startX // 计算偏移量
        // this.touch.left + deltaX为相对于整个界面的偏移,但是不能超出整个进度条的宽度                   // 已经偏移的量加上deltaX,值要大于0
        const offsetWidth = Math.min(this.$refs.progressBar.clientWidth - progressBtnWidth, Math.max(0, this.touch.left + deltaX))
        // 设置进度条的偏移和btn的transform
        this._offset(offsetWidth)
      },
      progressTouchEnd() { // 重置为false
        this.touch.initiated = false
        this._triggerPercent()  // 派发事件,将拖动进度条信息派发出去
      }

至此,进度条已经可以拖动了,进度条滚动过的长度和btn都会随着进度条的拖动儿移动,但是还没有,但是进度条被拖动之后会跳回到正在播放歌曲所在进度条的位置,那是因为只要歌曲在播放,percent就在变化,她控制着进度条的位置,在watch中又修改了它的宽度

我们希望在拖动的时候不受到percent更新的困扰,在watch percent的时候加入判断条件: !this.touch.initiated,保证watch到的percent的变化实在进度条没有被拖动的前提下

 watch: {
      percent(newPercent) {
        if (newPercent >= 0 && !this.touch.initiated) { // 没有在拖动的时候采取改变percent
         // 整个进度条的宽度,要减去按钮的宽度,通过常量定义
          const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
         // 偏移的宽度
          const offsetWidth = newPercent * barWidth
         //  设置progress的宽度,即进度条走过的长度
          this._offset(offsetWidth)
        }
      }
    }

等我们拖动完进度条之后,touch.initated被设置为false,percent的值又可以被watch到了,解决办法就是在end的时候将拖动的进度告诉给外层播放器,让歌曲播放的进度(peercent)与进度条位置相同,所以在end的时候派发一个函数

 progressTouchEnd() { // 重置为false
        this.touch.initiated = false
        this._triggerPercent()  // 派发事件,将拖动进度条信息派发出去
      },

在touchEnd中调用triggerPercent,使其派发一个监听函数,将percent的变化派发出去

 _triggerPercent() {
         // 进度条的长度
        const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
        // 运动的percent
        const percent = this.$refs.progress.clientWidth / barWidth
        this.$emit('percentChange', percent)
      }

然后回到player组件中监听percentChange

   

操作audio对象的currentTime才能更新进度条的进度,currentTime是进度条自带的属性

        onProgressBarChange(percent) {
            // 设置audio的currentTime,当前歌曲的总时间乘以percent
            const currentTime = this.currentSong.duration * percent
            this.$refs.audio.currentTime = currentTime
            if (!this.playing) { // 若是拖动进度条之后暂停了,就变为继续播放
                this.togglePlaying()
            }
        }

先暂停,然后拖动到某个位置,他不会自动播放

 if (!this.playing) { // 若是拖动进度条之后暂停了,就变为继续播放
                this.togglePlaying()
            }

实现了进度条的拖动之后,我们为进度条添加点击事件,在progress-bar中