misc.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { compress, fetchAdvanced, openInNewTab, randomId } from "@sv443-network/userutils";
  2. import { marked } from "marked";
  3. import { branch, compressionFormat, repo } from "../constants";
  4. import { type Domain, type ResourceKey } from "../types";
  5. import { error, type TrLocale, warn } from ".";
  6. import langMapping from "../../assets/locales.json" assert { type: "json" };
  7. //#region misc
  8. /**
  9. * Returns the current domain as a constant string representation
  10. * @throws Throws if script runs on an unexpected website
  11. */
  12. export function getDomain(): Domain {
  13. if(location.hostname.match(/^music\.youtube/))
  14. return "ytm";
  15. else if(location.hostname.match(/youtube\./))
  16. return "yt";
  17. else
  18. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  19. }
  20. /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */
  21. export function getSessionId(): string | null {
  22. try {
  23. let sesId = window.sessionStorage.getItem("_bytm-session-id");
  24. if(!sesId)
  25. window.sessionStorage.setItem("_bytm-session-id", sesId = randomId(8, 36));
  26. return sesId;
  27. }
  28. catch(err) {
  29. warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err);
  30. return null;
  31. }
  32. }
  33. let isCompressionSupported: boolean | undefined;
  34. /** Tests whether compression via the predefined {@linkcode compressionFormat} is supported */
  35. export async function compressionSupported() {
  36. if(typeof isCompressionSupported === "boolean")
  37. return isCompressionSupported;
  38. try {
  39. await compress(".", compressionFormat, "string");
  40. return isCompressionSupported = true;
  41. }
  42. catch(e) {
  43. return isCompressionSupported = false;
  44. }
  45. }
  46. /** Returns a string with the given array's items separated by a default separator (`", "` by default), with an optional different separator for the last item */
  47. export function arrayWithSeparators<TArray>(array: TArray[], separator = ", ", lastSeparator?: string) {
  48. const arr = [...array];
  49. if(!lastSeparator)
  50. lastSeparator = separator;
  51. if(arr.length === 0)
  52. return "";
  53. else if(arr.length <= 2)
  54. return arr.join(lastSeparator);
  55. else
  56. return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)!}`;
  57. }
  58. /** Returns the watch ID of the current video or null if not on a video page */
  59. export function getWatchId() {
  60. const { searchParams, pathname } = new URL(location.href);
  61. return pathname.includes("/watch") ? searchParams.get("v") : null;
  62. }
  63. type ThumbQuality = `${"" | "hq" | "mq" | "sd" | "maxres"}default`;
  64. /** Returns the thumbnail URL for a video with the given watch ID and quality (defaults to "hqdefault") */
  65. export function getThumbnailUrl(watchId: string, quality?: ThumbQuality): string
  66. /** Returns the thumbnail URL for a video with the given watch ID and index */
  67. export function getThumbnailUrl(watchId: string, index: 0 | 1 | 2 | 3): string
  68. /** Returns the thumbnail URL for a video with either a given quality identifier or index */
  69. export function getThumbnailUrl(watchId: string, qualityOrIndex: ThumbQuality | 0 | 1 | 2 | 3 = "hqdefault") {
  70. return `https://i.ytimg.com/vi/${watchId}/${qualityOrIndex}.jpg`;
  71. }
  72. /** Returns the best available thumbnail URL for a video with the given watch ID */
  73. export async function getBestThumbnailUrl(watchId: string) {
  74. const priorityList = ["maxresdefault", "sddefault", 0];
  75. for(const quality of priorityList) {
  76. let response: Response | undefined;
  77. const url = getThumbnailUrl(watchId, quality as ThumbQuality);
  78. try {
  79. response = await fetchAdvanced(url, { method: "HEAD", timeout: 5000 });
  80. }
  81. catch(e) {
  82. void e;
  83. }
  84. if(response?.ok)
  85. return url;
  86. }
  87. }
  88. /** Copies a JSON-serializable object */
  89. export function reserialize<T>(data: T): T {
  90. return JSON.parse(JSON.stringify(data));
  91. }
  92. /** Opens the given URL in a new tab, using GM.openInTab if available */
  93. export function openInTab(href: string, background = true) {
  94. try {
  95. openInNewTab(href, background);
  96. }
  97. catch(err) {
  98. window.open(href, "_blank", "noopener noreferrer");
  99. }
  100. }
  101. //#region resources
  102. /**
  103. * Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl)
  104. * Falls back to a `raw.githubusercontent.com` URL or base64-encoded data URI if the resource is not available in the GM resource cache
  105. */
  106. export async function getResourceUrl(name: ResourceKey | "_") {
  107. let url = await GM.getResourceUrl(name);
  108. if(!url || url.length === 0) {
  109. const resource = GM.info.script.resources?.[name].url;
  110. if(typeof resource === "string") {
  111. const resourceUrl = new URL(resource);
  112. const resourcePath = resourceUrl.pathname;
  113. if(resourcePath)
  114. return `https://raw.githubusercontent.com/${repo}/${branch}${resourcePath}`;
  115. }
  116. warn(`Couldn't get blob URL nor external URL for @resource '${name}', trying to use base64-encoded fallback`);
  117. // @ts-ignore
  118. url = await GM.getResourceUrl(name, false);
  119. }
  120. return url;
  121. }
  122. /**
  123. * Returns the preferred locale of the user, provided it is supported by the userscript.
  124. * Prioritizes `navigator.language`, then `navigator.languages`, then `"en_US"` as a fallback.
  125. */
  126. export function getPreferredLocale(): TrLocale {
  127. const navLang = navigator.language.replace(/-/g, "_");
  128. const navLangs = navigator.languages
  129. .filter(lang => lang.match(/^[a-z]{2}(-|_)[A-Z]$/) !== null)
  130. .map(lang => lang.replace(/-/g, "_"));
  131. if(Object.entries(langMapping).find(([key]) => key === navLang))
  132. return navLang as TrLocale;
  133. for(const loc of navLangs) {
  134. if(Object.entries(langMapping).find(([key]) => key === loc))
  135. return loc as TrLocale;
  136. }
  137. // if navigator.languages has entries that aren't locale codes in the format xx_XX
  138. if(navigator.languages.some(lang => lang.match(/^[a-z]{2}$/))) {
  139. for(const lang of navLangs) {
  140. const foundLoc = Object.entries(langMapping).find(([key]) => key.startsWith(lang))?.[0];
  141. if(foundLoc)
  142. return foundLoc as TrLocale;
  143. }
  144. }
  145. return "en_US";
  146. }
  147. /** Returns the content behind the passed resource identifier to be assigned to an element's innerHTML property */
  148. export async function resourceToHTMLString(resource: ResourceKey) {
  149. try {
  150. const resourceUrl = await getResourceUrl(resource);
  151. if(!resourceUrl)
  152. throw new Error(`Couldn't find URL for resource '${resource}'`);
  153. return await (await fetchAdvanced(resourceUrl)).text();
  154. }
  155. catch(err) {
  156. error("Couldn't get SVG element from resource:", err);
  157. return null;
  158. }
  159. }
  160. /** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */
  161. export function parseMarkdown(mdString: string) {
  162. return marked.parse(mdString, {
  163. async: true,
  164. gfm: true,
  165. });
  166. }
  167. /** Returns the content of the changelog markdown file */
  168. export async function getChangelogMd() {
  169. return await (await fetchAdvanced(await getResourceUrl("doc-changelog"))).text();
  170. }
  171. /** Returns the changelog as HTML with a details element for each version */
  172. export async function getChangelogHtmlWithDetails() {
  173. try {
  174. const changelogMd = await getChangelogMd();
  175. let changelogHtml = await parseMarkdown(changelogMd);
  176. const getVerId = (verStr: string) => verStr.trim().replace(/[._#\s-]/g, "");
  177. changelogHtml = changelogHtml.replace(/<div\s+class="split">\s*<\/div>\s*\n?\s*<br(\s\/)?>/gm, "</details>\n<br>\n<details class=\"bytm-changelog-version-details\">");
  178. const h2Matches = Array.from(changelogHtml.matchAll(/<h2(\s+id=".+")?>([\d\w\s.]+)<\/h2>/gm));
  179. for(const match of h2Matches) {
  180. const [fullMatch, , verStr] = match;
  181. const verId = getVerId(verStr);
  182. const h2Elem = `<h2 id="${verId}" role="subheading" aria-level="1">Version ${verStr}</h2>`;
  183. const summaryElem = `<summary tab-index="0">${h2Elem}</summary>`;
  184. changelogHtml = changelogHtml.replace(fullMatch, `${summaryElem}`);
  185. }
  186. changelogHtml = `<details class="bytm-changelog-version-details">${changelogHtml}</details>`;
  187. return changelogHtml;
  188. }
  189. catch(err) {
  190. return `Error while preparing changelog: ${err}`;
  191. }
  192. }