utils.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { Event as EventParam, EventEmitter, EventHandler } from "@billjs/event-emitter";
  2. import type { Domain, LogLevel } from "./types";
  3. import { scriptInfo } from "./constants";
  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. if(typeof args.at(-1) === "number")
  12. return args.splice(args.length - 1, 1)[0] as number;
  13. return 0;
  14. }
  15. /** Common prefix to be able to tell logged messages apart */
  16. const consPrefix = `[${scriptInfo.name}]`;
  17. /**
  18. * Logs string-compatible values to the console, as long as the log level is sufficient.
  19. * @param args Last parameter is logLevel: 0 = Debug, 1/undefined = Info
  20. */
  21. export function log(...args: unknown[]): void {
  22. if(curLogLevel <= getLogLevel(...args))
  23. console.log(consPrefix, ...args);
  24. }
  25. /**
  26. * Logs string-compatible values to the console as info, as long as the log level is sufficient.
  27. * @param args Last parameter is logLevel: 0 = Debug, 1/undefined = Info
  28. */
  29. export function info(...args: unknown[]): void {
  30. if(curLogLevel <= getLogLevel(...args))
  31. console.info(consPrefix, ...args);
  32. }
  33. /**
  34. * Logs string-compatible values to the console as a warning, as long as the log level is sufficient.
  35. * @param args Last parameter is logLevel: 0 = Debug, 1/undefined = Info
  36. */
  37. export function warn(...args: unknown[]): void {
  38. if(curLogLevel <= getLogLevel(...args))
  39. console.warn(consPrefix, ...args);
  40. }
  41. /** Logs string-compatible values to the console as an error. */
  42. export function error(...args: unknown[]): void {
  43. console.error(consPrefix, ...args);
  44. }
  45. /**
  46. * Returns the current domain as a constant string representation
  47. * @throws Throws if script runs on an unexpected website
  48. */
  49. export function getDomain(): Domain {
  50. const { hostname } = new URL(location.href);
  51. if(hostname.includes("music.youtube"))
  52. return "ytm";
  53. else if(hostname.includes("youtube"))
  54. return "yt";
  55. else
  56. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  57. }
  58. /**
  59. * Returns the current video time in seconds
  60. * @param force Set to true to dispatch 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(force = false) {
  64. const domain = getDomain();
  65. try {
  66. if(domain === "ytm") {
  67. const pbEl = document.querySelector("#progress-bar") as HTMLProgressElement;
  68. return !isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null;
  69. }
  70. else if(domain === "yt") {
  71. // YT doesn't update the progress bar when it's hidden (YTM doesn't hide it) so TODO: come up with some solution here
  72. // Possible solution:
  73. // - Use MutationObserver to detect when attributes of progress bar (selector `div.ytp-progress-bar[role="slider"]`) change
  74. // - Wait until the attribute increments, then save the value of `aria-valuenow` and the current system time to memory
  75. // - When site switch hotkey is pressed, take saved `aria-valuenow` value and add the difference between saved system time and current system time
  76. // - If no value is present, use the script from `dev/ytForceShowVideoTime.js` to simulate mouse movement to force the element to update
  77. // - Subtract one or two seconds to make up for rounding errors
  78. // - profit
  79. // if(!ytCurrentVideoTime) {
  80. // ytForceShowVideoTime();
  81. // const videoTime = document.querySelector("#TODO")?.getAttribute("aria-valuenow") ?? null;
  82. // }
  83. void [ force, ytForceShowVideoTime ];
  84. return null;
  85. }
  86. return null;
  87. }
  88. catch(err) {
  89. error("Couldn't get video time due to error:", err);
  90. return null;
  91. }
  92. }
  93. /** Sends events that force the video controls to become visible for about 3 seconds */
  94. function ytForceShowVideoTime() {
  95. const player = document.querySelector("#movie_player");
  96. if(!player)
  97. return false;
  98. const defaultProps = {
  99. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  100. view: unsafeWindow ?? window,
  101. bubbles: true,
  102. cancelable: false,
  103. };
  104. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  105. const { x, y, width, height } = player.getBoundingClientRect();
  106. const screenY = Math.round(y + height / 2);
  107. const screenX = x + Math.min(50, Math.round(width / 3));
  108. player.dispatchEvent(new MouseEvent("mousemove", {
  109. ...defaultProps,
  110. screenY,
  111. screenX,
  112. movementX: 5,
  113. movementY: 0
  114. }));
  115. setTimeout(() => {
  116. player.dispatchEvent(new MouseEvent("mouseleave", defaultProps));
  117. }, 4000);
  118. return true;
  119. }
  120. //#MARKER DOM
  121. /**
  122. * Inserts `afterNode` as a sibling just after the provided `beforeNode`
  123. * @param beforeNode
  124. * @param afterNode
  125. * @returns Returns the `afterNode`
  126. */
  127. export function insertAfter(beforeNode: HTMLElement, afterNode: HTMLElement) {
  128. beforeNode.parentNode?.insertBefore(afterNode, beforeNode.nextSibling);
  129. return afterNode;
  130. }
  131. /**
  132. * Adds global CSS style through a `<style>` element in the document's `<head>`
  133. * @param style CSS string
  134. * @param ref Reference name that is included in the `<style>`'s ID - prefixed with `betterytm-style-` - defaults to a random number if left undefined
  135. */
  136. export function addGlobalStyle(style: string, ref?: string) {
  137. if(typeof ref !== "string" || ref.length === 0)
  138. ref = String(Math.floor(Math.random() * 10_000));
  139. const styleElem = document.createElement("style");
  140. styleElem.id = `betterytm-style-${ref}`;
  141. styleElem.innerHTML = style;
  142. document.head.appendChild(styleElem);
  143. log(`Inserted global style with ref '${ref}':`, styleElem);
  144. }
  145. //#MARKER site events
  146. export interface SiteEvents extends EventEmitter {
  147. /** Emitted whenever child nodes are added to or removed from the song queue */
  148. on(event: "queueChanged", listener: EventHandler): boolean;
  149. /** Emitted whenever carousel shelf containers are added or removed from their parent container */
  150. on(event: "carouselShelvesChanged", listener: EventHandler): boolean;
  151. /** Emitted once the home page is filled with content */
  152. on(event: "homePageLoaded", listener: EventHandler): boolean;
  153. }
  154. export const siteEvents = new EventEmitter() as SiteEvents;
  155. /**
  156. * Returns the data of an event from the @billjs/event-emitter library
  157. * @param evt Event object from the `.on()` or `.once()` method
  158. * @template T Type of the data passed by `.fire(type: string, data: T)`
  159. */
  160. export function getEvtData<T>(evt: EventParam): T {
  161. return evt.data as T;
  162. }
  163. let observers: MutationObserver[] = [];
  164. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  165. export async function initSiteEvents() {
  166. try {
  167. //#SECTION queue
  168. // the queue container always exists so it doesn't need the extra init function
  169. const queueObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  170. if(addedNodes.length > 0 || removedNodes.length > 0) {
  171. info("Detected queue change - added nodes:", addedNodes.length, "- removed nodes:", removedNodes.length);
  172. siteEvents.fire("queueChanged", target);
  173. }
  174. });
  175. // only observe added or removed elements
  176. queueObs.observe(document.querySelector(".side-panel.modular #contents.ytmusic-player-queue")!, {
  177. childList: true,
  178. });
  179. //#SECTION home page observers
  180. initHomeObservers();
  181. info("Successfully initialized SiteEvents observers");
  182. observers = [
  183. queueObs,
  184. ];
  185. }
  186. catch(err) {
  187. error("Couldn't initialize SiteEvents observers due to an error:\n", err);
  188. }
  189. }
  190. /** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
  191. export function removeAllObservers() {
  192. observers.forEach((observer) => observer.disconnect());
  193. observers = [];
  194. }
  195. /**
  196. * The home page might not exist yet if the site was accessed through any path like /watch directly.
  197. * This function will keep waiting for when the home page exists, then create the necessary MutationObservers.
  198. */
  199. async function initHomeObservers() {
  200. let interval: NodeJS.Timer | undefined;
  201. // hidden="" attribute is only present if the content of the page doesn't exist yet
  202. // so this resolves only once that attribute is removed
  203. if(document.querySelector("ytmusic-browse-response#browse-page")?.hasAttribute("hidden")) {
  204. await new Promise<void>((res) => {
  205. interval = setInterval(() => {
  206. if(!document.querySelector("ytmusic-browse-response#browse-page")?.hasAttribute("hidden")) {
  207. info("found home page");
  208. res();
  209. }
  210. }, 50);
  211. });
  212. }
  213. interval && clearInterval(interval);
  214. siteEvents.fire("homePageLoaded");
  215. info("Initialized home page observers");
  216. //#SECTION carousel shelves
  217. const shelfContainerObs = new MutationObserver(([ { addedNodes, removedNodes } ]) => {
  218. if(addedNodes.length > 0 || removedNodes.length > 0) {
  219. info("Detected carousel shelf container change - added nodes:", addedNodes.length, "- removed nodes:", removedNodes.length);
  220. siteEvents.fire("carouselShelvesChanged", { addedNodes, removedNodes });
  221. }
  222. });
  223. shelfContainerObs.observe(document.querySelector("#contents.ytmusic-section-list-renderer")!, {
  224. childList: true,
  225. });
  226. observers.concat([ shelfContainerObs ]);
  227. }