网页播放器歌词画中画实现(仿网易云音乐)
qwe5283
2024年12月21日 19:01

仿网易云音乐网页前端的歌词画中画技术


在做歌词画中画之前,先简单的实现一下歌词同步播放进度的功能

先新建一个空白HTML模板,放入audio与p标签,用于播放音乐和显示歌词,并准备好音源与歌词数据

代码块
HTML
自动换行
复制代码
<!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元素播放时间,将对应歌词显示在页面上

代码块
JavaScript
自动换行
复制代码
// 同步歌词的方法
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,就是在我们绘制处理前元素大小就确定了,绘制处理起来也更方便

代码块
HTML
自动换行
复制代码
<!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标签内继续写绘制画布的方法

代码块
JavaScript
自动换行
复制代码
// 绘制滚动歌词的方法
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效果

下一步该制作画中画了


来实现画中画按钮的功能

代码块
JavaScript
自动换行
复制代码
// 绑定画中画视频流
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以实现隐藏

代码块
CSS
自动换行
复制代码
video {
  position: fixed;
  top: 10px;
  left: 10px;
  z-index: -1;
  opacity: 0;
}

canvas {
  display: none;
}
复制成功

来看看隐藏后的效果

最终效果

做出来的歌词没有变化过渡效果,如果要加入过渡动画的话,得自己重写绘制画布的逻辑,可以将事件触发从timeupdate换成window.requestAnimationFrame()方法,后者会根据屏幕刷新率调用,从而绘制更细腻的过渡效果。

画中画窗口也是能添加按钮的,如果想添加暂停,上一首,下一首按钮并支持耳机线控之类的功能,可以通过实现Media Seeion API来实现


最后附上完整的源码

代码块
HTML
自动换行
复制代码
<!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>
复制成功