4個小時實現(xiàn)一個HTML5音樂播放器

技術(shù)點:ES6+Webpack+HTML5 Audio+Sass
這里和簸,我們將一步步的學(xué)到如何從零去實現(xiàn)一個H5音樂播放器帅戒。
首先來看一下最終的實現(xiàn)效果:Demo鏈接 =>
界面:

skPlayer


接下來就步入正題:

  1. 要做一個音樂播放器就要非常了解在Web中音頻播放的方式,通常都采用HTML5的audio標簽
    關(guān)于audio標簽晾蜘,它有大量的屬性镇眷、方法和事件鼓鲁,在這里我就做一個大致的介紹。
    屬性
    src:必需唠雕,音頻來源贸营;
    controls:常見吨述,設(shè)置后顯示瀏覽器默認的audio控制面板,不設(shè)置默認隱藏audio標簽钞脂;
    autoplay:常見揣云,設(shè)置后自動播放音頻(移動端不支持);
    loop:常見冰啃,設(shè)置后音頻將循環(huán)播放邓夕;
    preload:常見,設(shè)置音頻預(yù)加載(移動端不支持)亿笤;
    volume:少見翎迁,設(shè)置或返回音頻大小,值為0-1之間的一個浮點數(shù)(移動端不支持)净薛;
    muted:少見汪榔,設(shè)置或返回靜音狀態(tài);
    duration:少見肃拜,返回音頻時長痴腌;
    currentTime:少見,設(shè)置或返回當前播放時間燃领;
    paused:少見士聪,返回當前播放狀態(tài),是否暫停猛蔽;
    buffered:少見剥悟,一個TimeRanges對象,包含已緩沖的時間段信息曼库,即加載進度区岗。該對象包含一個屬性length,返回一個從0開始的數(shù)表示當前緩沖了多少段音頻毁枯;還包含兩個方法慈缔,start()end()种玛,分別需要傳入一個參數(shù)藐鹤,即傳入音頻已加載的第幾段,從0開始赂韵。start()返回該段的起始時間娱节,end()返回該段的終點時間。舉例:即傳入0右锨,第一段的起始是0括堤,終止時間是17,單位秒;
    屬性就介紹到這里悄窃,可能還有一些比較少用的屬性如:playbackRate等讥电,在視頻播放中可能會用到,我就暫不講解轧抗。
    方法
    play():開始播放音頻恩敌;
    pause():暫停播放音頻;
    事件
    canplay:當前音頻可以開始播放(只加載了部分buffered横媚,并未全部加載完成)纠炮;
    canplaythrough:可以無停頓播放(即音頻全部加載完成);
    durationchange:音頻時長發(fā)生變化灯蝴;
    ended:播放結(jié)束恢口;
    error:發(fā)生錯誤;
    pause:播放暫停穷躁;
    play:播放開始耕肩;
    progress:音頻下載過程中觸發(fā),事件觸發(fā)過程中可以通過訪問audiobuffered屬性獲取加載進度问潭;
    seeking:音頻跳躍中觸發(fā)猿诸,即為修改currentTime時;
    seeked:音頻跳躍完成時觸發(fā)狡忙,即為修改完成currentTime時梳虽;
    timeupdate:音頻播放過程中觸發(fā),同時currentTime屬性在同步更新灾茁;
    事件就介紹到這里窜觉,可能還有一些不常用的事件暫不講解。
    最后再講解一下 一個音頻從開始加載到播放結(jié)束過程中北专,所觸發(fā)的事件流以及我們在不同時間段可以操作的屬性
    loadstart:開始加載竖螃;
    durationchange:獲取到音頻時長(此時可以獲取duration屬性);
    progress:音頻下載中(將伴隨下載過程一直觸發(fā)逗余,此時可以獲取buffered屬性);
    canplay:所加載的音頻足夠開始播放(每次暫停后開始播放也會觸發(fā))季惩;
    canplaythrough:音頻全部加載完成录粱;
    timeupdate:播放過程中(currentTime屬性伴隨著同步更新);
    seeking:修改當前播放進度中(即為修改currentTime屬性)画拾;
    seeked:修改當前播放進度完成啥繁;
    ended:播放完成;
    這就是整個音頻的大致事件流青抛,可能有一些少用的事件沒有列舉出旗闽。
    在事件觸發(fā)過程中,有一些屬性在音頻還沒有開始加載的時候就可以設(shè)置,如:controls适室、loop嫡意、volume等等;

  2. 因為自己是做成插件的方式發(fā)布在npm上供他人使用的捣辆,所以我們就采用面向?qū)ο蟮姆绞竭M行代碼編寫蔬螟,又因為用戶的需求不一,所以在設(shè)計之初就暴露出大量的API和配置項以滿足大部分用戶的需求汽畴。
    這里因為自己更習(xí)慣es6的語法旧巾,就全程以es6為基礎(chǔ)進行開發(fā),同時為了開發(fā)效率忍些,又使用了sass進行css的編寫鲁猩,最后還使用了webpackwebpack-dev-server用以編譯es6sass,項目打包罢坝,構(gòu)建本地服務(wù)器廓握。

  3. 確定播放器UI和交互:
    可能關(guān)于界面每個人有自己的想法,這里就不過多贅述了炸客,以我做好的播放器UI為例進行分解

    skPlayer

    從界面中可以看出一個播放器所需要的最基礎(chǔ)功能
    播放/暫停疾棵、封面/歌名/歌手的顯示、播放進度條/加載進度條/進度操作功能痹仙、循環(huán)模式切換是尔、進度文字更新/歌曲時長、靜音/音量大小控制开仰、列表顯示狀態(tài)控制拟枚、點擊列表項切歌功能
    再結(jié)合我們想要滿足用戶需求,提供配置項和API的出發(fā)點可以得出我們想設(shè)計的配置項和暴露的API項:
    配置項:自動播放是否開啟众弓、默認歌曲列表的顯示狀態(tài)恩溅、默認循環(huán)模式的設(shè)置
    API:播放/暫停/toggle、循環(huán)模式的切換谓娃、靜音/恢復(fù)脚乡、列表顯示狀態(tài)的切換、上一曲/下一曲/切歌滨达、銷毀當前實例

  4. 確立項目結(jié)構(gòu)奶稠,開始編碼:
    因為使用webpack,所以我們直接將css打包至js內(nèi)捡遍,以便作為插件供用戶使用:

