utils.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import { Event as EventParam, EventEmitter, EventHandler } from "@billjs/event-emitter";
  2. import { branch, scriptInfo } from "./constants";
  3. import type { Domain, LogLevel } from "./types";
  4. //#MARKER BYTM-specific
  5. let curLogLevel: LogLevel = 1;
  6. /** Sets the current log level. 0 = Debug, 1 = Info */
  7. export function setLogLevel(level: LogLevel) {
  8. curLogLevel = level;
  9. }
  10. function getLogLevel(args: unknown[]): number {
  11. const minLogLvl = 0, maxLogLvl = 1;
  12. if(typeof args.at(-1) === "number")
  13. return Math.max(
  14. Math.min(
  15. args.splice(args.length - 1)[0] as number,
  16. minLogLvl,
  17. ),
  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. */
  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. /**
  58. * Returns the current domain as a constant string representation
  59. * @throws Throws if script runs on an unexpected website
  60. */
  61. export function getDomain(): Domain {
  62. const { hostname } = new URL(location.href);
  63. if(hostname.includes("music.youtube"))
  64. return "ytm";
  65. else if(hostname.includes("youtube"))
  66. return "yt";
  67. else
  68. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  69. }
  70. /**
  71. * Returns the current video time in seconds
  72. * @param force Set to true to dispatch mouse movement events in case the video time can't be estimated
  73. * @returns Returns null if the video time is unavailable
  74. */
  75. export function getVideoTime(force = false) {
  76. const domain = getDomain();
  77. try {
  78. if(domain === "ytm") {
  79. const pbEl = document.querySelector("#progress-bar") as HTMLProgressElement;
  80. return !isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null;
  81. }
  82. else if(domain === "yt") {
  83. // YT doesn't update the progress bar when it's hidden (YTM doesn't hide it) so TODO: come up with some solution here
  84. // Possible solution:
  85. // - Use MutationObserver to detect when attributes of progress bar (selector `div.ytp-progress-bar[role="slider"]`) change
  86. // - Wait until the attribute increments, then save the value of `aria-valuenow` and the current system time to memory
  87. // - When site switch hotkey is pressed, take saved `aria-valuenow` value and add the difference between saved system time and current system time
  88. // - If no value is present, use the script from `dev/ytForceShowVideoTime.js` to simulate mouse movement to force the element to update
  89. // - Subtract one or two seconds to make up for rounding errors
  90. // - profit
  91. // if(!ytCurrentVideoTime) {
  92. // ytForceShowVideoTime();
  93. // const videoTime = document.querySelector("#TODO")?.getAttribute("aria-valuenow") ?? null;
  94. // }
  95. void [ force, ytForceShowVideoTime ];
  96. return null;
  97. }
  98. return null;
  99. }
  100. catch(err) {
  101. error("Couldn't get video time due to error:", err);
  102. return null;
  103. }
  104. }
  105. /** Sends events that force the video controls to become visible for about 3 seconds */
  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: unsafeWindow ?? window,
  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. setTimeout(() => {
  128. player.dispatchEvent(new MouseEvent("mouseleave", defaultProps));
  129. }, 4000);
  130. return true;
  131. }
  132. /** Returns the URL of the asset hosted on GitHub at the specified relative `path` (starting at `ROOT/assets/`) */
  133. export function getAssetUrl(path: string) {
  134. return `https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/assets/${path}`;
  135. }
  136. /**
  137. * Creates an invisible anchor with _blank target and clicks it.
  138. * This has to be run in relatively quick succession to a user interaction event, else the browser rejects it.
  139. */
  140. export function openInNewTab(href: string) {
  141. const openElem = document.createElement("a");
  142. Object.assign(openElem, {
  143. className: "betterytm-open-in-new-tab",
  144. target: "_blank",
  145. rel: "noopener noreferrer",
  146. href: href,
  147. style: {
  148. visibility: "hidden",
  149. },
  150. });
  151. document.body.appendChild(openElem);
  152. openElem.click();
  153. // just to be safe
  154. setTimeout(() => openElem.remove(), 200);
  155. }
  156. //#MARKER DOM
  157. /**
  158. * Inserts `afterNode` as a sibling just after the provided `beforeNode`
  159. * @param beforeNode
  160. * @param afterNode
  161. * @returns Returns the `afterNode`
  162. */
  163. export function insertAfter(beforeNode: HTMLElement, afterNode: HTMLElement) {
  164. beforeNode.parentNode?.insertBefore(afterNode, beforeNode.nextSibling);
  165. return afterNode;
  166. }
  167. /**
  168. * Adds global CSS style through a `<style>` element in the document's `<head>`
  169. * @param style CSS string
  170. * @param ref Reference name that is included in the `<style>`'s ID - prefixed with `betterytm-style-` - defaults to a random number if left undefined
  171. */
  172. export function addGlobalStyle(style: string, ref?: string) {
  173. if(typeof ref !== "string" || ref.length === 0)
  174. ref = String(Math.floor(Math.random() * 10_000));
  175. const styleElem = document.createElement("style");
  176. styleElem.id = `betterytm-style-${ref}`;
  177. styleElem.innerHTML = style;
  178. document.head.appendChild(styleElem);
  179. log(`Inserted global style with ref '${ref}':`, styleElem);
  180. }
  181. //#MARKER site events
  182. export interface SiteEvents extends EventEmitter {
  183. /** Emitted whenever child nodes are added to or removed from the song queue */
  184. on(event: "queueChanged", listener: EventHandler): boolean;
  185. /** Emitted whenever carousel shelf containers are added or removed from their parent container */
  186. on(event: "carouselShelvesChanged", listener: EventHandler): boolean;
  187. /** Emitted once the home page is filled with content */
  188. on(event: "homePageLoaded", listener: EventHandler): boolean;
  189. }
  190. export const siteEvents = new EventEmitter() as SiteEvents;
  191. /**
  192. * Returns the data of an event from the `@billjs/event-emitter` library.
  193. * This function is used to assert the type passed in `<T>`
  194. * @param evt Event object from the `.on()` or `.once()` method
  195. * @template T Type of the data passed by `.fire(type: string, data: T)`
  196. */
  197. export function getEvtData<T>(evt: EventParam): T {
  198. return evt.data as T;
  199. }
  200. let observers: MutationObserver[] = [];
  201. /** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
  202. export function removeAllObservers() {
  203. observers.forEach((observer) => observer.disconnect());
  204. observers = [];
  205. }
  206. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  207. export async function initSiteEvents() {
  208. try {
  209. //#SECTION queue
  210. // the queue container always exists so it doesn't need the extra init function
  211. const queueObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  212. if(addedNodes.length > 0 || removedNodes.length > 0) {
  213. info("Detected queue change - added nodes:", addedNodes.length, "- removed nodes:", removedNodes.length);
  214. siteEvents.fire("queueChanged", target);
  215. }
  216. });
  217. // only observe added or removed elements
  218. queueObs.observe(document.querySelector(".side-panel.modular #contents.ytmusic-player-queue")!, {
  219. childList: true,
  220. });
  221. //#SECTION home page observers
  222. initHomeObservers();
  223. info("Successfully initialized SiteEvents observers");
  224. observers = [
  225. queueObs,
  226. ];
  227. }
  228. catch(err) {
  229. error("Couldn't initialize SiteEvents observers due to an error:\n", err);
  230. }
  231. }
  232. /**
  233. * The home page might not exist yet if the site was accessed through any path like /watch directly.
  234. * This function will keep waiting for when the home page exists, then create the necessary MutationObservers.
  235. */
  236. async function initHomeObservers() {
  237. let interval: NodeJS.Timer | undefined;
  238. // hidden="" attribute is only present if the content of the page doesn't exist yet
  239. // so this resolves only once that attribute is removed
  240. if(document.querySelector("ytmusic-browse-response#browse-page")?.hasAttribute("hidden")) {
  241. await new Promise<void>((res) => {
  242. interval = setInterval(() => {
  243. if(!document.querySelector("ytmusic-browse-response#browse-page")?.hasAttribute("hidden")) {
  244. info("found home page");
  245. res();
  246. }
  247. }, 50);
  248. });
  249. }
  250. interval && clearInterval(interval);
  251. siteEvents.fire("homePageLoaded");
  252. info("Initialized home page observers");
  253. //#SECTION carousel shelves
  254. const shelfContainerObs = new MutationObserver(([ { addedNodes, removedNodes } ]) => {
  255. if(addedNodes.length > 0 || removedNodes.length > 0) {
  256. info("Detected carousel shelf container change - added nodes:", addedNodes.length, "- removed nodes:", removedNodes.length);
  257. siteEvents.fire("carouselShelvesChanged", { addedNodes, removedNodes });
  258. }
  259. });
  260. shelfContainerObs.observe(document.querySelector("#contents.ytmusic-section-list-renderer")!, {
  261. childList: true,
  262. });
  263. observers = observers.concat([ shelfContainerObs ]);
  264. }