Fork me on GitHub
余鸢

Vue音乐播放器开发(六):播放器的实现(一)

播放器内置组件开发

播放器可以通过歌手详情列表、歌单详情列表、排行榜列表以及搜索结果打开,换言之,多个组件都可以操作这个播放器。

播放器Vuex数据设计

打开播放器时点击缩小播放器仍然可以在后台播放运行,也就是说全局性的控制播放器数据,所以要通过vuex管理 。首先思考播放器需要哪些相关数据。

列出播放器相关的数据:

playing:播放状态

fullScreen:展开或收起

playlist:播放列表

sequenceList:顺序列表

mode:播放模式

currentIndex:当前播放索引

state.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import {playMode} from 'common/js/config'
const state = {
singer: {},
playing: false,
fullScreen: false,
playlist: [],
sequenceList: [],
mode: playMode.sequence,
currentIndex: -1
}
export default state

这几个数据的其他配置如mutations、getters在此省略,具体参考代码。

播放器组件开发

播放器player.vue 基础样式

1
2
3
4
5
6
7
8
9
<template>
<div class="player">
<div class="normal-player">播放器</div>
<div class="mini-player"></div>
</div>
</template>
<script type="text/ecmascript-6">
</script>

播放器是全局组件,放在App.vue下面,通过Vuex传递数据,触发action提交mutation,从而使播放器开始工作。

App.vue

1
2
3
4
5
6
7
8
9
10
<template>
<div id="app">
<m-header></m-header>
<tab></tab>
<keep-alive>
<router-view></router-view>
</keep-alive>
<player></player>
</div>
</template>

用vuex相关数据控制播放器的显示和隐藏,传入fullScreen控制显示或隐藏,playlist控制播放器渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div class="player" v-show="playlist.length>0">
<div class="normal-player" v-show="fullScreen">播放器</div>
<div class="mini-player" v-show="fullScreen"></div>
</div>
</template>
<script type="text/ecmascript-6">
import {mapGetters} from 'vuex'
export default {
computed: {
...mapGetters([
'fullScreen',
'playlist'
])
}
}
</script>

控制播放器的展示

点击歌曲列表时展开播放器,也就是点击歌曲列表song-list组件,给song-list组件添加事件selectItem(item, index)

v40

而song-list又被music-list组件使用,在music-list组件触发select事件@select="selectItem"

music-list.vue

1
2
3
<div class="song-list-wrapper">
<song-list @select="selectItem" :songs="songs"></song-list>
</div>

selectItem要做三件事:

1、点击歌曲时要播放整个歌曲列表,设置playlistsequenceList

2、根据点击的歌曲索引,设置currentIndex,点击时实际上歌曲要播放了,设置播放状态playing

3、默认展开全屏播放器,设置fullScreen

设置这些数据实际就是提交mutations。在一个动作中多次改变mutation那么会封装一个action。在actions.js里定义selectPlay

action.js

1
2
3
4
5
6
7
8
9
import * as types from './mutation-types'
export const selectPlay = function ({commit, state}, {list, index}) {
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)
}

selectPlay对一系列mutation做封装,提交mutation。

在music-list组件里调用actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {mapActions} from 'vuex'
export default{
methods: {
selectItem (item, index) {
this.selectPlay({
list: this.songs,
index
})
},
...mapActions([
'selectPlay'
])
}
}

action逻辑执行mutation就会改变,mutation改变就会映射到mapGetters,也就会得到fullScreenplaylist的改变。

v46

通过定义的vuex以及一些事件点击操作去修改的vuex数据,这些操作行为成功实现了player组件的显示。

传入currentSong填充歌曲基本数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<template>
<div class="player" v-show="playlist.length>0">
<div class="normal-player" v-show="fullScreen">
<div class="background">
<img width="100%" height="100%" :src="currentSong.image">
</div>
...
<h1 class="title" v-html="currentSong.name"></h1>
<h2 class="subtitle" v-html="currentSong.singer"></h2>
</div>
<div class="middle">
<div class="middle-l">
<div class="cd-wrapper">
<div class="cd">
<img class="image" :src="currentSong.image">
</div>
</div>
</div>
</div>
<div class="bottom">...</div>
</div>
<div class="mini-player" v-show="!fullScreen">
<div class="icon">
<img width="40" height="40" :src="currentSong.image">
</div>
<div class="text">
<h2 class="name" v-html="currentSong.name"></h2>
<p class="desc" v-html="currentSong.singer"></p>
</div>
...
</div>
</div>
</template>
<script type="text/ecmascript-6">
import {mapGetters} from 'vuex'
export default {
computed: {
...mapGetters([
'fullScreen',
'playlist',
'currentSong'
])
}
}
</script>

效果展示:

v41

mini播放器的展示

miniPlayer是需要把fullScreen设置为false,因此点击返回按钮时把fullScreen设置为false,添加个click事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div class="normal-player" v-show="fullScreen">
...
<div class="top">
<div class="back" @click="back">
<i class="icon-back"></i>
</div>
<h1 class="title" v-html="currentSong.name"></h1>
<h2 class="subtitle" v-html="currentSong.singer"></h2>
</div>
</div>
export default {
methods: {
back () {
this.setFullScreen(false)
},
...mapMutations({
setFullScreen: 'SET_FULL_SCREEN'
})
}
}

