Пишем проигрыватель lossless аудио на JavaScript

в 8:20, , рубрики: flac, html, html5, javascript, web audio api, Веб-разработка, Программирование

Добрый день, %username%. Сегодня я хотел бы поделится своим опытом разработки прототипа онлайн lossless аудио плеера.

На сегодняшний день, вряд ли можно кого-то удивить аудио или видео плеером, встроенного непосредственно в веб-страницу. Существующие технологии, библиотеки и API позволяют легко наполнить сайт любым медиа-контентом. Но есть такие люди, которым этого недостаточно (в том числе я). Именно поэтому, как истинному любителю музыки в lossless, мне потребовалось сделать браузерный плеер поддерживающий такой формат аудио, как flac.

К этой идее меня подтолкнула одна статья: Web плеер FLAC.JS (HTML5). Узнав, что есть такой замечательный фреймворк как Aurora.js и декодер формата flac для него, я не смог просто пройти мимо всего этого. Все — подумал я — теперь, когда мой уровень энтузиазма зашкалил, я должен сделать этот плеер. Итак, начнем…

Бекенд

В качестве бекенда у нас будет выступать небезызвестные Nginx и Apache c PHP (куда же без него). Первый будет отвечать за отдачу аудио данных, второй будет выдавать страницу с плеером и обрабатывать запросы Ajax.

Настройка Nginx c поддержкой CORS

http {
	sendfile on;
	include /etc/nginx/mime.types;
	default_type audio/flac;

	server {
		listen *:80;
		server_name as.iostd.ru;
		root /var/mcs/storage;
		
		location / {
			rewrite "^/(([a-z0-9]{2})([a-z0-9]{2})([a-z0-9]{2})([a-z0-9]{2})[a-z0-9]{56}).flac$" /$2/$3/$4/$5/$1.flac last;
			
			if ($http_origin ~* (https?://([^/]*.)?.?iostd.ru(:[0-9]+)?)) {
				set $cors "true";
			}
			
			if ($request_method = 'OPTIONS') {
				set $cors "${cors}options";  
			}
		
			if ($request_method = 'GET') {
				set $cors "${cors}get";  
			}
			
			if ($request_method = 'POST') {
				set $cors "${cors}post";
			}
			
			if ($request_method = 'HEAD') {
				set $cors "${cors}head";
			}
		
			if ($cors = "trueget") {
				add_header 'Access-Control-Allow-Origin' "$http_origin";
				add_header 'Access-Control-Allow-Credentials' 'true';
			}
		
			if ($cors = "truepost") {
				add_header 'Access-Control-Allow-Origin' "$http_origin";
				add_header 'Access-Control-Allow-Credentials' 'true';
			}
			
			if ($cors = "truehead") {
				add_header 'Access-Control-Allow-Origin' "$http_origin";
				add_header 'Access-Control-Allow-Credentials' 'true';
				add_header 'Access-Control-Max-Age' 1728000;
				add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
				add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,Range,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since';
				add_header 'Access-Control-Expose-Headers' 'Accept-Ranges,Content-Encoding,Content-Length,Content-Range';
			}
		
			if ($cors = "trueoptions") {
				add_header 'Access-Control-Allow-Origin' "$http_origin";
				add_header 'Access-Control-Allow-Credentials' 'true';
				add_header 'Access-Control-Max-Age' 1728000;
				add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
				add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,Range,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since';
				add_header 'Access-Control-Expose-Headers' 'Accept-Ranges,Content-Encoding,Content-Length,Content-Range';
				add_header 'Content-Length' 0;
				add_header 'Content-Type' 'text/plain charset=UTF-8';
				return 204;
			}
			
			try_files $uri $uri/;
		}
	}
}

PHP Скрипт «На скорую руку»

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
require_once('MysqliDb.php'); // https://github.com/joshcam/PHP-MySQLi-Database-Class

function cors() {
    if (isset($_SERVER['HTTP_ORIGIN'])) {
        header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
        header('Access-Control-Allow-Credentials: true');
        header('Access-Control-Max-Age: 86400');
    }
    if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
        if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']))
            header("Access-Control-Allow-Methods: GET, POST, OPTIONS");         

        if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']))
            header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");

        exit(0);
    }
}

function answer($data = array(), $status = "OK", $message = "") {
	if(!is_array($data)) return;
	
	$rcode = array();
	$rcode["status"] = $status;
	if($status == "ERROR" && $message != "" ) $rcode["message"] = $message;
	
	header('Content-Type: application/json');
	echo json_encode(array_merge($rcode, array("result" => $data)));
	exit();
}

