效果如图:
image.pngimage.pngimage.png

1. 界面开发思路

由于播放功能是贯穿整个项目的一个功能,所以在view文件夹中定义为一个全局的子组件,命名为Player.vue,又由播放页含有三种不同显示效果(全屏页小播放器播放列表)所以又单独拆分为独立的组件。
开发过程省略。。。

2. 开发核心—Vuex管理全局播放数据

在开发全局播放器的时候,需要用到的核心思想就是,播放器中所有涉及到更新的(例如:播放、暂停、切换歌曲等)操作均采用Vuex来统一管理操作更新。

  1. import Vue from 'vue'
  2. import Vuex from 'vuex'
  3. Vue.use(Vuex)
  4. export default new Vuex.Store({
  5. // state用于保存全局共享的数据
  6. state: {
  7. player: {
  8. isFullScreen: false
  9. }
  10. },
  11. // mutations用于保存修改全局共享数据的方法
  12. mutations: {
  13. changeFullScreen (state, flag) {
  14. state.player.isFullScreen = flag
  15. }
  16. },
  17. // actions用于保存触发mutations中保存的方法的方法
  18. actions: {
  19. setFullScreen ({ commit }, flag) {
  20. commit('changeFullScreen', flag)
  21. }
  22. },
  23. getters: {
  24. isFullScreen (state) {
  25. return state.player.isFullScreen
  26. }
  27. }
  28. })

image.png
使用Vuex官方推荐的做法:最先使用Actions里面的事件来调用后端接口并处理api数据,将处理好的数据,提交到mutation对应的事件中,最后由mutation来更改store中的数据。

注意点1

但是随着我们项目的增大,Vuex单独的index.js文件中的逻辑代码和数据会越来越多,这样非常不利于我们维护,我们应该将Vuex中的statemutationsactionsgetters全部抽离出来形成单独的文件,这才符合单一原则的开发方式。

注意点2

我们在通过Action提交到mutation的过程中通常的做法是commit('changeFullScreen', flag),将mutation定义的事件编写在了字符串中,这样IDE无法识别关联其中的事件,所以如果出现拼写错误是无法识别的。这里可以再优化一下:
单独创建一个mutations-type.js保存mutations中的相关事件的事件名称,将事件名称定义为一个常量,分别引入在actions和mutations中,这样当actions提交到mutation的时候可以直接使用常量调用的方式commit(FUNCTIONNAME, flag)

Vuex结构优化

按照上面的思想就把Vuex所需要的文件拆分为了以下逻辑:
image.png

modeType.js是播放项目开发过程中所需要的,并不是必须

Vuex结构定义部分:

  1. /* mutations-type.js */
  2. export const SET_FULL_SCREEN = 'SET_FULL_SCREEN'
  3. export const SET_MINI_PLAYER = 'SET_MINI_PLAYER'
  4. export const SET_LIST_PLAYER = 'SET_LIST_PLAYER'
  1. /* mutations.js */
  2. import {
  3. SET_FULL_SCREEN,
  4. SET_MINI_PLAYER,
  5. SET_LIST_PLAYER
  6. } from './mutations-type'
  7. export default {
  8. // 把常量作为方法名,需要用中括号括起来
  9. [SET_FULL_SCREEN] (state, flag) {
  10. state.player.isFullScreen = flag
  11. },
  12. [SET_MINI_PLAYER] (state, flag) {
  13. state.player.isShowMiniPlayer = flag
  14. },
  15. [SET_LIST_PLAYER] (state, flag) {
  16. state.player.isShowListPlayer = flag
  17. }
  18. }
  1. /* actions.js */
  2. import {
  3. SET_FULL_SCREEN,
  4. SET_MINI_PLAYER,
  5. SET_LIST_PLAYER
  6. } from './mutations-type'
  7. export default {
  8. setFullScreen ({ commit }, flag) {
  9. commit(SET_FULL_SCREEN, flag)
  10. },
  11. setMiniPlayer ({ commit }, flag) {
  12. commit(SET_MINI_PLAYER, flag)
  13. },
  14. setListPlayer ({ commit }, flag) {
  15. commit(SET_LIST_PLAYER, flag)
  16. }
  17. }
  1. /* getters.js */
  2. export default {
  3. isFullScreen (state) {
  4. return state.player.isFullScreen
  5. },
  6. isShowMiniPlayer (state) {
  7. return state.player.isShowMiniPlayer
  8. },
  9. isShowListPlayer (state) {
  10. return state.player.isShowListPlayer
  11. }
  12. }
  1. /* state.js */
  2. import mode from './modeType'
  3. export default {
  4. // header组件所需数据
  5. header: {
  6. themes: ['theme', 'theme1', 'theme2'],
  7. headerIcon: ['icon-music163', 'icon-qq', 'icon-custom'],
  8. curIcon: 'icon-music163',
  9. themeIndex: 0
  10. },
  11. // 播放器所需数据
  12. player: {
  13. isShowMiniPlayer: false,
  14. isFullScreen: false,
  15. isShowListPlayer: false
  16. }
  17. }
  1. /* index.js */
  2. import Vue from 'vue'
  3. import Vuex from 'vuex'
  4. import state from './state'
  5. import mutations from './mutations'
  6. import actions from './actions'
  7. import getters from './getters'
  8. Vue.use(Vuex)
  9. export default new Vuex.Store({
  10. state: state,
  11. mutations: mutations,
  12. actions: actions,
  13. getters: getters
  14. })

