Пишемо програвач lossless аудіо на JavaScript

Добрий день,% 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, смуга буферизації і пошуку, гучність, рядок назви треку і час. Також хотілося б реалізувати відображення обкладинки альбомів, плейлисти, пошук по базі аудіотеки і так далі, але я вирішив поки зупинитися на самому основному. Так як з веб-розробкою я стикався не часто, то з версткою і дизайном у мене, м'яко кажучи, не дуже.
 
Ось що у мене вийшло в результаті:
 
 
 
Верстка:
 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('http://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/
Исходники: https://github.com/Show-vars/HTML5LosslessPlayer

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.