Здравствуйте, уважаемые читатели. Сегодня хотелось бы рассказать Вам о том, как сделать простейший кастомный аудиоплеер с плейлистом. Будем делать его, используя HTML5 и Javascript. В результате, получим вот такой простенький плеер. По внешнему виду похожий на плеер vk.com

Пример

Самый простой способ для воспроизведения аудиофайлов в браузере — использование тэга
audio

 <audio controls="controls">
 <source src="files/track1.mp3" />
</audio>
 

Атрибут controls отображает стандартные элементы управления, такие как кнопка play/pause, mute.

Различные браузеры отображают плеер со своим дизайном без каких-либо особых возможностей. Если нам необходим настраиваемый аудиоплеер, одним из лучших вариантов будет посмотреть готовые решения, которые были проверены сотнями разработчиков и рядовыми пользователями. Если, вдруг, по какой-то причине мы не хотим использовать готовое решение, например, из-за избыточного функционала или его отсутствия, можно написать собственное решение. Как минимум, это будет полезно в целях самообразования.

Чтобы воспроизвести аудио, используя Javascript, достаточно лишь две строки кода:

let file = new Audio('track1.mp3');
file.play(); 

Тут создается объект Audio, им-то мы и будем оперировать. Вот краткий список общих методов и свойств:
.play() — запустить проигрывание аудиофайла;
.pause() — поставить на паузу;
.duration — длина дорожки (в секундах). Duration становится доступен только после срабатывания события ‘loadedmetadata’;
currentTime — получить/установить момент проигрывания звуковой дорожки;

Полный список доступен тут
Так как данная реализация всего-лишь фронтенд пример, то добавим аудио файлы в локальную папку files (для удобства и наглядности) и захардкодим их названия:
const FILES = [
 'track1',
 'track2',
 'track3',
 'track4' 
]

В продакшене нельзя использовать такой подход по очевидным причинам.

Теперь создадим класс Player с такими методами:
init — инициализация плейлиста;
loadFile — загрузить дорожку, при запросе на запуск, только если она еще не было загружена.
play — запустить/поставить на паузу дорожку;
playNext — запустить следующую дорожку;
playPrev — запустить предыдущую дорожку;
setTitle — установить название файла в шапке плеера;
setProgress — установить процент на сколько заполнен прогресс-бар;
countProgress — посчитать в процентах количество проигранного времени;
runProgress — запустить отрисовку заполнения прогресс-бара;
stopProgress — сбросить процент заполненности прогресс-бара;
pickNewProgress — навигация по файлу с помощью клика по прогресс-бару;
toggleStyles — метод для изменения стилей кнопки play/pause и плейлиста;

Код плеера:
 class Player {
 constructor(files) {
   this.current = null;
   this.status = 'pause';
   this.progress = 0;
   this.progressTimeout = null;
   this.files = FILES.map(name => {
     return {
       name: name
     }
   });
 }
 init() {
   let playlist = getByQuery('.playlist');
 
   this.files.forEach((f, i) => {
     let playlistFileContainer = createElem({
       type: 'div',
       appendTo: playlist,
       textContent: f.name,
       class: 'fileEntity',
       handlers: {
         click: this.play.bind(this, null, i)
       }
     });
     createElem({
       type: 'div',
       appendTo: playlistFileContainer,
       textContent: '--:--',
       class: 'fileEntity_duration',
     })
   });
 }
 loadFile(i) {
 
   let f = this.files[i];
   f.file = new Audio(prepareFilePath(f.name));
 
   f.file.addEventListener('loadedmetadata', () => {
     getByQuery('.playlist').children[i].children[0].textContent = prettifyTime(f.file.duration);
   });
 
   f.file.addEventListener('ended', this.playNext.bind(this, null, i));
 }
 
 play(e, i = this.current || 0) {
   if (!this.files[i].file) {
     this.loadFile(i);
   }
 
   let action = 'play';
 
   if (this.current == i) {
     action = this.status === 'pause' ? 'play' : 'pause';
     this.toggleStyles(action, i);
   } else if (typeof this.current !== 'object') {
     this.files[this.current].file.pause();
     this.files[this.current].file.currentTime = 0;
     this.toggleStyles(action, this.current, i);
   } else {
     this.toggleStyles(action, i);
   }
 
   this.current = i;
   this.status = action;
   this.files[i].file[action]();
 
   if (action == 'play') {
     this.setTitle(this.files[i].name);
     this.stopProgress();
     this.runProgress();
   } else {
     this.stopProgress();
   }
 }
 
 playNext(e, currentIndex) {
   let nextIndex = (currentIndex ? currentIndex : this.current) + 1;
 
   if (!this.files[nextIndex]) {
     nextIndex = 0;
   }
 
   this.play(null, nextIndex);
 }
 
 playPrev(e, currentIndex) {
   let prevIndex = (currentIndex ? currentIndex : this.current) - 1;
 
   if (!this.files[prevIndex]) {
     prevIndex = this.files.length - 1;
   }
 
   this.play(null, prevIndex);
 }
 
 setTitle(title) {
   getByQuery('.progress_bar_title').textContent = title;
 }
 
 setProgress(percent = 0, cb) {
   getByQuery('.progress_bar_container_percentage').style.width = `${percent}%`;
   cb && cb();
 }
 
 countProgress() {
   let file = this.files[this.current].file;
 
   return (file.currentTime * 100 / file.duration) || 0;
 }
 
 runProgress(percent = 0) {
   let percentage = percent || this.countProgress();
   let cb = percent ? () => {
     this.files[this.current].file.currentTime = percentage * this.files[this.current].file.duration / 100;
   } : null;
 
   this.setProgress(percentage, cb);
   this.progressTimeout = setTimeout(this.runProgress.bind(this), 1000)
 }
 
 stopProgress() {
   clearTimeout(this.progressTimeout);
   this.progressTimeout = null;
 }
 
 pickNewProgress(e) {
   if (this.status != 'play') {
     this.play();
   }
 
   let coords = e.target.getBoundingClientRect().left;
   let progressBar = getByQuery('.progress_bar_stripe');
   let newPercent = (e.clientX - coords) / progressBar.offsetWidth * 100;
 
   this.stopProgress();
   this.runProgress(newPercent);
 }
 
 toggleStyles(action, prev, next) {
   let prevNode = getByQuery('.playlist').children[prev];
   let nextNode = getByQuery('.playlist').children[next];
   let playPause = getByQuery('.play_pause .play_pause_icon');
 
   if (!next && next !== 0) {
     if (!prevNode.classList.contains('fileEntity-active')) {
       prevNode.classList.add('fileEntity-active');
     }
     playPause.classList.toggle('play_pause-play');
     playPause.classList.toggle('play_pause-pause');
   } else {
     prevNode.classList.toggle('fileEntity-active');
     nextNode.classList.toggle('fileEntity-active');
   }
 
   if (playPause.classList.contains('play_pause-play') && action == 'play' && prev != next) {
     playPause.classList.toggle('play_pause-play');
     playPause.classList.toggle('play_pause-pause');
   }
 }
}


