VUE移动端音乐APP学习【十六】:播放器歌词显示开发

播放器歌词数据抓取

在api文件夹下创建song.js 设置获取歌曲api

import axios from 'axios';

export function getLyric(id) {
  return axios.get(`/api/lyric?id=${id}`);
}

把这个方法封装到common->js->下的song类,歌词可以理解为song的一个属性。不能直接拿歌词需要调用这个接口,所以给song扩展一个方法getLyric

import { getLyric } from '../../api/song';
import { ERR_OK } from '../../api/config';

export default class Song {
  // song的id,歌手,歌曲名name,专辑名album,歌曲长度duration,歌曲图片img,歌曲的真实路径url
  constructor({
    id, singer, name, album, duration, image, url,
  }) {
    this.id = id;
    this.singer = singer;
    this.name = name;
    this.album = album;
    this.duration = duration;
    this.image = image;
    this.url = url;
  }

  getLyric() {
    getLyric(this.id).then((res) => {
      if (res.data.code === ERR_OK) {
        this.lyric = res.data.lrc.lyric;
        console.log(this.lyric);
      }
    });
  }
}

在player组件的watch里调用看看能不能正确获取到歌词

 

 watch: {
    currentSong(newSong, oldSong) {
      if (newSong.id === oldSong.id) {
        return;
      }
      this.$nextTick(() => {
        this.$refs.audio.play().catch((error) =>  {
          this.togglePlaying();
          // eslint-disable-next-line no-alert
          alert('播放出错,暂无该歌曲资源');
        });
        this.currentSong.getLyric();
      });
    },

 

播放器歌词数据解析

可以看到歌词是非常长的字符串。接下来就是解析字符串。利用第三方库 lyric-parser ,它支持传入lyricStrhandler,歌词在不断播放的时候,每执行到一个时间点都会执行handler函数

 

npm install lyric-parser@1.0.1

 

在数据解析之前需要优化一个地方:currentSong每次变化的时候都会调用song的getLyric方法,则会有很多次请求,这样是不合理的。

所以要加个逻辑判断并利用Promise进行改造

getLyric() {
    if (this.lyric) {
      // getLyric本身返回的就是Promise
      return Promise.resolve(this.lyric);
    }
    // 封装Promise,只用于获取歌词
    return new Promise((resolve, reject) => {
      getLyric(this.id).then((res) => {
        if (res.data.code === ERR_OK) {
          this.lyric = res.data.lrc.lyric;
          resolve(this.lyric);
        } else {
          // 获取不到歌词
          // eslint-disable-next-line prefer-promise-reject-errors
          reject('no lyric');
        }
      });
    });

  }

player组件里引入插件lyric-parser,并且修改watch里的currentSong(),不直接调用currentSong.getLyric(),在methods里封装getLyric()

 

import Lyric from 'lyric-parser';


currentSong(newSong, oldSong) {
     ...
        // 这里不直接调用currentSong.getLyric()
        this.getLyric();
      });
    },

 

 getLyric() {
      this.currentSong.getLyric().then((lyric) => {
        this.currentLyric = new Lyric(lyric);
        console.log(this.currentLyric);
      });
    },

//在data里添加currentLyric
 data() {
    return {
      songReady: false,
      currentTime: 0,
      radius: 32,
      currentLyric: null,
    };
  },

打印可以看到lyric对象的数据,数据里有一个lines,每个lines对象都有time和txt

播放器歌词滚动列表实现

在player组件添加以下dom结构:使用获取到currentLyric,并且遍历lines对象

 <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">{{line.text}}</p>
        </div>
    </div>
</div>

在浏览器先将middle-l的dom结构删掉,可以看到歌词列表

 

 

目前的歌词列表是无法滚动的,也不能实时根据歌曲的播放显示对应的歌词,需要去处理一下

当执行到getLyric()时,获取到歌词的时候,调用this.currentLyric.play(),这样的话歌词就会播放了。还要在初始化的时候传一个回调函数handleLyric()。

getLyric() {
      this.currentSong.getLyric().then((lyric) => {
        this.currentLyric = new Lyric(lyric, this.handleLyric);
        if (this.playing) {
          this.currentLyric.play();
        }
        console.log(this.currentLyric);
      });
    },

定义这个方法handleLyric():当歌词每一行发生改变的时候,它就回调一下,让当前的歌词变高亮。

  • 首先需要先去data里定义一个currentLineNum,表示当前所在的行。

 

 data() {
    return {
      songReady: false,
      currentTime: 0,
      radius: 32,
      currentLyric: null,
      currentLineNum: 0,
    };
  },
  • 在刚才的dom结构绑定一个class:当currentLineNum等于index的时候就显示current样式,实现高亮效果
<p ref="lyricLine" class="text" :class="{'current':currentLineNum === index}" v-for="(line,index) in currentLyric.lines" :key="index">{{line.txt}}</p>
  • handleLyric设置当前currentLine等于index,这样就可以看到当前播放的歌词
handleLyric({ lineNum, txt }) {
      this.currentLineNum = lineNum;
    },

歌词列表如果想要实现滚动,就需要用到scroll组件。向scroll传入data是为了currentLyric发生变化的时候,它可以自动调用它的refresh方法

<scroll class="middle-r" ref="lyricList" :data="currentLyric&&currentLyric.lines">
      <div class="lyric-wrapper">
              <div v-if="currentLyric">
                <p ref="lyricLine" class="text" :class="{'current':currentLineNum === index}" v-for="(line,index) in currentLyric.lines" :key="index">{{line.txt}}</p>
              </div>
       </div>
</scroll>


import Scroll from '../../base/scroll/scroll';
components: {
    ProgressBar,
    ProgressCircle,
    Scroll,
  },

当我们歌词播放到中间的时候(歌词的第5-6行开始,可以保证在屏幕的中间),会有上下滚动的效果。这个时候如果滚动到一个位置它也会再滚回去。

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);
      }
    },

播放器歌词左右滑动实现

在播放器页面有个dot的dom结构

<div class="bottom">
     <div class="dot-wrapper">
            <span class="dot"></span>
            <span class="dot"></span>
    </div>
    ...
</div>

当前哪个点应该是active,用一个变量维护这个状态。currentShow默认为cd,当切换右边页面的时候,currentShow就改为lyric

  data() {
    return {
      songReady: false,
      currentTime: 0,
      radius: 32,
      currentLyric: null,
      currentLineNum: 0,
      currentShow: 'cd',
    };
  },
<span class="dot" :class="{'active':currentShow==='cd'}"></span>
<span class="dot" :class="{'active':currentShow==='lyric'}"></span>

接下来就是实现左右滑动。

  • created()下定义touch变量
  • middle绑定touch事件,touchStart,touchMove,touchEnd,并定义这三个方法(其中还添加了一些动画效果使得滑动画面不生硬)
middleTouchStart(e) {
      this.touch.initiated = true;
      const touch = e.touches[0];
      // 记录X坐标和Y坐标
      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;
      // 为什么要维护纵坐标呢,因为歌词滚动是用scroll是一个上下滚动的过程,当纵轴偏移大于横轴偏移时,就不应该左右移动
      const deltaY = touch.pageY - this.touch.startY;
      if (Math.abs(deltaY) > Math.abs(deltaX)) {
        return;
      }
      // 在滚动时,需要知道歌词列表滚动的宽度是多少。首先要记录在滚动过程中,middle-r距离右侧的宽度
      const left = this.currentShow === 'cd' ? 0 : -window.innerWidth;
      // 最大不超过0
      const offsetWidth = Math.min(0, Math.max(-window.innerWidth, left + deltaX));
      // lyricList实际上是个scroll组件,即vue组件,是没法直接操作dom,需要访问它的element才能访问dom
      // 滑动的比例=列表向左宽度的宽度/整个屏幕的宽度
      this.touch.percent = Math.abs(offsetWidth / window.innerWidth);
      this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px,0,0)`;
      this.$refs.lyricList.$el.style[transitionDuration] = 0;
      // percent越大,透明度就越小
      this.$refs.middleL.style.opacity = 1 - this.touch.percent;
      this.$refs.middleL.style[transitionDuration] = 0;
    },
    middleTouchEnd() {
      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 { // 从左向右滑
      // 如果滑超过10%就要偏移回去
        // eslint-disable-next-line no-lonely-if
        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`;
    },
View Code

播放器歌词剩余功能实现

有以下问题需要去完善

  • 多次切换歌曲时,歌词会不停来回跳动

原因:歌词是用currentLyric对象内部的一些功能实现跳跃,每个currentLyric内部是用了一个计时器实现歌曲的播放跳到相应的位置。每次currentSong改变的时候,都会重新new一个新的lyric-parser出来,但是之前的对象并没有做清理操作,也就是之前的currentLyric还是有计时器在里面,所以造成了歌词来回闪动的bug.

解决方法:在切currentSong即重新getLyric之前,把当前的getLyricstop

 currentSong(newSong, oldSong) {
      if (newSong.id === oldSong.id) {
        return;
      }
      if (this.currentLyric) {
        this.currentLyric.stop();
      }
     ...
    },
  • 点击暂停时,歌词并没有停止滚动

原因:在播放状态改变的时候,歌词的播放状态没有改变

解决方法:在togglePlaying()中添加判断逻辑:播放状态改变时,歌词的播放状态也随之改变

 togglePlaying() {
      // 如果没有ready好的话就直接返回
      if (!this.songReady) {
        return;
      }
      this.setPlayingState(!this.playing);
      if (this.currentLyric) {
        this.currentLyric.togglePlay();
      }
    },
  • 在循环播放模式下,将进度条切到末尾让它重新回到初始位置时,歌词并没有回到最初的位置

解决方法:在loop()中实现逻辑,使用歌词的seek方法将它偏移到初始位置。

loop() {
      this.$refs.audio.currentTime = 0;
      this.$refs.audio.play();
      if (this.currentLyric) {
        this.currentLyric.seek(0);
      }
    },
  • 在拖动进度条的时候,歌词并没有随着进度条的改变而改变

解决方法:在onProgressBarChange()中实现逻辑,也是调用歌词的seek方法

onProgressBarChange(percent) {
      const currentTime = this.currentSong.duration * percent;
      this.$refs.audio.currentTime = currentTime;
      if (!this.playing) {
        this.togglePlaying();
      }
      if (this.currentLyric) {
        this.currentLyric.seek(currentTime * 1000);
      }
    },
  • 在cd下方显示歌词,这样用户就不用每次切换到歌词列表看歌词

解决方法:在cd-wrapper下面加一个div,传入数据playingLyric。playingLyric在handleLyric执行的时候改变。

<div class="middle-l" ref="middleL">
     <div class="cd-wrapper" ref="cdWrapper">
           <div class="cd" :class="cdCls">
                <img class="image" :src="currentSong.image">
           </div>
     </div>
     <div class="playing-lyric-wrapper">
           <div class="playing-lyric">{{playingLyric}}</div>
     </div>
</div>



data() {
    return {
      ...
      playingLyric: '',
    };
  },


handleLyric({ lineNum, txt }) {
      ...
      this.playingLyric = txt;
    },
  • 考虑getLyric异常情况:获取不到歌词的时候,要做清理操作
getLyric() {
      this.currentSong.getLyric().then((lyric) => {
        this.currentLyric = new Lyric(lyric, this.handleLyric);
        if (this.playing) {
          this.currentLyric.play();
        }
      }).catch(() => {
        this.currentLyric = null;
        this.playingLyric = '';
        this.currentLineNum = 0;
      });
    },
  • 考虑边界条件:当歌曲列表只有一首歌,点击下一首或上一首的时候会有什么问题?源代码中index = currentIndex + 1,currentIndex = 0;此时index等于playlist的长度,重置为0,然后又执行下面的逻辑将currentIndex置为0,则playlist不会发生变化,currentSong的id也不会发生变化,之后的逻辑都不会执行。

解决方法:在next()和prev()加个判断,如果playlist的长度为1时,就让它使用loop()进行单曲循环。

