【经典动效】滚动歌词之《罗刹海市》

近期浏览了一堂公开课讲的《滚动歌词》的经典动效案例,这里我将通过文字按我的开发思路和顺序来进行讲述和实现。

  1. 实现静态页面
  2. 验证滚动方案
  3. 解析歌词数据
  4. 插入歌词元素
  5. 实现滚动方案
  6. 偏移公式讲解

实现静态页面

首先在htmlbody部分增加一个container,用来包裹由每一行歌词文本组成的ul无序列表:

html 复制代码
<body>
    <div class="container">
        <ul>
            <li>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Dicta, doloribus.</li>
            <li>Nulla, porro nisi quisquam sunt laudantium fugiat odit quas ipsa!</li>
            <li>Debitis, quibusdam in illo perferendis voluptates beatae ratione? Beatae, nisi?</li>
        </ul>
    </div>
</body>

接着对页面做一些样式调整:

  1. 容器样式调整:重置 marginpadding,设置 body 背景色、文本颜色及对齐方式,设置 .container 默认高度并禁止内容溢出(歌词的无序列表要在此容器中进行滚动展示);
css 复制代码
* {
    margin: 0;
    padding: 0;
}

body {
    background: #000;
    color: #666;
    text-align: center;
}

.container {
    height: 300px;
    overflow: hidden;
}
  1. 歌词无序列表初始化位置:默认将歌词无序列表的位置移动到 .container 容器垂直居中,也就是 容器高度/2 - 每行歌词高度/2 = 135px 的位置,在进行移动时优先使用 transform 进行非几何属性的变化,避免回流(reflow)频繁发生;
css 复制代码
.container ul {
    transform: translateY(135px);
}
  1. 每行歌词的样式调整:设置每行歌词的高度,并给定一个与高度一致的 line-height,让歌词文本居中显示;还会增加一个 active 样式类,用来高亮并放大显示当前播放的歌词;
css 复制代码
.container li {
    height: 30px;
    line-height: 30px;
}

.container li.active {
    color: #ccc;
    transform: scale(1.2);
}

增加过渡效果:

歌词无序列表的滚动和当前播放歌词的放大目前都是生硬的,需要为它们增加一些过渡效果,这样会顺滑一些。这里会用到 transition 属性,可以指定要生效的 property nameduration

  1. 歌词无序列表滚动:
css 复制代码
.container ul {
    transform: translateY(135px);
    transition: transform 0.5s;
}
  1. 当前播放歌词放大:
css 复制代码
.container li {
    height: 30px;
    line-height: 30px;
    transition: transform 0.5s;
}

注:这部分内容仅为静态内容,页面样式及动效通过控制台修改参数来进行简单验证。

验证滚动方案

音频播放需要用到 audio 标签,所以首先要在页面中插入一个 audio 标签;

html 复制代码
<body>
    <audio controls src="./assets/2177806392.mp3"></audio>
</body>

audio 在对音频进行播放期间当 currentTime 更新时会触发 timeupdate 事件,也就是说,我们可以通过监听 timeupdate 事件来获取当前播放的位置(时间)currentTime(单位:秒)。

JavaScript 复制代码
const audio = document.querySelector("audio");
audio.addEventListener("timeupdate", () =>
    console.log(`当前播放到 ${audio.currentTime} 秒`)
);

播放的时间搞定后,就需要与歌词文件进行匹配,歌词文件选择包含时间的 lrc 文件,在 lrc 文件中每一行为一组包含了时间和歌词的数据,时间是由 [分:秒] 组成的,需要将时间进行一定的转换后在与 audocurrentTime 进行匹配;

JavaScript 复制代码
function paresTime() {
    const times = timeStr && timeStr.split(":");
    if (times && times.length === 2) {
        const minute = times[0];
        const second = times[1];
        return +minute * 60 + +second;
    }
    return 0;
}

解析歌词数据

通过 fetch 函数加载Lrc歌词文件并将歌词数据对象化处理:会通过 splitslicemap 进行处理;

JavaScript 复制代码
async function getLrcData() {
    const data = await fetch("./assets/罗刹海市.lrc").then((response) =>
        response.text()
    );
    return data.split("\n").map((line) => {
        return {
            time: paresTime(line.split("]")[0].slice(1)),
            words: line.split("]")[1],
        };
    });
}

编写一个自执行函数来运行获取歌词;

JavaScript 复制代码
(async ()=>{
    const data = await getLrcData();
    console.log(data);
})()

插入歌词元素

创建一个 DocumentFragment,接收遍历歌词数据时创建了 li元素,在结束遍历后统一添加到 ul 无序列表中;

