utils.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import { branch, scriptInfo } from "./constants";
  2. import type { Domain, LogLevel } from "./types";
  3. //#SECTION logging
  4. let curLogLevel: LogLevel = 1;
  5. /** Sets the current log level. 0 = Debug, 1 = Info */
  6. export function setLogLevel(level: LogLevel) {
  7. curLogLevel = level;
  8. }
  9. /** Extracts the log level from the last item from spread arguments - returns 1 if the last item is not a number or too low or high */
  10. function getLogLevel(args: unknown[]): number {
  11. const minLogLvl = 0, maxLogLvl = 1;
  12. if(typeof args.at(-1) === "number")
  13. return clamp(
  14. args.splice(args.length - 1)[0] as number,
  15. minLogLvl,
  16. maxLogLvl,
  17. );
  18. return 0;
  19. }
  20. /** Common prefix to be able to tell logged messages apart and filter them in devtools */
  21. const consPrefix = `[${scriptInfo.name}]`;
  22. const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`;
  23. /**
  24. * Logs string-compatible values to the console, as long as the log level is sufficient.
  25. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
  26. */
  27. export function log(...args: unknown[]): void {
  28. if(curLogLevel <= getLogLevel(args))
  29. console.log(consPrefix, ...args);
  30. }
  31. /**
  32. * Logs string-compatible values to the console as info, as long as the log level is sufficient.
  33. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
  34. */
  35. export function info(...args: unknown[]): void {
  36. if(curLogLevel <= getLogLevel(args))
  37. console.info(consPrefix, ...args);
  38. }
  39. /**
  40. * Logs string-compatible values to the console as a warning, as long as the log level is sufficient.
  41. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
  42. */
  43. export function warn(...args: unknown[]): void {
  44. if(curLogLevel <= getLogLevel(args))
  45. console.warn(consPrefix, ...args);
  46. }
  47. /** Logs string-compatible values to the console as an error, no matter the log level. */
  48. export function error(...args: unknown[]): void {
  49. console.error(consPrefix, ...args);
  50. }
  51. /** Logs string-compatible values to the console, intended for debugging only */
  52. export function dbg(...args: unknown[]): void {
  53. console.log(consPrefixDbg, ...args);
  54. }
  55. //#SECTION video time
  56. /**
  57. * Returns the current video time in seconds
  58. * Dispatches mouse movement events in case the video time can't be estimated
  59. * @returns Returns null if the video time is unavailable
  60. */
  61. export function getVideoTime() {
  62. const domain = getDomain();
  63. try {
  64. if(domain === "ytm") {
  65. const pbEl = document.querySelector("#progress-bar") as HTMLProgressElement;
  66. return !isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null;
  67. }
  68. else if(domain === "yt") {
  69. // YT doesn't update the progress bar when it's hidden (YTM doesn't hide it) so TODO: come up with some solution here
  70. // Possible solution:
  71. // - Use MutationObserver to detect when attributes of progress bar (selector `div.ytp-progress-bar[role="slider"]`) change
  72. // - Wait until the attribute increments, then save the value of `aria-valuenow` and the current system time to memory
  73. // - When site switch hotkey is pressed, take saved `aria-valuenow` value and add the difference between saved system time and current system time
  74. // - If no value is present, use the script from `dev/ytForceShowVideoTime.js` to simulate mouse movement to force the element to update
  75. // - Subtract one or two seconds to make up for rounding errors
  76. // - profit
  77. // if(!ytCurrentVideoTime) {
  78. // ytForceShowVideoTime();
  79. // const videoTime = document.querySelector("#TODO")?.getAttribute("aria-valuenow") ?? null;
  80. // }
  81. void ytForceShowVideoTime;
  82. return null;
  83. }
  84. return null;
  85. }
  86. catch(err) {
  87. error("Couldn't get video time due to error:", err);
  88. return null;
  89. }
  90. }
  91. /** Sends events that force the video controls to become visible for about 3 seconds */
  92. function ytForceShowVideoTime() {
  93. const player = document.querySelector("#movie_player");
  94. if(!player)
  95. return false;
  96. const defaultProps = {
  97. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  98. view: getUnsafeWindow(),
  99. bubbles: true,
  100. cancelable: false,
  101. };
  102. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  103. const { x, y, width, height } = player.getBoundingClientRect();
  104. const screenY = Math.round(y + height / 2);
  105. const screenX = x + Math.min(50, Math.round(width / 3));
  106. player.dispatchEvent(new MouseEvent("mousemove", {
  107. ...defaultProps,
  108. screenY,
  109. screenX,
  110. movementX: 5,
  111. movementY: 0
  112. }));
  113. setTimeout(() => {
  114. player.dispatchEvent(new MouseEvent("mouseleave", defaultProps));
  115. }, 4000);
  116. return true;
  117. }
  118. //#SECTION DOM
  119. /**
  120. * Inserts `afterNode` as a sibling just after the provided `beforeNode`
  121. * @param beforeNode
  122. * @param afterNode
  123. * @returns Returns the `afterNode`
  124. */
  125. export function insertAfter(beforeNode: HTMLElement, afterNode: HTMLElement) {
  126. beforeNode.parentNode?.insertBefore(afterNode, beforeNode.nextSibling);
  127. return afterNode;
  128. }
  129. /** Adds a parent container around the provided element - returns the new parent node */
  130. export function addParent(element: HTMLElement, newParent: HTMLElement) {
  131. const oldParent = element.parentNode;
  132. if(!oldParent)
  133. throw new Error("Element doesn't have a parent node");
  134. oldParent.replaceChild(newParent, element);
  135. newParent.appendChild(element);
  136. return newParent;
  137. }
  138. /**
  139. * Adds global CSS style through a `<style>` element in the document's `<head>`
  140. * @param style CSS string
  141. * @param ref Reference name that is included in the `<style>`'s ID - prefixed with `betterytm-style-` - defaults to a random number if left undefined
  142. */
  143. export function addGlobalStyle(style: string, ref?: string) {
  144. if(typeof ref !== "string" || ref.length === 0)
  145. ref = String(Math.floor(Math.random() * 10_000));
  146. const styleElem = document.createElement("style");
  147. styleElem.id = `betterytm-style-${ref}`;
  148. styleElem.innerHTML = style;
  149. document.head.appendChild(styleElem);
  150. log(`Inserted global style with ref '${ref}':`, styleElem);
  151. }
  152. const selectorExistsMap = new Map<string, Array<(element: HTMLElement) => void>>();
  153. /**
  154. * Calls the `listener` as soon as the `selector` exists in the DOM.
  155. * Listeners are deleted as soon as they are called once.
  156. * Multiple listeners with the same selector may be registered.
  157. */
  158. export function onSelectorExists(selector: string, listener: (element: HTMLElement) => void) {
  159. const el = document.querySelector<HTMLElement>(selector);
  160. if(el)
  161. listener(el);
  162. else {
  163. if(selectorExistsMap.get(selector))
  164. selectorExistsMap.set(selector, [...selectorExistsMap.get(selector)!, listener]);
  165. else
  166. selectorExistsMap.set(selector, [listener]);
  167. }
  168. }
  169. /** Initializes the MutationObserver responsible for checking selectors registered in `onSelectorExists()` */
  170. export function initSelectorExistsCheck() {
  171. const observer = new MutationObserver(() => {
  172. for(const [selector, listeners] of selectorExistsMap.entries()) {
  173. const el = document.querySelector<HTMLElement>(selector);
  174. if(el) {
  175. listeners.forEach(listener => listener(el));
  176. selectorExistsMap.delete(selector);
  177. }
  178. }
  179. });
  180. observer.observe(document.body, {
  181. subtree: true,
  182. childList: true,
  183. });
  184. log("Initialized \"selector exists\" MutationObserver");
  185. }
  186. /** Preloads an array of image URLs so they can be loaded instantly from cache later on */
  187. export function precacheImages(srcUrls: string[], rejects = false) {
  188. const promises = srcUrls.map(src => new Promise((res, rej) => {
  189. const image = new Image();
  190. image.src = src;
  191. image.addEventListener("load", () => res(image));
  192. image.addEventListener("error", () => rejects && rej(`Failed to preload image with URL '${src}'`));
  193. }));
  194. return Promise.allSettled(promises);
  195. }
  196. //#SECTION misc
  197. /**
  198. * Creates an invisible anchor with _blank target and clicks it.
  199. * This has to be run in relatively quick succession to a user interaction event, else the browser rejects it.
  200. */
  201. export function openInNewTab(href: string) {
  202. try {
  203. const openElem = document.createElement("a");
  204. Object.assign(openElem, {
  205. className: "betterytm-open-in-new-tab",
  206. target: "_blank",
  207. rel: "noopener noreferrer",
  208. href,
  209. });
  210. openElem.style.visibility = "hidden";
  211. document.body.appendChild(openElem);
  212. openElem.click();
  213. // timeout just to be safe
  214. setTimeout(() => openElem.remove(), 200);
  215. }
  216. catch(err) {
  217. error("Couldn't open URL in a new tab due to an error:", err);
  218. }
  219. }
  220. /**
  221. * Returns `unsafeWindow` if it is available, otherwise falls back to just `window`
  222. * unsafeWindow is sometimes needed because otherwise YTM errors out - see [this issue](https://github.com/Sv443/BetterYTM/issues/18#show_issue)
  223. */
  224. export function getUnsafeWindow() {
  225. try {
  226. // throws ReferenceError if the "@grant unsafeWindow" isn't present
  227. return unsafeWindow;
  228. }
  229. catch(e) {
  230. return window;
  231. }
  232. }
  233. /**
  234. * Returns the current domain as a constant string representation
  235. * @throws Throws if script runs on an unexpected website
  236. */
  237. export function getDomain(): Domain {
  238. const { hostname } = new URL(location.href);
  239. if(hostname.includes("music.youtube"))
  240. return "ytm";
  241. else if(hostname.includes("youtube"))
  242. return "yt";
  243. else
  244. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  245. }
  246. /** Returns the URL of the asset hosted on GitHub at the specified relative `path` (starting at `ROOT/assets/`) */
  247. export function getAssetUrl(path: string) {
  248. return `https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/assets/${path}`;
  249. }
  250. /**
  251. * Automatically appends an `s` to the passed `word`, if `num` is not equal to 1
  252. * @param word A word in singular form, to auto-convert to plural
  253. * @param num If this is an array, the amount of items is used
  254. */
  255. export function autoPlural(word: string, num: number | unknown[]) {
  256. if(Array.isArray(num))
  257. num = num.length;
  258. return `${word}${num === 1 ? "" : "s"}`;
  259. }
  260. /** Ensures the passed `value` always stays between `min` and `max` */
  261. export function clamp(value: number, min: number, max: number) {
  262. return Math.max(Math.min(value, max), min);
  263. }
  264. /** Pauses async execution for the specified time in ms */
  265. export function pauseFor(time: number) {
  266. return new Promise((res) => {
  267. setTimeout(res, time);
  268. });
  269. }