diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ClientTools.hx | 11 | ||||
| -rw-r--r-- | src/Types.hx | 21 | ||||
| -rw-r--r-- | src/VideoList.hx | 6 | ||||
| -rw-r--r-- | src/client/Buttons.hx | 203 | ||||
| -rw-r--r-- | src/client/ChatInput.hx | 188 | ||||
| -rw-r--r-- | src/client/ClientSettings.hx | 2 | ||||
| -rw-r--r-- | src/client/Commands.hx | 182 | ||||
| -rw-r--r-- | src/client/FileUploader.hx | 96 | ||||
| -rw-r--r-- | src/client/InputWithHistory.hx | 11 | ||||
| -rw-r--r-- | src/client/JsApi.hx | 18 | ||||
| -rw-r--r-- | src/client/Main.hx | 357 | ||||
| -rw-r--r-- | src/client/Player.hx | 153 | ||||
| -rw-r--r-- | src/client/Utils.hx | 36 | ||||
| -rw-r--r-- | src/client/players/Iframe.hx | 29 | ||||
| -rw-r--r-- | src/client/players/Vimeo.hx | 302 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 26 | ||||
| -rw-r--r-- | src/server/Main.hx | 243 | ||||
| -rw-r--r-- | src/server/Utils.hx | 2 | ||||
| -rw-r--r-- | src/server/VideoTimer.hx | 4 | ||||
| -rw-r--r-- | src/server/cache/Cache.hx | 39 | ||||
| -rw-r--r-- | src/server/cache/CacheCallbacks.hx | 15 | ||||
| -rw-r--r-- | src/server/cache/RawCache.hx | 158 | ||||
| -rw-r--r-- | src/server/cache/YoutubeCache.hx | 156 | ||||
| -rw-r--r-- | src/server/cache/YtdlpCache.hx | 227 |
24 files changed, 1989 insertions, 496 deletions
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); + } +} |
