diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Client.hx | 37 | ||||
| -rw-r--r-- | src/ClientTools.hx | 26 | ||||
| -rw-r--r-- | src/Lang.hx | 62 | ||||
| -rw-r--r-- | src/Types.hx | 77 | ||||
| -rw-r--r-- | src/client/Main.hx | 334 | ||||
| -rw-r--r-- | src/client/MobileView.hx | 52 | ||||
| -rw-r--r-- | src/client/Player.hx | 182 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 106 | ||||
| -rw-r--r-- | src/server/Main.hx | 251 | ||||
| -rw-r--r-- | src/server/VideoTimer.hx | 54 |
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; + } + +} |
