VUE移动端音乐APP学习【十六】:播放器歌词显示开发
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 ,它支持传入lyricStr和handler,歌词在不断播放的时候,每执行到一个时间点都会执行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&¤tLyric.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`; },
播放器歌词剩余功能实现
有以下问题需要去完善
- 多次切换歌曲时,歌词会不停来回跳动
原因:歌词是用currentLyric对象内部的一些功能实现跳跃,每个currentLyric内部是用了一个计时器实现歌曲的播放跳到相应的位置。每次currentSong改变的时候,都会重新new一个新的lyric-parser出来,但是之前的对象并没有做清理操作,也就是之前的currentLyric还是有计时器在里面,所以造成了歌词来回闪动的bug.
解决方法:在切currentSong即重新getLyric之前,把当前的getLyric给stop掉
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(); },