马黑黑 发表于 2022-8-26 12:53

带进度条+歌词同步的canvas画布音频播放器

带进度条+歌词同步的canvas画布音频播放器 | 马黑黑

做童安格的《耶利亚女郎》帖子时,一时兴起用canvas画布做了一个进度条加歌词同步的音频播放器,感觉基本功能、外观都还可以,就是可定制性不够强。想想还是认为将播放器作为一个实例对象加以操作,使用时方能拥有更方便的自定义设定。

实例化对象适合做音频播放器,属性、方法可以无序化存在,需要什么不需要什么可以随意增删,无需因为一个属性或方法的变化四处去找寻和修改关联的代码语句,基于对象的变动所涉及修改范围基本就在对象代码块之内。

所以,建立了一个 mplayer 对象,它是实例化的,因为无需克隆:

let mplayer = {
        属性1: 值,
        属性2: 值,
        属性N: 值,
        方法1: function() { ... },
        方法2: function() { ... },
        方法M: function() { ... },
}

每一个属性和方法都是以键值对的形式存在,每一个属性和方法之间用逗号隔开。分行不是必须的,分行只是为了方便组织和阅读代码。

我们的 mplayer 对象的最终任务是描述播放器界面及其行为。实现手段是,对象给出各种组成播放器所需的“零件”即属性并提供这些零件的组装方法。比方说,播放器的按钮是个圆形的小东东,它上面还有一个表示播放的三角形图案或是表示暂停的两道杠,圆形、三角形、两道杠这些部件,通过对象的方法实现,而圆形等的尺寸、位置以及颜色啥的,是对象提供的“零件”即属性。具体说,圆形的按钮,圆心xy坐标及其半径,还有它要上什么颜色,都是 mplayer 对象以键值对的形式一一提供的属性,而圆形按钮画在哪、怎么画,则是通过 mplayer 对象提供的方法去完成。mplayer 提供的所有属性与方法,都是基于一个播放器的基本样式和功能,换言之,我们把实现一个播放器所需的“零件”和“组装”方法全部封装在了一个叫做 mplayer 的对象里。

对象的方法其实就是函数,函数是可以接收“第三方”提供的参数的。例如,mplayer 要绘制出歌词,它并不知道歌词是什么,这时,就由 audio 控件的播放进度机制来处理歌词,并一句一句地传递给 mplayer 对象,mplayer 则将歌词给绘制出来。就是说,针对lrc同步,mplayer 只管处理一个歌词变量 text,text 的内容传递过来了它就绘制这些文本。

具体实现过程请在播放器源码中体会:
<canvas id="player" style="position: absolute; left: 10px; top: 10px; border: 1px solid;"></canvas>

<script>
let ctx = player.getContext('2d'),
        w = player.width = 350,
        h = player.height = 100,
        flag, //鼠标滑过按钮标识
       aud = new Audio();
