仿网易云音乐网页前端的歌词画中画技术
在做歌词画中画之前,先简单的实现一下歌词同步播放进度的功能
先新建一个空白HTML模板,放入audio与p标签,用于播放音乐和显示歌词,并准备好音源与歌词数据
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<!-- 音频 -->
<audio id="player" src="晚风.mp3" controls></audio>
<!-- 用于同步显示歌词 -->
<p id="lyric"></p>
<script>
// 歌词Array
let lyrics = [
[2.299, "编曲:July"],
[7.299, "采样:星爷"],
[13.049, "混音:XTONE"],
[15.049, "出品:网易音乐人 X 青云LAB"],
[29, "将近的晚风快吹干整条小巷"],
[31.63, "燃尽的灯光无法再将我们点亮"],
// 此处省略完整的歌词...
[180.338, "那把它吹熄了 不熄"],
[182.199, "熄了可以再点着它"]
];
</script>
</body>
</html> 在script标签内继续实现,根据audio元素播放时间,将对应歌词显示在页面上
// 同步歌词的方法
function UpdateLyric(event) {
// 获得播放进度
let time = event.target.currentTime;
// 从后往前查找符合播放进度的歌词索引
let lrcIndex = lyrics.findLastIndex(line => line[0] < time);
let currentlyric = '';
if (lrcIndex !== -1) { // 前奏不显示
currentlyric = lyrics[lrcIndex][1];
}
// 写到页面上
document.getElementById('lyric').innerText = currentlyric;
}
// 绑定到audio播放进度变化事件
document.getElementById('player').addEventListener('timeupdate', UpdateLyric); 看看效果,随着播放进度变化,能正确显示对应的歌词

歌词同步显示在页面上
这是基本的歌词同步显示效果,接下来在此基础上进行修改,开始做歌词画中画
添加canvas和video和img元素并设置大小,很多Web开发初学者可能对画布元素不熟悉,但这是一个十分实用的元素,接下来得靠他绘制画面并投射到video上面,而image元素的图像将作为背景
不使用CSS样式修改其大小,是因为CSS样式以元素默认大小,经过我们绘制处理完后,再拉伸到目标大小,最终呈现效果会被拉伸,看起来特别奇怪;而直接设置元素的width与height,就是在我们绘制处理前元素大小就确定了,绘制处理起来也更方便
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<!-- 歌曲封面 -->
<img id="lrc-img" src="bg.jpg" alt="" width="200px" height="200px"><br>
<!-- 音频 -->
<audio id="player" src="晚风.mp3" controls></audio><br>
<!-- 画中画切换按钮 -->
<button id="toggle-btn">切换画中画状态</button>
<!-- 显示歌词 -->
<p id="lyric"></p>
<!-- 用于绘制滚动歌词的画布 -->
<canvas id="lrc-canvas" width="500px" height="600px"></canvas>
<!-- 用于实现画中画的视频 -->
<video id="lrc-video" muted autoplay></video>
<script>
// 与之前一样...
</script>
</body>
</html> 在script标签内继续写绘制画布的方法
// 绘制滚动歌词的方法
function UpdateCanvas(currentIndex) {
const canvas = document.getElementById('lrc-canvas');
const ctx = canvas.getContext('2d');
const h = canvas.height;
const w = canvas.width;
const lineHeight = 40;
let x = w / 2;
let y = 40;
// 清空画布重新绘制
ctx.clearRect(0, 0, w, h);
// 水平垂直居中
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 绘制背景(亮度40%, 12px高斯模糊)
ctx.filter = "blur(12px) brightness(40%)";
const img = document.getElementById('lrc-img');
// 裁剪边缘的15px, 去除模糊效果导致的黑边
ctx.drawImage(img, -15, -15, canvas.width + 30, canvas.height + 30);
// 背景调色, 如果需要可以像网易云那样添加一层灰蓝色的叠层
// ctx.fillStyle = "rgb(111,115,120)";
// ctx.filter = "opacity(20%) brightness(130%)";
// ctx.fillRect(0, 0, w, h);
// 在画布的顶部绘制歌名
ctx.filter = "blur(0px) brightness(100%)";
ctx.font = 'bold 24px "Microsoft YaHei"';
ctx.fillStyle = "white";
ctx.fillText("晚风", x, y);
// 绘制歌词
ctx.font = '18px "Microsoft YaHei"';
for (i = currentIndex - 2, y = (h / 2) - (2 * lineHeight); i <= currentIndex + 3; i++) {
// 跳过超出数组索引范围的行
if (i < 0 || i > lyrics.length - 1) {
y += lineHeight;
continue;
}
//控制画布颜色, 高亮当前行对应歌词
ctx.fillStyle = currentIndex !== -1 && currentIndex === i ? "white" : "grey";
//如果需要更细致的灰度控制
//ctx.filter = currentIndex !== -1 && currentIndex === i ? "brightness(100%)" : "brightness(60%)"
ctx.fillText(lyrics[i][1], x, y);
y += lineHeight;
}
} 写完别忘了在UpdateLyric()方法末尾调用它,最终在页面上呈现的效果就跟在网易云音乐看见的效果一样了

