dom.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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 } from "../types.js";
  5. import { siteEvents } from "../siteEvents.js";
  6. /** Whether the DOM has finished loading and elements can be added or modified */
  7. export let domLoaded = false;
  8. document.addEventListener("DOMContentLoaded", () => domLoaded = true);
  9. //#region video time, volume
  10. /** Returns the video element selector string based on the current domain */
  11. export const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
  12. /**
  13. * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
  14. * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
  15. * 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)
  16. * @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
  17. */
  18. export function getVideoTime(precision = 2) {
  19. return new Promise<number | null>(async (res) => {
  20. await waitVideoElementReady();
  21. try {
  22. if(getDomain() === "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(getDomain() === "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. /**
  95. * Waits for the video element to be in its readyState 4 / canplay state and returns it.
  96. * Resolves immediately if the video element is already ready.
  97. */
  98. export function waitVideoElementReady(): Promise<HTMLVideoElement> {
  99. return new Promise(async (res) => {
  100. const waitForEl = () => addSelectorListener<HTMLVideoElement>("body", getVideoSelector(), {
  101. listener: async (vidElem) => {
  102. if(vidElem) {
  103. // this is just after YT has finished doing their own shenanigans with the video time and volume
  104. if(vidElem.readyState === 4)
  105. res(vidElem);
  106. else
  107. vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
  108. }
  109. },
  110. });
  111. if(!location.pathname.startsWith("/watch"))
  112. await siteEvents.once("watchIdChanged");
  113. waitForEl();
  114. });
  115. }
  116. //#region other
  117. /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
  118. export function clearInner(element: Element) {
  119. while(element.hasChildNodes())
  120. clearNode(element!.firstChild as Element);
  121. }
  122. /** Removes all child nodes of an element recursively and also removes the element itself */
  123. export function clearNode(element: Element) {
  124. while(element.hasChildNodes())
  125. clearNode(element!.firstChild as Element);
  126. element.parentNode!.removeChild(element);
  127. }
  128. /**
  129. * Adds a style element to the DOM at runtime.
  130. * @param css The CSS stylesheet to add
  131. * @param ref A reference string to identify the style element - defaults to a random 5-character string
  132. * @param transform A function to transform the CSS before adding it to the DOM
  133. */
  134. export function addStyle(css: string, ref?: string, transform: (css: string) => string = (c) => c) {
  135. if(!domLoaded)
  136. throw new Error("DOM has not finished loading yet");
  137. const elem = addGlobalStyle(transform(css));
  138. elem.id = `bytm-global-style-${ref ?? randomId(5, 36)}`;
  139. return elem;
  140. }
  141. /**
  142. * Checks if the currently playing media is a song or a video.
  143. * This function should only be called after awaiting {@linkcode waitVideoElementReady}!
  144. */
  145. export function currentMediaType(): "video" | "song" {
  146. const songImgElem = document.querySelector("ytmusic-player #song-image");
  147. if(!songImgElem)
  148. throw new Error("Couldn't find the song image element. Use this function only after `await waitVideoElementReady()`!");
  149. return getUnsafeWindow().getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
  150. }
  151. /**
  152. * Adds a global style element with the contents fetched from the specified CSS resource.
  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. addStyle(transform(css), key.slice(4));
  159. return true;
  160. }
  161. return false;
  162. }
  163. /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
  164. export function copyToClipboard(text: Stringifiable) {
  165. try {
  166. GM.setClipboard(String(text));
  167. }
  168. catch {
  169. alert(t("copy_to_clipboard_error", String(text)));
  170. }
  171. }