4个小时实现一个HTML5音乐播放器

技术点:ES6+Webpack+HTML5 Audio+Sass
这里,我们将一步步的学到如何从零去实现一个H5音乐播放器。
首先来看一下最终的实现效果:Demo链接 =>
界面:

skPlayer


接下来就步入正题:

  1. 要做一个音乐播放器就要非常了解在Web中音频播放的方式,通常都采用HTML5的audio标签
    关于audio标签,它有大量的属性、方法和事件,在这里我就做一个大致的介绍。
    属性
    src:必需,音频来源;
    controls:常见,设置后显示浏览器默认的audio控制面板,不设置默认隐藏audio标签;
    autoplay:常见,设置后自动播放音频(移动端不支持);
    loop:常见,设置后音频将循环播放;
    preload:常见,设置音频预加载(移动端不支持);
    volume:少见,设置或返回音频大小,值为0-1之间的一个浮点数(移动端不支持);
    muted:少见,设置或返回静音状态;
    duration:少见,返回音频时长;
    currentTime:少见,设置或返回当前播放时间;
    paused:少见,返回当前播放状态,是否暂停;
    buffered:少见,一个TimeRanges对象,包含已缓冲的时间段信息,即加载进度。该对象包含一个属性length,返回一个从0开始的数表示当前缓冲了多少段音频;还包含两个方法,start()end(),分别需要传入一个参数,即传入音频已加载的第几段,从0开始。start()返回该段的起始时间,end()返回该段的终点时间。举例:即传入0,第一段的起始是0,终止时间是17,单位秒;
    属性就介绍到这里,可能还有一些比较少用的属性如:playbackRate等,在视频播放中可能会用到,我就暂不讲解。
    方法
    play():开始播放音频;
    pause():暂停播放音频;
    事件
    canplay:当前音频可以开始播放(只加载了部分buffered,并未全部加载完成);
    canplaythrough:可以无停顿播放(即音频全部加载完成);
    durationchange:音频时长发生变化;
    ended:播放结束;
    error:发生错误;
    pause:播放暂停;
    play:播放开始;
    progress:音频下载过程中触发,事件触发过程中可以通过访问audiobuffered属性获取加载进度;
    seeking:音频跳跃中触发,即为修改currentTime时;
    seeked:音频跳跃完成时触发,即为修改完成currentTime时;
    timeupdate:音频播放过程中触发,同时currentTime属性在同步更新;
    事件就介绍到这里,可能还有一些不常用的事件暂不讲解。
    最后再讲解一下 一个音频从开始加载到播放结束过程中,所触发的事件流以及我们在不同时间段可以操作的属性
    loadstart:开始加载;
    durationchange:获取到音频时长(此时可以获取duration属性);
    progress:音频下载中(将伴随下载过程一直触发,此时可以获取buffered属性);
    canplay:所加载的音频足够开始播放(每次暂停后开始播放也会触发);
    canplaythrough:音频全部加载完成;
    timeupdate:播放过程中(currentTime属性伴随着同步更新);
    seeking:修改当前播放进度中(即为修改currentTime属性);
    seeked:修改当前播放进度完成;
    ended:播放完成;
    这就是整个音频的大致事件流,可能有一些少用的事件没有列举出。
    在事件触发过程中,有一些属性在音频还没有开始加载的时候就可以设置,如:controlsloopvolume等等;

  2. 因为自己是做成插件的方式发布在npm上供他人使用的,所以我们就采用面向对象的方式进行代码编写,又因为用户的需求不一,所以在设计之初就暴露出大量的API和配置项以满足大部分用户的需求。
    这里因为自己更习惯es6的语法,就全程以es6为基础进行开发,同时为了开发效率,又使用了sass进行css的编写,最后还使用了webpackwebpack-dev-server用以编译es6sass,项目打包,构建本地服务器。

  3. 确定播放器UI和交互:
    可能关于界面每个人有自己的想法,这里就不过多赘述了,以我做好的播放器UI为例进行分解

    skPlayer

    从界面中可以看出一个播放器所需要的最基础功能
    播放/暂停、封面/歌名/歌手的显示、播放进度条/加载进度条/进度操作功能、循环模式切换、进度文字更新/歌曲时长、静音/音量大小控制、列表显示状态控制、点击列表项切歌功能
    再结合我们想要满足用户需求,提供配置项和API的出发点可以得出我们想设计的配置项和暴露的API项:
    配置项:自动播放是否开启、默认歌曲列表的显示状态、默认循环模式的设置
    API:播放/暂停/toggle、循环模式的切换、静音/恢复、列表显示状态的切换、上一曲/下一曲/切歌、销毁当前实例

  4. 确立项目结构,开始编码:
    因为使用webpack,所以我们直接将css打包至js内,以便作为插件供用户使用:

