一、简介
最近正好学到一个歌词滚动的课程,类似于QQ音乐那样,就想着自己去实现一番。同时我看了QQ音乐的那个播放界面,存在两个不足的点:一是不能跳转到指定歌词位置;二是在歌词那里拖动时有明显的卡顿。所以本着学习和改进的目的,自己去实现一下。
二、效果
直接看效果,可能并不是特别特别的美观,当然,本文主要的侧重点在各个功能的实现上。
三、思路
从功能角度来讲,大概步骤如下:
- 准备数据。
- 展示歌词。
- 通过拖动歌词内容,实现歌词的滚动浏览。
- 进度条控制歌词显示位置。
- 歌词高亮。
- 点击歌词右侧的播放按钮,可跳转到指定歌词位置。
接下来是具体的实现细节:
3.1、准备数据
txt
var lrc = `[ti:我不配]
[ar:周杰伦]
[al:我很忙]
[by:阿源]
[00:00.06]︿☆我不配☆︿
[00:00.75]
[00:01.11]演唱:周杰伦
[00:02.62]
[00:03.35]︿☆歌词制作:断尘☆︿
[00:06.13]→www.90lrc.cn←
[00:09.30]www.90lrc.cn ★【歌词网】
[00:11.09]
...
可以看到,我们的歌词是这样子的。我们需要将歌词与时间分开,并且把它转换成一个以时间为key
,歌词为value
的对象。
js
let lrcWithTimeObj = new Map(),
function showLrc(lrc) {
let lrcArr = lrc.split('\n')
let timeRegex = /(\[\d{2}\:\d{2}\.\d{2}\])(.*)/;
for (let i = 0; i < lrcArr.length; i++) {
let machResult = lrcArr[i].match(timeRegex)
if (machResult) {
let time = machResult[1];
let text = machResult[2];
let timeContent = parseTime(time);
text && keys.push(timeContent)
lrcWithTimeObj.set(timeContent, text)
}
}
}
function parseTime(time) {
let regex = /\d{2}/g;
let matchResult = time.match(regex);
return +matchResult[0] * 60 + +matchResult[1] + +matchResult[2] / 100;
}
这里为什么用map
,而不是用普通的对象,我后续再做解释。以下就是这个对象的结构。
3.2、展示歌词
我们需要创建对应的dom结构。
- 在
createElement
这个方法中,我们使用了DocumentFragment
。当需要添加多个 DOM 元素到页面时,如果先将这些元素添加到DocumentFragment
中,然后再统一将DocumentFragment
添加到页面,可以减少页面渲染 DOM 的次数,从而提高性能。 - 还有就是需要过滤掉相关时间内无内容的数据,就比如0.75s和36s那里。此操作是为了保证歌词的连续性,相对来说美观些。
- 设置一个
divObj
变量,方便找到对应时间的那个dom结构,此步骤是为了设置高亮;并且设置了一个标记变量count
,表明这个歌词内容是排在第几个,这个count
值与后面设置偏移量密切相关。
js
let divObj = {};
function createElement(map) {
let count = 0;
let frag = document.createDocumentFragment();
for (let key of map.keys()) {
let v = map.get(key);
if (v) {
let div = document.createElement('div');
div.innerText = v;
div.appendChild(createImgElement())
div.classList.add(`music-lyric`)
div.classList.add(`tag-${key}`)
divObj[`tag-${key}`] = {
dom: div,
count: count++
};
frag.appendChild(div);
}
}
lrcListDom.appendChild(frag)
//一行歌词的高度
divHeight = lrcListDom.children[0].clientHeight;
}
function createImgElement() {
let img = document.createElement('img');
img.src = './play.png';
img.classList.add('music-lyric-img')
img.addEventListener('click', function (e) {
setCurrentPlay(e);
})
return img;
}
3.3、通过拖动歌词内容,实现歌词的滚动浏览
3.3.1 CSS部分
滚动的原理大致是这样的,中间红色框是我们要显示的内容,因为父元素设置了overflow: hidden;
,因此多余的部分将被隐藏。当我们将内容区域(图中背蓝色的区域)向上移动,这样下面的内容就会显示出来,而上面的部分又超出了红色框部分,因此被隐藏,这样就达到了一个歌词可以滚动预览的效果。
可以看到,.lyric-content
向上移动-137px
。就对应第二幅图的那个效果,
3.3.2 JS部分
关键点在于我们需要监听鼠标的按下、移动和释放事件;
- 我们按下的时候需要设置一个变量
moveIng
,用来表示处于拖动状态。为什么要有这么一个状态,是因为避免在拖动的时候,歌词跳转到下一行产生的偏移(transform)和你拖动产生的偏移发生冲突。还有就是设置了一个基础的偏移量currentBaseOffSet
,用来记录上次拖动到什么位置,以免从位置0开始去偏移。 - 这是移动的时候我们需要关注两个点,一个是要偏移的量,还有就是要给它取消过渡效果;首先是偏移量如何计算,首先我们需要在鼠标按下的时候记录你此刻的位置
moveStartY
,然后在移动的时候取到e.clientY
,二者相减就可以算出需要移动多少。如下图,我分别打印了moveStartY
、e.clientY
和e.clientY - moveStartY
。 - 最后就是在鼠标拖动结束的时候,需要把过渡效果再加回去。
(再多说一句,就是在拖动的时候可能会发生文字选中的效果,比较不美观,因此我们需要在css中设置user-select: none;
)
js
lrcListDom.addEventListener('mousedown', function (e) {
moveIng = true
moveStartY = e.clientY;
let matchs = lrcListDom.style.transform.match(/-?\d+/);
currentBaseOffSet = matchs ? matchs[0] : 0;
})
lrcListDom.addEventListener('mousemove', function (e) {
if (moveIng) {
let moveY = e.clientY - moveStartY;
let v = +currentBaseOffSet + moveY
lrcListDom.style.transform = `translateY(${v}px)`;
lrcListDom.classList.remove('lyric-content-transition')
}
})
document.addEventListener('mouseup', function (e) {
moveIng = false
lrcListDom.classList.add('lyric-content-transition')
})
3.4、进度条控制歌词显示位置
3.4.1、首先我们需要监听播放器的事件timeupdate;
- 具体来说,当你播放一个音频或视频文件时,该文件的播放进度(即当前播放到的时间点)会不断地变化。每当这个时间点发生变化时(例如,由于播放、暂停、跳转或其他任何导致当前播放位置改变的操作),
timeupdate
事件就会被触发。 - 获取最近的播放时间。因为我们可能(准确的来说是几乎一定)点击进度条的时间不是正好对应一段歌词的开始时间。比如我们在6.13s时唱的是歌词1,在9.3s时唱下一句歌词2,我们正好将进度条拖动到了8s的时间,那么就需要歌词正中间显示的是6.13s的歌词1。
js
function registerEvent() {
audio.addEventListener('timeupdate', function () {
//获取当前的播放时间
let curTime = audio.currentTime;
//获取最近的播放时间
let time = getLeastTime(curTime);
//获取对应时间的dom
getCurDom(time);
})
}
registerEvent()
3.4.2、根据时间(6.13s)获取到对应的dom结构
正好使用到第五步设置的divObj
变量。
我们获取了需要居中显示的dom结构,并且获取了count
(为了设置偏移量,可以这么理解,这个count也就是需要往上或者往下移动几行)。这里需要高亮此时的dom,取消上一行dom的高亮,因此设置了在之前设置了lastShowDiv
这个变量。
js
function getCurDom(time) {
let key = `tag-${time}`;
let dom = divObj[key] && divObj[key]['dom'];
let count = divObj[key] && divObj[key]['count'];
if (!dom) {
return
}
if (lastShowDiv !== dom) {
if (lastShowDiv === null) {
lastShowDiv = dom
lastShowDiv.classList.add('active')
} else {
dom.classList.add('active')
lastShowDiv.classList.remove('active');
lastShowDiv = dom
}
if (!moveIng) {
setOffset(count);
}
}
}
3.4.3、设置偏移量
我在createElement
时候定义了divHeight
(一行歌词的高度)。需要根据count
来计算需要偏移的值。这里需要判断是否要进行偏移。因为如果是拖动到很靠前的歌词部分,那么就无需设置歌词内容的偏移。
js
function setOffset(c) {
let offSetValue = c * divHeight;
if (offSetValue > containerHeight / 2) {
let step = offSetValue - containerHeight / 2;
lrcListDom.style.transform = `translateY(-${step}px)`;
} else {
lrcListDom.style.transform = `translateY(0px)`;
}
}
3.5、歌词高亮
当完成上述步骤之后,歌词高亮就简单了很多,无非就是给当前正在唱的歌词加一个样式active即可。这一操作在getCurDom
这个方法中已经完成了。
js
dom.classList.add('active')
lastShowDiv.classList.remove('active');
css
.active {
scale: 1.2;
color: #fff;
transition: color 1s ease, scale 2s ease;
}
3.6、点击歌词右侧的播放按钮,可跳转到指定歌词位置
歌词右侧是一个img标签,它最初状态是隐藏的,当我们hover上去的时候它再显示出来。
css
.music-lyric-img {
background-image: url('./play.png');
background-size: cover;
cursor: pointer;
display: inline-block;
visibility: hidden;
width: 20px;
height: 20px;
transform: translate(30px,4px);
transition: scale 0.4s ease;
transform-origin: center;
}
.music-lyric-img:hover {
scale: 1.1;
}
.music-lyric:hover .music-lyric-img{
visibility: visible;
}
我们在最初创建元素内容的时候,即可监听img标签的点击事件。
js
function createImgElement() {
let img = document.createElement('img');
img.src = './play.png';
img.classList.add('music-lyric-img')
img.addEventListener('click', function (e) {
setCurrentPlay(e);
})
return img;
}
它的dom层级是这样的,我们需要获取到它的父元素。此时再次获取到这个父元素对应的时间,把之前的操作(歌词内容偏移、高亮)再重新执行一遍即可。
js
function setCurrentPlay (e) {
let curDom = e.target.parentNode;
let className = curDom.classList[1];
let time = className.split('-')[1];
getCurDom(time);
audio.currentTime = time;
}
以上就是歌词滚动效果的实现流程
四、注意点
4.1、为什么要用map存储时间歌词对象
为什么我们的时间歌词对象lrcWithTimeObj
需要用map
,而不是用普通的对象去存,原因在于如果用普通的对象去存,我们就无法获得正确的歌词顺序。因为我的key
是时间,value
是歌词,就算是按照顺序添加进对象,但js会重新按照键值对对象内容进行排序,这里给大家演示下这种问题。
假如这是我存取内容,但是打开浏览器我们会发现,它的内容顺序并不是像我们这个对象种定义的那种顺序。
js
let obj = {
0.06: '我不配',
0.75: '',
1.11: '演唱,周杰伦',
240: '是我忽略 你不过要人陪'
}
console.log(obj);
你可能会说它就是按照顺序的啊,实际情况呢,这个显示是浏览器的一个小bug,也不能说是bug吧,不探讨这个bug;我们需要关注的是红色框中的内容;它明显是240被提到前面了。所以如果采用普通对象去存,那么就会出现,240s这句歌词会在第一行,所以我这里用map
去存的。
当然,这个key没必要非得是时间,只不过我这里用的时间当key,所以遇到了这个问题。
4.2、为什么在拖动歌词的时候将过渡去掉
因为如果不去掉过渡效果,那么在拖动的时候也会有过渡效果,这个看起来就像qq音乐那个歌词滚动那样,拖动效果明显不流畅。
4.3、逐字效果
这个我这里没有实现,我看了网上说,想要实现这个,是需要有对应的歌词文件,这个歌词文件每个字都对应一个时间。呃呃呃,好吧,以后我发现了类似的功能我再去研究一番。
以上就是我的实现部分,大家有兴趣可以去实现下。感谢观看~~~