diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-19 15:32:53 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-19 15:32:53 -0700 |
| commit | 304bd1e77a5442f614aacd2b35f330777118b2cd (patch) | |
| tree | 970e7da13bb1bd8edf1f9b453af4d0994c665e94 | |
| parent | a4f3064542ddad6939598e7e60ca0090be07df60 (diff) | |
37 files changed, 4767 insertions, 1203 deletions
@@ -21,5 +21,4 @@ RUN haxelib setup /app \ && haxelib install all --always && \ haxe build-all.hxml -ENTRYPOINT [ "npm", "start" ] - +ENTRYPOINT [ "node", "build/server.js" ] @@ -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 <new-leader>`)
- 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 <name>` - 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<VideoItem> }>, ?updateClients : Null<{ clients : Array<ClientData> }>, 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<String> }>, ?playItem : Null<{ pos : Int }>, ?play : Null<{ time : Float }>, ?pause : Null<{ time : Float }>, ?message : Null<{ text : String, clientName : String }>, ?logout : Null<{ oldClientName : String, clients : Array<ClientData>, clientName : String }>, ?login : Null<{ ?passHash : Null<String>, ?isUnknownClient : Null<Bool>, ?clients : Null<Array<ClientData>>, clientName : String }>, ?kickClient : Null<{ name : String }>, ?getTime : Null<GetTimeEvent>, ?dump : Null<{ data : String }>, ?connected : Null<{ videoList : Array<VideoItem>, uuid : String, playersCacheSupport : Array<PlayerType>, itemPos : Int, isUnknownClient : Bool, isPlaylistOpen : Bool, history : Array<Message>, globalIp : String, config : Config, clients : Array<ClientData>, 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<VideoItem> }>, ?updateClients : Null<{ clients : Array<ClientData> }>, type : WsEventType, ?togglePlaylistLock : Null<{ isOpen : Bool }>, ?toggleItemType : Null<{ pos : Int, ?all : Null<Bool> }>, ?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<String>, type : ProgressType, ratio : Float, ?data : Null<String> }>, ?playItem : Null<{ pos : Int }>, ?play : Null<{ time : Float }>, ?pause : Null<{ time : Float }>, ?message : Null<{ text : String, clientName : String }>, ?logout : Null<{ oldClientName : String, clients : Array<ClientData>, clientName : String }>, ?login : Null<{ ?passHash : Null<String>, ?isUnknownClient : Null<Bool>, ?clients : Null<Array<ClientData>>, clientName : String }>, ?kickClient : Null<{ name : String }>, ?getTime : Null<GetTimeEvent>, ?dump : Null<{ data : String }>, ?connected : Null<{ videoList : Array<VideoItem>, uuid : String, playersCacheSupport : Array<PlayerType>, itemPos : Int, isUnknownClient : Bool, isPlaylistOpen : Bool, history : Array<Message>, globalIp : String, config : Config, clients : Array<ClientData>, 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<Bool> }",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<String>, url : String, title : String, ?subs : Null<String>, playerType : PlayerType, isTemp : Bool, duration : Float, doCache : Bool, author : String }",pos)); + this.errors.push(json2object_Error.IncorrectType(variable,"{ ?voiceOverTrack : Null<String>, url : String, title : String, ?subs : Null<String>, playerType : PlayerType, isTemp : Bool, ?isIncomplete : Null<Bool>, 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<String> }",pos)); + this.errors.push(json2object_Error.IncorrectType(variable,"{ ?url : Null<String>, type : ProgressType, ratio : Float, ?data : Null<String> }",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 = "<span>" + Lang.get("hotkeys") + "</span><div class=\"toggle-switch\"></div>"; + 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 = "<span>" + Lang.get("pageFullscreen") + "</span><div class=\"toggle-switch\"></div>"; + switchEl = btn.querySelector(".toggle-switch"); + } + switchEl.classList.toggle("on",client_Buttons.settings.pageFullscreen); }; -client_Buttons.initChatInputs = function(main) { +client_Buttons.updateFullscreenActionBtn = function() { + var btn = window.document.querySelector("#fullscreenActionBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if(switchEl == null) { + btn.innerHTML = "<span>" + Lang.get(client_Utils.isTouch() ? "fullscreenActionTouch" : "fullscreenActionDesktop") + "</span><div class=\"toggle-switch\"></div>"; + 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 + " <span class=\"hotkey-hint\">(Alt-U)</span>"; + } +}; +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 "<span class=\"chat-command-arg-active\">" + token + "</span>"; } else { - el.classList.remove("mobile-view"); + return token; } - }; + }); + return new EReg("`([^`]+)`","g").map(desc,function(reg) { + return "<span class=\"chat-command-code\">" + reg.matched(1) + "</span>"; + }); }; 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("<span class=\"" + klass + "\">" + client.name + "</span></div>"); + list_b += Std.string("<span class=\"" + klass + "\">" + StringTools.htmlEscape(client.name) + "</span></div>"); } 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(); - return false; - case "ban": - this.mergeRedundantArgs(args,0,2); - var name = args[0]; - var time = this.parseSimpleDate(args[1]); - if(time < 0) { - return true; + 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)}}); } - 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") { + } 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; - } - if(wasNotFull && v > 1) { - this.serverMessage("Volume was not maxed yet to be boosted, you can send command again."); + 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; + case "/help": + this.showChatHintList(); + return true; + 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 = "" + ("<span class=\"paused-by\">" + StringTools.htmlEscape(StringTools.replace(Lang.get("pausedBy"),"$NAME",pauserName)) + "</span>") + " "; + 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("<li class=\"queue_entry info\" title=\"" + Lang.get("addedBy") + ": " + author + "\">\n\t\t\t\t<header>\n\t\t\t\t\t<span class=\"qe_time\">" + duration + "</span>\n\t\t\t\t\t<h4><a class=\"qe_title\" href=\"" + url + "\" target=\"_blank\">" + StringTools.htmlEscape(item.title) + "</a></h4>\n\t\t\t\t\t<small class=\"qe_author\">" + Lang.get("addedBy") + ": " + author + "</small>\n\t\t\t\t</header>\n\t\t\t\t<span class=\"controls\">\n\t\t\t\t\t<button class=\"qbtn-play\" title=\"" + Lang.get("play") + "\"><ion-icon name=\"play\"></ion-icon></button>\n\t\t\t\t\t<button class=\"qbtn-next\" title=\"" + Lang.get("setNext") + "\"><ion-icon name=\"arrow-up\"></ion-icon></button>\n\t\t\t\t\t<button class=\"qbtn-tmp\"><ion-icon></ion-icon></button>\n\t\t\t\t\t<button class=\"qbtn-delete\" title=\"" + Lang.get("delete") + "\"><ion-icon name=\"close\"></ion-icon></button>\n\t\t\t\t</span>\n\t\t\t</li>"); + var author = StringTools.htmlEscape(item.author,true); + var isIncomplete = item.isIncomplete == true; + var itemEl = client_Utils.nodeFromString("<li class=\"queue_entry info" + (isIncomplete ? " queue_incomplete" : "") + "\" title=\"" + Lang.get("addedBy") + ": " + author + "\">\n\t\t\t\t" + (isIncomplete ? "<div class=\"qe_progress\"></div>" : "") + "<header>\n\t\t\t\t\t<span class=\"qe_time\">" + duration + "</span>\n\t\t\t\t\t<h4><a class=\"qe_title\" href=\"" + url + "\" target=\"_blank\">" + StringTools.htmlEscape(item.title) + "</a></h4>\n\t\t\t\t\t<small class=\"qe_author\">" + Lang.get("addedBy") + ": " + author + "</small>\n\t\t\t\t</header>\n\t\t\t\t<span class=\"controls\">\n\t\t\t\t\t<button class=\"qbtn-play\" title=\"" + Lang.get("play") + "\"><ion-icon name=\"play\"></ion-icon></button>\n\t\t\t\t\t<button class=\"qbtn-next\" title=\"" + Lang.get("setNext") + "\"><ion-icon name=\"arrow-up\"></ion-icon></button>\n\t\t\t\t\t<button class=\"qbtn-tmp\"><ion-icon></ion-icon></button>\n\t\t\t\t\t<button class=\"qbtn-delete\" title=\"" + Lang.get("delete") + "\"><ion-icon name=\"close\"></ion-icon></button>\n\t\t\t\t</span>\n\t\t\t</li>"); 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,"<iframe") || StringTools.startsWith(trimmed,"<object")) { + return true; + } + return false; } ,getVideoData: function(data,callback) { + var rawUrl = StringTools.trim(data.url); + var channel = client_players_Iframe.extractTwitchChannel(rawUrl); + if(channel.length > 0) { + callback({ duration : 356400, title : "Twitch: " + channel, url : "<iframe src=\"https://player.twitch.tv/?channel=" + channel + "&parent=www.example.com\" frameborder=\"0\" allowfullscreen=\"true\" scrolling=\"no\" height=\"378\" width=\"620\"></iframe>"}); + 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 { @@ -1006,10 +1308,6 @@ footer#footer { } /* - * 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 @@ <main id="video"> <!-- Player --> <section id="player"> - <div id="ytapiplayer" class="embed-responsive"></div> + <div id="ytapiplayer" class="embed-responsive"> + <button id="fullscreen-chat-btn" class="fullscreen-chat-btn"> + <ion-icon name="chatbubble"></ion-icon> + </button> + </div> <!-- Video info --> <div class="info video-info"> <header> @@ -33,18 +37,12 @@ </header> <!-- Video controls --> <span class="controls"> - <button id="togglesynch" title="${toggleVideoSync}"> - <ion-icon id="pause-indicator" name="play"></ion-icon> - </button> <button id="mediarefresh" title="${refreshPlayer}"> <ion-icon name="refresh"></ion-icon> </button> <button id="fullscreenbtn" title="${fullscreenPlayer}"> <ion-icon name="expand"></ion-icon> </button> - <button id="voteskip" title="${voteForSkip}"> - <ion-icon name="play-skip-forward"></ion-icon> - </button> </span> </div> </section> @@ -67,16 +65,36 @@ data-target="#addfromurl" aria-expanded="false"> <ion-icon name="add"></ion-icon> </button> - <button class="collapsed" id="showcustomembed" title="${embedCustomFrame}" data-toggle="collapse" - data-target="#customembed" aria-expanded="false"> - <ion-icon name="code"></ion-icon> - </button> - <button id="shuffleplaylist" title="${shufflePlaylist}"> - <ion-icon name="shuffle"></ion-icon> - </button> - <button id="clearplaylist" title="${clearPlaylist}"> - <ion-icon name="close"></ion-icon> - </button> + <div class="playlist-menu-wrap"> + <button id="playlist-menu-btn"> + <ion-icon name="menu"></ion-icon> + <div id="playlist-menu-indicator" style="display: none;"> + </div> + </button> + <div id="playlist-menu-dropdown" class="playlist-menu-dropdown" style="display: none;"> + <button class="playlist-menu-item" id="voteskip" title="${voteForSkip}"> + <ion-icon name="play-skip-forward"></ion-icon> + <span><span id="voteskip-text">${voteForSkip}</span> <span class="hotkey-hint">(Alt-S)</span></span> + </button> + <button class="playlist-menu-item" id="togglesynch" title="${disableVideoSync}"> + <ion-icon name="unlink"></ion-icon> + <span>${disableVideoSync} <span class="hotkey-hint">(Alt-U)</span></span> + </button> + <button class="collapsed playlist-menu-item" id="showcustomembed" data-toggle="collapse" + data-target="#customembed" aria-expanded="false"> + <ion-icon name="code"></ion-icon> + <span>${embedCustomFrame}</span> + </button> + <button class="playlist-menu-item" id="shuffleplaylist"> + <ion-icon name="shuffle"></ion-icon> + <span>${shufflePlaylist}</span> + </button> + <button class="playlist-menu-item" id="clearplaylist"> + <ion-icon name="close"></ion-icon> + <span>${clearPlaylist}</span> + </button> + </div> + </div> </span> </div> <!-- Playlist metadata --> @@ -112,7 +130,7 @@ <input id="voiceoverurl" type="text" placeholder="${voiceOverAudioTrackUrlOptional}"> </div> <div class="checkbox-row"> - <label class="checkbox-row-item"> + <label class="checkbox-row-item" style="display: none;"> <input id="add-temp" class="add-temp" type="checkbox" checked>${addAsTemporary} </label> <label class="checkbox-row-item"> @@ -131,9 +149,6 @@ <input id="customembed-title" type="text" placeholder="${optionalTitle}"> <textarea id="customembed-content" rows="5" placeholder="${pasteEmbedCodeAndClick}. ${acceptableEmbedCodesAre} <iframe> ${or} <object>. ${customEmbedsCannotBeSynchronized}."></textarea> - <label class="checkbox-row-item"> - <input id="add-temp-embed" class="add-temp" type="checkbox" checked>${addAsTemporary} - </label> <div class="queue-buttons"> <button id="ce_queue_next">${queueNext}</button> <button id="ce_queue_end">${queueLast}</button> @@ -163,7 +178,7 @@ <button id="userlisttoggle" title="${toggleUserList}"> <ion-icon name="chevron-down"></ion-icon> <span id="usercount">${connection}...</span> - <ion-icon id="pause-indicator-portrait" name="play" style="display: none;"></ion-icon> + <ion-icon id="pause-indicator-chat" name="play" style="display: none;"></ion-icon> </button> <span> <button id="togglechat" title="Collapse Chat"> @@ -176,68 +191,75 @@ </button> </span> </div> - <!-- User list --> - <div id="userlist-wrap" class="collapsible"> - <div id="userlist"></div> - </div> - <!-- Settings list --> - <ul id="optionsPanel" class="collapse" aria-expanded="false"> - <div id="optionsList" class="collapse"> - <div> - <div> - <h4>${account}</h4> - <li><button id="exitBtn">${login}</button></li> - </div> - <div> - <h4>${general}</h4> - <li><button id="hotkeysBtn"><span>${hotkeys}</span></button></li> - <li><button id="swapLayoutBtn" title="${swapLayout}"><span>${swapLayout}</span></button></li> - </div> + <div id="chat-body"> + <!-- User list --> + <div id="userlist-wrap" class="collapsible"> + <div id="userlist"></div> + </div> + <!-- Settings list --> + <ul id="optionsPanel" class="collapse" aria-expanded="false"> + <div id="optionsList" class="collapse"> <div> - <h4>${video}</h4> - <li><button id="synchThresholdBtn"><span>${synchThreshold}</span></button></li> - <li><button id="setVideoUrlBtn"><span>${setVideoUrl}</span></button></li> - <li><button id="selectLocalVideoBtn"><span>${selectLocalVideo}</span></button></li> - <li><button id="removePlayerBtn"><span>${removePlayer}</span></button></li> - <li><a href="/admin-register"><button><span>${registerAdmin}</span></button></a></li> - </div> - <div id="adminMenu" style="display: none;"> - <h4>${chat}</h4> - <li><button id="clearchatbtn"><span>${clearChat}</span></button></li> + <div class="options-category"> + <h4>${account}</h4> + <li><button id="exitBtn">${login}</button></li> + <li><a href="/admin-register"><button><span>${registerAdmin}</span></button></a></li> + </div> + <div class="options-category"> + <h4>${general}</h4> + <li><button id="swapLayoutBtn" title="${swapLayout}"><span>${swapLayout}</span></button></li> + <li><button id="pageFullscreenBtn" title="${pageFullscreen}"><span>${pageFullscreen}</span></button></li> + <li><button id="synchThresholdBtn"><span>${synchThreshold}</span></button></li> + <li><button id="hotkeysBtn"><span>${hotkeys}</span></button></li> + </div> + <div class="options-category"> + <h4>${player}</h4> + <li><button id="fullscreenActionBtn"></button></li> + <li><button id="setVideoUrlBtn"><span>${setVideoUrl}</span></button></li> + <li><button id="selectLocalVideoBtn"><span>${selectLocalVideo}</span></button></li> + <li><button id="removePlayerBtn"><span>${removePlayer}</span></button></li> + </div> + <div id="adminMenu" class="options-category" style="display: none;"> + <h4>${chat}</h4> + <li><button id="clearchatbtn"><span>${clearChat}</span></button></li> + </div> </div> </div> - </div> - </ul> - <!-- Messages --> - <div id="messagebuffer" style="flex-direction: column-reverse;"></div> - <div id="chat-inputs-wrapper"> - <div id="dynamic-chin" class="collapsed" style="display: none;"></div> - <button id="scroll-to-chat-end" style="display: none; opacity: 0;" class="active"> - <ion-icon name="arrow-down" role="img" class="md hydrated"></ion-icon> - </button> - <!-- Message input --> - <div id="chatbox"> - <input id="chatline" type="text" placeholder="${chatlinePlaceholder}"> - <button id="smilesbtn" title="${emotes}"> - <ion-icon name="happy"></ion-icon> + </ul> + <!-- Messages --> + <div id="messagebuffer" style="flex-direction: column-reverse;"></div> + <div id="chat-inputs-wrapper"> + <div id="dynamic-chin" class="collapsed" style="display: none;"></div> + <button id="scroll-to-chat-end" style="display: none; opacity: 0;" class="active"> + <ion-icon name="arrow-down" role="img" class="md hydrated"></ion-icon> </button> - </div> - <div id="smiles-wrap" class="collapsible" style="display: none;"> - <div id="smiles-list"></div> - </div> - <!-- Guest login --> - <div id="guestlogin" style="display: none;"> - <label>${enterAsGuest}</label> - <input id="guestname" type="text" placeholder="${yourName}"> - </div> - <div id="guestpassword" style="display: none;"> - <label>${enterUserPassword}</label> - <div id="passwordbox"> - <input id="guestpass" type="text" placeholder="${yourPassword}"> - <button id="guestpass_icon"> - <ion-icon name="eye"></ion-icon> + <div id="chat-commands-wrap" class="collapsible" style="display: none;"> + <div id="chat-commands-list"></div> + </div> + <!-- Message input --> + <div id="chatbox"> + <input id="chatline" type="text" placeholder="${chatlinePlaceholder}"> + <button id="smilesbtn" title="${emotes}"> + <ion-icon name="happy"></ion-icon> </button> </div> + <div id="smiles-wrap" class="collapsible" style="display: none;"> + <div id="smiles-list"></div> + </div> + <!-- Guest login --> + <div id="guestlogin" style="display: none;"> + <label>${enterAsGuest}</label> + <input id="guestname" type="text" placeholder="${yourName}"> + </div> + <div id="guestpassword" style="display: none;"> + <label>${enterUserPassword}</label> + <div id="passwordbox"> + <input id="guestpass" type="text" placeholder="${yourPassword}"> + <button id="guestpass_icon"> + <ion-icon name="eye"></ion-icon> + </button> + </div> + </div> </div> </div> </aside> diff --git a/res/langs/en.json b/res/langs/en.json index e0190c6..1bc7ecb 100644 --- a/res/langs/en.json +++ b/res/langs/en.json @@ -106,15 +106,32 @@ "clearPlaylistConfirm": "Are you sure you want to clear the playlist?", "shufflePlaylistConfirm": "Are you sure you want to shuffle the playlist?", "lockPlaylistConfirm": "Are you sure you want to lock the playlist?", - "yes": "Yes", "no": "No", "on": "On", "off": "Off", - "areYouSure": "Are you sure?", "dataWillBeLost": "The data will be lost.", - "setupTitle": "Welcome to Dohee Cinema!", - "registerAdmin": "Register as Admin" + "registerAdmin": "Register as Admin", + "cmdHelp": "Show introduction message", + "cmdClear": "Clear the chat history", + "cmdBan": "`/ban <name> <time>` - Ban a user", + "cmdUnban": "`/unban <name>` - Unban a user", + "cmdKick": "`/kick <name>` - Kick a user", + "cmdAd": "Skip a sponsored segment in the video", + "cmdVolume": "`/volume <0-3>` - Set the player volume", + "cmdDump": "Dump the current server state to a file", + "cmdCrash": "Simulate a server crash", + "cmdFb": "Rewind to the last remembered time (before a seek or skip)", + "player": "Player", + "pageFullscreen": "Page Fullscreen", + "fullscreenActionTouch": "Long-tap Fullscreen", + "fullscreenActionDesktop": "Right-click Fullscreen", + "pausedBy": "(Paused by $NAME)", + "server": "Server", + "disableVideoSync": "Disable video synchronization", + "enableVideoSync": "Enable video synchronization", + "skipVoteInitiated": "Skip vote started", + "cmdGiveup": "`/giveup <name>` - Transfer leadership to another user" } diff --git a/res/langs/ru.json b/res/langs/ru.json index 7ae9138..2b9bde9 100644 --- a/res/langs/ru.json +++ b/res/langs/ru.json @@ -23,6 +23,17 @@ "accessError": "Ошибка доступа", "noPermission": "Нет '$PERMISSION' разрешения.", "totalVideoLimitError": "Был достингут лимит видео в плейлисте.", + "cmdGiveup": "`/giveup <name>` - Передать управление другому пользователю", + "cmdHelp": "Показать вступительное сообщение", + "cmdClear": "Очистить историю чата", + "cmdBan": "`/ban <имя> <время>` - Заблокировать пользователя", + "cmdUnban": "`/unban <имя>` - Разблокировать пользователя", + "cmdKick": "`/kick <имя>` - Выгнать пользователя", + "cmdAd": "Пропустить спонсорскую вставку в видео", + "cmdVolume": "`/volume <0-3>` - Установить громкость плеера", + "cmdDump": "Сохранить текущее состояние сервера в файл", + "cmdCrash": "Симулировать сбой сервера", + "cmdFb": "Вернуться к предыдущему времени (до перемотки)", "userVideoLimitError": "Был достингут лимит видео на пользователя.", "videoAlreadyExistsError": "Видео уже имеется в плейлисте.", "addVideoError": "Не удалось добавить видео.", @@ -47,10 +58,13 @@ "synchThreshold": "Частота синхронизации", "general": "Основное", "hotkeys": "Горячие клавиши", - "video": "Видео", + "player": "Плеер", "channel": "Канал", "layout": "Разметка", "swapLayout": "Сменить разметку", + "pageFullscreen": "Полноэкранная страница", + "fullscreenActionTouch": "Полный экран по долгому нажатию", + "fullscreenActionDesktop": "Полный экран по правому клику", "chatOnly": "Только чат", "setVideoUrl": "Задать ссылку на видео", "setVideoUrlPrompt": "Новая ссылка на видео:\n(Не влияет на других пользователей)", @@ -71,6 +85,8 @@ "clearChat": "Очистить чат", "chatlinePlaceholder": "Введите сообщение...", "leaderDisconnectedServerOnPause": "Лидер вышел, сервер на паузе.", + "pausedBy": "(Пауза: $NAME)", + "server": "Сервер", "unpause": "Возобновить", "addVideoFromUrl": "Добавить видео по ссылке", "embedCustomFrame": "Добавить iframe", @@ -80,12 +96,14 @@ "playlistOpen": "Плейлист открыт", "playlistLocked": "Плейлист заблокирован", "expandPlayer": "Расширить плеер", - "toggleVideoSync": "Переключить синхронизацию видео", + "disableVideoSync": "Отключить синхронизацию видео", + "enableVideoSync": "Включить синхронизацию видео", "toggleSynchConfirm": "Вы уверены что хотите отключить синхронизацию видео?", "refreshPlayer": "Обновить плеер", "fullscreenPlayer": "Полноэкранный режим видео", "retrievePlaylistLinks": "Получить ссылки на видео из плейлиста", "voteForSkip": "Голосовать за пропуск", + "skipVoteInitiated": "Голосование за пропуск начато", "addAsTemporary": "Добавить как временное", "cacheOnServer": "Закэшировать на сервере", "mediaUrl": "Ссылка на видео", @@ -102,7 +120,6 @@ "acceptableEmbedCodesAre": "Можно добавить видео с тегами", "customEmbedsCannotBeSynchronized": "СИНХРОНИЗАЦИЯ БУДЕТ НЕДОСТУПНА", "save": "Сохранить", - "skipItemConfirm": "Вы уверены что хотите пропустить текущее видео?", "clearPlaylistConfirm": "Вы уверены что хотите очистить плейлист?", "shufflePlaylistConfirm": "Вы уверены что хотите перемешать плейлист?", "lockPlaylistConfirm": "Вы уверены что хотите заблокировать плейлист?", diff --git a/src/ClientTools.hx b/src/ClientTools.hx index 46400f7..5da23c4 100644 --- a/src/ClientTools.hx +++ b/src/ClientTools.hx @@ -1,8 +1,5 @@ package; -import Types.Permission; -import Types.Permissions; - class ClientTools { public static function setLeader(clients:Array<Client>, name:String):Void { for (client in clients) { @@ -12,10 +9,14 @@ class ClientTools { } public static function hasLeader(clients:Array<Client>):Bool { + return getLeader(clients) != null; + } + + public static function getLeader(clients:Array<Client>):Null<Client> { for (client in clients) { - if (client.isLeader) return true; + if (client.isLeader) return client; } - return false; + return null; } public static function getByName( diff --git a/src/Types.hx b/src/Types.hx index 1b1997a..1e59e82 100644 --- a/src/Types.hx +++ b/src/Types.hx @@ -6,6 +6,7 @@ enum abstract PlayerType(String) { var RawType; var YoutubeType; var VkType; + var VimeoType; var IframeType; } @@ -36,6 +37,7 @@ typedef ServerConfig = Config & { localAdmins:Bool, allowProxyIps:Bool, localNetworkOnly:Bool, + enableCORS:Bool, sslKeyPemPath:String, sslCertPemPath:String, cacheStorageLimitGiB:Float, @@ -129,6 +131,7 @@ enum abstract ProgressType(String) { var Downloading; var Uploading; var Canceled; + var Completed; } @:using(Types.VideoItemTools) @@ -142,10 +145,17 @@ typedef VideoItem = { var ?voiceOverTrack:String; var isTemp:Bool; var doCache:Bool; + var ?isIncomplete:Bool; var playerType:PlayerType; } private class VideoItemTools { + public static function isPlayable(item:VideoItem):Bool { + // if item is uploading instead of caching, it's playable + if (item.isIncomplete == true && item.doCache) return false; + return true; + } + public static function withUrl(item:VideoItem, url:String):VideoItem { return { url: url, @@ -156,6 +166,7 @@ private class VideoItemTools { voiceOverTrack: item.voiceOverTrack, isTemp: item.isTemp, doCache: item.doCache, + isIncomplete: item.isIncomplete, playerType: item.playerType }; } @@ -210,6 +221,7 @@ typedef WsEvent = { ?progress:{ type:ProgressType, ratio:Float, + ?url:String, ?data:String }, ?updateClients:{ @@ -232,6 +244,11 @@ typedef WsEvent = { ?skipVideo:{ url:String }, + ?skipVideoStats:{ + voted:Int, + total:Int, + required:Int + }, ?pause:{ time:Float }, @@ -258,7 +275,8 @@ typedef WsEvent = { pos:Int }, ?toggleItemType:{ - pos:Int + pos:Int, + ?all:Bool }, ?updatePlaylist:{ videoList:Array<VideoItem> @@ -287,6 +305,7 @@ enum abstract WsEventType(String) { var AddVideo; var RemoveVideo; var SkipVideo; + var SkipVideoStats; var VideoLoaded; var Pause; var Play; diff --git a/src/VideoList.hx b/src/VideoList.hx index 852c5e5..e974165 100644 --- a/src/VideoList.hx +++ b/src/VideoList.hx @@ -76,6 +76,12 @@ class VideoList { items[pos].isTemp = !items[pos].isTemp; } + public function setAllItemTypes(isTemp:Bool):Void { + for (item in items) { + item.isTemp = isTemp; + } + } + public function removeItem(index:Int):Void { if (index < pos) pos--; items.remove(items[index]); diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx index 82054ab..61f0367 100644 --- a/src/client/Buttons.hx +++ b/src/client/Buttons.hx @@ -5,9 +5,11 @@ import haxe.Timer; import js.Browser.document; import js.Browser.window; import js.html.Element; +import js.html.Event; import js.html.ImageElement; import js.html.InputElement; import js.html.KeyboardEvent; +import js.html.MouseEvent; import js.html.TransitionEvent; import js.html.VisualViewport; @@ -23,11 +25,11 @@ class Buttons { split = new Split(settings); split.setSize(settings.chatSize); - initChatInputs(main); + initInputs(main); for (item in settings.checkboxes) { if (item.checked == null) continue; - final checkbox:InputElement = getEl('#${item.id}') ?? continue; + final checkbox = (getEl('#${item.id}') : InputElement) ?? continue; checkbox.checked = item.checked; } @@ -137,20 +139,30 @@ class Buttons { getEl("#userlist-wrap").style.transition = "200ms"; }, 0); + final playlistMenuBtn = getEl("#playlist-menu-btn"); + final playlistMenuDropdown = getEl("#playlist-menu-dropdown"); + + inline function closePlaylistMenu() { + playlistMenuDropdown.style.display = "none"; + playlistMenuBtn.classList.remove("active"); + } + final toggleSynch = getEl("#togglesynch"); toggleSynch.onclick = e -> { + closePlaylistMenu(); final icon = toggleSynch.firstElementChild; if (main.isSyncActive) { if (!window.confirm(Lang.get("toggleSynchConfirm"))) return; 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}); } + updateToggleSynchBtn(main); } final mediaRefresh = getEl("#mediarefresh"); mediaRefresh.onclick = e -> { @@ -158,11 +170,18 @@ class Buttons { } final fullscreenBtn = getEl("#fullscreenbtn"); fullscreenBtn.onclick = e -> { - if ((Utils.isTouch() || main.isVerbose()) && !Utils.hasFullscreen()) { - window.scrollTo(0, 0); - Utils.requestFullscreen(document.documentElement); + Utils.toggleFullscreen(getEl("#ytapiplayer")); + } + final chatBtn = getEl("#fullscreen-chat-btn"); + chatBtn.onclick = e -> { + e.stopPropagation(); + e.preventDefault(); + clearUnreadChatMessages(); + + if (settings.pageFullscreen) { + Utils.switchToPageFullscreen(); } else { - Utils.requestFullscreen(getEl("#ytapiplayer")); + Utils.cancelFullscreen(); } } initPageFullscreen(); @@ -176,13 +195,39 @@ class Buttons { icon.setAttribute("name", "link"); }, 2000); } + + playlistMenuBtn.onclick = e -> { + e.stopPropagation(); + final isOpen = playlistMenuDropdown.style.display != "none"; + if (isOpen) { + closePlaylistMenu(); + } else { + main.hasSeenSkipVote = true; + playlistMenuDropdown.style.display = ""; + playlistMenuBtn.classList.add("active"); + getEl("#playlist-menu-indicator").style.display = "none"; + } + } + + document.addEventListener("click", e -> { + final target:Element = cast e.target; + if (!playlistMenuBtn.contains(target) && !playlistMenuDropdown.contains(target)) { + closePlaylistMenu(); + } + }); + + final voteSkip = getEl("#voteskip"); + voteSkip.addEventListener("click", () -> closePlaylistMenu()); + final clearPlaylist = getEl("#clearplaylist"); clearPlaylist.onclick = e -> { + closePlaylistMenu(); if (!window.confirm(Lang.get("clearPlaylistConfirm"))) return; main.send({type: ClearPlaylist}); } final shufflePlaylist = getEl("#shuffleplaylist"); shufflePlaylist.onclick = e -> { + closePlaylistMenu(); if (!window.confirm(Lang.get("shufflePlaylistConfirm"))) return; main.send({type: ShufflePlaylist}); } @@ -200,6 +245,7 @@ class Buttons { final showMediaUrl = getEl("#showmediaurl"); showMediaUrl.onclick = e -> { + closePlaylistMenu(); final isOpen = showPlayerGroup(showMediaUrl); if (isOpen) Timer.delay(() -> { getEl("#addfromurl").scrollIntoView(); @@ -209,6 +255,7 @@ class Buttons { final showCustomEmbed = getEl("#showcustomembed"); showCustomEmbed.onclick = e -> { + closePlaylistMenu(); final isOpen = showPlayerGroup(showCustomEmbed); if (isOpen) Timer.delay(() -> { getEl("#customembed").scrollIntoView(); @@ -343,6 +390,40 @@ class Buttons { } updateHotkeysBtn(); + final pageFullscreenBtn = getEl("#pageFullscreenBtn"); + pageFullscreenBtn.onclick = e -> { + settings.pageFullscreen = !settings.pageFullscreen; + Settings.write(settings); + updatePageFullscreenBtn(); + + if (settings.pageFullscreen) { + Utils.switchToPageFullscreen(); + } else { + Utils.cancelFullscreen(); + } + } + updatePageFullscreenBtn(); + + final fullscreenActionBtn = getEl("#fullscreenActionBtn"); + if (fullscreenActionBtn != null) { + fullscreenActionBtn.onclick = e -> { + settings.fullscreenAction = !settings.fullscreenAction; + Settings.write(settings); + updateFullscreenActionBtn(); + } + updateFullscreenActionBtn(); + } + + document.body.addEventListener("click", e -> { + if (!settings.pageFullscreen) return; + if (Utils.hasFullscreen()) return; + final target:Element = cast e.target; + if (target != null + && target.closest("button, input, a, textarea, select, #optionsPanel, #chatline, #userlist, .queue_sortable, .nav") != null) + return; + Utils.requestFullscreen(document.documentElement); + }); + final removeBtn = getEl("#removePlayerBtn"); removeBtn.onclick = e -> { final isActive = main.toggleVideoElement(); @@ -367,6 +448,7 @@ class Buttons { JsApi.setVideoSrc(url); }); } + updateToggleSynchBtn(main); } public static function initHotkeys(main:Main, player:Player):Void { @@ -385,6 +467,8 @@ class Buttons { switch (key) { case R: getEl("#mediarefresh").onclick(); + case U: + getEl("#togglesynch").onclick(); case S: getEl("#voteskip").onclick(); case C: @@ -417,12 +501,52 @@ class Buttons { } static function updateHotkeysBtn():Void { - final text = Lang.get("hotkeys"); - final state = settings.hotkeysEnabled ? Lang.get("on") : Lang.get("off"); - getEl("#hotkeysBtn").innerText = '$text: $state'; + final btn = getEl("#hotkeysBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if (switchEl == null) { + final text = Lang.get("hotkeys"); + btn.innerHTML = '<span>$text</span><div class="toggle-switch"></div>'; + switchEl = btn.querySelector(".toggle-switch"); + } + + switchEl.classList.toggle("on", settings.hotkeysEnabled); + document.body.classList.toggle("hotkeys-disabled", !settings.hotkeysEnabled); + } + + static function updatePageFullscreenBtn():Void { + final btn = getEl("#pageFullscreenBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if (switchEl == null) { + final text = Lang.get("pageFullscreen"); + btn.innerHTML = '<span>$text</span><div class="toggle-switch"></div>'; + switchEl = btn.querySelector(".toggle-switch"); + } + switchEl.classList.toggle("on", settings.pageFullscreen); } - static function initChatInputs(main:Main):Void { + static function updateFullscreenActionBtn():Void { + final btn = getEl("#fullscreenActionBtn"); + var switchEl = btn.querySelector(".toggle-switch"); + if (switchEl == null) { + final text = Lang.get(Utils.isTouch() ? "fullscreenActionTouch" : "fullscreenActionDesktop"); + btn.innerHTML = '<span>$text</span><div class="toggle-switch"></div>'; + switchEl = btn.querySelector(".toggle-switch"); + } + switchEl.classList.toggle("on", settings.fullscreenAction); + } + + public static function updateToggleSynchBtn(main:Main):Void { + final toggleSynch = getEl("#togglesynch"); + final span = toggleSynch.querySelector("span"); + final textKey = main.isSyncActive ? "disableVideoSync" : "enableVideoSync"; + final text = Lang.get(textKey); + toggleSynch.title = '$text (Alt-U)'; + if (span != null) { + span.innerHTML = '$text <span class="hotkey-hint">(Alt-U)</span>'; + } + } + + static function initInputs(main:Main):Void { final guestName:InputElement = getEl("#guestname"); guestName.onkeydown = e -> { if (e.keyCode == KeyCode.Return) { @@ -440,37 +564,12 @@ class Buttons { } } - final chatline:InputElement = getEl("#chatline"); - chatline.onfocus = e -> { - if (Utils.isIOS()) { - // final startY = window.scrollY; - final startY = 0; - Timer.delay(() -> { - window.scrollBy(0, -(window.scrollY - startY)); - getEl("#video").scrollTop = 0; - main.scrollChatToEnd(); - }, 100); - } else if (Utils.isTouch()) { - main.scrollChatToEnd(); - } - } final viewport = getVisualViewport(); if (viewport != null) { viewport.addEventListener("resize", e -> onViewportResize()); onViewportResize(); } - new InputWithHistory(chatline, 50, value -> { - if (main.handleCommands(value)) return true; - main.send({ - type: Message, - message: { - clientName: "", - text: value - } - }); - if (Utils.isTouch()) chatline.blur(); - return true; - }); + final checkboxes:Array<InputElement> = [ getEl("#add-temp"), ]; @@ -498,12 +597,32 @@ class Buttons { return (window : Dynamic).visualViewport; } + public static var hasUnreadChatMessages:Bool = false; + + public static function onNewChatMessage():Void { + hasUnreadChatMessages = true; + updateFullscreenChatBtn(); + } + + public static function clearUnreadChatMessages():Void { + hasUnreadChatMessages = false; + updateFullscreenChatBtn(); + } + + public static function updateFullscreenChatBtn():Void { + final chatBtn = getEl("#fullscreen-chat-btn"); + final isFs = Utils.hasFullscreen(); + if (isFs && hasUnreadChatMessages) { + chatBtn.classList.add("has-unread"); + } else { + chatBtn.classList.remove("has-unread"); + } + } + static function initPageFullscreen():Void { document.onfullscreenchange = e -> { - final el = document.documentElement; - if (Utils.hasFullscreen()) { - if (e.target == el) el.classList.add("mobile-view"); - } else el.classList.remove("mobile-view"); + clearUnreadChatMessages(); + updateFullscreenChatBtn(); } } } diff --git a/src/client/ChatInput.hx b/src/client/ChatInput.hx new file mode 100644 index 0000000..61fb95e --- /dev/null +++ b/src/client/ChatInput.hx @@ -0,0 +1,188 @@ +package client; + +import client.Main.getEl; +import haxe.Timer; +import js.Browser.document; +import js.Browser.window; +import js.html.Element; +import js.html.Event; +import js.html.InputElement; +import js.html.KeyboardEvent; + +class ChatInput { + public final chatline:InputElement; + public final commandsWrap:Element; + public final commandsList:Element; + public final inputWithHistory:InputWithHistory; + + final main:Main; + var filteredCommands:Array<Commands.CommandInfo> = []; + var selectedCommandIndex = -1; + + public function new(main:Main) { + this.main = main; + chatline = getEl("#chatline"); + commandsWrap = getEl("#chat-commands-wrap"); + commandsList = getEl("#chat-commands-list"); + + setupFocusHandler(); + setupAutocomplete(); + + inputWithHistory = new InputWithHistory(chatline, 50, onEnter, onInterceptKeyDown); + } + + function setupFocusHandler():Void { + chatline.onfocus = e -> { + if (Utils.isIOS()) { + final startY = 0; + Timer.delay(() -> { + window.scrollBy(0, -(window.scrollY - startY)); + getEl("#video").scrollTop = 0; + main.scrollChatToEnd(); + }, 100); + } else if (Utils.isTouch()) { + main.scrollChatToEnd(); + } + }; + } + + function setupAutocomplete():Void { + commandsList.addEventListener("mousemove", e -> { + commandsList.classList.remove("keyboard-nav"); + }); + + chatline.addEventListener("input", e -> { + final val = chatline.value; + if (val.startsWith("/")) { + filteredCommands = Commands.filterForAutocomplete(val); + selectedCommandIndex = filteredCommands.length > 0 ? 0 : -1; + renderCommands(); + } else { + commandsWrap.style.display = "none"; + selectedCommandIndex = -1; + } + }); + + chatline.addEventListener("blur", e -> { + commandsWrap.style.display = "none"; + }); + } + + function renderCommands():Void { + commandsList.innerHTML = ""; + if (filteredCommands.length == 0) { + commandsWrap.style.display = "none"; + return; + } + + final parsed = Commands.parse(chatline.value); + final currentArgIndex = parsed.currentArgIndex; + + for (i in 0...filteredCommands.length) { + final cmd = filteredCommands[i]; + final item = document.createDivElement(); + item.className = "chat-command-item" + (i == selectedCommandIndex ? " active" : ""); + item.onclick = e -> { + chatline.value = cmd.name + (cmd.args > 0 ? " " : ""); + chatline.focus(); + if (cmd.args == 0) { + commandsWrap.style.display = "none"; + selectedCommandIndex = -1; + } else { + chatline.dispatchEvent(new Event("input")); + } + return; + }; + item.onmouseenter = e -> { + if (!commandsList.classList.contains("keyboard-nav")) { + selectedCommandIndex = i; + for (j in 0...commandsList.children.length) { + final child = commandsList.children[j]; + if (j == i) child.classList.add("active"); + else child.classList.remove("active"); + } + } + }; + + final nameDiv = document.createDivElement(); + nameDiv.className = "chat-command-item-name"; + nameDiv.innerText = cmd.name.toString(); + + final descDiv = document.createDivElement(); + descDiv.className = "chat-command-item-desc"; + descDiv.innerHTML = Commands.formatDescWithHighlight(Lang.get(cmd.lang), currentArgIndex); + + item.appendChild(nameDiv); + item.appendChild(descDiv); + commandsList.appendChild(item); + } + commandsWrap.style.display = ""; + if (selectedCommandIndex >= 0 && commandsList.children.length > selectedCommandIndex) { + final selectedEl:Element = cast commandsList.children[selectedCommandIndex]; + untyped selectedEl.scrollIntoView({block: "center"}); + } + } + + function onEnter(value:String):Bool { + if (main.handleCommands(value)) return true; + main.send({ + type: Message, + message: { + clientName: "", + text: value + } + }); + if (Utils.isTouch()) chatline.blur(); + commandsWrap.style.display = "none"; + return true; + } + + function onInterceptKeyDown(e:KeyboardEvent):Bool { + if (commandsWrap.style.display == "none") return false; + final key:KeyCode = cast e.keyCode; + switch key { + case Up: + commandsList.classList.add("keyboard-nav"); + if (selectedCommandIndex > 0) selectedCommandIndex--; + else selectedCommandIndex = filteredCommands.length - 1; + renderCommands(); + e.preventDefault(); + return true; + case Down: + commandsList.classList.add("keyboard-nav"); + if (selectedCommandIndex < filteredCommands.length - 1) selectedCommandIndex++; + else selectedCommandIndex = 0; + renderCommands(); + e.preventDefault(); + return true; + case Return, Tab: + if (selectedCommandIndex < 0 || selectedCommandIndex >= filteredCommands.length) { + return false; + } + final selectedCmd = filteredCommands[selectedCommandIndex]; + final val = chatline.value; + final hasSpace = val.indexOf(" ") >= 0; + + if (key == Return) { + if (hasSpace + || (selectedCmd.args == 0 + && val.trim().toLowerCase() == selectedCmd.name.toString())) { + return false; + } + } + + if (!hasSpace) { + chatline.value = selectedCmd.name + (selectedCmd.args > 0 ? " " : ""); + } + + e.preventDefault(); + chatline.dispatchEvent(new Event("input")); + return true; + case Escape: + commandsWrap.style.display = "none"; + return false; + case _: + return false; + } + } +} diff --git a/src/client/ClientSettings.hx b/src/client/ClientSettings.hx index 1bca427..0ade401 100644 --- a/src/client/ClientSettings.hx +++ b/src/client/ClientSettings.hx @@ -14,6 +14,8 @@ typedef ClientSettings = { var latestLinks:Array<String>; var latestSubs:Array<String>; var hotkeysEnabled:Bool; + var pageFullscreen:Bool; + var fullscreenAction:Bool; var showHintList:Bool; var checkboxes:Array<{id:String, checked:Null<Bool>}>; var checkedCache:Array<PlayerType>; diff --git a/src/client/Commands.hx b/src/client/Commands.hx new file mode 100644 index 0000000..5f1d578 --- /dev/null +++ b/src/client/Commands.hx @@ -0,0 +1,182 @@ +package client; + +enum abstract CommandId(String) { + var Help = "/help"; + var Clear = "/clear"; + var Ban = "/ban"; + var Unban = "/unban"; + var Kick = "/kick"; + var Ad = "/ad"; + var Volume = "/volume"; + var Dump = "/dump"; + var Crash = "/crash"; + var Flashback = "/fb"; + var Giveup = "/giveup"; + + public inline function toString():String { + return this; + } +} + +@:structInit +class CommandInfo { + public final name:CommandId; + public final aliases:Array<String>; + public final args:Int; + public final lang:String; + + public function new(name:CommandId, args = 0, ?aliases:Array<String>) { + this.name = name; + this.args = args; + this.aliases = aliases ?? []; + lang = getLangId(); + } + + function getLangId():String { + final withoutSlash = name.toString().substr(1); + return "cmd" + withoutSlash.charAt(0).toUpperCase() + withoutSlash.substr(1); + } +} + +typedef ParsedCommand = { + final cmd:Null<CommandInfo>; + final rawCmd:String; + final args:Array<String>; + final currentArgIndex:Int; +} + +class Commands { + static final list:Array<CommandInfo> = [ + { + name: Help, + }, + { + name: Clear, + }, + { + name: Ban, + args: 2, + }, + { + name: Unban, + args: 1, + }, + { + name: Kick, + args: 1, + }, + { + name: Ad, + }, + { + name: Volume, + args: 1, + }, + { + name: Dump, + }, + { + name: Crash, + }, + { + name: Giveup, + args: 1, + }, + { + name: Flashback, + aliases: ["/flashback"], + } + ]; + + public static function find(name:String):Null<CommandInfo> { + final lower = name.toLowerCase(); + for (c in list) { + if (c.name.toString() == lower) return c; + for (a in c.aliases) { + if (a.toLowerCase() == lower) return c; + } + } + return null; + } + + public static function parse(input:String):ParsedCommand { + if (!input.startsWith("/")) { + return { + cmd: null, + rawCmd: "", + args: [], + currentArgIndex: -1 + }; + } + final parts = input.split(" "); + final rawCmd = parts[0]; + final args = parts.slice(1); + final currentArgIndex = args.length > 0 ? args.length - 1 : -1; + + return { + cmd: find(rawCmd), + rawCmd: rawCmd, + args: args, + currentArgIndex: currentArgIndex + }; + } + + public static function filterForAutocomplete(val:String):Array<CommandInfo> { + if (!val.startsWith("/")) return []; + final parts = val.toLowerCase().split(" "); + final searchCmd = parts[0]; + final spaceCount = parts.length - 1; + + final result:Array<CommandInfo> = []; + for (c in list) { + if (spaceCount == 0) { + if (c.name.toString().startsWith(searchCmd)) { + result.push(c); + } else { + for (a in c.aliases) { + if (a.toLowerCase().startsWith(searchCmd)) { + result.push(c); + break; + } + } + } + } else { + if (spaceCount <= c.args) { + if (c.name.toString() == searchCmd) { + result.push(c); + } else { + for (a in c.aliases) { + if (a.toLowerCase() == searchCmd) { + result.push(c); + break; + } + } + } + } + } + } + return result; + } + + public static function formatDescWithHighlight(desc:String, activeArgIndex:Int):String { + desc = StringTools.htmlEscape(desc); + + var argCounter = 0; + final argRegex = ~/<[^&]+>/g; + desc = argRegex.map(desc, reg -> { + final token = reg.matched(0); + final isCurrent = (argCounter == activeArgIndex); + argCounter++; + if (isCurrent) { + return '<span class="chat-command-arg-active">' + token + '</span>'; + } else { + return token; + } + }); + + final codeRegex = ~/`([^`]+)`/g; + return codeRegex.map(desc, reg -> { + return '<span class="chat-command-code">' + reg.matched(1) + '</span>'; + }); + } +} diff --git a/src/client/FileUploader.hx b/src/client/FileUploader.hx index 888dda6..fbd8639 100644 --- a/src/client/FileUploader.hx +++ b/src/client/FileUploader.hx @@ -1,13 +1,15 @@ package client; +import Types.ProgressType; import Types.UploadResponse; import client.Main.getEl; import haxe.Json; -import haxe.Timer; +import js.Browser.document; import js.Browser.window; import js.html.File; import js.html.InputElement; import js.html.ProgressEvent; +import js.html.URL; import js.html.XMLHttpRequest; class FileUploader { @@ -22,9 +24,12 @@ class FileUploader { main.serverMessage("Only admins may upload files.", true, false); return; } - var name = ~/[?#%\/\\]/g.replace(file.name, "").trim(); - if (name.length == 0) name = "video"; - name = (window : Dynamic).encodeURIComponent(name); + var title = ~/[?#%\/\\]/g.replace(file.name, "").trim(); + if (title.length == 0) title = "video"; + final name = (window : Dynamic).encodeURIComponent(title); + + final checkboxTemp:InputElement = getEl("#addfromurl .add-temp"); + final isTemp = Main.isTempChecked(checkboxTemp); // send last chunk separately to allow server file streaming while uploading uploadLastChunk(file, name, data -> { @@ -32,48 +37,95 @@ class FileUploader { main.serverMessage(data.info, true, false); return; } - final input:InputElement = getEl("#mediaurl"); - input.value = data.url; - - uploadFullFile(name, file); + final url = data.url; + getFileDuration(file, duration -> { + if (duration == 0 || Math.isNaN(duration) || !Math.isFinite(duration)) { + main.serverMessage(Lang.get("addVideoError"), true, false); + return; + } + uploadFullFile(name, file, url, () -> { + main.addUploadedVideo(url, title, duration, true, isTemp); + }); + }); }); } - function uploadFullFile(name:String, file:File):Void { + function getFileDuration(file:File, callback:(duration:Float) -> Void):Void { + final objUrl = URL.createObjectURL(file); + final video = document.createVideoElement(); + video.preload = "metadata"; + video.muted = true; + video.onloadedmetadata = () -> { + final duration = video.duration; + URL.revokeObjectURL(objUrl); + callback(duration); + } + video.onerror = e -> { + URL.revokeObjectURL(objUrl); + callback(0); + } + video.src = objUrl; + } + + function uploadFullFile(name:String, file:File, url:String, onStarted:() -> Void):Void { final request = new XMLHttpRequest(); request.open("POST", "/upload", true); request.setRequestHeader("content-name", name); request.setRequestHeader("x-client-uuid", main.settings.uuid ?? ""); + var added = false; + function ensureAdded():Void { + if (added) return; + added = true; + onStarted(); + main.registerUpload(url, () -> request.abort()); + } + + function sendProgress(type:ProgressType, ratio:Float):Void { + main.send({ + type: Progress, + progress: { + type: type, + ratio: ratio, + url: url + } + }); + } + + var lastSentRatio = 0.0; request.upload.onprogress = (event:ProgressEvent) -> { + ensureAdded(); var ratio = 0.0; if (event.lengthComputable) { ratio = (event.loaded / event.total).clamp(0, 1); } - main.onProgressEvent({ - type: Progress, - progress: { - type: Uploading, - ratio: ratio - } - }); + if (ratio - lastSentRatio < 0.01 && ratio < 1) return; + lastSentRatio = ratio; + sendProgress(Uploading, ratio); } request.onload = (e:ProgressEvent) -> { + main.unregisterUpload(url); final data:UploadResponse = try { Json.parse(request.responseText); } catch (e) { trace(e); + sendProgress(Canceled, 0); + return; + } + if (data.errorId != null) { + main.serverMessage(data.info, true, false); + sendProgress(Canceled, 0); return; } - if (data.errorId == null) return; - main.serverMessage(data.info, true, false); + ensureAdded(); + sendProgress(Completed, 1); } - request.onloadend = () -> { - Timer.delay(() -> { - main.hideDynamicChin(); - }, 500); + request.onerror = (e:ProgressEvent) -> { + main.unregisterUpload(url); + sendProgress(Canceled, 0); } + request.onabort = (e:ProgressEvent) -> main.unregisterUpload(url); request.send(file); } diff --git a/src/client/InputWithHistory.hx b/src/client/InputWithHistory.hx index e41ec16..c82267f 100644 --- a/src/client/InputWithHistory.hx +++ b/src/client/InputWithHistory.hx @@ -1,5 +1,6 @@ package client; +import js.html.Event; import js.html.InputElement; import js.html.KeyboardEvent; @@ -8,19 +9,22 @@ class InputWithHistory { final maxItems:Int; final history:Array<String>; final onEnter:(value:String) -> Bool; + final onInterceptKeyDown:Null<(e:KeyboardEvent) -> Bool>; var historyId = -1; public function new( element:InputElement, ?history:Array<String>, maxItems:Int, - onEnter:(value:String) -> Bool + onEnter:(value:String) -> Bool, + ?onInterceptKeyDown:(e:KeyboardEvent) -> Bool ) { this.element = element; if (history != null) this.history = history; else this.history = []; this.maxItems = maxItems; this.onEnter = onEnter; + this.onInterceptKeyDown = onInterceptKeyDown; element.onkeydown = onKeyDown; } @@ -30,6 +34,7 @@ class InputWithHistory { } function onKeyDown(e:KeyboardEvent) { + if (onInterceptKeyDown != null && onInterceptKeyDown(e)) return; final key:KeyCode = cast e.keyCode; switch (key) { case Return: @@ -59,11 +64,13 @@ class InputWithHistory { element.value = history[historyId]; } onInput(); + case Escape: + e.preventDefault(); default: } } function onInput():Void { - if (element.oninput != null) element.oninput(); + element.dispatchEvent(new Event("input")); } } diff --git a/src/client/JsApi.hx b/src/client/JsApi.hx index b576b47..3b4777a 100644 --- a/src/client/JsApi.hx +++ b/src/client/JsApi.hx @@ -58,8 +58,9 @@ class JsApi { @:expose static function hasScriptInHead(url:String):Bool { + final abs = new js.html.URL(url, document.baseURI).href; for (child in document.getElementsByTagName("head")[0].children) { - if ((child : Dynamic).src == url) return true; + if ((child : Dynamic).src == abs) return true; } return false; } @@ -73,9 +74,10 @@ class JsApi { } @:expose - static function addVideoItem(url:String, atEnd:Bool, isTemp:Bool, ?callback:() -> - Void, doCache = false):Void { - main.addVideo(url, atEnd, isTemp, doCache, callback); + static function addVideoItem( + url:String, atEnd:Bool, isTemp:Bool, ?afterSend:() -> Void, doCache = false + ):Void { + main.addVideo(url, atEnd, isTemp, doCache, afterSend); } @:expose @@ -164,14 +166,16 @@ class JsApi { } public static function fireEvents(event:WsEvent):Void { - for (listener in onListeners.reversed()) { + final listeners = onListeners.copy(); + for (listener in listeners.reversed()) { if (listener.type != event.type) continue; listener.callback(event); } - for (listener in onceListeners.reversed()) { + final onces = onceListeners.copy(); + for (listener in onces.reversed()) { if (listener.type != event.type) continue; - listener.callback(event); onceListeners.remove(listener); + listener.callback(event); } } diff --git a/src/client/Main.hx b/src/client/Main.hx index 62c12f8..8775070 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -27,7 +27,7 @@ import js.html.WebSocket; class Main { public static var instance(default, null):Main; - static inline var SETTINGS_VERSION = 6; + static inline var SETTINGS_VERSION = 7; static inline var MAX_CHAT_MESSAGES = 200; public final settings:ClientSettings; @@ -39,6 +39,12 @@ class Main { public var isPlaylistOpen(default, null) = true; public var playersCacheSupport(default, null):Array<PlayerType> = []; public var showingServerPause(default, null) = false; + public var hasSeenSkipVote = false; + public var chatInput:ChatInput; + + var skipVoteCount = 0; + var skipVoteRequired = 0; + /** How much time passed since lastState.time update **/ public var timeFromLastState(default, null) = 0.0; public final lastState:GetTimeEvent = { @@ -91,6 +97,8 @@ class Main { latestLinks: [], latestSubs: [], hotkeysEnabled: true, + pageFullscreen: false, + fullscreenAction: Utils.isTouch(), showHintList: true, checkboxes: [], checkedCache: [], @@ -138,28 +146,14 @@ class Main { function settingsPatcher(data:Any, version:Int):Any { switch (version) { - case 1: - final data:ClientSettings = data; - data.hotkeysEnabled = true; - case 2: - final data:ClientSettings = data; - data.latestSubs = []; - case 3: - final data:ClientSettings = data; - data.showHintList = true; - case 4: - final data:ClientSettings = data; - data.checkboxes = []; + case 1, 2, 3, 4: case 5: + Settings.reset(); + data = Settings.read(); + case 6: final data:ClientSettings = data; - data.checkedCache = []; - Reflect.deleteField(data, "playerSize"); - Reflect.deleteField(data, "isExtendedPlayer"); - final oldCheck = data.checkboxes.find(item -> item.id == "cache-on-server"); - if (oldCheck != null) { - data.checkboxes.remove(oldCheck); - data.checkedCache.push(YoutubeType); - } + data.pageFullscreen = false; + data.fullscreenAction = Utils.isTouch(); case SETTINGS_VERSION, _: throw 'skipped version $version'; } @@ -169,6 +163,7 @@ class Main { function requestTime():Void { if (!isSyncActive) return; if (player.isListEmpty()) return; + if (!player.getCurrentItem().isPlayable()) return; send({type: GetTime}); } @@ -209,6 +204,7 @@ class Main { } function initListeners():Void { + chatInput = new ChatInput(this); Buttons.init(this); var openBtn:js.html.Element = js.Browser.document.getElementById("openChatBtn"); @@ -275,7 +271,7 @@ class Main { final voteSkip = getEl("#voteskip"); voteSkip.onclick = e -> { - if (Utils.isTouch() && !window.confirm(Lang.get("skipItemConfirm"))) return; + hasSeenSkipVote = true; if (player.isListEmpty()) return; final items = player.getItems(); final pos = player.getItemPos(); @@ -354,7 +350,7 @@ class Main { final mediaUrl:InputElement = getEl("#mediaurl"); final subsUrl:InputElement = getEl("#subsurl"); final checkboxTemp:InputElement = getEl("#addfromurl .add-temp"); - final isTemp = checkboxTemp.checked; + final isTemp = isTempChecked(checkboxTemp); final checkboxCache:InputElement = getEl("#cache-on-server"); final doCache = isAdmin() && checkboxCache.checked @@ -404,11 +400,12 @@ class Main { function addVideoArray(links:Array<String>, atEnd:Bool, isTemp:Bool, doCache:Bool):Void { if (links.length == 0) return; final link = links.shift(); - addVideo(link, atEnd, isTemp, doCache, () -> - addVideoArray(links, atEnd, isTemp, doCache)); + addVideo(link, atEnd, isTemp, doCache, () -> { + addVideoArray(links, atEnd, isTemp, doCache); + }); } - public function addVideo(url:String, atEnd:Bool, isTemp:Bool, doCache:Bool, ?callback:() -> Void):Void { + public function addVideo(url:String, atEnd:Bool, isTemp:Bool, doCache:Bool, ?afterSend:() -> Void):Void { final protocol = Browser.location.protocol; if (url.startsWith("/")) { final host = Browser.location.hostname; @@ -420,25 +417,13 @@ class Main { if (!url.startsWith("http")) url = '$protocol//$url'; } - final obj:VideoDataRequest = { - url: url, - atEnd: atEnd - }; - if (doCache) showProgressInfo(Lang.get("caching") + "..."); - player.getVideoData(obj, (data:VideoData) -> { - if (data.duration == 0) { - if (doCache) hideDynamicChin(); - serverMessage(Lang.get("addVideoError")); - return; - } - data.title ??= Lang.get("rawVideo"); - data.url ??= url; + function sendItem(data:VideoData):Void { send({ type: AddVideo, addVideo: { item: { - url: data.url, - title: data.title, + url: data.url ?? url, + title: data.title ?? "", author: personal.name, duration: data.duration, isTemp: isTemp, @@ -450,7 +435,76 @@ class Main { atEnd: atEnd } }); - if (callback != null) callback(); + if (afterSend != null) afterSend(); + } + + final isServerResolvesMeta = doCache && isServerCacheServiceEnabled(); + + // skip vk/vimeo api load if we cache anyway + final linkPlayerType = getLinkPlayerType(url); + if (isServerResolvesMeta && (linkPlayerType == VkType || linkPlayerType == VimeoType)) { + sendItem({ + duration: 0, + title: "", + url: url, + playerType: linkPlayerType + }); + return; + } + + final obj:VideoDataRequest = { + url: url, + atEnd: atEnd + }; + player.getVideoData(obj, (data:VideoData) -> { + if (data.duration == 0) { + if (!isServerResolvesMeta) { + serverMessage(Lang.get("addVideoError")); + return; + } + } else { + data.title ??= Lang.get("rawVideo"); + } + sendItem(data); + }); + } + + function isServerCacheServiceEnabled():Bool { + return playersCacheSupport.exists(type -> type != RawType); + } + + final activeUploads:Map<String, () -> Void> = []; + + public function registerUpload(url:String, cancel:() -> Void):Void { + activeUploads[url] = cancel; + } + + public function unregisterUpload(url:String):Void { + activeUploads.remove(url); + } + + function cancelUpload(url:String):Void { + final cancel = activeUploads[url] ?? return; + activeUploads.remove(url); + cancel(); + } + + public function addUploadedVideo(url:String, title:String, duration:Float, atEnd:Bool, isTemp:Bool):Void { + send({ + type: AddVideo, + addVideo: { + item: { + url: url, + title: title, + author: personal.name, + duration: duration, + isTemp: isTemp, + doCache: false, + isIncomplete: true, + playerType: RawType + }, + atEnd: atEnd + } }); } @@ -462,8 +516,6 @@ class Main { final mediaTitle:InputElement = getEl("#customembed-title"); final title = mediaTitle.value; mediaTitle.value = ""; - final checkbox:InputElement = getEl("#customembed .add-temp"); - final isTemp = checkbox.checked; final obj:VideoDataRequest = { url: iframe, atEnd: atEnd @@ -484,7 +536,7 @@ class Main { title: data.title, author: personal.name, duration: data.duration, - isTemp: isTemp, + isTemp: true, doCache: false, playerType: IframeType }, @@ -615,39 +667,57 @@ class Main { if (player.itemsLength() == 1) player.setVideo(0); case VideoLoaded: - lastState.paused = false; - lastState.pausedByServer = false; + resetSkipVoteUi(); lastState.time = 0; updateLastStateTime(); + updatePauseState(false); player.setTime(0); player.play(); // try to sync leader after with GetTime events if (isLeader() && !player.isVideoLoaded()) forceSyncNextTick = true; case RemoveVideo: + final isCurrent = player.getCurrentItem()?.url == data.removeVideo.url; + cancelUpload(data.removeVideo.url); player.removeItem(data.removeVideo.url); + if (isCurrent) resetSkipVoteUi(); + updateServerPauseUi(); if (player.isListEmpty()) player.pause(); case SkipVideo: + resetSkipVoteUi(); player.skipItem(data.skipVideo.url); + updateServerPauseUi(); if (player.isListEmpty()) player.pause(); + case SkipVideoStats: + if (data.skipVideoStats.voted == 1 && skipVoteCount == 0) { + serverMessage(Lang.get("skipVoteInitiated")); + } + skipVoteCount = data.skipVideoStats.voted; + skipVoteRequired = data.skipVideoStats.required; + + final stat = '($skipVoteCount/$skipVoteRequired)'; + getEl("#voteskip-text").textContent = '$stat ${Lang.get("voteForSkip")}'; + + final indicator = getEl("#playlist-menu-indicator"); + final dropdown = getEl("#playlist-menu-dropdown"); + if (!hasSeenSkipVote && dropdown.style.display == "none") { + indicator.style.display = ""; + } + case Pause: lastState.time = data.pause.time; - lastState.paused = true; updateLastStateTime(); - player.setPauseIndicator(lastState.paused); - updateUserList(); + updatePauseState(true, lastState.pausedByServer); if (isLeader()) return; player.pause(); player.setTime(data.pause.time); case Play: lastState.time = data.play.time; - lastState.paused = false; updateLastStateTime(); - player.setPauseIndicator(lastState.paused); - updateUserList(); + updatePauseState(false); if (isLeader()) return; final synchThreshold = settings.synchThreshold; final newTime = data.play.time; @@ -662,21 +732,11 @@ class Main { data.getTime.pausedByServer ??= false; data.getTime.rate ??= 1; - final isPauseChanged = lastState.paused != data.getTime.paused; lastState.time = data.getTime.time; - lastState.paused = data.getTime.paused; - lastState.pausedByServer = data.getTime.pausedByServer; lastState.rate = data.getTime.rate; updateLastStateTime(); - if (isPauseChanged) updateUserList(); - - final pausedByServer = data.getTime.pausedByServer; - if (pausedByServer) { - showServerUnpause(); - } else if (showingServerPause) { - hideDynamicChin(); - } + updatePauseState(data.getTime.paused, data.getTime.pausedByServer); if (player.getPlaybackRate() != data.getTime.rate) { player.setPlaybackRate(data.getTime.rate); @@ -729,8 +789,10 @@ class Main { updateUserList(); setLeaderButton(isLeader()); if (isLeader()) player.onSetTime(); + if (!hasLeader()) updatePauseState(lastState.paused); case PlayItem: + resetSkipVoteUi(); player.setVideo(data.playItem.pos); case SetNextItem: @@ -743,7 +805,9 @@ class Main { clearChat(); case ClearPlaylist: + resetSkipVoteUi(); player.clearItems(); + updateServerPauseUi(); if (player.isListEmpty()) player.pause(); case ShufflePlaylist: // server-only @@ -762,25 +826,14 @@ class Main { public function onProgressEvent(data:WsEvent):Void { final data = data.progress; - final text = switch data.type { - case Caching: - final caching = Lang.get("caching"); - final name = data.data; - '$caching $name'; - case Downloading: Lang.get("downloading"); - case Uploading: Lang.get("uploading"); + final url = data.url ?? return; + switch data.type { + case Caching, Downloading, Uploading: + player.setItemProgress(url, data.ratio); + case Completed: + player.completeItem(url); case Canceled: - hideDynamicChin(); - return; - } - final percent = (data.ratio * 100).toFixed(1); - var text = '$text...'; - if (percent > 0) text += ' $percent%'; - showProgressInfo(text); - if (data.ratio == 1) { - Timer.delay(() -> { - hideDynamicChin(); - }, 500); + player.removeItem(url); } } @@ -1109,10 +1162,11 @@ class Main { if (client.isLeader) list.add('<ion-icon name="$iconName"></ion-icon>'); var klass = client.isBanned ? "userlist_banned" : ""; if (client.isAdmin) klass += " userlist_owner"; - list.add('<span class="$klass">${client.name}</span></div>'); + list.add('<span class="$klass">${client.name.htmlEscape()}</span></div>'); } 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<Element> { @@ -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<Client> { + return clients.getLeader(); + } + + public static function isTempChecked(checkbox:Null<InputElement>):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<T:Element>(id:String):T { + public static inline function getEl<T:Element>(id:String):Null<T> { 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 = '<span class="paused-by">${prefix.htmlEscape()}</span>'; + 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 ? '<div class="qe_progress"></div>' : ""; final itemEl = Utils.nodeFromString( - '<li class="queue_entry info" title="${Lang.get("addedBy")}: $author"> - <header> + '<li class="queue_entry info$incompleteClass" title="${Lang.get("addedBy")}: $author"> + $progressEl<header> <span class="qe_time">$duration</span> <h4><a class="qe_title" href="$url" target="_blank">${item.title.htmlEscape()}</a></h4> <small class="qe_author">${Lang.get("addedBy")}: $author</small> @@ -460,19 +514,44 @@ class Player { } } - function removeElementItem(url:String):Void { + function findItemElement(url:String):Null<Element> { 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("<iframe") || trimmed.startsWith("<object")) return true; + return false; } public function getVideoData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void { + final rawUrl = data.url.trim(); + final channel = extractTwitchChannel(rawUrl); + if (channel.length > 0) { + final iframeStr = '<iframe src="https://player.twitch.tv/?channel=$channel&parent=www.example.com" frameborder="0" allowfullscreen="true" scrolling="no" height="378" width="620"></iframe>'; + 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<Void>; + function pause():Promise<Void>; + function setCurrentTime(seconds:Float):Promise<Float>; + function getCurrentTime():Promise<Float>; + function getDuration():Promise<Float>; + function setVolume(volume:Float):Promise<Float>; + function getVolume():Promise<Float>; + function setMuted(muted:Bool):Promise<Bool>; + function getMuted():Promise<Bool>; + function setPlaybackRate(rate:Float):Promise<Float>; + function getPlaybackRate():Promise<Float>; + function destroy():Promise<Void>; + 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<String, Bool> = []; 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<Timer>; - var isServerPause = false; var activeSkipVoteUrl:Null<String>; final skipVotes:Map<Int, Bool> = []; @@ -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<String, Bool> = []; 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<String, () -> 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<String>, 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<String> = []; + 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<String>, 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<String>, outName:String, client:Client, callback:String->Void) { + function buildTsFiles(tempFiles:Array<String>, 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<String>):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<YtDlp>; + 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<ChildProcessObject> = 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<String> = 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<VideoInfo> { - return cast ytDlp.getInfoAsync(url, cast { - cookies: useCookies ? getCookiesPathOrNull() : null, - additionalOptions: ["--no-js-runtimes", "--js-runtimes", main.config.ytDlp.jsRuntime], - }); - } - - function getCookiesPathOrNull():Null<String> { - final cookiesPath = '${main.userDir}/cookies.txt'; - return FileSystem.exists(cookiesPath) ? cookiesPath : null; - } - function getBestYoutubeVideoFormat(formats:Array<VideoFormat>, ?ignoreQualities:Array<Int>):Null<VideoFormat> { 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<YtDlp>; + + 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<VideoInfo> { + return cast ytDlp.getInfoAsync(url, cast { + cookies: useCookies ? getCookiesPathOrNull() : null, + additionalOptions: ytExtraOptions(), + }); + } + + public function getCookiesPathOrNull():Null<String> { + final cookiesPath = '${main.userDir}/cookies.txt'; + return FileSystem.exists(cookiesPath) ? cookiesPath : null; + } + + public function ytExtraOptions():Array<String> { + return ["--no-js-runtimes", "--js-runtimes", main.config.ytDlp.jsRuntime]; + } + + /** + Runs `yt-dlp <url> --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<ChildProcessObject> = 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<Float> = 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<WsEvent>; + + function setup():Void { + // Keep voting tests independent of HTTP ports, files and playback timers. + server = Type.createEmptyInstance(Main); + events = []; + final permissions:Array<Permission> = [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<Int, Bool>()); + 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()); + } +} |