let mplayer = {
        startX: 30, //圆形按钮圆心x
        startY: 70, //圆形按钮圆心y
        radius: 16, //圆形按钮半径
        c_lrc: 'teal', //lrc歌词颜色
        c_time: 'teal', //时间信息颜色
        c_track: '#eee', //进度轨线颜色
        c_prg: 'red', //进度条颜色
        c_circle: 'lightblue', //圆圈颜色
        c_btn: 'snow', //按钮标识颜色
        c_btnhover: 'pink', //按钮标识鼠标滑过颜色
        lineLen: 200, //进度条长度
        drawLrc: function(text) { //lrc同步
                ctx.clearRect(0, 0, w, 50);
                ctx.fillStyle = this.c_lrc;
                ctx.textAlign = 'center';
                ctx.beginPath();
                ctx.font = '1.2em sans-serif';
                ctx.fillText(text, w/2, 30);
                ctx.fill();
        },
        drawProgress: function(prog, text) { //进度
                ctx.clearRect(this.startX + this.radius, this.startY - 10, w - this.startX + this.radius, 40);
                ctx.beginPath();
                ctx.font = '14px sans-serif';
                ctx.textBaseline = 'middle';
                ctx.strokeStyle = this.c_track;
                ctx.moveTo(this.startX + this.radius + 4, this.startY);
                ctx.lineTo(this.startX + this.radius + 4 + this.lineLen, this.startY); //底线
                ctx.stroke();
                ctx.beginPath();
                ctx.strokeStyle = this.c_prg;
                ctx.moveTo(this.startX + this.radius + 4, this.startY);
                ctx.lineTo(this.startX + this.radius + 4 + prog, this.startY); //进度线
                ctx.stroke();
                ctx.fillStyle = this.c_time;
                ctx.textAlign = 'left';
                ctx.fillText(text, this.startX + this.radius + 8 + this.lineLen, this.startY); //数字进度
                ctx.fill();
        },
        drawBtn: function(id) { //绘制播放+暂停按钮
                ctx.clearRect(this.startX - this.radius, this.startY - this.radius, this.radius *2, this.radius*2);
                ctx.fillStyle = this.c_circle;
                ctx.beginPath();
                ctx.arc(this.startX, this.startY, this.radius, 0, 2*Math.PI);
                ctx.fill();
                ctx.fillStyle = flag ? this.c_btnhover : this.c_btn;
                ctx.beginPath();
                if (id) { //播放图标
                        ctx.moveTo(this.startX - this.radius / 2 + 1, this.startY - this.radius / 2);
                        ctx.lineTo(this.startX - this.radius / 2 + 1, this.startY + this.radius / 2);
                        ctx.lineTo(this.startX + this.radius / 2 + 1, this.startY);
                        ctx.fill();
                } else { //暂停图标
                        ctx.fillRect(this.startX - this.radius / 2 + 5, this.startY - this.radius / 2, 2, this.radius);
                        ctx.fillRect(this.startX - this.radius / 2 + 10, this.startY - this.radius / 2, 2, this.radius);
                }
        },
        isHover: function (x, y) {
                return Math.pow(x - this.startX, 2) + Math.pow(y - this.startY, 2) <= Math.pow(this.radius, 2);
        },
}
let lrcAr = [
        ['0.01','童安格耶利亚女郎'],
        ['34.10','很远的地方有个女郎名字叫做耶利亚'],
        ['42.33','有人在传说她的眼睛看了使人更年轻'],
        ['50.62','如果你得到她的拥抱你就永远不会老'],
        ['59.15','为了这个神奇的传说我要努力去寻找'],
        ['66.42','耶利亚神秘耶利亚耶利耶利亚'],
        ['74.77','耶利亚神秘耶利亚我一定要找到她'],
        ['101.12','很远的地方有个女郎名字叫做耶利亚'],
        ['109.32','有人在传说她的眼睛看了使人更年轻'],
        ['117.90','如果你得到她的拥抱你就永远不会老'],
        ['126.22','为了这个神奇的传说我要努力去寻找'],
        ['133.57','耶利亚神秘耶利亚耶利耶利亚'],
        ['141.90','耶利亚神秘耶利亚我一定要找到她'],
        ['150.29','耶利亚神秘耶利亚耶利耶利亚'],
        ['158.76','耶利亚神秘耶利亚我一定要找到她'],
        ['198.70','耶利亚神秘耶利亚耶利耶利亚'],
        ['207.11','耶利亚神秘耶利亚我一定要找到她'],
        ['215.28','耶利亚神秘耶利亚耶利耶利亚'],
        ['223.72','耶利亚神秘耶利亚我一定要找到她']
];
//audio控件设置
aud.src = '耶利亚女郎.mp3';
aud.autoplay = true;
aud.loop = true;
//初始化按钮
mplayer.drawBtn(aud.paused);
mplayer.drawProgress(1, '00:00 | 00:00');
mplayer.drawLrc('等待播放 ...');
//监听鼠标经过
player.addEventListener('mousemove', (e) => {
        flag = mplayer.isHover(e.offsetX, e.offsetY);
        if (flag) {
                player.style.cursor = 'pointer';
                mplayer.drawBtn(aud.paused);
        } else {
                player.style.cursor = 'default';
                mplayer.drawBtn(aud.paused);
        }
});
player.addEventListener('click', (e) => { if(flag) aud.paused ? aud.play() : aud.pause(); }); //画布点击事件
aud.addEventListener('playing', ()=> mplayer.drawBtn(false)); //监听播放状态
aud.addEventListener('pause', ()=> mplayer.drawBtn(true)); //监听暂停状态
//进度监听
aud.addEventListener('timeupdate', () => {
        let prg = mplayer.lineLen * aud.currentTime / aud.duration, tMsg = toMin(aud.duration) + ' | ' + toMin(aud.currentTime);
        mplayer.drawProgress(prg, tMsg);
        for(j = 0; j < lrcAr.length;j ++) {
                if(aud.currentTime >= lrcAr) mplayer.drawLrc(lrcAr);
        }
});
//格式化时间信息 → 00:00
function toMin(val) {
        if (!val) return '00:00';
        val = Math.floor(val);
        let min = parseInt(val / 60);
        let sec = parseFloat(val % 60);
        if(min < 10) min = '0' + min;
        if(sec < 10) sec = '0' + sec;
        return min + ':' + sec;
}
</script>播放器演示在二楼。

马黑黑 发表于 2022-8-26 12:55

本帖最后由 马黑黑 于 2022-8-26 12:57 编辑 <br /><br /><canvas id="player" style="border: 1px solid;"></canvas>

<script>
let ctx = player.getContext('2d'),
        w = player.width = 350,
        h = player.height = 100,
        flag,
       aud = new Audio();
let mplayer = {
        startX: 30,
        startY: 70,
        radius: 16,
        c_lrc: 'teal',
        c_time: 'teal',
        c_track: '#eee',
        c_prg: 'red',
        c_circle: 'lightblue',
        c_btn: 'snow',
        c_btnhover: 'pink',
        lineLen: 200,
        drawLrc: function(text) {
                ctx.clearRect(0, 0, w, 50);
                ctx.fillStyle = this.c_lrc;
                ctx.textAlign = 'center';
                ctx.beginPath();
                ctx.font = '1.2em sans-serif';
                ctx.fillText(text, w/2, 30);
                ctx.fill();
        },
        drawProgress: function(prog, text) {
                ctx.clearRect(this.startX + this.radius, this.startY - 10, w - this.startX + this.radius, 40);
                ctx.beginPath();
                ctx.font = '14px sans-serif';
                ctx.textBaseline = 'middle';
                ctx.strokeStyle = this.c_track;
                ctx.moveTo(this.startX + this.radius + 4, this.startY);
                ctx.lineTo(this.startX + this.radius + 4 + this.lineLen, this.startY);
                ctx.stroke();
                ctx.beginPath();
                ctx.strokeStyle = this.c_prg;
                ctx.moveTo(this.startX + this.radius + 4, this.startY);
                ctx.lineTo(this.startX + this.radius + 4 + prog, this.startY);
                ctx.stroke();
                ctx.fillStyle = this.c_time;
                ctx.textAlign = 'left';
                ctx.fillText(text, this.startX + this.radius + 8 + this.lineLen, this.startY);
                ctx.fill();
        },
        drawBtn: function(id) { //绘制播放+暂停按钮
                ctx.clearRect(this.startX - this.radius, this.startY - this.radius, this.radius *2, this.radius*2);
                ctx.fillStyle = this.c_circle;
                ctx.beginPath();
                ctx.arc(this.startX, this.startY, this.radius, 0, 2*Math.PI);
                ctx.fill();
                ctx.fillStyle = flag ? this.c_btnhover : this.c_btn;
                ctx.beginPath();
                if (id) {
                        ctx.moveTo(this.startX - this.radius / 2 + 1, this.startY - this.radius / 2);
                        ctx.lineTo(this.startX - this.radius / 2 + 1, this.startY + this.radius / 2);
                        ctx.lineTo(this.startX + this.radius / 2 + 1, this.startY);
                        ctx.fill();
                } else {
                        ctx.fillRect(this.startX - this.radius / 2 + 5, this.startY - this.radius / 2, 2, this.radius);
                        ctx.fillRect(this.startX - this.radius / 2 + 10, this.startY - this.radius / 2, 2, this.radius);
                }
        },
        isHover: function (x, y) {
                return Math.pow(x - this.startX, 2) + Math.pow(y - this.startY, 2) <= Math.pow(this.radius, 2);
        },
}
let lrcAr = [
        ['0.01','童安格耶利亚女郎'],
        ['34.10','很远的地方有个女郎名字叫做耶利亚'],
        ['42.33','有人在传说她的眼睛看了使人更年轻'],
        ['50.62','如果你得到她的拥抱你就永远不会老'],
        ['59.15','为了这个神奇的传说我要努力去寻找'],
        ['66.42','耶利亚神秘耶利亚耶利耶利亚'],
        ['74.77','耶利亚神秘耶利亚我一定要找到她'],
        ['101.12','很远的地方有个女郎名字叫做耶利亚'],
        ['109.32','有人在传说她的眼睛看了使人更年轻'],
        ['117.90','如果你得到她的拥抱你就永远不会老'],
        ['126.22','为了这个神奇的传说我要努力去寻找'],
        ['133.57','耶利亚神秘耶利亚耶利耶利亚'],
        ['141.90','耶利亚神秘耶利亚我一定要找到她'],
        ['150.29','耶利亚神秘耶利亚耶利耶利亚'],
        ['158.76','耶利亚神秘耶利亚我一定要找到她'],
        ['198.70','耶利亚神秘耶利亚耶利耶利亚'],
        ['207.11','耶利亚神秘耶利亚我一定要找到她'],
        ['215.28','耶利亚神秘耶利亚耶利耶利亚'],
        ['223.72','耶利亚神秘耶利亚我一定要找到她']
];