Далее цепляемся на событие DOMContentLoaded инициализируем наш плеер и вешаем обработчики на элементы управления:

window.addEventListener('DOMContentLoaded', initHandlers);
 
function initHandlers() {
 let player = new Player(FILES);
 player.init();
 
 getByQuery('.player .controls .play_pause').addEventListener('click', player.play.bind(player));
 getByQuery('.player .controls .navigation_prev').addEventListener('click', player.playPrev.bind(player));
 getByQuery('.player .controls .navigation_next').addEventListener('click', player.playNext.bind(player));
 getByQuery('.player .controls .progress_bar_stripe').addEventListener('click', player.pickNewProgress.bind(player));
}

Функция getByQuery() сокращенный вариант document.querySelector():

А вот она в коде:

function getByQuery(elem) {
 return typeof elem === 'string' ? document.querySelector(elem) : elem;
}

Помимо этого было использовано еще несколько кастомных функций, код которых можно найти в репозитории.

Логика работы плеера очень простая. Мы читаем массив файлов и записываем его в this. Кликнув по дорожке, в плейлисте вызывается метод play. Ему мы передаем индекс файла в массиве. Он подгружает дорожку, используя метод loadFileТолько если дорожка еще не была загружена. Затем метод запускает ее проигрывание. Не забываем и за прогресс-бар. В контексте плеера мы также храним статус (play/pause), индекс текущего файла и переменную с таймаутом. Таймаут мы используем, чтоб раз в секунду (при условии проигрывания файла) мы могли перерисовывать прогресс-бар. Ссылка на демо.

В следующей статье разберем более сложные способы реализации аудиоплеера.
Спасибо за внимание.

Над статьей работали varog-norman и greebn9k

Комментарии (6)


  1. MrGobus
    13.09.2017 16:03

    Логика работы плеера очень простая. Мы читаем массив файлов и записываем его в
    this


    Как по мне, не лучшая практика переопределять ключевые слова языка. Думаю лучше использовать current или track.


    1. Zenitchik
      13.09.2017 16:34
      +1

      Переопределение тут ни при чём. Массив имён файлов записывается в свойство this.


  1. bromzh
    13.09.2017 17:20
    +4

    this.files = FILES.map(name => {
         return {
           name: name
         }
       });

    А почему не


    this.files = FILES.map(name => ({ name }));


  1. JSmitty
    13.09.2017 20:34

    По коду — в конструкторе Player косяк, параметр конструктора не используется, а используется константа FILES напрямую. Определяемый там же this.progress — вообще нигде больше не используется.

    getByQuery лучше обозвать покороче, оно у вас helper-функция, им обычно 1-2 символа назначают. Я бы предложил «q». С таким определением:

    const q = document.querySelector;
    


    Вот это:
    getByQuery('.playlist').children[i].children[0].textContent
    

    даже для jQuery-like кода очень плохо. Завязывает код намертво на структуру DOM (в остальных местах вы ссылаетесь на элементы по классам).

    Шторм подсказывает, что сравнение по == не самая лучшая идея, и я с ним согласен.

    Функция toggleStyles() объявляет переменные prevNode, nextNode, playPause (которая тоже Node по факту), и далее от них всегда используются classList. Можно сделать так:
        let playListNode = getByQuery('.playlist');
        let prevNode = (playListNode.children[prev] || {}).classList;
        let nextNode = (playListNode.children[next] || {}).classList;
        let playPause = getByQuery('.play_pause .play_pause_icon').classList;
    


    Функции-утилиты очень хорошо было бы вместе с остальным кодом обернуть в (function(){ /* ваш код */ })(), чтобы не загрязнять глобальное пространство имён.

    А вообще городить огород, когда эта задача очень декларативно ляжет на тот же Vue.js или React — странно. Как видно из вашего решения — начинается опять изобретение фреймворка (jQuery вы уже изобразили).

    PS А почему Gulp? Вроде webpack сейчас мейнстрим.


  1. JSmitty
    13.09.2017 21:25

    По итогу никому из новичков в JS очень не рекомендую этот код воспринимать как инструкцию к действию. Стиль не очень, jquery-like построение интерфейса, архитектура — лапша классическая.


    1. Zenitchik
      13.09.2017 23:00

      Вы правы по сути, но хочу заметить, что вряд ли кто-то (включая автора) будет в таком стиле писать большой проект. Наколеночная поделка — она и есть наколеночная поделка, с характерными стилевыми послаблениями.