require('./skPlayer.scss');

抽离公共方法,在播放器中有很多可能需要抽离的公共方法如:点击播放进度条和音量进度条时需要计算鼠标距离进度条左端的距离以进行进度跳转,时间从duration中获取到的以秒为单位的时间转换成标准时间格式等等:

const Util = {
    leftDistance: (el) => {
        let left = el.offsetLeft;
        let scrollLeft;
        while (el.offsetParent) {
            el = el.offsetParent;
            left += el.offsetLeft;
        }
        scrollLeft = document.body.scrollLeft + document.documentElement.scrollLeft;
        return left - scrollLeft;
    },
    timeFormat: (time) => {
        let tempMin = parseInt(time / 60);
        let tempSec = parseInt(time % 60);
        let curMin = tempMin < 10 ? ('0' + tempMin) : tempMin;
        let curSec = tempSec < 10 ? ('0' + tempSec) : tempSec;
        return curMin + ':' + curSec;
    },
    percentFormat: (percent) => {
        return (percent * 100).toFixed(2) + '%';
    },
    ajax: (option) => {
        option.beforeSend && option.beforeSend();
        let xhr = new XMLHttpRequest();
        xhr.onreadystatechange = () => {
            if(xhr.readyState === 4){
                if(xhr.status >= 200 && xhr.status < 300){
                    option.success && option.success(xhr.responseText);
                }else{
                    option.fail && option.fail(xhr.status);
                }
            }
        };
        xhr.open('GET',option.url);
        xhr.send(null);
    }
};

由于设计之初,考虑到播放器的独特性,设计为只能存在一个实例,设置了一个全局变量以判断当前是否存在实例:
(实例判断,如果存在返回无原型的空对象,因为ES6构造函数内默认返回带原型的实例)

let instance = false;
if(instance){
    console.error('SKPlayer只能存在一个实例!');
    return Object.create(null);
}else{
    instance = true;
}

在使用ES6的情况下,我们将主逻辑放在构造函数内部,将通用性强和API放在公共函数内部:

class skPlayer {
    constructor(option){}
    template(){}
    init(){}
    bind(){}
    prev(){}
    next(){}
    switchMusic(index){}
    play(){}
    pause(){}
    toggle(){}
    toggleList(){}
    toggleMute(){}
    switchMode(){}
    destroy(){}
}

初始化配置项,默认配置与用户配置合并:

const defaultOption = {
     ... 
};
this.option = Object.assign({},defaultOption,option);

将常用属性绑定在实例上:

this.root = this.option.element;
this.type = this.option.music.type;
this.music = this.option.music.source;
this.isMobile = /mobile/i.test(window.navigator.userAgent);

一些公共的API内部this指向在默认情况下指向实例,但是为了减少代码量,将操作界面上的功能与API调用一套代码,在绑定事件的时候this指向会改变,所以通过bind的方式绑定this,当然也可以在绑定事件的时候使用箭头函数

this.toggle = this.toggle.bind(this);
this.toggleList = this.toggleList.bind(this);
this.toggleMute = this.toggleMute.bind(this);
this.switchMode = this.switchMode.bind(this);

接下来,我们就使用ES6字符串模板开始生成HTML,插入到页面中:

this.root.innerHTML = this.template();

接下来初始化,初始化过程中将常用DOM节点绑定,初始化配置项,初始化操作界面:

