今天是自製播放器的最後一帕,來講講實際與 UI 互動的串接,以及解說 Audio Player 自製模組的運作原理。
const mediaData = [{ ... }, { ... }, { ... }, { ... }, { ... }];
// 格式化秒數
const formatTime = sec => { ... }
// 播放器功能
class AudioPlayer { ... }
// UI 互動
$(document).ready(() => {
// ... 各種 DOM selector
// ... 各種 render functions
const myAudio = new AudioPlayer(mediaData);
// ... 各種事件監聽
renderPlaylist(mediaData);
})
這個功能直覺是滿簡單的,但是實作上有踩到一些觀念上的盲點,值得提出來講講。
// 建立自訂事件
this.event = {
isPlaying: new Event("playstatuschange"),
}
// 調整播放狀態
setPlayStatus(val) {
if (typeof val !== 'boolean') return;
this.isPlaying = val;
this.audioPlayer.dispatchEvent(this.event.isPlaying);
}
// 播放 / 暫停
togglePlay(nextIsPlaying = !this.isPlaying) {
if (nextIsPlaying) {
// 播放
this.audioPlayer.play()
.then(() => this.setPlayStatus(true))
.catch(e => console.error('播放發生錯誤', e))
} else {
// 暫停
this.audioPlayer.pause()
this.setPlayStatus(false);
}
}
首先,Audio Player 使用 this.isPlaying
儲存播放狀態,這裡有個重點:
this.isPlaying
代表「是否繼續播放」,與播放器本身的停止、播放不是同一件事
像是點暫停會觸發 pause
、播放中延遲重 loading 也會觸發 pause
、播放一首歌結束也會觸發 pause
。要實作換歌時自動播放、整份歌單結束時停止 ... 等常見的行為,就需要區分兩者。
在狀態變更時,會觸發自訂事件 playstatuschange
,因此透過取得狀態與監聽此事件,就能決定 UI 的顯示。
// Material UI 的 Icon Font 使用文字而非 class name 切換圖示
const myAudio = new AudioPlayer(mediaData);
myAudio.on('playstatuschange', () => playBtn.html(myAudio.getIsPlaying() ? 'pause' : 'play_arrow'))
// 錯誤狀態停止播放
this.audioPlayer.addEventListener('playing', () => this.setPlayStatus(true))
this.audioPlayer.addEventListener('waiting', () => this.setPlayStatus(false))
this.audioPlayer.addEventListener('error', () => this.setPlayStatus(false))
this.audioPlayer.addEventListener('stalled', () => this.setPlayStatus(false))
針對一些非預期的事件監聽讓 this.isPlaying
狀態與之同步。
name | type | description |
---|---|---|
playing | event listener | 資料足以繼續播放時觸發 |
waiting | event listener | 資料不足以繼續播放時觸發 |
stalled | event listener | 歌曲資料不能用時觸發 |
error | event listener | 任何錯誤時觸發 |
this.event = {
current: new Event("currentmusicchange"),
}
// 換歌時判斷是否繼續播放
this.audioPlayer.addEventListener('currentmusicchange', () => {
this.isPlaying ? this.audioPlayer.play() : this.audioPlayer.pause()
})
// 自動播下一首
this.audioPlayer.addEventListener('ended', () => {
const nextIdx = this.getNextMusicIdx();
const stopWhenReachPlaylistEnd = (this.playMode === 'step' && nextIdx === 0)
this.setCurrentMusic(nextIdx);
this.setPlayStatus(!stopWhenReachPlaylistEnd);
})
我們監聽 ended
事件用以處理一首歌播放完畢後的動作,並在自訂事件 currentmusicchange
換歌時才決定是否要讓播放器開始播。
有發現到上述的事件中,完全沒有監聽 play
和 pause
嗎?
其實我們不需要知道播放器是否正在播放,只要在相關的事件監聽有正確的處理即可,故只需維護 this.isPlaying
這個狀態。
我們使用 this.currentIdx
紀錄現在要播哪一首歌,並設計了兩個方法切換、讀取要播的歌,可以做到直接指定、或是根據規則自動播下一首歌曲:
// 指定並讀取當下要播放的音樂
// identifier = prev, next, 0, <integer>
setCurrentMusic(identifier = this.currentIdx) {
if (Number.isInteger(identifier)) {
if (identifier < 0 || identifier >= this.playList.length) return;
this.currentIdx = identifier;
} else if (typeof identifier === 'string') {
const newIdx = this.getNextMusicIdx(identifier);
if (!newIdx) return;
this.currentIdx = newIdx;
} else {
console.error('不合法的 identifier in setCurrentMusic');
return;
}
this.audioPlayer.setAttribute('src', this.getMediaInfo().fileUrl)
this.audioPlayer.load()
}
// 取得下一首要播放的歌曲
getNextMusicIdx(operation = this.playMode) {
let nextIdx = 0;
switch (operation) {
case 'step':
// 到播放清單底,結束播放
if ((this.currentIdx + 1) >= this.playList.length) this.audioPlayer.pause();
nextIdx = (this.currentIdx + 1) >= this.playList.length ? 0 : this.currentIdx + 1;
break;
case 'next':
case 'repeat-all':
nextIdx = (this.currentIdx + 1) >= this.playList.length ? 0 : this.currentIdx + 1;
break;
case 'prev':
nextIdx = (this.currentIdx - 1) < 0 ? (this.playList.length - 1) : (this.currentIdx - 1);
break;
case 'shuffle':
nextIdx = Math.floor(Math.random()*this.playList.length - 1);
break;
case 'repeat-one':
nextIdx = this.currentIdx;
break;
default:
console.log('不合法的操作', operation);
return;
}
return nextIdx;
}
const renderCurrent = (info, currentTime, duration) => {
$('#current-thumb').attr('src', info.thumb)
$('#current-author').attr('href', info.authorUrl || '#')
$('#current-author').html(info.author || 'Author')
$('#current-name').html(info.fileName || 'Song Name')
$('#passtime').html(formatTime(currentTime))
$('#duration').html(formatTime(duration))
}
myAudio.on('currentmusicchange', () => {
const currentTime = myAudio.getCurrentTime()
const duration = myAudio.getDuration()
renderCurrent(myAudio.getMediaInfo(), myAudio.getCurrentTime(), myAudio.getDuration());
passtime.html(formatTime(currentTime))
})
監聽 currentmusicchange
自訂事件來顯示歌曲資訊。它會發生在 durationchange
之後,因此確保拿到的歌曲總時間不會是 NaN 。
仔細分析一下切換歌曲相關的功能,上一首歌、下一首歌這兩個性質和隨機播放、循環播放不同,除了會改變 this.currentIdx
,還需要立即讀取歌曲。而隨機播放、循環播放不會立即生效,等到一首歌播完後才有作用。
因此這兩個按鈕是這樣做的:
const prevBtn = $('#prev')
const nextBtn = $('#next')
// 上一首歌 or 下一首歌
prevBtn.click(() => myAudio.setCurrentMusic(myAudio.getNextMusicIdx('prev')))
nextBtn.click(() => myAudio.setCurrentMusic(myAudio.getNextMusicIdx('next')))
點擊歌單裡的歌曲,可以切換到那首歌播放,實作方法與上下一首歌相同:
// 歌曲資訊元件
const MusicInfo = (info, idx) => {
return `
<div class="info">
<img class="info__thumb" src="${info.thumb}"/>
<div class="info__wrapper">
<p class="info__author" title="${info.author || 'Author'}">${info.author || 'Author'}</p>
<span class="info__name">${info.fileName || 'Song Name'}</span>
</div>
</div>
`;
}
// 播放清單
const renderPlaylist = playlist => {
$('#playlist').html(mediaData.map((musicInfo, idx) => `<div id="queue-item-${idx}" class="queue__item">${MusicInfo(musicInfo)}</div>`))
$('#playlist .queue__item').click(function() {
const idx = parseInt($(this).attr('id').replace("queue-item-", ''));
myAudio.setCurrentMusic(idx)
});
}
renderPlaylist(mediaData);
針對「自動決定下一首歌」的部分,我們設計了播放模式 this.playMode
處理這件事。
// 建立自訂事件
this.event = {
playMode: new Event("playmodechange"),
}
// 調整播放模式
setPlayMode(mode) {
const validMode = ['step', 'shuffle', 'repeat-one', 'repeat-all'];
if (validMode.indexOf(mode) !== -1) {
this.playMode = mode;
this.audioPlayer.dispatchEvent(this.event.playMode);
}
}
// 取得當前播放模式
getPlayMode() {
return this.playMode;
}
一共分成四種:一般 (step)、隨機 (shuffle)、單曲循環 (repeat-one)、歌單循環 (repeat-all),這些只是簡單的「開關」,實際上處理邏輯是寫在 getNextMusicIdx
中,當遇到 ended
事件時,就會呼叫 getNextMusicIdx
自動判斷要播哪首歌。
一樣透過監聽自訂事件 playmodechange
處理 UI 顯示
myAudio.on('playmodechange', () => {
switch(myAudio.playMode) {
case 'step': {
shuffleBtn.removeClass('select')
repeatBtn.removeClass('select')
repeatBtn.html('repeat')
break;
}
case 'shuffle': {
shuffleBtn.addClass('select')
repeatBtn.removeClass('select')
repeatBtn.html('repeat')
break;
}
case 'repeat-one': {
shuffleBtn.removeClass('select')
repeatBtn.addClass('select')
repeatBtn.html('repeat_one')
break;
}
case 'repeat-all': {
shuffleBtn.removeClass('select')
repeatBtn.addClass('select')
repeatBtn.html('repeat')
break;
}
default:
return;
}
})
這兩個做法其實很類似,就一起討論吧!
拖曳條實際上分成三層:圓點 (handle)、可拖曳區域條 (bg)、可伸縮的區域條 (bar)。
<!-- 進度條 -->
<div class="player__timeline player__item">
<div class="timeline"><span class="timeline__passtime" id="passtime">00:00:00</span>
<div class="timeline__progress-wrapper">
<div class="timeline__progress-bg" id="timeline-bg"></div>
<div class="timeline__progress-bar" id="timeline-bar"></div>
<div class="timeline__progress-handle" id="timeline-handle"></div>
</div><span class="timeline__duration" id="duration">00:10:59</span>
</div>
</div>
<!-- 音量條 -->
<div class="volume">
<div class="volume__wrapper hidden" id="volume-wrapper">
<div class="volume__progress-bg" id="volume_bg"></div>
<div class="volume__progress-bar" id="volume_bar"></div>
<div class="volume__progress-handle" id="volume_handle"></div>
</div>
</div>
這邊使用 jQuery UI 的 draggable,將 handle 變成可拖曳,限制可拖方向、可拖區域, bar 會隨著 drag 事件伸縮:
const timelineBarTotalLength = 250; // px
const volumeBarTotalLength = 100; // px
// 拖動時間軸
timelineHandle.draggable({
axis: "x",
containment: timelineBg,
start: (event, ui) => myAudio.togglePlay(false),
drag: (event, ui) => {
const nextSec = Math.floor(ui.position.left * (myAudio.getDuration() / timelineBarTotalLength));
passtime.html(formatTime(nextSec));
timelineBar.width(`${ui.position.left}px`)
},
stop: (event, ui) => {
const nextSec = Math.floor(ui.position.left * (myAudio.getDuration() / timelineBarTotalLength));
myAudio.setCurrentTime(nextSec);
myAudio.togglePlay(true);
}
})
// 調整音量
volumeHandle.draggable({
axis: "y",
containment: volumeBg,
drag: (event, ui) => {
const vol = volumeBarTotalLength - ui.position.top;
volumeBar.height(`${vol}px`);
myAudio.setVolume(vol);
}
})
拖放時會觸發的事件
// 音量調整 (0 - 100)
setVolume(vol) {
if (typeof vol !== 'number') return;
this.audioPlayer.volume = vol / 100;
}
// 進度條調整
setCurrentTime(nextSec) {
const currentMusic = this.getMediaInfo();
if (!currentMusic) return;
if (nextSec > currentMusic.duration) {
this.audioPlayer.currentTime = 0;
} else if (nextSec < 0) {
this.audioPlayer.currentTime = 0;
} else {
this.audioPlayer.currentTime = nextSec;
}
}
此外,我們監聽 timeupdate
和 volumechange
事件,一有變化就顯示新的 UI 。
// 取得當前音量
getVolume() {
return this.audioPlayer.volume;
}
// 取得當前秒數
getCurrentTime() {
return this.audioPlayer.currentTime;
}
// 取得當前歌曲總長度
getDuration() {
return this.audioPlayer.duration;
}
// 進度條
myAudio.on('timeupdate', () => {
const currentTime = myAudio.getCurrentTime()
const duration = myAudio.getDuration()
passtime.html(formatTime(currentTime))
timelineBar.width(`${(currentTime / duration) * timelineBarTotalLength}px`)
timelineHandle.css('left', `${(currentTime / duration) * timelineBarTotalLength}px`);
})
// 音量條
myAudio.on('volumechange', () => volumeBar.height(`${myAudio.getVolume() * volumeBarTotalLength}px`))
我們使用 HTMLAudioElement 、 Media Event 、 Custom Event,實作出功能齊全的小型播放器了! (灑花)透過這些就能玩出很多花樣。
全靠監聽 Event 做事情可以省去很多同步問題,但在設計模組介面時需要特別小心,如果沒有釐清好什麼 Event 該負責什麼任務、每項功能觸發的 event flow 有哪些,反而 debug 上會更加困難。
使用 HTML 播放音檔的部分就先告個段落了,接下來會開始談概念性的東西,像是聲音檔案格式與編碼、數位音樂的基本,為之後的 Web Audio API 先打點底吧!