通过mutation改变fullScreen。

效果展示:

v42

点击mini播放器打开全屏,同理在mini播放器添加个click事件

1
2
3
4
5
6
7
<div class="mini-player" v-show="!fullScreen" @click="open">
...
</div>
open () {
this.setFullScreen(true)
}

播放器展开收起动画

最大化和最小化切换

播放器最大化和最小化切换时没有交互动画显得比较生硬,为了体验更好使用<transition></transition>自定义标签设置交互动画。

v43

使用<transition></transition>包裹要实现的区块。然后根据name编写css样式

v44

点击放大或缩小时的交互动画

当展开收起播放器时,mini播放器的图片放大或缩小有渐变的效果,利用vuejs提供JavaScript钩子,在钩子里创建css3的animation。给name为normal的添加@enter@afterEnter@leave@afterLeave这几个事件。

v45

enter方法有两个参数,第一个参数el是要做动画的dom,第二个参数done是回调函数,done执行时就会跳到下一个钩子afterEnterleaveenter一样有两个参数,done执行时跳到下个钩子afterLeave

使用css3写动画首先要知道几个位置:从运动的起始点和终点的区域、横坐标和纵坐标以及scale的大小。但这些是动态获取的不能预先知道,所以通过js的方式创建css3动画。这里使用create-keyframe-animation第三方库实现通过js编程方式创建css3动画。

在这之前先封装个函数来获取初始位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取初始位置
_getPosAndScale() {
const targetWidth = 40
const paddingLeft = 40
const paddingBottom = 30
const paddingTop = 80
// cd-wrapper的宽度
const width = window.innerWidth * 0.8
// 初始缩放比例
const scale = targetWidth / width
const x = -(window.innerWidth / 2 - paddingLeft)
const y = window.innerHeight - paddingTop - width / 2 - paddingBottom
return {
x,
y,
scale
}
},

点击目标创建动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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)
},

动画执行完后调用done函数,done函数执行后跳到afterEnter,afterEnter做的事情就是结束动画,animation设置为空

1
2
3
4
afterEnter () {
animations.unregisterAnimation('move')
this.$refs.cdWrapper.style.animation = ''
},

leave动画

1
2
3
4
5
6
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)
},

leave动画执行完后调用done,done执行完后跳到afterLeave,afterLeave将动画设置为空

1
2
3
4
afterLeave() {
this.$refs.cdWrapper.style.transition = ''
this.$refs.cdWrapper.style[transform] = ''
},

播放器歌曲播放功能实现

播放功能实际上是使用HTML5的audio标签实现的,src属性指向的是播放音乐地址。

player.vue里添加audio标签

1
<audio ref="audio" :src="currentSong.url"></audio>

仅仅通过指定播放地址是不能播放,还需要调用audioplay方法,在currentSong发生改变时调用play方法,这样要watch currentSong的变化

1
2
3
4
5
watch: {
currentSong () {
this.$refs.audio.play()
}
}

启动效果会报错:

1
Uncaught (in promise) DOMException: The play() request was interrupted by a new load request.

这是dom异常,在调用play方法时同时去请求src,但dom还没被读取就调用play,所以报错了。在这加个延迟$nextTick()

1
2
3
4
5
6
7
watch: {
currentSong () {
this.$nextTick(() => {
this.$refs.audio.play()
})
}
}

报错消失,歌曲正常播放。

音乐暂停功能实现

vuex里有定义一个状态叫playing,是控制当前播放歌曲是播放还是暂停状态。点击歌曲列表时会提交一个action,在action里提交SET_PLAYING_STATE的mutation设置为true,所以当点击歌曲列表时playing状态为true。通过mapGetters里的playing获取当前状态,然后通过mutation改变playing的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
computed: {
...mapGetters([
'fullScreen',
'playlist',
'currentSong',
'playing'
])
},
methods: {
...mapMutations({
setFullScreen: 'SET_FULL_SCREEN',
setPlayingState: 'SET_PLAYING_STATE'
})
}

调用setPlayingState方法改变playing状态

1
2
3
4
5
6
7
8
9
<div class="bottom">
<div class="icon i-center">
<i @click="togglePlaying" class="icon-play"></i>
</div>
</div>
togglePlaying () {
this.setPlayingState(!this.playing)
},

仅仅设置playing不能让播放器停止,真正控制播放的还是播放器,所以watch playing状态

1
2
3
4
5
6
playing (newPlaying) {
const audio = this.$refs.audio
this.$nextTick(() => {
newPlaying ? audio.play() : audio.pause()
})
}

动态改变播放按钮样式

当点击歌曲播放时显示播放按钮,点击暂停时显示暂停按钮。

