aboutsummaryrefslogtreecommitdiffstats
path: root/src/client
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/client
parenta4f3064542ddad6939598e7e60ca0090be07df60 (diff)
merge with upstream https://github.com/RblSb/SyncTubeHEADmaster
Diffstat (limited to 'src/client')
-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
12 files changed, 1324 insertions, 253 deletions
diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx
index 82054ab..61f0367 100644
--- a/src/client/Buttons.hx
+++ b/src/client/Buttons.hx
@@ -5,9 +5,11 @@ import haxe.Timer;
import js.Browser.document;
import js.Browser.window;
import js.html.Element;
+import js.html.Event;
import js.html.ImageElement;
import js.html.InputElement;
import js.html.KeyboardEvent;
+import js.html.MouseEvent;
import js.html.TransitionEvent;
import js.html.VisualViewport;
@@ -23,11 +25,11 @@ class Buttons {
split = new Split(settings);
split.setSize(settings.chatSize);
- initChatInputs(main);
+ initInputs(main);
for (item in settings.checkboxes) {
if (item.checked == null) continue;
- final checkbox:InputElement = getEl('#${item.id}') ?? continue;
+ final checkbox = (getEl('#${item.id}') : InputElement) ?? continue;
checkbox.checked = item.checked;
}
@@ -137,20 +139,30 @@ class Buttons {
getEl("#userlist-wrap").style.transition = "200ms";
}, 0);
+ final playlistMenuBtn = getEl("#playlist-menu-btn");
+ final playlistMenuDropdown = getEl("#playlist-menu-dropdown");
+
+ inline function closePlaylistMenu() {
+ playlistMenuDropdown.style.display = "none";
+ playlistMenuBtn.classList.remove("active");
+ }
+
final toggleSynch = getEl("#togglesynch");
toggleSynch.onclick = e -> {
+ closePlaylistMenu();
final icon = toggleSynch.firstElementChild;
if (main.isSyncActive) {
if (!window.confirm(Lang.get("toggleSynchConfirm"))) return;
main.isSyncActive = false;
icon.style.color = "rgba(238, 72, 67, 0.75)";
- icon.setAttribute("name", "pause");
+ playlistMenuBtn.classList.add("danger");
} else {
main.isSyncActive = true;
icon.style.color = "";
- icon.setAttribute("name", "play");
+ playlistMenuBtn.classList.remove("danger");
main.send({type: UpdatePlaylist});
}
+ updateToggleSynchBtn(main);
}
final mediaRefresh = getEl("#mediarefresh");
mediaRefresh.onclick = e -> {
@@ -158,11 +170,18 @@ class Buttons {
}
final fullscreenBtn = getEl("#fullscreenbtn");
fullscreenBtn.onclick = e -> {
- if ((Utils.isTouch() || main.isVerbose()) && !Utils.hasFullscreen()) {
- window.scrollTo(0, 0);
- Utils.requestFullscreen(document.documentElement);
+ Utils.toggleFullscreen(getEl("#ytapiplayer"));
+ }
+ final chatBtn = getEl("#fullscreen-chat-btn");
+ chatBtn.onclick = e -> {
+ e.stopPropagation();
+ e.preventDefault();
+ clearUnreadChatMessages();
+
+ if (settings.pageFullscreen) {
+ Utils.switchToPageFullscreen();
} else {
- Utils.requestFullscreen(getEl("#ytapiplayer"));
+ Utils.cancelFullscreen();
}
}
initPageFullscreen();
@@ -176,13 +195,39 @@ class Buttons {
icon.setAttribute("name", "link");
}, 2000);
}
+
+ playlistMenuBtn.onclick = e -> {
+ e.stopPropagation();
+ final isOpen = playlistMenuDropdown.style.display != "none";
+ if (isOpen) {
+ closePlaylistMenu();
+ } else {
+ main.hasSeenSkipVote = true;
+ playlistMenuDropdown.style.display = "";
+ playlistMenuBtn.classList.add("active");
+ getEl("#playlist-menu-indicator").style.display = "none";
+ }
+ }
+
+ document.addEventListener("click", e -> {
+ final target:Element = cast e.target;
+ if (!playlistMenuBtn.contains(target) && !playlistMenuDropdown.contains(target)) {
+ closePlaylistMenu();
+ }
+ });
+
+ final voteSkip = getEl("#voteskip");
+ voteSkip.addEventListener("click", () -> closePlaylistMenu());
+
final clearPlaylist = getEl("#clearplaylist");
clearPlaylist.onclick = e -> {
+ closePlaylistMenu();
if (!window.confirm(Lang.get("clearPlaylistConfirm"))) return;
main.send({type: ClearPlaylist});
}
final shufflePlaylist = getEl("#shuffleplaylist");
shufflePlaylist.onclick = e -> {
+ closePlaylistMenu();
if (!window.confirm(Lang.get("shufflePlaylistConfirm"))) return;
main.send({type: ShufflePlaylist});
}
@@ -200,6 +245,7 @@ class Buttons {
final showMediaUrl = getEl("#showmediaurl");
showMediaUrl.onclick = e -> {
+ closePlaylistMenu();
final isOpen = showPlayerGroup(showMediaUrl);
if (isOpen) Timer.delay(() -> {
getEl("#addfromurl").scrollIntoView();
@@ -209,6 +255,7 @@ class Buttons {
final showCustomEmbed = getEl("#showcustomembed");
showCustomEmbed.onclick = e -> {
+ closePlaylistMenu();
final isOpen = showPlayerGroup(showCustomEmbed);
if (isOpen) Timer.delay(() -> {
getEl("#customembed").scrollIntoView();
@@ -343,6 +390,40 @@ class Buttons {
}
updateHotkeysBtn();
+ final pageFullscreenBtn = getEl("#pageFullscreenBtn");
+ pageFullscreenBtn.onclick = e -> {
+ settings.pageFullscreen = !settings.pageFullscreen;
+ Settings.write(settings);
+ updatePageFullscreenBtn();
+
+ if (settings.pageFullscreen) {
+ Utils.switchToPageFullscreen();
+ } else {
+ Utils.cancelFullscreen();
+ }
+ }
+ updatePageFullscreenBtn();
+
+ final fullscreenActionBtn = getEl("#fullscreenActionBtn");
+ if (fullscreenActionBtn != null) {
+ fullscreenActionBtn.onclick = e -> {
+ settings.fullscreenAction = !settings.fullscreenAction;
+ Settings.write(settings);
+ updateFullscreenActionBtn();
+ }
+ updateFullscreenActionBtn();
+ }
+
+ document.body.addEventListener("click", e -> {
+ if (!settings.pageFullscreen) return;
+ if (Utils.hasFullscreen()) return;
+ final target:Element = cast e.target;
+ if (target != null
+ && target.closest("button, input, a, textarea, select, #optionsPanel, #chatline, #userlist, .queue_sortable, .nav") != null)
+ return;
+ Utils.requestFullscreen(document.documentElement);
+ });
+
final removeBtn = getEl("#removePlayerBtn");
removeBtn.onclick = e -> {
final isActive = main.toggleVideoElement();
@@ -367,6 +448,7 @@ class Buttons {
JsApi.setVideoSrc(url);
});
}
+ updateToggleSynchBtn(main);
}
public static function initHotkeys(main:Main, player:Player):Void {
@@ -385,6 +467,8 @@ class Buttons {
switch (key) {
case R:
getEl("#mediarefresh").onclick();
+ case U:
+ getEl("#togglesynch").onclick();
case S:
getEl("#voteskip").onclick();
case C:
@@ -417,12 +501,52 @@ class Buttons {
}
static function updateHotkeysBtn():Void {
- final text = Lang.get("hotkeys");
- final state = settings.hotkeysEnabled ? Lang.get("on") : Lang.get("off");
- getEl("#hotkeysBtn").innerText = '$text: $state';
+ final btn = getEl("#hotkeysBtn");
+ var switchEl = btn.querySelector(".toggle-switch");
+ if (switchEl == null) {
+ final text = Lang.get("hotkeys");
+ btn.innerHTML = '<span>$text</span><div class="toggle-switch"></div>';
+ switchEl = btn.querySelector(".toggle-switch");
+ }
+
+ switchEl.classList.toggle("on", settings.hotkeysEnabled);
+ document.body.classList.toggle("hotkeys-disabled", !settings.hotkeysEnabled);
+ }
+
+ static function updatePageFullscreenBtn():Void {
+ final btn = getEl("#pageFullscreenBtn");
+ var switchEl = btn.querySelector(".toggle-switch");
+ if (switchEl == null) {
+ final text = Lang.get("pageFullscreen");
+ btn.innerHTML = '<span>$text</span><div class="toggle-switch"></div>';
+ switchEl = btn.querySelector(".toggle-switch");
+ }
+ switchEl.classList.toggle("on", settings.pageFullscreen);
}
- static function initChatInputs(main:Main):Void {
+ static function updateFullscreenActionBtn():Void {
+ final btn = getEl("#fullscreenActionBtn");
+ var switchEl = btn.querySelector(".toggle-switch");
+ if (switchEl == null) {
+ final text = Lang.get(Utils.isTouch() ? "fullscreenActionTouch" : "fullscreenActionDesktop");
+ btn.innerHTML = '<span>$text</span><div class="toggle-switch"></div>';
+ switchEl = btn.querySelector(".toggle-switch");
+ }
+ switchEl.classList.toggle("on", settings.fullscreenAction);
+ }
+
+ public static function updateToggleSynchBtn(main:Main):Void {
+ final toggleSynch = getEl("#togglesynch");
+ final span = toggleSynch.querySelector("span");
+ final textKey = main.isSyncActive ? "disableVideoSync" : "enableVideoSync";
+ final text = Lang.get(textKey);
+ toggleSynch.title = '$text (Alt-U)';
+ if (span != null) {
+ span.innerHTML = '$text <span class="hotkey-hint">(Alt-U)</span>';
+ }
+ }
+
+ static function initInputs(main:Main):Void {
final guestName:InputElement = getEl("#guestname");
guestName.onkeydown = e -> {
if (e.keyCode == KeyCode.Return) {
@@ -440,37 +564,12 @@ class Buttons {
}
}
- final chatline:InputElement = getEl("#chatline");
- chatline.onfocus = e -> {
- if (Utils.isIOS()) {
- // final startY = window.scrollY;
- final startY = 0;
- Timer.delay(() -> {
- window.scrollBy(0, -(window.scrollY - startY));
- getEl("#video").scrollTop = 0;
- main.scrollChatToEnd();
- }, 100);
- } else if (Utils.isTouch()) {
- main.scrollChatToEnd();
- }
- }
final viewport = getVisualViewport();
if (viewport != null) {
viewport.addEventListener("resize", e -> onViewportResize());
onViewportResize();
}
- new InputWithHistory(chatline, 50, value -> {
- if (main.handleCommands(value)) return true;
- main.send({
- type: Message,
- message: {
- clientName: "",
- text: value
- }
- });
- if (Utils.isTouch()) chatline.blur();
- return true;
- });
+
final checkboxes:Array<InputElement> = [
getEl("#add-temp"),
];
@@ -498,12 +597,32 @@ class Buttons {
return (window : Dynamic).visualViewport;
}
+ public static var hasUnreadChatMessages:Bool = false;
+
+ public static function onNewChatMessage():Void {
+ hasUnreadChatMessages = true;
+ updateFullscreenChatBtn();
+ }
+
+ public static function clearUnreadChatMessages():Void {
+ hasUnreadChatMessages = false;
+ updateFullscreenChatBtn();
+ }
+
+ public static function updateFullscreenChatBtn():Void {
+ final chatBtn = getEl("#fullscreen-chat-btn");
+ final isFs = Utils.hasFullscreen();
+ if (isFs && hasUnreadChatMessages) {
+ chatBtn.classList.add("has-unread");
+ } else {
+ chatBtn.classList.remove("has-unread");
+ }
+ }
+
static function initPageFullscreen():Void {
document.onfullscreenchange = e -> {
- final el = document.documentElement;
- if (Utils.hasFullscreen()) {
- if (e.target == el) el.classList.add("mobile-view");
- } else el.classList.remove("mobile-view");
+ clearUnreadChatMessages();
+ updateFullscreenChatBtn();
}
}
}
diff --git a/src/client/ChatInput.hx b/src/client/ChatInput.hx
new file mode 100644
index 0000000..61fb95e
--- /dev/null
+++ b/src/client/ChatInput.hx
@@ -0,0 +1,188 @@
+package client;
+
+import client.Main.getEl;
+import haxe.Timer;
+import js.Browser.document;
+import js.Browser.window;
+import js.html.Element;
+import js.html.Event;
+import js.html.InputElement;
+import js.html.KeyboardEvent;
+
+class ChatInput {
+ public final chatline:InputElement;
+ public final commandsWrap:Element;
+ public final commandsList:Element;
+ public final inputWithHistory:InputWithHistory;
+
+ final main:Main;
+ var filteredCommands:Array<Commands.CommandInfo> = [];
+ var selectedCommandIndex = -1;
+
+ public function new(main:Main) {
+ this.main = main;
+ chatline = getEl("#chatline");
+ commandsWrap = getEl("#chat-commands-wrap");
+ commandsList = getEl("#chat-commands-list");
+
+ setupFocusHandler();
+ setupAutocomplete();
+
+ inputWithHistory = new InputWithHistory(chatline, 50, onEnter, onInterceptKeyDown);
+ }
+
+ function setupFocusHandler():Void {
+ chatline.onfocus = e -> {
+ if (Utils.isIOS()) {
+ final startY = 0;
+ Timer.delay(() -> {
+ window.scrollBy(0, -(window.scrollY - startY));
+ getEl("#video").scrollTop = 0;
+ main.scrollChatToEnd();
+ }, 100);
+ } else if (Utils.isTouch()) {
+ main.scrollChatToEnd();
+ }
+ };
+ }
+
+ function setupAutocomplete():Void {
+ commandsList.addEventListener("mousemove", e -> {
+ commandsList.classList.remove("keyboard-nav");
+ });
+
+ chatline.addEventListener("input", e -> {
+ final val = chatline.value;
+ if (val.startsWith("/")) {
+ filteredCommands = Commands.filterForAutocomplete(val);
+ selectedCommandIndex = filteredCommands.length > 0 ? 0 : -1;
+ renderCommands();
+ } else {
+ commandsWrap.style.display = "none";
+ selectedCommandIndex = -1;
+ }
+ });
+
+ chatline.addEventListener("blur", e -> {
+ commandsWrap.style.display = "none";
+ });
+ }
+
+ function renderCommands():Void {
+ commandsList.innerHTML = "";
+ if (filteredCommands.length == 0) {
+ commandsWrap.style.display = "none";
+ return;
+ }
+
+ final parsed = Commands.parse(chatline.value);
+ final currentArgIndex = parsed.currentArgIndex;
+
+ for (i in 0...filteredCommands.length) {
+ final cmd = filteredCommands[i];
+ final item = document.createDivElement();
+ item.className = "chat-command-item" + (i == selectedCommandIndex ? " active" : "");
+ item.onclick = e -> {
+ chatline.value = cmd.name + (cmd.args > 0 ? " " : "");
+ chatline.focus();
+ if (cmd.args == 0) {
+ commandsWrap.style.display = "none";
+ selectedCommandIndex = -1;
+ } else {
+ chatline.dispatchEvent(new Event("input"));
+ }
+ return;
+ };
+ item.onmouseenter = e -> {
+ if (!commandsList.classList.contains("keyboard-nav")) {
+ selectedCommandIndex = i;
+ for (j in 0...commandsList.children.length) {
+ final child = commandsList.children[j];
+ if (j == i) child.classList.add("active");
+ else child.classList.remove("active");
+ }
+ }
+ };
+
+ final nameDiv = document.createDivElement();
+ nameDiv.className = "chat-command-item-name";
+ nameDiv.innerText = cmd.name.toString();
+
+ final descDiv = document.createDivElement();
+ descDiv.className = "chat-command-item-desc";
+ descDiv.innerHTML = Commands.formatDescWithHighlight(Lang.get(cmd.lang), currentArgIndex);
+
+ item.appendChild(nameDiv);
+ item.appendChild(descDiv);
+ commandsList.appendChild(item);
+ }
+ commandsWrap.style.display = "";
+ if (selectedCommandIndex >= 0 && commandsList.children.length > selectedCommandIndex) {
+ final selectedEl:Element = cast commandsList.children[selectedCommandIndex];
+ untyped selectedEl.scrollIntoView({block: "center"});
+ }
+ }
+
+ function onEnter(value:String):Bool {
+ if (main.handleCommands(value)) return true;
+ main.send({
+ type: Message,
+ message: {
+ clientName: "",
+ text: value
+ }
+ });
+ if (Utils.isTouch()) chatline.blur();
+ commandsWrap.style.display = "none";
+ return true;
+ }
+
+ function onInterceptKeyDown(e:KeyboardEvent):Bool {
+ if (commandsWrap.style.display == "none") return false;
+ final key:KeyCode = cast e.keyCode;
+ switch key {
+ case Up:
+ commandsList.classList.add("keyboard-nav");
+ if (selectedCommandIndex > 0) selectedCommandIndex--;
+ else selectedCommandIndex = filteredCommands.length - 1;
+ renderCommands();
+ e.preventDefault();
+ return true;
+ case Down:
+ commandsList.classList.add("keyboard-nav");
+ if (selectedCommandIndex < filteredCommands.length - 1) selectedCommandIndex++;
+ else selectedCommandIndex = 0;
+ renderCommands();
+ e.preventDefault();
+ return true;
+ case Return, Tab:
+ if (selectedCommandIndex < 0 || selectedCommandIndex >= filteredCommands.length) {
+ return false;
+ }
+ final selectedCmd = filteredCommands[selectedCommandIndex];
+ final val = chatline.value;
+ final hasSpace = val.indexOf(" ") >= 0;
+
+ if (key == Return) {
+ if (hasSpace
+ || (selectedCmd.args == 0
+ && val.trim().toLowerCase() == selectedCmd.name.toString())) {
+ return false;
+ }
+ }
+
+ if (!hasSpace) {
+ chatline.value = selectedCmd.name + (selectedCmd.args > 0 ? " " : "");
+ }
+
+ e.preventDefault();
+ chatline.dispatchEvent(new Event("input"));
+ return true;
+ case Escape:
+ commandsWrap.style.display = "none";
+ return false;
+ case _:
+ return false;
+ }
+ }
+}
diff --git a/src/client/ClientSettings.hx b/src/client/ClientSettings.hx
index 1bca427..0ade401 100644
--- a/src/client/ClientSettings.hx
+++ b/src/client/ClientSettings.hx
@@ -14,6 +14,8 @@ typedef ClientSettings = {
var latestLinks:Array<String>;
var latestSubs:Array<String>;
var hotkeysEnabled:Bool;
+ var pageFullscreen:Bool;
+ var fullscreenAction:Bool;
var showHintList:Bool;
var checkboxes:Array<{id:String, checked:Null<Bool>}>;
var checkedCache:Array<PlayerType>;
diff --git a/src/client/Commands.hx b/src/client/Commands.hx
new file mode 100644
index 0000000..5f1d578
--- /dev/null
+++ b/src/client/Commands.hx
@@ -0,0 +1,182 @@
+package client;
+
+enum abstract CommandId(String) {
+ var Help = "/help";
+ var Clear = "/clear";
+ var Ban = "/ban";
+ var Unban = "/unban";
+ var Kick = "/kick";
+ var Ad = "/ad";
+ var Volume = "/volume";
+ var Dump = "/dump";
+ var Crash = "/crash";
+ var Flashback = "/fb";
+ var Giveup = "/giveup";
+
+ public inline function toString():String {
+ return this;
+ }
+}
+
+@:structInit
+class CommandInfo {
+ public final name:CommandId;
+ public final aliases:Array<String>;
+ public final args:Int;
+ public final lang:String;
+
+ public function new(name:CommandId, args = 0, ?aliases:Array<String>) {
+ this.name = name;
+ this.args = args;
+ this.aliases = aliases ?? [];
+ lang = getLangId();
+ }
+
+ function getLangId():String {
+ final withoutSlash = name.toString().substr(1);
+ return "cmd" + withoutSlash.charAt(0).toUpperCase() + withoutSlash.substr(1);
+ }
+}
+
+typedef ParsedCommand = {
+ final cmd:Null<CommandInfo>;
+ final rawCmd:String;
+ final args:Array<String>;
+ final currentArgIndex:Int;
+}
+
+class Commands {
+ static final list:Array<CommandInfo> = [
+ {
+ name: Help,
+ },
+ {
+ name: Clear,
+ },
+ {
+ name: Ban,
+ args: 2,
+ },
+ {
+ name: Unban,
+ args: 1,
+ },
+ {
+ name: Kick,
+ args: 1,
+ },
+ {
+ name: Ad,
+ },
+ {
+ name: Volume,
+ args: 1,
+ },
+ {
+ name: Dump,
+ },
+ {
+ name: Crash,
+ },
+ {
+ name: Giveup,
+ args: 1,
+ },
+ {
+ name: Flashback,
+ aliases: ["/flashback"],
+ }
+ ];
+
+ public static function find(name:String):Null<CommandInfo> {
+ final lower = name.toLowerCase();
+ for (c in list) {
+ if (c.name.toString() == lower) return c;
+ for (a in c.aliases) {
+ if (a.toLowerCase() == lower) return c;
+ }
+ }
+ return null;
+ }
+
+ public static function parse(input:String):ParsedCommand {
+ if (!input.startsWith("/")) {
+ return {
+ cmd: null,
+ rawCmd: "",
+ args: [],
+ currentArgIndex: -1
+ };
+ }
+ final parts = input.split(" ");
+ final rawCmd = parts[0];
+ final args = parts.slice(1);
+ final currentArgIndex = args.length > 0 ? args.length - 1 : -1;
+
+ return {
+ cmd: find(rawCmd),
+ rawCmd: rawCmd,
+ args: args,
+ currentArgIndex: currentArgIndex
+ };
+ }
+
+ public static function filterForAutocomplete(val:String):Array<CommandInfo> {
+ if (!val.startsWith("/")) return [];
+ final parts = val.toLowerCase().split(" ");
+ final searchCmd = parts[0];
+ final spaceCount = parts.length - 1;
+
+ final result:Array<CommandInfo> = [];
+ for (c in list) {
+ if (spaceCount == 0) {
+ if (c.name.toString().startsWith(searchCmd)) {
+ result.push(c);
+ } else {
+ for (a in c.aliases) {
+ if (a.toLowerCase().startsWith(searchCmd)) {
+ result.push(c);
+ break;
+ }
+ }
+ }
+ } else {
+ if (spaceCount <= c.args) {
+ if (c.name.toString() == searchCmd) {
+ result.push(c);
+ } else {
+ for (a in c.aliases) {
+ if (a.toLowerCase() == searchCmd) {
+ result.push(c);
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ public static function formatDescWithHighlight(desc:String, activeArgIndex:Int):String {
+ desc = StringTools.htmlEscape(desc);
+
+ var argCounter = 0;
+ final argRegex = ~/&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);
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage