123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- import { addGlobalStyle, getUnsafeWindow, randomId, type Stringifiable } from "@sv443-network/userutils";
- import DOMPurify from "dompurify";
- import { error, fetchCss, getDomain, t } from "./index.js";
- import { addSelectorListener } from "../observers.js";
- import type { ResourceKey, TTPolicy } from "../types.js";
- import { siteEvents } from "../siteEvents.js";
- import { showPrompt } from "../dialogs/prompt.js";
- /** Whether the DOM has finished loading and elements can be added or modified */
- export let domLoaded = false;
- document.addEventListener("DOMContentLoaded", () => domLoaded = true);
- //#region vid time & vol.
- /** Returns the video element selector string based on the current domain */
- export const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
- /** Returns the video element based on the current domain */
- export function getVideoElement() {
- return document.querySelector<HTMLVideoElement>(getVideoSelector());
- }
- let vidElemReady = false;
- /**
- * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
- * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
- * 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)
- * @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
- */
- export function getVideoTime(precision = 2) {
- return new Promise<number | null>(async (res) => {
- if(!vidElemReady) {
- await waitVideoElementReady();
- vidElemReady = true;
- }
- const resolveWithVal = (time: number | null) => res(
- time && !isNaN(time)
- ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision))
- : null
- );
- try {
- if(getDomain() === "ytm") {
- const vidElem = getVideoElement();
- if(vidElem)
- return resolveWithVal(vidElem.currentTime);
- addSelectorListener<HTMLProgressElement>("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
- listener: (pbEl) =>
- resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
- });
- }
- else if(getDomain() === "yt") {
- const vidElem = getVideoElement();
- if(vidElem)
- return resolveWithVal(vidElem.currentTime);
- // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
- ytForceShowVideoTime();
- const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
- let videoTime = -1;
- const mut = new MutationObserver(() => {
- // .observe() is only called when the element exists - no need to check for null
- videoTime = Number(document.querySelector<HTMLProgressElement>(pbSelector)!.getAttribute("aria-valuenow")!);
- });
- const observe = (progElem: HTMLProgressElement) => {
- mut.observe(progElem, {
- attributes: true,
- attributeFilter: ["aria-valuenow"],
- });
- if(videoTime >= 0 && !isNaN(videoTime)) {
- resolveWithVal(Math.floor(videoTime));
- mut.disconnect();
- }
- else
- setTimeout(() => {
- resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
- mut.disconnect();
- }, 500);
- };
- addSelectorListener<HTMLProgressElement>("body", pbSelector, { listener: observe });
- }
- }
- catch(err) {
- error("Couldn't get video time due to error:", err);
- res(null);
- }
- });
- }
- /**
- * Sends events that force the video controls to become visible for about 3 seconds.
- * This only works once (for some reason), then the page needs to be reloaded!
- */
- function ytForceShowVideoTime() {
- const player = document.querySelector("#movie_player");
- if(!player)
- return false;
- const defaultProps = {
- // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
- view: getUnsafeWindow(),
- bubbles: true,
- cancelable: false,
- };
- player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
- const { x, y, width, height } = player.getBoundingClientRect();
- const screenY = Math.round(y + height / 2);
- const screenX = x + Math.min(50, Math.round(width / 3));
- player.dispatchEvent(new MouseEvent("mousemove", {
- ...defaultProps,
- screenY,
- screenX,
- movementX: 5,
- movementY: 0,
- }));
- return true;
- }
- /**
- * Waits for the video element to be in its readyState 4 / canplay state and returns it.
- * Could take a very long time to resolve if the `/watch` page isn't open.
- * Resolves immediately if the video element is already ready.
- */
- export function waitVideoElementReady(): Promise<HTMLVideoElement> {
- return new Promise(async (res, rej) => {
- try {
- const vidEl = getVideoElement();
- if(vidEl?.readyState === 4)
- return res(vidEl);
- if(!location.pathname.startsWith("/watch"))
- await siteEvents.once("watchIdChanged");
- addSelectorListener<HTMLVideoElement>("body", getVideoSelector(), {
- listener(vidElem) {
- // this is just after YT has finished doing their own shenanigans with the video time and volume
- if(vidElem.readyState === 4)
- res(vidElem);
- else
- vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
- },
- });
- }
- catch(err) {
- rej(err);
- }
- });
- }
- //#region css utils
- /**
- * Adds a style element to the DOM at runtime.
- * @param css The CSS stylesheet to add
- * @param ref A reference string to identify the style element - defaults to a random 5-character string
- * @param transform A function to transform the CSS before adding it to the DOM
- */
- export async function addStyle(css: string, ref?: string, transform: (css: string) => string | Promise<string> = (c) => c) {
- if(!domLoaded)
- throw new Error("DOM has not finished loading yet");
- const elem = addGlobalStyle(await transform(css));
- elem.id = `bytm-style-${ref ?? randomId(6, 36)}`;
- return elem;
- }
- /**
- * Adds a global style element with the contents fetched from the specified resource starting with `css-`
- * The CSS can be transformed using the provided function before being added to the DOM.
- */
- export async function addStyleFromResource(key: ResourceKey & `css-${string}`, transform: (css: string) => string = (c) => c) {
- const css = await fetchCss(key);
- if(css) {
- await addStyle(transform(css), key.slice(4));
- return true;
- }
- return false;
- }
- /** Sets a global CSS variable on the <document> element with the name `--bytm-global-${name}` */
- export function setGlobalCssVar(name: string, value: Stringifiable) {
- document.documentElement.style.setProperty(`--bytm-global-${name.toLowerCase().trim()}`, String(value));
- }
- /** Sets multiple global CSS variables on the <document> element with the name `--bytm-global-${name}` */
- export function setGlobalCssVars(vars: Record<string, Stringifiable>) {
- for(const [name, value] of Object.entries(vars))
- setGlobalCssVar(name, value);
- }
- //#region other
- /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
- export function clearInner(element: Element) {
- while(element.hasChildNodes())
- clearNode(element!.firstChild as Element);
- }
- /** Removes all child nodes of an element recursively and also removes the element itself */
- export function clearNode(element: Element) {
- while(element.hasChildNodes())
- clearNode(element!.firstChild as Element);
- element.parentNode!.removeChild(element);
- }
- /**
- * Returns an identifier for the currently playing media type on YTM (song or video).
- * Only works on YTM and will throw on YT or if {@linkcode waitVideoElementReady} hasn't been awaited yet.
- */
- export function getCurrentMediaType(): "video" | "song" {
- if(getDomain() === "yt")
- throw new Error("currentMediaType() is only available on YTM!");
- const songImgElem = document.querySelector("ytmusic-player #song-image");
- if(!songImgElem)
- throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!");
- return window.getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
- }
- /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
- export function copyToClipboard(text: Stringifiable) {
- try {
- GM.setClipboard(String(text));
- }
- catch {
- showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) });
- }
- }
- let ttPolicy: TTPolicy | undefined;
- // workaround for supporting `target="_blank"` links without compromising security:
- const tempTargetAttrName = `data-tmp-target-${randomId(6, 36)}`;
- DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
- if(node.tagName === "A") {
- if(!node.hasAttribute("target"))
- node.setAttribute("target", "_self");
- if(node.hasAttribute("target"))
- node.setAttribute(tempTargetAttrName, node.getAttribute("target")!);
- }
- });
- DOMPurify.addHook("afterSanitizeAttributes", (node) => {
- if(node.tagName === "A" && node.hasAttribute(tempTargetAttrName)) {
- node.setAttribute("target", node.getAttribute(tempTargetAttrName)!);
- node.removeAttribute(tempTargetAttrName);
- if(node.getAttribute("target") === "_blank")
- node.setAttribute("rel", "noopener noreferrer");
- }
- });
- /** 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 */
- export function setInnerHtml(element: HTMLElement, html: string) {
- if(!ttPolicy && window?.trustedTypes?.createPolicy) {
- ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", {
- createHTML: (dirty: string) => DOMPurify.sanitize(dirty, {
- RETURN_TRUSTED_TYPE: true,
- }) as unknown as string,
- });
- }
- element.innerHTML = ttPolicy?.createHTML(html)
- ?? DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: false });
- }
- /** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */
- export function downloadFile(fileName: string, data: string | Blob, mimeType = "text/plain") {
- const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType });
- const a = document.createElement("a");
- a.classList.add("bytm-hidden");
- a.href = URL.createObjectURL(blob);
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- setTimeout(() => a.remove(), 50);
- }
|