Vuex使用:

  1. <template>
  2. <transition
  3. @enter="enter"
  4. @leave="leave"
  5. :css="false">
  6. <!-- 通过映射过后的Getters可在组件中直接使用 -->
  7. <div class="mini-player" v-if="this.isShowMiniPlayer">
  8. <div class="player-wrapper">
  9. <div class="player-left" @click="showNormalPlayer">
  10. <img :class="isPlaying === false ? '' : 'active'" v-lazy="currentSong.picUrl" alt="">
  11. <div class="player-title">
  12. <h3>{{ currentSong.name }}</h3>
  13. <p>{{ currentSong.singer }}</p>
  14. </div>
  15. </div>
  16. <div class="player-right">
  17. <div class="play" @click="play">
  18. <i :class="['iconfont', isPlaying === false ? 'icon-pause' : 'icon-play']"></i>
  19. </div>
  20. <div class="list" @click.stop="showList">
  21. <i class="iconfont icon-31liebiao"></i>
  22. </div>
  23. </div>
  24. </div>
  25. </div>
  26. </transition>
  27. </template>
  28. <script>
  29. import { mapActions, mapGetters } from 'vuex'
  30. import Velocity from 'velocity-animate'
  31. import 'velocity-animate/velocity.ui'
  32. export default {
  33. name: 'MiniPlayer',
  34. methods: {
  35. // Vuex Actions 方法映射
  36. ...mapActions([
  37. 'setFullScreen',
  38. 'setMiniPlayer',
  39. 'setListPlayer',
  40. 'setIsPlaying'
  41. ]),
  42. showList () {
  43. this.setListPlayer(true)
  44. },
  45. showNormalPlayer () {
  46. this.setFullScreen(true)
  47. this.setMiniPlayer(false)
  48. },
  49. enter (el, done) {
  50. Velocity(el, 'transition.slideUpIn', { duration: 200 }, () => {
  51. done()
  52. })
  53. },
  54. leave (el, done) {
  55. Velocity(el, 'transition.slideUpOut', { duration: 200 }, () => {
  56. done()
  57. })
  58. },
  59. play () {
  60. // 通过映射后的Actions方法可直接调用
  61. this.setIsPlaying(!this.isPlaying)
  62. }
  63. },
  64. computed: {
  65. // Vuex Getters 方法映射
  66. ...mapGetters([
  67. 'isShowMiniPlayer',
  68. 'isPlaying',
  69. 'currentSong'
  70. ])
  71. }
  72. }
  73. </script>
  74. <style scoped lang="scss">
  75. </style>

优点

这样做的好处是在今后开发播放器功能的时候,如果某个组件需要获取数据,就直接调用getters里面的方法返回Vuex全局数据,如果某个组件需要操纵全局数据,就直接调用actions中的方法对Vuex全局数据进行修改。由于处处都是绑定和操作的全局数据,所以在全局数据发生变化的时候,各个组件间的内容就统一发生了改变,无需手动更新,这样会大大减小逻辑上的操作成本。

3. 解决歌曲歌词资源不对版(debug难度:⭐⭐)

在开发过程中发现一个bug,例如随便点进一个歌单,原本点击收听的是《永不失联的爱》这首歌,可播放的时候确是《这世界那么多人》。经过debug发现这不是我们程序的问题,这是调用的第三方API出现的问题,在调用这个接口的时候传入的id是这个顺序,服务器结果返回数据的时候顺序改变了。
image.png
image.png
这时候需要在Vuex的actions里找到数据请求和处理的那一行代码,此时只需要在遍历歌曲详情的里面每次都遍历一次歌曲URL资源,用歌曲详情数组的id与歌曲URL数组的id相匹配,构成双循环。

  1. const res = await getSongDetail({ ids: ids.join(',') }) // 获取所有歌曲名称、id、封面图片
  2. const songUrls = await getSongURL({ id: ids.join(',') }) // 获取所有歌曲url资源地址
  3. for (let i = 0; i < res.songs.length; i++) {
  4. const obj = {}
  5. // 解决歌曲资源不对版
  6. for (let n = 0; n < songUrls.data.length; n++) {
  7. const item = songUrls.data[n]
  8. if (res.songs[i].id === item.id) {
  9. obj.url = item.url
  10. break
  11. }
  12. }
  13. }

4. 公共方法抽取

在播放界面开发的时候,不同组件之间常常会使用相同的工具方法对数据进行处理,这时候就需要我们将这些工具方法统一抽取到一个公共工具类中,方便以后使用,减少代码冗余。

  1. // 获取随机数,用于随机播放模式
  2. export const getRandomInt = (min, max) => {
  3. min = Math.ceil(min)
  4. max = Math.floor(max)
  5. return Math.floor(Math.random() * (max - min + 1)) + min
  6. }
  7. // 格式化时间,用于格式化歌曲时间进度信息
  8. export const formatTime = time => {
  9. // 得到两个时间之间的差值(秒)
  10. const differSecond = time
  11. // 利用相差的总秒数 / 每一天的秒数 = 相差的天数
  12. let day = Math.floor(differSecond / (60 * 60 * 24))
  13. day = day >= 10 ? day : '0' + day
  14. // 利用相差的总秒数 / 小时 % 24;
  15. let hour = Math.floor(differSecond / (60 * 60) % 24)
  16. hour = hour >= 10 ? hour : '0' + hour
  17. // 利用相差的总秒数 / 分钟 % 60;
  18. let minute = Math.floor(differSecond / 60 % 60)
  19. minute = minute >= 10 ? minute : '0' + minute
  20. // 利用相差的总秒数 % 秒数
  21. let second = Math.floor(differSecond % 60)
  22. second = second >= 10 ? second : '0' + second
  23. return {
  24. day: day,
  25. hour: hour,
  26. minute: minute,
  27. second: second
  28. }
  29. }
  30. // 本地存储方法
  31. export const setLocalStorage = (key, value) => {
  32. window.localStorage.setItem(key, JSON.stringify(value))
  33. }
  34. export const getLocalStorage = (key) => {
  35. return JSON.parse(window.localStorage.getItem(key))
  36. }

歌曲播放模式状态检测

例如,在歌曲的播放模式状态检测时就需要用到随机数方法

  1. <template>
  2. <div class="player">
  3. <audio :src="currentSong.url" ref="audio" @timeupdate="timeupdate" @ended="end"></audio>
  4. </div>
  5. </template>
  6. <script>
  7. import mode from '../store/modeType'
  8. export default {
  9. name: 'Player',
  10. // ...
  11. methods: {
  12. end () {
  13. if (this.modeType === mode.loop) {
  14. this.setCurrentIndex(this.currentIndex + 1)
  15. if (this.songs.length === 1) {
  16. this.$refs.audio.play()
  17. }
  18. } else if (this.modeType === mode.one) {
  19. this.$refs.audio.play()
  20. } else if (this.modeType === mode.random) {
  21. if (this.songs.length === 1) {
  22. this.$refs.audio.play()
  23. } else {
  24. const index = getRandomInt(0, this.songs.length - 1)
  25. this.setCurrentIndex(index)
  26. if (index === this.currentIndex) {
  27. this.$refs.audio.play()
  28. }
  29. }
  30. }
  31. }
  32. }
  33. }
  34. </script>

5. 歌词界面IScroll滑动问题(debug难度:⭐)