this.init();
    init(){
        this.dom = {
            cover: this.root.querySelector('.skPlayer-cover'),
            playbutton: this.root.querySelector('.skPlayer-play-btn'),
            name: this.root.querySelector('.skPlayer-name'),
            author: this.root.querySelector('.skPlayer-author'),
            timeline_total: this.root.querySelector('.skPlayer-percent'),
            timeline_loaded: this.root.querySelector('.skPlayer-line-loading'),
            timeline_played: this.root.querySelector('.skPlayer-percent .skPlayer-line'),
            timetext_total: this.root.querySelector('.skPlayer-total'),
            timetext_played: this.root.querySelector('.skPlayer-cur'),
            volumebutton: this.root.querySelector('.skPlayer-icon'),
            volumeline_total: this.root.querySelector('.skPlayer-volume .skPlayer-percent'),
            volumeline_value: this.root.querySelector('.skPlayer-volume .skPlayer-line'),
            switchbutton: this.root.querySelector('.skPlayer-list-switch'),
            modebutton: this.root.querySelector('.skPlayer-mode'),
            musiclist: this.root.querySelector('.skPlayer-list'),
            musicitem: this.root.querySelectorAll('.skPlayer-list li')
        };
        this.audio = this.root.querySelector('.skPlayer-source');
        if(this.option.listshow){
            this.root.className = 'skPlayer-list-on';
        }
        if(this.option.mode === 'singleloop'){
            this.audio.loop = true;
        }
        this.dom.musicitem[0].className = 'skPlayer-curMusic';
         ... 
    }

事件绑定,主要绑定audio的事件以及操作面板的事件:

this.bind();
    bind(){
        this.updateLine = () => {
            let percent = this.audio.buffered.length ? (this.audio.buffered.end(this.audio.buffered.length - 1) / this.audio.duration) : 0;
            this.dom.timeline_loaded.style.width = Util.percentFormat(percent);
        };

        // this.audio.addEventListener('load', (e) => {
        //     if(this.option.autoplay && this.isMobile){
        //         this.play();
        //     }
        // });
        this.audio.addEventListener('durationchange', (e) => {
            this.dom.timetext_total.innerHTML = Util.timeFormat(this.audio.duration);
            this.updateLine();
        });
        this.audio.addEventListener('progress', (e) => {
            this.updateLine();
        });
        this.audio.addEventListener('canplay', (e) => {
            if(this.option.autoplay && !this.isMobile){
                this.play();
            }
        });
        this.audio.addEventListener('timeupdate', (e) => {
            let percent = this.audio.currentTime / this.audio.duration;
            this.dom.timeline_played.style.width = Util.percentFormat(percent);
            this.dom.timetext_played.innerHTML = Util.timeFormat(this.audio.currentTime);
        });
        //this.audio.addEventListener('seeked', (e) => {
        //    this.play();
        //});
        this.audio.addEventListener('ended', (e) => {
            this.next();
        });

        this.dom.playbutton.addEventListener('click', this.toggle);
        this.dom.switchbutton.addEventListener('click', this.toggleList);
        if(!this.isMobile){
            this.dom.volumebutton.addEventListener('click', this.toggleMute);
        }
        this.dom.modebutton.addEventListener('click', this.switchMode);
        this.dom.musiclist.addEventListener('click', (e) => {
            let target,index,curIndex;
            if(e.target.tagName.toUpperCase() === 'LI'){
                target = e.target;
            }else{
                target = e.target.parentElement;
            }
            index = parseInt(target.getAttribute('data-index'));
            curIndex = parseInt(this.dom.musiclist.querySelector('.skPlayer-curMusic').getAttribute('data-index'));
            if(index === curIndex){
                this.play();
            }else{
                this.switchMusic(index + 1);
            }
        });
        this.dom.timeline_total.addEventListener('click', (event) => {
            let e = event || window.event;
            let percent = (e.clientX - Util.leftDistance(this.dom.timeline_total)) / this.dom.timeline_total.clientWidth;
            if(!isNaN(this.audio.duration)){
                this.dom.timeline_played.style.width = Util.percentFormat(percent);
                this.dom.timetext_played.innerHTML = Util.timeFormat(percent * this.audio.duration);
                this.audio.currentTime = percent * this.audio.duration;
            }
        });
        if(!this.isMobile){
            this.dom.volumeline_total.addEventListener('click', (event) => {
                let e = event || window.event;
                let percent = (e.clientX - Util.leftDistance(this.dom.volumeline_total)) / this.dom.volumeline_total.clientWidth;
                this.dom.volumeline_value.style.width = Util.percentFormat(percent);
                this.audio.volume = percent;
                if(this.audio.muted){
                    this.toggleMute();
                }
            });
        }
    }

最后我们暴露模块:

module.exports = skPlayer;

至此,核心代码基本完成,接下来就是自己根据需要完成API部分,详细部分移步至我的github查看源码。
一个HTML5音乐播放器就大功告成了 ~ !

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容