Canvas效果
下一步该制作画中画了
来实现画中画按钮的功能
// 绑定画中画视频流
UpdateCanvas(); // 始终在进入页面时绘制一次画布确保视频源可用, 否则浏览器刷新按钮会一直显示为停止按钮
const canvas = document.getElementById('lrc-canvas');
const video = document.getElementById('lrc-video');
const stream = canvas.captureStream(30); // 30 FPS
video.srcObject = stream;
// 切换按钮
function TogglePIP() {
if (document.pictureInPictureElement) { // 退出画中画
document.exitPictureInPicture();
} else { // 进入画中画
if (document.pictureInPictureEnabled) {
video.requestPictureInPicture().catch(err => {
console.log("画中画开启失败");
})
} else {
console.log("画中画模式不受支持");
}
}
}
// 绑定按钮事件
document.getElementById('toggle-btn').addEventListener('click', TogglePIP); 视频默认是暂停的,上面为video标签添加muted和autoplay属性是为了确保视频能够自动播放;作为背景的图像如果跨域了,captureStream()方法会失败
其中document.pictureInPictureEnabled属性表示浏览器是否支持画中画API,例如Chrome与Edge就支持,而Firefox与WebView就不支持
按下按钮来试试效果吧

画中画效果
画中画效果就是这样了,成功在桌面上显示了歌词,然后我们用CSS在页面上隐藏canvas与video元素,如果歌曲封面对应的img元素不想要的话,也可以隐藏
因为video元素在上下滚动出页面可视范围外会停止渲染,导致画中画冻结,所以可以使用绝对定位将其固定在页面左上角,z-index调为负值防止遮挡页面其他元素,再将其透明度设为0以实现隐藏
video {
position: fixed;
top: 10px;
left: 10px;
z-index: -1;
opacity: 0;
}
canvas {
display: none;
} 来看看隐藏后的效果

