aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-10-19 15:32:53 -0700
committerPinapelz <yukais@pinapelz.com>2026-10-19 15:32:53 -0700
commit304bd1e77a5442f614aacd2b35f330777118b2cd (patch)
tree970e7da13bb1bd8edf1f9b453af4d0994c665e94 /src
parenta4f3064542ddad6939598e7e60ca0090be07df60 (diff)
merge with upstream https://github.com/RblSb/SyncTubeHEADmaster
Diffstat (limited to 'src')
-rw-r--r--src/ClientTools.hx11
-rw-r--r--src/Types.hx21
-rw-r--r--src/VideoList.hx6
-rw-r--r--src/client/Buttons.hx203
-rw-r--r--src/client/ChatInput.hx188
-rw-r--r--src/client/ClientSettings.hx2
-rw-r--r--src/client/Commands.hx182
-rw-r--r--src/client/FileUploader.hx96
-rw-r--r--src/client/InputWithHistory.hx11
-rw-r--r--src/client/JsApi.hx18
-rw-r--r--src/client/Main.hx357
-rw-r--r--src/client/Player.hx153
-rw-r--r--src/client/Utils.hx36
-rw-r--r--src/client/players/Iframe.hx29
-rw-r--r--src/client/players/Vimeo.hx302
-rw-r--r--src/server/HttpServer.hx26
-rw-r--r--src/server/Main.hx243
-rw-r--r--src/server/Utils.hx2
-rw-r--r--src/server/VideoTimer.hx4
-rw-r--r--src/server/cache/Cache.hx39
-rw-r--r--src/server/cache/CacheCallbacks.hx15
-rw-r--r--src/server/cache/RawCache.hx158
-rw-r--r--src/server/cache/YoutubeCache.hx156
-rw-r--r--src/server/cache/YtdlpCache.hx227
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 = ~/&lt;[^&]+&gt;/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);
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage