diff options
Diffstat (limited to 'src/client')
| -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 |
12 files changed, 1324 insertions, 253 deletions
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); + } +} |
