utils.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { clamp, getUnsafeWindow, onSelector } from "@sv443-network/userutils";
  2. import { scriptInfo } from "./constants";
  3. import type { Domain, LogLevel } from "./types";
  4. import * as resources from "../assets/resources.json" assert { type: "json" };
  5. //#SECTION logging
  6. let curLogLevel: LogLevel = 1;
  7. /** Sets the current log level. 0 = Debug, 1 = Info */
  8. export function setLogLevel(level: LogLevel) {
  9. curLogLevel = level;
  10. }
  11. /** 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 */
  12. function getLogLevel(args: unknown[]): number {
  13. const minLogLvl = 0, maxLogLvl = 1;
  14. if(typeof args.at(-1) === "number")
  15. return clamp(
  16. args.splice(args.length - 1)[0] as number,
  17. minLogLvl,
  18. maxLogLvl,
  19. );
  20. return 0;
  21. }
  22. /** Common prefix to be able to tell logged messages apart and filter them in devtools */
  23. const consPrefix = `[${scriptInfo.name}]`;
  24. const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`;
  25. /**
  26. * Logs string-compatible values to the console, as long as the log level is sufficient.
  27. * @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.
  28. */
  29. export function log(...args: unknown[]): void {
  30. if(curLogLevel <= getLogLevel(args))
  31. console.log(consPrefix, ...args);
  32. }
  33. /**
  34. * Logs string-compatible values to the console as info, as long as the log level is sufficient.
  35. * @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.
  36. */
  37. export function info(...args: unknown[]): void {
  38. if(curLogLevel <= getLogLevel(args))
  39. console.info(consPrefix, ...args);
  40. }
  41. /**
  42. * Logs string-compatible values to the console as a warning, as long as the log level is sufficient.
  43. * @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.
  44. */
  45. export function warn(...args: unknown[]): void {
  46. if(curLogLevel <= getLogLevel(args))
  47. console.warn(consPrefix, ...args);
  48. }
  49. /** Logs string-compatible values to the console as an error, no matter the log level. */
  50. export function error(...args: unknown[]): void {
  51. console.error(consPrefix, ...args);
  52. }
  53. /** Logs string-compatible values to the console, intended for debugging only */
  54. export function dbg(...args: unknown[]): void {
  55. console.log(consPrefixDbg, ...args);
  56. }
  57. //#SECTION video time
  58. /**
  59. * Returns the current video time in seconds
  60. * Dispatches mouse movement events in case the video time can't be estimated
  61. * @returns Returns null if the video time is unavailable
  62. */
  63. export function getVideoTime() {
  64. return new Promise<number | null>((res) => {
  65. const domain = getDomain();
  66. try {
  67. if(domain === "ytm") {
  68. const pbEl = document.querySelector("#progress-bar") as HTMLProgressElement;
  69. return res(!isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null);
  70. }
  71. else if(domain === "yt") {
  72. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  73. ytForceShowVideoTime();
  74. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  75. const progElem = document.querySelector<HTMLProgressElement>(pbSelector);
  76. let videoTime = progElem ? Number(progElem.getAttribute("aria-valuenow")!) : -1;
  77. const mut = new MutationObserver(() => {
  78. // .observe() is only called when the element exists - no need to check for null
  79. videoTime = Number(document.querySelector<HTMLProgressElement>(pbSelector)!.getAttribute("aria-valuenow")!);
  80. });
  81. const observe = (progElem: HTMLElement) => {
  82. mut.observe(progElem, {
  83. attributes: true,
  84. attributeFilter: ["aria-valuenow"],
  85. });
  86. setTimeout(() => {
  87. res(videoTime >= 0 && !isNaN(videoTime) ? videoTime : null);
  88. }, 500);
  89. };
  90. if(!progElem)
  91. return onSelector(pbSelector, { listener: observe });
  92. else
  93. return observe(progElem);
  94. }
  95. }
  96. catch(err) {
  97. error("Couldn't get video time due to error:", err);
  98. res(null);
  99. }
  100. });
  101. }
  102. /**
  103. * Sends events that force the video controls to become visible for about 3 seconds.
  104. * This only works once, then the page needs to be reloaded!
  105. */
  106. function ytForceShowVideoTime() {
  107. const player = document.querySelector("#movie_player");
  108. if(!player)
  109. return false;
  110. const defaultProps = {
  111. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  112. view: getUnsafeWindow(),
  113. bubbles: true,
  114. cancelable: false,
  115. };
  116. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  117. const { x, y, width, height } = player.getBoundingClientRect();
  118. const screenY = Math.round(y + height / 2);
  119. const screenX = x + Math.min(50, Math.round(width / 3));
  120. player.dispatchEvent(new MouseEvent("mousemove", {
  121. ...defaultProps,
  122. screenY,
  123. screenX,
  124. movementX: 5,
  125. movementY: 0,
  126. }));
  127. return true;
  128. }
  129. // /** 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 */
  130. // function parseVideoTime(videoTime: string) {
  131. // const matches = /^((\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(videoTime);
  132. // if(!matches)
  133. // return 0;
  134. // const [, , hrs, min, sec] = matches as unknown as [string, string | undefined, string | undefined, string, string];
  135. // let finalTime = 0;
  136. // if(hrs)
  137. // finalTime += Number(hrs) * 60 * 60;
  138. // finalTime += Number(min) * 60 + Number(sec);
  139. // return isNaN(finalTime) ? 0 : finalTime;
  140. // }
  141. // const selectorExistsMap = new Map<string, Array<(element: HTMLElement) => void>>();
  142. // /**
  143. // * Calls the `listener` as soon as the `selector` exists in the DOM.
  144. // * Listeners are deleted as soon as they are called once.
  145. // * Multiple listeners with the same selector may be registered.
  146. // */
  147. // export function onSelectorExists(selector: string, listener: (element: HTMLElement) => void) {
  148. // const el = document.querySelector<HTMLElement>(selector);
  149. // if(el)
  150. // listener(el);
  151. // else {
  152. // if(selectorExistsMap.get(selector))
  153. // selectorExistsMap.set(selector, [...selectorExistsMap.get(selector)!, listener]);
  154. // else
  155. // selectorExistsMap.set(selector, [listener]);
  156. // }
  157. // }
  158. // /** Initializes the MutationObserver responsible for checking selectors registered in `onSelectorExists()` */
  159. // export function initSelectorExistsCheck() {
  160. // const observer = new MutationObserver(() => {
  161. // for(const [selector, listeners] of selectorExistsMap.entries()) {
  162. // const el = document.querySelector<HTMLElement>(selector);
  163. // if(el) {
  164. // listeners.forEach(listener => listener(el));
  165. // selectorExistsMap.delete(selector);
  166. // }
  167. // }
  168. // });
  169. // observer.observe(document.body, {
  170. // subtree: true,
  171. // childList: true,
  172. // });
  173. // log("Initialized \"selector exists\" MutationObserver");
  174. // }
  175. /**
  176. * Returns the current domain as a constant string representation
  177. * @throws Throws if script runs on an unexpected website
  178. */
  179. export function getDomain(): Domain {
  180. const { hostname } = new URL(location.href);
  181. if(hostname.includes("music.youtube"))
  182. return "ytm";
  183. else if(hostname.includes("youtube"))
  184. return "yt";
  185. else
  186. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  187. }
  188. /** Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl) */
  189. export function getResourceUrl(name: keyof typeof resources) {
  190. return GM.getResourceUrl(name);
  191. }