dom.ts 7.8 KB

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