实现一个歌词滚动效果

一、简介

最近正好学到一个歌词滚动的课程,类似于QQ音乐那样,就想着自己去实现一番。同时我看了QQ音乐的那个播放界面,存在两个不足的点:一是不能跳转到指定歌词位置;二是在歌词那里拖动时有明显的卡顿。所以本着学习和改进的目的,自己去实现一下。

二、效果

直接看效果,可能并不是特别特别的美观,当然,本文主要的侧重点在各个功能的实现上。

三、思路

从功能角度来讲,大概步骤如下:

  1. 准备数据。
  2. 展示歌词。
  3. 通过拖动歌词内容,实现歌词的滚动浏览。
  4. 进度条控制歌词显示位置。
  5. 歌词高亮。
  6. 点击歌词右侧的播放按钮,可跳转到指定歌词位置。

接下来是具体的实现细节:

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,二者相减就可以算出需要移动多少。如下图,我分别打印了moveStartYe.clientYe.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、逐字效果

这个我这里没有实现,我看了网上说,想要实现这个,是需要有对应的歌词文件,这个歌词文件每个字都对应一个时间。呃呃呃,好吧,以后我发现了类似的功能我再去研究一番。


以上就是我的实现部分,大家有兴趣可以去实现下。感谢观看~~~

仓库地址

相关推荐
m0_7482561421 分钟前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小马哥编程1 小时前
Function.prototype和Object.prototype 的区别
javascript
小白学前端6661 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react
web130933203982 小时前
前端下载后端文件流,文件可以下载,但是打不开,显示“文件已损坏”的问题分析与解决方案
前端
王小王和他的小伙伴2 小时前
解决 vue3 中 echarts图表在el-dialog中显示问题
javascript·vue.js·echarts
学前端的小朱2 小时前
处理字体图标、js、html及其他资源
开发语言·javascript·webpack·html·打包工具
outstanding木槿2 小时前
react+antd的Table组件编辑单元格
前端·javascript·react.js·前端框架
好名字08213 小时前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
摇光933 小时前
js高阶-async与事件循环
开发语言·javascript·事件循环·宏任务·微任务
隐形喷火龙3 小时前
element ui--下拉根据拼音首字母过滤
前端·vue.js·ui