JavaScript 复制代码
function genLrcFragment(data) {
    const fragment = document.createDocumentFragment();
    data.forEach(line => {
        const li = document.createElement('li');
        li.textContent = line.words;
        fragment.appendChild(li);
    });
    return fragment;
}

const fragment = genLrcFragment(data);
document.querySelector('.container ul').appendChild(fragment);

实现滚动方案

  1. 确定当前播放音乐对应歌词在歌词数据中的下标位置:如当前播放时间为 14 秒,那么当前高亮的应该就是 14.36 秒前的一句歌词;但当前播放时间大于 04:32.3 秒(完整音乐播放到05:32秒)时,始终返回歌词数据的最后一个下标位置;
JavaScript 复制代码
const index = data.findIndex((line) => currentTime < line.time);
const highlightIndex = index != -1 ? index - 1 : data.length - 1;
  1. 获取歌词需要偏移(移动)的距离:某下标[x]的歌词位置 = 容器高度/2 - 每行歌词高度/2 - 每行歌词高度*[x];
JavaScript 复制代码
let containerHeight = 0;
let liHeight = 0;
let uldom = null;

if (!containerHeight) {
    containerHeight = document.querySelector('.container').clientHeight;
}
if (!liHeight) {
    liHeight = document.querySelector('.container ul').children[0].clientHeight;
}
// 300/2 - 30*1 - 30/2 = 105px
const offset = containerHeight / 2 - liHeight * highlightIndex - liHeight / 2;
if (!uldom) {
    uldom = document.querySelector('.container ul');
}
uldom.style.transform = `translateY(${offset}px)`;
  1. 处理高亮歌词:每次高亮新的歌词之前要移除已高亮部分;
JavaScript 复制代码
function highlight(data, currentTime) {
    // 移除高亮
    const active = document.querySelector(".active");
    active && active.classList.remove("active");

    const index = data.findIndex((line) => currentTime < line.time);
    const highlightIndex = index != -1 ? index - 1 : data.length - 1;
    if (!containerHeight) {
        containerHeight = document.querySelector('.container').clientHeight;
    }
    if (!liHeight) {
        liHeight = document.querySelector('.container ul').children[0].clientHeight;
    }
    const offset = containerHeight / 2 - liHeight * highlightIndex - liHeight / 2;
    if (!uldom) {
        uldom = document.querySelector('.container ul');
    }
    // 添加新歌词的高亮
    uldom.children[highlightIndex].classList.add("active");
    uldom.style.transform = `translateY(${offset}px)`;
}

偏移公式讲解

公式:某下标[x]的歌词位置 = 容器高度/2 - 每行歌词高度/2 - 每行歌词高度*[x];

我们默认的容器高度为 300px,一半的容器高度就是 150pxul 直接偏移 容器高度/2 后其实并非容器的正中间,而是多偏移了 15px,也就是每行歌词一半的距离,所以需要减去 每行高度/2 的一个距离,剩下的在图中也可以看的出来,下标为 1 的时候 每行歌词高度*1,下标为 2 的时候 每行歌词高度*2。最后实际的偏移高度就如图和公式所示进行相减获得。

总结

在实现案例之前,首先要做的就是熟悉歌词数据,搞清楚歌词数据中包含有哪些内容,同时要结合 audio 标签提供的事件进行监听并获取到实时的播放时间,能相互匹配确认可行后才能着手开发。


如果看完觉得有收获,欢迎点赞、评论、分享支持一下。你的支持和肯定,是我坚持写作的动力~

相关推荐
袁煦丞33 分钟前
【局域网秒传神器】LocalSend:cpolar内网穿透实验室第418个成功挑战
前端·程序员·远程工作
江城开朗的豌豆34 分钟前
Vuex数据突然消失?六招教你轻松找回来!
前端·javascript·vue.js
好奇心笔记44 分钟前
ai写代码随机拉大的,所以我准备给AI出一个设计规范
前端·javascript
江城开朗的豌豆44 分钟前
Vue状态管理进阶:数据到底是怎么"跑"的?
前端·javascript·vue.js
用户21411832636021 小时前
dify案例分享-Dify v1.6.0 重磅升级:双向 MCP 协议引爆 AI 生态互联革命
前端
程序员海军1 小时前
AI领域又新增协议: AG-UI
前端·openai·agent
我想说一句1 小时前
React待办事项开发记:Hook魔法与组件间的悄悄话
前端·javascript·前端框架
真夜1 小时前
CommonJS与ESM
前端·javascript
LaoZhangAI1 小时前
GPT-image-1 API如何传多图:开发者完全指南
前端·后端
G等你下课1 小时前
从点击到执行:如何优雅地控制高频事件触发频率
前端·javascript·面试