From 304bd1e77a5442f614aacd2b35f330777118b2cd Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Mon, 19 Oct 2026 15:32:53 -0700 Subject: merge with upstream https://github.com/RblSb/SyncTube --- src/client/Main.hx | 357 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 228 insertions(+), 129 deletions(-) (limited to 'src/client/Main.hx') 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 = []; 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, 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 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(''); var klass = client.isBanned ? "userlist_banned" : ""; if (client.isAdmin) klass += " userlist_owner"; - list.add('${client.name}'); + list.add('${client.name.htmlEscape()}'); } final userlist = getEl("#userlist"); userlist.innerHTML = list.toString(); + if (player != null) player.updateTitle(); } function getPageTitle():String { @@ -1184,6 +1238,7 @@ class Main { showScrollToChatEndBtn(); } if (onBlinkTab == null) blinkTabWithTitle('*${Lang.get("chat")}*'); + if (name != personal.name) Buttons.onNewChatMessage(); } function getFirstMessageDiv():Null { @@ -1214,34 +1269,57 @@ class Main { }, {once: true}); } - public function showProgressInfo(text:String):Void { - final chin = getEl("#dynamic-chin"); - var div = chin.querySelector("#progress-info"); - if (div == null) { - div = document.createDivElement(); - div.id = "progress-info"; - chin.prepend(div); - } - div.textContent = text; - showDynamicChin(); + public function resetSkipVoteUi():Void { + hasSeenSkipVote = false; + skipVoteCount = 0; + skipVoteRequired = 0; + getEl("#voteskip-text").textContent = Lang.get("voteForSkip"); + getEl("#playlist-menu-indicator").style.display = "none"; + } + + public function updatePauseState(paused:Bool, pausedByServer = false):Void { + final isPauseChanged = lastState.paused != paused; + lastState.paused = paused; + lastState.pausedByServer = pausedByServer; + + player.setPauseIndicator(lastState.paused); + if (isPauseChanged) updateUserList(); + + updateServerPauseUi(); + } + + public function updateServerPauseUi():Void { + final show = lastState.pausedByServer && !player.isListEmpty(); + if (showingServerPause == show) return; + showingServerPause = show; + renderDynamicChin(); } - public function showServerUnpause():Void { - if (showingServerPause) return; - showingServerPause = true; + public function renderDynamicChin():Void { final chin = getEl("#dynamic-chin"); + if (!showingServerPause) { + hideDynamicChinAnim(); + return; + } chin.innerHTML = ""; + final wrapper = document.createDivElement(); + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + wrapper.style.gap = "0.5rem"; + final div = document.createDivElement(); div.className = "server-whisper"; + div.style.textAlign = "center"; div.textContent = Lang.get("leaderDisconnectedServerOnPause"); - chin.appendChild(div); + wrapper.appendChild(div); + final btn = document.createButtonElement(); btn.id = "unpause-server"; btn.textContent = Lang.get("unpause"); - chin.appendChild(btn); + wrapper.appendChild(btn); btn.onclick = () -> { - hideDynamicChin(); + updatePauseState(lastState.paused, false); send({ type: SetLeader, setLeader: { @@ -1250,11 +1328,12 @@ class Main { }); JsApi.once(SetLeader, event -> removeLeader()); } + chin.appendChild(wrapper); - showDynamicChin(); + showDynamicChinAnim(); } - function showDynamicChin():Void { + function showDynamicChinAnim():Void { final chin = getEl("#dynamic-chin"); if (chin.style.display == "") return; chin.style.display = ""; @@ -1275,9 +1354,9 @@ class Main { chin.addEventListener("transitionend", onTransitionEnd); } - public function hideDynamicChin():Void { - showingServerPause = false; + function hideDynamicChinAnim():Void { final chin = getEl("#dynamic-chin"); + if (chin.style.display == "none") return; final h = chin.clientHeight; chin.style.height = '${h}px'; Timer.delay(() -> { @@ -1342,15 +1421,26 @@ class Main { /* Returns `true` if text should not be sent to chat */ public function handleCommands(command:String):Bool { if (!command.startsWith("/")) return false; - final args = command.trim().split(" "); - command = args.shift().substr(1); - if (command.length == 0) return false; + final parsed = Commands.parse(command); + final args = parsed.args; + switch (parsed.cmd?.name) { + case null: + final cmdNameWithoutSlash = parsed.rawCmd.substr(1); + if (matchSimpleDate.match(cmdNameWithoutSlash)) { + send({ + type: Rewind, + rewind: { + time: parseSimpleDate(cmdNameWithoutSlash) + } + }); + } + return false; - switch (command) { - case "help": + case Help: showChatHintList(); return true; - case "ban": + + case Ban: mergeRedundantArgs(args, 0, 2); final name = args[0]; final time = parseSimpleDate(args[1]); @@ -1363,7 +1453,8 @@ class Main { } }); return true; - case "unban", "removeBan": + + case Unban: mergeRedundantArgs(args, 0, 1); final name = args[0]; send({ @@ -1374,7 +1465,8 @@ class Main { } }); return true; - case "kick": + + case Kick: mergeRedundantArgs(args, 0, 1); final name = args[0]; send({ @@ -1384,10 +1476,11 @@ class Main { } }); return true; - case "clear": + + case Clear: send({type: ClearChat}); return true; - case "giveup": + case Giveup: mergeRedundantArgs(args, 0, 1); final name = args[0] ?? ""; send({ @@ -1397,13 +1490,17 @@ class Main { } }); return true; - case "flashback", "fb": + + + case Flashback: send({type: Flashback}); return false; - case "ad": + + case Ad: player.skipAd(); return false; - case "volume": + + case Volume: var v = Std.parseFloat(args[0]); if (Math.isNaN(v)) v = 1; v = v.clamp(0, 3); @@ -1418,23 +1515,15 @@ class Main { final rawPlayer = @:privateAccess player.rawPlayer; rawPlayer.boostVolume(v); return true; - case "dump": + + case Dump: send({type: Dump}); return true; - case "crash": + + case Crash: send({type: CrashTest}); return true; } - if (matchSimpleDate.match(command)) { - send({ - type: Rewind, - rewind: { - time: parseSimpleDate(command) - } - }); - return false; - } - return false; } final matchSimpleDate = ~/^-?([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s?)?$/; @@ -1553,6 +1642,16 @@ class Main { return clients.hasLeader(); } + public function getLeader():Null { + return clients.getLeader(); + } + + public static function isTempChecked(checkbox:Null):Bool { + if (checkbox == null) return true; + final isHidden = checkbox.style.display == "none"; + return isHidden ? true : checkbox.checked; + } + public function hasLeaderOnPauseRequest():Bool { final hasAccess = isPageVisible && !isPageUnloading; return config.requestLeaderOnPause && hasAccess; @@ -1593,7 +1692,7 @@ class Main { } @:generic - public static inline function getEl(id:String):T { + public static inline function getEl(id:String):Null { return cast document.querySelector(id); } } -- cgit v1.2.3