这也算的上是使用IScroll最常遇到的老问题了,同样在全屏播放器的歌词界面也会遇到歌词无法滚动,歌词跳转上下反复抽搐等bug。尽管在此项目使用IScroll的时候我们对滚动容器进行了二次封装,在里面加入了观察者对象,使之里边的dom元素发生变化的时候就调用refresh方法对容器最大滚动距离进行刷新,可有时还是无法避免一些未知情况。例如有时候选择一首歌词比较短的歌就没有超出滚动容器高度,这时候IScroll是默认不滚动的。然后再快速切换到下一首歌词较长且超出滚动容器高度的歌并将歌曲进度调到一半之后,IScroll好像还是反应不过来,滚动容器会反复抽搐。这时候可以尝试用watch监听全屏播放状态和当前歌词,如果处于全屏播放状态或者当前歌词更换的时候就调用IScroll的refresh方法。

  1. watch: {
  2. currentLyric (newValue, oldValue) {
  3. if (newValue) {
  4. this.iscroll.refresh()
  5. }
  6. }
  7. }

6. 歌曲无音源问题(debug难度:⭐⭐)

其实这个问题可以说是整个项目中其中一个未知性最大的问题,原因出自于我们是调用的网易云的api,我们并没有api相关的文档,所以在获取到数据渲染歌曲列表的时候无法分析出数据中哪一个字段代表无音源,也就无法对一些特殊歌曲进行屏蔽操作。无音源有可能是无版权,也有可能是收费歌曲。

官方做法:

image.pngimage.png

个人做法:

由于我们不知道歌曲处于什么状态,所以就只有见机行事,也就是只有当播放到某一首歌曲的时候才能获取到当前的歌曲是哪种状态,然后进行处理。当播放的时候获取到此歌曲如果没有URL资源就不允许播放和收藏,以免出现更多未知的错误。
image.png
无音源2.png

7. 组件切换动画卡顿问题

在经过一段时间的调试过程中发现从mini播放器切换到全屏播放器组件的时候产生的过渡动画始终不是很流畅,强迫症程序员是绝对不允许这种情况出现的。于是将Velocity.js操作的动画部分改为了CSS动画过渡,所以还是应验了那句话:能用CSS的地方绝不用JS
组件过渡.gif

8. 播放界面全局状态展示

开发完整个播放组件之后的全局状态如下:
image.png
image.png

特别说明:项目Vuex中的songs数据是根据当前播放的歌曲的方式来进行更新的,并不是一直累加的方式。例如当前点击的是播放全部,songs中的数据可能就有10条,如果单击某一个歌曲,songs中的数据就始终只有一条,就是当前点击的那一首。historyList(播放历史)数据才是根据播放一直累加的。

9. 歌曲索引越界问题(debug难度:⭐⭐⭐)

在播放歌曲的时候还有一个比较难调试的bug,例如当选择播放推荐歌单列表的全部歌曲的时候,此时Vuex中的songs数据可能有10条,Vuex的currentIndex索引的范围可以是0-9。如果我们当前只是从列表中的第一首歌曲切换到外边推荐单曲的歌曲的时候还不会出问题,因为此时的currentIndex始终都是0,切换到外边推荐单曲的时候songs的数据数组中就只有一条,所以下标为0是没问题。但是当我们在歌单列表中的播放第三首第四首的时候,currentIndex就为2或者3。此时如果切换到推荐单曲栏目中的某个单曲就导致songs中的数据只有一条,所以songs中没有当前索引为2或3的数据,这样就导致加载错误。因为在JS中的数组是稀松数组,不存在越界报错这一问题,所以如果越界取到的数据顶多是undefined,这样就导致渲染歌曲资源时出现问题不太好调错。
要解决这一个问题只需要在Vuex取当前歌曲的getters方法中对数据进行判断。

  1. currentSong (state) {
  2. let song = {
  3. name: '',
  4. singer: '',
  5. picUrl: '',
  6. url: ''
  7. }
  8. if (state.player.songs.length !== 0) {
  9. song = state.player.songs[state.player.currentIndex]
  10. }
  11. // 纠正currentIndex越界(全部播放、切换到最新音乐单曲时)
  12. if (song === undefined) {
  13. state.player.currentIndex = 0
  14. song = state.player.songs[state.player.currentIndex]
  15. }
  16. return song
  17. }

因为只要有播放歌曲操作,除开接口挂掉的情况,songs数据中始终至少有一条数据,所以如果发生越界情况,直接把currentIndex置为0即可。