From 304bd1e77a5442f614aacd2b35f330777118b2cd Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Mon, 19 Oct 2026 15:32:53 -0700 Subject: merge with upstream https://github.com/RblSb/SyncTube --- Dockerfile | 3 +- README.md | 28 +- build/server.js | 1116 ++++++++++++++++++++------ default-config.json | 1 + docker-compose.yml | 2 +- package-lock.json | 1 + res/client.js | 1510 ++++++++++++++++++++++++++++-------- res/css/des.css | 385 +++++++-- res/index.html | 178 +++-- res/langs/en.json | 25 +- res/langs/ru.json | 23 +- src/ClientTools.hx | 11 +- src/Types.hx | 21 +- src/VideoList.hx | 6 + src/client/Buttons.hx | 203 ++++- src/client/ChatInput.hx | 188 +++++ src/client/ClientSettings.hx | 2 + src/client/Commands.hx | 182 +++++ src/client/FileUploader.hx | 96 ++- src/client/InputWithHistory.hx | 11 +- src/client/JsApi.hx | 18 +- src/client/Main.hx | 357 ++++++--- src/client/Player.hx | 153 +++- src/client/Utils.hx | 36 +- src/client/players/Iframe.hx | 29 +- src/client/players/Vimeo.hx | 302 ++++++++ src/server/HttpServer.hx | 26 +- src/server/Main.hx | 243 +++++- src/server/Utils.hx | 2 +- src/server/VideoTimer.hx | 4 + src/server/cache/Cache.hx | 39 +- src/server/cache/CacheCallbacks.hx | 15 + src/server/cache/RawCache.hx | 158 ++-- src/server/cache/YoutubeCache.hx | 156 ++-- src/server/cache/YtdlpCache.hx | 227 ++++++ test/tests/TestServer.hx | 62 ++ test/tests/TestSkipVote.hx | 151 ++++ 37 files changed, 4767 insertions(+), 1203 deletions(-) create mode 100644 src/client/ChatInput.hx create mode 100644 src/client/Commands.hx create mode 100644 src/client/players/Vimeo.hx create mode 100644 src/server/cache/CacheCallbacks.hx create mode 100644 src/server/cache/YtdlpCache.hx create mode 100644 test/tests/TestSkipVote.hx diff --git a/Dockerfile b/Dockerfile index 6dacf8d..ab35cc8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,5 +21,4 @@ RUN haxelib setup /app \ && haxelib install all --always && \ haxe build-all.hxml -ENTRYPOINT [ "npm", "start" ] - +ENTRYPOINT [ "node", "build/server.js" ] diff --git a/README.md b/README.md index 33fe47c..63c12e0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Lightweight modern implementation and a very easy way to run locally. - External `vtt`/`srt`/`ass` subtitles support - External audiotrack / voiceover support - `/30`, `/-21`, etc chat commands to rewind video playback by seconds -- Hotkeys (`Alt-P` for global play/pause, [etc](https://github.com/pinapelz/SyncTube-plus-plus/blob/382f9b2ebedca905028341825350a0fa69d88673/src/client/Buttons.hx#L416-L427)) +- [Hotkeys](#hotkeys) (`Alt-P` for global play/pause, etc) +- Compact view button with page fullscreen on Android - Playback rate synchronization (with leader) - Links mask: `foo.com/bar${1-4}.mp4` to add multiple items - Override every front-end file you want (`user/res` folder) @@ -16,13 +17,15 @@ Lightweight modern implementation and a very easy way to run locally. ## PlusPlus Features + Changes - Admin registration via configuring the `adminToken` config key and then registering on the `/admin-register` page - `gatePassword` config option to prevent unwanted users from joining -- Majority vote skip management (Admins and the original video queuer overrides this behavior) +- Strict majority skip voting with live vote counts (Admins and the original video queuer can skip immediately) - Leader request now requires the current leader to give up the role (via `/giveup `) - Overhauled default CSS and improved mobile view - Caching and uploading files is limited to Admin users only ## Supported players - Youtube (videos, shorts, streams and playlists) +- Twitch (streams) +- Vimeo - [Streamable](https://streamable.com) - [VK](https://vk.com/video) - [Peertube](https://joinpeertube.org) (with `pt:` url prefix) @@ -30,7 +33,7 @@ Lightweight modern implementation and a very easy way to run locally. - Iframes (without sync) ## Setup -- Open `4200` port in your router settings (port is cust@omizable) +- Open `4200` port in your router settings (port is customizable) - `npm ci` in this project folder ([NodeJS 14+](https://nodejs.org) required) - `haxe build-all.hxml` in this project folder ([HAXE homepage](https://haxe.org/)) - Run `node build/server.js` @@ -40,7 +43,7 @@ Lightweight modern implementation and a very easy way to run locally. As alternative, you can install Docker and run: > ```shell > docker build -t synctube . -> docker run --rm -it -p 4200:4200 -v ${PWD}/user:/usr/src/app/user synctube +> docker run --rm -it -p 4200:4200 -v ${PWD}/user:/app/user synctube > ``` or @@ -54,9 +57,9 @@ or ## "Cache On Server" "Cache on Server" is a feature that enables a user to upload videos to the server and serve it to all other users temporarily. The video is deleted after it has been removed from the queue. This feature is limited to admin users only. -This feature requires `ffmpeg` on your server system, it's only used to build single mp4 from downloaded audio/video tracks. Default cache size is 3.0 GiB. +This feature requires `ffmpeg` and `ffprobe` on your server system. Default cache size is 3.0 GiB. -If you want to enable `Cache on server` feature for Youtube player (effectively download the video on the server-side first), you can also run: +If you want to enable `Cache on server` feature for Youtube and other [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md), you can also run: ```shell npm i https://github.com/RblSb/ytdlp-nodejs ``` @@ -77,17 +80,28 @@ It just works, but you can also check [user/ folder](/user/README.md) for server - `/ad` - Rewind sponsored block in active YouTube video - `/fb` (`/flashback`) - Rewind video to a prev time if someone rewinded/restarted video accidentally - `/volume 2.6` - Change player volume in `0-1` range or boost it in `0-3` range for quiet videos +- `/giveup ` - Transfer leadership to another user - `/clear` - Clear chat. Admin clears chat globally - `/help` - Show initial tutorial message ### Admins only: - `/ban Guest 1 2h` - Ban user `Guest 1` ip for `2 hours` -- `/unban Foo` (`/removeBan`) - Unban user `Foo` +- `/unban Foo` - Unban user `Foo` - `/kick Foo` - Force `Foo` disconnection until page reload - `/dump` - Download state dump to report issues - `/crash` - Crash server if you need to test your auto-restart solution +### Hotkeys +- `Alt-L` - Request leader +- `Alt-P` - Request leader and toggle pause/play for everyone +- `Alt-R` - Refresh player +- `Alt-F` - Fullscreen player +- `Alt-S` - Vote for skip +- `Alt-C` - Retrieve playlist links +- `Alt-U` - Toggle video synchronization +- `Alt-click` on playlist item lock icon - Make all items permanent/temporary + ## Plugins - [octosubs](https://github.com/RblSb/SyncTube-octosubs) - More colorful `ASS`/`SSA` subtitles support - [qswitcher](https://github.com/aNNiMON/SyncTube-QSwitcher) - Raw video quality switcher diff --git a/build/server.js b/build/server.js index 5f68d3c..5b8b03f 100644 --- a/build/server.js +++ b/build/server.js @@ -1,4 +1,4 @@ -// Generated by Haxe 4.3.7 +// Generated by Haxe 4.3.3 (function ($global) { "use strict"; var $estr = function() { return js_Boot.__string_rec(this,''); },$hxEnums = $hxEnums || {},$_; function $extend(from, fields) { @@ -71,11 +71,18 @@ ClientTools.setLeader = function(clients,name) { } }; ClientTools.hasLeader = function(clients) { + return ClientTools.getLeader(clients) != null; +}; +ClientTools.getLeader = function(clients) { var _g = 0; - while(_g < clients.length) if((clients[_g++].group & 4) != 0) { - return true; + while(_g < clients.length) { + var client = clients[_g]; + ++_g; + if((client.group & 4) != 0) { + return client; + } } - return false; + return null; }; ClientTools.getByName = function(clients,name,def) { var _g = 0; @@ -759,7 +766,7 @@ JsonParser_$237c3c85cf00342dfb7f01e6a6157d10.prototype = $extend(json2object_rea this.value = null; } ,loadJsonString: function(s,pos,variable) { - this.value = this.loadString(s,pos,variable,["RawType","YoutubeType","VkType","IframeType"],"RawType"); + this.value = this.loadString(s,pos,variable,["RawType","YoutubeType","VkType","VimeoType","IframeType"],"RawType"); } ,__class__: JsonParser_$237c3c85cf00342dfb7f01e6a6157d10 }); @@ -1060,7 +1067,7 @@ JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.__name__ = true; JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.__super__ = json2object_reader_BaseParser; JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.prototype = $extend(json2object_reader_BaseParser.prototype,{ onIncorrectType: function(pos,variable) { - this.errors.push(json2object_Error.IncorrectType(variable,"{ ?updatePlaylist : Null<{ videoList : Array }>, ?updateClients : Null<{ clients : Array }>, type : WsEventType, ?togglePlaylistLock : Null<{ isOpen : Bool }>, ?toggleItemType : Null<{ pos : Int }>, ?skipVideo : Null<{ url : String }>, ?setTime : Null<{ time : Float }>, ?setRate : Null<{ rate : Float }>, ?setNextItem : Null<{ pos : Int }>, ?setLeader : Null<{ clientName : String }>, ?serverMessage : Null<{ textId : String }>, ?rewind : Null<{ time : Float }>, ?removeVideo : Null<{ url : String }>, ?progress : Null<{ type : ProgressType, ratio : Float, ?data : Null }>, ?playItem : Null<{ pos : Int }>, ?play : Null<{ time : Float }>, ?pause : Null<{ time : Float }>, ?message : Null<{ text : String, clientName : String }>, ?logout : Null<{ oldClientName : String, clients : Array, clientName : String }>, ?login : Null<{ ?passHash : Null, ?isUnknownClient : Null, ?clients : Null>, clientName : String }>, ?kickClient : Null<{ name : String }>, ?getTime : Null, ?dump : Null<{ data : String }>, ?connected : Null<{ videoList : Array, uuid : String, playersCacheSupport : Array, itemPos : Int, isUnknownClient : Bool, isPlaylistOpen : Bool, history : Array, globalIp : String, config : Config, clients : Array, clientName : String }>, ?banClient : Null<{ time : Float, name : String }>, ?addVideo : Null<{ item : VideoItem, atEnd : Bool }> }",pos)); + this.errors.push(json2object_Error.IncorrectType(variable,"{ ?updatePlaylist : Null<{ videoList : Array }>, ?updateClients : Null<{ clients : Array }>, type : WsEventType, ?togglePlaylistLock : Null<{ isOpen : Bool }>, ?toggleItemType : Null<{ pos : Int, ?all : Null }>, ?skipVideoStats : Null<{ voted : Int, total : Int, required : Int }>, ?skipVideo : Null<{ url : String }>, ?setTime : Null<{ time : Float }>, ?setRate : Null<{ rate : Float }>, ?setNextItem : Null<{ pos : Int }>, ?setLeader : Null<{ clientName : String }>, ?serverMessage : Null<{ textId : String }>, ?rewind : Null<{ time : Float }>, ?removeVideo : Null<{ url : String }>, ?progress : Null<{ ?url : Null, type : ProgressType, ratio : Float, ?data : Null }>, ?playItem : Null<{ pos : Int }>, ?play : Null<{ time : Float }>, ?pause : Null<{ time : Float }>, ?message : Null<{ text : String, clientName : String }>, ?logout : Null<{ oldClientName : String, clients : Array, clientName : String }>, ?login : Null<{ ?passHash : Null, ?isUnknownClient : Null, ?clients : Null>, clientName : String }>, ?kickClient : Null<{ name : String }>, ?getTime : Null, ?dump : Null<{ data : String }>, ?connected : Null<{ videoList : Array, uuid : String, playersCacheSupport : Array, itemPos : Int, isUnknownClient : Bool, isPlaylistOpen : Bool, history : Array, globalIp : String, config : Config, clients : Array, clientName : String }>, ?banClient : Null<{ time : Float, name : String }>, ?addVideo : Null<{ item : VideoItem, atEnd : Bool }> }",pos)); json2object_reader_BaseParser.prototype.onIncorrectType.call(this,pos,variable); } ,loadJsonNull: function(pos,variable) { @@ -1068,7 +1075,7 @@ JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.prototype = $extend(json2object_rea } ,loadJsonObject: function(o,pos,variable) { var assigned = new haxe_ds_StringMap(); - this.objectSetupAssign(assigned,["addVideo","banClient","connected","dump","getTime","kickClient","login","logout","message","pause","play","playItem","progress","removeVideo","rewind","serverMessage","setLeader","setNextItem","setRate","setTime","skipVideo","toggleItemType","togglePlaylistLock","type","updateClients","updatePlaylist"],[true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,false,true,true]); + this.objectSetupAssign(assigned,["addVideo","banClient","connected","dump","getTime","kickClient","login","logout","message","pause","play","playItem","progress","removeVideo","rewind","serverMessage","setLeader","setNextItem","setRate","setTime","skipVideo","skipVideoStats","toggleItemType","togglePlaylistLock","type","updateClients","updatePlaylist"],[true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,true,false,true,true]); this.value = this.getAuto(); var _g = 0; while(_g < o.length) { @@ -1112,7 +1119,7 @@ JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.prototype = $extend(json2object_rea this.value.playItem = this.loadObjectField(($_=new JsonParser_$5696efcdbd81f0be9220dc4cfb255706(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"playItem",assigned,this.value.playItem,pos); break; case "progress": - this.value.progress = this.loadObjectField(($_=new JsonParser_$e15c2211613da80aa2992b6249949a65(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"progress",assigned,this.value.progress,pos); + this.value.progress = this.loadObjectField(($_=new JsonParser_$df6a9f7e332eb07b32b03fe169ced290(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"progress",assigned,this.value.progress,pos); break; case "removeVideo": this.value.removeVideo = this.loadObjectField(($_=new JsonParser_$bf1400338ef221d86126b3ec267cf395(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"removeVideo",assigned,this.value.removeVideo,pos); @@ -1138,8 +1145,11 @@ JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.prototype = $extend(json2object_rea case "skipVideo": this.value.skipVideo = this.loadObjectField(($_=new JsonParser_$bf1400338ef221d86126b3ec267cf395(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"skipVideo",assigned,this.value.skipVideo,pos); break; + case "skipVideoStats": + this.value.skipVideoStats = this.loadObjectField(($_=new JsonParser_$bd8ec2f9337eacbdc88803aa17adf898(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"skipVideoStats",assigned,this.value.skipVideoStats,pos); + break; case "toggleItemType": - this.value.toggleItemType = this.loadObjectField(($_=new JsonParser_$5696efcdbd81f0be9220dc4cfb255706(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"toggleItemType",assigned,this.value.toggleItemType,pos); + this.value.toggleItemType = this.loadObjectField(($_=new JsonParser_$d5567c7dc98bd09ace00c5da6aea116a(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"toggleItemType",assigned,this.value.toggleItemType,pos); break; case "togglePlaylistLock": this.value.togglePlaylistLock = this.loadObjectField(($_=new JsonParser_$e5a960b500232cc84a0caf718de13706(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"togglePlaylistLock",assigned,this.value.togglePlaylistLock,pos); @@ -1160,7 +1170,7 @@ JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9.prototype = $extend(json2object_rea this.objectErrors(assigned,pos); } ,getAuto: function() { - return { addVideo : new JsonParser_$17f62a9968afd16686d4888a98c34102([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), banClient : new JsonParser_$cceff829ad5e63207b6f78bebb03c69a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), connected : new JsonParser_$d011018d886fa55f8828ebb1e93413c3([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), dump : new JsonParser_$dda5f87e2972892b16c7b833df96706a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), getTime : new JsonParser_$feded5bdb427eb48f39e60029d5dca4a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), kickClient : new JsonParser_$b2777fc36a3f1c43a2ae7d80ba0572ca([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), login : new JsonParser_$128275a4734b926daca1924fc6a200c7([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), logout : new JsonParser_$8d3a3702ec359ae3022bce905b75550b([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), message : new JsonParser_$33c34d246ae14cf949ef186cb0e1d8eb([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), pause : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), play : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), playItem : new JsonParser_$5696efcdbd81f0be9220dc4cfb255706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), progress : new JsonParser_$e15c2211613da80aa2992b6249949a65([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), removeVideo : new JsonParser_$bf1400338ef221d86126b3ec267cf395([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), rewind : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), serverMessage : new JsonParser_$d1c1e71f4452df068de6cecc104f6dd6([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setLeader : new JsonParser_$65985176838b1da92d970132a0cb6d58([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setNextItem : new JsonParser_$5696efcdbd81f0be9220dc4cfb255706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setRate : new JsonParser_$359929cb56fda98b8740cbc66ef083c4([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setTime : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), skipVideo : new JsonParser_$bf1400338ef221d86126b3ec267cf395([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), toggleItemType : new JsonParser_$5696efcdbd81f0be9220dc4cfb255706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), togglePlaylistLock : new JsonParser_$e5a960b500232cc84a0caf718de13706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), type : new JsonParser_$d8e5213783812cec0906ca233f0379dc([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), updateClients : new JsonParser_$126814d365d277a375ef70de1963f53d([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), updatePlaylist : new JsonParser_$a82c17fc41c21179c58e1d406d2b20cd([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; + return { addVideo : new JsonParser_$17f62a9968afd16686d4888a98c34102([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), banClient : new JsonParser_$cceff829ad5e63207b6f78bebb03c69a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), connected : new JsonParser_$d011018d886fa55f8828ebb1e93413c3([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), dump : new JsonParser_$dda5f87e2972892b16c7b833df96706a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), getTime : new JsonParser_$feded5bdb427eb48f39e60029d5dca4a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), kickClient : new JsonParser_$b2777fc36a3f1c43a2ae7d80ba0572ca([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), login : new JsonParser_$128275a4734b926daca1924fc6a200c7([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), logout : new JsonParser_$8d3a3702ec359ae3022bce905b75550b([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), message : new JsonParser_$33c34d246ae14cf949ef186cb0e1d8eb([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), pause : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), play : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), playItem : new JsonParser_$5696efcdbd81f0be9220dc4cfb255706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), progress : new JsonParser_$df6a9f7e332eb07b32b03fe169ced290([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), removeVideo : new JsonParser_$bf1400338ef221d86126b3ec267cf395([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), rewind : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), serverMessage : new JsonParser_$d1c1e71f4452df068de6cecc104f6dd6([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setLeader : new JsonParser_$65985176838b1da92d970132a0cb6d58([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setNextItem : new JsonParser_$5696efcdbd81f0be9220dc4cfb255706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setRate : new JsonParser_$359929cb56fda98b8740cbc66ef083c4([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), setTime : new JsonParser_$6340d11111c9ffddbd7ebb0d12d7c162([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), skipVideo : new JsonParser_$bf1400338ef221d86126b3ec267cf395([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), skipVideoStats : new JsonParser_$bd8ec2f9337eacbdc88803aa17adf898([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), toggleItemType : new JsonParser_$d5567c7dc98bd09ace00c5da6aea116a([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), togglePlaylistLock : new JsonParser_$e5a960b500232cc84a0caf718de13706([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), type : new JsonParser_$d8e5213783812cec0906ca233f0379dc([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), updateClients : new JsonParser_$126814d365d277a375ef70de1963f53d([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), updatePlaylist : new JsonParser_$a82c17fc41c21179c58e1d406d2b20cd([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; } ,__class__: JsonParser_$5f812affc76e9ba3f21130cdbd3b05d9 }); @@ -1478,6 +1488,51 @@ JsonParser_$b3a4781c98609b64e4a870171c45561f.prototype = $extend(json2object_rea } ,__class__: JsonParser_$b3a4781c98609b64e4a870171c45561f }); +var JsonParser_$bd8ec2f9337eacbdc88803aa17adf898 = function(errors,putils,errorType) { + if(errorType == null) { + errorType = 0; + } + json2object_reader_BaseParser.call(this,errors,putils,errorType); +}; +JsonParser_$bd8ec2f9337eacbdc88803aa17adf898.__name__ = true; +JsonParser_$bd8ec2f9337eacbdc88803aa17adf898.__super__ = json2object_reader_BaseParser; +JsonParser_$bd8ec2f9337eacbdc88803aa17adf898.prototype = $extend(json2object_reader_BaseParser.prototype,{ + onIncorrectType: function(pos,variable) { + this.errors.push(json2object_Error.IncorrectType(variable,"{ voted : Int, total : Int, required : Int }",pos)); + json2object_reader_BaseParser.prototype.onIncorrectType.call(this,pos,variable); + } + ,loadJsonNull: function(pos,variable) { + this.value = null; + } + ,loadJsonObject: function(o,pos,variable) { + var assigned = new haxe_ds_StringMap(); + this.objectSetupAssign(assigned,["required","total","voted"],[false,false,false]); + this.value = this.getAuto(); + var _g = 0; + while(_g < o.length) { + var field = o[_g]; + ++_g; + switch(field.name) { + case "required": + this.value.required = this.loadObjectField(($_=new JsonParser_$1686a6c336b71b36d77354cea19a8b52(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"required",assigned,this.value.required,pos); + break; + case "total": + this.value.total = this.loadObjectField(($_=new JsonParser_$1686a6c336b71b36d77354cea19a8b52(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"total",assigned,this.value.total,pos); + break; + case "voted": + this.value.voted = this.loadObjectField(($_=new JsonParser_$1686a6c336b71b36d77354cea19a8b52(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"voted",assigned,this.value.voted,pos); + break; + default: + this.errors.push(json2object_Error.UnknownVariable(field.name,this.putils.convertPosition(field.namePos))); + } + } + this.objectErrors(assigned,pos); + } + ,getAuto: function() { + return { required : new JsonParser_$1686a6c336b71b36d77354cea19a8b52([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), total : new JsonParser_$1686a6c336b71b36d77354cea19a8b52([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), voted : new JsonParser_$1686a6c336b71b36d77354cea19a8b52([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; + } + ,__class__: JsonParser_$bd8ec2f9337eacbdc88803aa17adf898 +}); var JsonParser_$bf1400338ef221d86126b3ec267cf395 = function(errors,putils,errorType) { if(errorType == null) { errorType = 0; @@ -1733,6 +1788,48 @@ JsonParser_$d1c1e71f4452df068de6cecc104f6dd6.prototype = $extend(json2object_rea } ,__class__: JsonParser_$d1c1e71f4452df068de6cecc104f6dd6 }); +var JsonParser_$d5567c7dc98bd09ace00c5da6aea116a = function(errors,putils,errorType) { + if(errorType == null) { + errorType = 0; + } + json2object_reader_BaseParser.call(this,errors,putils,errorType); +}; +JsonParser_$d5567c7dc98bd09ace00c5da6aea116a.__name__ = true; +JsonParser_$d5567c7dc98bd09ace00c5da6aea116a.__super__ = json2object_reader_BaseParser; +JsonParser_$d5567c7dc98bd09ace00c5da6aea116a.prototype = $extend(json2object_reader_BaseParser.prototype,{ + onIncorrectType: function(pos,variable) { + this.errors.push(json2object_Error.IncorrectType(variable,"{ pos : Int, ?all : Null }",pos)); + json2object_reader_BaseParser.prototype.onIncorrectType.call(this,pos,variable); + } + ,loadJsonNull: function(pos,variable) { + this.value = null; + } + ,loadJsonObject: function(o,pos,variable) { + var assigned = new haxe_ds_StringMap(); + this.objectSetupAssign(assigned,["all","pos"],[true,false]); + this.value = this.getAuto(); + var _g = 0; + while(_g < o.length) { + var field = o[_g]; + ++_g; + switch(field.name) { + case "all": + this.value.all = this.loadObjectField(($_=new JsonParser_$f55acea3678203c700715b781ad1ef0c(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"all",assigned,this.value.all,pos); + break; + case "pos": + this.value.pos = this.loadObjectField(($_=new JsonParser_$1686a6c336b71b36d77354cea19a8b52(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"pos",assigned,this.value.pos,pos); + break; + default: + this.errors.push(json2object_Error.UnknownVariable(field.name,this.putils.convertPosition(field.namePos))); + } + } + this.objectErrors(assigned,pos); + } + ,getAuto: function() { + return { all : new JsonParser_$f55acea3678203c700715b781ad1ef0c([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), pos : new JsonParser_$1686a6c336b71b36d77354cea19a8b52([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; + } + ,__class__: JsonParser_$d5567c7dc98bd09ace00c5da6aea116a +}); var JsonParser_$d581436aae29874719279c60b2eb25d0 = function(errors,putils,errorType) { if(errorType == null) { errorType = 0; @@ -1860,7 +1957,7 @@ JsonParser_$d89734267d6f665b411f49f013149267.__name__ = true; JsonParser_$d89734267d6f665b411f49f013149267.__super__ = json2object_reader_BaseParser; JsonParser_$d89734267d6f665b411f49f013149267.prototype = $extend(json2object_reader_BaseParser.prototype,{ onIncorrectType: function(pos,variable) { - this.errors.push(json2object_Error.IncorrectType(variable,"{ ?voiceOverTrack : Null, url : String, title : String, ?subs : Null, playerType : PlayerType, isTemp : Bool, duration : Float, doCache : Bool, author : String }",pos)); + this.errors.push(json2object_Error.IncorrectType(variable,"{ ?voiceOverTrack : Null, url : String, title : String, ?subs : Null, playerType : PlayerType, isTemp : Bool, ?isIncomplete : Null, duration : Float, doCache : Bool, author : String }",pos)); json2object_reader_BaseParser.prototype.onIncorrectType.call(this,pos,variable); } ,loadJsonNull: function(pos,variable) { @@ -1868,7 +1965,7 @@ JsonParser_$d89734267d6f665b411f49f013149267.prototype = $extend(json2object_rea } ,loadJsonObject: function(o,pos,variable) { var assigned = new haxe_ds_StringMap(); - this.objectSetupAssign(assigned,["author","doCache","duration","isTemp","playerType","subs","title","url","voiceOverTrack"],[false,false,false,false,false,true,false,false,true]); + this.objectSetupAssign(assigned,["author","doCache","duration","isIncomplete","isTemp","playerType","subs","title","url","voiceOverTrack"],[false,false,false,true,false,false,true,false,false,true]); this.value = this.getAuto(); var _g = 0; while(_g < o.length) { @@ -1884,6 +1981,9 @@ JsonParser_$d89734267d6f665b411f49f013149267.prototype = $extend(json2object_rea case "duration": this.value.duration = this.loadObjectField(($_=new JsonParser_$22ae0e2b89e5e3d477f988cc36d3272b(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"duration",assigned,this.value.duration,pos); break; + case "isIncomplete": + this.value.isIncomplete = this.loadObjectField(($_=new JsonParser_$f55acea3678203c700715b781ad1ef0c(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"isIncomplete",assigned,this.value.isIncomplete,pos); + break; case "isTemp": this.value.isTemp = this.loadObjectField(($_=new JsonParser_$c26f15e86e3de4c398a8273272aba034(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"isTemp",assigned,this.value.isTemp,pos); break; @@ -1909,7 +2009,7 @@ JsonParser_$d89734267d6f665b411f49f013149267.prototype = $extend(json2object_rea this.objectErrors(assigned,pos); } ,getAuto: function() { - return { author : new JsonParser_$27118326006d3829667a400ad23d5d98([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), doCache : new JsonParser_$c26f15e86e3de4c398a8273272aba034([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), duration : new JsonParser_$22ae0e2b89e5e3d477f988cc36d3272b([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), isTemp : new JsonParser_$c26f15e86e3de4c398a8273272aba034([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), playerType : new JsonParser_$237c3c85cf00342dfb7f01e6a6157d10([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), subs : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), title : new JsonParser_$27118326006d3829667a400ad23d5d98([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), url : new JsonParser_$27118326006d3829667a400ad23d5d98([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), voiceOverTrack : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; + return { author : new JsonParser_$27118326006d3829667a400ad23d5d98([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), doCache : new JsonParser_$c26f15e86e3de4c398a8273272aba034([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), duration : new JsonParser_$22ae0e2b89e5e3d477f988cc36d3272b([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), isIncomplete : new JsonParser_$f55acea3678203c700715b781ad1ef0c([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), isTemp : new JsonParser_$c26f15e86e3de4c398a8273272aba034([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), playerType : new JsonParser_$237c3c85cf00342dfb7f01e6a6157d10([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), subs : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), title : new JsonParser_$27118326006d3829667a400ad23d5d98([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), url : new JsonParser_$27118326006d3829667a400ad23d5d98([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), voiceOverTrack : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; } ,__class__: JsonParser_$d89734267d6f665b411f49f013149267 }); @@ -1931,7 +2031,7 @@ JsonParser_$d8e5213783812cec0906ca233f0379dc.prototype = $extend(json2object_rea this.value = null; } ,loadJsonString: function(s,pos,variable) { - this.value = this.loadString(s,pos,variable,["Connected","Disconnected","Login","PasswordRequest","LoginError","Logout","Message","ServerMessage","Progress","UpdateClients","BanClient","KickClient","AddVideo","RemoveVideo","SkipVideo","VideoLoaded","Pause","Play","GetTime","SetTime","SetRate","Rewind","Flashback","SetLeader","PlayItem","SetNextItem","ToggleItemType","ClearChat","ClearPlaylist","ShufflePlaylist","UpdatePlaylist","TogglePlaylistLock","Dump","CrashTest"],"Connected"); + this.value = this.loadString(s,pos,variable,["Connected","Disconnected","Login","PasswordRequest","LoginError","Logout","Message","ServerMessage","Progress","UpdateClients","BanClient","KickClient","AddVideo","RemoveVideo","SkipVideo","SkipVideoStats","VideoLoaded","Pause","Play","GetTime","SetTime","SetRate","Rewind","Flashback","SetLeader","PlayItem","SetNextItem","ToggleItemType","ClearChat","ClearPlaylist","ShufflePlaylist","UpdatePlaylist","TogglePlaylistLock","Dump","CrashTest"],"Connected"); } ,__class__: JsonParser_$d8e5213783812cec0906ca233f0379dc }); @@ -1993,17 +2093,17 @@ JsonParser_$ddce6d3de223cb2759be5c48797abca5.prototype = $extend(json2object_rea } ,__class__: JsonParser_$ddce6d3de223cb2759be5c48797abca5 }); -var JsonParser_$e15c2211613da80aa2992b6249949a65 = function(errors,putils,errorType) { +var JsonParser_$df6a9f7e332eb07b32b03fe169ced290 = function(errors,putils,errorType) { if(errorType == null) { errorType = 0; } json2object_reader_BaseParser.call(this,errors,putils,errorType); }; -JsonParser_$e15c2211613da80aa2992b6249949a65.__name__ = true; -JsonParser_$e15c2211613da80aa2992b6249949a65.__super__ = json2object_reader_BaseParser; -JsonParser_$e15c2211613da80aa2992b6249949a65.prototype = $extend(json2object_reader_BaseParser.prototype,{ +JsonParser_$df6a9f7e332eb07b32b03fe169ced290.__name__ = true; +JsonParser_$df6a9f7e332eb07b32b03fe169ced290.__super__ = json2object_reader_BaseParser; +JsonParser_$df6a9f7e332eb07b32b03fe169ced290.prototype = $extend(json2object_reader_BaseParser.prototype,{ onIncorrectType: function(pos,variable) { - this.errors.push(json2object_Error.IncorrectType(variable,"{ type : ProgressType, ratio : Float, ?data : Null }",pos)); + this.errors.push(json2object_Error.IncorrectType(variable,"{ ?url : Null, type : ProgressType, ratio : Float, ?data : Null }",pos)); json2object_reader_BaseParser.prototype.onIncorrectType.call(this,pos,variable); } ,loadJsonNull: function(pos,variable) { @@ -2011,7 +2111,7 @@ JsonParser_$e15c2211613da80aa2992b6249949a65.prototype = $extend(json2object_rea } ,loadJsonObject: function(o,pos,variable) { var assigned = new haxe_ds_StringMap(); - this.objectSetupAssign(assigned,["data","ratio","type"],[true,false,false]); + this.objectSetupAssign(assigned,["data","ratio","type","url"],[true,false,false,true]); this.value = this.getAuto(); var _g = 0; while(_g < o.length) { @@ -2027,6 +2127,9 @@ JsonParser_$e15c2211613da80aa2992b6249949a65.prototype = $extend(json2object_rea case "type": this.value.type = this.loadObjectField(($_=new JsonParser_$ec6c0149f584f56bd68388d292edf21c(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"type",assigned,this.value.type,pos); break; + case "url": + this.value.url = this.loadObjectField(($_=new JsonParser_$ddce6d3de223cb2759be5c48797abca5(this.errors,this.putils,1),$bind($_,$_.loadJson)),field,"url",assigned,this.value.url,pos); + break; default: this.errors.push(json2object_Error.UnknownVariable(field.name,this.putils.convertPosition(field.namePos))); } @@ -2034,9 +2137,9 @@ JsonParser_$e15c2211613da80aa2992b6249949a65.prototype = $extend(json2object_rea this.objectErrors(assigned,pos); } ,getAuto: function() { - return { data : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), ratio : new JsonParser_$22ae0e2b89e5e3d477f988cc36d3272b([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), type : new JsonParser_$ec6c0149f584f56bd68388d292edf21c([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; + return { data : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), ratio : new JsonParser_$22ae0e2b89e5e3d477f988cc36d3272b([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), type : new JsonParser_$ec6c0149f584f56bd68388d292edf21c([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1))), url : new JsonParser_$ddce6d3de223cb2759be5c48797abca5([],this.putils,0).loadJson(new hxjsonast_Json(hxjsonast_JsonValue.JNull,new hxjsonast_Position("",0,1)))}; } - ,__class__: JsonParser_$e15c2211613da80aa2992b6249949a65 + ,__class__: JsonParser_$df6a9f7e332eb07b32b03fe169ced290 }); var JsonParser_$e5a960b500232cc84a0caf718de13706 = function(errors,putils,errorType) { if(errorType == null) { @@ -2093,7 +2196,7 @@ JsonParser_$ec6c0149f584f56bd68388d292edf21c.prototype = $extend(json2object_rea this.value = null; } ,loadJsonString: function(s,pos,variable) { - this.value = this.loadString(s,pos,variable,["Caching","Downloading","Uploading","Canceled"],"Caching"); + this.value = this.loadString(s,pos,variable,["Caching","Downloading","Uploading","Canceled","Completed"],"Caching"); } ,__class__: JsonParser_$ec6c0149f584f56bd68388d292edf21c }); @@ -2519,8 +2622,14 @@ StringTools.hex = function(n,digits) { }; var _$Types_VideoItemTools = function() { }; _$Types_VideoItemTools.__name__ = true; +_$Types_VideoItemTools.isPlayable = function(item) { + if(item.isIncomplete == true && item.doCache) { + return false; + } + return true; +}; _$Types_VideoItemTools.withUrl = function(item,url) { - return { url : url, title : item.title, author : item.author, duration : item.duration, subs : item.subs, voiceOverTrack : item.voiceOverTrack, isTemp : item.isTemp, doCache : item.doCache, playerType : item.playerType}; + return { url : url, title : item.title, author : item.author, duration : item.duration, subs : item.subs, voiceOverTrack : item.voiceOverTrack, isTemp : item.isTemp, doCache : item.doCache, isIncomplete : item.isIncomplete, playerType : item.playerType}; }; var VideoList = function() { this.items = []; @@ -2547,6 +2656,9 @@ VideoList.prototype = { ,exists: function(f) { return Lambda.exists(this.items,f); } + ,find: function(f) { + return Lambda.find(this.items,f); + } ,findIndex: function(f) { return Lambda.findIndex(this.items,f); } @@ -2568,6 +2680,11 @@ VideoList.prototype = { ,toggleItemType: function(pos) { this.items[pos].isTemp = !this.items[pos].isTemp; } + ,setAllItemTypes: function(isTemp) { + var _g = 0; + var _g1 = this.items; + while(_g < _g1.length) _g1[_g++].isTemp = isTemp; + } ,removeItem: function(index) { if(index < this.pos) { this.pos--; @@ -2860,7 +2977,14 @@ var haxe_ds_IntMap = function() { haxe_ds_IntMap.__name__ = true; haxe_ds_IntMap.__interfaces__ = [haxe_IMap]; haxe_ds_IntMap.prototype = { - __class__: haxe_ds_IntMap + remove: function(key) { + if(!this.h.hasOwnProperty(key)) { + return false; + } + delete(this.h[key]); + return true; + } + ,__class__: haxe_ds_IntMap }; var haxe_ds_StringMap = function() { this.h = Object.create(null); @@ -3139,6 +3263,7 @@ hxjsonast_JObjectField.prototype = { __class__: hxjsonast_JObjectField }; var hxjsonast_Parser = function(source,filename) { + this.allowDuplicateKeys = false; this.source = source; this.filename = filename; this.pos = 0; @@ -3296,7 +3421,7 @@ hxjsonast_Parser.prototype = { var fieldStartPos = this.pos - 1; field = this.parseString(); fieldPos = new hxjsonast_Position(this.filename,fieldStartPos,this.pos); - if(Object.prototype.hasOwnProperty.call(names_h,field)) { + if(Object.prototype.hasOwnProperty.call(names_h,field) && !this.allowDuplicateKeys) { throw haxe_Exception.thrown(new hxjsonast_Error("Duplicate field name \"" + field + "\"",fieldPos)); } else { names_h[field] = true; @@ -3942,8 +4067,9 @@ server_ConsoleInput.prototype = { } ,__class__: server_ConsoleInput }; -var server__$HttpServer_HttpServerConfig = function(dir,customDir,allowLocalRequests,cache) { +var server__$HttpServer_HttpServerConfig = function(dir,customDir,allowLocalRequests,enableCORS,cache) { this.cache = null; + this.enableCORS = false; this.allowLocalRequests = false; this.customDir = null; this.dir = dir; @@ -3953,6 +4079,9 @@ var server__$HttpServer_HttpServerConfig = function(dir,customDir,allowLocalRequ if(allowLocalRequests != null) { this.allowLocalRequests = allowLocalRequests; } + if(enableCORS != null) { + this.enableCORS = enableCORS; + } if(cache != null) { this.cache = cache; } @@ -3969,6 +4098,7 @@ var server_HttpServer = function(main,config) { this.uploadingFilesSizes = new haxe_ds_StringMap(); this.CHUNK_SIZE = 5242880; this.cache = null; + this.enableCORS = false; this.allowLocalRequests = false; this.allowedLocalFiles = new haxe_ds_StringMap(); this.hasCustomRes = false; @@ -3976,6 +4106,7 @@ var server_HttpServer = function(main,config) { this.dir = config.dir; this.customDir = config.customDir; this.allowLocalRequests = config.allowLocalRequests; + this.enableCORS = config.enableCORS; this.cache = config.cache; if(this.customDir != null) { this.hasCustomRes = sys_FileSystem.exists(this.customDir); @@ -3993,6 +4124,17 @@ server_HttpServer.prototype = { } var filePath = this.getPath(this.dir,url); var ext = haxe_io_Path.extension(filePath).toLowerCase(); + if(this.enableCORS) { + res.setHeader("access-control-allow-origin","*"); + res.setHeader("access-control-allow-headers","content-type, range, content-name, x-client-uuid"); + res.setHeader("access-control-expose-headers","accept-ranges, content-encoding, content-length, content-range"); + if(req.method == "OPTIONS") { + res.setHeader("access-control-allow-methods","GET, HEAD, POST, OPTIONS"); + res.statusCode = 204; + res.end(); + return; + } + } res.setHeader("accept-ranges","bytes"); res.setHeader("content-type",this.getMimeType(ext)); if(req.method == "POST") { @@ -4170,7 +4312,6 @@ server_HttpServer.prototype = { if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { delete(_this.h[filePath]); } - this.cache.remove(name); req.destroy(); var tmp1 = ClientTools.getByName(this.main.clients,name); if(tmp1 == null) { @@ -4195,7 +4336,7 @@ server_HttpServer.prototype = { } }); stream.on("error",function(err) { - haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 277, className : "server.HttpServer", methodName : "uploadFile"}); + haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 291, className : "server.HttpServer", methodName : "uploadFile"}); tools_HttpServerTools.json(tools_HttpServerTools.status(res,500),{ info : "File write stream error."}); var _this = _gthis.uploadingFilesSizes; if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { @@ -4208,7 +4349,7 @@ server_HttpServer.prototype = { _gthis.cache.remove(name); }); req.on("error",function(err) { - haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 284, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]}); + haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 298, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]}); stream.destroy(); tools_HttpServerTools.json(tools_HttpServerTools.status(res,500),{ info : "File request error."}); var _this = _gthis.uploadingFilesSizes; @@ -4386,12 +4527,18 @@ server_HttpServer.prototype = { return true; } ,parseRangeHeader: function(rangeHeader,videoSize) { - var ranges = new EReg("[-=]","g").split(rangeHeader); + var ranges = new EReg("[=-]","g").split(rangeHeader); var start = Std.parseInt(ranges[1]); + var end = Std.parseInt(ranges[2]); + if(start == null) { + var tmp = end; + var a = videoSize - (tmp != null ? tmp : 0); + start = a < 0 ? 0 : a; + end = videoSize - 1; + } if(server_Utils.isOutOfRange(start,0,videoSize - 1)) { start = 0; } - var end = Std.parseInt(ranges[2]); if(end == null) { end = start + this.CHUNK_SIZE; } @@ -4614,8 +4761,8 @@ var server_Main = function(opts) { this.loadedClientsCount = 0; this.matchGuestName = new EReg("guest [0-9]+",""); this.matchHtmlChars = new EReg("[&^<>'\"]",""); + this.cacheCancelers = new haxe_ds_StringMap(); this.skipVotes = new haxe_ds_IntMap(); - this.isServerPause = false; this.flashbacks = []; this.messages = []; this.videoTimer = new server_VideoTimer(); @@ -4660,6 +4807,8 @@ var server_Main = function(opts) { this.cache = new server_cache_Cache(this,this.cacheDir); if(this.cache.isYtReady) { this.playersCacheSupport.push("YoutubeType"); + this.playersCacheSupport.push("VkType"); + this.playersCacheSupport.push("VimeoType"); } this.initIntergationHandlers(); this.loadState(); @@ -4687,7 +4836,7 @@ var server_Main = function(opts) { preparePort = function() { server_Utils.isPortFree(_gthis.port,function(isFree) { if(!isFree && attempts > 0) { - haxe_Log.trace("Warning: port " + _gthis.port + " is already in use. Changed to " + (_gthis.port + 1),{ fileName : "src/server/Main.hx", lineNumber : 159, className : "server.Main", methodName : "new"}); + haxe_Log.trace("Warning: port " + _gthis.port + " is already in use. Changed to " + (_gthis.port + 1),{ fileName : "src/server/Main.hx", lineNumber : 163, className : "server.Main", methodName : "new"}); attempts -= 1; _gthis.port++; preparePort(); @@ -4738,20 +4887,20 @@ server_Main.prototype = { var protocol = ssl == null ? "http" : "https"; var port = this.getPort(protocol); var colonPort = this.isDefaultPort() ? "" : ":" + port; - haxe_Log.trace("Local: " + protocol + "://" + this.localIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 189, className : "server.Main", methodName : "runServer"}); + haxe_Log.trace("Local: " + protocol + "://" + this.localIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 193, className : "server.Main", methodName : "runServer"}); if(this.config.localNetworkOnly) { - haxe_Log.trace("Global network is disabled in config",{ fileName : "src/server/Main.hx", lineNumber : 191, className : "server.Main", methodName : "runServer"}); + haxe_Log.trace("Global network is disabled in config",{ fileName : "src/server/Main.hx", lineNumber : 195, className : "server.Main", methodName : "runServer"}); } else if(!this.isNoState) { server_Utils.getGlobalIp(function(ip) { if(ip.indexOf(":") != -1) { ip = "[" + ip + "]"; } _gthis.globalIp = ip; - haxe_Log.trace("Global: " + protocol + "://" + _gthis.globalIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 197, className : "server.Main", methodName : "runServer"}); + haxe_Log.trace("Global: " + protocol + "://" + _gthis.globalIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 201, className : "server.Main", methodName : "runServer"}); }); } var dir = "" + this.rootDir + "/res"; - var httpServer = new server_HttpServer(this,new server__$HttpServer_HttpServerConfig(dir,"" + this.userDir + "/res",this.config.localAdmins,this.cache)); + var httpServer = new server_HttpServer(this,new server__$HttpServer_HttpServerConfig(dir,"" + this.userDir + "/res",this.config.localAdmins,this.config.enableCORS,this.cache)); Lang.init("" + dir + "/langs"); var server; if(ssl == null) { @@ -4768,7 +4917,7 @@ server_Main.prototype = { redirectHttpServer.listen(80); } catch( _g ) { var _g1 = haxe_Exception.caught(_g); - haxe_Log.trace("Cannot run http server on port 80 for https redirects:",{ fileName : "src/server/Main.hx", lineNumber : 223, className : "server.Main", methodName : "runServer", customParams : [_g1]}); + haxe_Log.trace("Cannot run http server on port 80 for https redirects:",{ fileName : "src/server/Main.hx", lineNumber : 228, className : "server.Main", methodName : "runServer", customParams : [_g1]}); } } server = js_node_Https.createServer({ key : ssl.key, cert : ssl.cert},$bind(httpServer,httpServer.serveFiles)); @@ -4805,10 +4954,10 @@ server_Main.prototype = { return { key : key, cert : cert}; } if(!sys_FileSystem.exists(config.sslKeyPemPath)) { - haxe_Log.trace("sslKeyPemPath: absolute file path not found: " + config.sslKeyPemPath,{ fileName : "src/server/Main.hx", lineNumber : 261, className : "server.Main", methodName : "getSslConfig"}); + haxe_Log.trace("sslKeyPemPath: absolute file path not found: " + config.sslKeyPemPath,{ fileName : "src/server/Main.hx", lineNumber : 266, className : "server.Main", methodName : "getSslConfig"}); } if(!sys_FileSystem.exists(config.sslCertPemPath)) { - haxe_Log.trace("sslCertPemPath: absolute file path not found: " + config.sslCertPemPath,{ fileName : "src/server/Main.hx", lineNumber : 263, className : "server.Main", methodName : "getSslConfig"}); + haxe_Log.trace("sslCertPemPath: absolute file path not found: " + config.sslCertPemPath,{ fileName : "src/server/Main.hx", lineNumber : 268, className : "server.Main", methodName : "getSslConfig"}); } return null; } @@ -4858,6 +5007,7 @@ server_Main.prototype = { if(!sys_FileSystem.exists(customPath)) { return config; } + var defaultYtDlp = config.ytDlp; var customConfig = JSON.parse(js_node_Fs.readFileSync(customPath,{ encoding : "utf8"})); var _g = 0; var _g1 = Reflect.fields(customConfig); @@ -4865,10 +5015,12 @@ server_Main.prototype = { var field = _g1[_g]; ++_g; if(Reflect.field(config,field) == null) { - haxe_Log.trace("Warning: config field \"" + field + "\" is unknown",{ fileName : "src/server/Main.hx", lineNumber : 308, className : "server.Main", methodName : "getUserConfig"}); + haxe_Log.trace("Warning: config field \"" + field + "\" is unknown",{ fileName : "src/server/Main.hx", lineNumber : 314, className : "server.Main", methodName : "getUserConfig"}); } config[field] = Reflect.field(customConfig,field); } + config.ytDlp.channel = config.ytDlp.channel != null ? config.ytDlp.channel : defaultYtDlp.channel; + config.ytDlp.jsRuntime = config.ytDlp.jsRuntime != null ? config.ytDlp.jsRuntime : defaultYtDlp.jsRuntime; var emoteCopies_h = Object.create(null); var _g = 0; var _g1 = config.emotes; @@ -4876,14 +5028,14 @@ server_Main.prototype = { var emote = _g1[_g]; ++_g; if(emoteCopies_h[emote.name]) { - haxe_Log.trace("Warning: emote name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 314, className : "server.Main", methodName : "getUserConfig"}); + haxe_Log.trace("Warning: emote name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 322, className : "server.Main", methodName : "getUserConfig"}); } emoteCopies_h[emote.name] = true; if(!this.verbose) { continue; } if(emoteCopies_h[emote.image]) { - haxe_Log.trace("Warning: emote url of name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 318, className : "server.Main", methodName : "getUserConfig"}); + haxe_Log.trace("Warning: emote url of name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 326, className : "server.Main", methodName : "getUserConfig"}); } emoteCopies_h[emote.image] = true; } @@ -4920,7 +5072,7 @@ server_Main.prototype = { js_node_Fs.writeFileSync("" + this.userDir + "/users.json",JSON.stringify({ admins : users1, bans : _g, salt : users.salt},null,"\t")); } ,saveState: function() { - haxe_Log.trace("Saving state...",{ fileName : "src/server/Main.hx", lineNumber : 356, className : "server.Main", methodName : "saveState"}); + haxe_Log.trace("Saving state...",{ fileName : "src/server/Main.hx", lineNumber : 364, className : "server.Main", methodName : "saveState"}); var json = JSON.stringify(this.getCurrentState(),null,"\t"); js_node_Fs.writeFileSync(this.statePath,json); this.writeUsers(this.userList); @@ -4935,7 +5087,7 @@ server_Main.prototype = { if(!sys_FileSystem.exists(this.statePath)) { return; } - haxe_Log.trace("Loading state...",{ fileName : "src/server/Main.hx", lineNumber : 380, className : "server.Main", methodName : "loadState"}); + haxe_Log.trace("Loading state...",{ fileName : "src/server/Main.hx", lineNumber : 388, className : "server.Main", methodName : "loadState"}); var state = JSON.parse(js_node_Fs.readFileSync(this.statePath,{ encoding : "utf8"})); state.flashbacks = state.flashbacks != null ? state.flashbacks : []; state.cachedFiles = state.cachedFiles != null ? state.cachedFiles : []; @@ -4957,7 +5109,7 @@ server_Main.prototype = { } ,logError: function(type,data) { this.cache.removeOlderCache(1048576); - haxe_Log.trace(type,{ fileName : "src/server/Main.hx", lineNumber : 404, className : "server.Main", methodName : "logError", customParams : [data]}); + haxe_Log.trace(type,{ fileName : "src/server/Main.hx", lineNumber : 412, className : "server.Main", methodName : "logError", customParams : [data]}); var crashesFolder = "" + this.userDir + "/crashes"; server_Utils.ensureDir(crashesFolder); var name = DateTools.format(new Date(),"%Y-%m-%d_%H_%M_%S") + "-" + type; @@ -4979,7 +5131,7 @@ server_Main.prototype = { if(_gthis.clients.length == 0) { return; } - haxe_Log.trace("Ping " + url,{ fileName : "src/server/Main.hx", lineNumber : 417, className : "server.Main", methodName : "initIntergationHandlers"}); + haxe_Log.trace("Ping " + url,{ fileName : "src/server/Main.hx", lineNumber : 425, className : "server.Main", methodName : "initIntergationHandlers"}); js_node_Http.get(url,null,function(r) { }); }; @@ -5008,13 +5160,13 @@ server_Main.prototype = { password += this.config.salt; var hash = haxe_crypto_Sha256.encode(password); this.userList.admins.push({ name : name, hash : hash}); - haxe_Log.trace("Admin " + name + " added.",{ fileName : "src/server/Main.hx", lineNumber : 443, className : "server.Main", methodName : "addAdmin"}); + haxe_Log.trace("Admin " + name + " added.",{ fileName : "src/server/Main.hx", lineNumber : 451, className : "server.Main", methodName : "addAdmin"}); } ,removeAdmin: function(name) { HxOverrides.remove(this.userList.admins,Lambda.find(this.userList.admins,function(item) { return item.name == name; })); - haxe_Log.trace("Admin " + name + " removed.",{ fileName : "src/server/Main.hx", lineNumber : 450, className : "server.Main", methodName : "removeAdmin"}); + haxe_Log.trace("Admin " + name + " removed.",{ fileName : "src/server/Main.hx", lineNumber : 458, className : "server.Main", methodName : "removeAdmin"}); } ,replayLog: function(events) { var _gthis = this; @@ -5081,7 +5233,7 @@ server_Main.prototype = { var ip = this.clientIp(req); var id = this.freeIds.length > 0 ? this.freeIds.shift() : this.clients.length; var name = "Guest " + (id + 1); - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 513, className : "server.Main", methodName : "onConnect", customParams : ["" + name + " connected (" + ip + ")"]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 521, className : "server.Main", methodName : "onConnect", customParams : ["" + name + " connected (" + ip + ")"]}); var remoteIp = req.socket.remoteAddress; var isAdmin = this.config.localAdmins && this.isLoopbackIp(remoteIp); var client = new Client(ws,req,id,name,0); @@ -5096,7 +5248,7 @@ server_Main.prototype = { var obj = _gthis.wsEventParser.fromJson(data.toString()); if(_gthis.wsEventParser.errors.length > 0 || _gthis.noTypeObj(obj)) { var errors = "" + ("Wrong request for type \"" + obj.type + "\":") + "\n" + json2object_ErrorUtils.convertErrorArray(_gthis.wsEventParser.errors); - haxe_Log.trace(errors,{ fileName : "src/server/Main.hx", lineNumber : 531, className : "server.Main", methodName : "onConnect"}); + haxe_Log.trace(errors,{ fileName : "src/server/Main.hx", lineNumber : 539, className : "server.Main", methodName : "onConnect"}); _gthis.serverMessage(client,errors); return; } @@ -5171,47 +5323,55 @@ server_Main.prototype = { data.addVideo.item = item; _gthis.videoList.addItem(item,data.addVideo.atEnd); _gthis.broadcast(data); - if(_gthis.videoList.items.length == 1) { + if(_gthis.videoList.items.length == 1 && _$Types_VideoItemTools.isPlayable(item)) { _gthis.restartWaitTimer(); } - } else { - var _g = item.playerType; - switch(_g) { - case "RawType": - this.cache.cacheRawVideo(client,item.url,function(name) { - item = _$Types_VideoItemTools.withUrl(item,_gthis.cache.getFileUrl(name)); - data.addVideo.item = item; - _gthis.videoList.addItem(item,data.addVideo.atEnd); - _gthis.broadcast(data); - if(_gthis.videoList.items.length == 1) { - _gthis.restartWaitTimer(); - } - }); - break; - case "YoutubeType": - this.cache.cacheYoutubeVideo(client,item.url,function(name) { - item = _$Types_VideoItemTools.withUrl(item,_gthis.cache.getFileUrl(name)); - if(item.duration > 1) { - item.duration -= 1; - } - data.addVideo.item = item; - _gthis.videoList.addItem(item,data.addVideo.atEnd); - _gthis.broadcast(data); - if(_gthis.videoList.items.length == 1) { - _gthis.restartWaitTimer(); - } - }); - break; - default: - var name = StringTools.replace("" + _g,"Type",""); - this.serverMessage(client,"No cache support for " + name + " player."); - data.addVideo.item = item; - _gthis.videoList.addItem(item,data.addVideo.atEnd); - _gthis.broadcast(data); - if(_gthis.videoList.items.length == 1) { - _gthis.restartWaitTimer(); - } + return; + } + var isYoutube = item.playerType == "YoutubeType"; + if(this.playersCacheSupport.indexOf(item.playerType) == -1) { + var name = StringTools.replace("" + item.playerType,"Type",""); + this.serverMessage(client,"No cache support for " + name + " player."); + data.addVideo.item = item; + _gthis.videoList.addItem(item,data.addVideo.atEnd); + _gthis.broadcast(data); + if(_gthis.videoList.items.length == 1 && _$Types_VideoItemTools.isPlayable(item)) { + _gthis.restartWaitTimer(); } + return; + } + var callbacks = { onResolved : function(finalUrl) { + item = _$Types_VideoItemTools.withUrl(item,finalUrl); + item.isIncomplete = true; + if(isYoutube && item.duration > 1) { + item.duration -= 1; + } + data.addVideo.item = item; + _gthis.videoList.addItem(item,data.addVideo.atEnd); + _gthis.broadcast(data); + if(_gthis.videoList.items.length == 1 && _$Types_VideoItemTools.isPlayable(item)) { + _gthis.restartWaitTimer(); + } + }, onProgress : function(ratio) { + _gthis.broadcastCacheProgress(item.url,ratio); + }, onComplete : function() { + _gthis.completeIncompleteItem(item.url); + }, onError : function() { + _gthis.removeIncompleteItem(item.url); + }, registerCancel : function(cancel) { + _gthis.cacheCancelers.h[item.url] = cancel; + }, onMetadata : function(title,duration) { + _gthis.updateIncompleteItemMeta(item.url,title,duration); + }}; + switch(item.playerType) { + case "VimeoType":case "VkType": + this.cache.cacheYtdlpVideo(client,item.url,callbacks); + break; + case "YoutubeType": + this.cache.cacheYoutubeVideo(client,item.url,callbacks); + break; + default: + this.cache.cacheRawVideo(client,item.url,callbacks); } break; case "BanClient": @@ -5257,10 +5417,10 @@ server_Main.prototype = { if(this.isPlaylistLockedFor(client)) { return; } - this.resetSkipVote(); if(!this.checkPermission(client,"removeVideo")) { return; } + this.resetSkipVote(); if(this.videoList.items.length != 0) { if(this.videoTimer.getTime() > 30) { var _this = this.videoList; @@ -5282,7 +5442,7 @@ server_Main.prototype = { tmp.stop(); } if(this.clients.length == 1 && this.videoList.items.length > 0) { - if(!this.isServerPause) { + if(!this.videoTimer.isServerPause) { if(this.videoTimer.isPaused()) { this.videoTimer.play(); } @@ -5291,28 +5451,31 @@ server_Main.prototype = { this.checkBan(client); this.send(client,{ type : "Connected", connected : { uuid : client.uuid, config : { channelName : this.config.channelName, emotes : this.config.emotes, filters : this.config.filters, isVerbose : this.config.isVerbose, maxLoginLength : this.config.maxLoginLength, maxMessageLength : this.config.maxMessageLength, permissions : this.config.permissions, port : this.config.port, requestLeaderOnPause : this.config.requestLeaderOnPause, salt : this.config.salt, serverVersion : this.config.serverVersion, templateUrl : this.config.templateUrl, totalVideoLimit : this.config.totalVideoLimit, unpauseWithoutLeader : this.config.unpauseWithoutLeader, userVideoLimit : this.config.userVideoLimit, youtubeApiKey : this.config.youtubeApiKey, youtubePlaylistLimit : this.config.youtubePlaylistLimit}, history : this.messages, isUnknownClient : true, clientName : client.name, clients : this.clientList(), videoList : this.videoList.items, isPlaylistOpen : this.videoList.isOpen, itemPos : this.videoList.pos, globalIp : this.globalIp, playersCacheSupport : this.playersCacheSupport}}); this.sendClientListExcept(client); + this.broadcastSkipVoteStats(); break; case "CrashTest": if((client.group & 8) == 0) { return; } - haxe_Log.trace("Crashing...",{ fileName : "src/server/Main.hx", lineNumber : 1073, className : "server.Main", methodName : "onMessage"}); + haxe_Log.trace("Crashing...",{ fileName : "src/server/Main.hx", lineNumber : 1132, className : "server.Main", methodName : "onMessage"}); null[1]++; break; case "Disconnected": if(!internal) { return; } - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 596, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " disconnected"]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 605, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " disconnected"]}); server_Utils.sortedPush(this.freeIds,client.id); HxOverrides.remove(this.clients,client); this.sendClientList(); if((client.group & 4) != 0) { if(this.videoList.items.length > 0) { this.videoTimer.pause(); - this.isServerPause = true; + this.videoTimer.isServerPause = true; } } + this.skipVotes.remove(client.id); + this.updateSkipVote(); if(this.clients.length == 0) { var tmp = this.emptyRoomCallbackTimer; if(tmp != null) { @@ -5373,6 +5536,10 @@ server_Main.prototype = { return; } var _this = this.videoList; + if(_this.items[_this.pos].isIncomplete) { + return; + } + var _this = this.videoList; var maxTime = _this.items[_this.pos].duration - 0.01; if(this.videoTimer.getTime() > maxTime) { this.videoTimer.pause(); @@ -5388,7 +5555,7 @@ server_Main.prototype = { if(this.videoTimer.isPaused()) { obj.getTime.paused = true; } - if(this.isServerPause) { + if(this.videoTimer.isServerPause) { obj.getTime.pausedByServer = true; } if(this.videoTimer.getRate() != 1) { @@ -5444,12 +5611,13 @@ server_Main.prototype = { this.send(client,{ type : "LoginError"}); return; } - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 687, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 698, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]}); client.name = name; client.setGroupFlag(ClientGroup.User,true); this.checkBan(client); this.send(client,{ type : data.type, login : { isUnknownClient : true, clientName : client.name, clients : this.clientList()}}); this.sendClientListExcept(client); + this.broadcastSkipVoteStats(); break; case "LoginError": break; @@ -5457,9 +5625,10 @@ server_Main.prototype = { var oldName = client.name; client.name = "Guest " + (this.clients.indexOf(client) + 1); client.setGroupFlag(ClientGroup.User,false); - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 708, className : "server.Main", methodName : "onMessage", customParams : ["Client " + oldName + " logout to " + client.name]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 720, className : "server.Main", methodName : "onMessage", customParams : ["Client " + oldName + " logout to " + client.name]}); this.send(client,{ type : data.type, logout : { oldClientName : oldName, clientName : client.name, clients : this.clientList()}}); this.sendClientListExcept(client); + this.broadcastSkipVoteStats(); break; case "Message": if(!this.checkPermission(client,"writeChat")) { @@ -5511,7 +5680,6 @@ server_Main.prototype = { this.saveFlashbackTime(_this.items[_this.pos]); } this.videoTimer.setTime(data.play.time); - this.isServerPause = false; this.videoTimer.play(); this.broadcast({ type : data.type, play : data.play}); break; @@ -5519,11 +5687,11 @@ server_Main.prototype = { if(!this.checkPermission(client,"changeOrder")) { return; } - this.resetSkipVote(); var pos = data.playItem.pos; if(!this.videoList.hasItem(pos)) { return; } + this.resetSkipVote(); if(this.videoTimer.getTime() > 30) { var _this = this.videoList; this.saveFlashbackTime(_this.items[_this.pos]); @@ -5534,37 +5702,80 @@ server_Main.prototype = { this.broadcast(data); break; case "Progress": + var p = data.progress; + var url; + var tmp = p.url; + if(tmp != null) { + url = tmp; + } else { + return; + } + var index = this.videoList.findIndex(function(i) { + return i.url == url; + }); + if(index == -1) { + return; + } + var item1 = this.videoList.items[index]; + if(item1.isIncomplete != true) { + return; + } + if(item1.doCache) { + return; + } + if(item1.author != client.name) { + return; + } + switch(p.type) { + case "Canceled": + this.removeIncompleteItem(url); + break; + case "Completed": + this.completeIncompleteItem(url); + break; + default: + this.broadcast(data); + } break; case "RemoveVideo": if(this.videoList.items.length == 0) { return; } - if(this.activeSkipVoteUrl == data.removeVideo.url) { - this.resetSkipVote(); - } if(this.isPlaylistLockedFor(client)) { return; } if(!this.checkPermission(client,"removeVideo")) { return; } - var url = data.removeVideo.url; + var url1 = data.removeVideo.url; var index = this.videoList.findIndex(function(item) { - return item.url == url; + return item.url == url1; }); if(index == -1) { return; } var _this = this.videoList; - var isCurrent = _this.items[_this.pos].url == url; + var isCurrent = _this.items[_this.pos].url == url1; + if(isCurrent) { + this.resetSkipVote(); + } if(isCurrent && this.videoTimer.getTime() > 30) { var _this = this.videoList; this.saveFlashbackTime(_this.items[_this.pos]); } this.videoList.removeItem(index); + this.cancelCache(url1); this.broadcast(data); if(isCurrent && this.videoList.items.length > 0) { - this.restartWaitTimer(); + var _this = this.videoList; + if(!_$Types_VideoItemTools.isPlayable(_this.items[_this.pos])) { + this.pauseForIncompleteItem(); + } else { + this.restartWaitTimer(); + } + } + if(this.videoList.items.length == 0) { + this.videoTimer.stop(); } break; case "Rewind": @@ -5610,7 +5821,7 @@ server_Main.prototype = { this.serverMessage(client,"Client \"" + clientName + "\" not found."); return; } - this.isServerPause = false; + this.videoTimer.isServerPause = false; ClientTools.setLeader(this.clients,clientName); this.broadcast({ type : "SetLeader", setLeader : { clientName : clientName}}); if(this.videoList.items.length == 0) { @@ -5684,6 +5895,8 @@ server_Main.prototype = { } this.handleSkipVote(client,data); break; + case "SkipVideoStats": + break; case "ToggleItemType": if(this.isPlaylistLockedFor(client)) { return; @@ -5695,6 +5908,11 @@ server_Main.prototype = { if(!this.videoList.hasItem(pos)) { return; } + if(data.toggleItemType.all == true) { + this.videoList.setAllItemTypes(!this.videoList.items[pos].isTemp); + this.broadcast({ type : "UpdatePlaylist", updatePlaylist : { videoList : this.videoList.items}}); + return; + } this.videoList.toggleItemType(pos); this.broadcast(data); break; @@ -5712,7 +5930,7 @@ server_Main.prototype = { this.broadcast({ type : "UpdatePlaylist", updatePlaylist : { videoList : this.videoList.items}}); break; case "VideoLoaded": - if(this.isServerPause) { + if(this.videoTimer.isServerPause) { return; } this.prepareVideoPlayback(); @@ -5738,13 +5956,6 @@ server_Main.prototype = { ,send: function(client,data) { client.ws.send(server_Main.jsonStringify(data),null); } - ,sendByName: function(clientName,data) { - var tmp = ClientTools.getByName(this.clients,clientName); - if(tmp == null) { - return; - } - tmp.ws.send(server_Main.jsonStringify(data),null); - } ,broadcast: function(data) { var json = server_Main.jsonStringify(data); var _g = 0; @@ -5823,23 +6034,44 @@ server_Main.prototype = { return; } this.skipVotes.h[client.id] = true; - var votes = this.getSkipVoteCount(); - var required = this.getSkipVoteRequiredCount(); - this.broadcast({ type : "ServerMessage", serverMessage : { textId : "" + client.name + " voted to skip (" + votes + "/" + required + ")."}}); - if(votes >= required) { + this.updateSkipVote(); + } + ,updateSkipVote: function() { + if(this.activeSkipVoteUrl == null) { + return; + } + var tmp; + if(this.videoList.items.length != 0) { + var _this = this.videoList; + tmp = _this.items[_this.pos].url != this.activeSkipVoteUrl; + } else { + tmp = true; + } + if(tmp) { this.resetSkipVote(); - this.skipVideo(data); + return; + } + if(this.getSkipVoteCount() >= this.getSkipVoteRequiredCount()) { + this.skipVideo({ type : "SkipVideo", skipVideo : { url : this.activeSkipVoteUrl}}); + return; } + this.broadcastSkipVoteStats(); + } + ,broadcastSkipVoteStats: function() { + if(this.activeSkipVoteUrl == null) { + return; + } + this.broadcast({ type : "SkipVideoStats", skipVideoStats : { voted : this.getSkipVoteCount(), total : this.getSkipVoteEligibleClients().length, required : this.getSkipVoteRequiredCount()}}); } ,skipVideo: function(data) { if(this.videoList.items.length == 0) { return; } - this.resetSkipVote(); var _this = this.videoList; if(_this.items[_this.pos].url != data.skipVideo.url) { return; } + this.resetSkipVote(); var _this = this.videoList; var dur = _this.items[_this.pos].duration; if(this.videoTimer.getTime() > 30 && this.videoTimer.getTime() < dur - 30) { @@ -5848,10 +6080,100 @@ server_Main.prototype = { } this.videoList.skipItem(); if(this.videoList.items.length > 0) { - this.restartWaitTimer(); + var _this = this.videoList; + if(!_$Types_VideoItemTools.isPlayable(_this.items[_this.pos])) { + this.pauseForIncompleteItem(); + } else { + this.restartWaitTimer(); + } + } else { + this.videoTimer.stop(); } this.broadcast(data); } + ,pauseForIncompleteItem: function() { + this.videoTimer.stop(); + var tmp = this.waitVideoStart; + if(tmp != null) { + tmp.stop(); + } + } + ,cancelCache: function(url) { + var tmp = this.cacheCancelers.h[url]; + if(tmp == null) { + return; + } + var _this = this.cacheCancelers; + if(Object.prototype.hasOwnProperty.call(_this.h,url)) { + delete(_this.h[url]); + } + tmp(); + } + ,broadcastCacheProgress: function(url,ratio) { + this.broadcast({ type : "Progress", progress : { type : "Downloading", ratio : ratio, url : url}}); + } + ,updateIncompleteItemMeta: function(url,title,duration) { + var tmp = this.videoList.find(function(item) { + return item.url == url; + }); + if(tmp == null) { + return; + } + if(tmp.isIncomplete != true) { + return; + } + var changed = false; + if(tmp.duration <= 0 && duration > 0) { + tmp.duration = duration; + changed = true; + } + if(title != null && title.length > 0 && tmp.title.length == 0) { + tmp.title = title; + changed = true; + } + if(!changed) { + return; + } + this.broadcast({ type : "UpdatePlaylist", updatePlaylist : { videoList : this.videoList.items}}); + } + ,completeIncompleteItem: function(url) { + var _this = this.cacheCancelers; + if(Object.prototype.hasOwnProperty.call(_this.h,url)) { + delete(_this.h[url]); + } + var index = this.videoList.findIndex(function(i) { + return i.url == url; + }); + if(index == -1) { + return; + } + var item = this.videoList.items[index]; + if(item.isIncomplete != true) { + return; + } + item.isIncomplete = false; + this.broadcast({ type : "Progress", progress : { type : "Completed", ratio : 1, url : url}}); + if(this.videoList.pos == index && item.doCache) { + this.restartWaitTimer(); + } + } + ,removeIncompleteItem: function(url) { + var _this = this.cacheCancelers; + if(Object.prototype.hasOwnProperty.call(_this.h,url)) { + delete(_this.h[url]); + } + var index = this.videoList.findIndex(function(i) { + return i.url == url; + }); + if(index == -1) { + return; + } + if(this.videoList.items[index].isIncomplete != true) { + return; + } + this.videoList.removeItem(index); + this.broadcast({ type : "RemoveVideo", removeVideo : { url : url}}); + } ,checkPermission: function(client,perm) { if((client.group & 1) != 0) { this.checkBan(client); @@ -5880,7 +6202,7 @@ server_Main.prototype = { client.setGroupFlag(ClientGroup.Banned,!isOutdated); if(isOutdated) { HxOverrides.remove(this.userList.bans,ban); - haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1255, className : "server.Main", methodName : "checkBan"}); + haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1415, className : "server.Main", methodName : "checkBan"}); this.sendClientList(); } break; @@ -5933,7 +6255,6 @@ server_Main.prototype = { } this.loadedClientsCount = 0; this.broadcast({ type : "VideoLoaded"}); - this.isServerPause = false; this.videoTimer.start(); } ,saveFlashbackTime: function(item) { @@ -6064,7 +6385,7 @@ server_Utils.getGlobalIp = function(callback) { data_b += Std.string(chunk); }); r.on("end",function() { - callback(data_b); + callback(StringTools.trim(data_b)); }); }).on("error",onError).on("timeout",onError); }; @@ -6106,12 +6427,14 @@ var server_VideoTimer = function() { this.rateStartTime = 0.0; this.pauseStartTime = 0.0; this.startTime = 0.0; + this.isServerPause = false; this.isStarted = false; }; server_VideoTimer.__name__ = true; server_VideoTimer.prototype = { start: function() { this.isStarted = true; + this.isServerPause = false; var hrtime = process.hrtime(); this.startTime = hrtime[0] + hrtime[1] / 1e9; this.pauseStartTime = 0; @@ -6120,6 +6443,7 @@ server_VideoTimer.prototype = { } ,stop: function() { this.isStarted = false; + this.isServerPause = false; this.startTime = 0; this.pauseStartTime = 0; } @@ -6139,6 +6463,7 @@ server_VideoTimer.prototype = { if(!this.isStarted) { this.start(); } + this.isServerPause = false; this.startTime += this.pauseTime(); this.pauseStartTime = 0; var hrtime = process.hrtime(); @@ -6203,12 +6528,13 @@ var server_cache_Cache = function(main,cacheDir) { this.main = main; this.cacheDir = cacheDir; server_Utils.ensureDir(cacheDir); - this.youtubeCache = new server_cache_YoutubeCache(main,this); + this.ytdlpCache = new server_cache_YtdlpCache(main,this); + this.youtubeCache = new server_cache_YoutubeCache(main,this,this.ytdlpCache); this.rawCache = new server_cache_RawCache(main,this); - this.isYtReady = this.youtubeCache.checkYtDeps(); + this.isYtReady = this.ytdlpCache.checkYtDeps(); if(this.isYtReady) { - this.youtubeCache.cleanYtInputFiles(); - this.youtubeCache.checkUpdate(); + this.ytdlpCache.cleanInputFiles(); + this.ytdlpCache.checkUpdate(); } }; server_cache_Cache.__name__ = true; @@ -6237,16 +6563,16 @@ server_cache_Cache.prototype = { if(this.cachedFiles.indexOf(name) != -1) { continue; } - haxe_Log.trace("Remove untracked cache " + name,{ fileName : "src/server/cache/Cache.hx", lineNumber : 54, className : "server.cache.Cache", methodName : "removeUntrackedFiles"}); + haxe_Log.trace("Remove untracked cache " + name,{ fileName : "src/server/cache/Cache.hx", lineNumber : 56, className : "server.cache.Cache", methodName : "removeUntrackedFiles"}); this.remove(name); } } ,log: function(client,msg) { - haxe_Log.trace(msg,{ fileName : "src/server/cache/Cache.hx", lineNumber : 60, className : "server.cache.Cache", methodName : "log"}); + haxe_Log.trace(msg,{ fileName : "src/server/cache/Cache.hx", lineNumber : 62, className : "server.cache.Cache", methodName : "log"}); this.main.serverMessage(client,msg); } ,logByName: function(clientName,msg) { - haxe_Log.trace(msg,{ fileName : "src/server/cache/Cache.hx", lineNumber : 65, className : "server.cache.Cache", methodName : "logByName"}); + haxe_Log.trace(msg,{ fileName : "src/server/cache/Cache.hx", lineNumber : 67, className : "server.cache.Cache", methodName : "logByName"}); var tmp = ClientTools.getByName(this.main.clients,clientName); if(tmp == null) { return; @@ -6274,11 +6600,29 @@ server_cache_Cache.prototype = { } } } - ,cacheYoutubeVideo: function(client,url,callback) { - this.youtubeCache.cacheYoutubeVideo(client,url,callback); + ,cacheYoutubeVideo: function(client,url,callbacks) { + this.youtubeCache.cacheYoutubeVideo(client,url,callbacks); + } + ,cacheYtdlpVideo: function(client,url,callbacks) { + if(!this.isYtReady) { + this.rawCache.cacheRawVideo(client,url,callbacks); + return; + } + this.ytdlpCache.cacheVideo(client,url,callbacks); } - ,cacheRawVideo: function(client,url,callback) { - this.rawCache.cacheRawVideo(client,url,callback); + ,cacheRawVideo: function(client,url,callbacks) { + var _gthis = this; + if(!this.isYtReady) { + this.rawCache.cacheRawVideo(client,url,callbacks); + return; + } + this.ytdlpCache.checkUrlSupported(url,function(supported) { + if(supported) { + _gthis.ytdlpCache.cacheVideo(client,url,callbacks); + } else { + _gthis.rawCache.cacheRawVideo(client,url,callbacks); + } + }); } ,setStorageLimit: function(bytes) { var _gthis = this; @@ -6301,13 +6645,13 @@ server_cache_Cache.prototype = { var _gthis = this; var tmp = js_node_Fs.statfs; if(tmp == null) { - haxe_Log.trace("Warning: no fs.statfs support in current nodejs version (needs v18+)",{ fileName : "src/server/cache/Cache.hx", lineNumber : 104, className : "server.cache.Cache", methodName : "getFreeDiskSpace"}); + haxe_Log.trace("Warning: no fs.statfs support in current nodejs version (needs v18+)",{ fileName : "src/server/cache/Cache.hx", lineNumber : 125, className : "server.cache.Cache", methodName : "getFreeDiskSpace"}); callback(this.storageLimit); return; } - tmp("/",function(err,stats) { + tmp(this.cacheDir,function(err,stats) { if(err != null) { - haxe_Log.trace(err,{ fileName : "src/server/cache/Cache.hx", lineNumber : 110, className : "server.cache.Cache", methodName : "getFreeDiskSpace"}); + haxe_Log.trace(err,{ fileName : "src/server/cache/Cache.hx", lineNumber : 131, className : "server.cache.Cache", methodName : "getFreeDiskSpace"}); callback(_gthis.storageLimit); return; } @@ -6421,7 +6765,7 @@ var server_cache_RawCache = function(main,cache) { }; server_cache_RawCache.__name__ = true; server_cache_RawCache.prototype = { - cacheRawVideo: function(client,url,callback) { + cacheRawVideo: function(client,url,cb) { var isM3U8 = url.indexOf(".m3u8") != -1; var ext = isM3U8 ? "m3u8" : "mp4"; var matchName = new EReg("^([^:.]+)\\.(.+)",""); @@ -6434,49 +6778,94 @@ server_cache_RawCache.prototype = { var outName = matchName.match(HxOverrides.substr(decodedUrl,decodedUrl.lastIndexOf("/") + 1,null)) ? matchName.matched(1) + ("." + ext) : "video." + ext; outName = this.cache.getFreeFileName(outName); if(this.cache.exists(outName)) { - callback(outName); + var tmp = this.cache.getFileUrl(outName); + cb.onResolved(tmp); + cb.onComplete(); return; } - haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 46, className : "server.cache.RawCache", methodName : "cacheRawVideo"}); - this.main.send(client,{ type : "Progress", progress : { type : "Caching", ratio : 0, data : outName}}); + haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 47, className : "server.cache.RawCache", methodName : "cacheRawVideo"}); + var tmp = this.cache.getFileUrl(outName); + cb.onResolved(tmp); if(isM3U8) { - this.handleM3u8(client,url,outName,callback); + this.handleM3u8(client,url,outName,cb); } else { - this.handleMp4(client,url,outName,callback); + this.handleMp4(client,url,outName,cb); } } - ,handleMp4: function(client,url,outName,callback) { + ,handleMp4: function(client,url,outName,cb) { var _gthis = this; var clientName = client.name; + var lastSentRatio = 0.0; + var canceled = false; this.downloadFile(client,url,outName,function(downloaded,total) { + if(canceled) { + return; + } var v = downloaded / total; - _gthis.main.sendByName(clientName,{ type : "Progress", progress : { type : "Downloading", ratio : tools_MathTools.toFixed(v < 0 ? 0 : v > 1 ? 1 : v,4)}}); + var ratio = v < 0 ? 0 : v > 1 ? 1 : v; + if(ratio - lastSentRatio < 0.01 && ratio < 1) { + return; + } + lastSentRatio = ratio; + var cb1 = cb; + var tmp = tools_MathTools.toFixed(ratio,4); + cb1.onProgress(tmp); },function() { - _gthis.cache.add(outName); - callback(outName); + if(canceled) { + return; + } + _gthis.probeDuration(outName,function(duration) { + if(canceled) { + return; + } + if(duration > 0 && cb.onMetadata != null) { + cb.onMetadata(null,duration); + } + _gthis.cache.add(outName); + cb.onComplete(); + }); },function(err) { + if(canceled) { + return; + } _gthis.log(clientName,"Mp4 download failed: " + err); - _gthis.cancelProgress(clientName); + cb.onError(); + },function(cancel) { + if(cb.registerCancel == null) { + return; + } + cb.registerCancel(function() { + canceled = true; + cancel(); + }); }); } - ,handleM3u8: function(client,url,outName,callback) { + ,handleM3u8: function(client,url,outName,cb) { var _gthis = this; var clientName = client.name; var useProxy = true; + var canceled = false; + if(cb.registerCancel != null) { + cb.registerCancel(function() { + canceled = true; + }); + } this.downloadM3u8Playlist(client,url,useProxy,function(playlist,totalSize,segments) { + if(canceled) { + return; + } if(useProxy) { totalSize = playlist.length; } if(!_gthis.cache.removeOlderCache(totalSize + _gthis.cache.freeSpaceBlock)) { _gthis.log(clientName,_gthis.cache.notEnoughSpaceErrorText); - _gthis.cancelProgress(clientName); + cb.onError(); return; } if(useProxy) { - _gthis.main.sendByName(clientName,{ type : "Progress", progress : { type : "Caching", ratio : 1, data : outName}}); js_node_Fs.writeFileSync("" + _gthis.cache.cacheDir + "/" + outName,playlist); _gthis.cache.add(outName); - callback(outName); + cb.onComplete(); return; } var activeDownloads = 0; @@ -6496,7 +6885,7 @@ server_cache_RawCache.prototype = { } segment[0].started = true; activeDownloads += 1; - haxe_Log.trace("download segment",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 120, className : "server.cache.RawCache", methodName : "handleM3u8", customParams : [segment[0].i]}); + haxe_Log.trace("download segment",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 121, className : "server.cache.RawCache", methodName : "handleM3u8", customParams : [segment[0].i]}); _gthis.downloadFile(client,segment[0].url,segment[0].name,(function() { return function(downloadedBytes,totalBytes) { }; @@ -6506,12 +6895,12 @@ server_cache_RawCache.prototype = { segment[0].completed = true; downloaded += 1; var progress = downloaded / segments.length; - _gthis.main.sendByName(clientName,{ type : "Progress", progress : { type : "Downloading", ratio : progress < 0 ? 0 : progress > 1 ? 1 : progress}}); + cb.onProgress(progress < 0 ? 0 : progress > 1 ? 1 : progress); if(downloaded == segments.length) { - haxe_Log.trace("All " + downloaded + "/" + segments.length + " segments downloaded",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 140, className : "server.cache.RawCache", methodName : "handleM3u8"}); + haxe_Log.trace("All " + downloaded + "/" + segments.length + " segments downloaded",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 135, className : "server.cache.RawCache", methodName : "handleM3u8"}); js_node_Fs.writeFileSync("" + _gthis.cache.cacheDir + "/" + outName,playlist); _gthis.cache.add(outName); - callback(outName); + cb.onComplete(); } else { downloadNextBatch(); } @@ -6521,7 +6910,7 @@ server_cache_RawCache.prototype = { activeDownloads -= 1; downloaded += 1; _gthis.log(clientName,"TS segment " + segment[0].i + " download failed: " + err); - _gthis.cancelProgress(clientName); + cb.onError(); var _gthis1 = _gthis; var result = new Array(segments.length); var _g = 0; @@ -6538,7 +6927,7 @@ server_cache_RawCache.prototype = { downloadNextBatch(); },function(err) { _gthis.log(clientName,"M3U8 processing failed: " + err); - _gthis.cancelProgress(clientName); + cb.onError(); }); } ,request: function(url,options,callback) { @@ -6599,6 +6988,7 @@ server_cache_RawCache.prototype = { } var req = _gthis.request(segments[0].url,{ method : "GET"},function(res) { var tmp = Std.parseInt(res.headers["content-length"]); + res.destroy(); var totalSize = (tmp != null ? tmp : 0) * (segments.length + 1); if(totalSize == 0) { onError("Failed to get segment sizes: no content-length"); @@ -6619,8 +7009,9 @@ server_cache_RawCache.prototype = { req.on("error",onError); req.end(); } - ,downloadFile: function(client,url,fileName,onProgress,onComplete,onError) { - var file = js_node_Fs.createWriteStream("" + this.cache.cacheDir + "/" + fileName); + ,downloadFile: function(client,url,fileName,onProgress,onComplete,onError,onCancelable) { + var outPath = "" + this.cache.cacheDir + "/" + fileName; + var file = js_node_Fs.createWriteStream(outPath); var req = this.request(url,{ method : "GET", headers : { "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537." + Std.random(100), "Accept" : "*/*"}},function(res) { var total; var tmp = Std.parseInt(res.headers["content-length"]); @@ -6653,67 +7044,52 @@ server_cache_RawCache.prototype = { file.destroy(); onError("Request failed: " + err); }); + if(onCancelable != null) { + onCancelable(function() { + req.destroy(); + file.destroy(); + if(sys_FileSystem.exists(outPath)) { + js_node_Fs.unlinkSync(outPath); + } + }); + } req.end(); } + ,probeDuration: function(fileName,callback) { + js_node_ChildProcess.execFile("ffprobe",["-v","error","-show_entries","format=duration","-of","default=noprint_wrappers=1:nokey=1","" + this.cache.cacheDir + "/" + fileName],function(err,stdout,_) { + if(err != null) { + callback(0); + return; + } + var duration = parseFloat(StringTools.trim(Std.string(stdout))); + callback(isNaN(duration) ? 0 : duration); + }); + } ,cleanupFiles: function(files) { var _g = 0; while(_g < files.length) { - var file = files[_g]; - ++_g; - if(sys_FileSystem.exists(file)) { - js_node_Fs.unlinkSync(file); + var path = "" + this.cache.cacheDir + "/" + files[_g++]; + if(sys_FileSystem.exists(path)) { + js_node_Fs.unlinkSync(path); } } } ,log: function(clientName,msg) { this.cache.logByName(clientName,msg); } - ,cancelProgress: function(clientName) { - this.main.sendByName(clientName,{ type : "Progress", progress : { type : "Canceled", ratio : 0}}); - } ,__class__: server_cache_RawCache }; -var server_cache_YoutubeCache = function(main,cache) { +var server_cache_YoutubeCache = function(main,cache,ytdlp) { this.main = main; this.cache = cache; + this.ytdlp = ytdlp; }; server_cache_YoutubeCache.__name__ = true; server_cache_YoutubeCache.prototype = { - checkYtDeps: function() { - try { - js_node_ChildProcess.execSync("ffmpeg -version",{ stdio : "ignore", timeout : 5000}); - this.ytDlp = new (require('ytdlp-nodejs')).YtDlp(); - return true; - } catch( _g ) { - return false; - } - } - ,checkUpdate: function() { - this.ytDlp.execAsync("",{ updateTo : this.main.config.ytDlp.channel, onData : function(d) { - haxe_Log.trace(d,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 36, className : "server.cache.YoutubeCache", methodName : "checkUpdate"}); - }}).catch(function(e) { - haxe_Log.trace(e,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 39, className : "server.cache.YoutubeCache", methodName : "checkUpdate"}); - }); - } - ,cleanYtInputFiles: function(prefix) { - if(prefix == null) { - prefix = "__tmp"; - } - var names = js_node_Fs.readdirSync(this.cache.cacheDir); - var _g = 0; - while(_g < names.length) { - var name = names[_g]; - ++_g; - if(!StringTools.startsWith(name,prefix)) { - continue; - } - this.cache.remove(name); - } - } - ,cacheYoutubeVideo: function(client,url,callback) { + cacheYoutubeVideo: function(client,url,cb) { var _gthis = this; if(!this.cache.isYtReady) { - haxe_Log.trace("Do `npm i https://github.com/RblSb/ytdlp-nodejs` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks).",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 53, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace("Do `npm i https://github.com/RblSb/ytdlp-nodejs` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks).",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 23, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); return; } var clientName = client.name; @@ -6722,9 +7098,13 @@ server_cache_YoutubeCache.prototype = { this.log(clientName,"Error: youtube video id not found in url: " + url); return; } + url = "https://youtu.be/" + videoId; var outName = videoId + ".mp4"; if(this.cache.exists(outName)) { - callback(outName); + var cb1 = cb; + var tmp = this.cache.getFileUrl(outName); + cb1.onResolved(tmp); + cb.onComplete(); return; } var inVideoName = "__tmp-video-" + videoId; @@ -6732,11 +7112,31 @@ server_cache_YoutubeCache.prototype = { this.log(clientName,"Caching " + outName + " already in progress"); return; } - haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 85, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); - this.main.sendByName(clientName,{ type : "Progress", progress : { type : "Caching", ratio : 0, data : outName}}); + haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 58, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + var cb1 = cb; + var tmp = this.cache.getFileUrl(outName); + cb1.onResolved(tmp); var useCookies = false; + var lastSentRatio = 0.0; + var canceled = false; + var $process = null; + if(cb.registerCancel != null) { + cb.registerCancel(function() { + canceled = true; + if($process != null) { + $process.kill(); + } + _gthis.ytdlp.cleanInputFiles(inVideoName); + }); + } var onGetInfo = function(info) { - haxe_Log.trace("Get info with " + info.formats.length + " formats",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 98, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + if(canceled) { + return; + } + if(cb.onMetadata != null) { + cb.onMetadata(info.title,info.duration); + } + haxe_Log.trace("Get info with " + info.formats.length + " formats",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 74, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); var _this = info.formats; var _g = []; var _g1 = 0; @@ -6794,7 +7194,7 @@ server_cache_YoutubeCache.prototype = { if(tmp == null) { _gthis.log(clientName,"Error: format with audio not found"); var _g = 0; - while(_g < aformats.length) haxe_Log.trace(aformats[_g++],{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 110, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + while(_g < aformats.length) haxe_Log.trace(aformats[_g++],{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 86, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); return; } var _this = info.formats; @@ -6823,7 +7223,7 @@ server_cache_YoutubeCache.prototype = { } else { _gthis.log(clientName,"Error: video format not found"); var _g1 = 0; - while(_g1 < _g.length) haxe_Log.trace(_g[_g1++],{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 120, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + while(_g1 < _g.length) haxe_Log.trace(_g[_g1++],{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 96, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); return; } var ignoreQualities = []; @@ -6847,8 +7247,8 @@ server_cache_YoutubeCache.prototype = { var tmp2 = tmp.filesize; var hasSpace = _gthis.cache.removeOlderCache(((tmp1 != null ? tmp1 : 0) + (tmp2 != null ? tmp2 : 0)) * 2 + _gthis.cache.freeSpaceBlock); if(!hasSpace) { - _gthis.cleanYtInputFiles(inVideoName); - _gthis.cancelProgress(clientName); + _gthis.ytdlp.cleanInputFiles(inVideoName); + cb.onError(); _gthis.log(clientName,_gthis.cache.notEnoughSpaceErrorText); } if(!hasSpace) { @@ -6870,13 +7270,13 @@ server_cache_YoutubeCache.prototype = { videoSizeRatio = 1; audioSizeRatio = 0; } - haxe_Log.trace(formatIds,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 153, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo", customParams : ["" + tools_MathTools.toFixed(totalSize / 1024 / 1024) + " MiB",tools_MathTools.toFixed(videoSizeRatio),tools_MathTools.toFixed(audioSizeRatio)]}); + haxe_Log.trace(formatIds,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 129, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo", customParams : ["" + tools_MathTools.toFixed(totalSize / 1024 / 1024) + " MiB",tools_MathTools.toFixed(videoSizeRatio),tools_MathTools.toFixed(audioSizeRatio)]}); var videoRatioCache = 0.0; var audioRatioCache = 0.0; - var dlVideo = _gthis.ytDlp.downloadAsync(url,{ format : formatIds, output : "" + _gthis.cache.cacheDir + "/" + inVideoName, remuxVideo : "mp4", additionalOptions : ["--no-js-runtimes","--js-runtimes",_gthis.main.config.ytDlp.jsRuntime], cookies : useCookies ? _gthis.getCookiesPathOrNull() : null, forceIpv4 : true, socketTimeout : 2, extractorRetries : 0, onProgress : function(p) { + $process = _gthis.ytdlp.download(url,{ format : formatIds, output : "" + _gthis.cache.cacheDir + "/" + inVideoName, remuxVideo : "mp4", additionalOptions : _gthis.ytdlp.ytExtraOptions(), cookies : useCookies ? _gthis.ytdlp.getCookiesPathOrNull() : null, forceIpv4 : true, socketTimeout : 2, extractorRetries : 0},{ onProgress : function(p) { var isFinished = p.status == "finished"; if(isFinished) { - haxe_Log.trace("" + haxe_io_Path.withoutDirectory(p.filename) + " format file downloaded",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 171, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace("" + haxe_io_Path.withoutDirectory(p.filename) + " format file downloaded",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 147, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); } var ratio; if(isFinished) { @@ -6891,51 +7291,52 @@ server_cache_YoutubeCache.prototype = { audioRatioCache = ratio; } ratio = videoRatioCache * videoSizeRatio + audioRatioCache * audioSizeRatio; - _gthis.main.sendByName(clientName,{ type : "Progress", progress : { type : "Downloading", ratio : tools_MathTools.toFixed(ratio,4)}}); - }}).catch(function(err) { - _gthis.cache.logWithAdmins(client,"Error during video download: " + err); - _gthis.cleanYtInputFiles(inVideoName); - _gthis.cancelProgress(clientName); - }); - dlVideo.then(function(v) { + if(canceled) { + return; + } + if(ratio - lastSentRatio < 0.01 && ratio < 1) { + return; + } + lastSentRatio = ratio; + var cb1 = cb; + var $process = tools_MathTools.toFixed(ratio,4); + cb1.onProgress($process); + }, onComplete : function() { + if(canceled) { + _gthis.ytdlp.cleanInputFiles(inVideoName); + return; + } var tmp = _gthis.cache.findFile(function(n) { return StringTools.startsWith(n,inVideoName); }); if(tmp == null) { _gthis.cache.logWithAdmins(client,"Error: cannot find downloaded file with prefix " + inVideoName); - _gthis.cancelProgress(clientName); + cb.onError(); return; } js_node_Fs.renameSync("" + _gthis.cache.cacheDir + "/" + tmp,"" + _gthis.cache.cacheDir + "/" + outName); - _gthis.cleanYtInputFiles(inVideoName); + _gthis.ytdlp.cleanInputFiles(inVideoName); _gthis.cache.add(outName); - callback(outName); - }); + cb.onComplete(); + }, onError : function(err) { + if(canceled) { + return; + } + _gthis.cache.logWithAdmins(client,"Error during video download: " + err); + _gthis.ytdlp.cleanInputFiles(inVideoName); + cb.onError(); + }}); }; - this.getInfoAsync(url,useCookies).then(onGetInfo).catch(function(err) { - haxe_Log.trace(err,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 215, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); + this.ytdlp.getInfoAsync(url,useCookies).then(onGetInfo).catch(function(err) { + haxe_Log.trace(err,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 193, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"}); useCookies = true; - return _gthis.getInfoAsync(url,useCookies).then(onGetInfo).catch(function(err) { - _gthis.cleanYtInputFiles(inVideoName); - _gthis.cancelProgress(clientName); + return _gthis.ytdlp.getInfoAsync(url,useCookies).then(onGetInfo).catch(function(err) { + _gthis.ytdlp.cleanInputFiles(inVideoName); + cb.onError(); _gthis.log(clientName,"" + err); }); }); } - ,getInfoAsync: function(url,useCookies) { - if(useCookies == null) { - useCookies = false; - } - return this.ytDlp.getInfoAsync(url,{ cookies : useCookies ? this.getCookiesPathOrNull() : null, additionalOptions : ["--no-js-runtimes","--js-runtimes",this.main.config.ytDlp.jsRuntime]}); - } - ,getCookiesPathOrNull: function() { - var cookiesPath = "" + this.main.userDir + "/cookies.txt"; - if(sys_FileSystem.exists(cookiesPath)) { - return cookiesPath; - } else { - return null; - } - } ,getBestYoutubeVideoFormat: function(formats,ignoreQualities) { var qPriority = [1080,720,480,360,240,144]; if(ignoreQualities != null) { @@ -6984,11 +7385,230 @@ server_cache_YoutubeCache.prototype = { ,log: function(clientName,msg) { this.cache.logByName(clientName,msg); } - ,cancelProgress: function(clientName) { - this.main.sendByName(clientName,{ type : "Progress", progress : { type : "Canceled", ratio : 0}}); - } ,__class__: server_cache_YoutubeCache }; +var server_cache_YtdlpCache = function(main,cache) { + this.main = main; + this.cache = cache; +}; +server_cache_YtdlpCache.__name__ = true; +server_cache_YtdlpCache.prototype = { + checkYtDeps: function() { + try { + js_node_ChildProcess.execSync("ffmpeg -version",{ stdio : "ignore", timeout : 5000}); + this.ytDlp = new (require('ytdlp-nodejs')).YtDlp(); + return true; + } catch( _g ) { + return false; + } + } + ,checkUpdate: function() { + this.ytDlp.execAsync("",{ updateTo : this.main.config.ytDlp.channel, onData : function(d) { + haxe_Log.trace(d,{ fileName : "src/server/cache/YtdlpCache.hx", lineNumber : 43, className : "server.cache.YtdlpCache", methodName : "checkUpdate"}); + }}).catch(function(e) { + haxe_Log.trace(e,{ fileName : "src/server/cache/YtdlpCache.hx", lineNumber : 46, className : "server.cache.YtdlpCache", methodName : "checkUpdate"}); + }); + } + ,cleanInputFiles: function(prefix) { + if(prefix == null) { + prefix = "__tmp"; + } + var names = js_node_Fs.readdirSync(this.cache.cacheDir); + var _g = 0; + while(_g < names.length) { + var name = names[_g]; + ++_g; + if(!StringTools.startsWith(name,prefix)) { + continue; + } + this.cache.remove(name); + } + } + ,getInfoAsync: function(url,useCookies) { + if(useCookies == null) { + useCookies = false; + } + return this.ytDlp.getInfoAsync(url,{ cookies : useCookies ? this.getCookiesPathOrNull() : null, additionalOptions : this.ytExtraOptions()}); + } + ,getCookiesPathOrNull: function() { + var cookiesPath = "" + this.main.userDir + "/cookies.txt"; + if(sys_FileSystem.exists(cookiesPath)) { + return cookiesPath; + } else { + return null; + } + } + ,ytExtraOptions: function() { + return ["--no-js-runtimes","--js-runtimes",this.main.config.ytDlp.jsRuntime]; + } + ,checkUrlSupported: function(url,callback) { + var $process = this.ytDlp.exec(url,{ useExtractors : ["default","-generic"], simulate : true, quiet : true, socketTimeout : 5, extractorRetries : 0}); + var done = false; + $process.on("close",function(code,_signal) { + if(!done) { + done = true; + callback(code == 0); + } + }); + $process.on("error",function(_err) { + if(!done) { + done = true; + callback(false); + } + }); + return $process; + } + ,download: function(url,options,handlers) { + var $process = this.ytDlp.exec(url,options); + var stderr = ""; + var done = false; + $process.stderr.on("data",function(d) { + stderr += Std.string(d); + return stderr; + }); + $process.on("progress",function(p) { + handlers.onProgress(p); + }); + $process.on("close",function(code,_signal) { + if(done) { + return; + } + done = true; + if(code == 0) { + handlers.onComplete(); + } else { + handlers.onError("yt-dlp exited with code " + code + ": " + stderr); + } + }); + $process.on("error",function(err) { + if(done) { + return; + } + done = true; + handlers.onError("Failed to start yt-dlp process: " + Std.string(err)); + }); + return $process; + } + ,cacheVideo: function(client,url,cb) { + var _gthis = this; + var clientName = client.name; + var outName = this.deriveOutName(url); + haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/YtdlpCache.hx", lineNumber : 128, className : "server.cache.YtdlpCache", methodName : "cacheVideo"}); + var cb1 = cb; + var tmp = this.cache.getFileUrl(outName); + cb1.onResolved(tmp); + var inName = "__tmp-" + Std.random(16777215) + "-" + haxe_io_Path.withoutExtension(outName); + var canceled = false; + var $process = null; + if(cb.registerCancel != null) { + cb.registerCancel(function() { + canceled = true; + if($process != null) { + $process.kill(); + } + _gthis.cleanInputFiles(inName); + }); + } + var useCookies = false; + var lastSentRatio = 0.0; + var onGetInfo = function(info) { + if(canceled) { + return; + } + if(cb.onMetadata != null) { + cb.onMetadata(info.title,info.duration); + } + if(!_gthis.cache.removeOlderCache(_gthis.estimateSize(info) * 2 + _gthis.cache.freeSpaceBlock)) { + _gthis.cleanInputFiles(inName); + cb.onError(); + _gthis.log(clientName,_gthis.cache.notEnoughSpaceErrorText); + return; + } + $process = _gthis.download(url,{ output : "" + _gthis.cache.cacheDir + "/" + inName, remuxVideo : "mp4", additionalOptions : _gthis.ytExtraOptions(), cookies : useCookies ? _gthis.getCookiesPathOrNull() : null, forceIpv4 : true, socketTimeout : 5, extractorRetries : 0},{ onProgress : function(p) { + if(canceled) { + return; + } + var ratio; + if(p.status == "finished") { + ratio = 1.0; + } else { + var v = p.downloaded / p.total; + ratio = v < 0 ? 0 : v > 1 ? 1 : v; + } + if(ratio - lastSentRatio < 0.01 && ratio < 1) { + return; + } + lastSentRatio = ratio; + var cb1 = cb; + var $process = tools_MathTools.toFixed(ratio,4); + cb1.onProgress($process); + }, onComplete : function() { + if(canceled) { + _gthis.cleanInputFiles(inName); + return; + } + var tmp = _gthis.cache.findFile(function(n) { + return StringTools.startsWith(n,inName); + }); + if(tmp == null) { + _gthis.cache.logWithAdmins(client,"Error: cannot find downloaded file with prefix " + inName); + cb.onError(); + return; + } + js_node_Fs.renameSync("" + _gthis.cache.cacheDir + "/" + tmp,"" + _gthis.cache.cacheDir + "/" + outName); + _gthis.cleanInputFiles(inName); + _gthis.cache.add(outName); + cb.onComplete(); + }, onError : function(err) { + if(canceled) { + return; + } + _gthis.cache.logWithAdmins(client,"Error during video download: " + err); + _gthis.cleanInputFiles(inName); + cb.onError(); + }}); + }; + this.getInfoAsync(url,useCookies).then(onGetInfo).catch(function(err) { + if(canceled) { + return; + } + useCookies = true; + _gthis.getInfoAsync(url,useCookies).then(onGetInfo).catch(function(err) { + _gthis.cleanInputFiles(inName); + cb.onError(); + _gthis.log(clientName,"" + err); + }); + }); + } + ,deriveOutName: function(url) { + var decoded; + try { + decoded = decodeURIComponent(url.split("+").join(" ")); + } catch( _g ) { + decoded = url; + } + var last = HxOverrides.substr(decoded,decoded.lastIndexOf("/") + 1,null); + var q = last.indexOf("?"); + if(q != -1) { + last = HxOverrides.substr(last,0,q); + } + last = haxe_io_Path.withoutExtension(last); + if(last == "") { + last = "video"; + } + return this.cache.getFreeFileName("" + last + ".mp4"); + } + ,estimateSize: function(info) { + var d = info; + var tmp = d.filesize_approx; + var tmp1 = tmp != null ? tmp : d.filesize; + return (tmp1 != null ? tmp1 : 0) | 0; + } + ,log: function(clientName,msg) { + this.cache.logByName(clientName,msg); + } + ,__class__: server_cache_YtdlpCache +}; var sys_FileSystem = function() { }; sys_FileSystem.__name__ = true; sys_FileSystem.exists = function(path) { diff --git a/default-config.json b/default-config.json index e4bb98d..4378239 100644 --- a/default-config.json +++ b/default-config.json @@ -13,6 +13,7 @@ "localAdmins": true, "allowProxyIps": true, "localNetworkOnly": false, + "enableCORS": false, "sslKeyPemPath": "", "sslCertPemPath": "", "templateUrl": "YouTube Playlist URL here", diff --git a/docker-compose.yml b/docker-compose.yml index aa5ba19..87375cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,4 @@ services: ports: - "4200:4200" volumes: - - "${PWD}/user:/usr/src/app/user" + - "${PWD}/user:/app/user" diff --git a/package-lock.json b/package-lock.json index 36d9e2a..0f2ba39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/res/client.js b/res/client.js index 89c5829..23ac4fe 100644 --- a/res/client.js +++ b/res/client.js @@ -1,4 +1,4 @@ -// Generated by Haxe 4.3.7 +// Generated by Haxe 4.3.3 (function ($hx_exports, $global) { "use strict"; $hx_exports["client"] = $hx_exports["client"] || {}; $hx_exports["client"]["JsApi"] = $hx_exports["client"]["JsApi"] || {}; @@ -68,11 +68,18 @@ ClientTools.setLeader = function(clients,name) { } }; ClientTools.hasLeader = function(clients) { + return ClientTools.getLeader(clients) != null; +}; +ClientTools.getLeader = function(clients) { var _g = 0; - while(_g < clients.length) if((clients[_g++].group & 4) != 0) { - return true; + while(_g < clients.length) { + var client = clients[_g]; + ++_g; + if((client.group & 4) != 0) { + return client; + } } - return false; + return null; }; ClientTools.getByName = function(clients,name,def) { var _g = 0; @@ -338,13 +345,6 @@ Reflect.fields = function(o) { } return a; }; -Reflect.deleteField = function(o,field) { - if(!Object.prototype.hasOwnProperty.call(o,field)) { - return false; - } - delete(o[field]); - return true; -}; Reflect.copy = function(o) { if(o == null) { return null; @@ -501,8 +501,14 @@ StringTools.hex = function(n,digits) { }; var _$Types_VideoItemTools = function() { }; _$Types_VideoItemTools.__name__ = true; +_$Types_VideoItemTools.isPlayable = function(item) { + if(item.isIncomplete == true && item.doCache) { + return false; + } + return true; +}; _$Types_VideoItemTools.withUrl = function(item,url) { - return { url : url, title : item.title, author : item.author, duration : item.duration, subs : item.subs, voiceOverTrack : item.voiceOverTrack, isTemp : item.isTemp, doCache : item.doCache, playerType : item.playerType}; + return { url : url, title : item.title, author : item.author, duration : item.duration, subs : item.subs, voiceOverTrack : item.voiceOverTrack, isTemp : item.isTemp, doCache : item.doCache, isIncomplete : item.isIncomplete, playerType : item.playerType}; }; var VideoList = function() { this.items = []; @@ -571,7 +577,7 @@ client_Buttons.init = function(main) { } client_Buttons.split = new client_Split(client_Buttons.settings); client_Buttons.split.setSize(client_Buttons.settings.chatSize); - client_Buttons.initChatInputs(main); + client_Buttons.initInputs(main); var _g = 0; var _g1 = client_Buttons.settings.checkboxes; while(_g < _g1.length) { @@ -697,8 +703,12 @@ client_Buttons.init = function(main) { haxe_Timer.delay(function() { window.document.querySelector("#userlist-wrap").style.transition = "200ms"; },0); + var playlistMenuBtn = window.document.querySelector("#playlist-menu-btn"); + var playlistMenuDropdown = window.document.querySelector("#playlist-menu-dropdown"); var toggleSynch = window.document.querySelector("#togglesynch"); toggleSynch.onclick = function(e) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); var icon = toggleSynch.firstElementChild; if(main.isSyncActive) { if(!window.confirm(Lang.get("toggleSynchConfirm"))) { @@ -706,23 +716,29 @@ client_Buttons.init = function(main) { } main.isSyncActive = false; icon.style.color = "rgba(238, 72, 67, 0.75)"; - icon.setAttribute("name","pause"); + playlistMenuBtn.classList.add("danger"); } else { main.isSyncActive = true; icon.style.color = ""; - icon.setAttribute("name","play"); + playlistMenuBtn.classList.remove("danger"); main.send({ type : "UpdatePlaylist"}); } + client_Buttons.updateToggleSynchBtn(main); }; window.document.querySelector("#mediarefresh").onclick = function(e) { main.refreshPlayer(); }; window.document.querySelector("#fullscreenbtn").onclick = function(e) { - if((client_Utils.isTouch() || main.isVerbose()) && !client_Utils.hasFullscreen()) { - window.scrollTo(0,0); - return client_Utils.requestFullscreen(window.document.documentElement); + return client_Utils.toggleFullscreen(window.document.querySelector("#ytapiplayer")); + }; + window.document.querySelector("#fullscreen-chat-btn").onclick = function(e) { + e.stopPropagation(); + e.preventDefault(); + client_Buttons.clearUnreadChatMessages(); + if(client_Buttons.settings.pageFullscreen) { + client_Utils.switchToPageFullscreen(); } else { - return client_Utils.requestFullscreen(window.document.querySelector("#ytapiplayer")); + client_Utils.cancelFullscreen(); } }; client_Buttons.initPageFullscreen(); @@ -735,13 +751,41 @@ client_Buttons.init = function(main) { icon.setAttribute("name","link"); },2000); }; + playlistMenuBtn.onclick = function(e) { + e.stopPropagation(); + var isOpen = playlistMenuDropdown.style.display != "none"; + if(isOpen) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); + } else { + main.hasSeenSkipVote = true; + playlistMenuDropdown.style.display = ""; + playlistMenuBtn.classList.add("active"); + window.document.querySelector("#playlist-menu-indicator").style.display = "none"; + } + }; + window.document.addEventListener("click",function(e) { + var target = e.target; + if(!playlistMenuBtn.contains(target) && !playlistMenuDropdown.contains(target)) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); + } + }); + window.document.querySelector("#voteskip").addEventListener("click",function() { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); + }); window.document.querySelector("#clearplaylist").onclick = function(e) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); if(!window.confirm(Lang.get("clearPlaylistConfirm"))) { return; } main.send({ type : "ClearPlaylist"}); }; window.document.querySelector("#shuffleplaylist").onclick = function(e) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); if(!window.confirm(Lang.get("shufflePlaylistConfirm"))) { return; } @@ -760,6 +804,8 @@ client_Buttons.init = function(main) { }; var showMediaUrl = window.document.querySelector("#showmediaurl"); showMediaUrl.onclick = function(e) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); if(client_Buttons.showPlayerGroup(showMediaUrl)) { haxe_Timer.delay(function() { window.document.querySelector("#addfromurl").scrollIntoView(); @@ -769,6 +815,8 @@ client_Buttons.init = function(main) { }; var showCustomEmbed = window.document.querySelector("#showcustomembed"); showCustomEmbed.onclick = function(e) { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); if(client_Buttons.showPlayerGroup(showCustomEmbed)) { haxe_Timer.delay(function() { window.document.querySelector("#customembed").scrollIntoView(); @@ -905,6 +953,39 @@ client_Buttons.initTextButtons = function(main) { client_Buttons.updateHotkeysBtn(); }; client_Buttons.updateHotkeysBtn(); + window.document.querySelector("#pageFullscreenBtn").onclick = function(e) { + client_Buttons.settings.pageFullscreen = !client_Buttons.settings.pageFullscreen; + client_Settings.write(client_Buttons.settings); + client_Buttons.updatePageFullscreenBtn(); + if(client_Buttons.settings.pageFullscreen) { + client_Utils.switchToPageFullscreen(); + } else { + client_Utils.cancelFullscreen(); + } + }; + client_Buttons.updatePageFullscreenBtn(); + var fullscreenActionBtn = window.document.querySelector("#fullscreenActionBtn"); + if(fullscreenActionBtn != null) { + fullscreenActionBtn.onclick = function(e) { + client_Buttons.settings.fullscreenAction = !client_Buttons.settings.fullscreenAction; + client_Settings.write(client_Buttons.settings); + client_Buttons.updateFullscreenActionBtn(); + }; + client_Buttons.updateFullscreenActionBtn(); + } + window.document.body.addEventListener("click",function(e) { + if(!client_Buttons.settings.pageFullscreen) { + return; + } + if(client_Utils.hasFullscreen()) { + return; + } + var target = e.target; + if(target != null && target.closest("button, input, a, textarea, select, #optionsPanel, #chatline, #userlist, .queue_sortable, .nav") != null) { + return; + } + client_Utils.requestFullscreen(window.document.documentElement); + }); var removeBtn = window.document.querySelector("#removePlayerBtn"); removeBtn.onclick = function(e) { if(main.toggleVideoElement()) { @@ -926,6 +1007,7 @@ client_Buttons.initTextButtons = function(main) { client_JsApi.setVideoSrc(url); }); }; + client_Buttons.updateToggleSynchBtn(main); }; client_Buttons.initHotkeys = function(main,player) { window.document.querySelector("#mediarefresh").title += " (Alt-R)"; @@ -967,6 +1049,9 @@ client_Buttons.initHotkeys = function(main,player) { case 83: window.document.querySelector("#voteskip").onclick(); break; + case 85: + window.document.querySelector("#togglesynch").onclick(); + break; default: return; } @@ -991,11 +1076,43 @@ client_Buttons.updateSynchThresholdBtn = function() { window.document.querySelector("#synchThresholdBtn").innerText = tmp + "s"; }; client_Buttons.updateHotkeysBtn = function() { - var text = Lang.get("hotkeys"); - var state = client_Buttons.settings.hotkeysEnabled ? Lang.get("on") : Lang.get("off"); - window.document.querySelector("#hotkeysBtn").innerText = "" + text + ": " + state; + var btn = window.document.querySelector("#hotkeysBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if(switchEl == null) { + btn.innerHTML = "" + Lang.get("hotkeys") + "
"; + switchEl = btn.querySelector(".toggle-switch"); + } + switchEl.classList.toggle("on",client_Buttons.settings.hotkeysEnabled); + window.document.body.classList.toggle("hotkeys-disabled",!client_Buttons.settings.hotkeysEnabled); +}; +client_Buttons.updatePageFullscreenBtn = function() { + var btn = window.document.querySelector("#pageFullscreenBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if(switchEl == null) { + btn.innerHTML = "" + Lang.get("pageFullscreen") + "
"; + switchEl = btn.querySelector(".toggle-switch"); + } + switchEl.classList.toggle("on",client_Buttons.settings.pageFullscreen); +}; +client_Buttons.updateFullscreenActionBtn = function() { + var btn = window.document.querySelector("#fullscreenActionBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if(switchEl == null) { + btn.innerHTML = "" + Lang.get(client_Utils.isTouch() ? "fullscreenActionTouch" : "fullscreenActionDesktop") + "
"; + switchEl = btn.querySelector(".toggle-switch"); + } + switchEl.classList.toggle("on",client_Buttons.settings.fullscreenAction); +}; +client_Buttons.updateToggleSynchBtn = function(main) { + var toggleSynch = window.document.querySelector("#togglesynch"); + var span = toggleSynch.querySelector("span"); + var text = Lang.get(main.isSyncActive ? "disableVideoSync" : "enableVideoSync"); + toggleSynch.title = "" + text + " (Alt-U)"; + if(span != null) { + span.innerHTML = "" + text + " (Alt-U)"; + } }; -client_Buttons.initChatInputs = function(main) { +client_Buttons.initInputs = function(main) { var guestName = window.document.querySelector("#guestname"); guestName.onkeydown = function(e) { if(e.keyCode == 13) { @@ -1015,19 +1132,6 @@ client_Buttons.initChatInputs = function(main) { } } }; - var chatline = window.document.querySelector("#chatline"); - chatline.onfocus = function(e) { - if(client_Utils.isIOS()) { - var startY = 0; - haxe_Timer.delay(function() { - window.scrollBy(0,-(window.scrollY - startY)); - window.document.querySelector("#video").scrollTop = 0; - main.scrollChatToEnd(); - },100); - } else if(client_Utils.isTouch()) { - main.scrollChatToEnd(); - } - }; var viewport = window.visualViewport; if(viewport != null) { viewport.addEventListener("resize",function(e) { @@ -1035,16 +1139,6 @@ client_Buttons.initChatInputs = function(main) { }); client_Buttons.onViewportResize(); } - new client_InputWithHistory(chatline,null,50,function(value) { - if(main.handleCommands(value)) { - return true; - } - main.send({ type : "Message", message : { clientName : "", text : value}}); - if(client_Utils.isTouch()) { - chatline.blur(); - } - return true; - }); var checkboxes = [window.document.querySelector("#add-temp")]; var _g = 0; while(_g < checkboxes.length) { @@ -1078,17 +1172,302 @@ client_Buttons.onViewportResize = function() { } window.document.querySelector("#chat").style.height = "" + h + "px"; }; +client_Buttons.onNewChatMessage = function() { + client_Buttons.hasUnreadChatMessages = true; + client_Buttons.updateFullscreenChatBtn(); +}; +client_Buttons.clearUnreadChatMessages = function() { + client_Buttons.hasUnreadChatMessages = false; + client_Buttons.updateFullscreenChatBtn(); +}; +client_Buttons.updateFullscreenChatBtn = function() { + var chatBtn = window.document.querySelector("#fullscreen-chat-btn"); + if(client_Utils.hasFullscreen() && client_Buttons.hasUnreadChatMessages) { + chatBtn.classList.add("has-unread"); + } else { + chatBtn.classList.remove("has-unread"); + } +}; client_Buttons.initPageFullscreen = function() { window.document.onfullscreenchange = function(e) { - var el = window.document.documentElement; - if(client_Utils.hasFullscreen()) { - if(e.target == el) { - el.classList.add("mobile-view"); + client_Buttons.clearUnreadChatMessages(); + client_Buttons.updateFullscreenChatBtn(); + }; +}; +var client_ChatInput = function(main) { + this.selectedCommandIndex = -1; + this.filteredCommands = []; + this.main = main; + this.chatline = window.document.querySelector("#chatline"); + this.commandsWrap = window.document.querySelector("#chat-commands-wrap"); + this.commandsList = window.document.querySelector("#chat-commands-list"); + this.setupFocusHandler(); + this.setupAutocomplete(); + this.inputWithHistory = new client_InputWithHistory(this.chatline,null,50,$bind(this,this.onEnter),$bind(this,this.onInterceptKeyDown)); +}; +client_ChatInput.__name__ = true; +client_ChatInput.prototype = { + setupFocusHandler: function() { + var _gthis = this; + this.chatline.onfocus = function(e) { + if(client_Utils.isIOS()) { + var startY = 0; + haxe_Timer.delay(function() { + window.scrollBy(0,-(window.scrollY - startY)); + window.document.querySelector("#video").scrollTop = 0; + _gthis.main.scrollChatToEnd(); + },100); + } else if(client_Utils.isTouch()) { + _gthis.main.scrollChatToEnd(); } + }; + } + ,setupAutocomplete: function() { + var _gthis = this; + this.commandsList.addEventListener("mousemove",function(e) { + _gthis.commandsList.classList.remove("keyboard-nav"); + }); + this.chatline.addEventListener("input",function(e) { + var val = _gthis.chatline.value; + if(StringTools.startsWith(val,"/")) { + _gthis.filteredCommands = client_Commands.filterForAutocomplete(val); + _gthis.selectedCommandIndex = _gthis.filteredCommands.length > 0 ? 0 : -1; + _gthis.renderCommands(); + } else { + _gthis.commandsWrap.style.display = "none"; + _gthis.selectedCommandIndex = -1; + } + }); + this.chatline.addEventListener("blur",function(e) { + return _gthis.commandsWrap.style.display = "none"; + }); + } + ,renderCommands: function() { + var _gthis = this; + this.commandsList.innerHTML = ""; + if(this.filteredCommands.length == 0) { + this.commandsWrap.style.display = "none"; + return; + } + var parsed = client_Commands.parse(this.chatline.value); + var currentArgIndex = parsed.currentArgIndex; + var _g = 0; + var _g1 = this.filteredCommands.length; + while(_g < _g1) { + var i = [_g++]; + var cmd = [this.filteredCommands[i[0]]]; + var item = window.document.createElement("div"); + item.className = "chat-command-item" + (i[0] == this.selectedCommandIndex ? " active" : ""); + item.onclick = (function(cmd) { + return function(e) { + _gthis.chatline.value = (cmd[0].name == null ? "null" : cmd[0].name) + (cmd[0].args > 0 ? " " : ""); + _gthis.chatline.focus(); + if(cmd[0].args == 0) { + _gthis.commandsWrap.style.display = "none"; + _gthis.selectedCommandIndex = -1; + } else { + _gthis.chatline.dispatchEvent(new Event("input")); + } + }; + })(cmd); + item.onmouseenter = (function(i) { + return function(e) { + if(!_gthis.commandsList.classList.contains("keyboard-nav")) { + _gthis.selectedCommandIndex = i[0]; + var _g = 0; + var _g1 = _gthis.commandsList.children.length; + while(_g < _g1) { + var j = _g++; + var child = _gthis.commandsList.children[j]; + if(j == i[0]) { + child.classList.add("active"); + } else { + child.classList.remove("active"); + } + } + } + }; + })(i); + var nameDiv = window.document.createElement("div"); + nameDiv.className = "chat-command-item-name"; + nameDiv.innerText = cmd[0].name; + var descDiv = window.document.createElement("div"); + descDiv.className = "chat-command-item-desc"; + descDiv.innerHTML = client_Commands.formatDescWithHighlight(Lang.get(cmd[0].lang),currentArgIndex); + item.appendChild(nameDiv); + item.appendChild(descDiv); + this.commandsList.appendChild(item); + } + this.commandsWrap.style.display = ""; + if(this.selectedCommandIndex >= 0 && this.commandsList.children.length > this.selectedCommandIndex) { + var selectedEl = this.commandsList.children[this.selectedCommandIndex]; + selectedEl.scrollIntoView({ block : "center"}); + } + } + ,onEnter: function(value) { + if(this.main.handleCommands(value)) { + return true; + } + this.main.send({ type : "Message", message : { clientName : "", text : value}}); + if(client_Utils.isTouch()) { + this.chatline.blur(); + } + this.commandsWrap.style.display = "none"; + return true; + } + ,onInterceptKeyDown: function(e) { + if(this.commandsWrap.style.display == "none") { + return false; + } + var key = e.keyCode; + switch(key) { + case 9:case 13: + if(this.selectedCommandIndex < 0 || this.selectedCommandIndex >= this.filteredCommands.length) { + return false; + } + var selectedCmd = this.filteredCommands[this.selectedCommandIndex]; + var val = this.chatline.value; + var hasSpace = val.indexOf(" ") >= 0; + if(key == 13) { + if(hasSpace || selectedCmd.args == 0 && StringTools.trim(val).toLowerCase() == selectedCmd.name) { + return false; + } + } + if(!hasSpace) { + this.chatline.value = (selectedCmd.name == null ? "null" : selectedCmd.name) + (selectedCmd.args > 0 ? " " : ""); + } + e.preventDefault(); + this.chatline.dispatchEvent(new Event("input")); + return true; + case 27: + this.commandsWrap.style.display = "none"; + return false; + case 38: + this.commandsList.classList.add("keyboard-nav"); + if(this.selectedCommandIndex > 0) { + this.selectedCommandIndex--; + } else { + this.selectedCommandIndex = this.filteredCommands.length - 1; + } + this.renderCommands(); + e.preventDefault(); + return true; + case 40: + this.commandsList.classList.add("keyboard-nav"); + if(this.selectedCommandIndex < this.filteredCommands.length - 1) { + this.selectedCommandIndex++; + } else { + this.selectedCommandIndex = 0; + } + this.renderCommands(); + e.preventDefault(); + return true; + default: + return false; + } + } +}; +var client_CommandInfo = function(name,args,aliases) { + if(args == null) { + args = 0; + } + this.name = name; + this.args = args; + this.aliases = aliases != null ? aliases : []; + this.lang = this.getLangId(); +}; +client_CommandInfo.__name__ = true; +client_CommandInfo.prototype = { + getLangId: function() { + var withoutSlash = HxOverrides.substr(this.name,1,null); + return "cmd" + withoutSlash.charAt(0).toUpperCase() + HxOverrides.substr(withoutSlash,1,null); + } +}; +var client_Commands = function() { }; +client_Commands.__name__ = true; +client_Commands.find = function(name) { + var lower = name.toLowerCase(); + var _g = 0; + var _g1 = client_Commands.list; + while(_g < _g1.length) { + var c = _g1[_g]; + ++_g; + if(c.name == lower) { + return c; + } + var _g2 = 0; + var _g3 = c.aliases; + while(_g2 < _g3.length) if(_g3[_g2++].toLowerCase() == lower) { + return c; + } + } + return null; +}; +client_Commands.parse = function(input) { + if(!StringTools.startsWith(input,"/")) { + return { cmd : null, rawCmd : "", args : [], currentArgIndex : -1}; + } + var parts = input.split(" "); + var rawCmd = parts[0]; + var args = parts.slice(1); + var currentArgIndex = args.length > 0 ? args.length - 1 : -1; + return { cmd : client_Commands.find(rawCmd), rawCmd : rawCmd, args : args, currentArgIndex : currentArgIndex}; +}; +client_Commands.filterForAutocomplete = function(val) { + if(!StringTools.startsWith(val,"/")) { + return []; + } + var parts = val.toLowerCase().split(" "); + var searchCmd = parts[0]; + var spaceCount = parts.length - 1; + var result = []; + var _g = 0; + var _g1 = client_Commands.list; + while(_g < _g1.length) { + var c = _g1[_g]; + ++_g; + if(spaceCount == 0) { + if(StringTools.startsWith(c.name,searchCmd)) { + result.push(c); + } else { + var _g2 = 0; + var _g3 = c.aliases; + while(_g2 < _g3.length) if(StringTools.startsWith(_g3[_g2++].toLowerCase(),searchCmd)) { + result.push(c); + break; + } + } + } else if(spaceCount <= c.args) { + if(c.name == searchCmd) { + result.push(c); + } else { + var _g4 = 0; + var _g5 = c.aliases; + while(_g4 < _g5.length) if(_g5[_g4++].toLowerCase() == searchCmd) { + result.push(c); + break; + } + } + } + } + return result; +}; +client_Commands.formatDescWithHighlight = function(desc,activeArgIndex) { + desc = StringTools.htmlEscape(desc); + var argCounter = 0; + desc = new EReg("<[^&]+>","g").map(desc,function(reg) { + var token = reg.matched(0); + var isCurrent = argCounter == activeArgIndex; + argCounter += 1; + if(isCurrent) { + return "" + token + ""; } else { - el.classList.remove("mobile-view"); + return token; } - }; + }); + return new EReg("`([^`]+)`","g").map(desc,function(reg) { + return "" + reg.matched(1) + ""; + }); }; var client_FileUploader = function(main) { this.main = main; @@ -1102,52 +1481,104 @@ client_FileUploader.prototype = { return; } var _this_r = new RegExp("[?#%/\\\\]","g".split("u").join("")); - var name = StringTools.trim(file.name.replace(_this_r,"")); - if(name.length == 0) { - name = "video"; + var title = StringTools.trim(file.name.replace(_this_r,"")); + if(title.length == 0) { + title = "video"; } - name = window.encodeURIComponent(name); + var name = window.encodeURIComponent(title); + var isTemp = client_Main.isTempChecked(window.document.querySelector("#addfromurl .add-temp")); this.uploadLastChunk(file,name,function(data) { if(data.errorId != null) { _gthis.main.serverMessage(data.info,true,false); return; } - window.document.querySelector("#mediaurl").value = data.url; - _gthis.uploadFullFile(name,file); + var url = data.url; + _gthis.getFileDuration(file,function(duration) { + if(duration == 0 || isNaN(duration) || !isFinite(duration)) { + _gthis.main.serverMessage(Lang.get("addVideoError"),true,false); + return; + } + _gthis.uploadFullFile(name,file,url,function() { + _gthis.main.addUploadedVideo(url,title,duration,true,isTemp); + }); + }); }); } - ,uploadFullFile: function(name,file) { + ,getFileDuration: function(file,callback) { + var objUrl = URL.createObjectURL(file); + var video = window.document.createElement("video"); + video.preload = "metadata"; + video.muted = true; + video.onloadedmetadata = function() { + var duration = video.duration; + URL.revokeObjectURL(objUrl); + callback(duration); + }; + video.onerror = function(e) { + URL.revokeObjectURL(objUrl); + callback(0); + }; + video.src = objUrl; + } + ,uploadFullFile: function(name,file,url,onStarted) { var _gthis = this; var request = new XMLHttpRequest(); request.open("POST","/upload",true); request.setRequestHeader("content-name",name); var tmp = this.main.settings.uuid; request.setRequestHeader("x-client-uuid",tmp != null ? tmp : ""); + var added = false; + var ensureAdded = function() { + if(added) { + return; + } + added = true; + onStarted(); + _gthis.main.registerUpload(url,function() { + request.abort(); + }); + }; + var sendProgress = function(type,ratio) { + _gthis.main.send({ type : "Progress", progress : { type : type, ratio : ratio, url : url}}); + }; + var lastSentRatio = 0.0; request.upload.onprogress = function(event) { + ensureAdded(); var ratio = 0.0; if(event.lengthComputable) { var v = event.loaded / event.total; ratio = v < 0 ? 0 : v > 1 ? 1 : v; } - _gthis.main.onProgressEvent({ type : "Progress", progress : { type : "Uploading", ratio : ratio}}); + if(ratio - lastSentRatio < 0.01 && ratio < 1) { + return; + } + lastSentRatio = ratio; + sendProgress("Uploading",ratio); }; request.onload = function(e) { + _gthis.main.unregisterUpload(url); var data; try { data = JSON.parse(request.responseText); } catch( _g ) { - haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/FileUploader.hx", lineNumber : 66, className : "client.FileUploader", methodName : "uploadFullFile"}); + haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/FileUploader.hx", lineNumber : 112, className : "client.FileUploader", methodName : "uploadFullFile"}); + sendProgress("Canceled",0); return; } - if(data.errorId == null) { + if(data.errorId != null) { + _gthis.main.serverMessage(data.info,true,false); + sendProgress("Canceled",0); return; } - _gthis.main.serverMessage(data.info,true,false); + ensureAdded(); + sendProgress("Completed",1); }; - request.onloadend = function() { - return haxe_Timer.delay(function() { - _gthis.main.hideDynamicChin(); - },500); + request.onerror = function(e) { + _gthis.main.unregisterUpload(url); + sendProgress("Canceled",0); + }; + request.onabort = function(e) { + _gthis.main.unregisterUpload(url); }; request.send(file); } @@ -1163,7 +1594,7 @@ client_FileUploader.prototype = { }); } }; -var client_InputWithHistory = function(element,history,maxItems,onEnter) { +var client_InputWithHistory = function(element,history,maxItems,onEnter,onInterceptKeyDown) { this.historyId = -1; this.element = element; if(history != null) { @@ -1173,6 +1604,7 @@ var client_InputWithHistory = function(element,history,maxItems,onEnter) { } this.maxItems = maxItems; this.onEnter = onEnter; + this.onInterceptKeyDown = onInterceptKeyDown; element.onkeydown = $bind(this,this.onKeyDown); }; client_InputWithHistory.__name__ = true; @@ -1184,6 +1616,9 @@ client_InputWithHistory.pushIfNotLast = function(arr,item) { }; client_InputWithHistory.prototype = { onKeyDown: function(e) { + if(this.onInterceptKeyDown != null && this.onInterceptKeyDown(e)) { + return; + } var key = e.keyCode; switch(key) { case 13: @@ -1201,6 +1636,9 @@ client_InputWithHistory.prototype = { this.element.value = ""; this.onInput(); break; + case 27: + e.preventDefault(); + break; case 38: this.historyId--; if(this.historyId == -2) { @@ -1231,9 +1669,7 @@ client_InputWithHistory.prototype = { } } ,onInput: function() { - if(this.element.oninput != null) { - this.element.oninput(); - } + this.element.dispatchEvent(new Event("input")); } }; var client_JsApi = function() { }; @@ -1268,9 +1704,10 @@ client_JsApi.addScriptToHead = $hx_exports["client"]["JsApi"]["addScriptToHead"] window.document.head.appendChild(script); }; client_JsApi.hasScriptInHead = $hx_exports["client"]["JsApi"]["hasScriptInHead"] = function(url) { + var abs = new URL(url,window.document.baseURI).href; var _g = 0; var _g1 = window.document.getElementsByTagName("head")[0].children; - while(_g < _g1.length) if(_g1[_g++].src == url) { + while(_g < _g1.length) if(_g1[_g++].src == abs) { return true; } return false; @@ -1282,11 +1719,11 @@ client_JsApi.getVideoItems = $hx_exports["client"]["JsApi"]["getVideoItems"] = f while(_g1 < items.length) _g.push(Reflect.copy(items[_g1++])); return _g; }; -client_JsApi.addVideoItem = $hx_exports["client"]["JsApi"]["addVideoItem"] = function(url,atEnd,isTemp,callback,doCache) { +client_JsApi.addVideoItem = $hx_exports["client"]["JsApi"]["addVideoItem"] = function(url,atEnd,isTemp,afterSend,doCache) { if(doCache == null) { doCache = false; } - client_JsApi.main.addVideo(url,atEnd,isTemp,doCache,callback); + client_JsApi.main.addVideo(url,atEnd,isTemp,doCache,afterSend); }; client_JsApi.removeVideoItem = $hx_exports["client"]["JsApi"]["removeVideoItem"] = function(url) { client_JsApi.main.removeVideoItem(url); @@ -1329,24 +1766,24 @@ client_JsApi.once = $hx_exports["client"]["JsApi"]["once"] = function(type,callb client_JsApi.onceListeners.unshift({ type : type, callback : callback}); }; client_JsApi.fireEvents = function(event) { - var _g_arr = client_JsApi.onListeners; - var _g_i = _g_arr.length - 1; + var listeners = client_JsApi.onListeners.slice(); + var _g_i = listeners.length - 1; while(_g_i > -1) { - var listener = _g_arr[_g_i--]; + var listener = listeners[_g_i--]; if(listener.type != event.type) { continue; } listener.callback(event); } - var _g_arr = client_JsApi.onceListeners; - var _g_i = _g_arr.length - 1; + var onces = client_JsApi.onceListeners.slice(); + var _g_i = onces.length - 1; while(_g_i > -1) { - var listener = _g_arr[_g_i--]; + var listener = onces[_g_i--]; if(listener.type != event.type) { continue; } - listener.callback(event); HxOverrides.remove(client_JsApi.onceListeners,listener); + listener.callback(event); } }; client_JsApi.notifyOnVideoChange = $hx_exports["client"]["JsApi"]["notifyOnVideoChange"] = function(callback) { @@ -1373,6 +1810,7 @@ client_JsApi.fireVideoRemoveEvents = function(item) { }; var client_Main = function() { this.matchSimpleDate = new EReg("^-?([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s?)?$",""); + this.activeUploads = new haxe_ds_StringMap(); this.urlMask = new EReg("\\${([0-9]+)-([0-9]+)}","g"); this.isPageVisible = true; this.isPageUnloading = false; @@ -1388,6 +1826,9 @@ var client_Main = function() { this.lastStateTimeStamp = 0.0; this.lastState = { time : 0, rate : 1.0, paused : false, pausedByServer : false}; this.timeFromLastState = 0.0; + this.skipVoteRequired = 0; + this.skipVoteCount = 0; + this.hasSeenSkipVote = false; this.showingServerPause = false; this.playersCacheSupport = []; this.isPlaylistOpen = true; @@ -1403,7 +1844,7 @@ var client_Main = function() { if(this.host == "") { this.host = "localhost"; } - client_Settings.init({ version : 6, uuid : null, name : "", hash : "", chatSize : 300, synchThreshold : 1, isSwapped : false, isUserListHidden : true, latestLinks : [], latestSubs : [], hotkeysEnabled : true, showHintList : true, checkboxes : [], checkedCache : []},$bind(this,this.settingsPatcher)); + client_Settings.init({ version : 7, uuid : null, name : "", hash : "", chatSize : 300, synchThreshold : 1, isSwapped : false, isUserListHidden : true, latestLinks : [], latestSubs : [], hotkeysEnabled : true, pageFullscreen : false, fullscreenAction : client_Utils.isTouch(), showHintList : true, checkboxes : [], checkedCache : []},$bind(this,this.settingsPatcher)); this.settings = client_Settings.read(); this.initListeners(); this.onTimeGet = new haxe_Timer(this.settings.synchThreshold * 1000); @@ -1443,6 +1884,17 @@ client_Main.__name__ = true; client_Main.main = function() { new client_Main(); }; +client_Main.isTempChecked = function(checkbox) { + if(checkbox == null) { + return true; + } + var isHidden = checkbox.style.display == "none"; + if(isHidden) { + return true; + } else { + return checkbox.checked; + } +}; client_Main.prototype = { onFirstInteraction: function() { if(this.gotFirstPageInteraction) { @@ -1460,32 +1912,18 @@ client_Main.prototype = { } ,settingsPatcher: function(data,version) { switch(version) { - case 1: - data.hotkeysEnabled = true; - break; - case 2: - data.latestSubs = []; - break; - case 3: - data.showHintList = true; - break; - case 4: - data.checkboxes = []; + case 1:case 2:case 3:case 4: break; case 5: - var data1 = data; - data1.checkedCache = []; - Reflect.deleteField(data1,"playerSize"); - Reflect.deleteField(data1,"isExtendedPlayer"); - var oldCheck = Lambda.find(data1.checkboxes,function(item) { - return item.id == "cache-on-server"; - }); - if(oldCheck != null) { - HxOverrides.remove(data1.checkboxes,oldCheck); - data1.checkedCache.push("YoutubeType"); - } + client_Settings.reset(); + data = client_Settings.read(); break; case 6: + var data1 = data; + data1.pageFullscreen = false; + data1.fullscreenAction = client_Utils.isTouch(); + break; + case 7: throw haxe_Exception.thrown("skipped version " + version); default: throw haxe_Exception.thrown("skipped version " + version); @@ -1499,6 +1937,9 @@ client_Main.prototype = { if(this.player.isListEmpty()) { return; } + if(!_$Types_VideoItemTools.isPlayable(this.player.getCurrentItem())) { + return; + } this.send({ type : "GetTime"}); } ,openWebSocket: function() { @@ -1545,6 +1986,7 @@ client_Main.prototype = { } ,initListeners: function() { var _gthis = this; + this.chatInput = new client_ChatInput(this); client_Buttons.init(this); var openBtn = window.document.getElementById("openChatBtn"); var openBtnFadeTimer = null; @@ -1625,9 +2067,7 @@ client_Main.prototype = { e.preventDefault(); }; window.document.querySelector("#voteskip").onclick = function(e) { - if(client_Utils.isTouch() && !window.confirm(Lang.get("skipItemConfirm"))) { - return; - } + _gthis.hasSeenSkipVote = true; if(_gthis.player.isListEmpty()) { return; } @@ -1697,7 +2137,7 @@ client_Main.prototype = { ,addVideoUrl: function(atEnd) { var mediaUrl = window.document.querySelector("#mediaurl"); var subsUrl = window.document.querySelector("#subsurl"); - var isTemp = window.document.querySelector("#addfromurl .add-temp").checked; + var isTemp = client_Main.isTempChecked(window.document.querySelector("#addfromurl .add-temp")); var checkboxCache = window.document.querySelector("#cache-on-server"); var doCache = (this.personal.group & 8) != 0 && checkboxCache.checked && checkboxCache.parentElement.style.display != "none"; var url = mediaUrl.value; @@ -1758,7 +2198,7 @@ client_Main.prototype = { _gthis.addVideoArray(links,atEnd,isTemp,doCache); }); } - ,addVideo: function(url,atEnd,isTemp,doCache,callback) { + ,addVideo: function(url,atEnd,isTemp,doCache,afterSend) { var _gthis = this; var protocol = $global.location.protocol; if(StringTools.startsWith(url,"/")) { @@ -1771,26 +2211,60 @@ client_Main.prototype = { url = "" + protocol + "//" + url; } } - var obj = { url : url, atEnd : atEnd}; - if(doCache) { - this.showProgressInfo(Lang.get("caching") + "..."); + var sendItem = function(data) { + var tmp = data.url; + var tmp1 = data.title; + _gthis.send({ type : "AddVideo", addVideo : { item : { url : tmp != null ? tmp : url, title : tmp1 != null ? tmp1 : "", author : _gthis.personal.name, duration : data.duration, isTemp : isTemp, doCache : doCache, subs : data.subs, voiceOverTrack : data.voiceOverTrack, playerType : data.playerType}, atEnd : atEnd}}); + if(afterSend != null) { + afterSend(); + } + }; + var isServerResolvesMeta = doCache && this.isServerCacheServiceEnabled(); + var linkPlayerType = this.getLinkPlayerType(url); + if(isServerResolvesMeta && (linkPlayerType == "VkType" || linkPlayerType == "VimeoType")) { + sendItem({ duration : 0, title : "", url : url, playerType : linkPlayerType}); + return; } - this.player.getVideoData(obj,function(data) { + this.player.getVideoData({ url : url, atEnd : atEnd},function(data) { if(data.duration == 0) { - if(doCache) { - _gthis.hideDynamicChin(); + if(!isServerResolvesMeta) { + _gthis.serverMessage(Lang.get("addVideoError")); + return; } - _gthis.serverMessage(Lang.get("addVideoError")); - return; - } - data.title = data.title != null ? data.title : Lang.get("rawVideo"); - data.url = data.url != null ? data.url : url; - _gthis.send({ type : "AddVideo", addVideo : { item : { url : data.url, title : data.title, author : _gthis.personal.name, duration : data.duration, isTemp : isTemp, doCache : doCache, subs : data.subs, voiceOverTrack : data.voiceOverTrack, playerType : data.playerType}, atEnd : atEnd}}); - if(callback != null) { - callback(); + } else { + data.title = data.title != null ? data.title : Lang.get("rawVideo"); } + sendItem(data); }); } + ,isServerCacheServiceEnabled: function() { + return Lambda.exists(this.playersCacheSupport,function(type) { + return type != "RawType"; + }); + } + ,registerUpload: function(url,cancel) { + this.activeUploads.h[url] = cancel; + } + ,unregisterUpload: function(url) { + var _this = this.activeUploads; + if(Object.prototype.hasOwnProperty.call(_this.h,url)) { + delete(_this.h[url]); + } + } + ,cancelUpload: function(url) { + var tmp = this.activeUploads.h[url]; + if(tmp == null) { + return; + } + var _this = this.activeUploads; + if(Object.prototype.hasOwnProperty.call(_this.h,url)) { + delete(_this.h[url]); + } + tmp(); + } + ,addUploadedVideo: function(url,title,duration,atEnd,isTemp) { + this.send({ type : "AddVideo", addVideo : { item : { url : url, title : title, author : this.personal.name, duration : duration, isTemp : isTemp, doCache : false, isIncomplete : true, playerType : "RawType"}, atEnd : atEnd}}); + } ,addIframe: function(atEnd) { var _gthis = this; var iframeCode = window.document.querySelector("#customembed-content"); @@ -1802,7 +2276,6 @@ client_Main.prototype = { var mediaTitle = window.document.querySelector("#customembed-title"); var title = mediaTitle.value; mediaTitle.value = ""; - var isTemp = window.document.querySelector("#customembed .add-temp").checked; this.player.getIframeData({ url : iframe, atEnd : atEnd},function(data) { if(data.duration == 0) { _gthis.serverMessage(Lang.get("addVideoError")); @@ -1813,7 +2286,7 @@ client_Main.prototype = { } data.title = data.title != null ? data.title : "Custom Media"; data.url = data.url != null ? data.url : iframe; - _gthis.send({ type : "AddVideo", addVideo : { item : { url : data.url, title : data.title, author : _gthis.personal.name, duration : data.duration, isTemp : isTemp, doCache : false, playerType : "IframeType"}, atEnd : atEnd}}); + _gthis.send({ type : "AddVideo", addVideo : { item : { url : data.url, title : data.title, author : _gthis.personal.name, duration : data.duration, isTemp : true, doCache : false, playerType : "IframeType"}, atEnd : atEnd}}); }); } ,removeVideoItem: function(url) { @@ -1854,7 +2327,7 @@ client_Main.prototype = { var data = JSON.parse(e.data); if(this.config != null && this.config.isVerbose) { var t = data.type; - haxe_Log.trace("Event: " + data.type,{ fileName : "src/client/Main.hx", lineNumber : 547, className : "client.Main", methodName : "onMessage", customParams : [Reflect.field(data,t.charAt(0).toLowerCase() + HxOverrides.substr(t,1,null))]}); + haxe_Log.trace("Event: " + data.type,{ fileName : "src/client/Main.hx", lineNumber : 599, className : "client.Main", methodName : "onMessage", customParams : [Reflect.field(data,t.charAt(0).toLowerCase() + HxOverrides.substr(t,1,null))]}); } client_JsApi.fireEvents(data); switch(data.type) { @@ -1870,7 +2343,9 @@ client_Main.prototype = { this.clearChat(); break; case "ClearPlaylist": + this.resetSkipVoteUi(); this.player.clearItems(); + this.updateServerPauseUi(); if(this.player.isListEmpty()) { this.player.pause(); } @@ -1892,20 +2367,10 @@ client_Main.prototype = { data.getTime.paused = data.getTime.paused != null && data.getTime.paused; data.getTime.pausedByServer = data.getTime.pausedByServer != null && data.getTime.pausedByServer; data.getTime.rate = data.getTime.rate != null ? data.getTime.rate : 1; - var isPauseChanged = this.lastState.paused != data.getTime.paused; this.lastState.time = data.getTime.time; - this.lastState.paused = data.getTime.paused; - this.lastState.pausedByServer = data.getTime.pausedByServer; this.lastState.rate = data.getTime.rate; this.updateLastStateTime(); - if(isPauseChanged) { - this.updateUserList(); - } - if(data.getTime.pausedByServer) { - this.showServerUnpause(); - } else if(this.showingServerPause) { - this.hideDynamicChin(); - } + this.updatePauseState(data.getTime.paused,data.getTime.pausedByServer); if(this.player.getPlaybackRate() != data.getTime.rate) { this.player.setPlaybackRate(data.getTime.rate); } @@ -1974,10 +2439,8 @@ client_Main.prototype = { break; case "Pause": this.lastState.time = data.pause.time; - this.lastState.paused = true; this.updateLastStateTime(); - this.player.setPauseIndicator(this.lastState.paused); - this.updateUserList(); + this.updatePauseState(true,this.lastState.pausedByServer); if((this.personal.group & 4) != 0) { return; } @@ -1986,10 +2449,8 @@ client_Main.prototype = { break; case "Play": this.lastState.time = data.play.time; - this.lastState.paused = false; this.updateLastStateTime(); - this.player.setPauseIndicator(this.lastState.paused); - this.updateUserList(); + this.updatePauseState(false); if((this.personal.group & 4) != 0) { return; } @@ -2001,13 +2462,21 @@ client_Main.prototype = { this.player.play(); break; case "PlayItem": + this.resetSkipVoteUi(); this.player.setVideo(data.playItem.pos); break; case "Progress": this.onProgressEvent(data); break; case "RemoveVideo": + var tmp = this.player.getCurrentItem(); + var isCurrent = (tmp != null ? tmp.url : null) == data.removeVideo.url; + this.cancelUpload(data.removeVideo.url); this.player.removeItem(data.removeVideo.url); + if(isCurrent) { + this.resetSkipVoteUi(); + } + this.updateServerPauseUi(); if(this.player.isListEmpty()) { this.player.pause(); } @@ -2038,6 +2507,9 @@ client_Main.prototype = { if((this.personal.group & 4) != 0) { this.player.onSetTime(); } + if(!this.hasLeader()) { + this.updatePauseState(this.lastState.paused); + } break; case "SetNextItem": this.player.setNextItem(data.setNextItem.pos); @@ -2061,11 +2533,28 @@ client_Main.prototype = { case "ShufflePlaylist": break; case "SkipVideo": + this.resetSkipVoteUi(); this.player.skipItem(data.skipVideo.url); + this.updateServerPauseUi(); if(this.player.isListEmpty()) { this.player.pause(); } break; + case "SkipVideoStats": + if(data.skipVideoStats.voted == 1 && this.skipVoteCount == 0) { + this.serverMessage(Lang.get("skipVoteInitiated")); + } + this.skipVoteCount = data.skipVideoStats.voted; + this.skipVoteRequired = data.skipVideoStats.required; + var tmp = "" + ("(" + this.skipVoteCount + "/" + this.skipVoteRequired + ")") + " "; + var tmp1 = Lang.get("voteForSkip"); + window.document.querySelector("#voteskip-text").textContent = tmp + tmp1; + var indicator = window.document.querySelector("#playlist-menu-indicator"); + var dropdown = window.document.querySelector("#playlist-menu-dropdown"); + if(!this.hasSeenSkipVote && dropdown.style.display == "none") { + indicator.style.display = ""; + } + break; case "ToggleItemType": this.player.toggleItemType(data.toggleItemType.pos); break; @@ -2084,10 +2573,10 @@ client_Main.prototype = { this.player.setItems(data.updatePlaylist.videoList); break; case "VideoLoaded": - this.lastState.paused = false; - this.lastState.pausedByServer = false; + this.resetSkipVoteUi(); this.lastState.time = 0; this.updateLastStateTime(); + this.updatePauseState(false); this.player.setTime(0); this.player.play(); if((this.personal.group & 4) != 0 && !this.player.isVideoLoaded()) { @@ -2097,33 +2586,21 @@ client_Main.prototype = { } } ,onProgressEvent: function(data) { - var _gthis = this; var data1 = data.progress; - var text; + var tmp = data1.url; + if(tmp == null) { + return; + } switch(data1.type) { - case "Caching": - text = "" + Lang.get("caching") + " " + data1.data; - break; case "Canceled": - this.hideDynamicChin(); - return; - case "Downloading": - text = Lang.get("downloading"); + this.player.removeItem(tmp); break; - case "Uploading": - text = Lang.get("uploading"); + case "Completed": + this.player.completeItem(tmp); + break; + case "Caching":case "Downloading":case "Uploading": + this.player.setItemProgress(tmp,data1.ratio); break; - } - var percent = tools_MathTools.toFixed(data1.ratio * 100,1); - var text1 = "" + text + "..."; - if(percent > 0) { - text1 += " " + percent + "%"; - } - this.showProgressInfo(text1); - if(data1.ratio == 1) { - haxe_Timer.delay(function() { - _gthis.hideDynamicChin(); - },500); } } ,updateLastStateTime: function() { @@ -2451,9 +2928,12 @@ client_Main.prototype = { if((client.group & 8) != 0) { klass += " userlist_owner"; } - list_b += Std.string("" + client.name + ""); + list_b += Std.string("" + StringTools.htmlEscape(client.name) + ""); } window.document.querySelector("#userlist").innerHTML = list_b; + if(this.player != null) { + this.player.updateTitle(); + } } ,getPageTitle: function() { return "" + this.pageTitle + " (" + this.clients.length + ")"; @@ -2519,6 +2999,9 @@ client_Main.prototype = { if(this.onBlinkTab == null) { this.blinkTabWithTitle("*" + Lang.get("chat") + "*"); } + if(name != this.personal.name) { + client_Buttons.onNewChatMessage(); + } } ,getFirstMessageDiv: function() { if(this.isMessageBufferReversed()) { @@ -2558,43 +3041,66 @@ client_Main.prototype = { return btn.style.display = "none"; },{ once : true}); } - ,showProgressInfo: function(text) { - var chin = window.document.querySelector("#dynamic-chin"); - var div = chin.querySelector("#progress-info"); - if(div == null) { - div = window.document.createElement("div"); - div.id = "progress-info"; - chin.prepend(div); + ,resetSkipVoteUi: function() { + this.hasSeenSkipVote = false; + this.skipVoteCount = 0; + this.skipVoteRequired = 0; + window.document.querySelector("#voteskip-text").textContent = Lang.get("voteForSkip"); + window.document.querySelector("#playlist-menu-indicator").style.display = "none"; + } + ,updatePauseState: function(paused,pausedByServer) { + if(pausedByServer == null) { + pausedByServer = false; + } + var isPauseChanged = this.lastState.paused != paused; + this.lastState.paused = paused; + this.lastState.pausedByServer = pausedByServer; + this.player.setPauseIndicator(this.lastState.paused); + if(isPauseChanged) { + this.updateUserList(); } - div.textContent = text; - this.showDynamicChin(); + this.updateServerPauseUi(); } - ,showServerUnpause: function() { - var _gthis = this; - if(this.showingServerPause) { + ,updateServerPauseUi: function() { + var show = this.lastState.pausedByServer && !this.player.isListEmpty(); + if(this.showingServerPause == show) { return; } - this.showingServerPause = true; + this.showingServerPause = show; + this.renderDynamicChin(); + } + ,renderDynamicChin: function() { + var _gthis = this; var chin = window.document.querySelector("#dynamic-chin"); + if(!this.showingServerPause) { + this.hideDynamicChinAnim(); + return; + } chin.innerHTML = ""; + var wrapper = window.document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + wrapper.style.gap = "0.5rem"; var div = window.document.createElement("div"); div.className = "server-whisper"; + div.style.textAlign = "center"; div.textContent = Lang.get("leaderDisconnectedServerOnPause"); - chin.appendChild(div); + wrapper.appendChild(div); var btn = window.document.createElement("button"); btn.id = "unpause-server"; btn.textContent = Lang.get("unpause"); - chin.appendChild(btn); + wrapper.appendChild(btn); btn.onclick = function() { - _gthis.hideDynamicChin(); + _gthis.updatePauseState(_gthis.lastState.paused,false); _gthis.send({ type : "SetLeader", setLeader : { clientName : _gthis.personal.name}}); client_JsApi.once("SetLeader",function(event) { _gthis.removeLeader(); }); }; - this.showDynamicChin(); + chin.appendChild(wrapper); + this.showDynamicChinAnim(); } - ,showDynamicChin: function() { + ,showDynamicChinAnim: function() { var chin = window.document.querySelector("#dynamic-chin"); if(chin.style.display == "") { return; @@ -2619,9 +3125,11 @@ client_Main.prototype = { }; chin.addEventListener("transitionend",onTransitionEnd); } - ,hideDynamicChin: function() { - this.showingServerPause = false; + ,hideDynamicChinAnim: function() { var chin = window.document.querySelector("#dynamic-chin"); + if(chin.style.display == "none") { + return; + } var h = chin.clientHeight; chin.style.height = "" + h + "px"; haxe_Timer.delay(function() { @@ -2686,79 +3194,81 @@ client_Main.prototype = { if(!StringTools.startsWith(command,"/")) { return false; } - var args = StringTools.trim(command).split(" "); - command = HxOverrides.substr(args.shift(),1,null); - if(command.length == 0) { - return false; - } - switch(command) { - case "ad": - this.player.skipAd(); + var parsed = client_Commands.parse(command); + var args = parsed.args; + var tmp = parsed.cmd; + var _g = tmp != null ? tmp.name : null; + if(_g == null) { + var cmdNameWithoutSlash = HxOverrides.substr(parsed.rawCmd,1,null); + if(this.matchSimpleDate.match(cmdNameWithoutSlash)) { + this.send({ type : "Rewind", rewind : { time : this.parseSimpleDate(cmdNameWithoutSlash)}}); + } return false; - case "ban": - this.mergeRedundantArgs(args,0,2); - var name = args[0]; - var time = this.parseSimpleDate(args[1]); - if(time < 0) { + } else { + switch(_g) { + case "/ad": + this.player.skipAd(); + return false; + case "/ban": + this.mergeRedundantArgs(args,0,2); + var name = args[0]; + var time = this.parseSimpleDate(args[1]); + if(time < 0) { + return true; + } + this.send({ type : "BanClient", banClient : { name : name, time : time}}); + return true; + case "/clear": + this.send({ type : "ClearChat"}); + return true; + case "/crash": + this.send({ type : "CrashTest"}); + return true; + case "/dump": + this.send({ type : "Dump"}); + return true; + case "/fb": + this.send({ type : "Flashback"}); + return false; + case "/giveup": + this.mergeRedundantArgs(args,0,1); + var tmp = args[0]; + this.send({ type : "SetLeader", setLeader : { clientName : tmp != null ? tmp : ""}}); return true; - } - this.send({ type : "BanClient", banClient : { name : name, time : time}}); - return true; - case "clear": - this.send({ type : "ClearChat"}); - return true; - case "crash": - this.send({ type : "CrashTest"}); - return true; - case "dump": - this.send({ type : "Dump"}); - return true; - case "fb":case "flashback": - this.send({ type : "Flashback"}); - return false; - case "giveup": - this.mergeRedundantArgs(args,0,1); - var tmp = args[0]; - this.send({ type : "SetLeader", setLeader : { clientName : tmp != null ? tmp : ""}}); - return true; - case "help": - this.showChatHintList(); - return true; - case "kick": - this.mergeRedundantArgs(args,0,1); - this.send({ type : "KickClient", kickClient : { name : args[0]}}); - return true; - case "removeBan":case "unban": - this.mergeRedundantArgs(args,0,1); - this.send({ type : "BanClient", banClient : { name : args[0], time : 0}}); - return true; - case "volume": - var v = parseFloat(args[0]); - if(isNaN(v)) { - v = 1; - } - if(v < 0) { - v = 0; - } else if(v > 3) { - v = 3; - } - var wasNotFull = this.player.getVolume() < 1; - this.player.setVolume(v < 0 ? 0 : v > 1 ? 1 : v); - if(this.player.getPlayerType() != "RawType") { + case "/help": + this.showChatHintList(); return true; - } - if(wasNotFull && v > 1) { - this.serverMessage("Volume was not maxed yet to be boosted, you can send command again."); + case "/kick": + this.mergeRedundantArgs(args,0,1); + this.send({ type : "KickClient", kickClient : { name : args[0]}}); + return true; + case "/unban": + this.mergeRedundantArgs(args,0,1); + this.send({ type : "BanClient", banClient : { name : args[0], time : 0}}); + return true; + case "/volume": + var v = parseFloat(args[0]); + if(isNaN(v)) { + v = 1; + } + if(v < 0) { + v = 0; + } else if(v > 3) { + v = 3; + } + var wasNotFull = this.player.getVolume() < 1; + this.player.setVolume(v < 0 ? 0 : v > 1 ? 1 : v); + if(this.player.getPlayerType() != "RawType") { + return true; + } + if(wasNotFull && v > 1) { + this.serverMessage("Volume was not maxed yet to be boosted, you can send command again."); + return true; + } + this.player.rawPlayer.boostVolume(v); return true; } - this.player.rawPlayer.boostVolume(v); - return true; - } - if(this.matchSimpleDate.match(command)) { - this.send({ type : "Rewind", rewind : { time : this.parseSimpleDate(command)}}); - return false; } - return false; } ,parseSimpleDate: function(text) { if(text == null) { @@ -2870,6 +3380,9 @@ client_Main.prototype = { ,hasLeader: function() { return ClientTools.hasLeader(this.clients); } + ,getLeader: function() { + return ClientTools.getLeader(this.clients); + } ,hasLeaderOnPauseRequest: function() { if(this.config.requestLeaderOnPause) { return this.isPageVisible && !this.isPageUnloading; @@ -2900,9 +3413,6 @@ client_Main.prototype = { } return this.gotFirstPageInteraction; } - ,isVerbose: function() { - return this.config.isVerbose; - } ,escapeRegExp: function(regex) { var _this_r = new RegExp("([.*+?^${}()|[\\]\\\\])","g".split("u").join("")); return regex.replace(_this_r,"\\$1"); @@ -2923,9 +3433,9 @@ var client_Player = function(main) { var _gthis = this; this.main = main; this.youtube = new client_players_Youtube(main,this); - this.players = [this.youtube,new client_players_Vk(main,this),new client_players_Streamable(main,this),new client_players_Peertube(main,this)]; this.iframePlayer = new client_players_Iframe(main,this); this.rawPlayer = new client_players_Raw(main,this); + this.players = [this.youtube,new client_players_Vimeo(main,this),new client_players_Vk(main,this),new client_players_Streamable(main,this),new client_players_Peertube(main,this),this.iframePlayer]; this.initItemButtons(); var resizeObserver = client_Utils.createResizeObserver(function(entries) { if(_gthis.isVideoLoaded() || _gthis.videoList.items.length == 0) { @@ -2949,6 +3459,22 @@ var client_Player = function(main) { _gthis.inUserInteraction = false; },350); },{ }); + this.playerEl.oncontextmenu = function(e) { + if(!main.settings.fullscreenAction) { + return; + } + e.preventDefault(); + var isPlayerFs = window.document.fullscreenElement == _gthis.playerEl; + if(isPlayerFs) { + if(main.settings.pageFullscreen) { + client_Utils.switchToPageFullscreen(); + } else { + client_Utils.cancelFullscreen(); + } + } else { + client_Utils.requestFullscreen(_gthis.playerEl); + } + }; }; client_Player.__name__ = true; client_Player.prototype = { @@ -2965,13 +3491,20 @@ client_Player.prototype = { _gthis.main.send({ type : "SetNextItem", setNextItem : { pos : i}}); } if(btn.classList.contains("qbtn-tmp")) { - _gthis.main.send({ type : "ToggleItemType", toggleItemType : { pos : i}}); + if(e.altKey || e.shiftKey) { + _gthis.toggleAllItemTypes(i); + } else { + _gthis.main.send({ type : "ToggleItemType", toggleItemType : { pos : i}}); + } } if(btn.classList.contains("qbtn-delete")) { _gthis.main.removeVideoItem(item.querySelector(".qe_title").getAttribute("href")); } }; } + ,toggleAllItemTypes: function(pos) { + this.main.send({ type : "ToggleItemType", toggleItemType : { pos : pos, all : true}}); + } ,setNextItem: function(pos) { this.videoList.setNextItem(pos); var next = this.videoItemsEl.children[pos]; @@ -3055,10 +3588,12 @@ client_Player.prototype = { return; } var item = this.videoList.items[i]; + if(!_$Types_VideoItemTools.isPlayable(item)) { + return; + } this.setSupportedPlayer(item.url,item.playerType); - this.removeActiveLabel(this.videoList.pos); this.videoList.setPos(i); - this.addActiveLabel(this.videoList.pos); + this.addActiveItemLabel(); this.canBePlayedSent = false; if(this.main.isVideoEnabled) { this.player.loadVideo(item); @@ -3067,7 +3602,7 @@ client_Player.prototype = { this.onCanBePlayed(); } client_JsApi.fireVideoChangeEvents(item); - window.document.querySelector("#currenttitle").textContent = item.title; + this.updateTitle(); } ,setExternalAudioTrack: function(item) { var _gthis = this; @@ -3089,7 +3624,7 @@ client_Player.prototype = { return _gthis.isAudioTrackLoaded = true; }; this.audioTrack.onerror = function(e) { - haxe_Log.trace(e,{ fileName : "src/client/Player.hx", lineNumber : 220, className : "client.Player", methodName : "setExternalAudioTrack"}); + haxe_Log.trace(e,{ fileName : "src/client/Player.hx", lineNumber : 255, className : "client.Player", methodName : "setExternalAudioTrack"}); _gthis.audioTrack.oncanplay = null; _gthis.audioTrack.onerror = null; _gthis.isAudioTrackLoaded = false; @@ -3139,19 +3674,41 @@ client_Player.prototype = { var _this = this.videoList; client_JsApi.fireVideoRemoveEvents(_this.items[_this.pos]); this.player.removeVideo(); - window.document.querySelector("#currenttitle").textContent = Lang.get("nothingPlaying"); + this.updateTitle(); this.setPauseIndicator(false); } + ,updateTitle: function() { + var baseTitle; + if(this.isListEmpty()) { + baseTitle = Lang.get("nothingPlaying"); + } else { + var _this = this.videoList; + baseTitle = _this.items[_this.pos].title; + } + if(!this.main.lastState.paused) { + window.document.querySelector("#currenttitle").textContent = baseTitle; + return; + } + var pauserName = Lang.get("server"); + if(!this.main.lastState.pausedByServer) { + var leader = this.main.getLeader(); + if(leader != null) { + pauserName = leader.name; + } + } + var tmp = "" + ("" + StringTools.htmlEscape(StringTools.replace(Lang.get("pausedBy"),"$NAME",pauserName)) + "") + " "; + var tmp1 = StringTools.htmlEscape(baseTitle); + window.document.querySelector("#currenttitle").innerHTML = tmp + tmp1; + } ,setPauseIndicator: function(isPause) { if(!this.main.isSyncActive) { return; } - var state = isPause ? "pause" : "play"; - window.document.querySelector("#pause-indicator").setAttribute("name",state); - var el2 = window.document.querySelector("#pause-indicator-portrait"); - el2.setAttribute("name",state); + var el = window.document.querySelector("#pause-indicator-chat"); + el.setAttribute("name",isPause ? "pause" : "play"); var tmp = isPause || this.main.hasLeader() ? "" : "none"; - el2.style.display = tmp; + el.style.display = tmp; + this.updateTitle(); } ,onCanBePlayed: function() { if(!this.canBePlayedSent) { @@ -3285,8 +3842,9 @@ client_Player.prototype = { ,addVideoItem: function(item,atEnd) { var url = StringTools.htmlEscape(item.url,true); var duration = item.playerType == "IframeType" ? "" : this.duration(item.duration); - var author = StringTools.htmlEscape(item.author); - var itemEl = client_Utils.nodeFromString("
  • \n\t\t\t\t
    \n\t\t\t\t\t" + duration + "\n\t\t\t\t\t

    " + StringTools.htmlEscape(item.title) + "

    \n\t\t\t\t\t" + Lang.get("addedBy") + ": " + author + "\n\t\t\t\t
    \n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
  • "); + var author = StringTools.htmlEscape(item.author,true); + var isIncomplete = item.isIncomplete == true; + var itemEl = client_Utils.nodeFromString("
  • \n\t\t\t\t" + (isIncomplete ? "
    " : "") + "
    \n\t\t\t\t\t" + duration + "\n\t\t\t\t\t

    " + StringTools.htmlEscape(item.title) + "

    \n\t\t\t\t\t" + Lang.get("addedBy") + ": " + author + "\n\t\t\t\t
    \n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
  • "); this.videoList.addItem(item,atEnd); this.setItemElementType(itemEl,item.isTemp); if(atEnd) { @@ -3318,17 +3876,61 @@ client_Player.prototype = { this.setVideo(this.videoList.pos); } } - ,removeElementItem: function(url) { + ,findItemElement: function(url) { var _g = 0; var _g1 = this.videoItemsEl.children; while(_g < _g1.length) { var child = _g1[_g]; ++_g; if(child.querySelector(".qe_title").getAttribute("href") == url) { - this.videoItemsEl.removeChild(child); - break; + return child; + } + } + return null; + } + ,removeElementItem: function(url) { + var tmp = this.findItemElement(url); + if(tmp == null) { + return; + } + this.videoItemsEl.removeChild(tmp); + } + ,setItemProgress: function(url,ratio) { + var tmp = this.findItemElement(url); + if(tmp == null) { + return; + } + var tmp1 = tmp.querySelector(".qe_progress"); + if(tmp1 == null) { + return; + } + tmp1.style.width = "" + ratio * 100 + "%"; + } + ,completeItem: function(url) { + var index = this.videoList.findIndex(function(item) { + return item.url == url; + }); + if(index == -1) { + return; + } + var item = this.videoList.items[index]; + if(item.isIncomplete != true) { + return; + } + var wasPlayable = _$Types_VideoItemTools.isPlayable(item); + item.isIncomplete = false; + var el = this.findItemElement(url); + if(el != null) { + el.classList.remove("queue_incomplete"); + el.classList.add("queue_cached"); + var bar = el.querySelector(".qe_progress"); + if(bar != null) { + el.removeChild(bar); } } + if(index == this.videoList.pos && !wasPlayable) { + this.setVideo(index); + } } ,skipItem: function(url) { var pos = this.videoList.findIndex(function(item) { @@ -3337,7 +3939,6 @@ client_Player.prototype = { if(pos == -1) { return; } - this.removeActiveLabel(this.videoList.pos); this.videoList.setPos(pos); var _this = this.videoList; if(_this.items[_this.pos].isTemp) { @@ -3350,16 +3951,19 @@ client_Player.prototype = { } this.setVideo(this.videoList.pos); } - ,addActiveLabel: function(pos) { + ,addActiveItemLabel: function() { var childs = this.videoItemsEl.children; - if(childs[this.videoList.pos] != null) { - childs[this.videoList.pos].classList.add("queue_active"); + var _g = 0; + while(_g < childs.length) { + var element = childs[_g]; + ++_g; + if(element.classList.contains("queue_active")) { + element.classList.remove("queue_active"); + break; + } } - } - ,removeActiveLabel: function(pos) { - var childs = this.videoItemsEl.children; if(childs[this.videoList.pos] != null) { - childs[this.videoList.pos].classList.remove("queue_active"); + childs[this.videoList.pos].classList.add("queue_active"); } } ,updateCounters: function() { @@ -3392,7 +3996,7 @@ client_Player.prototype = { if(currentUrl != _this.items[_this.pos].url) { this.setVideo(this.videoList.pos); } else { - this.addActiveLabel(this.videoList.pos); + this.addActiveItemLabel(); } } ,clearItems: function() { @@ -3455,7 +4059,7 @@ client_Player.prototype = { return this.videoList.pos; } ,hasVideo: function() { - return this.playerEl.children.length != 0; + return this.playerEl.querySelector("video, iframe, embed, object") != null; } ,getDuration: function() { if(this.videoList.pos >= this.videoList.items.length) { @@ -3612,7 +4216,7 @@ client_Player.prototype = { } }; http.onError = function(msg) { - haxe_Log.trace(msg,{ fileName : "src/client/Player.hx", lineNumber : 677, className : "client.Player", methodName : "skipAd"}); + haxe_Log.trace(msg,{ fileName : "src/client/Player.hx", lineNumber : 756, className : "client.Player", methodName : "skipAd"}); }; http.request(); } @@ -3710,6 +4314,12 @@ client_Settings.write = function(data) { } client_Settings.storage.setItem("data",JSON.stringify(data)); }; +client_Settings.reset = function() { + if(client_Settings.defaults == null) { + throw haxe_Exception.thrown("reset: default data is null"); + } + client_Settings.write(client_Settings.defaults); +}; var client_Split = function(settings) { this.settings = settings; this.split = new Split({ columnGutters : [{ element : window.document.querySelector(".gutter"), track : 1}], minSize : 200, snapOffset : 0, onDragEnd : $bind(this,this.saveSize)}); @@ -3801,23 +4411,48 @@ client_Utils.getIndex = function(parent,child) { return i; }; client_Utils.hasFullscreen = function() { - var doc = window.document; - if(!(window.document.fullscreenElement != null || doc.mozFullScreenElement != null)) { - return doc.webkitFullscreenElement != null; - } else { - return true; - } + return window.document.fullscreenElement != null; }; client_Utils.requestFullscreen = function(el) { - var el2 = el; if(el.requestFullscreen != null) { el.requestFullscreen(); - } else if(el2.webkitRequestFullscreen != null) { - el2.webkitRequestFullscreen(HTMLElement.ALLOW_KEYBOARD_INPUT); + return true; + } + return false; +}; +client_Utils.cancelFullscreen = function(el) { + if(window.document.exitFullscreen != null) { + window.document.exitFullscreen(); + } +}; +client_Utils.switchToPageFullscreen = function() { + var docEl = window.document.documentElement; + if(window.document.fullscreenElement == docEl) { + return; + } + if(window.document.fullscreenElement == null) { + client_Utils.requestFullscreen(docEl); + return; + } + var promise = window.document.exitFullscreen(); + if(promise != null) { + promise.then(function(_) { + client_Utils.switchToPageFullscreen(); + }).catch(function(_) { + return; + }); } else { + haxe_Timer.delay(function() { + client_Utils.switchToPageFullscreen(); + },1); + } +}; +client_Utils.toggleFullscreen = function(el) { + if(client_Utils.hasFullscreen()) { + client_Utils.cancelFullscreen(el); return false; } - return true; + return client_Utils.requestFullscreen(el); }; client_Utils.copyToClipboard = function(text) { var clipboardData = window.clipboardData; @@ -3941,18 +4576,38 @@ var client_players_Iframe = function(main,player) { this.player = player; }; client_players_Iframe.__name__ = true; +client_players_Iframe.extractTwitchChannel = function(url) { + url = StringTools.trim(url); + if(!client_players_Iframe.matchTwitchChannel.match(url)) { + return ""; + } + return client_players_Iframe.matchTwitchChannel.matched(1); +}; client_players_Iframe.prototype = { getPlayerType: function() { return "IframeType"; } ,isSupportedLink: function(url) { - return true; + var trimmed = StringTools.trim(url); + if(client_players_Iframe.extractTwitchChannel(trimmed).length > 0) { + return true; + } + if(StringTools.startsWith(trimmed," 0) { + callback({ duration : 356400, title : "Twitch: " + channel, url : ""}); + return; + } var iframe = window.document.createElement("div"); - iframe.innerHTML = StringTools.trim(data.url); + iframe.innerHTML = rawUrl; if(this.isValidIframe(iframe)) { - callback({ duration : 356400, title : "Iframe media"}); + callback({ duration : 356400, title : "Iframe media", url : rawUrl}); } else { callback({ duration : 0}); } @@ -4799,6 +5454,245 @@ client_players_Streamable.prototype = $extend(client_players_Raw.prototype,{ http.request(); } }); +var client_players_Vimeo = function(main,player) { + this.matchVimeoId = new EReg("vimeo\\.com/(?:channels/(?:\\w+/)?|groups/[^/]+/videos/|video/|ondemand/[^/]+/)?([0-9]+)","i"); + this.matchVimeo = new EReg("(?:vimeo\\.com|player\\.vimeo\\.com)","i"); + this.prevTime = 0; + this.volume = 1; + this.playbackRate = 1; + this.currentTime = 0; + this.isPausedState = true; + this.isLoaded = false; + this.playerEl = window.document.querySelector("#ytapiplayer"); + this.main = main; + this.player = player; +}; +client_players_Vimeo.__name__ = true; +client_players_Vimeo.prototype = { + getPlayerType: function() { + return "VimeoType"; + } + ,isSupportedLink: function(url) { + if(this.matchVimeo.match(url)) { + return this.extractVideoId(url).length > 0; + } else { + return false; + } + } + ,extractVideoId: function(url) { + if(!this.matchVimeoId.match(url)) { + return ""; + } + return this.matchVimeoId.matched(1); + } + ,loadApi: function(callback) { + if(client_players_Vimeo.isApiLoaded) { + callback(); + return; + } + client_players_Vimeo.apiCallbacks.push(callback); + if(client_players_Vimeo.isApiLoading) { + return; + } + client_players_Vimeo.isApiLoading = true; + client_JsApi.addScriptToHead("https://player.vimeo.com/api/player.js",function() { + client_players_Vimeo.isApiLoaded = true; + client_players_Vimeo.isApiLoading = false; + var _g = 0; + var _g1 = client_players_Vimeo.apiCallbacks; + while(_g < _g1.length) _g1[_g++](); + client_players_Vimeo.apiCallbacks = []; + }); + } + ,getVideoData: function(data,callback) { + var _gthis = this; + var url = data.url; + if(!this.isSupportedLink(url)) { + callback({ duration : 0}); + return; + } + var http = new haxe_http_HttpJs("https://vimeo.com/api/oembed.json?url=" + encodeURIComponent(url)); + http.onData = function(text) { + try { + var json = JSON.parse(text); + var tmp = json.title; + var tmp1 = json.duration; + callback({ duration : tmp1 != null ? tmp1 : 0, title : tmp != null ? tmp : "Vimeo video", url : url}); + } catch( _g ) { + _gthis.getRemoteDataFallback(url,callback); + } + }; + http.onError = function(msg) { + _gthis.getRemoteDataFallback(url,callback); + }; + http.request(); + } + ,getRemoteDataFallback: function(url,callback) { + var _gthis = this; + if(!client_players_Vimeo.isApiLoaded) { + this.loadApi(function() { + _gthis.getRemoteDataFallback(url,callback); + }); + return; + } + var tempVideo = window.document.createElement("div"); + tempVideo.className = "temp-videoplayer"; + tempVideo.style.display = "none"; + this.playerEl.appendChild(tempVideo); + var tempPlayer = null; + tempPlayer = new window.Vimeo.Player(tempVideo, { url : url}); + tempPlayer.on("loaded",function() { + return tempPlayer.getDuration().then(function(dur) { + try { + tempPlayer.destroy(); + } catch( _g ) { + } + if(_gthis.playerEl.contains(tempVideo)) { + _gthis.playerEl.removeChild(tempVideo); + } + callback({ title : "Vimeo video", duration : dur, url : url}); + }); + }); + tempPlayer.on("error",function(err) { + try { + tempPlayer.destroy(); + } catch( _g ) { + } + if(_gthis.playerEl.contains(tempVideo)) { + _gthis.playerEl.removeChild(tempVideo); + } + callback({ duration : 0}); + }); + } + ,loadVideo: function(item) { + var _gthis = this; + if(!client_players_Vimeo.isApiLoaded) { + this.loadApi(function() { + _gthis.loadVideo(item); + }); + return; + } + this.removeVideo(); + this.video = window.document.createElement("div"); + this.video.id = "videoplayer"; + this.playerEl.appendChild(this.video); + this.vimeoPlayer = new window.Vimeo.Player(this.video, { url : item.url, autoplay : true, playsinline : true, responsive : true}); + this.vimeoPlayer.on("loaded",function() { + if(!_gthis.main.isAutoplayAllowed()) { + _gthis.vimeoPlayer.setVolume(0); + } + _gthis.isLoaded = true; + if(_gthis.main.lastState.paused) { + _gthis.vimeoPlayer.pause(); + } + _gthis.player.onCanBePlayed(); + }); + this.vimeoPlayer.on("play",function() { + _gthis.isPausedState = false; + if(!_gthis.isLoaded) { + _gthis.isLoaded = true; + _gthis.player.onCanBePlayed(); + } + _gthis.player.onPlay(); + }); + this.vimeoPlayer.on("pause",function() { + _gthis.isPausedState = true; + if(!_gthis.isLoaded) { + _gthis.isLoaded = true; + _gthis.player.onCanBePlayed(); + } + _gthis.player.onPause(); + }); + this.vimeoPlayer.on("timeupdate",function(data) { + var sec = data.seconds; + _gthis.currentTime = sec; + var diff = Math.abs(_gthis.prevTime - sec); + _gthis.prevTime = sec; + if(diff > 1) { + _gthis.player.onSetTime(); + } + }); + this.vimeoPlayer.on("ratechange",function(data) { + _gthis.playbackRate = data.playbackRate; + _gthis.player.onRateChange(); + }); + this.vimeoPlayer.on("volumechange",function(data) { + return _gthis.volume = data.volume; + }); + this.vimeoPlayer.on("error",function(data) { + haxe_Log.trace("Vimeo error: " + data,{ fileName : "src/client/players/Vimeo.hx", lineNumber : 234, className : "client.players.Vimeo", methodName : "loadVideo"}); + }); + } + ,removeVideo: function() { + if(this.video == null) { + return; + } + this.isLoaded = false; + this.isPausedState = true; + if(this.vimeoPlayer != null) { + try { + this.vimeoPlayer.destroy(); + } catch( _g ) { + haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/players/Vimeo.hx", lineNumber : 246, className : "client.players.Vimeo", methodName : "removeVideo"}); + } + this.vimeoPlayer = null; + } + if(this.playerEl.contains(this.video)) { + this.playerEl.removeChild(this.video); + } + this.video = null; + } + ,isVideoLoaded: function() { + return this.isLoaded; + } + ,play: function() { + if(this.vimeoPlayer != null) { + this.vimeoPlayer.play(); + } + } + ,pause: function() { + if(this.vimeoPlayer != null) { + this.vimeoPlayer.pause(); + } + } + ,isPaused: function() { + return this.isPausedState; + } + ,getTime: function() { + return this.currentTime; + } + ,setTime: function(time) { + this.currentTime = time; + if(this.vimeoPlayer != null) { + this.vimeoPlayer.setCurrentTime(time); + } + } + ,getPlaybackRate: function() { + return this.playbackRate; + } + ,setPlaybackRate: function(rate) { + this.playbackRate = rate; + if(this.vimeoPlayer != null) { + this.vimeoPlayer.setPlaybackRate(rate); + } + } + ,getVolume: function() { + return this.volume; + } + ,setVolume: function(vol) { + this.volume = vol; + if(this.vimeoPlayer != null) { + this.vimeoPlayer.setVolume(vol); + } + } + ,unmute: function() { + if(this.vimeoPlayer == null) { + return; + } + this.vimeoPlayer.setMuted(false); + this.vimeoPlayer.setVolume(1); + } +}; var client_players_Vk = function(main,player) { this.matchIds = new EReg("video(-?[0-9]+)_([0-9]+)","g"); this.matchVk = new EReg("(vk.com/video|vkvideo)","g"); @@ -6025,18 +6919,6 @@ js_youtube_Youtube.init = function(onAPIReady) { } }; }; -var tools_MathTools = function() { }; -tools_MathTools.__name__ = true; -tools_MathTools.toFixed = function(v,digits) { - if(digits == null) { - digits = 2; - } - if(digits > 8) { - throw haxe_Exception.thrown("digits is " + digits + ", but cannot be bigger than 8 (for value " + v + ")"); - } - var ratio = Math.pow(10,digits); - return (v * ratio | 0) / ratio; -}; var utils_YoutubeUtils = function() { }; utils_YoutubeUtils.__name__ = true; utils_YoutubeUtils.extractVideoId = function(url) { @@ -6074,6 +6956,8 @@ js_Boot.__toStr = ({ }).toString; Lang.langs = new haxe_ds_StringMap(); Lang.ids = ["en","ru"]; Lang.lang = HxOverrides.substr($global.navigator.language,0,2).toLowerCase(); +client_Buttons.hasUnreadChatMessages = false; +client_Commands.list = [new client_CommandInfo("/help",0,null),new client_CommandInfo("/clear",0,null),new client_CommandInfo("/ban",2,null),new client_CommandInfo("/unban",1,null),new client_CommandInfo("/kick",1,null),new client_CommandInfo("/ad",0,null),new client_CommandInfo("/volume",1,null),new client_CommandInfo("/dump",0,null),new client_CommandInfo("/crash",0,null),new client_CommandInfo("/giveup",1,null),new client_CommandInfo("/fb",0,["/flashback"])]; client_JsApi.subtitleFormats = []; client_JsApi.videoChange = []; client_JsApi.videoRemove = []; @@ -6081,7 +6965,11 @@ client_JsApi.onListeners = []; client_JsApi.onceListeners = []; client_Settings.isSupported = false; client_Utils.isMacSafari = client_Utils._isMacSafari(); +client_players_Iframe.matchTwitchChannel = new EReg("^(?:https?://)?(?:www\\.|m\\.)?twitch\\.tv/([A-Za-z0-9_]+)/?$","i"); client_players_RawSubs.assTimeStamp = new EReg("([0-9]+):([0-9][0-9]):([0-9][0-9]).([0-9][0-9])",""); +client_players_Vimeo.isApiLoaded = false; +client_players_Vimeo.isApiLoading = false; +client_players_Vimeo.apiCallbacks = []; haxe_crypto_Base64.CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; haxe_crypto_Base64.BYTES = haxe_io_Bytes.ofString(haxe_crypto_Base64.CHARS); js_youtube_Youtube.isLoadedAPI = false; diff --git a/res/css/des.css b/res/css/des.css index 99a2c58..da003e7 100644 --- a/res/css/des.css +++ b/res/css/des.css @@ -39,6 +39,7 @@ --warning: var(--cl-yellow); --error: var(--cl-red-6); --border: var(--cl-gray-3); + --paused-by: var(--cl-white); --box-shadow: 0rem .25rem 1rem rgba(0, 0, 0, .25); } @@ -392,6 +393,10 @@ button.danger-bg:focus { overflow: hidden; } +.paused-by { + color: var(--paused-by); +} + .info header { display: flex; align-items: center; @@ -431,6 +436,83 @@ header h4 { align-items: center; } +.playlist-menu-wrap { + position: relative; + display: inline-flex; + margin-left: .5rem; +} + +#playlist-menu-btn { + position: relative; +} + +#playlist-menu-indicator { + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background-color: var(--accent); + border-radius: 50%; +} + +.playlist-menu-dropdown { + position: absolute; + top: calc(100% + .5rem); + right: 0; + background: var(--background-chat); + border: .063rem solid var(--border); + border-radius: 0; + box-shadow: var(--box-shadow); + z-index: 1000; + min-width: 14rem; + padding: .35rem 0; + display: flex; + flex-direction: column; +} + +.playlist-menu-dropdown button.playlist-menu-item { + display: flex; + align-items: center; + width: 100%; + padding: .6rem 1rem; + margin-left: 0; + background: transparent; + border-radius: 0; + text-align: left; + white-space: nowrap; + font-size: .9rem; + color: var(--midground); + transition: background-color ease .15s, color ease .15s; +} + +.playlist-menu-dropdown button.playlist-menu-item:hover, +.playlist-menu-dropdown button.playlist-menu-item.active { + background-color: rgba(255, 255, 255, .08); + color: var(--foreground); +} + +.playlist-menu-dropdown button.playlist-menu-item ion-icon { + margin-right: .6rem; + font-size: 1.1rem; +} + +.playlist-menu-dropdown button.playlist-menu-item .hotkey-hint { + color: var(--midground); + opacity: .75; + font-size: .85em; + margin-left: .25rem; +} + +.hotkeys-disabled .hotkey-hint { + display: none; +} + +#playlist-menu-btn.danger, +#playlist-menu-btn.danger ion-icon { + color: var(--error); +} + /* * Start main */ @@ -453,9 +535,7 @@ header h4 { grid-area: video; width: 100%; height: 100%; - /* Breaks subs after viewport overflow on FF+Linux (SyncTube#28) */ - /* overflow: auto; */ - overflow: scroll; + overflow: auto; background: var(--background-video); } } @@ -488,14 +568,54 @@ header h4 { /* Embed responsive */ -.embed-responsive:empty { - min-height: 60vh; +.embed-responsive:empty, +.embed-responsive:not(:has(iframe, video, object, embed)) { + aspect-ratio: 16 / 9; + min-height: unset; } .embed-responsive { background-color: var(--background); width: 100%; max-height: 80vh; + position: relative; +} + +.fullscreen-chat-btn { + display: none; + position: absolute; + top: 15px; + right: 15px; + z-index: 1; + background: rgba(0, 0, 0, 0.5); + opacity: 0.5; + border-radius: 50%; + width: 42px; + height: 42px; + justify-content: center; + outline: none; + transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.15s ease; +} + +.fullscreen-chat-btn:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.75); + color: #ffffff; + transform: scale(1.08); +} + +.fullscreen-chat-btn:active { + transform: scale(0.95); +} + +.fullscreen-chat-btn ion-icon { + color: white; + font-size: 22px; +} + +/* Fullscreen chat button display only when unread messages exist */ +#ytapiplayer:fullscreen .fullscreen-chat-btn.has-unread { + display: flex; } .temp-videoplayer { @@ -507,6 +627,10 @@ header h4 { max-height: 80vh; } +#ytapiplayer:fullscreen #videoplayer { + max-height: unset; +} + .videoplayerIframeParent { width: 100%; height: 100%; @@ -520,10 +644,6 @@ iframe#videoplayer { } @media only screen and (orientation: portrait) { - .embed-responsive:empty { - min-height: 20vh; - } - .embed-responsive { max-height: 30vh; } @@ -623,6 +743,8 @@ iframe#videoplayer { } .queue_entry { + position: relative; + overflow: hidden; padding: 1rem; border-bottom: .063rem solid; border-color: var(--border); @@ -632,9 +754,56 @@ iframe#videoplayer { transition: background-color ease-in-out .15s; } +.queue_entry>header, +.queue_entry>.controls { + position: relative; + z-index: 1; +} + +.qe_progress { + position: absolute; + left: 0; + top: 0; + z-index: 0; + height: 100%; + width: 0; + background-color: var(--background-chat); + pointer-events: none; + transition: width ease-out .2s; +} + +.queue_cached { + background-color: var(--background-chat); +} + +.queue_entry.queue_incomplete { + background-color: transparent; +} + +.queue_incomplete>header, +.queue_incomplete .qe_title { + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + +.queue_incomplete .controls button { + background-color: color-mix(in srgb, var(--background-chat) 50%, transparent); +} + +.queue_entry.queue_incomplete { + border-left: .25rem solid; + border-left-color: var(--background-chat); + animation: queue_incomplete_blinker 3s ease-in-out infinite; +} + +@keyframes queue_incomplete_blinker { + 50% { + border-left-color: var(--border); + } +} + .queue_entry:first-child { border-top: .063rem solid; - border-color: var(--border); + border-top-color: var(--border); } .queue_entry a { @@ -654,10 +823,6 @@ footer#footer { padding: 1rem; } -/* - * End main - */ - .gutter { display: none; } @@ -687,10 +852,26 @@ footer#footer { order: 0; flex-direction: column; flex-wrap: nowrap; - padding: 1rem; + container-type: inline-size; + --chat-px: clamp(0.25rem, 10cqw - 1rem, 1rem); + --chat-py: clamp(0.25rem, 2vh, 1rem); + padding: 0; height: 40vh; } +#chat>* { + padding-left: var(--chat-px); + padding-right: var(--chat-px); +} + +#chat>*:first-child { + padding-top: var(--chat-py); +} + +#chat>#chat-body { + padding-bottom: var(--chat-py); +} + @media only screen and (orientation: landscape) { #chat { grid-area: chat; @@ -707,7 +888,15 @@ footer#footer { #chat .controls { display: flex; justify-content: space-between; - padding-bottom: 1rem; + padding-bottom: clamp(0.25rem, 1.5vh, 1rem); +} + +#chat-body { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; } /* Users online */ @@ -763,7 +952,7 @@ footer#footer { text-align: left; } -#pause-indicator-portrait { +#pause-indicator-chat { padding-left: 0.2rem; } @@ -780,38 +969,91 @@ footer#footer { /* Options */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + background-color: var(--midground); + border-radius: 24px; + transition: background-color 0.3s; + flex-shrink: 0; + margin-left: 1rem; + cursor: pointer; +} + +.toggle-switch.on { + background-color: var(--accent); +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background-color: #fff; + border-radius: 50%; + transition: transform 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.toggle-switch.on::after { + transform: translateX(20px); +} + #optionsPanel { + display: flex; + flex-direction: column; z-index: 1; background-color: var(--background-chat); - overflow-y: scroll; position: absolute; - top: 4rem; - left: 1rem; - bottom: 1rem; - right: 1rem; + /* to prevent fraction gap between chat controls and this panel */ + top: -1px; + bottom: 0; + left: 0; + right: 0; + padding-left: var(--chat-px); + padding-right: var(--chat-px); + padding-bottom: var(--chat-py); + padding-top: 1rem; transition: opacity 200ms; opacity: 0; } -#optionsList div { +#optionsList { + overflow-y: auto; +} + +.options-category { display: flex; flex-direction: column; - margin-bottom: .5rem; + margin-bottom: 0.5rem; } -#optionsList div:not(:first-child) { +.options-category:not(:first-child) { border-top: .063rem solid; border-color: var(--border); - padding-top: 1rem; + padding-top: 1.25rem; +} + +#optionsList h4 { + padding: 0 1.25rem 0.75rem 0; + font-size: 1.15rem; + font-weight: 600; + color: #fff; } #optionsList li button { - padding: 1rem 0; + padding: 0.8rem 0.8rem 0.8rem 0; display: flex; - align-items: stretch; + align-items: center; + justify-content: space-between; width: 100%; border-radius: 0; text-align: left; + font-size: 1rem; } #optionsList li button:hover { @@ -915,7 +1157,7 @@ footer#footer { } #chatbox { - padding-top: 1rem; + padding-top: clamp(0.25rem, 1.5vh, 1rem); border-top: .063rem solid; border-color: var(--border); display: flex; @@ -931,6 +1173,66 @@ footer#footer { width: 2rem; } +#chat-commands-wrap { + display: flex; + width: 100%; + background: var(--cl-gray-2); + border-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + overflow: hidden; + flex-direction: column; + position: absolute; + bottom: 100%; + z-index: 1; +} + +#chat-commands-list { + display: flex; + max-height: 50vh; + overflow-y: auto; + flex-direction: column; + width: 100%; + padding: 0.5rem; +} + +.chat-command-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + padding: 0.5rem 1rem; + cursor: pointer; + border-radius: 0; +} + +.chat-command-item.active { + background: var(--cl-gray-3); +} + +.chat-command-item-name { + font-weight: bold; +} + +.chat-command-item-desc { + font-size: 0.8rem; + opacity: 0.8; +} + +.chat-command-arg-active { + color: var(--accent); + font-weight: bold; + opacity: 1; +} + +.chat-command-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + background-color: var(--cl-gray-1); + color: var(--foreground); + padding: 0.1rem 0.3rem; + border-radius: 0; +} + /* Emotes */ #smiles-wrap { @@ -1005,10 +1307,6 @@ footer#footer { width: 2rem; } -/* - * End chat - */ - /* * Scrollbar */ @@ -1028,28 +1326,9 @@ html { ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); + border-radius: .5rem; } ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } - -/* Mobile page fullscreen */ - -.mobile-view #chatbox { - padding-top: 0; - border-top: 0; -} - -.mobile-view #chat { - padding: 0 1rem; -} - -.mobile-view #chat .controls { - padding-bottom: 0; -} - -.mobile-view #optionsPanel { - top: 2.2rem; - bottom: 0; -} diff --git a/res/index.html b/res/index.html index 02ef525..3e2673a 100644 --- a/res/index.html +++ b/res/index.html @@ -25,7 +25,11 @@
    -
    +
    + +
    @@ -33,18 +37,12 @@
    - -
    @@ -67,16 +65,36 @@ data-target="#addfromurl" aria-expanded="false"> - - - +
    + + +
    @@ -112,7 +130,7 @@
    -
    '); + list.add('${client.name.htmlEscape()}'); } final userlist = getEl("#userlist"); userlist.innerHTML = list.toString(); + if (player != null) player.updateTitle(); } function getPageTitle():String { @@ -1184,6 +1238,7 @@ class Main { showScrollToChatEndBtn(); } if (onBlinkTab == null) blinkTabWithTitle('*${Lang.get("chat")}*'); + if (name != personal.name) Buttons.onNewChatMessage(); } function getFirstMessageDiv():Null { @@ -1214,34 +1269,57 @@ class Main { }, {once: true}); } - public function showProgressInfo(text:String):Void { - final chin = getEl("#dynamic-chin"); - var div = chin.querySelector("#progress-info"); - if (div == null) { - div = document.createDivElement(); - div.id = "progress-info"; - chin.prepend(div); - } - div.textContent = text; - showDynamicChin(); + public function resetSkipVoteUi():Void { + hasSeenSkipVote = false; + skipVoteCount = 0; + skipVoteRequired = 0; + getEl("#voteskip-text").textContent = Lang.get("voteForSkip"); + getEl("#playlist-menu-indicator").style.display = "none"; + } + + public function updatePauseState(paused:Bool, pausedByServer = false):Void { + final isPauseChanged = lastState.paused != paused; + lastState.paused = paused; + lastState.pausedByServer = pausedByServer; + + player.setPauseIndicator(lastState.paused); + if (isPauseChanged) updateUserList(); + + updateServerPauseUi(); + } + + public function updateServerPauseUi():Void { + final show = lastState.pausedByServer && !player.isListEmpty(); + if (showingServerPause == show) return; + showingServerPause = show; + renderDynamicChin(); } - public function showServerUnpause():Void { - if (showingServerPause) return; - showingServerPause = true; + public function renderDynamicChin():Void { final chin = getEl("#dynamic-chin"); + if (!showingServerPause) { + hideDynamicChinAnim(); + return; + } chin.innerHTML = ""; + final wrapper = document.createDivElement(); + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + wrapper.style.gap = "0.5rem"; + final div = document.createDivElement(); div.className = "server-whisper"; + div.style.textAlign = "center"; div.textContent = Lang.get("leaderDisconnectedServerOnPause"); - chin.appendChild(div); + wrapper.appendChild(div); + final btn = document.createButtonElement(); btn.id = "unpause-server"; btn.textContent = Lang.get("unpause"); - chin.appendChild(btn); + wrapper.appendChild(btn); btn.onclick = () -> { - hideDynamicChin(); + updatePauseState(lastState.paused, false); send({ type: SetLeader, setLeader: { @@ -1250,11 +1328,12 @@ class Main { }); JsApi.once(SetLeader, event -> removeLeader()); } + chin.appendChild(wrapper); - showDynamicChin(); + showDynamicChinAnim(); } - function showDynamicChin():Void { + function showDynamicChinAnim():Void { final chin = getEl("#dynamic-chin"); if (chin.style.display == "") return; chin.style.display = ""; @@ -1275,9 +1354,9 @@ class Main { chin.addEventListener("transitionend", onTransitionEnd); } - public function hideDynamicChin():Void { - showingServerPause = false; + function hideDynamicChinAnim():Void { final chin = getEl("#dynamic-chin"); + if (chin.style.display == "none") return; final h = chin.clientHeight; chin.style.height = '${h}px'; Timer.delay(() -> { @@ -1342,15 +1421,26 @@ class Main { /* Returns `true` if text should not be sent to chat */ public function handleCommands(command:String):Bool { if (!command.startsWith("/")) return false; - final args = command.trim().split(" "); - command = args.shift().substr(1); - if (command.length == 0) return false; + final parsed = Commands.parse(command); + final args = parsed.args; + switch (parsed.cmd?.name) { + case null: + final cmdNameWithoutSlash = parsed.rawCmd.substr(1); + if (matchSimpleDate.match(cmdNameWithoutSlash)) { + send({ + type: Rewind, + rewind: { + time: parseSimpleDate(cmdNameWithoutSlash) + } + }); + } + return false; - switch (command) { - case "help": + case Help: showChatHintList(); return true; - case "ban": + + case Ban: mergeRedundantArgs(args, 0, 2); final name = args[0]; final time = parseSimpleDate(args[1]); @@ -1363,7 +1453,8 @@ class Main { } }); return true; - case "unban", "removeBan": + + case Unban: mergeRedundantArgs(args, 0, 1); final name = args[0]; send({ @@ -1374,7 +1465,8 @@ class Main { } }); return true; - case "kick": + + case Kick: mergeRedundantArgs(args, 0, 1); final name = args[0]; send({ @@ -1384,10 +1476,11 @@ class Main { } }); return true; - case "clear": + + case Clear: send({type: ClearChat}); return true; - case "giveup": + case Giveup: mergeRedundantArgs(args, 0, 1); final name = args[0] ?? ""; send({ @@ -1397,13 +1490,17 @@ class Main { } }); return true; - case "flashback", "fb": + + + case Flashback: send({type: Flashback}); return false; - case "ad": + + case Ad: player.skipAd(); return false; - case "volume": + + case Volume: var v = Std.parseFloat(args[0]); if (Math.isNaN(v)) v = 1; v = v.clamp(0, 3); @@ -1418,23 +1515,15 @@ class Main { final rawPlayer = @:privateAccess player.rawPlayer; rawPlayer.boostVolume(v); return true; - case "dump": + + case Dump: send({type: Dump}); return true; - case "crash": + + case Crash: send({type: CrashTest}); return true; } - if (matchSimpleDate.match(command)) { - send({ - type: Rewind, - rewind: { - time: parseSimpleDate(command) - } - }); - return false; - } - return false; } final matchSimpleDate = ~/^-?([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s?)?$/; @@ -1553,6 +1642,16 @@ class Main { return clients.hasLeader(); } + public function getLeader():Null { + return clients.getLeader(); + } + + public static function isTempChecked(checkbox:Null):Bool { + if (checkbox == null) return true; + final isHidden = checkbox.style.display == "none"; + return isHidden ? true : checkbox.checked; + } + public function hasLeaderOnPauseRequest():Bool { final hasAccess = isPageVisible && !isPageUnloading; return config.requestLeaderOnPause && hasAccess; @@ -1593,7 +1692,7 @@ class Main { } @:generic - public static inline function getEl(id:String):T { + public static inline function getEl(id:String):Null { return cast document.querySelector(id); } } diff --git a/src/client/Player.hx b/src/client/Player.hx index 68b1a4a..4e1b8a6 100644 --- a/src/client/Player.hx +++ b/src/client/Player.hx @@ -9,14 +9,17 @@ import client.players.Iframe; import client.players.Peertube; import client.players.Raw; import client.players.Streamable; +import client.players.Vimeo; import client.players.Vk; import client.players.Youtube; import haxe.Http; import haxe.Json; import haxe.Timer; +import js.Browser.document; import js.html.Audio; import js.html.Element; import js.html.InputElement; +import js.html.MouseEvent; class Player { final main:Main; @@ -44,14 +47,16 @@ class Player { public function new(main:Main):Void { this.main = main; youtube = new Youtube(main, this); + iframePlayer = new Iframe(main, this); + rawPlayer = new Raw(main, this); players = [ youtube, + new Vimeo(main, this), new Vk(main, this), new Streamable(main, this), new Peertube(main, this), + iframePlayer, ]; - iframePlayer = new Iframe(main, this); - rawPlayer = new Raw(main, this); initItemButtons(); final resizeObserver = Utils.createResizeObserver(entries -> { @@ -73,11 +78,27 @@ class Player { // for some reason Chrome has ~300ms event delay Timer.delay(() -> inUserInteraction = false, 350); }, {}); + + playerEl.oncontextmenu = e -> { + if (!main.settings.fullscreenAction) return; + e.preventDefault(); + + final isPlayerFs = document.fullscreenElement == playerEl; + if (isPlayerFs) { + if (main.settings.pageFullscreen) { + Utils.switchToPageFullscreen(); + } else { + Utils.cancelFullscreen(); + } + } else { + Utils.requestFullscreen(playerEl); + } + } } function initItemButtons():Void { final queue = getEl("#queue"); - queue.onclick = e -> { + queue.onclick = (e:MouseEvent) -> { final btn:Element = cast e.target; final item = btn.parentElement.parentElement; final i = Utils.getIndex(item.parentElement, item); @@ -98,12 +119,16 @@ class Player { }); } if (btn.classList.contains("qbtn-tmp")) { - main.send({ - type: ToggleItemType, - toggleItemType: { - pos: i - } - }); + if (e.altKey || e.shiftKey) { + toggleAllItemTypes(i); + } else { + main.send({ + type: ToggleItemType, + toggleItemType: { + pos: i + } + }); + } } if (btn.classList.contains("qbtn-delete")) { main.removeVideoItem(item.querySelector(".qe_title").getAttribute("href")); @@ -111,6 +136,16 @@ class Player { } } + public function toggleAllItemTypes(pos:Int):Void { + main.send({ + type: ToggleItemType, + toggleItemType: { + pos: pos, + all: true + } + }); + } + public function setNextItem(pos:Int):Void { videoList.setNextItem(pos); @@ -183,11 +218,11 @@ class Player { public function setVideo(i:Int):Void { if (!main.isSyncActive) return; final item = videoList.getItem(i); + if (!item.isPlayable()) return; setSupportedPlayer(item.url, item.playerType); - removeActiveLabel(videoList.pos); videoList.setPos(i); - addActiveLabel(videoList.pos); + addActiveItemLabel(); canBePlayedSent = false; if (main.isVideoEnabled) { @@ -197,7 +232,7 @@ class Player { onCanBePlayed(); } JsApi.fireVideoChangeEvents(item); - getEl("#currenttitle").textContent = item.title; + updateTitle(); } function setExternalAudioTrack(item:VideoItem):Void { @@ -253,20 +288,36 @@ class Player { public function removeVideo():Void { JsApi.fireVideoRemoveEvents(videoList.currentItem); player.removeVideo(); - getEl("#currenttitle").textContent = Lang.get("nothingPlaying"); + updateTitle(); setPauseIndicator(false); } + public function updateTitle():Void { + final baseTitle = isListEmpty() ? Lang.get("nothingPlaying") : videoList.currentItem.title; + if (!main.lastState.paused) { + getEl("#currenttitle").textContent = baseTitle; + return; + } + var pauserName = Lang.get("server"); + if (!main.lastState.pausedByServer) { + final leader = main.getLeader(); + if (leader != null) pauserName = leader.name; + } + final prefix = Lang.get("pausedBy").replace("$NAME", pauserName); + final span = '${prefix.htmlEscape()}'; + getEl("#currenttitle").innerHTML = '$span ${baseTitle.htmlEscape()}'; + } + public function setPauseIndicator(isPause:Bool):Void { if (!main.isSyncActive) return; final state = isPause ? "pause" : "play"; - final el = getEl("#pause-indicator"); - el.setAttribute("name", state); - final el2 = getEl("#pause-indicator-portrait"); - el2.setAttribute("name", state); + final el = getEl("#pause-indicator-chat"); + el.setAttribute("name", state); var isVisible = isPause || main.hasLeader(); - el2.style.display = isVisible ? "" : "none"; + el.style.display = isVisible ? "" : "none"; + + updateTitle(); } public function onCanBePlayed():Void { @@ -415,10 +466,13 @@ class Player { public function addVideoItem(item:VideoItem, atEnd:Bool):Void { final url = item.url.htmlEscape(true); final duration = item.playerType == IframeType ? "" : duration(item.duration); - final author = item.author.htmlEscape(); + final author = item.author.htmlEscape(true); + final isIncomplete = item.isIncomplete == true; + final incompleteClass = isIncomplete ? " queue_incomplete" : ""; + final progressEl = isIncomplete ? '
    ' : ""; final itemEl = Utils.nodeFromString( - '
  • -
    + '
  • + $progressEl
    $duration

    ${item.title.htmlEscape()}

    ${Lang.get("addedBy")}: $author @@ -460,19 +514,44 @@ class Player { } } - function removeElementItem(url:String):Void { + function findItemElement(url:String):Null { for (child in videoItemsEl.children) { - if (child.querySelector(".qe_title").getAttribute("href") == url) { - videoItemsEl.removeChild(child); - break; - } + if (child.querySelector(".qe_title").getAttribute("href") == url) return child; + } + return null; + } + + function removeElementItem(url:String):Void { + final child = findItemElement(url) ?? return; + videoItemsEl.removeChild(child); + } + + public function setItemProgress(url:String, ratio:Float):Void { + final el = findItemElement(url) ?? return; + final bar = el.querySelector(".qe_progress") ?? return; + bar.style.width = '${ratio * 100}%'; + } + + public function completeItem(url:String):Void { + final index = videoList.findIndex(item -> item.url == url); + if (index == -1) return; + final item = videoList.getItem(index); + if (item.isIncomplete != true) return; + final wasPlayable = item.isPlayable(); + item.isIncomplete = false; + final el = findItemElement(url); + if (el != null) { + el.classList.remove("queue_incomplete"); + el.classList.add("queue_cached"); + final bar = el.querySelector(".qe_progress"); + if (bar != null) el.removeChild(bar); } + if (index == videoList.pos && !wasPlayable) setVideo(index); } public function skipItem(url:String):Void { final pos = videoList.findIndex(item -> item.url == url); if (pos == -1) return; - removeActiveLabel(videoList.pos); videoList.setPos(pos); if (videoList.currentItem.isTemp) removeElementItem(url); videoList.skipItem(); @@ -481,17 +560,17 @@ class Player { setVideo(videoList.pos); } - function addActiveLabel(pos:Int):Void { + function addActiveItemLabel():Void { final childs = videoItemsEl.children; - if (childs[videoList.pos] != null) { - childs[videoList.pos].classList.add("queue_active"); + final activeLabel = "queue_active"; + for (element in childs) { + if (element.classList.contains(activeLabel)) { + element.classList.remove(activeLabel); + break; + } } - } - - function removeActiveLabel(pos:Int):Void { - final childs = videoItemsEl.children; if (childs[videoList.pos] != null) { - childs[videoList.pos].classList.remove("queue_active"); + childs[videoList.pos].classList.add(activeLabel); } } @@ -513,7 +592,7 @@ class Player { } if (pos != null) videoList.setPos(pos); if (currentUrl != videoList.currentItem.url) setVideo(videoList.pos); - else addActiveLabel(videoList.pos); + else addActiveItemLabel(); } public function clearItems():Void { @@ -568,7 +647,7 @@ class Player { } public function hasVideo():Bool { - return playerEl.children.length != 0; + return playerEl.querySelector("video, iframe, embed, object") != null; } public function getDuration():Float { diff --git a/src/client/Utils.hx b/src/client/Utils.hx index 54025aa..057cb23 100644 --- a/src/client/Utils.hx +++ b/src/client/Utils.hx @@ -1,5 +1,6 @@ package client; +import haxe.Timer; import haxe.io.Mime; import js.Browser.document; import js.Browser.navigator; @@ -74,25 +75,36 @@ class Utils { } public static function hasFullscreen():Bool { - final doc:Dynamic = document; - return (document.fullscreenElement != null || doc.mozFullScreenElement != null - || doc.webkitFullscreenElement != null); + return document.fullscreenElement != null; } public static function requestFullscreen(el:Element):Bool { - final el2:Dynamic = el; if (el.requestFullscreen != null) { el.requestFullscreen(); - } else if (el2.webkitRequestFullscreen != null) { - el2.webkitRequestFullscreen(untyped Element.ALLOW_KEYBOARD_INPUT); - } else return false; - return true; + return true; + } + return false; + } + + public static function cancelFullscreen(el:Element = null):Void { + if (document.exitFullscreen != null) document.exitFullscreen(); } - public static function cancelFullscreen(el:Element):Void { - final doc:Dynamic = document; - if (doc.cancelFullScreen != null) doc.cancelFullScreen(); - else if (doc.webkitCancelFullScreen != null) doc.webkitCancelFullScreen(); + public static function switchToPageFullscreen():Void { + final docEl = document.documentElement; + if (document.fullscreenElement == docEl) return; + + if (document.fullscreenElement == null) { + requestFullscreen(docEl); + return; + } + + final promise = document.exitFullscreen(); + if (promise != null) { + promise.then(_ -> switchToPageFullscreen()).catchError(_ -> {}); + } else { + Timer.delay(() -> switchToPageFullscreen(), 1); + } } public static function toggleFullscreen(el:Element):Bool { diff --git a/src/client/players/Iframe.hx b/src/client/players/Iframe.hx index 9c24aed..ae6e6dc 100644 --- a/src/client/players/Iframe.hx +++ b/src/client/players/Iframe.hx @@ -10,6 +10,8 @@ import js.Browser; import js.html.Element; class Iframe implements IPlayer { + static final matchTwitchChannel = ~/^(?:https?:\/\/)?(?:www\.|m\.)?twitch\.tv\/([A-Za-z0-9_]+)\/?$/i; + final main:Main; final player:Player; final playerEl:Element = getEl("#ytapiplayer"); @@ -24,17 +26,40 @@ class Iframe implements IPlayer { return IframeType; } + public static function extractTwitchChannel(url:String):String { + url = url.trim(); + if (!matchTwitchChannel.match(url)) return ""; + final channel = matchTwitchChannel.matched(1); + return channel; + } + public function isSupportedLink(url:String):Bool { - return true; + final trimmed = url.trim(); + if (extractTwitchChannel(trimmed).length > 0) return true; + if (trimmed.startsWith(" Void):Void { + final rawUrl = data.url.trim(); + final channel = extractTwitchChannel(rawUrl); + if (channel.length > 0) { + final iframeStr = ''; + callback({ + duration: 99 * 60 * 60, + title: 'Twitch: $channel', + url: iframeStr + }); + return; + } + final iframe = document.createDivElement(); - iframe.innerHTML = data.url.trim(); + iframe.innerHTML = rawUrl; if (isValidIframe(iframe)) { callback({ duration: 99 * 60 * 60, title: "Iframe media", + url: rawUrl }); } else { callback({duration: 0}); diff --git a/src/client/players/Vimeo.hx b/src/client/players/Vimeo.hx new file mode 100644 index 0000000..da0b235 --- /dev/null +++ b/src/client/players/Vimeo.hx @@ -0,0 +1,302 @@ +package client.players; + +import Types.PlayerType; +import Types.VideoData; +import Types.VideoDataRequest; +import Types.VideoItem; +import client.IPlayer; +import client.JsApi; +import client.Main.getEl; +import client.Main; +import haxe.Constraints.Function; +import haxe.Http; +import haxe.Json; +import js.Browser.document; +import js.Browser.window; +import js.Syntax; +import js.html.Element; +import js.lib.Promise; + +private typedef VimeoPlayerOptions = { + ?url:String, + ?id:Int, + ?autoplay:Bool, + ?playsinline:Bool, + ?responsive:Bool, +} + +private extern class VimeoPlayer { + function play():Promise; + function pause():Promise; + function setCurrentTime(seconds:Float):Promise; + function getCurrentTime():Promise; + function getDuration():Promise; + function setVolume(volume:Float):Promise; + function getVolume():Promise; + function setMuted(muted:Bool):Promise; + function getMuted():Promise; + function setPlaybackRate(rate:Float):Promise; + function getPlaybackRate():Promise; + function destroy():Promise; + function on(event:String, callback:Function):Void; + function off(event:String, callback:Function):Void; +} + +class Vimeo implements IPlayer { + static var isApiLoaded = false; + static var isApiLoading = false; + static var apiCallbacks:Array<() -> Void> = []; + + final main:Main; + final player:Player; + final playerEl:Element = getEl("#ytapiplayer"); + var video:Element; + var vimeoPlayer:VimeoPlayer; + + var isLoaded = false; + var isPausedState = true; + var currentTime:Float = 0; + var playbackRate:Float = 1; + var volume:Float = 1; + var prevTime:Float = 0; + + public function new(main:Main, player:Player) { + this.main = main; + this.player = player; + } + + public function getPlayerType():PlayerType { + return VimeoType; + } + + final matchVimeo = ~/(?:vimeo\.com|player\.vimeo\.com)/i; + final matchVimeoId = ~/vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/[^\/]+\/videos\/|video\/|ondemand\/[^\/]+\/)?([0-9]+)/i; + + public function isSupportedLink(url:String):Bool { + return matchVimeo.match(url) && extractVideoId(url).length > 0; + } + + public function extractVideoId(url:String):String { + if (!matchVimeoId.match(url)) return ""; + return matchVimeoId.matched(1); + } + + function loadApi(callback:() -> Void):Void { + if (isApiLoaded) { + callback(); + return; + } + apiCallbacks.push(callback); + if (isApiLoading) return; + isApiLoading = true; + + JsApi.addScriptToHead("https://player.vimeo.com/api/player.js", () -> { + isApiLoaded = true; + isApiLoading = false; + for (cb in apiCallbacks) cb(); + apiCallbacks = []; + }); + } + + public function getVideoData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void { + final url = data.url; + if (!isSupportedLink(url)) { + callback({duration: 0}); + return; + } + final oembedUrl = 'https://vimeo.com/api/oembed.json?url=${url.urlEncode()}'; + final http = new Http(oembedUrl); + http.onData = text -> { + try { + final json:{?title:String, ?duration:Float} = Json.parse(text); + final title:String = json.title ?? "Vimeo video"; + final duration:Float = json.duration ?? 0; + callback({ + duration: duration, + title: title, + url: url + }); + } catch (e) { + getRemoteDataFallback(url, callback); + } + }; + http.onError = msg -> getRemoteDataFallback(url, callback); + http.request(); + } + + function getRemoteDataFallback(url:String, callback:(data:VideoData) -> Void):Void { + if (!isApiLoaded) { + loadApi(() -> getRemoteDataFallback(url, callback)); + return; + } + final tempVideo = document.createDivElement(); + tempVideo.className = "temp-videoplayer"; + tempVideo.style.display = "none"; + playerEl.appendChild(tempVideo); + + var tempPlayer:VimeoPlayer = null; + final options:VimeoPlayerOptions = {url: url}; + tempPlayer = Syntax.code("new window.Vimeo.Player({0}, {1})", tempVideo, options); + tempPlayer.on("loaded", () -> { + tempPlayer.getDuration().then(dur -> { + try { + tempPlayer.destroy(); + } catch (e) {} + if (playerEl.contains(tempVideo)) playerEl.removeChild(tempVideo); + callback({ + title: "Vimeo video", + duration: dur, + url: url + }); + }); + }); + tempPlayer.on("error", err -> { + try { + tempPlayer.destroy(); + } catch (e) {} + if (playerEl.contains(tempVideo)) playerEl.removeChild(tempVideo); + callback({duration: 0}); + }); + } + + public function loadVideo(item:VideoItem):Void { + if (!isApiLoaded) { + loadApi(() -> loadVideo(item)); + return; + } + + removeVideo(); + + video = document.createDivElement(); + video.id = "videoplayer"; + playerEl.appendChild(video); + + final options:VimeoPlayerOptions = { + url: item.url, + autoplay: true, + playsinline: true, + responsive: true + }; + + vimeoPlayer = Syntax.code("new window.Vimeo.Player({0}, {1})", video, options); + + vimeoPlayer.on("loaded", () -> { + if (!main.isAutoplayAllowed()) { + vimeoPlayer.setVolume(0); + } + isLoaded = true; + if (main.lastState.paused) { + vimeoPlayer.pause(); + } + player.onCanBePlayed(); + }); + + vimeoPlayer.on("play", () -> { + isPausedState = false; + if (!isLoaded) { + isLoaded = true; + player.onCanBePlayed(); + } + player.onPlay(); + }); + + vimeoPlayer.on("pause", () -> { + isPausedState = true; + if (!isLoaded) { + isLoaded = true; + player.onCanBePlayed(); + } + player.onPause(); + }); + + vimeoPlayer.on("timeupdate", (data:{ + seconds:Float, + percent:Float, + duration:Float + }) -> { + final sec:Float = data.seconds; + currentTime = sec; + final diff = Math.abs(prevTime - sec); + prevTime = sec; + if (diff > 1) player.onSetTime(); + }); + + vimeoPlayer.on("ratechange", (data:{playbackRate:Float}) -> { + playbackRate = data.playbackRate; + player.onRateChange(); + }); + + vimeoPlayer.on("volumechange", (data:{volume:Float}) -> { + volume = data.volume; + }); + + vimeoPlayer.on("error", data -> { + trace('Vimeo error: $data'); + }); + } + + public function removeVideo():Void { + if (video == null) return; + isLoaded = false; + isPausedState = true; + if (vimeoPlayer != null) { + try { + vimeoPlayer.destroy(); + } catch (e) { + trace(e); + } + vimeoPlayer = null; + } + if (playerEl.contains(video)) playerEl.removeChild(video); + video = null; + } + + public function isVideoLoaded():Bool { + return isLoaded; + } + + public function play():Void { + if (vimeoPlayer != null) vimeoPlayer.play(); + } + + public function pause():Void { + if (vimeoPlayer != null) vimeoPlayer.pause(); + } + + public function isPaused():Bool { + return isPausedState; + } + + public function getTime():Float { + return currentTime; + } + + public function setTime(time:Float):Void { + currentTime = time; + if (vimeoPlayer != null) vimeoPlayer.setCurrentTime(time); + } + + public function getPlaybackRate():Float { + return playbackRate; + } + + public function setPlaybackRate(rate:Float):Void { + playbackRate = rate; + if (vimeoPlayer != null) vimeoPlayer.setPlaybackRate(rate); + } + + public function getVolume():Float { + return volume; + } + + public function setVolume(vol:Float):Void { + volume = vol; + if (vimeoPlayer != null) vimeoPlayer.setVolume(vol); + } + + public function unmute():Void { + if (vimeoPlayer == null) return; + vimeoPlayer.setMuted(false); + vimeoPlayer.setVolume(1); + } +} diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index bcbf9aa..7bd42f4 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -22,6 +22,7 @@ private class HttpServerConfig { public final dir:String; public final customDir:String = null; public final allowLocalRequests = false; + public final enableCORS = false; public final cache:Cache = null; } @@ -68,6 +69,7 @@ class HttpServer { final hasCustomRes = false; final allowedLocalFiles:Map = []; final allowLocalRequests = false; + final enableCORS = false; final cache:Cache = null; final CHUNK_SIZE = 1024 * 1024 * 5; // 5 MB // temp media data while file is uploading to allow instant streaming @@ -79,6 +81,7 @@ class HttpServer { dir = config.dir; customDir = config.customDir; allowLocalRequests = config.allowLocalRequests; + enableCORS = config.enableCORS; cache = config.cache; if (customDir != null) hasCustomRes = FileSystem.exists(customDir); @@ -93,6 +96,18 @@ class HttpServer { var filePath = getPath(dir, url); final ext = Path.extension(filePath).toLowerCase(); + if (enableCORS) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("access-control-allow-headers", "content-type, range, content-name, x-client-uuid"); + res.setHeader("access-control-expose-headers", "accept-ranges, content-encoding, content-length, content-range"); + if (req.method == "OPTIONS") { + res.setHeader("access-control-allow-methods", "GET, HEAD, POST, OPTIONS"); + res.statusCode = 204; + res.end(); + return; + } + } + res.setHeader("accept-ranges", "bytes"); res.setHeader("content-type", getMimeType(ext)); @@ -255,7 +270,6 @@ class HttpServer { info: cache.notEnoughSpaceErrorText, errorId: "freeSpace", }); - cache.remove(name); req.destroy(); final client = main.clients.getByName(name) ?? return; main.serverMessage(client, cache.notEnoughSpaceErrorText); @@ -453,10 +467,16 @@ class HttpServer { } function parseRangeHeader(rangeHeader:String, videoSize:Int):{start:Int, end:Int} { - final ranges = ~/[-=]/g.split(rangeHeader); + final ranges = ~/[=-]/g.split(rangeHeader); var start = Std.parseInt(ranges[1]); - if (Utils.isOutOfRange(start, 0, videoSize - 1)) start = 0; var end = Std.parseInt(ranges[2]); + // handle file end requests like `bytes=-500`: ["bytes","","500"] + if (start == null) { + final suffix = end ?? 0; + start = (videoSize - suffix).limitMin(0); + end = videoSize - 1; + } + if (Utils.isOutOfRange(start, 0, videoSize - 1)) start = 0; if (end == null) end = start + CHUNK_SIZE; if (Utils.isOutOfRange(end, start, videoSize - 1)) end = (videoSize - 1).limitMin(0); return { diff --git a/src/server/Main.hx b/src/server/Main.hx index 12a3bfa..b55f9b0 100644 --- a/src/server/Main.hx +++ b/src/server/Main.hx @@ -25,6 +25,7 @@ import js.npm.ws.WebSocket; import json2object.ErrorUtils; import json2object.JsonParser; import server.cache.Cache; +import server.cache.CacheCallbacks; import sys.FileSystem; import sys.io.File; import utils.macro.Macro; @@ -81,7 +82,6 @@ class Main { to stopped server time. **/ var emptyRoomCallbackTimer:Null; - var isServerPause = false; var activeSkipVoteUrl:Null; final skipVotes:Map = []; @@ -135,7 +135,11 @@ class Main { consoleInput = new ConsoleInput(this); consoleInput.initConsoleInput(); cache = new Cache(this, cacheDir); - if (cache.isYtReady) playersCacheSupport.push(YoutubeType); + if (cache.isYtReady) { + playersCacheSupport.push(YoutubeType); + playersCacheSupport.push(VkType); + playersCacheSupport.push(VimeoType); + } initIntergationHandlers(); loadState(); cache.setStorageLimit(cast config.cacheStorageLimitGiB * 1024 * 1024 * 1024); @@ -203,6 +207,7 @@ class Main { dir: dir, customDir: '$userDir/res', allowLocalRequests: config.localAdmins, + enableCORS: config.enableCORS, cache: cache, }); Lang.init('$dir/langs'); @@ -302,6 +307,7 @@ class Main { if (isNoState) return config; final customPath = '$userDir/config.json'; if (!FileSystem.exists(customPath)) return config; + final defaultYtDlp = config.ytDlp; final customConfig:ServerConfig = Json.parse(File.getContent(customPath)); for (field in Reflect.fields(customConfig)) { if (Reflect.field(config, field) == null) { @@ -309,6 +315,8 @@ class Main { } Reflect.setField(config, field, Reflect.field(customConfig, field)); } + config.ytDlp.channel ??= defaultYtDlp.channel; + config.ytDlp.jsRuntime ??= defaultYtDlp.jsRuntime; final emoteCopies:Map = []; for (emote in config.emotes) { if (emoteCopies[emote.name]) trace('Warning: emote name "${emote.name}" has copy'); @@ -567,7 +575,7 @@ class Main { if (!internal) return; emptyRoomCallbackTimer?.stop(); if (clients.length == 1 && videoList.length > 0) { - if (!isServerPause) { + if (!videoTimer.isServerPause) { if (videoTimer.isPaused()) videoTimer.play(); } } @@ -590,6 +598,7 @@ class Main { } }); sendClientListExcept(client); + broadcastSkipVoteStats(); case Disconnected: if (!internal) return; @@ -600,9 +609,11 @@ class Main { if (client.isLeader) { if (videoList.length > 0) { videoTimer.pause(); - isServerPause = true; + videoTimer.isServerPause = true; } } + skipVotes.remove(client.id); + updateSkipVote(); if (clients.length == 0) { emptyRoomCallbackTimer?.stop(); emptyRoomCallbackTimer = Timer.delay(() -> { @@ -697,6 +708,7 @@ class Main { } }); sendClientListExcept(client); + broadcastSkipVoteStats(); case PasswordRequest: case LoginError: @@ -715,6 +727,7 @@ class Main { } }); sendClientListExcept(client); + broadcastSkipVoteStats(); case Message: if (!checkPermission(client, WriteChatPerm)) return; @@ -734,6 +747,20 @@ class Main { case ServerMessage: case Progress: + final p = data.progress; + final url = p.url ?? return; + final index = videoList.findIndex(i -> i.url == url); + if (index == -1) return; + final item = videoList.getItem(index); + if (item.isIncomplete != true) return; + if (item.doCache) return; + if (item.author != client.name) return; + switch p.type { + case Completed: completeIncompleteItem(url); + case Canceled: removeIncompleteItem(url); + case _: broadcast(data); + } + case AddVideo: if (isPlaylistLockedFor(client)) return; if (!checkPermission(client, AddVideoPerm)) return; @@ -766,7 +793,7 @@ class Main { videoList.addItem(item, data.addVideo.atEnd); broadcast(data); // Initial timer start if VideoLoaded is not happen - if (videoList.length == 1) restartWaitTimer(); + if (videoList.length == 1 && item.isPlayable()) restartWaitTimer(); } if (item.doCache && !client.isAdmin) { item.doCache = false; @@ -774,34 +801,45 @@ class Main { } if (!item.doCache) { addVideo(); - } else { - switch item.playerType { - case RawType: - cache.cacheRawVideo(client, item.url, (name) -> { - item = item.withUrl(cache.getFileUrl(name)); - addVideo(); - }); - case YoutubeType: - cache.cacheYoutubeVideo(client, item.url, (name) -> { - item = item.withUrl(cache.getFileUrl(name)); - if (item.duration > 1) item.duration -= 1; - addVideo(); - }); - case type: - final name = '$type'.replace("Type", ""); - serverMessage(client, 'No cache support for $name player.'); - addVideo(); - } + return; + } + final isYoutube = item.playerType == YoutubeType; + if (!playersCacheSupport.contains(item.playerType)) { + final name = '${item.playerType}'.replace("Type", ""); + serverMessage(client, 'No cache support for $name player.'); + addVideo(); + return; + } + final callbacks:CacheCallbacks = { + onResolved: finalUrl -> { + item = item.withUrl(finalUrl); + item.isIncomplete = true; + if (isYoutube && item.duration > 1) item.duration -= 1; + addVideo(); + }, + onProgress: ratio -> broadcastCacheProgress(item.url, ratio), + onComplete: () -> completeIncompleteItem(item.url), + onError: () -> removeIncompleteItem(item.url), + registerCancel: cancel -> cacheCancelers[item.url] = cancel, + onMetadata: (title, duration) -> + updateIncompleteItemMeta(item.url, title, duration), + }; + switch (item.playerType) { + case YoutubeType: + cache.cacheYoutubeVideo(client, item.url, callbacks); + case VkType, VimeoType: + cache.cacheYtdlpVideo(client, item.url, callbacks); + case _: + cache.cacheRawVideo(client, item.url, callbacks); } case VideoLoaded: // Called if client loads next video and can play it - if (isServerPause) return; + if (videoTimer.isServerPause) return; prepareVideoPlayback(); case RemoveVideo: if (videoList.length == 0) return; - if (activeSkipVoteUrl == data.removeVideo.url) resetSkipVote(); if (isPlaylistLockedFor(client)) return; if (!checkPermission(client, RemoveVideoPerm)) return; final url = data.removeVideo.url; @@ -809,17 +847,27 @@ class Main { if (index == -1) return; final isCurrent = videoList.currentItem.url == url; + if (isCurrent) resetSkipVote(); if (isCurrent && videoTimer.getTime() > FLASHBACK_DIST) { saveFlashbackTime(videoList.currentItem); } videoList.removeItem(index); + cancelCache(url); broadcast(data); - if (isCurrent && videoList.length > 0) restartWaitTimer(); + if (isCurrent && videoList.length > 0) { + if (!videoList.currentItem.isPlayable()) pauseForIncompleteItem(); + else restartWaitTimer(); + } + if (videoList.length == 0) { + videoTimer.stop(); + } case SkipVideo: if (!checkPermission(client, RemoveVideoPerm)) return; handleSkipVote(client, data); + case SkipVideoStats: + case Pause: if (videoList.length == 0) return; if (!client.isLeader) return; @@ -840,7 +888,6 @@ class Main { saveFlashbackTime(videoList.currentItem); } videoTimer.setTime(data.play.time); - isServerPause = false; videoTimer.play(); broadcast({ type: data.type, @@ -849,6 +896,7 @@ class Main { case GetTime: if (videoList.length == 0) return; + if (videoList.currentItem.isIncomplete) return; final maxTime = videoList.currentItem.duration - 0.01; if (videoTimer.getTime() > maxTime) { videoTimer.pause(); @@ -871,7 +919,7 @@ class Main { } }; if (videoTimer.isPaused()) obj.getTime.paused = true; - if (isServerPause) obj.getTime.pausedByServer = true; + if (videoTimer.isServerPause) obj.getTime.pausedByServer = true; if (videoTimer.getRate() != 1) { if (!clients.hasLeader()) videoTimer.setRate(1); else obj.getTime.rate = videoTimer.getRate(); @@ -944,7 +992,7 @@ class Main { serverMessage(client, 'Client "$clientName" not found.'); return; } - isServerPause = false; + videoTimer.isServerPause = false; clients.setLeader(clientName); broadcast({ type: SetLeader, @@ -966,9 +1014,9 @@ class Main { case PlayItem: if (!checkPermission(client, ChangeOrderPerm)) return; - resetSkipVote(); final pos = data.playItem.pos; if (!videoList.hasItem(pos)) return; + resetSkipVote(); if (videoTimer.getTime() > FLASHBACK_DIST) { saveFlashbackTime(videoList.currentItem); } @@ -991,6 +1039,17 @@ class Main { if (!checkPermission(client, ToggleItemTypePerm)) return; final pos = data.toggleItemType.pos; if (!videoList.hasItem(pos)) return; + if (data.toggleItemType.all == true) { + final targetIsTemp = !videoList.getItem(pos).isTemp; + videoList.setAllItemTypes(targetIsTemp); + broadcast({ + type: UpdatePlaylist, + updatePlaylist: { + videoList: videoList.getItems() + } + }); + return; + } videoList.toggleItemType(pos); broadcast(data); @@ -1001,8 +1060,8 @@ class Main { case ClearPlaylist: if (isPlaylistLockedFor(client)) return; - resetSkipVote(); if (!checkPermission(client, RemoveVideoPerm)) return; + resetSkipVote(); if (videoList.length != 0) { if (videoTimer.getTime() > FLASHBACK_DIST) { saveFlashbackTime(videoList.currentItem); @@ -1200,36 +1259,137 @@ class Main { } skipVotes[client.id] = true; + updateSkipVote(); + } + + function updateSkipVote():Void { + if (activeSkipVoteUrl == null) return; + if (videoList.length == 0 || videoList.currentItem.url != activeSkipVoteUrl) { + resetSkipVote(); + return; + } final votes = getSkipVoteCount(); final required = getSkipVoteRequiredCount(); + if (votes >= required) { + skipVideo({ + type: SkipVideo, + skipVideo: {url: activeSkipVoteUrl} + }); + return; + } + broadcastSkipVoteStats(); + } + + function broadcastSkipVoteStats():Void { + if (activeSkipVoteUrl == null) return; broadcast({ - type: ServerMessage, - serverMessage: { - textId: '${client.name} voted to skip (${votes}/${required}).' + type: SkipVideoStats, + skipVideoStats: { + voted: getSkipVoteCount(), + total: getSkipVoteEligibleClients().length, + required: getSkipVoteRequiredCount() } }); - - if (votes >= required) { - resetSkipVote(); - skipVideo(data); - } } function skipVideo(data:WsEvent):Void { if (videoList.length == 0) return; - resetSkipVote(); final item = videoList.currentItem; if (item.url != data.skipVideo.url) return; + resetSkipVote(); final dur = videoList.currentItem.duration; if (videoTimer.getTime() > FLASHBACK_DIST && videoTimer.getTime() < dur - FLASHBACK_DIST) { saveFlashbackTime(videoList.currentItem); } videoList.skipItem(); - if (videoList.length > 0) restartWaitTimer(); + if (videoList.length > 0) { + if (!videoList.currentItem.isPlayable()) pauseForIncompleteItem(); + else restartWaitTimer(); + } else { + videoTimer.stop(); + } broadcast(data); } + function pauseForIncompleteItem():Void { + videoTimer.stop(); + waitVideoStart?.stop(); + } + + final cacheCancelers:Map Void> = []; + + function cancelCache(url:String):Void { + final cancel = cacheCancelers[url] ?? return; + cacheCancelers.remove(url); + cancel(); + } + + function broadcastCacheProgress(url:String, ratio:Float):Void { + broadcast({ + type: Progress, + progress: { + type: Downloading, + ratio: ratio, + url: url + } + }); + } + + function updateIncompleteItemMeta(url:String, title:Null, duration:Float):Void { + final item = videoList.find(item -> item.url == url) ?? return; + if (item.isIncomplete != true) return; + var changed = false; + if (item.duration <= 0 && duration > 0) { + item.duration = duration; + changed = true; + } + if (title != null && title.length > 0 && item.title.length == 0) { + item.title = title; + changed = true; + } + if (!changed) return; + broadcast({ + type: UpdatePlaylist, + updatePlaylist: { + videoList: videoList.getItems() + } + }); + } + + function completeIncompleteItem(url:String):Void { + cacheCancelers.remove(url); + final index = videoList.findIndex(i -> i.url == url); + if (index == -1) return; + final item = videoList.getItem(index); + if (item.isIncomplete != true) return; + item.isIncomplete = false; + broadcast({ + type: Progress, + progress: { + type: Completed, + ratio: 1, + url: url + } + }); + // uploaded videos don't need timer restart + if (videoList.pos == index && item.doCache) restartWaitTimer(); + } + + function removeIncompleteItem(url:String):Void { + cacheCancelers.remove(url); + final index = videoList.findIndex(i -> i.url == url); + if (index == -1) return; + if (videoList.getItem(index).isIncomplete != true) return; + videoList.removeItem(index); + broadcast({ + type: RemoveVideo, + removeVideo: { + url: url + } + }); + } + function checkPermission(client:Client, perm:Permission):Bool { if (client.isBanned) checkBan(client); final state = client.hasPermission(perm, config.permissions); @@ -1291,7 +1451,6 @@ class Main { waitVideoStart?.stop(); loadedClientsCount = 0; broadcast({type: VideoLoaded}); - isServerPause = false; videoTimer.start(); } diff --git a/src/server/Utils.hx b/src/server/Utils.hx index eb28115..fc71b0d 100644 --- a/src/server/Utils.hx +++ b/src/server/Utils.hx @@ -70,7 +70,7 @@ class Utils { r.setEncoding("utf8"); final data = new StringBuf(); r.on("data", chunk -> data.add(chunk)); - r.on("end", () -> callback(data.toString())); + r.on("end", () -> callback(data.toString().trim())); }).on("error", onError).on("timeout", onError); } diff --git a/src/server/VideoTimer.hx b/src/server/VideoTimer.hx index 311e7f4..ac6a655 100644 --- a/src/server/VideoTimer.hx +++ b/src/server/VideoTimer.hx @@ -4,6 +4,7 @@ import haxe.Timer.stamp; class VideoTimer { public var isStarted(default, null) = false; + public var isServerPause = false; var startTime = 0.0; var pauseStartTime = 0.0; @@ -14,6 +15,7 @@ class VideoTimer { public function start():Void { isStarted = true; + isServerPause = false; startTime = stamp(); pauseStartTime = 0; rateStartTime = stamp(); @@ -21,6 +23,7 @@ class VideoTimer { public function stop():Void { isStarted = false; + isServerPause = false; startTime = 0; pauseStartTime = 0; } @@ -38,6 +41,7 @@ class VideoTimer { public function play():Void { if (!isStarted) start(); + isServerPause = false; startTime += pauseTime(); pauseStartTime = 0; rateStartTime = stamp(); diff --git a/src/server/cache/Cache.hx b/src/server/cache/Cache.hx index cfe2f66..f0fe741 100644 --- a/src/server/cache/Cache.hx +++ b/src/server/cache/Cache.hx @@ -18,6 +18,7 @@ class Cache { public final freeSpaceBlock = 10 * 1024 * 1024; // 10MB final cachedFiles:Array = []; + var ytdlpCache:YtdlpCache; var youtubeCache:YoutubeCache; var rawCache:RawCache; @@ -25,12 +26,13 @@ class Cache { this.main = main; this.cacheDir = cacheDir; Utils.ensureDir(cacheDir); - youtubeCache = new YoutubeCache(main, this); + ytdlpCache = new YtdlpCache(main, this); + youtubeCache = new YoutubeCache(main, this, ytdlpCache); rawCache = new RawCache(main, this); - isYtReady = youtubeCache.checkYtDeps(); + isYtReady = ytdlpCache.checkYtDeps(); if (isYtReady) { - youtubeCache.cleanYtInputFiles(); - youtubeCache.checkUpdate(); + ytdlpCache.cleanInputFiles(); + ytdlpCache.checkUpdate(); } } @@ -75,12 +77,31 @@ class Cache { } } - public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) { - youtubeCache.cacheYoutubeVideo(client, url, callback); + public function cacheYoutubeVideo(client:Client, url:String, callbacks:CacheCallbacks) { + youtubeCache.cacheYoutubeVideo(client, url, callbacks); } - public function cacheRawVideo(client:Client, url:String, callback:(name:String) -> Void) { - rawCache.cacheRawVideo(client, url, callback); + /** Site-specific extractor download via yt-dlp, no fallback. **/ + public function cacheYtdlpVideo(client:Client, url:String, callbacks:CacheCallbacks) { + if (!isYtReady) { + rawCache.cacheRawVideo(client, url, callbacks); + return; + } + ytdlpCache.cacheVideo(client, url, callbacks); + } + + /** + use yt-dlp if a site-specific extractor supports the url, otherwise fallback to raw download. + **/ + public function cacheRawVideo(client:Client, url:String, callbacks:CacheCallbacks) { + if (!isYtReady) { + rawCache.cacheRawVideo(client, url, callbacks); + return; + } + ytdlpCache.checkUrlSupported(url, supported -> { + if (supported) ytdlpCache.cacheVideo(client, url, callbacks); + else rawCache.cacheRawVideo(client, url, callbacks); + }); } public function setStorageLimit(bytes:Int) { @@ -105,7 +126,7 @@ class Cache { callback(storageLimit); return; } - statfs("/", (err, stats) -> { + statfs(cacheDir, (err, stats) -> { if (err != null) { trace(err); callback(storageLimit); diff --git a/src/server/cache/CacheCallbacks.hx b/src/server/cache/CacheCallbacks.hx new file mode 100644 index 0000000..28ced3c --- /dev/null +++ b/src/server/cache/CacheCallbacks.hx @@ -0,0 +1,15 @@ +package server.cache; + +typedef CacheCallbacks = { + /** Called once the final cache url is known, before download starts. **/ + final onResolved:(url:String) -> Void; + final onProgress:(ratio:Float) -> Void; + final onComplete:() -> Void; + final onError:() -> Void; + final ?registerCancel:(cancel:() -> Void) -> Void; + /** + Called when the real title/duration become known (for items added + with a zero duration, e.g. when the client could not resolve them). + **/ + final ?onMetadata:(title:Null, duration:Float) -> Void; +} diff --git a/src/server/cache/RawCache.hx b/src/server/cache/RawCache.hx index 1fb251d..3505119 100644 --- a/src/server/cache/RawCache.hx +++ b/src/server/cache/RawCache.hx @@ -27,7 +27,7 @@ class RawCache { this.cache = cache; } - public function cacheRawVideo(client:Client, url:String, callback:(name:String) -> Void) { + public function cacheRawVideo(client:Client, url:String, cb:CacheCallbacks) { final isM3U8 = url.contains(".m3u8"); final ext = isM3U8 ? "m3u8" : "mp4"; @@ -39,71 +39,72 @@ class RawCache { outName = cache.getFreeFileName(outName); if (cache.exists(outName)) { - callback(outName); + cb.onResolved(cache.getFileUrl(outName)); + cb.onComplete(); return; } trace('Caching $url to $outName...'); - main.send(client, { - type: Progress, - progress: { - type: Caching, - ratio: 0, - data: outName - } - }); + cb.onResolved(cache.getFileUrl(outName)); if (isM3U8) { - handleM3u8(client, url, outName, callback); + handleM3u8(client, url, outName, cb); } else { - handleMp4(client, url, outName, callback); + handleMp4(client, url, outName, cb); } } - function handleMp4(client:Client, url:String, outName:String, callback:(name:String) -> Void) { + function handleMp4(client:Client, url:String, outName:String, cb:CacheCallbacks) { final clientName = client.name; + var lastSentRatio = 0.0; + var canceled = false; downloadFile(client, url, outName, (downloaded, total) -> { - main.sendByName(clientName, { - type: Progress, - progress: { - type: Downloading, - ratio: (downloaded / total).clamp(0, 1).toFixed(4) - } - }); + if (canceled) return; + final ratio = (downloaded / total).clamp(0, 1); + if (ratio - lastSentRatio < 0.01 && ratio < 1) return; + lastSentRatio = ratio; + cb.onProgress(ratio.toFixed(4)); }, () -> { - cache.add(outName); - callback(outName); + if (canceled) return; + probeDuration(outName, duration -> { + if (canceled) return; + if (duration > 0 && cb.onMetadata != null) cb.onMetadata(null, duration); + cache.add(outName); + cb.onComplete(); + }); }, (err) -> { + if (canceled) return; log(clientName, 'Mp4 download failed: $err'); - cancelProgress(clientName); + cb.onError(); + }, (cancel) -> { + if (cb.registerCancel == null) return; + cb.registerCancel(() -> { + canceled = true; + cancel(); + }); }); } - function handleM3u8(client:Client, url:String, outName:String, callback:(name:String) -> Void):Void { + function handleM3u8(client:Client, url:String, outName:String, cb:CacheCallbacks):Void { final clientName = client.name; final useProxy = true; + var canceled = false; + if (cb.registerCancel != null) cb.registerCancel(() -> canceled = true); downloadM3u8Playlist(client, url, useProxy, (playlist, totalSize, segments) -> { + if (canceled) return; // only playlist file donwloaded if (useProxy) totalSize = playlist.length; if (!cache.removeOlderCache(totalSize + cache.freeSpaceBlock)) { log(clientName, cache.notEnoughSpaceErrorText); - cancelProgress(clientName); + cb.onError(); return; } if (useProxy) { - main.sendByName(clientName, { - type: Progress, - progress: { - type: Caching, - ratio: 1, - data: outName - } - }); File.saveContent('${cache.cacheDir}/$outName', playlist); cache.add(outName); - callback(outName); + cb.onComplete(); return; } @@ -128,25 +129,19 @@ class RawCache { downloaded++; final progress = downloaded / segments.length; - main.sendByName(clientName, { - type: Progress, - progress: { - type: Downloading, - ratio: progress.clamp(0, 1) - } - }); + cb.onProgress(progress.clamp(0, 1)); if (downloaded == segments.length) { trace('All ${downloaded}/${segments.length} segments downloaded'); File.saveContent('${cache.cacheDir}/$outName', playlist); cache.add(outName); - callback(outName); + cb.onComplete(); // buildTsFiles( // segments.map(item -> item.name), // outName, // client, - // callback + // cb // ); } else { downloadNextBatch(); @@ -157,7 +152,7 @@ class RawCache { activeDownloads--; downloaded++; log(clientName, 'TS segment ${segment.i} download failed: $err'); - cancelProgress(clientName); + cb.onError(); cleanupFiles(segments.map(item -> item.name)); } ); @@ -168,7 +163,7 @@ class RawCache { downloadNextBatch(); }, (err) -> { log(clientName, 'M3U8 processing failed: $err'); - cancelProgress(clientName); + cb.onError(); }); } @@ -247,6 +242,7 @@ class RawCache { // Head request can return full stream size, so lets do loose assumption final req = request(segments[0].url, {method: Get}, (res:IncomingMessage) -> { final contentLength = Std.parseInt(res.headers["content-length"]) ?? 0; + res.destroy(); final totalSize = contentLength * (segments.length + 1); if (totalSize == 0) { onError("Failed to get segment sizes: no content-length"); @@ -272,7 +268,8 @@ class RawCache { client:Client, url:String, fileName:String, onProgress:(downloaded:Int, total:Int) -> Void, onComplete:() -> Void, - onError:(err:String) -> Void + onError:(err:String) -> Void, + ?onCancelable:(cancel:() -> Void) -> Void ):Void { final outPath = '${cache.cacheDir}/$fileName'; final file = Fs.createWriteStream(outPath); @@ -325,16 +322,22 @@ class RawCache { onError('Request failed: $err'); }); + if (onCancelable != null) onCancelable(() -> { + req.destroy(); + file.destroy(); + if (FileSystem.exists(outPath)) FileSystem.deleteFile(outPath); + }); + req.end(); } - function buildTsFiles(tempFiles:Array, outName:String, client:Client, callback:String->Void) { + function buildTsFiles(tempFiles:Array, outName:String, client:Client, cb:CacheCallbacks) { final clientName = client.name; final missingFiles = tempFiles.filter(f -> !FileSystem.exists('${cache.cacheDir}/$f')); if (missingFiles.length > 0) { log(clientName, 'Concatenation failed: ${missingFiles.length} segments are missing'); - cancelProgress(clientName); + cb.onError(); cleanupFiles(tempFiles); return; } @@ -376,7 +379,7 @@ class RawCache { final timeoutId = js.Node.setTimeout(() -> { process.kill(); log(clientName, 'FFmpeg process timed out after ${timeout / 1000} seconds'); - cancelProgress(clientName); + cb.onError(); cleanupFiles(tempFiles.concat([concatFile])); }, timeout); @@ -393,28 +396,16 @@ class RawCache { final admins = main.clients.filter(client -> client.isAdmin); for (admin in admins) main.serverMessage(admin, ffmpegErr); - main.send(client, { - type: Progress, - progress: { - type: Canceled, - ratio: 1 - } - }); + cb.onError(); } else { // Verify the output file exists and has content if (FileSystem.exists('${cache.cacheDir}/$outName') && FileSystem.stat('${cache.cacheDir}/$outName').size > 0) { cache.add(outName); - callback(outName); + cb.onComplete(); } else { log(clientName, 'FFmpeg process completed but output file is missing or empty'); - main.send(client, { - type: Progress, - progress: { - type: Canceled, - ratio: 1 - } - }); + cb.onError(); } } @@ -426,34 +417,37 @@ class RawCache { process.on("error", (err) -> { js.Node.clearTimeout(timeoutId); log(clientName, 'Failed to start FFmpeg: $err'); - main.send(client, { - type: Progress, - progress: { - type: Canceled, - ratio: 1 - } - }); + cb.onError(); cleanupFiles(tempFiles.concat([concatFile])); }); } + function probeDuration(fileName:String, callback:(duration:Float) -> Void):Void { + final path = '${cache.cacheDir}/$fileName'; + final args = [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path + ]; + ChildProcess.execFile("ffprobe", args, (err, stdout, _) -> { + if (err != null) { + callback(0); + return; + } + final duration = Std.parseFloat(Std.string(stdout).trim()); + callback(Math.isNaN(duration) ? 0 : duration); + }); + } + function cleanupFiles(files:Array):Void { for (file in files) { - if (FileSystem.exists(file)) FileSystem.deleteFile(file); + final path = '${cache.cacheDir}/$file'; + if (FileSystem.exists(path)) FileSystem.deleteFile(path); } } function log(clientName:String, msg:String):Void { cache.logByName(clientName, msg); } - - function cancelProgress(clientName:String):Void { - main.sendByName(clientName, { - type: Progress, - progress: { - type: Canceled, - ratio: 0 - } - }); - } } diff --git a/src/server/cache/YoutubeCache.hx b/src/server/cache/YoutubeCache.hx index 09ab67b..50bd37c 100644 --- a/src/server/cache/YoutubeCache.hx +++ b/src/server/cache/YoutubeCache.hx @@ -1,54 +1,24 @@ package server.cache; import haxe.io.Path; -import js.lib.Promise; -import js.node.ChildProcess; +import js.node.child_process.ChildProcess as ChildProcessObject; import sys.FileSystem; import utils.YoutubeUtils; import ytdlp_nodejs.VideoFormat; import ytdlp_nodejs.VideoInfo; -import ytdlp_nodejs.YtDlp; class YoutubeCache { final main:Main; final cache:Cache; - var ytDlp:Null; + final ytdlp:YtdlpCache; - public function new(main:Main, cache:Cache):Void { + public function new(main:Main, cache:Cache, ytdlp:YtdlpCache):Void { this.main = main; this.cache = cache; + this.ytdlp = ytdlp; } - public function checkYtDeps():Bool { - try { - ChildProcess.execSync("ffmpeg -version", {stdio: "ignore", timeout: 5000}); - ytDlp = js.Syntax.code("new (require('ytdlp-nodejs')).YtDlp()"); - return true; - } catch (e) { - return false; - } - } - - public function checkUpdate():Void { - ytDlp.execAsync("", { - updateTo: main.config.ytDlp.channel, - onData: d -> { - trace(d); - } - }).catchError(e -> { - trace(e); - }); - } - - public function cleanYtInputFiles(prefix = "__tmp"):Void { - final names = FileSystem.readDirectory(cache.cacheDir); - for (name in names) { - if (!name.startsWith(prefix)) continue; - cache.remove(name); - } - } - - public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) { + public function cacheYoutubeVideo(client:Client, url:String, cb:CacheCallbacks) { if (!cache.isYtReady) { trace("Do `npm i https://github.com/RblSb/ytdlp-nodejs` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks)."); return; @@ -59,20 +29,23 @@ class YoutubeCache { log(clientName, 'Error: youtube video id not found in url: $url'); return; } + // to prevent playlist in url handlings + url = 'https://youtu.be/$videoId'; final outName = videoId + ".mp4"; if (cache.exists(outName)) { - callback(outName); + cb.onResolved(cache.getFileUrl(outName)); + cb.onComplete(); return; } final inVideoName = '__tmp-video-$videoId'; inline function removeInputFiles():Void { - cleanYtInputFiles(inVideoName); + ytdlp.cleanInputFiles(inVideoName); } inline function checkEnoughSpace(contentLength:Int):Bool { final hasSpace = cache.removeOlderCache(contentLength + cache.freeSpaceBlock); if (!hasSpace) { removeInputFiles(); - cancelProgress(clientName); + cb.onError(); log(clientName, cache.notEnoughSpaceErrorText); } return hasSpace; @@ -83,18 +56,21 @@ class YoutubeCache { return; } trace('Caching $url to $outName...'); - main.sendByName(clientName, { - type: Progress, - progress: { - type: Caching, - ratio: 0, - data: outName - } - }); + cb.onResolved(cache.getFileUrl(outName)); var useCookies = false; + var lastSentRatio = 0.0; + var canceled = false; + var process:Null = null; + if (cb.registerCancel != null) cb.registerCancel(() -> { + canceled = true; + process?.kill(); + removeInputFiles(); + }); function onGetInfo(info:VideoInfo):Void { + if (canceled) return; + if (cb.onMetadata != null) cb.onMetadata(info.title, info.duration); trace('Get info with ${info.formats.length} formats'); var aformats = info.formats.filter(f -> f.acodec != "none" && f.vcodec == "none" && f.format_note?.contains("original")); @@ -154,16 +130,16 @@ class YoutubeCache { var videoRatioCache = 0.0; var audioRatioCache = 0.0; - final dlVideo:Promise = ytDlp.downloadAsync(url, { + process = ytdlp.download(url, { format: formatIds, output: '${cache.cacheDir}/$inVideoName', remuxVideo: "mp4", - additionalOptions: ["--no-js-runtimes", "--js-runtimes", main.config.ytDlp.jsRuntime], - // verbose: true, - cookies: useCookies ? getCookiesPathOrNull() : null, + additionalOptions: ytdlp.ytExtraOptions(), + cookies: useCookies ? ytdlp.getCookiesPathOrNull() : null, forceIpv4: true, socketTimeout: 2, extractorRetries: 0, + }, { onProgress: p -> { final isFinished = p.status == "finished"; if (isFinished) { @@ -182,58 +158,48 @@ class YoutubeCache { ratio = videoRatioCache * videoSizeRatio + audioRatioCache * audioSizeRatio; - main.sendByName(clientName, { - type: Progress, - progress: { - type: Downloading, - ratio: ratio.toFixed(4) - } - }); - } - }).catchError(err -> { - final err = "Error during video download: " + err; - cache.logWithAdmins(client, err); - removeInputFiles(); - cancelProgress(clientName); - }); - - dlVideo.then((v) -> { - final name = cache.findFile(n -> n.startsWith(inVideoName)) ?? { - final err = 'Error: cannot find downloaded file with prefix $inVideoName'; + if (canceled) return; + if (ratio - lastSentRatio < 0.01 && ratio < 1) return; + lastSentRatio = ratio; + cb.onProgress(ratio.toFixed(4)); + }, + onComplete: () -> { + if (canceled) { + removeInputFiles(); + return; + } + final name = cache.findFile(n -> n.startsWith(inVideoName)) ?? { + final err = 'Error: cannot find downloaded file with prefix $inVideoName'; + cache.logWithAdmins(client, err); + cb.onError(); + return; + }; + FileSystem.rename('${cache.cacheDir}/$name', '${cache.cacheDir}/$outName'); + removeInputFiles(); + cache.add(outName); + cb.onComplete(); + }, + onError: err -> { + if (canceled) return; + final err = "Error during video download: " + err; cache.logWithAdmins(client, err); - cancelProgress(clientName); - return; - }; - FileSystem.rename('${cache.cacheDir}/$name', '${cache.cacheDir}/$outName'); - removeInputFiles(); - cache.add(outName); - callback(outName); + removeInputFiles(); + cb.onError(); + } }); } - getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> { + ytdlp.getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> { trace(err); useCookies = true; - getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> { + ytdlp.getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> { removeInputFiles(); - cancelProgress(clientName); + cb.onError(); log(clientName, "" + err); }); }); } - function getInfoAsync(url:String, useCookies = false):Promise { - return cast ytDlp.getInfoAsync(url, cast { - cookies: useCookies ? getCookiesPathOrNull() : null, - additionalOptions: ["--no-js-runtimes", "--js-runtimes", main.config.ytDlp.jsRuntime], - }); - } - - function getCookiesPathOrNull():Null { - final cookiesPath = '${main.userDir}/cookies.txt'; - return FileSystem.exists(cookiesPath) ? cookiesPath : null; - } - function getBestYoutubeVideoFormat(formats:Array, ?ignoreQualities:Array):Null { final qPriority = [1080, 720, 480, 360, 240, 144]; if (ignoreQualities != null) { @@ -274,14 +240,4 @@ class YoutubeCache { function log(clientName:String, msg:String):Void { cache.logByName(clientName, msg); } - - function cancelProgress(clientName:String):Void { - main.sendByName(clientName, { - type: Progress, - progress: { - type: Canceled, - ratio: 0 - } - }); - } } diff --git a/src/server/cache/YtdlpCache.hx b/src/server/cache/YtdlpCache.hx new file mode 100644 index 0000000..31df7b2 --- /dev/null +++ b/src/server/cache/YtdlpCache.hx @@ -0,0 +1,227 @@ +package server.cache; + +import haxe.io.Path; +import js.lib.Promise; +import js.node.ChildProcess; +import js.node.child_process.ChildProcess as ChildProcessObject; +import sys.FileSystem; +import ytdlp_nodejs.ArgsOptions; +import ytdlp_nodejs.VideoInfo; +import ytdlp_nodejs.VideoProgress; +import ytdlp_nodejs.YtDlp; + +typedef YtdlpHandlers = { + final onProgress:(p:VideoProgress) -> Void; + final onComplete:() -> Void; + final onError:(err:String) -> Void; +} + +class YtdlpCache { + final main:Main; + final cache:Cache; + var ytDlp:Null; + + public function new(main:Main, cache:Cache):Void { + this.main = main; + this.cache = cache; + } + + public function checkYtDeps():Bool { + try { + ChildProcess.execSync("ffmpeg -version", {stdio: "ignore", timeout: 5000}); + ytDlp = js.Syntax.code("new (require('ytdlp-nodejs')).YtDlp()"); + return true; + } catch (e) { + return false; + } + } + + public function checkUpdate():Void { + ytDlp.execAsync("", { + updateTo: main.config.ytDlp.channel, + onData: d -> { + trace(d); + } + }).catchError(e -> { + trace(e); + }); + } + + public function cleanInputFiles(prefix = "__tmp"):Void { + final names = FileSystem.readDirectory(cache.cacheDir); + for (name in names) { + if (!name.startsWith(prefix)) continue; + cache.remove(name); + } + } + + public function getInfoAsync(url:String, useCookies = false):Promise { + return cast ytDlp.getInfoAsync(url, cast { + cookies: useCookies ? getCookiesPathOrNull() : null, + additionalOptions: ytExtraOptions(), + }); + } + + public function getCookiesPathOrNull():Null { + final cookiesPath = '${main.userDir}/cookies.txt'; + return FileSystem.exists(cookiesPath) ? cookiesPath : null; + } + + public function ytExtraOptions():Array { + return ["--no-js-runtimes", "--js-runtimes", main.config.ytDlp.jsRuntime]; + } + + /** + Runs `yt-dlp --ies default,-generic` to check if a site-specific + extractor handles the url. Returns the process so it can be killed on cancel. + **/ + public function checkUrlSupported(url:String, callback:(supported:Bool) -> Void):ChildProcessObject { + final process:ChildProcessObject = ytDlp.exec(url, { + useExtractors: ["default", "-generic"], + simulate: true, + quiet: true, + socketTimeout: 5, + extractorRetries: 0, + }); + var done = false; + inline function finish(supported:Bool):Void { + if (done) return; + done = true; + callback(supported); + } + process.on("close", (code:Int, _signal:String) -> finish(code == 0)); + process.on("error", (_err:Dynamic) -> finish(false)); + return process; + } + + /** + Spawns yt-dlp as a manual process and wires progress/completion to `handlers`. + Returns the process so the caller can kill it on cancel. + **/ + public function download(url:String, options:ArgsOptions, handlers:YtdlpHandlers):ChildProcessObject { + final process:ChildProcessObject = ytDlp.exec(url, options); + var stderr = ""; + var done = false; + process.stderr.on("data", (d:Dynamic) -> stderr += d); + process.on("progress", (p:VideoProgress) -> handlers.onProgress(p)); + process.on("close", (code:Int, _signal:String) -> { + if (done) return; + done = true; + if (code == 0) handlers.onComplete(); + else handlers.onError('yt-dlp exited with code $code: $stderr'); + }); + process.on("error", (err:Dynamic) -> { + if (done) return; + done = true; + handlers.onError('Failed to start yt-dlp process: $err'); + }); + return process; + } + + /** + Generic best-quality download to mp4 for vk and other site-specific + extractors. Used after `checkUrlSupported` succeeds (or directly for vk). + **/ + public function cacheVideo(client:Client, url:String, cb:CacheCallbacks):Void { + final clientName = client.name; + final outName = deriveOutName(url); + trace('Caching $url to $outName...'); + cb.onResolved(cache.getFileUrl(outName)); + + final inName = '__tmp-${Std.random(0xFFFFFF)}-${Path.withoutExtension(outName)}'; + var canceled = false; + var process:Null = null; + inline function cleanup():Void { + cleanInputFiles(inName); + } + if (cb.registerCancel != null) cb.registerCancel(() -> { + canceled = true; + process?.kill(); + cleanup(); + }); + + var useCookies = false; + var lastSentRatio = 0.0; + + function onGetInfo(info:VideoInfo):Void { + if (canceled) return; + if (cb.onMetadata != null) cb.onMetadata(info.title, info.duration); + if (!cache.removeOlderCache(estimateSize(info) * 2 + cache.freeSpaceBlock)) { + cleanup(); + cb.onError(); + log(clientName, cache.notEnoughSpaceErrorText); + return; + } + process = download(url, { + output: '${cache.cacheDir}/$inName', + remuxVideo: "mp4", + additionalOptions: ytExtraOptions(), + cookies: useCookies ? getCookiesPathOrNull() : null, + forceIpv4: true, + socketTimeout: 5, + extractorRetries: 0, + }, { + onProgress: p -> { + if (canceled) return; + final ratio = p.status == "finished" ? 1.0 : (p.downloaded / p.total) + .clamp(0, 1); + if (ratio - lastSentRatio < 0.01 && ratio < 1) return; + lastSentRatio = ratio; + cb.onProgress(ratio.toFixed(4)); + }, + onComplete: () -> { + if (canceled) { + cleanup(); + return; + } + final name = cache.findFile(n -> n.startsWith(inName)) ?? { + final err = 'Error: cannot find downloaded file with prefix $inName'; + cache.logWithAdmins(client, err); + cb.onError(); + return; + }; + FileSystem.rename('${cache.cacheDir}/$name', '${cache.cacheDir}/$outName'); + cleanup(); + cache.add(outName); + cb.onComplete(); + }, + onError: err -> { + if (canceled) return; + cache.logWithAdmins(client, "Error during video download: " + err); + cleanup(); + cb.onError(); + } + }); + } + + getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> { + if (canceled) return; + useCookies = true; + getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> { + cleanup(); + cb.onError(); + log(clientName, "" + err); + }); + }); + } + + function deriveOutName(url:String):String { + final decoded = try url.urlDecode() catch (e) url; + var last = decoded.substr(decoded.lastIndexOf("/") + 1); + final q = last.indexOf("?"); + if (q != -1) last = last.substr(0, q); + last = Path.withoutExtension(last); + if (last == "") last = "video"; + return cache.getFreeFileName('$last.mp4'); + } + + function estimateSize(info:VideoInfo):Int { + final d:Dynamic = info; + final size:Null = d.filesize_approx ?? d.filesize; + return Std.int(size ?? 0); + } + + function log(clientName:String, msg:String):Void { + cache.logByName(clientName, msg); + } +} diff --git a/test/tests/TestServer.hx b/test/tests/TestServer.hx index 2d0b71d..2bd4a3a 100644 --- a/test/tests/TestServer.hx +++ b/test/tests/TestServer.hx @@ -1,5 +1,6 @@ package test.tests; +import Types.ProgressType; import Types.WsEvent; import Types.WsEventType; import haxe.Json; @@ -19,6 +20,7 @@ class TestServer extends Test { @:timeout(500) function testBadRequests(async:Async) { final server = new Main({loadState: false}); + server.config.gatePassword = ""; server.onServerInited = () -> { final url = 'http://${server.localIp}:${server.port}'; request('$url/你好,世界!@$^&*)_+-=', data -> { @@ -52,6 +54,66 @@ class TestServer extends Test { return ~/\r?\n/g.split(text); } + @:timeout(2000) + function testIncompleteUpload(async:Async) { + final server = new Main({loadState: false}); + server.onServerInited = () -> { + final client = new FakeClient(server.localIp, server.port); + final url = "/cache/upload-test.mp4"; + client.message().then(data -> { + Assert.equals(Connected, data.type); + client.name = data.connected.clientName; + return client.send({ + type: AddVideo, + addVideo: { + item: { + url: url, + title: "upload", + author: "", + duration: 30, + isTemp: false, + playerType: RawType, + doCache: false, + isIncomplete: true + }, + atEnd: true + } + }); + }).then(data -> { + Assert.equals(AddVideo, data.type); + Assert.isTrue(data.addVideo.item.isIncomplete == true); + Assert.equals(1, server.videoList.length); + // uploads stream while incomplete, so they stay playable + Assert.isTrue(server.videoList.getItem(0).isPlayable()); + return client.send({ + type: Progress, + progress: { + type: Uploading, + ratio: 0.5, + url: url + } + }); + }).then(data -> { + Assert.equals(Progress, data.type); + Assert.equals(ProgressType.Uploading, data.progress.type); + Assert.equals(0.5, data.progress.ratio); + return client.send({ + type: Progress, + progress: { + type: Completed, + ratio: 1, + url: url + } + }); + }).then(data -> { + Assert.equals(Progress, data.type); + Assert.equals(ProgressType.Completed, data.progress.type); + Assert.isFalse(server.videoList.getItem(0).isIncomplete == true); + async.done(); + }); + } + } + function request(url:String, onComplete:(data:String) -> Void):Void { Http.get(url, r -> { r.setEncoding("utf8"); diff --git a/test/tests/TestSkipVote.hx b/test/tests/TestSkipVote.hx new file mode 100644 index 0000000..d4e9c8d --- /dev/null +++ b/test/tests/TestSkipVote.hx @@ -0,0 +1,151 @@ +package test.tests; + +import Types.Permission; +import Types.ServerConfig; +import Types.WsEvent; +import Types.WsEventType; +import haxe.Json; +import server.Main; +import server.VideoTimer; +import utest.Assert; +import utest.Test; + +@:access(server.Main) +class TestSkipVote extends Test { + var server:Main; + var events:Array; + + function setup():Void { + // Keep voting tests independent of HTTP ports, files and playback timers. + server = Type.createEmptyInstance(Main); + events = []; + final permissions:Array = [RemoveVideoPerm]; + final config:ServerConfig = cast { + permissions: { + banned: [], guest: permissions, user: permissions, + leader: permissions, admin: permissions + } + }; + Reflect.setField(server, "config", config); + Reflect.setField(server, "clients", []); + Reflect.setField(server, "skipVotes", new Map()); + Reflect.setField(server, "videoTimer", new VideoTimer()); + final list = new VideoList(); + list.addItem({ + url: "video", title: "Video", author: "Queuer", duration: 60, + isTemp: true, doCache: false, playerType: RawType + }, true); + Reflect.setField(server, "videoList", list); + } + + function addClient(name:String, id:Int):Client { + final ws:Dynamic = { + send: (text:String, options:Dynamic) -> events.push(Json.parse(text)) + }; + final client = new Client(cast ws, null, id, name, 0); + server.clients.push(client); + return client; + } + + function vote(client:Client, url = "video"):Void { + server.handleSkipVote(client, {type: SkipVideo, skipVideo: {url: url}}); + } + + function testGiveupCommand():Void { + final parsed = client.Commands.parse("/giveup Guest 2"); + Assert.equals(client.Commands.CommandId.Giveup, parsed.cmd.name); + Assert.equals(1, client.Commands.filterForAutocomplete("/give").length); + } + + function testStrictMajorityWithLiveStats():Void { + final first = addClient("First", 0); + final second = addClient("Second", 1); + final third = addClient("Third", 2); + addClient("Fourth", 3); + vote(first); + Assert.equals(1, events[0].skipVideoStats.voted); + Assert.equals(4, events[0].skipVideoStats.total); + Assert.equals(3, events[0].skipVideoStats.required); + vote(second); + Assert.equals(1, server.videoList.length); + vote(third); + Assert.equals(0, server.videoList.length); + Assert.equals(SkipVideo, events[events.length - 1].type); + Assert.isNull(server.activeSkipVoteUrl); + } + + function testDuplicateAndStaleVotes():Void { + final first = addClient("First", 0); + addClient("Second", 1); + vote(first); + vote(first); + vote(first, "previous-video"); + server.skipVideo({type: SkipVideo, skipVideo: {url: "previous-video"}}); + Assert.equals(1, server.getSkipVoteCount()); + Assert.equals("video", server.activeSkipVoteUrl); + Assert.equals(1, server.videoList.length); + } + + function testAdminOverride():Void { + final admin = addClient("Admin", 0); + admin.isAdmin = true; + addClient("Second", 1); + addClient("Third", 2); + vote(admin); + Assert.equals(0, server.videoList.length); + } + + function testQueuerOverrideDuringVote():Void { + final first = addClient("First", 0); + final queuer = addClient("Queuer", 1); + addClient("Third", 2); + vote(first); + vote(queuer); + Assert.equals(0, server.videoList.length); + Assert.equals(0, server.getSkipVoteCount()); + } + + function testBannedClientsExcluded():Void { + final first = addClient("First", 0); + addClient("Second", 1); + addClient("Banned", 2).isBanned = true; + vote(first); + Assert.equals(2, events[0].skipVideoStats.total); + Assert.equals(2, events[0].skipVideoStats.required); + } + + function testDisconnectCanCompleteMajority():Void { + final first = addClient("First", 0); + final second = addClient("Second", 1); + addClient("Third", 2); + final departing = addClient("Fourth", 3); + vote(first); + vote(second); + server.clients.remove(departing); + server.skipVotes.remove(departing.id); + server.updateSkipVote(); + Assert.equals(0, server.videoList.length); + } + + function testDepartingVoterAndReusedId():Void { + final first = addClient("First", 0); + addClient("Second", 1); + addClient("Third", 2); + vote(first); + server.clients.remove(first); + server.skipVotes.remove(first.id); + server.updateSkipVote(); + addClient("Replacement", 0); + Assert.equals(0, server.getSkipVoteCount()); + Assert.equals(1, server.videoList.length); + } + + function testRenamingDoesNotDuplicateVote():Void { + final first = addClient("First", 0); + addClient("Second", 1); + vote(first); + first.name = "Renamed"; + vote(first); + Assert.equals(1, server.getSkipVoteCount()); + } +} -- cgit v1.2.3