utils.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { branch, scriptInfo } from "./constants";
  2. import type { Domain, LogLevel, SelectorExistsOpts } 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. return new Promise<number | null>((res) => {
  63. const domain = getDomain();
  64. try {
  65. if(domain === "ytm") {
  66. const pbEl = document.querySelector("#progress-bar") as HTMLProgressElement;
  67. return res(!isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null);
  68. }
  69. else if(domain === "yt") {
  70. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  71. ytForceShowVideoTime();
  72. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  73. const progElem = document.querySelector<HTMLProgressElement>(pbSelector);
  74. let videoTime = progElem ? Number(progElem.getAttribute("aria-valuenow")!) : -1;
  75. const mut = new MutationObserver(() => {
  76. // .observe() is only called when the element exists - no need to check for null
  77. videoTime = Number(document.querySelector<HTMLProgressElement>(pbSelector)!.getAttribute("aria-valuenow")!);
  78. });
  79. const observe = (progElem: HTMLElement) => {
  80. mut.observe(progElem, {
  81. attributes: true,
  82. attributeFilter: ["aria-valuenow"],
  83. });
  84. setTimeout(() => {
  85. res(videoTime >= 0 && !isNaN(videoTime) ? videoTime : null);
  86. }, 500);
  87. };
  88. if(!progElem)
  89. return onSelectorExists(pbSelector, observe);
  90. else
  91. return observe(progElem);
  92. }
  93. }
  94. catch(err) {
  95. error("Couldn't get video time due to error:", err);
  96. res(null);
  97. }
  98. });
  99. }
  100. /**
  101. * Sends events that force the video controls to become visible for about 3 seconds.
  102. * This only works once, then the page needs to be reloaded!
  103. */
  104. function ytForceShowVideoTime() {
  105. const player = document.querySelector("#movie_player");
  106. if(!player)
  107. return false;
  108. const defaultProps = {
  109. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  110. view: getUnsafeWindow(),
  111. bubbles: true,
  112. cancelable: false,
  113. };
  114. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  115. const { x, y, width, height } = player.getBoundingClientRect();
  116. const screenY = Math.round(y + height / 2);
  117. const screenX = x + Math.min(50, Math.round(width / 3));
  118. player.dispatchEvent(new MouseEvent("mousemove", {
  119. ...defaultProps,
  120. screenY,
  121. screenX,
  122. movementX: 5,
  123. movementY: 0,
  124. }));
  125. return true;
  126. }
  127. // /** Parses a video time string in the format `[hh:m]m:ss` to the equivalent number of seconds - returns 0 if input couldn't be parsed */
  128. // function parseVideoTime(videoTime: string) {
  129. // const matches = /^((\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(videoTime);
  130. // if(!matches)
  131. // return 0;
  132. // const [, , hrs, min, sec] = matches as unknown as [string, string | undefined, string | undefined, string, string];
  133. // let finalTime = 0;
  134. // if(hrs)
  135. // finalTime += Number(hrs) * 60 * 60;
  136. // finalTime += Number(min) * 60 + Number(sec);
  137. // return isNaN(finalTime) ? 0 : finalTime;
  138. // }
  139. //#SECTION DOM
  140. /**
  141. * Inserts `afterNode` as a sibling just after the provided `beforeNode`
  142. * @returns Returns the `afterNode`
  143. */
  144. export function insertAfter(beforeNode: HTMLElement, afterNode: HTMLElement) {
  145. beforeNode.parentNode?.insertBefore(afterNode, beforeNode.nextSibling);
  146. return afterNode;
  147. }
  148. /** Adds a parent container around the provided element - returns the new parent node */
  149. export function addParent(element: HTMLElement, newParent: HTMLElement) {
  150. const oldParent = element.parentNode;
  151. if(!oldParent)
  152. throw new Error("Element doesn't have a parent node");
  153. oldParent.replaceChild(newParent, element);
  154. newParent.appendChild(element);
  155. return newParent;
  156. }
  157. /**
  158. * Adds global CSS style through a `<style>` element in the document's `<head>`
  159. * @param style CSS string
  160. * @param ref Reference name that is included in the `<style>`'s ID - prefixed with `betterytm-style-` - no ID is given if it's `undefined`
  161. */
  162. export function addGlobalStyle(style: string, ref?: string) {
  163. const styleElem = document.createElement("style");
  164. if(ref)
  165. styleElem.id = `betterytm-style-${ref}`;
  166. styleElem.innerHTML = style;
  167. document.head.appendChild(styleElem);
  168. log(`Inserted global style${ref ? ` with ref '${ref}'` : ""}:`, styleElem);
  169. }
  170. const selectorExistsMap = new Map<string, Array<(element: HTMLElement) => void>>();
  171. /**
  172. * Calls the `listener` as soon as the `selector` exists in the DOM.
  173. * Listeners are deleted as soon as they are called once.
  174. * Multiple listeners with the same selector may be registered.
  175. */
  176. export function onSelectorExists(selector: string, listener: (element: HTMLElement) => void) {
  177. const el = document.querySelector<HTMLElement>(selector);
  178. if(el)
  179. listener(el);
  180. else {
  181. if(selectorExistsMap.get(selector))
  182. selectorExistsMap.set(selector, [...selectorExistsMap.get(selector)!, listener]);
  183. else
  184. selectorExistsMap.set(selector, [listener]);
  185. }
  186. }
  187. /**
  188. * Calls the `listener` as soon as the `selector` exists in the DOM.
  189. * Listeners are deleted when they are called once, unless `options.continuous` is set.
  190. * Multiple listeners with the same selector may be registered.
  191. * @template TElem The type of element that this selector will return - FIXME: listener inferring doesn't work when this generic is given
  192. */
  193. export function onSelector<TElem = HTMLElement, TOpts extends SelectorExistsOpts = SelectorExistsOpts>(
  194. options: TOpts,
  195. listener: (element: TOpts["all"] extends true ? (TElem extends HTMLElement ? NodeListOf<TElem> : TElem) : TElem) => void,
  196. ) {
  197. // TODO:
  198. void [options, listener];
  199. }
  200. // onSelector({
  201. // selector: "a",
  202. // all: true,
  203. // }, (elem) => {
  204. // void elem;
  205. // });
  206. /** Removes all listeners registered in `onSelector()` with a matching selector property */
  207. export function removeOnSelector(selector: string) {
  208. // TODO:
  209. void [selector];
  210. }
  211. /** Initializes the MutationObserver responsible for checking selectors registered in `onSelectorExists()` */
  212. export function initSelectorExistsCheck() {
  213. const observer = new MutationObserver(() => {
  214. for(const [selector, listeners] of selectorExistsMap.entries()) {
  215. const el = document.querySelector<HTMLElement>(selector);
  216. if(el) {
  217. listeners.forEach(listener => listener(el));
  218. selectorExistsMap.delete(selector);
  219. }
  220. }
  221. });
  222. observer.observe(document.body, {
  223. subtree: true,
  224. childList: true,
  225. });
  226. log("Initialized \"selector exists\" MutationObserver");
  227. }
  228. /** Preloads an array of image URLs so they can be loaded instantly from cache later on */
  229. export function precacheImages(srcUrls: string[], rejects = false) {
  230. const promises = srcUrls.map(src => new Promise((res, rej) => {
  231. const image = new Image();
  232. image.src = src;
  233. image.addEventListener("load", () => res(image));
  234. image.addEventListener("error", () => rejects && rej(`Failed to preload image with URL '${src}'`));
  235. }));
  236. return Promise.allSettled(promises);
  237. }
  238. //#SECTION misc
  239. type FetchOpts = RequestInit & {
  240. timeout: number;
  241. };
  242. /** Calls the fetch API with special options */
  243. export async function fetchAdvanced(url: string, options: Partial<FetchOpts> = {}) {
  244. const { timeout = 10000 } = options;
  245. const controller = new AbortController();
  246. const id = setTimeout(() => controller.abort(), timeout);
  247. const res = await fetch(url, {
  248. ...options,
  249. signal: controller.signal,
  250. });
  251. clearTimeout(id);
  252. return res;
  253. }
  254. /**
  255. * Creates an invisible anchor with _blank target and clicks it.
  256. * This has to be run in relatively quick succession to a user interaction event, else the browser rejects it.
  257. */
  258. export function openInNewTab(href: string) {
  259. try {
  260. const openElem = document.createElement("a");
  261. Object.assign(openElem, {
  262. className: "betterytm-open-in-new-tab",
  263. target: "_blank",
  264. rel: "noopener noreferrer",
  265. href,
  266. });
  267. openElem.style.visibility = "hidden";
  268. document.body.appendChild(openElem);
  269. openElem.click();
  270. // timeout just to be safe
  271. setTimeout(() => openElem.remove(), 200);
  272. }
  273. catch(err) {
  274. error("Couldn't open URL in a new tab due to an error:", err);
  275. }
  276. }
  277. /**
  278. * Returns `unsafeWindow` if it is available, otherwise falls back to just `window`
  279. * unsafeWindow is sometimes needed because otherwise YTM errors out - see [this issue](https://github.com/Sv443/BetterYTM/issues/18#show_issue)
  280. */
  281. export function getUnsafeWindow() {
  282. try {
  283. // throws ReferenceError if the "@grant unsafeWindow" isn't present
  284. return unsafeWindow;
  285. }
  286. catch(e) {
  287. return window;
  288. }
  289. }
  290. /**
  291. * Returns the current domain as a constant string representation
  292. * @throws Throws if script runs on an unexpected website
  293. */
  294. export function getDomain(): Domain {
  295. const { hostname } = new URL(location.href);
  296. if(hostname.includes("music.youtube"))
  297. return "ytm";
  298. else if(hostname.includes("youtube"))
  299. return "yt";
  300. else
  301. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  302. }
  303. /** Returns the URL of the asset hosted on GitHub at the specified relative `path` (starting at `{root}/assets/`) */
  304. export function getAssetUrl(path: string) {
  305. return `https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/assets/${path}`;
  306. }
  307. /**
  308. * Automatically appends an `s` to the passed `word`, if `num` is not equal to 1
  309. * @param word A word in singular form, to auto-convert to plural
  310. * @param num If this is an array, the amount of items is used
  311. */
  312. export function autoPlural(word: string, num: number | unknown[] | NodeList) {
  313. if(Array.isArray(num) || num instanceof NodeList)
  314. num = num.length;
  315. return `${word}${num === 1 ? "" : "s"}`;
  316. }
  317. /** Ensures the passed `value` always stays between `min` and `max` */
  318. export function clamp(value: number, min: number, max: number) {
  319. return Math.max(Math.min(value, max), min);
  320. }
  321. /** Pauses async execution for the specified time in ms */
  322. export function pauseFor(time: number) {
  323. return new Promise((res) => {
  324. setTimeout(res, time);
  325. });
  326. }
  327. /**
  328. * Calls the passed `func` after the specified `timeout` in ms.
  329. * Any subsequent calls to this function will reset the timer and discard previous calls.
  330. */
  331. export function debounce<TFunc extends (...args: TArgs[]) => void, TArgs = any>(func: TFunc, timeout = 300) { // eslint-disable-line @typescript-eslint/no-explicit-any
  332. let timer: NodeJS.Timer | undefined;
  333. return function(...args: TArgs[]) {
  334. clearTimeout(timer);
  335. timer = setTimeout(() => func.apply(this, args), timeout);
  336. };
  337. }