function error($message) {
	answer(array(), "ERROR", $message);
}

cors();

$db = new MysqliDb ('localhost', 'root', 'hackme', 'audio'); // https://github.com/joshcam/PHP-MySQLi-Database-Class

if(!isset($_REQUEST["c"])) error("Bad request");
if($_REQUEST["c"] == "tracks") {
	$tracks = $db->rawQuery('SELECT title, a.artist, audio FROM tracks INNER JOIN artists a ON (tracks.artistid = a.id) LIMIT 20');
	answer($tracks);
}
error("Bad request");

Фронтенд

Первым делом нужно продумать весь функционал, который будет поддерживать наш плеер. Самое очевидное это Play/Pause, Next, Prev, полоса буферизации и поиска, громкость, строка названия трека и время. Также хотелось бы реализовать отображение обложки альбомов, плейлисты, поиск по базе аудиотеки и так далее, но я решил пока остановиться на самом основном. Так как с веб-разработкой я сталкивался не часто, то с версткой и дизайном у меня, мягко говоря, не очень.

Вот что у меня получилось в итоге:

Пишем проигрыватель lossless аудио на JavaScript

Верстка:

HTML

<div class="player">
    <div class="info">
      <div class="tackinfo"><span id="artist"></span> - <span id="title"></span></div>
      <div class="timer"><span id="time">00:00</span></div>
    </div>
    <div class="seekbar" id="seek">
      <div class="wrap">
        <div id="buffer"></div>
        <div id="progress"></div>
      </div>
    </div>
    <div class="controls">
      <div class="playback">
        <div id="play" class="fa fa-play"></div>
        <div class="fb">
          <div id="backward" class="fa fa-backward"></div>
          <div id="forward" class="fa fa-forward"></div>
        </div>
      </div>
      <div class="volumebar" id="volume">
        <div class="wrap">
          <div id="volumevalue"></div>
        </div>
      </div>
    </div>
  </div>

Стили

