aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Client.hx37
-rw-r--r--src/ClientTools.hx26
-rw-r--r--src/Lang.hx62
-rw-r--r--src/Types.hx77
-rw-r--r--src/client/Main.hx334
-rw-r--r--src/client/MobileView.hx52
-rw-r--r--src/client/Player.hx182
-rw-r--r--src/server/HttpServer.hx106
-rw-r--r--src/server/Main.hx251
-rw-r--r--src/server/VideoTimer.hx54
10 files changed, 1181 insertions, 0 deletions
diff --git a/src/Client.hx b/src/Client.hx
new file mode 100644
index 0000000..e44417a
--- /dev/null
+++ b/src/Client.hx
@@ -0,0 +1,37 @@
+package;
+
+#if nodejs
+import js.npm.ws.WebSocket;
+#elseif js
+import js.html.WebSocket;
+#end
+
+typedef ClientData = {
+ name:String,
+ isLeader:Bool
+}
+
+class Client {
+
+ public final ws:WebSocket;
+ public var name:String;
+ public var isLeader:Bool;
+
+ public function new(?ws:WebSocket, name:String, isLeader = false) {
+ this.ws = ws;
+ this.name = name;
+ this.isLeader = isLeader;
+ }
+
+ public function getData():ClientData {
+ return {
+ name: name,
+ isLeader: isLeader
+ }
+ }
+
+ public static function fromData(data:ClientData):Client {
+ return new Client(data.name, data.isLeader);
+ }
+
+}
diff --git a/src/ClientTools.hx b/src/ClientTools.hx
new file mode 100644
index 0000000..4503ada
--- /dev/null
+++ b/src/ClientTools.hx
@@ -0,0 +1,26 @@
+package;
+
+class ClientTools {
+
+ public static function setLeader(clients:Array<Client>, name:String):Void {
+ for (client in clients) {
+ if (client.name == name) client.isLeader = true;
+ else if (client.isLeader) client.isLeader = false;
+ }
+ }
+
+ public static function hasLeader(clients:Array<Client>):Bool {
+ for (client in clients) {
+ if (client.isLeader) return true;
+ }
+ return false;
+ }
+
+ public static function getByName(clients:Array<Client>, name:String):Null<Client> {
+ for (client in clients) {
+ if (client.name == name) return client;
+ }
+ return null;
+ }
+
+}
diff --git a/src/Lang.hx b/src/Lang.hx
new file mode 100644
index 0000000..8632812
--- /dev/null
+++ b/src/Lang.hx
@@ -0,0 +1,62 @@
+package;
+
+import haxe.Json;
+import haxe.io.Path;
+#if (sys || nodejs)
+import sys.io.File;
+#else
+import haxe.Http;
+#end
+
+private typedef LangMap = Map<String, String>;
+
+class Lang {
+
+ static final ids = ["en", "ru"];
+ static final langs:Map<String, LangMap> = [];
+
+ static function request(path:String, callback:(data:String)->Void):Void {
+ #if (sys || nodejs)
+ callback(File.getContent(path));
+ #else
+ final http = new Http(path);
+ http.onData = callback;
+ http.request();
+ #end
+ }
+
+ public static function init(folderPath:String, ?callback:()->Void):Void {
+ langs.clear();
+ var count = 0;
+ for (name in ids) {
+ request('$folderPath/$name.json', data -> {
+ final data = Json.parse(data);
+ final lang = new LangMap();
+ for (key in Reflect.fields(data)) {
+ lang[key] = Reflect.field(data, key);
+ }
+ final id = Path.withoutExtension(name);
+ langs[id] = lang;
+ count++;
+ if (count == ids.length && callback != null) callback();
+ });
+ }
+ }
+
+ #if (sys || nodejs)
+ public static function get(lang:String, ?key:String):String {
+ if (langs[lang] == null) lang = "en";
+ final text = langs[lang][key];
+ return text == null ? key : text;
+ }
+ #else
+ static var lang = js.Browser.navigator.language.substr(0, 2).toLowerCase();
+
+ public static function get(key:String):String {
+ if (langs[lang] == null) lang = "en";
+ final text = langs[lang][key];
+ return text == null ? key : text;
+ }
+ #end
+
+}
diff --git a/src/Types.hx b/src/Types.hx
new file mode 100644
index 0000000..3d4ac4f
--- /dev/null
+++ b/src/Types.hx
@@ -0,0 +1,77 @@
+package;
+
+import Client.ClientData;
+
+typedef VideoItem = {
+ url:String,
+ title:String,
+ author:String,
+ duration:Float
+}
+
+typedef WsEvent = {
+ type:WsEventType,
+ ?connected:{
+ clients:Array<ClientData>,
+ isUnknownClient:Bool,
+ clientName:String,
+ videoList:Array<VideoItem>
+ },
+ ?login:{
+ clientName:String,
+ ?clients:Array<ClientData>,
+ ?isUnknownClient:Bool,
+ },
+ ?logout:{
+ clientName:String,
+ clients:Array<ClientData>,
+ },
+ ?message:{
+ clientName:String,
+ text:String
+ },
+ ?updateClients:{
+ clients:Array<ClientData>,
+ },
+ ?addVideo:{
+ item:VideoItem
+ },
+ ?removeVideo:{
+ url:String
+ },
+ ?pause:{
+ time:Float
+ },
+ ?play:{
+ time:Float
+ },
+ ?getTime:{
+ time:Float,
+ paused:Bool
+ },
+ ?setTime:{
+ time:Float
+ },
+ ?setLeader:{
+ clientName:String
+ }
+}
+
+enum abstract WsEventType(String) {
+ var Connected;
+ var Login;
+ var LoginError;
+ var Logout;
+ var Message;
+ var UpdateClients;
+ // var AddClient;
+ // var RemoveClient;
+ var AddVideo;
+ var RemoveVideo;
+ var VideoLoaded;
+ var Pause;
+ var Play;
+ var GetTime;
+ var SetTime;
+ var SetLeader;
+}
diff --git a/src/client/Main.hx b/src/client/Main.hx
new file mode 100644
index 0000000..2779202
--- /dev/null
+++ b/src/client/Main.hx
@@ -0,0 +1,334 @@
+package client;
+
+import haxe.Timer;
+import js.html.MouseEvent;
+import js.html.ButtonElement;
+import js.html.KeyboardEvent;
+import js.html.Element;
+import haxe.Json;
+import js.html.InputElement;
+import js.html.WebSocket;
+import js.Browser;
+import js.Browser.document;
+import js.lib.Date;
+import Client.ClientData;
+import Types;
+using ClientTools;
+
+class Main {
+
+ final clients:Array<Client> = [];
+ final personalHistory:Array<String> = [];
+ var personal:Null<Client>;
+ var personalHistoryId = -1;
+ var isConnected = false;
+ var ws:WebSocket;
+ final player:Player;
+ final onTimeGet = new Timer(2000);
+
+ static function main():Void new Main();
+
+ public function new(?host:String, port = 4201) {
+ player = new Player(this);
+ if (host == null) host = Browser.location.hostname;
+ if (host == "") host = "localhost";
+
+ initListeners();
+ onTimeGet.run = () -> send({type: GetTime});
+ Lang.init("langs", () -> {
+ openWebSocket(host, port);
+ });
+ }
+
+ function openWebSocket(host:String, port:Int):Void {
+ ws = new WebSocket('ws://$host:$port');
+ ws.onmessage = onMessage;
+ ws.onopen = () -> {
+ serverMessage(1);
+ isConnected = true;
+ }
+ ws.onclose = () -> {
+ // if initial connection refused
+ // or server/client offline
+ if (isConnected) serverMessage(2);
+ isConnected = false;
+ player.pause();
+ Timer.delay(() -> openWebSocket(host, port), 2000);
+ }
+ }
+
+ function initListeners():Void {
+ final guestName:InputElement = cast ge("#guestname");
+ guestName.onkeydown = (e:KeyboardEvent) -> {
+ if (e.keyCode == 13) send({
+ type: Login,
+ login: {
+ clientName: guestName.value
+ }
+ });
+ }
+
+ final chatLine:InputElement = cast ge("#chatline");
+ chatLine.onkeydown = function(e:KeyboardEvent) {
+ switch (e.keyCode) {
+ case 13: // Enter
+ send({
+ type: Message,
+ message: {
+ clientName: "",
+ text: chatLine.value
+ }
+ });
+ personalHistory.push(chatLine.value);
+ if (personalHistory.length > 50) personalHistory.shift();
+ personalHistoryId = -1;
+ chatLine.value = "";
+ case 38: // Up
+ personalHistoryId--;
+ if (personalHistoryId == -2) {
+ personalHistoryId = personalHistory.length - 1;
+ if (personalHistoryId == -1) return;
+ } else if (personalHistoryId == -1) personalHistoryId++;
+ chatLine.value = personalHistory[personalHistoryId];
+ case 40: // Down
+ if (personalHistoryId == -1) return;
+ personalHistoryId++;
+ if (personalHistoryId > personalHistory.length - 1) {
+ personalHistoryId = -1;
+ chatLine.value = "";
+ return;
+ }
+ chatLine.value = personalHistory[personalHistoryId];
+ }
+ }
+
+ MobileView.init();
+
+ final leaderBtn:InputElement = cast ge("#leader_btn");
+ leaderBtn.onclick = (e) -> {
+ if (personal == null) return;
+ leaderBtn.classList.toggle('label-success');
+ final name = personal.isLeader ? "" : personal.name;
+ send({
+ type: SetLeader,
+ setLeader: {
+ clientName: name
+ }
+ });
+ }
+
+ final showMediaUrl:ButtonElement = cast ge("#showmediaurl");
+ showMediaUrl.onclick = (e:MouseEvent) -> {
+ ge("#showmediaurl").classList.toggle("collapsed");
+ ge("#showmediaurl").classList.toggle("active");
+ ge("#addfromurl").classList.toggle("collapse");
+ }
+ ge("#queue_next").onclick = (e:MouseEvent) -> addVideoUrl();
+ ge("#queue_end").onclick = (e:MouseEvent) -> addVideoUrl();
+ ge("#mediaurl").onkeydown = function(e:KeyboardEvent) {
+ if (e.keyCode == 13) addVideoUrl();
+ }
+ }
+
+ public function isLeader():Bool {
+ return personal != null && personal.isLeader;
+ }
+
+ function addVideoUrl():Void {
+ final mediaUrl:InputElement = cast ge("#mediaurl");
+ final url = mediaUrl.value;
+ final name = personal == null ? "Unknown" : personal.name;
+ getRemoteVideoDuration(mediaUrl.value, (duration:Float) -> {
+ send({
+ type: AddVideo,
+ addVideo: {
+ item: {
+ url: url,
+ title: Lang.get("rawVideo"),
+ author: name,
+ duration: duration
+ }
+ }
+ });
+ });
+ mediaUrl.value = "";
+ }
+
+ function getRemoteVideoDuration(src:String, callback:(duration:Float)->Void):Void {
+ final player:Element = ge("#ytapiplayer");
+ final video = document.createVideoElement();
+ video.src = src;
+ video.onloadedmetadata = () -> {
+ trace(video.duration);
+ player.removeChild(video);
+ callback(video.duration);
+ }
+ prepend(player, video);
+ }
+
+ function prepend(parent:Element, child:Element):Void {
+ if (parent.firstChild == null) parent.appendChild(child);
+ else parent.insertBefore(child, parent.firstChild);
+ }
+
+ function onMessage(e):Void {
+ final data:WsEvent = Json.parse(e.data);
+ final t:String = cast data.type;
+ final t = t.charAt(0).toLowerCase() + t.substr(1);
+ trace('Event: ${data.type}', untyped data[t]);
+ switch (data.type) {
+ case Connected:
+ if (data.connected.isUnknownClient) {
+ updateClients(data.connected.clients);
+ ge("#guestlogin").style.display = "block";
+ ge("#chatline").style.display = "none";
+ } else {
+ onLogin(data.connected.clients, data.connected.clientName);
+ }
+ final list = data.connected.videoList;
+ if (list.length == 0) return;
+ player.setVideo(list[0]);
+ for (video in data.connected.videoList) {
+ player.addVideoItem(video);
+ }
+ case Login:
+ onLogin(data.login.clients, data.login.clientName);
+ case LoginError:
+ serverMessage(4, Lang.get("usernameError"));
+ case Logout:
+ updateClients(data.logout.clients);
+ personal = null;
+ ge("#guestlogin").style.display = "block";
+ ge("#chatline").style.display = "none";
+ case UpdateClients:
+ updateClients(data.updateClients.clients);
+ if (personal != null) personal = clients.getByName(personal.name);
+ case Message:
+ addMessage(data.message.clientName, data.message.text);
+ case AddVideo:
+ if (player.isListEmpty()) player.setVideo(data.addVideo.item);
+ player.addVideoItem(data.addVideo.item);
+ case VideoLoaded:
+ player.setTime(0);
+ player.play();
+ case RemoveVideo:
+ player.removeItem(data.removeVideo.url);
+ if (player.isListEmpty()) player.pause();
+ case Pause:
+ player.pause();
+ player.setTime(data.pause.time);
+ case Play:
+ player.setTime(data.play.time);
+ player.play();
+ case GetTime:
+ final newTime = data.getTime.time;
+ final time = player.getTime();
+ if (Math.abs(time - newTime) < 2) return;
+ player.setTime(newTime);
+ if (!data.getTime.paused) player.play();
+ case SetTime:
+ final newTime = data.setTime.time;
+ final time = player.getTime();
+ if (Math.abs(time - newTime) < 2) return;
+ player.setTime(newTime);
+ case SetLeader:
+ clients.setLeader(data.setLeader.clientName);
+ updateUserList();
+ if (personal == null) return;
+ final leaderBtn:InputElement = cast ge("#leader_btn");
+ if (personal.isLeader) leaderBtn.classList.add('label-success');
+ else leaderBtn.classList.remove('label-success');
+ }
+ }
+
+ function onLogin(data:Array<ClientData>, clientName:String):Void {
+ updateClients(data);
+ personal = clients.getByName(clientName);
+ if (personal == null) return;
+ ge("#guestlogin").style.display = "none";
+ ge("#chatline").style.display = "block";
+ }
+
+ function updateClients(newClients:Array<ClientData>):Void {
+ clients.resize(0);
+ for (client in newClients) {
+ clients.push(Client.fromData(client));
+ }
+ updateUserList();
+ }
+
+ public function send(data:WsEvent):Void {
+ if (!isConnected) return;
+ ws.send(Json.stringify(data));
+ }
+
+ function serverMessage(type:Int, ?text:String):Void {
+ final msgBuf = ge("#messagebuffer");
+ final div = document.createDivElement();
+ final time = "[" + new Date().toTimeString().split(" ")[0] + "] ";
+ switch (type) {
+ case 1:
+ div.className = "server-msg-reconnect";
+ div.innerHTML = Lang.get("msgConnected");
+ case 2:
+ div.className = "server-msg-disconnect";
+ div.innerHTML = Lang.get("msgDisconnected");
+ case 3:
+ div.className = "server-whisper";
+ div.innerHTML = time + text + " " + Lang.get("entered");
+ case 4:
+ div.className = "server-whisper";
+ div.innerHTML = time + text;
+ default:
+ }
+ msgBuf.appendChild(div);
+ msgBuf.scrollTop = msgBuf.scrollHeight;
+ }
+
+ final pageTitle = document.title;
+
+ function updateUserList():Void {
+ final userCount = ge("#usercount");
+ userCount.innerHTML = clients.length + " " + Lang.get("online");
+ document.title = '$pageTitle (${clients.length})';
+
+ final list = new StringBuf();
+ for (client in clients) {
+ // final klass = client.isLeader ? "userlist_owner" : "userlist_item";
+ final klass = "userlist_item";
+ if (client.isLeader) list.add('<span class="glyphicon glyphicon-star-empty"></span>');
+ list.add('<span class="$klass">${client.name}</span></br>');
+ }
+ final userlist = ge("#userlist");
+ userlist.innerHTML = list.toString();
+ }
+
+ function addMessage(name:String, msg:String):Void {
+ final msgBuf = ge("#messagebuffer");
+ final userDiv = document.createDivElement();
+ userDiv.className = 'chat-msg-$name';
+
+ final tstamp = document.createSpanElement();
+ tstamp.className = "timestamp";
+ tstamp.innerHTML = "[" + new Date().toTimeString().split(" ")[0] + "] ";
+
+ final nameDiv = document.createElement("strong");
+ nameDiv.className = "username";
+ nameDiv.innerHTML = name + ": ";
+
+ final textDiv = document.createSpanElement();
+ textDiv.innerHTML = msg;
+
+ final isInChatEnd = msgBuf.scrollHeight - msgBuf.scrollTop == msgBuf.clientHeight;
+ userDiv.appendChild(tstamp);
+ userDiv.appendChild(nameDiv);
+ userDiv.appendChild(textDiv);
+ msgBuf.appendChild(userDiv);
+ if (isInChatEnd) msgBuf.scrollTop = msgBuf.scrollHeight;
+ }
+
+ public static inline function ge(id:String):Element {
+ return document.querySelector(id);
+ }
+
+}
diff --git a/src/client/MobileView.hx b/src/client/MobileView.hx
new file mode 100644
index 0000000..558df61
--- /dev/null
+++ b/src/client/MobileView.hx
@@ -0,0 +1,52 @@
+package client;
+
+import js.html.InputElement;
+import js.Browser.document;
+import client.Main.ge;
+
+class MobileView {
+
+ public static function init():Void {
+ final mvbtn:InputElement = cast ge("#mv_btn");
+ mvbtn.onclick = (e) -> {
+ final mobile_view = toggleFullScreen();
+ if (mobile_view) {
+ document.body.classList.add('mobile-view');
+ mvbtn.classList.add('label-success');
+ final vwrap = ge("#videowrap");
+ if (vwrap.children[0] == ge("currenttitle")) {
+ vwrap.appendChild(vwrap.children[0]);
+ }
+ } else {
+ document.body.classList.remove('mobile-view');
+ mvbtn.classList.remove('label-success');
+ final vwrap = ge("videowrap");
+ if (vwrap.children[0] != ge("currenttitle")) {
+ vwrap.insertBefore(vwrap.children[1],vwrap.children[0]);
+ }
+ }
+ }
+ }
+
+ static function toggleFullScreen():Bool {
+ var state = true;
+ final doc:Dynamic = document;
+ if (document.fullscreenElement == null &&
+ doc.mozFullScreenElement == null &&
+ doc.webkitFullscreenElement == null) {
+ if (document.documentElement.requestFullscreen != null) {
+ document.documentElement.requestFullscreen();
+ } else if (doc.documentElement.mozRequestFullScreen != null) {
+ doc.documentElement.mozRequestFullScreen();
+ } else if (doc.documentElement.webkitRequestFullscreen != null) {
+ doc.documentElement.webkitRequestFullscreen(untyped Element.ALLOW_KEYBOARD_INPUT);
+ } else state = false;
+ } else {
+ if (doc.cancelFullScreen != null) doc.cancelFullScreen();
+ else if (doc.mozCancelFullScreen != null) doc.mozCancelFullScreen();
+ else if (doc.webkitCancelFullScreen != null) doc.webkitCancelFullScreen();
+ state = false;
+ }
+ return state;
+ }
+}
diff --git a/src/client/Player.hx b/src/client/Player.hx
new file mode 100644
index 0000000..2973e9b
--- /dev/null
+++ b/src/client/Player.hx
@@ -0,0 +1,182 @@
+package client;
+
+import js.html.LIElement;
+import js.html.UListElement;
+import js.html.Element;
+import js.html.VideoElement;
+import js.Browser.document;
+import client.Main.ge;
+import Types.VideoItem;
+using Lambda;
+
+class Player {
+
+ final main:Main;
+ final items:Array<VideoItem> = [];
+ final videoItemsEl = ge("#queue");
+ final player:Element = ge("#ytapiplayer");
+ var isLoaded = false;
+ var skipSetTime = false;
+ var video:VideoElement;
+
+ public function new(main:Main):Void {
+ this.main = main;
+ }
+
+ public function setVideo(item:VideoItem):Void {
+ isLoaded = false;
+ video = document.createVideoElement();
+ video.id = "videoplayer";
+ video.src = item.url;
+ video.controls = true;
+ video.oncanplaythrough = (e) -> {
+ if (!isLoaded) main.send({type: VideoLoaded});
+ isLoaded = true;
+ }
+ video.ontimeupdate = (e) -> {
+ if (skipSetTime) {
+ skipSetTime = false;
+ return;
+ }
+ if (!main.isLeader()) return;
+ main.send({
+ type: SetTime,
+ setTime: {
+ time: video.currentTime
+ }
+ });
+ }
+ video.onpause = (e) -> {
+ if (!main.isLeader()) return;
+ main.send({
+ type: Pause,
+ pause: {
+ time: video.currentTime
+ }
+ });
+ }
+ video.onplay = (e) -> {
+ if (!main.isLeader()) return;
+ main.send({
+ type: Play,
+ play: {
+ time: video.currentTime
+ }
+ });
+ }
+ player.innerHTML = "";
+ player.appendChild(video);
+ }
+
+ public function addVideoItem(item:VideoItem):Void {
+ items.push(item);
+ final itemEl:LIElement = cast nodeFromString(
+ '<li class="queue_entry pluid-0 queue_temp queue_active" title="${Lang.get("addedBy")}: ${item.author}">
+ <a class="qe_title" href="${item.url}" target="_blank">${item.title}</a>
+ <span class="qe_time">${duration(item.duration)}</span>
+ <div class="qe_clear"></div>
+ <div class="btn-group" style="display: inline-block;">
+ <button class="btn btn-xs btn-default qbtn-play">
+ <span class="glyphicon glyphicon-play"></span>${Lang.get("play")}
+ </button>
+ <button class="btn btn-xs btn-default qbtn-next">
+ <span class="glyphicon glyphicon-share-alt"></span>${Lang.get("skip")}
+ </button>
+ <button class="btn btn-xs btn-default qbtn-tmp">
+ <span class="glyphicon glyphicon-flag"></span>${Lang.get("makePermanent")}
+ </button>
+ <button class="btn btn-xs btn-default qbtn-delete" id="btn-delete">
+ <span class="glyphicon glyphicon-trash"></span>${Lang.get("delete")}
+ </button>
+ </div>
+ </li>'
+ );
+ final deleteBtn = itemEl.querySelector("#btn-delete");
+ deleteBtn.onclick = (e) -> {
+ main.send({
+ type: RemoveVideo,
+ removeVideo: {
+ url: itemEl.querySelector(".qe_title").getAttribute("href")
+ }
+ });
+ }
+ videoItemsEl.appendChild(itemEl);
+ ge("#plcount").innerHTML = '${items.length} ${Lang.get("videos")}';
+ ge("#pllength").innerHTML = totalDuration();
+ }
+
+ public function removeVideo():Void {
+ player.removeChild(video);
+ video = null;
+ }
+
+ public function removeItem(url:String):Void {
+ final list = ge("#queue");
+ for (child in list.children) {
+ if (child.querySelector(".qe_title").getAttribute("href") == url) {
+ list.removeChild(child);
+ break;
+ }
+ }
+
+ items.remove(
+ items.find(item -> item.url == url)
+ );
+
+ if (video.src == url) {
+ if (items.length > 0) setVideo(items[0]);
+ }
+ ge("#plcount").innerHTML = '${items.length} ${Lang.get("videos")}';
+ ge("#pllength").innerHTML = totalDuration();
+ }
+
+ function duration(time:Float):String {
+ final h = Std.int(time / 60 / 60);
+ final m = Std.int(time / 60);
+ final s = Std.int(time % 60);
+ var time = '$m:';
+ if (m < 10) time = '0$time';
+ if (h > 0) time = '$h:$time';
+ if (s < 10) time = time + "0";
+ time += s;
+ return time;
+ }
+
+ function totalDuration():String {
+ var time = 0.0;
+ for (item in items) time += item.duration;
+ return duration(time);
+ }
+
+ function nodeFromString(div:String):Element {
+ final wrapper = document.createDivElement();
+ wrapper.innerHTML = div;
+ return wrapper.firstElementChild;
+ }
+
+ public function isListEmpty():Bool {
+ return items.length == 0;
+ }
+
+ public function pause():Void {
+ if (video == null) return;
+ video.pause();
+ }
+
+ public function play():Void {
+ if (video == null) return;
+ video.play();
+ }
+
+ public function setTime(time:Float, isLocal = true):Void {
+ if (video == null) return;
+ skipSetTime = isLocal;
+ video.currentTime = time;
+ }
+
+ public function getTime():Float {
+ if (video == null) return 0;
+ return video.currentTime;
+ }
+
+}
diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx
new file mode 100644
index 0000000..b49301b
--- /dev/null
+++ b/src/server/HttpServer.hx
@@ -0,0 +1,106 @@
+package server;
+
+import js.node.Buffer;
+import haxe.io.Path;
+import js.node.Fs;
+import sys.io.File;
+import js.node.http.IncomingMessage;
+import js.node.http.ServerResponse;
+import js.Node.__dirname;
+import js.node.Path as JsPath;
+using StringTools;
+
+class HttpServer {
+
+ static final mimeTypes = [
+ "html" => "text/html",
+ "js" => "text/javascript",
+ "css" => "text/css",
+ "json" => "application/json",
+ "png" => "image/png",
+ "jpg" => "image/jpg",
+ "gif" => "image/gif",
+ "svg" => "image/svg+xml",
+ "ico" => "image/x-icon",
+ "wav" => "audio/wav",
+ "mp3" => "audio/mpeg",
+ "mp4" => "video/mp4",
+ "woff" => "application/font-woff",
+ "ttf" => "application/font-ttf",
+ "eot" => "application/vnd.ms-fontobject",
+ "otf" => "application/font-otf",
+ "wasm" => "application/wasm"
+ ];
+
+ static var dir:String;
+
+ public static function init(directory:String):Void {
+ dir = directory;
+ }
+
+ public static function serveFiles(req:IncomingMessage, res:ServerResponse):Void {
+ var filePath = dir + req.url;
+ if (req.url == "/") filePath = '$dir/index.html';
+
+ final extension = Path.extension(filePath).toLowerCase();
+ final contentType = getMimeType(extension);
+
+ if (!isChildOf(dir, filePath)) {
+ res.statusCode = 500;
+ var rel = JsPath.relative(dir, filePath);
+ res.end('Error getting the file: No access to $rel.');
+ return;
+ }
+
+ // load client code from build folder
+ if (filePath == '$dir/client.js') {
+ filePath = '$__dirname/client.js';
+ }
+
+ Fs.readFile(filePath, function(err:Dynamic, data:Buffer) {
+ if (err != null) {
+ if (err.code == "ENOENT") {
+ res.statusCode = 404;
+ var rel = JsPath.relative(dir, filePath);
+ res.end('File $rel not found.');
+ } else {
+ res.statusCode = 500;
+ res.end('Error getting the file: $err.');
+ }
+ return;
+ }
+ res.setHeader("Content-Type", contentType);
+ if (extension == "html") {
+ // replace ${textId} to localized strings
+ data = cast localizeHtml(data.toString(), req.headers["accept-language"]);
+ }
+ res.end(data);
+ });
+ }
+
+ static final matchLang = ~/^[A-z]+/;
+
+ static function localizeHtml(data:String, lang:String):String {
+ if (lang != null && matchLang.match(lang)) {
+ lang = matchLang.matched(0);
+ } else lang = "en";
+ data = ~/\${([A-z_]+)}/g.map(data, (regExp) -> {
+ final key = regExp.matched(1);
+ return Lang.get(lang, key);
+ });
+ return data;
+ }
+
+ static function isChildOf(parent:String, child:String):Bool {
+ final path = JsPath;
+ final relative = path.relative(parent, child);
+ return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
+ }
+
+ static function getMimeType(ext:String):String {
+ var contentType = mimeTypes[ext];
+ if (contentType == null) contentType = "application/octet-stream";
+ return contentType;
+ }
+
+}
diff --git a/src/server/Main.hx b/src/server/Main.hx
new file mode 100644
index 0000000..8ec7e87
--- /dev/null
+++ b/src/server/Main.hx
@@ -0,0 +1,251 @@
+package server;
+
+import haxe.Timer;
+import Client.ClientData;
+import haxe.Json;
+import js.Node.process;
+import js.Node.__dirname;
+import js.npm.ws.Server as WSServer;
+import js.npm.ws.WebSocket;
+import js.node.Http;
+import js.node.Dns;
+import Types;
+using ClientTools;
+using Lambda;
+
+class Main {
+
+ final wss:WSServer;
+ final clients:Array<Client> = [];
+ final videoList:Array<VideoItem> = [];
+ final videoTimer = new VideoTimer();
+
+ static function main():Void new Main();
+
+ public function new(port = 4200, wsPort = 4201) {
+ wss = new WSServer({port: wsPort});
+ wss.on("connection", onConnect);
+ function exit() {
+ process.exit();
+ }
+ process.on("exit", exit);
+ process.on("SIGINT", exit); // ctrl+c
+ process.on("uncaughtException", (log) -> {
+ trace(log);
+ });
+ process.on("unhandledRejection", (reason, promise) -> {
+ trace("Unhandled Rejection at:", reason);
+ });
+
+ getPublicIp(ip -> {
+ trace('Local: http://127.0.0.1:$port');
+ trace('Global: http://$ip:$port');
+ });
+
+ final dir = '$__dirname/../res';
+ HttpServer.init(dir);
+ Lang.init('$dir/langs');
+
+ Http.createServer((req, res) -> {
+ HttpServer.serveFiles(req, res);
+ }).listen(port);
+ }
+
+ function getPublicIp(callback:(ip:String)->Void):Void {
+ Dns.resolve("google.com", function(err, arr) {
+ if (err != null) {
+ callback("ERROR " + err.code);
+ return;
+ }
+ Http.get("http://myexternalip.com/raw", r -> {
+ r.setEncoding("utf8");
+ r.on("data", callback);
+ });
+ });
+ }
+
+ function onConnect(ws:WebSocket, req):Void {
+ final ip = req.connection.remoteAddress;
+ trace('Client connected ($ip)');
+ final client = new Client(ws, "Unknown", false);
+ clients.push(client);
+
+ send(client, {
+ type: Connected,
+ connected: {
+ isUnknownClient: true,
+ clientName: client.name,
+ clients: [
+ for (client in clients) client.getData()
+ ],
+ videoList: videoList
+ }
+ });
+ sendClientList();
+
+ ws.on("message", data -> {
+ onMessage(client, Json.parse(data));
+ });
+ ws.on("close", err -> {
+ trace('Client ${client.name} disconnected');
+ clients.remove(client);
+ sendClientList();
+ if (client.isLeader) {
+ if (videoTimer.isPaused()) videoTimer.play();
+ }
+ });
+ }
+
+ function onMessage(client:Client, data:WsEvent):Void {
+ switch (data.type) {
+ case Connected:
+ case UpdateClients:
+ sendClientList();
+ case Login:
+ final name = data.login.clientName;
+ if (name.length == 0 || name.length > 20 || clients.getByName(name) != null) {
+ send(client, {type: LoginError});
+ return;
+ }
+ client.name = data.login.clientName;
+ send(client, {
+ type: data.type,
+ login: {
+ isUnknownClient: true,
+ clientName: client.name,
+ clients: clientList()
+ }
+ });
+ sendClientList();
+ case LoginError:
+ case Logout:
+ final oldName = client.name;
+ client.name = "Unknown";
+ send(client, {
+ type: data.type,
+ logout: {
+ clientName: oldName,
+ clients: clientList()
+ }
+ });
+ sendClientList();
+ case Message:
+ // todo message log, max items
+ // todo message max length check
+ data.message.clientName = client.name;
+ broadcast(data);
+ case AddVideo:
+ videoList.push(data.addVideo.item);
+ broadcast(data);
+ if (videoList.length == 1) {
+ waitVideoStart = Timer.delay(startVideoPlayback, 3000);
+ }
+ case VideoLoaded:
+ prepareVideoPlayback();
+ case RemoveVideo:
+ if (videoList.length == 0) return;
+ final url = data.removeVideo.url;
+ if (videoList[0].url == url) videoTimer.stop();
+ videoList.remove(
+ videoList.find(item -> item.url == url)
+ );
+ broadcast(data);
+ case Pause:
+ if (videoList.length == 0) return;
+ if (!client.isLeader) return;
+ videoTimer.pause();
+ broadcast(data);
+ case Play:
+ if (videoList.length == 0) return;
+ if (!client.isLeader) return;
+ videoTimer.play();
+ broadcast(data);
+ case GetTime:
+ if (videoList.length == 0) return;
+ if (videoTimer.getTime() > videoList[0].duration) {
+ videoTimer.stop();
+ onMessage(client, {
+ type: RemoveVideo,
+ removeVideo: {
+ url: videoList[0].url
+ }
+ });
+ return;
+ }
+ send(client, {
+ type: GetTime, getTime: {
+ time: videoTimer.getTime(),
+ paused: videoTimer.isPaused()
+ }});
+ case SetTime:
+ if (videoList.length == 0) return;
+ if (!client.isLeader) return;
+ videoTimer.setTime(data.setTime.time);
+ broadcastExcept(client, data);
+ case SetLeader:
+ clients.setLeader(data.setLeader.clientName);
+ sendClientList();
+ if (videoList.length == 0) return;
+ if (!clients.hasLeader()) {
+ if (videoTimer.isPaused()) videoTimer.play();
+ broadcast({
+ type: Play, play: {
+ time: videoTimer.getTime()
+ }
+ });
+ }
+ }
+ }
+
+ function clientList():Array<ClientData> {
+ return [
+ for (client in clients) client.getData()
+ ];
+ }
+
+ function sendClientList():Void {
+ broadcast({
+ type: UpdateClients,
+ updateClients: {
+ clients: clientList()
+ }
+ });
+ }
+
+ function send(client:Client, data:WsEvent):Void {
+ client.ws.send(Json.stringify(data), null);
+ }
+
+ function broadcast(data:WsEvent):Void {
+ final json = Json.stringify(data);
+ for (client in clients) client.ws.send(json, null);
+ }
+
+ function broadcastExcept(skipped:Client, data:WsEvent):Void {
+ final json = Json.stringify(data);
+ for (client in clients) {
+ if (client == skipped) continue;
+ client.ws.send(json, null);
+ }
+ }
+
+ var waitVideoStart:Timer;
+ var loadedClientsCount = 0;
+
+ function prepareVideoPlayback():Void {
+ if (videoTimer.isStarted) return;
+ loadedClientsCount++;
+ if (loadedClientsCount == 1) {
+ waitVideoStart = Timer.delay(startVideoPlayback, 3000);
+ }
+ if (loadedClientsCount >= clients.length) startVideoPlayback();
+ }
+
+ function startVideoPlayback():Void {
+ if (waitVideoStart != null) waitVideoStart.stop();
+ loadedClientsCount = 0;
+ broadcast({type: VideoLoaded});
+ videoTimer.start();
+ }
+
+}
diff --git a/src/server/VideoTimer.hx b/src/server/VideoTimer.hx
new file mode 100644
index 0000000..695ea5d
--- /dev/null
+++ b/src/server/VideoTimer.hx
@@ -0,0 +1,54 @@
+package server;
+
+import haxe.Timer;
+
+class VideoTimer {
+
+ public var isStarted(default, null) = false;
+ var startTime = 0.0;
+ var pauseStartTime = 0.0;
+
+ public function new() {}
+
+ public function start():Void {
+ isStarted = true;
+ startTime = Timer.stamp();
+ pauseStartTime = 0;
+ }
+
+ public function stop():Void {
+ isStarted = false;
+ startTime = 0.0;
+ pauseStartTime = 0.0;
+ }
+
+ public function pause():Void {
+ pauseStartTime = Timer.stamp();
+ }
+
+ public function play():Void {
+ if (!isStarted) start();
+ startTime += pauseTime();
+ pauseStartTime = 0;
+ }
+
+ public function getTime():Float {
+ if (startTime == 0) return 0;
+ return Timer.stamp() - startTime - pauseTime();
+ }
+
+ public function setTime(secs:Float):Void {
+ startTime = Timer.stamp() - secs;
+ if (isPaused()) pause();
+ }
+
+ public function isPaused():Bool {
+ return pauseStartTime != 0;
+ }
+
+ function pauseTime():Float {
+ if (!isPaused()) return 0;
+ return Timer.stamp() - pauseStartTime;
+ }
+
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage