dom.ts 11 KB

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