audio 要素と JavaScript でオーディオプレーヤーを作成
HTML と CSS、JavaScript で音声プレーヤーやプレイリストを独自に作成することができます。SVG アイコンなどを使えばボタンなども簡単にそれなりに表示することができます。
以下は独自の音声プレーヤーやプレイリストの作成方法とサンプルです(MediaElement.js などのオープンソースのメディアプレーヤーを使えば、もっと簡単に実装することができます)。
更新日:2023年05月27日
作成日:2023年5月13日
【2023年5月23日】 内容の大部分を書き換えました。
関連ページ
音声プレーヤーの作成
以下は JavaScript と CSS で作成したカスタム音声プレーヤー(コントロール)の例です。
HTML
.audio-controller を指定した div 要素で全体をラップし、その中に audio 要素とコントロールを配置します。audio 要素には controls 属性を指定しません。
ボタンは type="button" の button 要素にクラスを指定して識別し、再生位置とボリュームは type="range" の input 要素(レンジスライダー)にクラスと name 属性を指定して識別します。
この例の場合、ボタンのテキストが幅をとってしまうため、2段で表示しています。
<div class="audio-controller"> <audio src="sample.mp3"></audio> <div class="controls"> <div class="row"> <button class="toggle" type="button">Play</button> <div class="time"><span class="current-time">0:00</span></div> <input class="range-slider" type="range" name="seek" value="0" step=".1"> <div class="time"><span class="duration">0:00</span></div> </div> <div class="row"> <button class="mute" type="button">Mute</button> <input class="range-slider" type="range" name="vol" min="0.0" max="1.0" value="1.0" step=".1"> <button class="loop" type="button">Loop</button> </div> </div> </div>
CSS
.controls と .row には display: flex を指定し、.controls には flex-wrap: wrap を指定して2段で表示するようにし、.row はデフォルトの nowrap で折り返しません。
ボタンはクリックされると JavaScript により状態を表す .active や .playing などのクラスが追加・削除されるので、それを利用して背景色や文字サイズを変更しています。
再生位置を表示・操作するシークバーとボリュームの値を表示・操作するボリュームバーは type="range" を指定した input 要素(レンジスライダー)を使用しています。
レンジスライダーはブラウザによりスタイルが大きく異なるため、以下では ::-webkit-slider-thumb と ::-moz-range-thumb というベンダープレフィックスの付いた疑似要素にスタイルを指定して、異なるブラウザでもスタイルを統一して表示するようにしています
関連ページ:レンジスライダーをカスタマイズ
.audio-controller { position: relative; margin: 50px 0; width: 100%; max-width: 320px; border: 1px solid #999; padding: 15px 10px; background-color: #555; color: #fff; } .audio-controller .controls { display: flex; gap: 10px; flex-wrap: wrap; } .audio-controller .controls .row { display: flex; gap: 10px; } .audio-controller .controls button { border: none; padding: .25rem .5rem; cursor: pointer; height: 32px; font-size: 12px; border-radius: 50%; color: #fff; box-shadow: 1px 1px 0 0 rgba(0, 0, 0, 0.2); } .audio-controller .controls button.toggle { width: 12rem; background-color: #3976d8; } .audio-controller .controls button.toggle.playing { width: 12rem; background-color: #e06602; } .audio-controller .controls button.mute { width: 7rem; background-color: #aa5353; } .audio-controller .controls button.mute.active { background-color: #f31e1e; font-size: 11px; } .audio-controller .controls .loop { width: 9rem; background-color: #1f6438; } .audio-controller .controls .loop.active { background-color: #739c32; color: #fff; font-size: 11px; } .audio-controller .controls .time { font-size: 12px; line-height: 36px; } /* レンジスライダー */ .audio-controller input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; cursor: pointer; outline: none; border-radius: 15px; height: 6px; background: #ccc; margin-top: 14px; } /* スライダーのツマミ部分(webkit) */ .audio-controller input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 15px; width: 15px; background-color: #3976d8; border-radius: 50%; border: none; transition: .2s ease-in-out; box-shadow: 1px 1px 0 0 rgba(0, 0, 0, 0.2); } /* スライダーのツマミ部分(Firefox) */ .audio-controller input[type="range"]::-moz-range-thumb { height: 15px; width: 15px; background-color: #3976d8; border-radius: 50%; border: none; transition: .2s ease-in-out; box-shadow: 1px 1px 0 0 rgba(0, 0, 0, 0.2); } /* スライダーのツマミ部分のホバー、アクティブ、フォーカス(Webkit 用) */ audio-controller input[type="range"]::-webkit-slider-thumb:hover { box-shadow: 0 0 0 8px rgba(248, 200, 245, 0.4) } .audio-controller input[type="range"]:active::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(248, 200, 245, .5) } .audio-controller input[type="range"]:focus::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(248, 200, 245, .5) } /* スライダーのツマミ部分のホバー、アクティブ、フォーカス(Firfox 用) */ .audio-controller input[type="range"]::-moz-range-thumb:hover { box-shadow: 0 0 0 8px rgba(248, 200, 245, .4) } .audio-controller input[type="range"]:active::-moz-range-thumb { box-shadow: 0 0 0 5px rgba(248, 200, 245, .5) } .audio-controller input[type="range"]:focus::-moz-range-thumb { box-shadow: 0 0 0 5px rgba(248, 200, 245, .5) }
JavaScript
JavaScript では .audio-controller を指定した要素を取得して、その要素を基点に querySelector() を使って配下のボタンなどの要素にアクセスします。
生成した audio 要素には必要に応じてプロパティを設定することができます。この例では controls と volume 及び preload プロパティを設定しています。
preload に metadata を設定しているのは、iPhone の場合、初回再生前にシークバーなどで再生位置を0以外に変更すると、currentTime が正しく反映されず正しく動作しないためです(iPhone 対策)。
preload に metadata を設定したくない場合は、初回再生前はシークバーでの操作をできないようにする方法もあります(シークバー操作)。
再生位置は currentTime、再生時間(音声データの長さ)は duration プロパティで取得できます。
音声データの再生には play() メソッドを使用しますが、play() メソッドは正常に再生を開始すると resolve され、再生が開始できないと reject される Promise を返します。
この Promise を監視して再生状態を判定するための関数 playAudio() を別途定義して、再生ボタンがクリックされたら呼び出しています。
それぞれのボタンのクリックイベントでは、ボタンのラベルを変更し、クラスを着脱して背景色を変更しています。各処理にはコメントを入れてありますが、詳細は以下を御覧ください。
83〜95行目と191〜195行目の処理は独自に作成したコントロールと実際の audio 要素のコントロールを同期するためのものです。audio 要素は非表示になっているので、省略しても問題ありませんが、別途 JavaScript で audio 要素を操作する場合などは記述しておくと良いかと思います。
また、以下の関数を別途定義しています。以降の全てのサンプルで同じ関数を使用します(これらの関数がないと動作しません)。
- secToHMS()
-
再生位置の時刻や再生時間は秒数で取得するので、その値を mm:ss の形式に変換する関数
関連ページ:秒を時・分・秒や hh:mm:ss に変換
- updateSlider()
-
ボリュームバーやシークバーのトラックの現在の値を示す部分の背景色を更新する関数
関連ページ:レンジスライダーをカスタマイズ
document.addEventListener('DOMContentLoaded', () => { const audioControllers = document.querySelectorAll('.audio-controller'); audioControllers.forEach((audioController) => { // .audio-controller 内の audio 要素を取得 const audio = audioController.querySelector('audio'); // 念のため controls に false を指定して確実に非表示に audio.controls = false; // 初期ボリューム(デフォルトは 1。必要に応じて変更できます) audio.volume = 0.8; // iPhone 対策 audio.preload = 'metadata'; // トグルボタン(再生・停止ボタン) const toggleBtn = audioController.querySelector('.toggle'); // 現在の再生位置(時間)を表示する要素 const ctSpan = audioController.querySelector('.time .current-time'); // 現在の再生位置(時間)を hh:mm:ss に変換して表示 ctSpan.textContent = secToHMS(audio.currentTime); // 再生時間を表示する要素 const durSpan = audioController.querySelector('.time .duration'); // ループボタン const loopBtn = audioController.querySelector('.loop'); loopBtn.addEventListener('click', () => { // loop が有効であれば if (audio.loop) { // loop を無効にしてテキストと背景色を変更 audio.loop = false; loopBtn.textContent = 'Loop' loopBtn.classList.remove('active'); } else { // loop が無効であれば有効にしてテキストと背景色を変更 audio.loop = true; loopBtn.textContent = 'Unloop' loopBtn.classList.add('active'); } }); // ボリュームスライダー const volumeBar = audioController.querySelector('input[name="vol"]'); // スライダーの値に現在の volume の値(初期値)を設定 volumeBar.value = audio.volume; // スライダーの値が変更されたら volumeBar.addEventListener('input', (e) => { // スライダーの値に現在の値を設定 audio.volume = e.currentTarget.value; // ミュート中であればミュートを解除 if (audio.muted) { audio.muted = false; } }); // ミュートボタン const muteBtn = audioController.querySelector('.mute'); // ミュートボタンがクリックされたら muteBtn.addEventListener('click', () => { // ミュート中であれば if (audio.muted) { // ミュートを解除 audio.muted = false; // ボリュームバーの位置を更新 volumeBar.value = audio.volume; // ボリュームバーのトラックの色を更新 updateSlider(volumeBar); // ミュートボタンのテキストを変更 muteBtn.textContent = 'Mute'; // ミュートボタンの背景色を変更 muteBtn.classList.remove('active'); } else { audio.muted = true; volumeBar.value = 0; updateSlider(volumeBar); muteBtn.textContent = 'Unmute'; muteBtn.classList.add('active'); } }); // ミュート状態の変更を検出 // 独自コントロールと audio 要素のコントロールを同期するための処理 audio.addEventListener('volumechange', (e) => { if (e.currentTarget.muted) { volumeBar.value = 0; updateSlider(volumeBar); muteBtn.textContent = 'Unmute'; muteBtn.classList.add('active'); } else { volumeBar.value = audio.volume; updateSlider(volumeBar); muteBtn.textContent = 'Mute'; muteBtn.classList.remove('active'); } }, false); // シークバー const seekBar = audioController.querySelector('input[name="seek"]'); // シークバーの値が変更されたら seekBar.addEventListener('input', (e) => { //再生位置を変更された値に設定 audio.currentTime = e.currentTarget.value; }); // 再生時間(音声データの長さ) let duration; // メタデータの読み込みが完了した時点で再生時間を取得 audio.addEventListener('loadedmetadata', () => { duration = audio.duration; // 再生時間を hh:mm:ss に変換して表示 durSpan.textContent = secToHMS(Math.floor(duration)); // シークバー(レンジ入力欄)の max 属性に再生時間を設定 seekBar.setAttribute('max', Math.floor(duration)); }); // currentTime プロパティの値が更新される際に発火するイベント audio.addEventListener('timeupdate', updateTime, false); function updateTime() { const cTime = audio.currentTime; // 現在の再生位置(時間)の表示を更新 ctSpan.textContent = secToHMS(Math.floor(cTime)); // シークバーの現在の再生位置を更新 seekBar.value = cTime; // シークバーの塗り色を更新 updateSlider(seekBar); } // トグルボタンのクリックイベント toggleBtn.addEventListener('click', togglePlayPause, false); // トグルボタンのリスナー関数 function togglePlayPause() { // 停止中であれば if (audio.paused) { // 再生用関数 playAudio() を呼び出す playAudio(); } else { // 再生中であれば停止(ボタンのラベルと背景色は pause イベントで変更) audio.pause(); } } // 音声データを再生する非同期関数(Async Function) async function playAudio() { try { // await を指定して Promise が確定するまで待ちます await audio.play(); // Promise が解決されたらボタンのテキストを変更し、クラスを追加 toggleBtn.textContent = 'Pause'; toggleBtn.classList.add('playing'); } catch (err) { // 再生できなければ(クラスが追加されていれば)クラスを削除 toggleBtn.classList.remove('playing'); // コンソールにエラーを出力 console.warn(err); } } // pause イベントのリスナー(ボタンのラベルと背景色を変更) audio.addEventListener('pause', () => { toggleBtn.textContent = 'Play'; toggleBtn.classList.remove('playing'); }); // 再生終了時に発火するイベント audio.addEventListener('ended', audioEnded, false); // 再生終了時に呼び出す関数 function audioEnded() { // ボタンのテキストを Pause から Play に変更してクラスを削除(色を変更) toggleBtn.textContent = 'Play'; toggleBtn.classList.remove('playing'); // 再生位置を先頭に戻す(必要に応じて) audio.currentTime = 0; } // レンジスライダー const rangeSliders = audioController.querySelectorAll('.range-slider'); rangeSliders.forEach((slider) => { // レンジスライダーの input イベントに別途定義した関数 updateSlider を設定 slider.addEventListener('input', (e) => { updateSlider(e.target); }); // 初期状態に現在の状態を反映 updateSlider(slider); }); // play イベントのリスナー(ボタンのラベルと背景色を変更) // 独自コントロールと audio 要素のコントロールを同期するための処理 audio.addEventListener('play', (e) => { toggleBtn.textContent = 'Pause'; toggleBtn.classList.add('playing'); }); }); /** * 秒数を引数に受け取り hh:mm:ss に変換する関数 * @param {Number} seconds 秒数 */ function secToHMS(seconds) { const hour = Math.floor(seconds / 3600); const min = Math.floor(seconds % 3600 / 60); const sec = seconds % 60; let hh; // 3桁未満ならゼロパディング if (hour < 100) { hh = (`00${hour}`).slice(-2); } else { hh = hour; } // mm:ss の形式にするためゼロパディング const mm = (`00${min}`).slice(-2); const ss = (`00${sec}`).slice(-2); let time = ''; if (hour !== 0) { // 1時間以上であれば hh:mm:ss time = `${hh}:${mm}:${ss}`; } else { // 1時間未満であれば mm:ss time = `${mm}:${ss}`; } return time; } /** * レンジスライダーのトラックの塗りの範囲と色を更新する関数 * @param {HTMLElement} slider レンジスライダー(input type="range") * @param {String} bgc ベースとなるトラックの背景色(デフォルト #ccc) * @param {String} color 変化する領域(ツマミの左側)の背景色(デフォルト #8ea8f9) */ function updateSlider(slider, bgc = '#ccc', color = '#8ea8f9') { if (!slider.max) { // max 属性が設定されていなければ 100 を設定 slider.max = 100; } // 現在の値から割合(%)を取得 const progress = (slider.value / slider.max) * 100; // linear-gradient でトラックの背景色の領域を引数の色で更新 slider.style.background = `linear-gradient(to right, ${color} ${progress}%, ${bgc} ${progress}%)`; } // 再生を開始すると、他に再生中のものがあれば停止(必要に応じて設定) /* document.addEventListener('play', (e) => { const audios = document.querySelectorAll('audio'); audios.forEach((audio) => { if (audio !== e.target) { audio.pause(); } }); }, true); */ });
コントロールを JavaScript で追加
前述の例ではコントロールを HTML で記述しましたが、以下は JavaScript でコントロールを追加する例です。また、HTML で指定した muted と loop 属性を初期状態でコントロールに反映させています。
この例では js-audio クラスが指定されている audio 要素を独自のコントロールで表示します。
<audio class="js-audio" src="sample.mp3"></audio>
js-audio クラスが指定されている audio 要素を全て取得して、それぞれを独自のプレーヤーの外側の要素(ラッパー)で囲みます。
audio 要素をラッパー(audioController)で囲むには、ラッパーを生成して audio 要素の前に insertBefore() で挿入し、audio 要素を appendChild() でラッパーに追加(移動)しています。
そしてテンプレートリテラルを使って作成したコントロール部分の HTML を insertAdjacentHTML() でラッパーに追加しています。
また、HTML で audio 属性に記述される muted と loop 属性を初期状態に反映するために、45〜53行目と78〜90行目を追加しています。
残りの部分は前述の JavaScript と同じです。また、CSS も同じです。
関数にする
関数にした方が便利な場合があります。
以下は関数を作成して引数に渡された audio 要素をカスタマイズしたコントロールで表示する例です。
引数に対象の audio 要素とオプションでスライダー部分のトラックの背景色とボリュームの初期値を受け取る関数 createAudioController() を定義しています。
関数 createAudioController() の内容は前述のコードとほぼ同じです(異なる部分にはコメントあり)。
使用する際は、対象の audio 要素を引数に指定して実行します。
第1引数の audio 要素は必須ですが、その他は省略するとデフォルトのスライダーの背景色とボリュームの初期値(1.0)で表示されます。
document.addEventListener('DOMContentLoaded', () => { // js-audio クラスの audio 要素を全て取得 const jsAudios = document.querySelectorAll('.js-audio'); // 上記で取得した各要素を関数に渡して実行 jsAudios.forEach((audio) => { // 対象の audio 要素をカスタムプレーヤーで表示 createAudioController(audio); }); // id が foo の要素を取得 const foo = document.getElementById('foo'); // 関数に渡して実行(引数に背景色とボリュームの初期値を指定) createAudioController(foo, 'yellow', 'pink', 0.8); /** * audio 要素をカスタマイズしたコントロールで表示する関数 * 別途定義した関数 secToHMS() と updateSlider() が必要 * @param {HTMLElement} audio (カスタムプレーヤーで表示する audio 要素 ※必須) * @param {String} bgc スライダー部分のトラックの背景色(省略時は #ccc) * @param {String} color スライダー部分の変化する領域の背景色(省略時は #8ea8f9) * @param {Float} vol 初期ボリューム。0.0〜1.0 の範囲で指定(省略時は 1.0) */ function createAudioController(audio, bgc, color, vol = 1.0) { // 引数の audio が存在しない場合や audio 要素でなければ終了 if (!audio || audio.tagName !== 'AUDIO') { return; } const audioController = document.createElement('div'); audioController.className = 'audio-controller'; audio.parentNode.insertBefore(audioController, audio); audioController.appendChild(audio); const controls = `<div class="controls"> <div class="row"> <button class="toggle" type="button">Play</button> <div class="time"><span class="current-time">0:00</span></div> <input class="range-slider" type="range" name="seek" value="0" step=".1"> <div class="time"><span class="duration">0:00</span></div> </div> <div class="row"> <button class="mute" type="button">Mute</button> <input class="range-slider" type="range" name="vol" min="0.0" max="1.0" value="1.0" step=".1"> <button class="loop" type="button">Loop</button> </div> </div>`; audioController.insertAdjacentHTML('beforeend', controls); audio.controls = false; // 引数の vol を volume に設定 audio.volume = vol; audio.preload = 'metadata'; const toggleBtn = audioController.querySelector('.toggle'); const ctSpan = audioController.querySelector('.time .current-time'); ctSpan.textContent = secToHMS(audio.currentTime); const durSpan = audioController.querySelector('.time .duration'); const loopBtn = audioController.querySelector('.loop'); if (!audio.loop) { audio.loop = false; loopBtn.textContent = 'Loop' loopBtn.classList.remove('active'); } else { audio.loop = true; loopBtn.textContent = 'Unloop' loopBtn.classList.add('active'); } loopBtn.addEventListener('click', () => { if (audio.loop) { audio.loop = false; loopBtn.textContent = 'Loop' loopBtn.classList.remove('active'); } else { audio.loop = true; loopBtn.textContent = 'Unloop' loopBtn.classList.add('active'); } }); const volumeBar = audioController.querySelector('input[name="vol"]'); volumeBar.value = audio.volume; volumeBar.addEventListener('input', (e) => { audio.volume = e.currentTarget.value; if (audio.muted) { audio.muted = false; } }); const muteBtn = audioController.querySelector('.mute'); if (!audio.muted) { audio.muted = false; volumeBar.value = audio.volume; // 背景色の引数を追加 updateSlider(volumeBar, bgc, color); muteBtn.textContent = 'Mute'; muteBtn.classList.remove('active'); } else { audio.muted = true; volumeBar.value = 0; // 背景色の引数を追加 updateSlider(volumeBar, bgc, color); muteBtn.textContent = 'Unmute'; muteBtn.classList.add('active'); } muteBtn.addEventListener('click', () => { if (audio.muted) { audio.muted = false; volumeBar.value = audio.volume; // 背景色の引数を追加 updateSlider(volumeBar, bgc, color); muteBtn.textContent = 'Mute'; muteBtn.classList.remove('active'); } else { audio.muted = true; volumeBar.value = 0; // 背景色の引数を追加 updateSlider(volumeBar, bgc, color); muteBtn.textContent = 'Unmute'; muteBtn.classList.add('active'); } }); audio.addEventListener('volumechange', (e) => { if (e.currentTarget.muted) { volumeBar.value = 0; // 背景色の引数を追加 updateSlider(volumeBar, bgc, color); muteBtn.textContent = 'Unmute'; muteBtn.classList.add('active'); } else { volumeBar.value = audio.volume; // 背景色の引数を追加 updateSlider(volumeBar, bgc, color); muteBtn.textContent = 'Mute'; muteBtn.classList.remove('active'); } }, false); const seekBar = audioController.querySelector('input[name="seek"]'); seekBar.addEventListener('input', (e) => { audio.currentTime = e.currentTarget.value; }); let duration; audio.addEventListener('loadedmetadata', () => { duration = audio.duration; durSpan.textContent = secToHMS(Math.floor(duration)); seekBar.setAttribute('max', Math.floor(duration)); }); audio.addEventListener('timeupdate', updateTime, false); function updateTime() { const cTime = audio.currentTime; ctSpan.textContent = secToHMS(Math.floor(cTime)); seekBar.value = cTime; // 背景色の引数を追加 updateSlider(seekBar, bgc, color); } toggleBtn.addEventListener('click', togglePlayPause, false); function togglePlayPause() { if (audio.paused) { playAudio(); } else { audio.pause(); } } async function playAudio() { try { await audio.play(); toggleBtn.textContent = 'Pause'; toggleBtn.classList.add('playing'); } catch (err) { toggleBtn.classList.remove('playing'); console.warn(err); } } audio.addEventListener('pause', () => { toggleBtn.textContent = 'Play'; toggleBtn.classList.remove('playing'); }); audio.addEventListener('ended', audioEnded, false); function audioEnded() { toggleBtn.textContent = 'Play'; toggleBtn.classList.remove('playing'); audio.currentTime = 0; } const rangeSliders = audioController.querySelectorAll('.range-slider'); rangeSliders.forEach((slider) => { slider.addEventListener('input', (e) => { // 背景色の引数を追加 updateSlider(e.target, bgc, color); }); // 背景色の引数を追加 updateSlider(slider, bgc, color); }); audio.addEventListener('play', (e) => { toggleBtn.textContent = 'Pause'; toggleBtn.classList.add('playing'); }); }; /** * 秒数を引数に受け取り hh:mm:ss に変換する関数(前述の例と同じ) */ function secToHMS(seconds) { const hour = Math.floor(seconds / 3600); const min = Math.floor(seconds % 3600 / 60); const sec = seconds % 60; let hh; if (hour < 100) { hh = (`00${hour}`).slice(-2); } else { hh = hour; } const mm = (`00${min}`).slice(-2); const ss = (`00${sec}`).slice(-2); let time = ''; if (hour !== 0) { time = `${hh}:${mm}:${ss}`; } else { time = `${mm}:${ss}`; } return time; } /** * レンジスライダーのトラックの塗りの範囲と色を更新する関数(前述の例と同じ) */ function updateSlider(slider, bgc = '#ccc', color = '#8ea8f9') { if (!slider.max) { slider.max = 100; } const progress = (slider.value / slider.max) * 100; slider.style.background = `linear-gradient(to right, ${color} ${progress}%, ${bgc} ${progress}%)`; } // 再生を開始すると、他に再生中のものがあれば停止(必要に応じて設定) document.addEventListener('play', (e) => { const audios = document.querySelectorAll('audio'); audios.forEach((audio) => { if (audio !== e.target) { audio.pause(); } }); }, true); });
アイコンを使った音声プレーヤー
以下は前述の音声プレーヤーのボタンの文字をアイコンに置き換えた例です。
HTML
HTML には JavaScript を適用する際に識別するためのクラスや ID を付与した audio 要素を記述し、audio 要素または source 要素の src 属性に音声データの URL を指定します。
必要に応じて loop や mute 属性を指定できます。preload 属性はスクリプト側で metadata に設定しています(変更する場合はスクリプト側を変更します)
<audio class="js-audio" src="sample.mp3"></audio> <audio id="foo"> <source src="sample.mp3"> </audio>
JavaScript で対象の audio 要素を関数に渡すと以下のような HTML が出力されます。
<div class="audio-player"> <div class="controls"> <button class="toggle play-btn" type="button" aria-label="Play"></button> <div class="time" role="timer"> <span class="current-time">00:00</span> </div> <input class="range-slider" type="range" name="seek" value="0" step=".1" aria-label="seek bar" max="31"> <div class="time" role="timer"> <span class="duration">00:31</span> </div> <button class="mute volume-btn" type="button" aria-label="Mute"></button> <input class="range-slider" type="range" name="vol" min="0.0" max="1.0" value="1.0" step=".1" aria-label="volume bar"> <button class="loop loop-btn" type="button" aria-label="Loop"></button> </div> <audio class="js-audio" src="sample.mp3" preload="metadata"></audio> </div>
ボタンから Play や Pause などの文字の記述がなくなったので、代わりにボタンには aria-label 属性を使ってその役割を明示しています。
CSS
アイコンは CSS の ::before 疑似要素を使って background-image の値に url() 関数でデータ URL スキームを使って svg のコードを指定して表示しています。(CSS で svg 要素を表示)。
アイコンの大きさは疑似要素 button::before の width と height で指定できます。
アイコンの色はそれぞれの ::before 疑似要素の background-image に指定した SVG コードの fill 属性(例 fill='%23ffffff')で指定できます。 SVG コードの値はエスケープされているので、# は %23 に変換されています。
また、スマホなど画面幅が狭い場合は、メディアクエリでボリュームバーを非表示にしています(iPhone などではコントロールではボリュームの変更ができないので不要)。
.audio-player { position: relative; margin: 50px 0; width: 100%; max-width: 420px; padding: 10px; background-color: #0d023a; color: #fff; border-radius: 29px; } .audio-player .controls { display: flex; gap: 5px; flex-wrap: nowrap; /* 垂直方向中央揃え */ align-items: center; } .audio-player .controls button { cursor: pointer; border: none; background-color: transparent; } .audio-player .controls button.play-btn, .audio-player .controls button.volume-btn { margin-right: -20px; padding-right: 10px; } .audio-player input[name="vol"] { /* 480 未満では非表示 */ display: none; } @media screen and (min-width: 480px) { .audio-player { padding: 10px 10px; } .audio-player .controls { gap: 10px; } .audio-player input[name="vol"] { display: block; } } /* レンジスライダー */ .audio-player input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; cursor: pointer; outline: none; border-radius: 15px; height: 6px; background: #ccc; } .audio-player input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 15px; width: 15px; background-color: #3976d8; border-radius: 50%; border: none; transition: .2s ease-in-out; } .audio-player input[type="range"]::-moz-range-thumb { height: 15px; width: 15px; background-color: #3976d8; border-radius: 50%; border: none; transition: .2s ease-in-out; } .audio-player input[type="range"]::-webkit-slider-thumb:hover { box-shadow: 0 0 0 8px rgba(251, 255, 0, 0.3) } .audio-player input[type="range"]:active::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } .audio-player input[type="range"]:focus::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } .audio-player input[type="range"]::-moz-range-thumb:hover { box-shadow: 0 0 0 8px rgba(251, 255, 0, .3) } .audio-player input[type="range"]:active::-moz-range-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } .audio-player input[type="range"]:focus::-moz-range-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } /* ボタンに挿入する疑似要素の共通設定 */ .audio-player .controls button::before { content: ""; display: inline-block; height: 24px; width: 24px; vertical-align: -10px; margin-right: 8px; background-repeat: no-repeat; } /* ボタンによって異なる大きさや位置の個別の設定 */ .audio-player .controls button.loop-btn::before { height: 20px; width: 20px; vertical-align: -4px; } .audio-player .controls .time { font-size: 12px; line-height: 36px; } /* ボタンに表示する SVG アイコン*/ /* Play ボタン*/ .audio-player button.play-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E %3Cpath d='m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z'/%3E%3C/svg%3E"); } /* Pause ボタン */ .audio-player button.play-btn.playing::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E %3Cpath d='M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z'/%3E%3C/svg%3E"); } /* Mute ボタン */ .audio-player button.volume-btn.muted::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f74848' viewBox='0 0 16 16'%3E %3Cpath d='M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zM6 5.04 4.312 6.39A.5.5 0 0 1 4 6.5H2v3h2a.5.5 0 0 1 .312.11L6 10.96V5.04zm7.854.606a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E"); } .audio-player button.volume-btn.muted { transform: scale(1.1); } /* Volume ボタン */ .audio-player button.volume-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E %3Cpath d='M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z'/%3E %3Cpath d='M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z'/%3E %3Cpath d='M10.025 8a4.486 4.486 0 0 1-1.318 3.182L8 10.475A3.489 3.489 0 0 0 9.025 8c0-.966-.392-1.841-1.025-2.475l.707-.707A4.486 4.486 0 0 1 10.025 8zM7 4a.5.5 0 0 0-.812-.39L3.825 5.5H1.5A.5.5 0 0 0 1 6v4a.5.5 0 0 0 .5.5h2.325l2.363 1.89A.5.5 0 0 0 7 12V4zM4.312 6.39 6 5.04v5.92L4.312 9.61A.5.5 0 0 0 4 9.5H2v-3h2a.5.5 0 0 0 .312-.11z'/%3E%3C/svg%3E"); } /* Loop ボタン */ .audio-player button.loop-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E %3Cpath d='M11 5.466V4H5a4 4 0 0 0-3.584 5.777.5.5 0 1 1-.896.446A5 5 0 0 1 5 3h6V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192Zm3.81.086a.5.5 0 0 1 .67.225A5 5 0 0 1 11 13H5v1.466a.25.25 0 0 1-.41.192l-2.36-1.966a.25.25 0 0 1 0-.384l2.36-1.966a.25.25 0 0 1 .41.192V12h6a4 4 0 0 0 3.585-5.777.5.5 0 0 1 .225-.67Z'/%3E%3C/svg%3E"); } /* Loop ボタン (Active) */ .audio-player button.loop-btn.looped::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fbf704' viewBox='0 0 16 16'%3E %3Cpath d='M11 5.466V4H5a4 4 0 0 0-3.584 5.777.5.5 0 1 1-.896.446A5 5 0 0 1 5 3h6V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192Zm3.81.086a.5.5 0 0 1 .67.225A5 5 0 0 1 11 13H5v1.466a.25.25 0 0 1-.41.192l-2.36-1.966a.25.25 0 0 1 0-.384l2.36-1.966a.25.25 0 0 1 .41.192V12h6a4 4 0 0 0 3.585-5.777.5.5 0 0 1 .225-.67Z'/%3E%3C/svg%3E"); } .audio-player button.loop-btn.looped { transform: scale(1.1); }
JavaScript
JavaScript は前述の例と内容的にはほぼ同じです。click イベントなどでボタンの文字列や背景色を変更する代わりに、クラスの着脱によりアイコンを変更し、aria-label 属性の値を更新ています。
audio.preload = 'metadata' は iPhone 対策ですが、この設定をしたくない場合は初回再生前はシークバーでの操作をできないようにする方法もあります(シークバー操作)。
document.addEventListener('DOMContentLoaded', () => { // js-audio クラスの audio 要素を全て取得 const jsAudios = document.querySelectorAll('.js-audio'); // 上記で取得した各要素を関数 createAudioPlayer() に渡して実行 jsAudios.forEach((audio) => { // 対象の audio 要素をカスタムプレーヤーで表示 createAudioPlayer(audio); }); // id が foo の要素を取得 const foo = document.getElementById('foo'); // 関数に渡して実行(引数に背景色とボリュームの初期値を指定) createAudioPlayer(foo, 'yellow', 'pink', 0.8); /* 以下カスタムプレーヤーを表示する関数の定義 */ /** * カスタムプレーヤーを表示する関数 * 別途定義した関数 secToHMS() と updateSlider() が必要 * @param {HTMLElement} audio (カスタムプレーヤーで表示する audio 要素 ※必須) * @param {String} bgc スライダー部分のトラックの背景色(省略時は #ccc) * @param {String} color スライダー部分の変化する領域の背景色(省略時は #8ea8f9) * @param {Float} vol 初期ボリューム。0.0〜1.0 の範囲で指定(省略時は 1.0) */ function createAudioPlayer(audio, bgc, color, vol = 1.0) { // 第1引数の audio が存在しない場合やそれが audio 要素でなければ終了 if (!audio || audio.tagName !== 'AUDIO') { return; } // オーディオプレーヤーをラップする div 要素(ラッパー)を作成 const audioPlayer = document.createElement('div'); // クラス属性を付与 audioPlayer.className = 'audio-player'; // audio 要素の前にラッパーを挿入 audio.parentNode.insertBefore(audioPlayer, audio); // audio 要素をラッパーに追加 audioPlayer.appendChild(audio); // コントロール部分の HTML const controls = `<div class="controls"> <button class="toggle play-btn" type="button" aria-label="Play"></button> <div class="time" role="timer"> <span class="current-time">0:00</span> </div> <input class="range-slider" type="range" name="seek" value="0" step=".1" aria-label="seek bar"> <div class="time" role="timer"> <span class="duration">0:00</span> </div> <button class="mute volume-btn" type="button" aria-label="Mute"></button> <input class="range-slider" type="range" name="vol" min="0.0" max="1.0" value="1.0" step=".1" aria-label="volume bar"> <button class="loop loop-btn" type="button" aria-label="Loop"></button> </div>`; // コントロール部分を insertAdjacentHTML でラッパーに追加 audioPlayer.insertAdjacentHTML('afterbegin', controls); // audio 要素を非表示に audio.controls = false; // 引数の vol を volume に設定 audio.volume = vol; // iPhone 対策 audio.preload = 'metadata'; // トグルボタン(再生・停止ボタン) const toggleBtn = audioPlayer.querySelector('.toggle'); // 現在の再生位置(時間)を表示する要素 const ctSpan = audioPlayer.querySelector('.time .current-time'); // 現在の再生位置(時間)を hh:mm:ss に変換して表示 ctSpan.textContent = secToHMS(audio.currentTime); // 再生時間を表示する要素 const durSpan = audioPlayer.querySelector('.time .duration'); // ループボタン const loopBtn = audioPlayer.querySelector('.loop'); // ループボタンがクリックされた際の処理 loopBtn.addEventListener('click', () => { if (audio.loop) { audio.loop = false; loopBtn.classList.remove('looped'); loopBtn.setAttribute('aria-label', 'Loop'); } else { audio.loop = true; loopBtn.classList.add('looped'); loopBtn.setAttribute('aria-label', 'Unloop'); } }); // 記述されている audio 要素の loop 属性の値をループボタンに反映 if (!audio.loop) { audio.loop = false; loopBtn.classList.remove('looped'); loopBtn.setAttribute('aria-label', 'Loop'); } else { audio.loop = true; loopBtn.classList.add('looped'); loopBtn.setAttribute('aria-label', 'Unloop'); } // ボリュームスライダー const volumeBar = audioPlayer.querySelector('input[name="vol"]'); // スライダーの値に現在の volume の値(初期値)を設定 volumeBar.value = audio.volume; // スライダーの値が変更されたら volumeBar.addEventListener('input', (e) => { // スライダーの値に現在の値を設定 audio.volume = e.currentTarget.value; // ミュート中であればミュートを解除 if (audio.muted) { audio.muted = false; muteBtn.classList.remove('muted'); muteBtn.setAttribute('aria-label', 'Mute'); } }); // ミュートボタン const muteBtn = audioPlayer.querySelector('.mute'); // ミュートボタンがクリックされた際の処理 muteBtn.addEventListener('click', () => { // ミュート中であれば if (audio.muted) { // ミュートを解除 audio.muted = false; // ボリュームバーの位置を更新 volumeBar.value = audio.volume; // ボリュームバーの背景色を更新 updateSlider(volumeBar, bgc, color); // ミュートボタンの背景色を変更 muteBtn.classList.remove('muted'); // ミュートボタンの aria-label 属性の値を変更 muteBtn.setAttribute('aria-label', 'Mute'); } else { // ミュートする audio.muted = true; // ボリュームバーの位置を0に volumeBar.value = 0; // ボリュームバーの背景色を更新 updateSlider(volumeBar, bgc, color); muteBtn.classList.add('muted'); muteBtn.setAttribute('aria-label', 'Unmute'); } }); // 記述されている audio 要素の muted 属性の値を反映 if (!audio.muted) { audio.muted = false; volumeBar.value = audio.volume; updateSlider(volumeBar, bgc, color); muteBtn.classList.remove('muted'); muteBtn.setAttribute('aria-label', 'Mute'); } else { audio.muted = true; volumeBar.value = 0; updateSlider(volumeBar, bgc, color); muteBtn.classList.add('muted'); muteBtn.setAttribute('aria-label', 'Unmute'); } // ミュート状態の変更を検出 // 独自コントロールと audio 要素のコントロールを同期するための処理 audio.addEventListener('volumechange', (e) => { // ボリュームが変更されてミュート状態であれば if (e.currentTarget.muted) { // ボリュームバーの値を0に volumeBar.value = 0; // ボリュームバーの背景色を更新 updateSlider(volumeBar, bgc, color); muteBtn.classList.add('muted'); muteBtn.setAttribute('aria-label', 'Unmute'); } else { // ボリュームバーの値を現在の volue の値に volumeBar.value = audio.volume; // ボリュームバーの背景色を更新 updateSlider(volumeBar, bgc, color); muteBtn.classList.remove('muted'); muteBtn.setAttribute('aria-label', 'Mute'); } }, false); // シークバー(再生位置を表すレンジ入力欄のスライダー) const seekBar = audioPlayer.querySelector('input[name="seek"]'); // シークバーの値が変更されたら seekBar.addEventListener('input', (e) => { //再生位置を変更された値に更新 audio.currentTime = e.currentTarget.value; }); // 再生時間(音声データの長さ) let duration; // メタデータの読み込みが完了した時点(loadedmetadata)で再生時間を取得 audio.addEventListener('loadedmetadata', () => { duration = audio.duration; // 再生時間を hh:mm:ss に変換して表示 durSpan.textContent = secToHMS(Math.floor(duration)); // シークバー(レンジ入力欄)の max 属性に再生時間を設定 seekBar.setAttribute('max', Math.floor(duration)); }); // currentTime プロパティの値が更新される際のリスナーの登録 audio.addEventListener('timeupdate', updateTime, false); function updateTime() { const cTime = audio.currentTime; // 現在の再生位置(時間)の表示を更新 ctSpan.textContent = secToHMS(Math.floor(cTime)); // シークバーの現在の再生位置を更新 seekBar.value = cTime; // シークバーの塗り色を更新 updateSlider(seekBar, bgc, color); } // トグルボタンのクリックイベントのリスナーの登録 toggleBtn.addEventListener('click', togglePlayPause, false); function togglePlayPause() { // 停止中であれば if (audio.paused) { // 再生用関数 playAudio() を呼び出す playAudio(); } else { // 再生中であれば停止(ボタンのラベルと背景色は pause イベントで変更) audio.pause(); } } // 音声データを再生する関数(Async Function) async function playAudio() { try { // await を指定(Promise が確定するまで待つ) await audio.play(); // Promise が解決されたらボタンの aria-label の値を変更し、クラスを追加 toggleBtn.classList.add('playing'); toggleBtn.setAttribute('aria-label', 'Pause'); } catch (err) { // 再生が失敗し、クラスが追加されていればクラスを削除 toggleBtn.classList.remove('playing'); // コンソールにエラーを出力 console.warn(err) } } // pause イベントのリスナー(ボタンのラベルと背景色を変更) audio.addEventListener('pause', () => { toggleBtn.classList.remove('playing'); toggleBtn.setAttribute('aria-label', 'Play'); }); // play イベントのリスナー(ボタンのラベルと背景色を変更) // 独自コントロールと audio 要素のコントロールを同期するための処理 audio.addEventListener('play', (e) => { toggleBtn.classList.add('playing'); toggleBtn.setAttribute('aria-label', 'Pause'); }); // 再生終了時に発火する ended イベントのリスナー登録 audio.addEventListener('ended', audioEnded, false); function audioEnded() { toggleBtn.classList.remove('playing'); toggleBtn.setAttribute('aria-label', 'Play'); // 再生位置を先頭に戻す場合は以下のコメントを外す //audio.currentTime = 0; } // ボリュームと再生位置のレンジスライダー(レンジ入力欄の背景色を設定)を取得 const rangeSliders = audioPlayer.querySelectorAll('.range-slider'); // レンジスライダーの input イベントに別途定義した関数 updateSlider を設定 rangeSliders.forEach((slider) => { slider.addEventListener('input', (e) => { // 背景色を更新 updateSlider(e.target, bgc, color); }); // 初期状態に現在の状態での背景色を反映 updateSlider(slider, bgc, color); }); }; /** * 秒数を引数に受け取り hh:mm:ss に変換する関数 * @param {Number} seconds 秒数 */ function secToHMS(seconds) { const hour = Math.floor(seconds / 3600); const min = Math.floor(seconds % 3600 / 60); const sec = seconds % 60; let hh; if (hour < 100) { hh = (`00${hour}`).slice(-2); } else { hh = hour; } const mm = (`00${min}`).slice(-2); const ss = (`00${sec}`).slice(-2); let time = ''; if (hour !== 0) { time = `${hh}:${mm}:${ss}`; } else { time = `${mm}:${ss}`; } return time; } /** * レンジスライダーのトラックの塗りの範囲と色を更新する関数 * @param {HTMLElement} slider レンジスライダー(input type="range") * @param {String} bgc ベースとなるトラックの背景色(デフォルト #ccc) * @param {String} color 変化する領域(ツマミの左側)の背景色(デフォルト #8ea8f9) */ function updateSlider(slider, bgc = '#ccc', color = '#8ea8f9') { if (!slider.max) { slider.max = 100; } const progress = (slider.value / slider.max) * 100; slider.style.background = `linear-gradient(to right, ${color} ${progress}%, ${bgc} ${progress}%)`; } /** * プレーヤーが開始されると、他のプレーヤーを一時停止させる関数 * @param {String} selector audio 要素の CSS セレクタ名(デフォルト audio 要素) */ function pauseOtherAudioPlayers(selector = 'audio') { // ドキュメントの play イベント document.addEventListener('play', (e) => { // 全ての selector で指定された要素を取得 const audios = document.querySelectorAll(selector); // それぞれの audio 要素で以下を実行 audios.forEach((audio) => { // audio が存在しない場合やそれが audio 要素でなければ終了(audio 要素とは限らない) if (!audio || audio.tagName !== 'AUDIO') { return; } // play イベントが発生した要素が自身でなければ停止 if (audio !== e.target) { audio.pause(); } }); }, true); } //プレーヤーを開始すると、他のプレーヤーを一時停止(不要であれば以下をコメントアウトまたは削除) pauseOtherAudioPlayers() });
166〜183行目と253〜256行目は独自コントロールと audio 要素自体のコントロールを同期するための処理です。何らかの理由で JavaScript から直接 audio 要素を操作する場合に、独自コントロールのスタイル(ボタンのアイコンなど)も同期するようにしています。
329行目からの pauseOtherAudioPlayers() はプレーヤーが開始されると、他のプレーヤーを一時停止させる関数です。引数を省略すると、そのページの audio 要素は再生を開始すると、他が再生中の場合は、それを停止します。不要であれば349行目(または関数も含め)を削除します。
プレイリストの作成
以下はリストに記載された音声データ(トラック)を順番に再生するプレイリストの例です。
音声データ(トラック)が最後に到達すると次のトラックが再生され、リストの項目をクリックするとその項目のトラックが再生されます。
スキップボタンをクリックすると次または前のトラックを再生します。ループボタンをクリックして有効にした場合は、最後のトラックの再生が終了すると先頭のトラックが再生されます。
- 鳥の鳴き声1
- 小川のせせらぎ
- 波の音
- 鳥の鳴き声2
関連ページ:Javascript で audio を操作(プレイリストの作成)
HTML
コントロールは3段(.row)に配置し、1段目は再生・停止のトグルボタンと再生中のデータのタイトル、2段目には再生時間と再生位置及びシークバー、3段目にはスキップボタンとミュートボタン、ボリュームバー、ループボタンを配置しています。
再生する音声データ(トラック)は、.track-list 内の ul li 要素にタイトルをテキストとして記述し、音声データの URL をカスタムデータ属性 data-audio-src に指定します。
<div class="audio-playlist"> <div class="controls"> <div class="row"> <button class="toggle play-btn" type="button" aria-label="Play"></button> <div class="playing-title">Title</div> </div> <div class="row"> <div class="time" role="timer"> <span class="current-time">0:00</span> </div> <input class="range-slider" type="range" name="seek" value="0" step=".1" aria-label="seek bar"> <div class="time" role="timer"> <span class="duration">0:00</span> </div> </div> <div class="row"> <button class="skip-backward skip-backward-btn" type="button" aria-label="Skip Backward"></button> <button class="skip-forward skip-forward-btn" type="button" aria-label="Skip Forward"></button> <button class="mute volume-btn" type="button" aria-label="Mute"></button> <input class="range-slider" type="range" name="vol" min="0.0" max="1.0" value="1.0" step=".1" aria-label="volume bar"> <button class="loop loop-btn" type="button" aria-label="Loop"></button> </div> </div> <div class="track-list"> <ul> <li data-audio-src="birds1.mp3">鳥の鳴き声1</li> <li data-audio-src="tream1.mp3">小川のせせらぎ</li> <li data-audio-src="wave1.mp3">波の音</li> <li data-audio-src="birds2.mp3">鳥の鳴き声2</li> </ul> </div> </div>
CSS
CSS では再生及び停止のアイコンを変更して大きめに表示し、スキップボタンを追加しています。
音声データのリスト(トラックリスト)では、ul 要素で ::before 疑似要素を使って counter(list-item) でナンバリングしています。これは番号部分のスタイルがしやすいためです。
また、リストに項目が1つしかない場合は、JavaScript により .audio-playlist に single クラスが追加され、single モードとしてタイトルやスキップボタンを非表示にして、コントロールを1段で表示します。
.audio-playlist { position: relative; margin: 50px 0; width: 100%; max-width: 620px; border: 1px solid #999; padding: 10px; background-color: #000; color: #eee; } /* single モード */ .audio-playlist.single { max-width: 480px; padding: 10px 0; } .audio-playlist .controls { max-width: 380px; } /* single モード */ .audio-playlist.single .controls { display: flex; max-width: none; } .audio-playlist .controls .row { display: flex; gap: 10px; flex-wrap: nowrap; align-items: center; margin: 10px 0; } /* single モード */ .audio-playlist.single .controls .row { gap: 2px; margin: 5px 0; } .audio-playlist .controls button { cursor: pointer; border: none; background-color: transparent; position: relative; } .audio-playlist input[name="vol"] { display: none; } @media screen and (min-width: 480px) { .audio-playlist { padding: 10px 15px; } .audio-playlist input[name="vol"] { display: block; } } .audio-playlist .controls button::before { content: ""; display: inline-block; height: 24px; width: 24px; vertical-align: -10px; margin-right: 8px; background-repeat: no-repeat; } .audio-playlist .controls button.play-btn::before, .audio-playlist .controls button.pause-btn::before { height: 50px; width: 50px; vertical-align: -4px; } /* single モード */ .audio-playlist.single .controls button.play-btn::before, .audio-playlist.single .controls button.pause-btn::before { height: 30px; width: 30px; vertical-align: 0; } .audio-playlist .controls button.loop-btn::before { height: 20px; width: 20px; vertical-align: -4px; } .audio-playlist .controls button.loop-btn.looped::after { content: ''; position: absolute; top: 0; left: 0; width: 4px; height: 4px; border-radius: 50%; background-color: yellow; } .audio-playlist .controls .time { font-size: 12px; line-height: 36px; } .audio-playlist .track-list ul { list-style-type: none; padding-inline-start: .5rem; padding: 0; background-color: rgba(255, 255, 255, .2); } /* counter(list-item) で ul 要素にナンバリング*/ .audio-playlist .track-list ul li::before { content: counter(list-item) '.'; color: #aaa; margin-right: .25rem; } .audio-playlist .track-list ul li.active::before { color: #fff; } .audio-playlist .track-list ul li { padding: .5rem 1rem; cursor: pointer; font-size: 13px; border: 1px solid #000; } .audio-playlist .track-list ul li.active { background-color: #f70f5d; } /* レンジスライダー */ .audio-playlist input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; cursor: pointer; outline: none; border-radius: 15px; height: 6px; background: #ccc; margin: 0 5px; } /* Thumb: webkit */ .audio-playlist input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 15px; width: 15px; background-color: #3976d8; border-radius: 50%; border: none; transition: .2s ease-in-out; } /* Thumb: Firefox */ .audio-playlist input[type="range"]::-moz-range-thumb { height: 15px; width: 15px; background-color: #3976d8; border-radius: 50%; border: none; transition: .2s ease-in-out; } /* Hover, active & focus Thumb: Webkit */ .audio-playlist input[type="range"]::-webkit-slider-thumb:hover { box-shadow: 0 0 0 8px rgba(251, 255, 0, 0.3) } .audio-playlist input[type="range"]:active::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } .audio-playlist input[type="range"]:focus::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } /* Hover, active & focus Thumb: Firfox */ .audio-playlist input[type="range"]::-moz-range-thumb:hover { box-shadow: 0 0 0 8px rgba(251, 255, 0, .3) } .audio-playlist input[type="range"]:active::-moz-range-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } .audio-playlist input[type="range"]:focus::-moz-range-thumb { box-shadow: 0 0 0 5px rgba(251, 255, 0, .4) } /* Play ボタン*/ .audio-playlist button.play-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f70f5d' viewBox='0 0 16 16'%3E %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E %3Cpath d='M6.271 5.055a.5.5 0 0 1 .52.038l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 6 10.5v-5a.5.5 0 0 1 .271-.445z'/%3E%3C/svg%3E"); } /* Pause ボタン */ .audio-playlist button.play-btn.playing::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f70f5d' class='bi bi-pause-circle-fill' viewBox='0 0 16 16'%3E %3Cpath d='M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z'/%3E%3C/svg%3E"); } /* Skip Forward ボタン */ .audio-playlist button.skip-forward-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' class='bi bi-skip-forward-fill' viewBox='0 0 16 16'%3E %3Cpath d='M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.753l-6.267 3.636c-.54.313-1.233-.066-1.233-.697v-2.94l-6.267 3.636C.693 12.703 0 12.324 0 11.693V4.308c0-.63.693-1.01 1.233-.696L7.5 7.248v-2.94c0-.63.693-1.01 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5z'/%3E%3C/svg%3E"); } /* Skip Backward ボタン */ .audio-playlist button.skip-backward-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' class='bi bi-skip-backward-fill' viewBox='0 0 16 16'%3E %3Cpath d='M.5 3.5A.5.5 0 0 0 0 4v8a.5.5 0 0 0 1 0V8.753l6.267 3.636c.54.313 1.233-.066 1.233-.697v-2.94l6.267 3.636c.54.314 1.233-.065 1.233-.696V4.308c0-.63-.693-1.01-1.233-.696L8.5 7.248v-2.94c0-.63-.692-1.01-1.233-.696L1 7.248V4a.5.5 0 0 0-.5-.5z'/%3E%3C/svg%3E"); } /* Mute ボタン */ .audio-playlist button.volume-btn.muted::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f74848' class='bi bi-volume-mute' viewBox='0 0 16 16'%3E %3Cpath d='M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zM6 5.04 4.312 6.39A.5.5 0 0 1 4 6.5H2v3h2a.5.5 0 0 1 .312.11L6 10.96V5.04zm7.854.606a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E"); } .audio-playlist button.volume-btn.muted { transform: scale(1.1); } /* Volume ボタン */ .audio-playlist button.volume-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' class='bi bi-volume-up' viewBox='0 0 16 16'%3E %3Cpath d='M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z'/%3E %3Cpath d='M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z'/%3E %3Cpath d='M10.025 8a4.486 4.486 0 0 1-1.318 3.182L8 10.475A3.489 3.489 0 0 0 9.025 8c0-.966-.392-1.841-1.025-2.475l.707-.707A4.486 4.486 0 0 1 10.025 8zM7 4a.5.5 0 0 0-.812-.39L3.825 5.5H1.5A.5.5 0 0 0 1 6v4a.5.5 0 0 0 .5.5h2.325l2.363 1.89A.5.5 0 0 0 7 12V4zM4.312 6.39 6 5.04v5.92L4.312 9.61A.5.5 0 0 0 4 9.5H2v-3h2a.5.5 0 0 0 .312-.11z'/%3E%3C/svg%3E"); } /* Loop ボタン */ .audio-playlist button.loop-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' class='bi bi-repeat' viewBox='0 0 16 16'%3E %3Cpath d='M11 5.466V4H5a4 4 0 0 0-3.584 5.777.5.5 0 1 1-.896.446A5 5 0 0 1 5 3h6V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192Zm3.81.086a.5.5 0 0 1 .67.225A5 5 0 0 1 11 13H5v1.466a.25.25 0 0 1-.41.192l-2.36-1.966a.25.25 0 0 1 0-.384l2.36-1.966a.25.25 0 0 1 .41.192V12h6a4 4 0 0 0 3.585-5.777.5.5 0 0 1 .225-.67Z'/%3E%3C/svg%3E"); } /* Loop ボタン (Active) */ .audio-playlist button.loop-btn.looped::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fbf704' class='bi bi-repeat' viewBox='0 0 16 16'%3E %3Cpath d='M11 5.466V4H5a4 4 0 0 0-3.584 5.777.5.5 0 1 1-.896.446A5 5 0 0 1 5 3h6V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192Zm3.81.086a.5.5 0 0 1 .67.225A5 5 0 0 1 11 13H5v1.466a.25.25 0 0 1-.41.192l-2.36-1.966a.25.25 0 0 1 0-.384l2.36-1.966a.25.25 0 0 1 .41.192V12h6a4 4 0 0 0 3.585-5.777.5.5 0 0 1 .225-.67Z'/%3E%3C/svg%3E"); }
JavaScript
JavaScript では前述の音声プレーヤーと比べて、音声データのリスト(トラックリスト)とスキップボタンの処理が追加されています。
トラックリストの音声データは順番に再生し、ループが有効な場合は最後のデータの再生が終了すると先頭に戻って再生したり、クリックされたトラックを再生するため、現在再生中のトラック(li 要素)を表す変数 currentTrackLi を用意しています。
次のトラック(li 要素)やその前のトラックは、nextElementSibling や previousElementSibling を使って取得することができます。取得した値が null の場合は次またはその前が存在しない(最後または先頭である)ことになります。
また、トラックリストの項目が1つしかない場合は、リスト部分やスキップボタンは非表示にし、.audio-playlist に single クラスを追加しています。
document.addEventListener('DOMContentLoaded', () => { const audioPlayLists = document.querySelectorAll('.audio-playlist'); audioPlayLists.forEach((playList) => { // リストの親要素(.track-list)を取得 const trackList = playList.querySelector('.track-list'); // 再生する音声データのタイトルを表示する要素 const playingTitle = playList.querySelector('.playing-title'); // リスト項目(音声データを表す track)の要素を全て取得 const tracks = trackList.querySelectorAll('li'); // リスト項目(トラック)がなければメッセージを表示して終了 if (tracks.length < 1) { console.warn('No Track Error: Please add track to the track list'); playingTitle.textContent = 'No Track Data! Add track(s)' return; } // 現在再生中の項目を表す変数 currentTrackLi を停止して先頭のリスト項目を代入 let currentTrackLi = tracks[0]; // currentTrackLi の更新などを行う関数を実行して初期化 updateTrack(currentTrackLi); //音声データを取得 const src = currentTrackLi.dataset.audioSrc; // audio を生成 const audio = new Audio(src); // 初期ボリューム(必要に応じて設定) //audio.volume = 0.5; // iPhone 対策 audio.preload = 'metadata'; // 現在の再生位置(時間)を表示する要素を取得 const ctSpan = playList.querySelector('.time .current-time'); // 現在の再生位置(時間)を hh:mm:ss に変換して表示 ctSpan.textContent = secToHMS(audio.currentTime); // 再生時間の長さを表示する要素を取得 const durSpan = playList.querySelector('.time .duration'); // ループボタン const loopBtn = playList.querySelector('.loop'); // ループが有効かどうかのフラグを定義して初期化 let isLoopActive = false; // ループボタンにクリックイベントを設定 loopBtn.addEventListener('click', () => { if (isLoopActive) { isLoopActive = false; loopBtn.classList.remove('looped'); // aria-label 属性の値を更新 loopBtn.setAttribute('aria-label', 'Loop'); } else { isLoopActive = true; loopBtn.classList.add('looped'); // aria-label 属性の値を更新 loopBtn.setAttribute('aria-label', 'Unloop'); } }); // ボリュームスライダー const volumeBar = playList.querySelector('input[name="vol"]'); // ボリュームスライダーに voluem の初期値を設定 volumeBar.value = audio.volume; // ボリュームスライダーに input イベントを設定 volumeBar.addEventListener('input', (e) => { // 変更された値を volume プロパティに設定(ボリュームを変更) audio.volume = e.currentTarget.value; if (audio.muted) { audio.muted = false; // アイコンを変更 muteBtn.classList.remove('muted'); // aria-label 属性の値を更新 muteBtn.setAttribute('aria-label', 'Mute'); } }); // ミュートボタン const muteBtn = playList.querySelector('.mute'); // ミュートボタンに click イベントを設定 muteBtn.addEventListener('click', () => { if (audio.muted) { audio.muted = false; // ボリュームバーの位置を更新 volumeBar.value = audio.volume; // ボリュームバーのトラックの背景色の領域を更新 updateSlider(volumeBar); muteBtn.classList.remove('muted'); // aria-label 属性の値を更新 muteBtn.setAttribute('aria-label', 'Mute'); } else { audio.muted = true; volumeBar.value = 0; updateSlider(volumeBar); muteBtn.classList.add('muted'); // aria-label 属性の値を更新 muteBtn.setAttribute('aria-label', 'Unmute'); } }); // シークバー const seekBar = playList.querySelector('input[name="seek"]'); // シークバーに input イベントを設定 seekBar.addEventListener('input', (e) => { audio.currentTime = e.currentTarget.value; }); // 再生時間(音声データの長さ)の変数を定義 let duration; // メタデータの読み込みが完了した時点で再生時間を取得して時間を表示 audio.addEventListener('loadedmetadata', () => { // 再生時間の変数の値を更新 duration = audio.duration; // 再生時間を hh:mm:ss に変換して表示 durSpan.textContent = secToHMS(Math.floor(duration)); // シークバー(レンジ入力欄)の max 属性に再生時間を設定 seekBar.setAttribute('max', Math.floor(duration)); }); // currentTime プロパティの値が更新される際に発火するイベント audio.addEventListener('timeupdate', updateTime, false); function updateTime() { // 現在の再生位置(時間)を取得 const cTime = audio.currentTime; // 現在の再生位置(時間)の表示を更新 ctSpan.textContent = secToHMS(Math.floor(cTime)); // シークバーの現在の再生位置を更新 seekBar.value = cTime; updateSlider(seekBar); } // トグルボタン(再生・停止ボタン) const toggleBtn = playList.querySelector('.toggle'); // トグルボタンのクリックイベント toggleBtn.addEventListener('click', togglePlayPause, false); // トグルボタンの関数 function togglePlayPause() { if (audio.paused) { // 再生用関数を呼び出す playAudio(); } else { audio.pause(); toggleBtn.classList.remove('playing'); // aria-label 属性の値を更新 toggleBtn.setAttribute('aria-label', 'Play'); } } // track の li 要素に click イベントのリスナーを登録 tracks.forEach((track) => { track.addEventListener('click', playTrack, false) }); // track の li 要素のリスナー function playTrack(e) { // 現在再生中の項目を表す変数 currentTrackLi にクリックされた要素を代入 currentTrackLi = e.currentTarget; // currentTrackLi(li 要素)に active クラスを追加してそのテキストをタイトルに表示 updateTrack(currentTrackLi); // li 要素のカスタムデータ属性から音声データの URL を取得 const trackSrc = e.currentTarget.dataset.audioSrc; if (audio.paused) { //停止中であれば src に URL を設定して再生 audio.src = trackSrc; playAudio(); } else { //再生中であれば一度停止して src に URL を設定して再生 audio.pause(); audio.src = trackSrc; playAudio(); } } // currentTrackLi の更新と track の li 要素のクラスの着脱及びタイトルの更新 function updateTrack(li) { tracks.forEach((track) => { //全ての要素から active クラスを削除 track.classList.remove('active'); }); // currentTrackLi を引数の要素で更新 currentTrackLi = li; // 引数の要素に active クラスを追加 li.classList.add('active'); // タイトルを引数の要素のテキストで更新 playingTitle.textContent = li.textContent; } // Skip Forward ボタン(次のオーディオへ進むボタン) const skipForwardBtn = playList.querySelector('.skip-forward'); // Skip Forward ボタンにクリックイベントを設定 skipForwardBtn.addEventListener('click', skipForward, false); function skipForward() { // 次の兄弟要素を取得 const nextTrackLi = currentTrackLi.nextElementSibling; // 次のトラックが存在すれば(兄弟要素があれば) if (nextTrackLi) { audio.pause(); updateTrack(nextTrackLi) // src に次のトラックを設定 audio.src = nextTrackLi.dataset.audioSrc; playAudio(); } else { // 次のトラックが存在しなければ先頭のトラックを再生 updateTrack(tracks[0]) // src に最初のトラックを設定 audio.src = tracks[0].dataset.audioSrc; playAudio(); } } // Skip Backward ボタン(前のオーディオへ進むボタン) const skipBackwardBtn = playList.querySelector('.skip-backward'); // Skip Backward ボタンにクリックイベントを設定 skipBackwardBtn.addEventListener('click', skipFBackward, false); function skipFBackward() { const prevTrackLi = currentTrackLi.previousElementSibling; // 前のトラックが存在すれば(前の兄弟要素があれば) if (prevTrackLi) { audio.pause(); updateTrack(prevTrackLi) // src に前のトラックを設定 audio.src = prevTrackLi.dataset.audioSrc; playAudio(); } else { updateTrack(tracks[tracks.length - 1]) // src に最後のトラックを設定 audio.src = tracks[tracks.length - 1].dataset.audioSrc; playAudio(); } } // 再生用関数(play() で再生し、戻り値の Promise を監視する関数) async function playAudio() { try { await audio.play(); toggleBtn.classList.add('playing'); // aria-label 属性の値を更新 toggleBtn.setAttribute('aria-label', 'Pause'); } catch (err) { // 再生できなければコンソールにエラーを出力 console.warn(err) } } // 再生終了時に発火するイベント audio.addEventListener('ended', audioEnded, false); // 再生終了時に呼び出す関数 function audioEnded() { const nextTrackLi = currentTrackLi.nextElementSibling; // 次のトラックが存在すれば if (nextTrackLi) { audio.pause(); updateTrack(nextTrackLi) // src に次のトラックを設定 audio.src = nextTrackLi.dataset.audioSrc; playAudio(); } else { if (!isLoopActive) { // paused クラスを削除してアイコンを変更 toggleBtn.classList.remove('playing'); // aria-label 属性の値を更新 toggleBtn.setAttribute('aria-label', 'Play'); } else { audio.pause(); updateTrack(tracks[0]); // src に次のトラック(先頭のトラック)を設定 audio.src = tracks[0].dataset.audioSrc; playAudio(); } } } // リストが1つしかない場合は single モード if (tracks.length === 1) { // リスト、スキップボタン、タイトルを非表示 trackList.style.display = 'none'; skipBackwardBtn.style.display = 'none'; skipForwardBtn.style.display = 'none'; playingTitle.style.display = 'none'; // .audio-playlist に single クラスを追加 playList.classList.add('single'); } // レンジスライダー const rangeSliders = playList.querySelectorAll('.range-slider'); rangeSliders.forEach((slider) => { // レンジスライダーの input イベントに関数 updateSlider を登録 slider.addEventListener('input', (e) => { updateSlider(e.target); }); updateSlider(slider); }); }); /** * 秒数を引数に受け取り hh:mm:ss に変換する関数(前述の例と同じ) * @param {Number} seconds 秒数 */ function secToHMS(seconds) { const hour = Math.floor(seconds / 3600); const min = Math.floor(seconds % 3600 / 60); const sec = seconds % 60; let hh; // 3桁未満ならゼロパディング if (hour < 100) { hh = (`00${hour}`).slice(-2); } else { hh = hour; } // mm:ss の形式にするためゼロパディング const mm = (`00${min}`).slice(-2); const ss = (`00${sec}`).slice(-2); let time = ''; if (hour !== 0) { // 1時間以上であれば hh:mm:ss time = `${hh}:${mm}:${ss}`; } else { // 1時間未満であれば mm:ss time = `${mm}:${ss}`; } return time; } /** * レンジスライダーのトラックの塗りの範囲と色を更新する関数(前述の例と同じ) * @param {HTMLElement} slider レンジスライダー(input type="range") * @param {String} bgc ベースとなるトラックの背景色(デフォルト #ccc) * @param {String} color 変化する領域(ツマミの左側)の背景色(デフォルト #8ea8f9) */ function updateSlider(slider, bgc = '#ccc', color = '#8ea8f9') { if (!slider.max) { // max 属性が設定されていなければ 100 を設定 slider.max = 100; } // 現在の値から割合(%)を取得 const progress = (slider.value / slider.max) * 100; // linear-gradient でトラックの背景色の領域を引数の色で更新 slider.style.background = `linear-gradient(to right, ${color} ${progress}%, ${bgc} ${progress}%)`; } });
項目が1つだけの場合
以下はリスト項目(トラック)が1つだけの場合(single モード)の表示例です。
ul 要素に li 要素が1つだけ指定されている場合はリスト部分とタイトル、スキップボタンを非表示にしてプレイリストではない通常の音声プレーヤーとして使えるようにしています。
<div class="audio-playlist"> <div class="controls"> ・・・中略・・・ </div> <div class="track-list"> <ul> <li data-audio-src="birds1.mp3">鳥の鳴き声</li> </ul> </div> </div>
- 鳥の鳴き声3
コントロールを JavaScript で追加
以下はリスト部分の HTML(ul li 要素)を受け取ってプレイリストを出力する関数に書き換えたものです。
例えば、以下のような音声データの URL をカスタムデータ属性 data-audio-src に指定し、タイトルをコンテンツに記述した ul li 要素(リスト)を関数に渡してプレイリストを表示します。
ul 要素には JavaScript で識別するための class や id を指定します。
<ul class="audio-list"> <li data-audio-src="birds1.mp3">鳥の鳴き声1</li> <li data-audio-src="stream1.mp3">小川のせせらぎ</li> <li data-audio-src="wave1.mp3">波の音</li> <li data-audio-src="birds2.mp3">鳥の鳴き声2</li> </ul> <ul id="bar"> <li data-audio-src="birds3.mp3">鳥の鳴き声13</li> </ul>
プレイリストを表示する関数 createAudioPlayList() では音声データのリスト以外の部分を作成し(34〜69行目)、引数に受け取ったリストを追加します(74行目)。
それ以外の部分は前述の例とほぼ同じです。異なる部分にはコメントを入れてあります。
最後の play イベントのリスナー(354〜361行目)は、同時に音声データが再生されないようにするため、再生を開始すると、他に再生中のものがあれば停止するための処理です。
document.addEventListener('DOMContentLoaded', () => { //対象のリストを取得 const audioList = document.querySelectorAll('.audio-list'); // 上記で取得した各要素を関数 createAudioPlayer() に渡して実行 audioList.forEach((elem) => { // 対象の要素をプレイリストで表示 createAudioPlayList(elem); }); // id が bar の要素(対象のリスト)を取得 const bar = document.getElementById('bar'); // 関数に渡して実行(引数に背景色とボリュームの初期値を指定) createAudioPlayList(bar, 'yellow', 'pink', 0.8); /* 以下プレイリストを表示する関数の定義 */ /** * プレイリストの要素を受け取りコントロールを生成する関数 * 別途定義した関数 secToHMS() と updateSlider() が必要 * @param {HTMLElement} targetList (プレイリストの ul 要素 ※必須) * @param {String} bgc スライダー部分のトラックの背景色(省略時は #ccc) * @param {String} color スライダー部分の変化する領域の背景色(省略時は #8ea8f9) * @param {Float} vol 初期ボリューム。0.0〜1.0 の範囲で指定(省略時は 1.0) */ function createAudioPlayList(targetList, bgc, color, vol = 1.0) { // 第1引数の targetList が存在しない場合やそれが ul 要素でなければ終了 if (!targetList || targetList.tagName !== 'UL') { return; } // オーディオプレーヤーをラップする div 要素(ラッパー)を作成 const playList = document.createElement('div'); // クラス属性を付与 playList.className = 'audio-playlist'; // targetList の前にラッパーを挿入 targetList.parentNode.insertBefore(playList, targetList); // targetList をラッパーに追加 playList.appendChild(targetList); // コントロール(.controls)、リストのラッパー(.track-list)、audio 要素の HTML const htmls = `<div class="controls"> <div class="row"> <button class="toggle play-btn" type="button" aria-label="Play"></button> <div class="playing-title">Title</div> </div> <div class="row"> <div class="time" role="timer"> <span class="current-time">0:00</span> </div> <input class="range-slider" type="range" name="seek" value="0" step=".1" aria-label="seek bar"> <div class="time" role="timer"> <span class="duration">0:00</span> </div> </div> <div class="row"> <button class="skip-backward skip-backward-btn" type="button" aria-label="Skip Backward"></button> <button class="skip-forward skip-forward-btn" type="button" aria-label="Skip Forward"></button> <button class="mute volume-btn" type="button" aria-label="Mute"></button> <input class="range-slider" type="range" name="vol" min="0.0" max="1.0" value="1.0" step=".1" aria-label="volume bar"> <button class="loop loop-btn" type="button" aria-label="Loop"></button> </div> </div> <div class="track-list"></div> <audio></audio>`; // 上記を insertAdjacentHTML で追加 playList.insertAdjacentHTML('afterbegin', htmls); // リストのラッパー(.track-list) const trackList = playList.querySelector('.track-list'); // リストのラッパーに引数に受け取ったリストを追加 trackList.appendChild(targetList); const playingTitle = playList.querySelector('.playing-title'); const tracks = trackList.querySelectorAll('li'); if (tracks.length < 1) { console.warn('No Track Error: Please add track to the track list'); playingTitle.textContent = 'No Track Data! Add track(s)' return; } let currentTrackLi = tracks[0]; updateTrack(currentTrackLi); // audio 要素は予め HTML にコントロールと一緒に記述して挿入 const audio = playList.querySelector('audio'); // audio 要素の src に先頭のリストの音声データを設定 audio.src = currentTrackLi.dataset.audioSrc; // audio 要素を非表示に(省略可能) audio.controls = false; // 引数の vol を volume に設定 audio.volume = vol; audio.preload = 'metadata'; const ctSpan = playList.querySelector('.time .current-time'); ctSpan.textContent = secToHMS(audio.currentTime); const durSpan = playList.querySelector('.time .duration'); const loopBtn = playList.querySelector('.loop'); let isLoopActive = false; loopBtn.addEventListener('click', () => { if (isLoopActive) { isLoopActive = false; loopBtn.classList.remove('looped'); loopBtn.setAttribute('aria-label', 'Loop'); } else { isLoopActive = true; loopBtn.classList.add('looped'); loopBtn.setAttribute('aria-label', 'Unloop'); } }); const volumeBar = playList.querySelector('input[name="vol"]'); volumeBar.value = audio.volume; volumeBar.addEventListener('input', (e) => { audio.volume = e.currentTarget.value; if (audio.muted) { audio.muted = false; muteBtn.classList.remove('muted'); muteBtn.setAttribute('aria-label', 'Mute'); } }); const muteBtn = playList.querySelector('.mute'); muteBtn.addEventListener('click', () => { if (audio.muted) { audio.muted = false; volumeBar.value = audio.volume; // ボリュームバーの背景色を更新 updateSlider(volumeBar, bgc, color); muteBtn.classList.remove('muted'); muteBtn.setAttribute('aria-label', 'Mute'); } else { audio.muted = true; volumeBar.value = 0; // ボリュームバーの背景色を更新 updateSlider(volumeBar, bgc, color); muteBtn.classList.add('muted'); muteBtn.setAttribute('aria-label', 'Unmute'); } }); const seekBar = playList.querySelector('input[name="seek"]'); seekBar.addEventListener('input', (e) => { audio.currentTime = e.currentTarget.value; }); let duration; audio.addEventListener('loadedmetadata', () => { duration = audio.duration; durSpan.textContent = secToHMS(Math.floor(duration)); seekBar.setAttribute('max', Math.floor(duration)); }); audio.addEventListener('timeupdate', updateTime, false); function updateTime() { const cTime = audio.currentTime; ctSpan.textContent = secToHMS(Math.floor(cTime)); seekBar.value = cTime; // シークバーの背景色を更新 updateSlider(seekBar, bgc, color); } const toggleBtn = playList.querySelector('.toggle'); toggleBtn.addEventListener('click', togglePlayPause, false); function togglePlayPause() { if (audio.paused) { playAudio(); } else { audio.pause(); toggleBtn.classList.remove('playing'); toggleBtn.setAttribute('aria-label', 'Play'); } } tracks.forEach((track) => { track.addEventListener('click', playTrack, false) }); function playTrack(e) { currentTrackLi = e.currentTarget; updateTrack(currentTrackLi); const trackSrc = e.currentTarget.dataset.audioSrc; if (audio.paused) { audio.src = trackSrc; playAudio(); } else { audio.pause(); audio.src = trackSrc; playAudio(); } } function updateTrack(li) { tracks.forEach((track) => { track.classList.remove('active'); }); currentTrackLi = li; li.classList.add('active'); playingTitle.textContent = li.textContent; } const skipForwardBtn = playList.querySelector('.skip-forward'); skipForwardBtn.addEventListener('click', skipForward, false); function skipForward() { const nextTrackLi = currentTrackLi.nextElementSibling; if (nextTrackLi) { audio.pause(); updateTrack(nextTrackLi) audio.src = nextTrackLi.dataset.audioSrc; playAudio(); } else { updateTrack(tracks[0]) audio.src = tracks[0].dataset.audioSrc; playAudio(); } } const skipBackwardBtn = playList.querySelector('.skip-backward'); skipBackwardBtn.addEventListener('click', skipFBackward, false); function skipFBackward() { const prevTrackLi = currentTrackLi.previousElementSibling; if (prevTrackLi) { audio.pause(); updateTrack(prevTrackLi) audio.src = prevTrackLi.dataset.audioSrc; playAudio(); } else { updateTrack(tracks[tracks.length - 1]) audio.src = tracks[tracks.length - 1].dataset.audioSrc; playAudio(); } } async function playAudio() { try { await audio.play(); toggleBtn.classList.add('playing'); toggleBtn.setAttribute('aria-label', 'Pause'); } catch (err) { console.warn(err) } } // pause イベントのリスナー(ボタンのラベルと背景色を変更) audio.addEventListener('pause', () => { toggleBtn.classList.remove('playing'); toggleBtn.setAttribute('aria-label', 'Play'); }); // 独自コントロールと audio 要素のコントロールを同期するための処理(省略可能) // play イベントのリスナー(ボタンのラベルと背景色を変更) audio.addEventListener('play', (e) => { toggleBtn.classList.add('playing'); toggleBtn.setAttribute('aria-label', 'Pause'); }); audio.addEventListener('ended', audioEnded, false); function audioEnded() { const nextTrackLi = currentTrackLi.nextElementSibling; if (nextTrackLi) { audio.pause(); updateTrack(nextTrackLi) audio.src = nextTrackLi.dataset.audioSrc; playAudio(); } else { if (!isLoopActive) { toggleBtn.classList.remove('playing'); toggleBtn.setAttribute('aria-label', 'Play'); } else { audio.pause(); updateTrack(tracks[0]); audio.src = tracks[0].dataset.audioSrc; playAudio(); } } } if (tracks.length === 1) { trackList.style.display = 'none'; skipBackwardBtn.style.display = 'none'; skipForwardBtn.style.display = 'none'; playingTitle.style.display = 'none'; playList.classList.add('single'); } const rangeSliders = playList.querySelectorAll('.range-slider'); rangeSliders.forEach((slider) => { slider.addEventListener('input', (e) => { // 背景色を更新 updateSlider(e.target, bgc, color); }); // 初期状態に現在の状態での背景色を反映 updateSlider(slider, bgc, color); }); }; /** * 秒数を引数に受け取り hh:mm:ss に変換する関数 * @param {Number} seconds 秒数 */ function secToHMS(seconds) { const hour = Math.floor(seconds / 3600); const min = Math.floor(seconds % 3600 / 60); const sec = seconds % 60; let hh; // 3桁未満ならゼロパディング if (hour < 100) { hh = (`00${hour}`).slice(-2); } else { hh = hour; } // mm:ss の形式にするためゼロパディング const mm = (`00${min}`).slice(-2); const ss = (`00${sec}`).slice(-2); let time = ''; if (hour !== 0) { // 1時間以上であれば hh:mm:ss time = `${hh}:${mm}:${ss}`; } else { // 1時間未満であれば mm:ss time = `${mm}:${ss}`; } return time; } /** * レンジスライダーのトラックの塗りの範囲と色を更新する関数 * @param {HTMLElement} slider レンジスライダー(input type="range") * @param {String} bgc ベースとなるトラックの背景色(デフォルト #ccc) * @param {String} color 変化する領域(ツマミの左側)の背景色(デフォルト #8ea8f9) */ function updateSlider(slider, bgc = '#ccc', color = '#8ea8f9') { if (!slider.max) { // max 属性が設定されていなければ 100 を設定 slider.max = 100; } // 現在の値から割合(%)を取得 const progress = (slider.value / slider.max) * 100; // linear-gradient でトラックの背景色の領域を引数の色で更新 slider.style.background = `linear-gradient(to right, ${color} ${progress}%, ${bgc} ${progress}%)`; } }); // 再生を開始すると、他に再生中のものがあれば停止(必要に応じて) document.addEventListener('play', (e) => { const audios = document.querySelectorAll('audio'); audios.forEach((audio) => { if (audio !== e.target) { audio.pause(); } }); }, true);
上記は以下のようなプレイリストが表示されます。
- 鳥の鳴き声1
- 小川のせせらぎ
- 波の音
- 鳥の鳴き声2
- 鳥の鳴き声1
audio 要素でマークアップ
前述の例では HTML を ul li 要素でリストとしてマークアップしましたが、以下は audio 要素と複数の source 要素でマークアップする例です。
プレイリストとして表示する場合は、以下のように複数の source 要素でマークアップし、data-track-title 属性にタイトルを指定し、src 属性に音声データの URL を指定します。
<audio class="audio-list"> <source data-track-title="鳥の鳴き声1" src="birds1.mp3"> <source data-track-title="小川のせせらぎ" src="stream.mp3"> <source data-track-title="波の音" src="wave.mp3"> <source data-track-title="鳥の鳴き声2" src="birds2.mp3"> </audio>
audio 要素内の source 要素が1つの場合、または source 要素を指定せず audio 要素に src 属性を指定した場合はシングルモードとなり、プレイリストではなく音声プレーヤーとして表示します。
この例では、シングルモードの場合、data-track-title に文字列(タイトル)を指定すると音声プレーヤーの下部にタイトルを表示するようにしてみました。
<audio class="baz" data-track-title="鳥の鳴き声1" src="birds1.mp3"></audio> <!-- 以下でも同じ(下部にタイトルを表示) --> <audio class="baz"> <source data-track-title="鳥の鳴き声1" src="birds1.mp3"> </audio> <audio class="baz" src="birds1.mp3"></audio> <!-- 以下でも同じ(タイトルの表示なし) --> <audio class="baz"> <source src="birds1.mp3"> </audio>
CSS は前述の例と同じですが、シングルモードのタイトル用に以下を追加しました。
/* 追加 */ .audio-playlist.single .inner-title { font-size: 13px; margin: 0 0 0 1rem; color: #ccc; }
例えば、上記の HTML で以下を実行すると、
//対象のリストを取得 const audioList = document.querySelectorAll('.audio-list'); audioList.forEach((elem) => { // 対象の要素をプレイリストで表示 createAudioPlayList(elem); }); //対象のリストを取得 const audioList2 = document.querySelectorAll('.baz'); audioList2.forEach((elem) => { // 対象の要素をプレイリストで表示 createAudioPlayList(elem, '#d0f9cd', '#fbdef4', 0.8); });
複数の source 属性を指定した場合は、前述のプレイリストと表示は全く同じです。
以下が JavaScript です。受け取った audio 要素のマークアップをリストに変換する部分のみが前述の例と異なっています。
カスタム音声プレーヤーの利用
以下はアイコンを使った音声プレーヤーで作成した音声プレーヤーを利用したプレイリストの例です。
別途作成したカスタム音声プレーヤーにスキップボタンとリストのループボタンを追加しています。また、背景色を同じにすることでパネルのように表示しています。
コントロールの再生ボタンやスキップボタン、リストのアイコンをクリックすると再生します。
-
鳥のさえずり
-
小川のせせらぎ
-
波の音
カスタム音声プレーヤーを使用しない場合は、以下のようにブラウザのデフォルトの音声プレーヤー(コントロール)が表示されます。
-
鳥のさえずり
-
小川のせせらぎ
-
波の音
以下のような HTML を記述し、ul li 要素でリストを作成して li 要素の data-audio-src 属性に音声データの URL を、.track-title のテキストにタイトルを記述します。
<div class="playlist"> <h3 class="playlist-title">自然の音</h3> <div class="tracks"> <ul> <li data-audio-src="birds.mp3"> <div class="track-title">鳥のさえずり</div> </li> <li data-audio-src="stream.mp3"> <div class="track-title">小川のせせらぎ</div> </li> <li data-audio-src="wave.mp3"> <div class="track-title">波の音</div> </li> </ul> </div> </div>
この例では playlist クラスを指定した要素を取得して、プレイリストを作成する処理をしています。
38行目で別途定義した関数 createAudioPlayer() を呼び出して、audio 要素をカスタマイズして表示するようにしています。
38行目を削除またはコメントアウトすると、2つ目のサンプルのようにデフォルトのコントロールが表示されます。
document.addEventListener('DOMContentLoaded', () => { const playLists = document.querySelectorAll('.playlist'); playLists.forEach((playList) => { // 再生中のタイトル、スキップボタン、リストのループボタン、audio 要素の HTML const nowPlaying = `<div class="now-playing"> <div class="current-title"></div> <div class="audio-controls"> <div class="skip-controls"> <button class="skip-backward skip-backward-btn" type="button" aria-label="Skip Backward"></button> <button class="skip-forward skip-forward-btn" type="button" aria-label="Skip Forward"></button> </div> <audio controls></audio> </div> <div class="list-control"><button class="list-loop list-loop-btn" type="button" aria-label="Loop List"></button></div> </div>`; // プレイリストのタイトルを表示する要素を取得 const titleTag = playList.querySelector('.playlist-title'); // 上記 HTML(nowPlaying)をタイトルの後に追加 titleTag.insertAdjacentHTML('afterend', nowPlaying); // 全てのトラック(li 要素)を取得 const tracks = playList.querySelectorAll('.tracks li'); // 選択された(再生中の)トラックを表す変数の初期化 let currentTrack = tracks[0]; // audio 要素 const audio = playList.querySelector('audio'); // audio 要素に最初のトラックの音声データを指定 audio.src = currentTrack.dataset.audioSrc; // audio 要素を別途定義した関数で独自のスタイルで表示 createAudioPlayer(audio); // .audio-player のループボタンを非表示にする場合 /* const audioLoopBtn= playList.querySelector('.audio-player .controls .loop-btn'); if(audioLoopBtn) { audioLoopBtn.style.display = 'none'; }*/ // 現在再生中のタイトルを表示する要素 const currentTitle = playList.querySelector('.now-playing .current-title'); // currentTrack の更新などを行う関数を実行して初期化 updateTrack(currentTrack); // 各トラックで tracks.forEach((track) => { // ボタンを表示する要素をタイトルのテキストの前に追加 const playBtnElem = `<button class="play" type="button" aria-label="Play"></button>`; track.insertAdjacentHTML('afterbegin', playBtnElem); // 各トラックに追加した再生ボタン const playBtn = track.querySelector('button.play'); // 上記ボタンをクリックした際の処理 playBtn.addEventListener('click', (e) => { // 再生・一時停止を実行する関数を呼び出す toggleBtn(track); }, false); }); // 音声データを再生する非同期関数(引数にトラックの li 要素を受け取る) async function playAudio(track) { try { // 現在再生中と異なるトラック(項目)が選択された場合 if (track !== currentTrack) { audio.src = track.dataset.audioSrc; const btn = track.querySelector('button.play'); //currentTrack を更新 currentTrack = btn.parentElement; } await audio.play(); // currentTitle を更新 currentTitle.textContent = currentTrack.textContent; // トラックの情報を更新 updateTrack(track); } catch (err) { console.warn(err); } } // 各トラックに追加した再生ボタンの click イベントのリスナー function toggleBtn(track) { if (audio.paused) { // 停止中であれば playAudio() を呼び出して再生 playAudio(track); } else { if (track === currentTrack) { // 再生中であれば停止 audio.pause(); } else { // 全てのトラックのボタンを初期状態にリセット reseAllBtns(); // 再生 playAudio(track); } } } // 全てのトラックのボタンを初期状態にリセットする関数 function reseAllBtns() { tracks.forEach((track) => { // トラックのボタン const btn = track.querySelector('button.play'); btn.setAttribute('aria-label', 'Play'); btn.classList.remove('playing'); }); } // play イベントでボタンのクラスと aria-label を変更 audio.addEventListener('play', (e) => { const btn = currentTrack.querySelector('button.play'); btn.classList.add('playing'); btn.setAttribute('aria-label', 'Pause'); }, false); // pause イベントで全てのトラックのボタンを初期状態にリセット audio.addEventListener('pause', (e) => { reseAllBtns(); }, false); // currentTrack の更新と track の li 要素のクラスの着脱及びタイトルの更新 function updateTrack(li) { tracks.forEach((track) => { track.classList.remove('active'); }); currentTrack = li; li.classList.add('active'); currentTitle.textContent = li.textContent; } // リストのループボタン const ListLoopBtn = playList.querySelector('.list-loop'); // ループが有効かどうかのフラグを定義して初期化 let isLoopActive = false; // ループボタンにクリックイベントを設定 ListLoopBtn.addEventListener('click', () => { if (isLoopActive) { isLoopActive = false; ListLoopBtn.classList.remove('looped'); // aria-label 属性の値を更新 ListLoopBtn.setAttribute('aria-label', 'Loop List'); } else { isLoopActive = true; ListLoopBtn.classList.add('looped'); // aria-label 属性の値を更新 ListLoopBtn.setAttribute('aria-label', 'Unloop List'); } }); // Skip Forward ボタン(次のオーディオへ進むボタン) const skipForwardBtn = playList.querySelector('.skip-forward'); // Skip Forward ボタンにクリックイベントを設定 skipForwardBtn.addEventListener('click', skipForward, false); function skipForward() { // 次の兄弟要素を取得 const nextTrack = currentTrack.nextElementSibling; // 次のトラックが存在すれば(兄弟要素があれば) if (nextTrack) { audio.pause(); updateTrack(nextTrack) // src に次のトラックを設定 audio.src = nextTrack.dataset.audioSrc; reseAllBtns(); playAudio(nextTrack); } else { // 次のトラックが存在しなければ先頭のトラックを再生 updateTrack(tracks[0]) // src に最初のトラックを設定 audio.src = tracks[0].dataset.audioSrc; reseAllBtns(); playAudio(tracks[0]); } } // Skip Backward ボタン(前のオーディオへ進むボタン) const skipBackwardBtn = playList.querySelector('.skip-backward'); // Skip Backward ボタンにクリックイベントを設定 skipBackwardBtn.addEventListener('click', skipFBackward, false); function skipFBackward() { const prevTrack = currentTrack.previousElementSibling; // 前のトラックが存在すれば(前の兄弟要素があれば) if (prevTrack) { audio.pause(); updateTrack(prevTrack) // src に前のトラックを設定 audio.src = prevTrack.dataset.audioSrc; reseAllBtns(); playAudio(prevTrack); } else { updateTrack(tracks[tracks.length - 1]) // src に最後のトラックを設定 audio.src = tracks[tracks.length - 1].dataset.audioSrc; reseAllBtns(); playAudio(tracks[tracks.length - 1]); } } // .audio-player(音声プレーヤー)のトグルボタン const audioToggleBtn = playList.querySelector('.audio-player .controls .toggle'); // 再生終了時に発火するイベント audio.addEventListener('ended', audioEnded, false); function audioEnded() { const nextTrack = currentTrack.nextElementSibling; // 次のトラックが存在すれば if (nextTrack) { audio.pause(); updateTrack(nextTrack); // src に次のトラックを設定 audio.src = nextTrack.dataset.audioSrc; // 次のトラックを再生 playAudio(nextTrack); } else { if (!isLoopActive) { // .audio-player(音声プレーヤー)のトグルボタンのクラスの削除と aria-label の変更 audioToggleBtn.classList.remove('playing'); audioToggleBtn.setAttribute('aria-label', 'Play'); } else { audio.pause(); updateTrack(tracks[0]); // src に次のトラック(先頭のトラック)を設定 audio.src = tracks[0].dataset.audioSrc; playAudio(tracks[0]); } } } }); });
関数にする
以下は前述のコードを関数にする例です。
また、関数側で HTML も追加するようにして、HTML では以下のような ul li 要素のリストを作成して、関数に渡すようにします。
ul 要素の data-playlist-title 属性にプレイリストのタイトルを指定します。また、前述の例同様、 li 要素の data-audio-src 属性に音声データの URL を、.track-title のテキストにタイトルを記述します。
<ul class="sounds-list" data-playlist-title="自然の音"> <li data-audio-src="birds.mp3"> <div class="track-title">鳥のさえずり</div> </li> <li data-audio-src="stream.mp3"> <div class="track-title">小川のせせらぎ</div> </li> <li data-audio-src="wave.mp3"> <div class="track-title">波の音</div> </li> </ul>
以下が前述のコードを関数にしたものです。
第1引数のプレイリストの ul 要素は必須です。第2引数を省略するとブラウザのデフォルトのコントロールが表示されます。第3引数を省略すると audio 要素をカスタマイズする関数のデフォルトの値でカスタマイズされて表示されます。
/** * プレイリストの ul 要素を受け取りコントロールを生成する関数 * 別途定義した audio 要素をカスタマイズする関数を第2引数 func に指定 * @param {HTMLElement} targetList (プレイリストの ul 要素 ※必須) * @param {Function} func audio 要素をカスタマイズする関数 * @param {Object} funcArgs 第2引数 func の引数 (例){ bgc: '#f3a5d2', color: 'lime', vol: 0.5 } */ function setupAudioPlayList(targetList, func, funcArgs) { // 第1引数の targetList が存在しない場合やそれが ul 要素でなければ終了 if (!targetList || targetList.tagName !== 'UL') { return; } // オーディオプレーヤーをラップする div 要素(ラッパー)を作成 const playList = document.createElement('div'); // クラス属性を付与 playList.className = 'playlist'; // targetList の前にラッパーを挿入 targetList.parentNode.insertBefore(playList, targetList); // targetList をラッパーに追加 playList.appendChild(targetList); // 再生中のタイトル、スキップボタン、リストのループボタン、audio 要素の HTML const htmls = `<h3 class="playlist-title"></h3> <div class="now-playing"> <div class="current-title"></div> <div class="audio-controls"> <div class="skip-controls"> <button class="skip-backward skip-backward-btn" type="button" aria-label="Skip Backward"></button> <button class="skip-forward skip-forward-btn" type="button" aria-label="Skip Forward"></button> </div> <audio controls></audio> </div> <div class="list-control"><button class="list-loop list-loop-btn" type="button" aria-label="Loop List"></button></div> </div> <div class="tracks"></div>`; // 上記を insertAdjacentHTML で playList に追加 playList.insertAdjacentHTML('afterbegin', htmls); // targetList をトラック(.tracks)に追加 playList.querySelector('.tracks').appendChild(targetList); // プレイリストのタイトルを表示する要素を取得 const titleTag = playList.querySelector('.playlist-title'); // 引数 targetList の data-playlist-title の値をプレイリストのタイトルに titleTag.textContent = targetList.dataset.playlistTitle; // 全てのトラック(li 要素)を取得 const tracks = playList.querySelectorAll('.tracks li'); // 選択された(再生中の)トラックを表す変数の初期化 let currentTrack = tracks[0]; // audio 要素 const audio = playList.querySelector('audio'); // audio 要素に最初のトラックの音声データを指定 audio.src = currentTrack.dataset.audioSrc; // 引数に audio 要素をカスタマイズする関数が指定されていれば実行 if (func) { // 関数に渡す引数が指定されていれば if (funcArgs) { // 引数を展開 const { bgc, color, vol } = funcArgs; // 引数を渡して関数を実行 func(audio, bgc, color, vol); } else { // 引数なしで関数を実行 func(audio); } } // .audio-player のループボタンを非表示にする場合 /* const audioLoopBtn= playList.querySelector('.audio-player .controls .loop-btn'); if(audioLoopBtn) { audioLoopBtn.style.display = 'none'; }*/ // 現在再生中のタイトルを表示する要素 const currentTitle = playList.querySelector('.now-playing .current-title'); // currentTrack の更新などを行う関数を実行して初期化 updateTrack(currentTrack); // 各トラックで tracks.forEach((track) => { // ボタンを表示する要素をタイトルのテキストの前に追加 const playBtnElem = `<button class="play" type="button" aria-label="Play"></button>`; track.insertAdjacentHTML('afterbegin', playBtnElem); // 各トラックに追加した再生ボタン const playBtn = track.querySelector('button.play'); // 上記ボタンをクリックした際の処理 playBtn.addEventListener('click', (e) => { // 再生・一時停止を実行する関数を呼び出す toggleBtn(track); }, false); }); // 音声データを再生する非同期関数(引数にトラックの li 要素を受け取る) async function playAudio(track) { try { // 現在再生中と異なるトラック(項目)が選択された場合 if (track !== currentTrack) { audio.src = track.dataset.audioSrc; const btn = track.querySelector('button.play'); //currentTrack を更新 currentTrack = btn.parentElement; } await audio.play(); // currentTitle を更新 currentTitle.textContent = currentTrack.textContent; // トラックの情報を更新 updateTrack(track); } catch (err) { console.warn(err); } } // 各トラックに追加した再生ボタンの click イベントのリスナー function toggleBtn(track) { if (audio.paused) { // 停止中であれば playAudio() を呼び出して再生 playAudio(track); } else { if (track === currentTrack) { // 再生中であれば停止 audio.pause(); } else { // 全てのトラックのボタンを初期状態にリセット reseAllBtns(); // 再生 playAudio(track); } } } // 全てのトラックのボタンを初期状態にリセットする関数 function reseAllBtns() { tracks.forEach((track) => { // トラックのボタン const btn = track.querySelector('button.play'); btn.setAttribute('aria-label', 'Play'); btn.classList.remove('playing'); }); } // play イベントでボタンのクラスと aria-label を変更 audio.addEventListener('play', (e) => { const btn = currentTrack.querySelector('button.play'); btn.classList.add('playing'); btn.setAttribute('aria-label', 'Pause'); }, false); // pause イベントで全てのトラックのボタンを初期状態にリセット audio.addEventListener('pause', (e) => { reseAllBtns(); }, false); // currentTrack の更新と track の li 要素のクラスの着脱及びタイトルの更新 function updateTrack(li) { tracks.forEach((track) => { track.classList.remove('active'); }); currentTrack = li; li.classList.add('active'); currentTitle.textContent = li.textContent; } // リストのループボタン const ListLoopBtn = playList.querySelector('.list-loop'); // ループが有効かどうかのフラグを定義して初期化 let isLoopActive = false; // ループボタンにクリックイベントを設定 ListLoopBtn.addEventListener('click', () => { if (isLoopActive) { isLoopActive = false; ListLoopBtn.classList.remove('looped'); // aria-label 属性の値を更新 ListLoopBtn.setAttribute('aria-label', 'Loop List'); } else { isLoopActive = true; ListLoopBtn.classList.add('looped'); // aria-label 属性の値を更新 ListLoopBtn.setAttribute('aria-label', 'Unloop List'); } }); // Skip Forward ボタン(次のオーディオへ進むボタン) const skipForwardBtn = playList.querySelector('.skip-forward'); // Skip Forward ボタンにクリックイベントを設定 skipForwardBtn.addEventListener('click', skipForward, false); function skipForward() { // 次の兄弟要素を取得 const nextTrack = currentTrack.nextElementSibling; // 次のトラックが存在すれば(兄弟要素があれば) if (nextTrack) { audio.pause(); updateTrack(nextTrack) // src に次のトラックを設定 audio.src = nextTrack.dataset.audioSrc; reseAllBtns(); playAudio(nextTrack); } else { // 次のトラックが存在しなければ先頭のトラックを再生 updateTrack(tracks[0]) // src に最初のトラックを設定 audio.src = tracks[0].dataset.audioSrc; reseAllBtns(); playAudio(tracks[0]); } } // Skip Backward ボタン(前のオーディオへ進むボタン) const skipBackwardBtn = playList.querySelector('.skip-backward'); // Skip Backward ボタンにクリックイベントを設定 skipBackwardBtn.addEventListener('click', skipFBackward, false); function skipFBackward() { const prevTrack = currentTrack.previousElementSibling; // 前のトラックが存在すれば(前の兄弟要素があれば) if (prevTrack) { audio.pause(); updateTrack(prevTrack); // src に前のトラックを設定 audio.src = prevTrack.dataset.audioSrc; reseAllBtns(); playAudio(prevTrack); } else { updateTrack(tracks[tracks.length - 1]) // src に最後のトラックを設定 audio.src = tracks[tracks.length - 1].dataset.audioSrc; reseAllBtns(); playAudio(tracks[tracks.length - 1]); } } // .audio-player(音声プレーヤー)のトグルボタン(カスタマイズする関数が適用されている場合) const audioToggleBtn = playList.querySelector('.audio-player .controls .toggle'); // 再生終了時に発火するイベント audio.addEventListener('ended', audioEnded, false); function audioEnded() { const nextTrack = currentTrack.nextElementSibling; // 次のトラックが存在すれば if (nextTrack) { audio.pause(); updateTrack(nextTrack); // src に次のトラックを設定 audio.src = nextTrack.dataset.audioSrc; // 次のトラックを再生 playAudio(nextTrack); } else { if (!isLoopActive) { // カスタマイズする関数が適用されている場合 if (audioToggleBtn) { // .audio-player(音声プレーヤー)のトグルボタンのクラスの削除と aria-label の変更 audioToggleBtn.classList.remove('playing'); audioToggleBtn.setAttribute('aria-label', 'Play'); } } else { audio.pause(); updateTrack(tracks[0]); // src に次のトラック(先頭のトラック)を設定 audio.src = tracks[0].dataset.audioSrc; playAudio(tracks[0]); } } } };
使用例
document.addEventListener('DOMContentLoaded', () => { // sounds-list クラス が指定されている ul 要素 const soundsLists = document.querySelectorAll('ul.sounds-list'); soundsLists.forEach((elem) => { // 上記で取得した要素をプレイリストで表示 setupAudioPlayList(elem, createAudioPlayer, { bgc: '#f3a5d2', color: 'lime', vol: 0.5 }); }); });
setupAudioPlayList() の第3引数に 第2引数 createAudioPlayer に渡すパラメータ(スライダーの背景色と初期ボリューム)を指定しているので以下のように表示されます。
-
鳥のさえずり
-
小川のせせらぎ
-
波の音
.playlist { position: relative; margin: 50px 0; width: 100%; max-width: 520px; border: 1px solid #eee; padding: 0; } .playlist h3 { margin: 0 0 1px; font-size: 16px; background-color: #eee; padding: 0.5rem; } .playlist .tracks ul { list-style-type: none; margin: 0; padding: 0; } .playlist .tracks ul li { display: flex; flex-wrap: nowrap; align-items: center; margin: 2px 0; background-color: #eeeeee; padding: 0.5rem 0.25rem; } .playlist .tracks ul li.active { background-color: #ccc; } .playlist .tracks ul li:last-of-type { margin-bottom: 0; } .playlist .tracks .track-title { font-size: 14px; } .playlist .tracks button { cursor: pointer; border: none; background-color: transparent; position: relative; } .playlist .tracks button.play::before { content: ""; display: inline-block; height: 30px; width: 30px; vertical-align: -10px; margin-right: 8px; background-repeat: no-repeat; } /* 再生中のタイトルと音声プレーヤーを表示する部分 */ .playlist .now-playing { padding: 5px 20px; /* 音声プレーヤーと同じ背景色に */ background-color: #2c406e; } .playlist .current-title { margin: 10px 0; font-size: 16px; color: #eeeeee; } /* プレイリストの Play ボタン*/ .playlist .tracks button.play::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%234d6fbe' viewBox='0 0 16 16'%3E %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E %3Cpath d='M6.271 5.055a.5.5 0 0 1 .52.038l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 6 10.5v-5a.5.5 0 0 1 .271-.445z'/%3E%3C/svg%3E"); /* transition: background-image .1s; */ } /* プレイリストの Play ボタン :hover*/ .playlist .tracks button.play:hover::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%234d6fbe' viewBox='0 0 16 16'%3E %3Cpath d='M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z'/%3E%3C/svg%3E"); } /* プレイリストの Pause ボタン */ .playlist .tracks button.play.playing::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23eb213c' class='bi bi-pause-circle-fill' viewBox='0 0 16 16'%3E %3Cpath d='M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z'/%3E%3C/svg%3E"); } @media screen and (min-width: 480px) { .playlist .audio-controls { display: flex; align-items: center; } } /* プレイリストの中の音声プレーヤー */ .playlist .now-playing .audio-player { padding: 0; } /* スキップボタンのラッパー */ .skip-controls { display: flex; } /* スキップボタン */ .playlist .skip-controls button, .playlist .list-control button { cursor: pointer; border: none; background-color: transparent; position: relative; } .playlist .skip-controls button::before, .playlist .list-control button::before { content: ""; display: inline-block; height: 20px; width: 20px; vertical-align: -10px; margin-right: 8px; background-repeat: no-repeat; } /* Skip Forward ボタンのアイコン */ .playlist .now-playing button.skip-forward-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' class='bi bi-skip-forward-fill' viewBox='0 0 16 16'%3E %3Cpath d='M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.753l-6.267 3.636c-.54.313-1.233-.066-1.233-.697v-2.94l-6.267 3.636C.693 12.703 0 12.324 0 11.693V4.308c0-.63.693-1.01 1.233-.696L7.5 7.248v-2.94c0-.63.693-1.01 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5z'/%3E%3C/svg%3E"); } /* Skip Backward ボタンのアイコン */ .playlist .now-playing button.skip-backward-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' class='bi bi-skip-backward-fill' viewBox='0 0 16 16'%3E %3Cpath d='M.5 3.5A.5.5 0 0 0 0 4v8a.5.5 0 0 0 1 0V8.753l6.267 3.636c.54.313 1.233-.066 1.233-.697v-2.94l6.267 3.636c.54.314 1.233-.065 1.233-.696V4.308c0-.63-.693-1.01-1.233-.696L8.5 7.248v-2.94c0-.63-.692-1.01-1.233-.696L1 7.248V4a.5.5 0 0 0-.5-.5z'/%3E%3C/svg%3E"); } /* List Loop ボタンのアイコン */ .playlist .list-control button.list-loop-btn::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E %3Cpath d='M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z'/%3E %3Cpath fill-rule='evenodd' d='M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z'/%3E%3C/svg%3E"); } .playlist .list-control button.list-loop-btn.looped::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f3a5d2' viewBox='0 0 16 16'%3E %3Cpath d='M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z'/%3E %3Cpath fill-rule='evenodd' d='M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z'/%3E%3C/svg%3E"); } /* リストのナンバリング */ .playlist .tracks { counter-reset: track-counter; } .playlist .track-title::before { counter-increment: track-counter; content: counter(track-counter) '. '; margin-right: .125rem; }
第2引数に指定する audio 要素をカスタマイズする関数は アイコンを使った音声プレーヤー の関数を使っています(CSS で背景色やマージンを調整していますが、その他は同じです)。
関連ページ:audio タグ(要素)を Javascript を使って操作(プレイリストの作成)
JavaScript オープンソースのメディアプレーヤー MediaElement.js を使えば同じことをもっと簡単に実装できます。
関連ページ:MediaElement.js の使い方
audio 要素でマークアップ
以下は HTML のマークアップを ul li 要素ではなく、audio 要素と複数の source 要素でマークアップする例です。
以下のように複数の source 要素でマークアップし、data-track-title 属性にタイトルを指定し、src 属性に音声データの URL を指定します。また、プレイリストのタイトルは audio 要素の data-playlist-title 属性に指定します。
<audio class="audio-list" data-playlist-title="自然の音"> <source data-track-title="鳥のさえずり" src="birds.mp3"> <source data-track-title="小川のせせらぎ" src="stream.mp3"> <source data-track-title="波の音" src="wave.mp3"> </audio>
使い方は前述の関数にする例と同じです。第2引数と第3引数はオプションです。
const playLists = document.querySelectorAll('.audio-list'); playLists.forEach((elem) => { // 対象の要素をプレイリストで表示 setupAudioPlayList(elem, createAudioPlayer, { bgc: '#f3a5d2', color: 'lime', vol: 0.5 }); });