1
2
3
4
5
6
7
8
9
10
11
<div class="bottom">
<div class="icon i-center">
<i @click="togglePlaying" :class="playIcon"></i>
</div>
</div>
computed: {
playIcon () {
return this.playing ? 'icon-pause' : 'icon-play'
},
}

同理,mini播放器也是

1
2
3
4
5
6
7
8
9
10
11
<div class="mini-player" v-show="!fullScreen" @click="open">
<div class="control">
<i @click="togglePlaying" :class="miniIcon"></i>
</div>
</div>
computed: {
miniIcon () {
return this.playing ? 'icon-pause-mini' : 'icon-play-mini'
}
}

点击mini播放器播放按钮发现播放器又弹了出来,是因为子元素点击事件会冒泡到父元素上,父元素也有个点击事件去打开播放器,为了防止事件向上冒泡修改为@click.stop='togglePlaying'

1
2
3
4
5
<div class="mini-player" v-show="!fullScreen" @click="open">
<div class="control">
<i @click="togglePlaying" :class="miniIcon"></i>
</div>
</div>

cd图片旋转

歌曲播放时歌曲图片跟着旋转,暂停时图片不动

1
2
3
4
5
6
7
8
9
10
11
<div class="cd-wrapper" ref="cdWrapper">
<div class="cd" :class="cdCls">
<img class="image" :src="currentSong.image">
</div>
</div>
computed: {
cdCls () {
return this.playing ? 'play' : 'play pause'
}
}

同理,mini播放器也是

1
2
3
4
5
<div class="mini-player" v-show="!fullScreen" @click="open">
<div class="icon">
<img :class="cdCls" width="40" height="40" :src="currentSong.image">
</div>
</div>

歌曲前进后退功能实现

前进后退功能其实就是改变当前播放歌曲的索引。

vuex有个状态是currentIndex,表示当前播放歌曲的索引,当点击歌曲列表时触发action,action有个对SET_CURRENT_INDEX的mutation的提交,修改currentIndex播放到第几位,这也代表了当前播放的歌曲。通过mapGetters里的currentIndex获取当前播放歌曲,然后通过mutation改变currentIndex

1
2
3
4
5
6
7
...mapGetters([
'fullScreen',
'playlist',
'currentSong',
'playing',
'currentIndex'
])

添加@prev@next方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div class="icon i-left">
<i @click="prev" class="icon-prev"></i>
</div>
<div class="icon i-center">
<i @click="togglePlaying" :class="playIcon"></i>
</div>
<div class="icon i-right">
<i @click="next" class="icon-next"></i>
</div>
methods: {
prev () {
let index = this.currentIndex + 1
if (index === this.playlist.length) {
index = 0
}
this.setCurrentIndex(index)
},
next () {
let index = this.currentIndex - 1
if (index === -1) {
index = this.playlist.length - 1
}
this.setCurrentIndex(index)
},
}

点击暂停切换下首歌,下首歌播放了但icon没发生变化,是因为点击next时歌曲切换了但是playing状态没修改,这里修改下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
prev () {
let index = this.currentIndex + 1
if (index === this.playlist.length) {
index = 0
}
this.setCurrentIndex(index)
if (!this.playing) {
this.togglePlaying()
}
},
next () {
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@canplay@error两个事件:

1、@canplay:当歌曲加载播放会触发事件,

2、@error:当歌曲地址发生错误时会触发error事件

audio标签监听@canplay="read"@error="error"事件

1
<audio ref="audio" :src="currentSong.url" @canplay="read" @error="error"></audio>

只有当歌曲read时才能点击下首歌,反之不能点击下首歌。用一个标识位控制,在data里定义songReady

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
data () {
return {
songReady: false
}
},
methods: {
prev () {
if (!this.songReady) {
return
}
let index = this.currentIndex + 1
if (index === this.playlist.length) {
index = 0
}
this.setCurrentIndex(index)
if (!this.playing) {
this.togglePlaying()
}
this.songReady = false
},
next () {
if (!this.songReady) {
return
}
let index = this.currentIndex - 1
if (index === -1) {
index = this.playlist.length - 1
}
this.setCurrentIndex(index)
if (!this.playing) {
this.togglePlaying()
}
this.songReady = false
},
read () {
this.songReady = true
},
}

当用户切换下首歌遇到网络错误或者下首歌url错误,songReady永远不能执行,之后的点击事件都不能用,当下首歌加载失败触发error函数

1
2
3
4
5
6
read () {
this.songReady = true
},
error () {
this.songReady = true
},

从样式上做些处理,当我们不能点击时给这个按钮绑定disableClas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div class="bottom">
<div class="operators">
...
<div class="icon i-left" :class="disableCls">
<i @click="prev" class="icon-prev"></i>
</div>
<div class="icon i-center" :class="disableCls">
<i @click="togglePlaying" :class="playIcon"></i>
</div>
<div class="icon i-right" :class="disableCls">
<i @click="next" class="icon-next"></i>
</div>
...
</div>
</div>
computed: {
disableCls () {
return this.songReady ? '' : 'disable'
},
}

具体源代码

参考:https://github.com/kakajing/vue-music