aud.src = 'https://music.163.com/song/media/outer/url?id=150852.mp3';
aud.autoplay = true;
aud.loop = true;

mplayer.drawBtn(aud.paused);
mplayer.drawProgress(1, '00:00 | 00:00');
mplayer.drawLrc('等待播放 ...');

player.addEventListener('mousemove', (e) => {
        flag = mplayer.isHover(e.offsetX, e.offsetY);
        if (flag) {
                player.style.cursor = 'pointer';
                mplayer.drawBtn(aud.paused);
        } else {
                player.style.cursor = 'default';
                mplayer.drawBtn(aud.paused);
        }
});
player.addEventListener('click', (e) => { if(flag) aud.paused ? aud.play() : aud.pause(); });
aud.addEventListener('playing', ()=> mplayer.drawBtn(false));
aud.addEventListener('pause', ()=> mplayer.drawBtn(true));

aud.addEventListener('timeupdate', () => {
        let prg = mplayer.lineLen * aud.currentTime / aud.duration, tMsg = toMin(aud.duration) + ' | ' + toMin(aud.currentTime);
        mplayer.drawProgress(prg, tMsg);
        for(j = 0; j < lrcAr.length;j ++) {
                if(aud.currentTime >= lrcAr) mplayer.drawLrc(lrcAr);
        }
});

function toMin(val) {
        if (!val) return '00:00';
        val = Math.floor(val);
        let min = parseInt(val / 60);
        let sec = parseFloat(val % 60);
        if(min < 10) min = '0' + min;
        if(sec < 10) sec = '0' + sec;
        return min + ':' + sec;
}
</script>