prev() {
      // 如果没有ready好的话就直接返回 不能使用下面的逻辑实现功能
      if (!this.songReady) {
        return;
      }
      if (this.playlist.length === 1) {
        this.loop();
      } else {
        let index = this.currentIndex - 1;
        if (index === -1) {
          index = this.playlist.length - 1;
        }
        this.setCurrentIndex(index);
        if (!this.playing) {
          this.togglePlaying();
        }
      }
      this.songReady = false;
    },
  • 当我们在微信播放的时候,实际上js是不会执行的但是audio可以将当前歌曲播放完。一旦歌曲播放完就会触发end事件,但是end事件是js不会执行。如果end不执行,那么再次播放的时候,songReady就一直不会设置为true,我们就切换不了歌曲。

解决方法:让audio的play方法延迟时间更长一点,保证在手机浏览器从后台切换到前台js执行的时候,播放器可以正常播放。

currentSong(newSong, oldSong) {
      if (newSong.id === oldSong.id) {
        return;
      }
      if (this.currentLyric) {
        this.currentLyric.stop();
      }
      setTimeout(() => {
        this.$refs.audio.play().catch((error) =>  {
          this.togglePlaying();
          // eslint-disable-next-line no-alert
          alert('播放出错,暂无该歌曲资源');
        }, 1000);
        // 这里不直接调用currentSong.getLyric()
        this.getLyric();
      });
    },

播放器底部播放器适配

以前页面的滚动高度都是计算到底的,但是现在有了迷你播放器占了底部一定高度,scroll滚动的高度就出错了。

监听playerlist,如果当有playerlist的时候,scoll组件的bottom值重新设置成mini-player的高度,让它重新计算scorll滚动的高度。

因为这些组件都需要处理这个问题,处理这个问题的逻辑又非常类似,可以使用mixin

创建mixin.js

export const playlistMixin = {
  computed: {
    // 通过getters拿到playlist
    ...mapGetters([
      'playlist',
    ]),
  },
  mounted() {
    this.handlePlaylist(this.playlist);
  },
  activated() {
    this.handlePlaylist(this.playlist);
  },
  watch: {
    playlist(newVal) {
      this.handlePlaylist(newVal);
    },
  },
  methods: {
    handlePlaylist() {
      // 具体方法要到具体组件实现
      // 抛个异常,组件必须实现这个函数,一旦组件定义这个函数,它就会覆盖mixin里的这个函数。如果没有则调用mixin里的这个函数
      throw new Error('component must implement handlePlaylist method');
    },
  },
};

一个组件可以插入多个mixin,所以有个mixins属性使用。在music-list组件应用mixin,一旦组件使用了mixin,就必须定义handlePlaylist方法不然会报错.

定义handlePlaylist方法,判断如果有playlist,改变改变list的bottom并强制scroll重新计算

 

import { playlistMixin } from '../../common/js/mixin';


mixins: [playlistMixin],

handlePlaylist(playlist) {
      const bottom = playlist.length > 0 ? '60px' : '';
      this.$refs.list.$el.style.bottom = bottom;
      // 调用refresh()让scroll重新计算高度
      this.$refs.list.refresh();
    },

 

singer组件同理,但是需要调用listview让它重新计算,在listview.vue中暴露一个refresh方法后,再在singer.vue中调用

 

refresh() {
      this.$refs.listview.refresh();
    },
<div class="singer" ref="singer">
    <list-view @select="selectSinger" :data="singers" ref="list"></list-view>
    <router-view></router-view>
</div>

 handlePlaylist(playlist) {
      const bottom = playlist.length > 0 ? '60px' : '';
      this.$refs.list.$el.style.bottom = bottom;
      // 调用refresh()让scroll重新计算高度
      this.$refs.list.refresh();
    },

最后修改推荐页面

handlePlaylist(playlist) {
      const bottom = playlist.length > 0 ? '60px' : '';
      this.$refs.recommend.style.bottom = bottom;
      // 调用refresh()让scroll重新计算高度
      this.$refs.scroll.refresh();
    },

 

posted @ 2021-05-03 22:22  小风车吱呀转  阅读(763)  评论(2编辑  收藏  举报