dom.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { addGlobalStyle, getUnsafeWindow, randomId, type Stringifiable } from "@sv443-network/userutils";
  2. import { error, fetchCss, getDomain, t } from ".";
  3. import { addSelectorListener } from "../observers";
  4. import type { ResourceKey } from "../types";
  5. /** Whether the DOM has finished loading and elements can be added or modified */
  6. export let domLoaded = false;
  7. document.addEventListener("DOMContentLoaded", () => domLoaded = true);
  8. //#region video time, volume
  9. /** Returns the video element selector string based on the current domain */
  10. export const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
  11. /**
  12. * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
  13. * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
  14. * Dispatches mouse movement events in case the video time can't be read from the video or progress bar elements (needs a prior user interaction to work)
  15. * @returns Returns null if the video time is unavailable or no user interaction has happened prior to calling in case of the fallback behavior being used
  16. */
  17. export function getVideoTime(precision = 2) {
  18. return new Promise<number | null>(async (res) => {
  19. const domain = getDomain();
  20. await waitVideoElementReady();
  21. try {
  22. if(domain === "ytm") {
  23. const vidElem = document.querySelector<HTMLVideoElement>(getVideoSelector());
  24. if(vidElem)
  25. return res(Number(precision <= 0 ? Math.floor(vidElem.currentTime) : vidElem.currentTime.toFixed(precision)));
  26. addSelectorListener<HTMLProgressElement>("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
  27. listener: (pbEl) =>
  28. res(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
  29. });
  30. }
  31. else if(domain === "yt") {
  32. const vidElem = document.querySelector<HTMLVideoElement>(getVideoSelector());
  33. if(vidElem)
  34. return res(Number(precision <= 0 ? Math.floor(vidElem.currentTime) : vidElem.currentTime.toFixed(precision)));
  35. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  36. ytForceShowVideoTime();
  37. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  38. let videoTime = -1;
  39. const mut = new MutationObserver(() => {
  40. // .observe() is only called when the element exists - no need to check for null
  41. videoTime = Number(document.querySelector<HTMLProgressElement>(pbSelector)!.getAttribute("aria-valuenow")!);
  42. });
  43. const observe = (progElem: HTMLProgressElement) => {
  44. mut.observe(progElem, {
  45. attributes: true,
  46. attributeFilter: ["aria-valuenow"],
  47. });
  48. if(videoTime >= 0 && !isNaN(videoTime)) {
  49. res(Math.floor(videoTime));
  50. mut.disconnect();
  51. }
  52. else
  53. setTimeout(() => {
  54. res(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
  55. mut.disconnect();
  56. }, 500);
  57. };
  58. addSelectorListener<HTMLProgressElement>("body", pbSelector, { listener: observe });
  59. }
  60. }
  61. catch(err) {
  62. error("Couldn't get video time due to error:", err);
  63. res(null);
  64. }
  65. });
  66. }
  67. /**
  68. * Sends events that force the video controls to become visible for about 3 seconds.
  69. * This only works once (for some reason), then the page needs to be reloaded!
  70. */
  71. function ytForceShowVideoTime() {
  72. const player = document.querySelector("#movie_player");
  73. if(!player)
  74. return false;
  75. const defaultProps = {
  76. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  77. view: getUnsafeWindow(),
  78. bubbles: true,
  79. cancelable: false,
  80. };
  81. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  82. const { x, y, width, height } = player.getBoundingClientRect();
  83. const screenY = Math.round(y + height / 2);
  84. const screenX = x + Math.min(50, Math.round(width / 3));
  85. player.dispatchEvent(new MouseEvent("mousemove", {
  86. ...defaultProps,
  87. screenY,
  88. screenX,
  89. movementX: 5,
  90. movementY: 0,
  91. }));
  92. return true;
  93. }
  94. /** Waits for the video element to be in its readyState 4 / canplay state and returns it - resolves immediately if the video is already ready */
  95. export function waitVideoElementReady(): Promise<HTMLVideoElement> {
  96. return new Promise((res) => {
  97. addSelectorListener<HTMLVideoElement>("body", getVideoSelector(), {
  98. listener: async (vidElem) => {
  99. if(vidElem) {
  100. // this is just after YT has finished doing their own shenanigans with the video time and volume
  101. if(vidElem.readyState === 4)
  102. res(vidElem);
  103. else
  104. vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
  105. }
  106. },
  107. });
  108. });
  109. }
  110. //#region other
  111. /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
  112. export function clearInner(element: Element) {
  113. while(element.hasChildNodes())
  114. clearNode(element!.firstChild as Element);
  115. }
  116. function clearNode(element: Element) {
  117. while(element.hasChildNodes())
  118. clearNode(element!.firstChild as Element);
  119. element.parentNode!.removeChild(element);
  120. }
  121. const interactionKeys = ["Enter", " ", "Space"];
  122. /**
  123. * Adds generic, accessible interaction listeners to the passed element.
  124. * All listeners have the default behavior prevented and stop immediate propagation.
  125. * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
  126. */
  127. export function onInteraction<TElem extends HTMLElement>(elem: TElem, listener: (evt: MouseEvent | KeyboardEvent) => void, listenerOptions?: AddEventListenerOptions) {
  128. const proxListener = (e: MouseEvent | KeyboardEvent) => {
  129. if(e instanceof KeyboardEvent) {
  130. if(interactionKeys.includes(e.key)) {
  131. e.preventDefault();
  132. e.stopImmediatePropagation();
  133. }
  134. else return;
  135. }
  136. else if(e instanceof MouseEvent) {
  137. e.preventDefault();
  138. e.stopImmediatePropagation();
  139. }
  140. // clean up the other listener that isn't automatically removed if `once` is set
  141. listenerOptions?.once && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOptions);
  142. listenerOptions?.once && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOptions);
  143. listener(e);
  144. };
  145. elem.addEventListener("click", proxListener, listenerOptions);
  146. elem.addEventListener("keydown", proxListener, listenerOptions);
  147. }
  148. /**
  149. * Adds a style element to the DOM at runtime.
  150. * @param css The CSS stylesheet to add
  151. * @param ref A reference string to identify the style element - defaults to a random 5-character string
  152. */
  153. export function addStyle(css: string, ref?: string) {
  154. if(!domLoaded)
  155. throw new Error("DOM has not finished loading yet");
  156. const elem = addGlobalStyle(css);
  157. elem.id = `bytm-global-style-${ref ?? randomId(5, 36)}`;
  158. return elem;
  159. }
  160. /**
  161. * Checks if the currently playing media is a song or a video.
  162. * This function should only be called after awaiting `waitVideoElementReady()`!
  163. */
  164. export function currentMediaType(): "video" | "song" {
  165. const songImgElem = document.querySelector("ytmusic-player #song-image");
  166. if(!songImgElem)
  167. throw new Error("Couldn't find the song image element. Use this function only after `await waitVideoElementReady()`!");
  168. return getUnsafeWindow().getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
  169. }
  170. /**
  171. * Inserts {@linkcode beforeElement} as a sibling just before the provided {@linkcode afterElement}
  172. * @returns Returns the {@linkcode beforeElement}
  173. */
  174. export function insertBefore(afterElement: Element, beforeElement: Element) {
  175. afterElement.parentNode?.insertBefore(beforeElement, afterElement);
  176. return beforeElement;
  177. }
  178. /** Adds a global style element with the contents of the specified CSS resource */
  179. export async function addStyleFromResource(key: ResourceKey & `css-${string}`) {
  180. const css = await fetchCss(key);
  181. if(css) {
  182. addStyle(css, key.slice(4));
  183. return true;
  184. }
  185. return false;
  186. }
  187. /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
  188. export function copyToClipboard(text: Stringifiable) {
  189. try {
  190. GM.setClipboard(String(text));
  191. }
  192. catch(err) {
  193. alert(t("copy_to_clipboard_error", String(text)));
  194. }
  195. }