马黑黑 发表于 2022-8-26 13:11

播放器总共有四个地方需要反复重绘:

一、lrc歌词

由audio控件的timeupdate监听事件驱动,每一句歌词,按currentTime步进刷新,每次刷新先区域性擦除再重绘。

二和三、进度条和播放数字信息

这两样何在一处处理,也是由 timeupdate 事件驱动,每次刷新所擦除的区域从圆形按钮之后到画布的最右边,擦除区域的高度在行内。

四、播放暂停按钮

有鼠标指针在画布上的移动事件驱动,每次刷新的擦除区域圆形自身区域。

小辣椒 发表于 2022-8-26 15:01

黒黑这个是播放器分享啊{:4_178:}

小辣椒 发表于 2022-8-26 15:03

今天周末我可以玩的,黑黑辛苦又分享新的教程了{:4_199:}

红影 发表于 2022-8-26 17:10

这个好,把别的都拿掉了,单纯讲画布播放器。这个真不容易,所有的东东都是画出来的,厉害{:4_187:}

马黑黑 发表于 2022-8-26 19:21

小辣椒 发表于 2022-8-26 15:01
黒黑这个是播放器分享啊

是的

马黑黑 发表于 2022-8-26 19:22

红影 发表于 2022-8-26 17:10
这个好,把别的都拿掉了,单纯讲画布播放器。这个真不容易,所有的东东都是画出来的,厉害

画布是一个不同寻常的画家{:4_170:}

马黑黑 发表于 2022-8-26 19:22

小辣椒 发表于 2022-8-26 15:03
今天周末我可以玩的,黑黑辛苦又分享新的教程了

过一下子还有一个,在现在这个的基础上加入可控进度功能

红影 发表于 2022-8-26 20:19

马黑黑 发表于 2022-8-26 19:22
画布是一个不同寻常的画家

黑黑才是最出类拔萃的画家呢{:4_187:}

马黑黑 发表于 2022-8-26 20:20

红影 发表于 2022-8-26 20:19
黑黑才是最出类拔萃的画家呢

我画盲

小辣椒 发表于 2022-8-26 20:46

马黑黑 发表于 2022-8-26 19:22
过一下子还有一个,在现在这个的基础上加入可控进度功能

是今天吗

马黑黑 发表于 2022-8-26 21:15

小辣椒 发表于 2022-8-26 20:46
是今天吗

{:4_181:}

红影 发表于 2022-8-27 19:38

马黑黑 发表于 2022-8-26 20:20
我画盲

你可以用代码画的呢,在画布上描绘所有的梦{:4_187:}

马黑黑 发表于 2022-8-27 20:20

红影 发表于 2022-8-27 19:38
你可以用代码画的呢,在画布上描绘所有的梦

没那么夸张

红影 发表于 2022-8-27 20:49

马黑黑 发表于 2022-8-27 20:20
没那么夸张

反正我在心目中你无所不能{:4_187:}

马黑黑 发表于 2022-8-27 21:15

红影 发表于 2022-8-27 20:49
反正我在心目中你无所不能

这是少见多怪的原因

红影 发表于 2022-8-28 11:02

马黑黑 发表于 2022-8-27 21:15
这是少见多怪的原因

论坛里就你一个会代码的,你是孤勇者{:4_199:}

马黑黑 发表于 2022-8-28 11:07

红影 发表于 2022-8-28 11:02
论坛里就你一个会代码的,你是孤勇者

不是的。会代码的人,根据观察,有一些的

红影 发表于 2022-8-28 13:58

马黑黑 发表于 2022-8-28 11:07
不是的。会代码的人,根据观察,有一些的

哦哦,反正我知道的就你一个{:4_173:}
页: [1] 2
查看完整版本: 带进度条+歌词同步的canvas画布音频播放器