.player { border: 1px solid #D0D0D0; background-color: #F0F0F0; height: 67px; border-radius: 2px; padding: 5px;}
.player .tackinfo { float: left; margin-left: 0px; }
.player .tackinfo #artist { color: #0474C0; font-weight: bold; }
.player .tackinfo #title { color: #787878; }
.player .timer { float: right;  cursor: pointer; }
.player .seekbar { clear: both; cursor: pointer; padding: 5px 0;}
.player .seekbar .wrap { background-color: #D0D0D0; overflow:hidden; height:5px; border-radius: 2px;}
.player #buffer, .player #progress { height:100%; width:0%; }
.player #buffer { background-color:#909090; }
.player #progress { background-color: #0474C0; margin-top:-5px; }

.controls .playback { float: left; }
.controls .playback .fb { float: left; }
.playback #play, .playback #backward, .playback #forward { color: #0474C0; cursor: pointer; border-radius: 2px; text-align: center; vertical-align: middle; float:left; margin-right: 2px;}
.playback #play:hover, .playback #backward:hover, .playback #forward:hover { background-color: #D8D8D8; }
.playback #play { font-size: 24px; height: 32px; width: 32px; line-height: 32px;}
.playback #backward { font-size: 16px; height: 32px; width: 32px; line-height: 32px;}
.playback #forward { font-size: 16px; height: 32px; width: 32px; line-height: 32px;}

.volumebar { float: right; cursor: pointer; padding: 15px 0; width:80px;}
.volumebar .wrap { background-color:#D8D8D8; overflow:hidden; height:5px; border-radius: 2px; }
.volumebar #volumevalue { height:100%; width:0%; background-color: #0474C0; }

Отлично. Необходимый минимум у нас есть. Теперь нужно все это оживить. Поэтому переходим к JavaScript.

Создадим класс Playlist который, как вы уже поняли, будет отвечать за список воспроизведения:

Playlist = function() {
	this.list = [];
	this.current = 0;
	this.repeatmode = 0;
};

Где list — сам список, current — номер текущего трека, repeatmode — режим повтора (0 — без повтора, 1 — повтор всего списка, 2 — повтор одного трека).

Далее реализовываем все необходимые методы.

Добавление трека:

Playlist.prototype.add = function(track) {
	this.list.push(track);
};

Получение текущего трека:

Playlist.prototype.getCurrent = function() {
	return this.list[this.current];
};

Методы вперед, назад:

Playlist.prototype.next = function() {
	if(this.repeatmode == 2) {
		return this.current;
	}
	if(this.current >= this.list.length - 1) {
		if(this.repeatmode == 0) {
			return -1;
		} else if(this.repeatmode == 1) {
			return (this.current = 0);
		}
	}
	return ++this.current;
};

Playlist.prototype.prev = function() {
	if(this.current == 0) return this.current;
	return --this.current;
};

И напоследок метод перемешивания списка:

Playlist.prototype.shuffle = function(){
	for(var j, x, i = this.list.length; i; j = Math.floor(Math.random() * i), x = this.list[--i], this.list[i] = this.list[j], this.list[j] = x);
};

Плейлист у нас есть, переходим к самому плееру. Создадим класс Musica:

Musica = function(params) {
	this.ui = {
		artist: params.artist,
		title: params.title,
		seekbar: params.seekbar,
		bufferbar: params.bufferbar,
		progressbar: params.progressbar,
		timer: params.timer,
		playbtn: params.playbtn,
		backwardbtn: params.backwardbtn,
		forwardbtn: params.forwardbtn,
		volumebar: params.volumebar,
		volume: params.volume
	};
	this.pstate = 0;
	this.seekstate = 0;
	this.timetype = 0;
	this.aurora;
	this.volume = 100;
	this.playlist = new Playlist();
	
	this.ui.timer.click((function (_this) {
		return function(e) {
			_this.timetype = _this.timetype == 0 ? 1 : 0;
			_this.setTimer(_this.aurora.currentTime);
		};
	})(this));
	this.ui.playbtn.click((function (_this) {
		return function(e) {
			if(_this.pstate == 0) _this.play(); else _this.pause();
		};
	})(this));
	this.ui.backwardbtn.click((function (_this) {
		return function(e) {
			_this.prev();
		};
	})(this));
	this.ui.forwardbtn.click((function (_this) {
		return function(e) {
			_this.next();
		};
	})(this));
};

В params мы разместим все элементы интерфейса с помощью селекторов JQuery, по переменной pstate мы будем определять состояние плеера (воспроизводит/не воспроизводит), seekstate нам пригодится, когда мы будем реализовывать полосу поиска, а timetype определяет тип таймера (сколько прошло или сколько осталось). Также в этом конструкторе мы сразу повесили обработчики событий на все имеющиеся кнопки.

Фреймворк Aurora.js содержит класс Player, который реализует весь необходимый нам минимум. В нем есть такие методы как play(), pause(), stop(), seek(), а также реализован обработчик событий. Это сильно упрощает нам задачу.

Попробуем реализовать метод инициализации плеера:

Musica.prototype.open = function() {
	if(this.aurora) this.aurora.stop();
	this.aurora = AV.Player.fromURL('https://as.iostd.ru/' + this.playlist.getCurrent().audio + '.flac');
	this.aurora.volume = this.volume;
	this.ui.volume.css('width', ((this.volume * 100 ) / this.ui.volumebar.width())+'%');
	this.pstate = 0;
	this.ui.playbtn.removeClass("fa-play fa-pause").addClass("fa-play");
	this.ui.artist.html(this.playlist.getCurrent().artist);
	this.ui.title.html(this.playlist.getCurrent().title);
	this.ui.bufferbar.css('width', '0%');
	this.ui.progressbar.css('width', '0%');

	this.aurora.on('buffer', (function (_this) {
		return function(percent) {
			_this.ui.bufferbar.css('width', percent+'%');
		};
	})(this));

	this.aurora.on('progress', (function (_this) {
		return function(time) {
			if(_this.seekstate == 0) _this.ui.progressbar.css('width', ((time * 100 ) / _this.aurora.duration)+'%');
			_this._setTimer(time);
		};
	})(this));
	this.aurora.on('end', (function (_this) {
		return function() {
			_this.next();
		};
	})(this));
	this.aurora.preload();
};

Здесь мы загружаем в фреймворк текущий трек из списка воспроизведения, сбрасываем графический интерфейс на значения по умолчанию и подключаем обработчики событий. Этот метод мы будем вызывать каждый раз, когда нам нужно воспроизвести новый трек.

Теперь нам нужно сделать, на мой взгляд, самое сложное: полосу поиска и регулятор громкости. Эти два элемента интерфейса очень похожи между собой (по крайней мере в нашем случае).

	this.ui.seekbar.off();
	this.ui.seekbar.mousedown((function (_this) {
		return function(e) {
			var offsetx = e.offsetX;
			var origin = $(this);
			_this.seekstate = 1;
			_this.ui.progressbar.css('width', ((offsetx * 100 ) / $(this).width())+'%');
			$(document).mousemove(function(e) {
				offsetx = e.pageX - origin.offset().left;
				
				offsetx = offsetx < 0 ? 0 : (offsetx > origin.width() ? origin.width() : offsetx);
				_this.ui.progressbar.css('width', ((offsetx * 100 ) / origin.width())+'%');
			});
			$(document).mouseup(function(e) {
				$(document).off("mousemove");
				$(document).off("mouseup");
				_this.aurora.seek(Math.floor((offsetx * _this.aurora.duration) / origin.width()));
				_this.seekstate = 0;
			});
			
		};
	})(this));

Сначала мы подключаем обработчик события mousedown. После этого мы меняем значение progressbar и подключаем еще два обработчика. В первом (mousemove) мы также меняем значение progressbar. Во втором (mouseup) мы отключаем эти два обработчика и вызываем метод фреймворка seek(). Для того, чтобы во время поиска мышкой, progressbar не дергался от события progress, которое мы обрабатываем выше, нам нужен seekstate.

Почти таким же способом делаем регулятор громкости:

	this.ui.volumebar.off();
	this.ui.volumebar.mousedown((function (_this) {
		return function(e) {
			var offsetx = e.offsetX;
			var origin = $(this);
			_this.ui.volume.css('width', ((offsetx * 100 ) / origin.width())+'%');
			_this.volume = Math.floor((offsetx * 100) / origin.width());
			_this.aurora.volume = _this.volume;
			$(document).mousemove(function(e) {
				offsetx = e.pageX - origin.offset().left;
				
				offsetx = offsetx < 0 ? 0 : (offsetx > origin.width() ? origin.width() : offsetx);
				_this.ui.volume.css('width', ((offsetx * 100 ) / origin.width())+'%');
				_this.volume = Math.floor((offsetx * 100) / origin.width());
				_this.aurora.volume = _this.volume
			});
			$(document).mouseup(function(e) {
				$(document).off("mousemove");
				$(document).off("mouseup");
				_this.volume = Math.floor((offsetx * 100) / origin.width());
				_this.aurora.volume = _this.volume;
			});
			
		};
	})(this));

Плеер почти готов. Нам только осталось добавить методы play(), netxt(), prev(). В их реализации нет ничего сложного:

Musica.prototype.play = function() {
	this.aurora.play();
	this.pstate = 1;
	this.ui.playbtn.removeClass("fa-play fa-pause").addClass("fa-pause");
};

Musica.prototype.pause = function() {
	this.aurora.pause();
	this.pstate = 0;
	this.ui.playbtn.removeClass("fa-play fa-pause").addClass("fa-play");
};

Musica.prototype.next = function() {
	if(this.playlist.next() !== -1) {
		var pss = this.pstate;
		this.pause();
		this.open();
		if(pss == 1) {
			this.play();
		}
	}
};

Musica.prototype.prev = function() {
	if(this.playlist.prev() !== -1) {
		var pss = this.pstate;
		this.pause();
		this.open();
		if(pss == 1) {
			this.play();
		}
	}
};

Готово. Можно подключать плеер.

Подключаем

$(function() {
	var params = {
		artist: $('#artist'),
		title: $('#title'),
		seekbar: $('#seek'),
		bufferbar: $("#buffer"),
		progressbar: $("#progress"),
		timer: $("#time"),
		playbtn: $("#play"),
		backwardbtn: $("#backward"),
		forwardbtn: $("#forward"),
		volumebar: $("#volume"),
		volume: $("#volumevalue")
	};
	
	var mplayer = new Musica(params);
	$.get( "http://iostd.ru/audioapi.php?c=tracks", function( data ) {
		for (var i = 0; i < data.result.length; i++) {
			mplayer.playlist.add(data.result[i]);
		}
		mplayer.open();
		mplayer.play();
	});
});

P.S. Как видите, сделать плеер на подобии VK не так уж и сложно.

Демо: http://audio.iostd.ru/
Исходники: goo.gl/XQ2IwE

Автор:

Источник

* - обязательные к заполнению поля