dom.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import { addGlobalStyle, getUnsafeWindow, randomId, type Stringifiable } from "@sv443-network/userutils";
  2. import { error, fetchCss, getDomain, t } from "./index.js";
  3. import { addSelectorListener } from "../observers.js";
  4. import type { ResourceKey, TTPolicy } from "../types.js";
  5. import { siteEvents } from "../siteEvents.js";
  6. import DOMPurify from "dompurify";
  7. /** Whether the DOM has finished loading and elements can be added or modified */
  8. export let domLoaded = false;
  9. document.addEventListener("DOMContentLoaded", () => domLoaded = true);
  10. //#region vid time & vol.
  11. /** Returns the video element selector string based on the current domain */
  12. export const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
  13. /** Returns the video element based on the current domain */
  14. export function getVideoElement() {
  15. return document.querySelector<HTMLVideoElement>(getVideoSelector());
  16. }
  17. let vidElemReady = false;
  18. /**
  19. * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
  20. * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
  21. * 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)
  22. * @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
  23. */
  24. export function getVideoTime(precision = 2) {
  25. return new Promise<number | null>(async (res) => {
  26. if(!vidElemReady) {
  27. await waitVideoElementReady();
  28. vidElemReady = true;
  29. }
  30. const resolveWithVal = (time: number | null) => res(
  31. time && !isNaN(time)
  32. ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision))
  33. : null
  34. );
  35. try {
  36. if(getDomain() === "ytm") {
  37. const vidElem = getVideoElement();
  38. if(vidElem)
  39. return resolveWithVal(vidElem.currentTime);
  40. addSelectorListener<HTMLProgressElement>("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
  41. listener: (pbEl) =>
  42. resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
  43. });
  44. }
  45. else if(getDomain() === "yt") {
  46. const vidElem = getVideoElement();
  47. if(vidElem)
  48. return resolveWithVal(vidElem.currentTime);
  49. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  50. ytForceShowVideoTime();
  51. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  52. let videoTime = -1;
  53. const mut = new MutationObserver(() => {
  54. // .observe() is only called when the element exists - no need to check for null
  55. videoTime = Number(document.querySelector<HTMLProgressElement>(pbSelector)!.getAttribute("aria-valuenow")!);
  56. });
  57. const observe = (progElem: HTMLProgressElement) => {
  58. mut.observe(progElem, {
  59. attributes: true,
  60. attributeFilter: ["aria-valuenow"],
  61. });
  62. if(videoTime >= 0 && !isNaN(videoTime)) {
  63. resolveWithVal(Math.floor(videoTime));
  64. mut.disconnect();
  65. }
  66. else
  67. setTimeout(() => {
  68. resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
  69. mut.disconnect();
  70. }, 500);
  71. };
  72. addSelectorListener<HTMLProgressElement>("body", pbSelector, { listener: observe });
  73. }
  74. }
  75. catch(err) {
  76. error("Couldn't get video time due to error:", err);
  77. res(null);
  78. }
  79. });
  80. }
  81. /**
  82. * Sends events that force the video controls to become visible for about 3 seconds.
  83. * This only works once (for some reason), then the page needs to be reloaded!
  84. */
  85. function ytForceShowVideoTime() {
  86. const player = document.querySelector("#movie_player");
  87. if(!player)
  88. return false;
  89. const defaultProps = {
  90. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  91. view: getUnsafeWindow(),
  92. bubbles: true,
  93. cancelable: false,
  94. };
  95. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  96. const { x, y, width, height } = player.getBoundingClientRect();
  97. const screenY = Math.round(y + height / 2);
  98. const screenX = x + Math.min(50, Math.round(width / 3));
  99. player.dispatchEvent(new MouseEvent("mousemove", {
  100. ...defaultProps,
  101. screenY,
  102. screenX,
  103. movementX: 5,
  104. movementY: 0,
  105. }));
  106. return true;
  107. }
  108. /**
  109. * Waits for the video element to be in its readyState 4 / canplay state and returns it.
  110. * Resolves immediately if the video element is already ready.
  111. */
  112. export function waitVideoElementReady(): Promise<HTMLVideoElement> {
  113. return new Promise(async (res) => {
  114. if(getVideoElement()?.readyState === 4)
  115. return res(getVideoElement()!);
  116. const waitForEl = () => addSelectorListener<HTMLVideoElement>("body", getVideoSelector(), {
  117. listener: async (vidElem) => {
  118. if(vidElem) {
  119. // this is just after YT has finished doing their own shenanigans with the video time and volume
  120. if(vidElem.readyState === 4)
  121. res(vidElem);
  122. else
  123. vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
  124. }
  125. },
  126. });
  127. if(!location.pathname.startsWith("/watch"))
  128. await siteEvents.once("watchIdChanged");
  129. waitForEl();
  130. });
  131. }
  132. //#region css utils
  133. /**
  134. * Adds a style element to the DOM at runtime.
  135. * @param css The CSS stylesheet to add
  136. * @param ref A reference string to identify the style element - defaults to a random 5-character string
  137. * @param transform A function to transform the CSS before adding it to the DOM
  138. */
  139. export async function addStyle(css: string, ref?: string, transform: (css: string) => string | Promise<string> = (c) => c) {
  140. if(!domLoaded)
  141. throw new Error("DOM has not finished loading yet");
  142. const elem = addGlobalStyle(await transform(css));
  143. elem.id = `bytm-style-${ref ?? randomId(5, 36)}`;
  144. return elem;
  145. }
  146. /**
  147. * Adds a global style element with the contents fetched from the specified CSS resource.
  148. * The CSS can be transformed using the provided function before being added to the DOM.
  149. */
  150. export async function addStyleFromResource(key: ResourceKey & `css-${string}`, transform: (css: string) => string = (c) => c) {
  151. const css = await fetchCss(key);
  152. if(css) {
  153. await addStyle(transform(css), key.slice(4));
  154. return true;
  155. }
  156. return false;
  157. }
  158. /** Sets a global CSS variable on the &lt;document&gt; element */
  159. export function setGlobalCssVar(name: string, value: Stringifiable) {
  160. document.documentElement.style.setProperty(`--bytm-global-${name}`, String(value));
  161. }
  162. /** Sets multiple global CSS variables on the &lt;document&gt; element */
  163. export function setGlobalCssVars(vars: Record<string, Stringifiable>) {
  164. for(const [name, value] of Object.entries(vars))
  165. setGlobalCssVar(name, value);
  166. }
  167. //#region other
  168. /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
  169. export function clearInner(element: Element) {
  170. while(element.hasChildNodes())
  171. clearNode(element!.firstChild as Element);
  172. }
  173. /** Removes all child nodes of an element recursively and also removes the element itself */
  174. export function clearNode(element: Element) {
  175. while(element.hasChildNodes())
  176. clearNode(element!.firstChild as Element);
  177. element.parentNode!.removeChild(element);
  178. }
  179. /**
  180. * Checks if the currently playing media is a song or a video.
  181. * Only works on YTM and will throw on YT!
  182. * This function should only be called after awaiting {@linkcode waitVideoElementReady}!
  183. */
  184. export function currentMediaType(): "video" | "song" {
  185. if(getDomain() === "yt")
  186. throw new Error("currentMediaType() is only available on YTM!");
  187. const songImgElem = document.querySelector("ytmusic-player #song-image");
  188. if(!songImgElem)
  189. throw new Error("Couldn't find the song image element. Use this function only after `await waitVideoElementReady()`!");
  190. return getUnsafeWindow().getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
  191. }
  192. /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
  193. export function copyToClipboard(text: Stringifiable) {
  194. try {
  195. GM.setClipboard(String(text));
  196. }
  197. catch {
  198. alert(t("copy_to_clipboard_error", String(text)));
  199. }
  200. }
  201. let ttPolicy: TTPolicy | undefined;
  202. /** On Firefox, sets innerHTML directly, on Chromium, uses a [TrustedTypes](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) policy to set the HTML */
  203. export function setInnerHtmlTrusted(element: HTMLElement, html: string) {
  204. if(!ttPolicy && window?.trustedTypes?.createPolicy)
  205. ttPolicy = window.trustedTypes?.createPolicy("default", {
  206. createHTML: (unsafeHtml) =>
  207. DOMPurify.sanitize(unsafeHtml, {
  208. RETURN_TRUSTED_TYPE: false,
  209. }),
  210. });
  211. element.innerHTML = ttPolicy ? ttPolicy!.createHTML(html) : html;
  212. }