最终效果
做出来的歌词没有变化过渡效果,如果要加入过渡动画的话,得自己重写绘制画布的逻辑,可以将事件触发从timeupdate换成window.requestAnimationFrame()方法,后者会根据屏幕刷新率调用,从而绘制更细腻的过渡效果。
画中画窗口也是能添加按钮的,如果想添加暂停,上一首,下一首按钮并支持耳机线控之类的功能,可以通过实现Media Seeion API来实现
最后附上完整的源码
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>PIP Demo</title>
<style>
video {
position: fixed;
top: 10px;
left: 10px;
z-index: -1;
opacity: 0;
}
canvas {
display: none;
}
</style>
</head>
<body>
<!-- 歌曲封面 -->
<img id="lrc-img" src="bg.jpg" alt="" width="200px" height="200px"><br>
<!-- 音频 -->
<audio id="player" src="晚风.mp3" controls></audio><br>
<!-- 画中画切换按钮 -->
<button id="toggle-btn">切换画中画状态</button>
<!-- 显示歌词 -->
<p id="lyric"></p>
<!-- 用于绘制滚动歌词的画布 -->
<canvas id="lrc-canvas" width="300px" height="360px"></canvas>
<!-- 用于实现画中画的视频 -->
<video id="lrc-video" muted autoplay></video>
<script>
// 歌词Array
let lyrics = [
[2.299, "编曲:July"],
[7.299, "采样:星爷"],
[13.049, "混音:XTONE"],
[15.049, "出品:网易音乐人 X 青云LAB"],
[29, "将近的晚风快吹干整条小巷"],
[31.63, "燃尽的灯光无法再将我们点亮"],
[34.43, "要明白有些事情不能总靠想象"],
[37.07, "大不了今晚继续喝到明天早上"],
[39.26, "看不到希望的人还在自言自语说着"],
[42.17, "抱怨得不到温暖的人还在继续作着"],
[44.88, "他对着电话那头说着自己过得很好"],
[47.43, "挂断之后他又在被子里面偷偷哭了"],
[50.02, "不存在一帆风顺 偶尔也会去疯混"],
[52.76, "尽管有好多人讨论着你是什么门货"],
[55.41, "慢慢也有了分寸 不屑于那些争论"],
[58.18, "所以我大多数的时候都会选择沉默"],
[60.768, "付出的一切难免最后都会化为落尘"],
[63.268, "就算整个世界空绝就剩我独自一人"],
[66.099, "感觉到肩上重了 心里又再次痛了"],
[68.658, "把委屈偷偷收好"],
[69.95, "索性忘掉那些过程"],
[72.378, "一个人漫无目的 迷失在晚风里"],
[77.789, "我知道后会无期 但也不能逃避"],
[83.048, "迷惘的日子里 感谢能遇见你"],
[88.22, "究竟还要经历多少荆棘"],
[90.73, "也许该为我的选择庆幸"],
[92.84, "人活着到底为了什么东西"],
[94.36, "忙碌可能为了一张到老"],
[96.018, "也不够还清的账单"],
[97.97999999999999, "我每天碌碌无为"],
[99.179, "却又感觉活的累"],
[100.268, "总有人劝我别**追梦了"],
[102.14, "快去上班"],
[103.399, "混到头来不过为了一个名称"],
[106, "你喜欢的东西总有许多人争"],
[108.34, "他们随便动动手指就能得到的"],
[110.619, "你却要付出百倍努力"],
[112.22999999999999, "还要赌上你的人生"],
[113.649, "或许被打到鼻青脸肿"],
[115.17, "也不敢还手吧"],
[116.628, "你知道后果"],
[117.45, "没有人可以来为你承担"],
[119.369, "吃完泡面会撒谎自己的钱还有"],
[122.118, "一半是不想被担心一半怕再难堪"],
[124.908, "我没有他们那么出众"],
[126.509, "没有开豪车的叔送"],
[127.959, "就这样其实我也无所谓"],
[130.179, "不就是被骂了两句"],
[131.788, "并不值得你去选择放弃"],
[133.498, "听完这首歌就早点睡"],
[136.318, "一个人漫无目的 迷失在晚风里"],
[141.389, "我知道后会无期 但也不能逃避"],
[146.848, "迷惘的日子里 感谢能遇见你"],
[152.06799999999998, "究竟还要经历多少荆棘"],
[154.66899999999998, "也许该为我的选择庆幸"],
[156.96, "你没做怎么知道没搞头"],
[158.258, "你不要做了"],
[159.938, "我劝你要脚踏实地的做人嘛"],
[162.278, "这里有份洗厕所的工作你先做着"],
[164.788, "你就别做梦了"],
[166.71800000000002, "做人如果没梦想"],
[167.929, "那跟咸鱼有什么分别啊"],
[170.028, "你连鞋都没有"],
[171.16, "那不就是咸鱼一条喽"],
[172.929, "还学人家讲理想"],
[174.899, "话不能这么讲"],
[176.49, "我心中的一团火是不会熄的"],
[180.338, "那把它吹熄了 不熄"],
[182.199, "熄了可以再点着它"]
];
// 同步歌词的方法
function UpdateLyric(event) {
// 获得播放进度
let time = event.target.currentTime;
// 从后往前查找符合播放进度的歌词索引
let lrcIndex = lyrics.findLastIndex(line => line[0] < time);
let currentlyric = '';
if (lrcIndex !== -1) { // 前奏不显示
currentlyric = lyrics[lrcIndex][1];
}
// 写到页面上
document.getElementById('lyric').innerText = currentlyric;
UpdateCanvas(lrcIndex);
}
// 绑定到audio播放进度变化事件
document.getElementById('player').addEventListener('timeupdate', UpdateLyric);
// 绘制滚动歌词的方法
function UpdateCanvas(currentIndex) {
const canvas = document.getElementById('lrc-canvas');
const ctx = canvas.getContext('2d');
const h = canvas.height;
const w = canvas.width;
const lineHeight = 40;
let x = w / 2;
let y = 40;
// 清空画布重新绘制
ctx.clearRect(0, 0, w, h);
// 水平垂直居中
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 绘制背景(亮度40%, 12px高斯模糊)
ctx.filter = "blur(12px) brightness(40%)";
const img = document.getElementById('lrc-img');
// 裁剪边缘的15px, 去除模糊效果导致的黑边
ctx.drawImage(img, -15, -15, canvas.width + 30, canvas.height + 30);
// 在画布的顶部绘制歌名
ctx.filter = "blur(0px) brightness(100%)";
ctx.font = 'bold 24px "Microsoft YaHei"';
ctx.fillStyle = "white";
ctx.fillText("晚风", x, y);
// 绘制歌词
ctx.font = '18px "Microsoft YaHei"';
for (i = currentIndex - 2, y = (h / 2) - (2 * lineHeight); i <= currentIndex + 3; i++) {
// 跳过超出数组索引范围的行
if (i < 0 || i > lyrics.length - 1) {
y += lineHeight;
continue;
}
//控制画布颜色, 高亮当前行对应歌词
ctx.fillStyle = currentIndex !== -1 && currentIndex === i ? "white" : "grey";
ctx.fillText(lyrics[i][1], x, y);
y += lineHeight;
}
}
// 绑定画中画视频流
UpdateCanvas();
const canvas = document.getElementById('lrc-canvas');
const video = document.getElementById('lrc-video');
const stream = canvas.captureStream(30); // 30 FPS
video.srcObject = stream;
// 切换按钮
function TogglePIP() {
if (document.pictureInPictureElement) { // 退出画中画
document.exitPictureInPicture();
} else { // 进入画中画
if (document.pictureInPictureEnabled) {
video.requestPictureInPicture().catch(err => {
console.log("画中画开启失败");
})
} else {
console.log("画中画模式不受支持");
}
}
}
// 绑定按钮事件
document.getElementById('toggle-btn').addEventListener('click', TogglePIP);
</script>
</body>
</html>