require('./skPlayer.scss');

抽離公共方法锌订,在播放器中有很多可能需要抽離的公共方法如:點擊播放進度條和音量進度條時需要計算鼠標距離進度條左端的距離以進行進度跳轉(zhuǎn),時間從duration中獲取到的以秒為單位的時間轉(zhuǎn)換成標準時間格式等等:

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);
    }
};

由于設(shè)計之初画株,考慮到播放器的獨特性辆飘,設(shè)計為只能存在一個實例啦辐,設(shè)置了一個全局變量以判斷當前是否存在實例:
(實例判斷,如果存在返回無原型的空對象蜈项,因為ES6構(gòu)造函數(shù)內(nèi)默認返回帶原型的實例)

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

在使用ES6的情況下,我們將主邏輯放在構(gòu)造函數(shù)內(nèi)部战得,將通用性強和API放在公共函數(shù)內(nèi)部:

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內(nèi)部this指向在默認情況下指向?qū)嵗菫榱藴p少代碼量常侦,將操作界面上的功能與API調(diào)用一套代碼浇冰,在綁定事件的時候this指向會改變,所以通過bind的方式綁定this聋亡,當然也可以在綁定事件的時候使用箭頭函數(shù)

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節(jié)點綁定漂佩,初始化配置項,初始化操作界面:

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;

至此投蝉,核心代碼基本完成,接下來就是自己根據(jù)需要完成API部分征堪,詳細部分移步至我的github查看源碼瘩缆。
一個HTML5音樂播放器就大功告成了 ~ !

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末佃蚜,一起剝皮案震驚了整個濱河市庸娱,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌谐算,老刑警劉巖熟尉,帶你破解...
    沈念sama閱讀 210,914評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異洲脂,居然都是意外死亡斤儿,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,935評論 2 383
  • 文/潘曉璐 我一進店門恐锦,熙熙樓的掌柜王于貴愁眉苦臉地迎上來雇毫,“玉大人,你說我怎么就攤上這事踩蔚。” “怎么了枚粘?”我有些...
    開封第一講書人閱讀 156,531評論 0 345
  • 文/不壞的土叔 我叫張陵馅闽,是天一觀的道長。 經(jīng)常有香客問我,道長福也,這世上最難降的妖魔是什么局骤? 我笑而不...
    開封第一講書人閱讀 56,309評論 1 282
  • 正文 為了忘掉前任,我火速辦了婚禮暴凑,結(jié)果婚禮上峦甩,老公的妹妹穿的比我還像新娘。我一直安慰自己现喳,他們只是感情好凯傲,可當我...
    茶點故事閱讀 65,381評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著嗦篱,像睡著了一般冰单。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上灸促,一...
    開封第一講書人閱讀 49,730評論 1 289
  • 那天诫欠,我揣著相機與錄音,去河邊找鬼浴栽。 笑死荒叼,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的典鸡。 我是一名探鬼主播被廓,決...
    沈念sama閱讀 38,882評論 3 404
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼椿每!你這毒婦竟也來了伊者?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,643評論 0 266
  • 序言:老撾萬榮一對情侶失蹤间护,失蹤者是張志新(化名)和其女友劉穎亦渗,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體汁尺,經(jīng)...
    沈念sama閱讀 44,095評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡法精,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,448評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了痴突。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片搂蜓。...
    茶點故事閱讀 38,566評論 1 339
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖辽装,靈堂內(nèi)的尸體忽然破棺而出帮碰,到底是詐尸還是另有隱情,我是刑警寧澤拾积,帶...
    沈念sama閱讀 34,253評論 4 328
  • 正文 年R本政府宣布殉挽,位于F島的核電站丰涉,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏斯碌。R本人自食惡果不足惜一死,卻給世界環(huán)境...
    茶點故事閱讀 39,829評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望傻唾。 院中可真熱鬧投慈,春花似錦、人聲如沸冠骄。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,715評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽猴抹。三九已至带族,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蟀给,已是汗流浹背蝙砌。 一陣腳步聲響...
    開封第一講書人閱讀 31,945評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留跋理,地道東北人择克。 一個月前我還...
    沈念sama閱讀 46,248評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像前普,于是被迫代替她去往敵國和親肚邢。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,440評論 2 348

推薦閱讀更多精彩內(nèi)容