layout.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. import { addParent, autoPlural, debounce, fetchAdvanced, pauseFor } from "@sv443-network/userutils";
  2. import { getFeature, getFeatures } from "../config.js";
  3. import { siteEvents } from "../siteEvents.js";
  4. import { addSelectorListener } from "../observers.js";
  5. import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, addStyle, currentMediaType, domLoaded, waitVideoElementReady, getVideoTime, fetchCss, addStyleFromResource, fetchVideoVotes, getWatchId, getLocale, tp } from "../utils/index.js";
  6. import { mode, scriptInfo } from "../constants.js";
  7. import { openCfgMenu } from "../menu/menu_old.js";
  8. import { createCircularBtn, createRipple } from "../components/index.js";
  9. import type { NumberNotation, ResourceKey, VideoVotesObj } from "../types.js";
  10. import "./layout.css";
  11. //#region cfg menu btns
  12. let logoExchanged = false, improveLogoCalled = false;
  13. /** Adds a watermark beneath the logo */
  14. export async function addWatermark() {
  15. const watermark = document.createElement("a");
  16. watermark.role = "button";
  17. watermark.id = "bytm-watermark";
  18. watermark.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select");
  19. watermark.textContent = scriptInfo.name;
  20. watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
  21. watermark.tabIndex = 0;
  22. improveLogo();
  23. const watermarkOpenMenu = (e: MouseEvent | KeyboardEvent) => {
  24. e.stopPropagation();
  25. if((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  26. openCfgMenu();
  27. if(!logoExchanged && (e.shiftKey || e.ctrlKey))
  28. exchangeLogo();
  29. };
  30. onInteraction(watermark, watermarkOpenMenu);
  31. addSelectorListener("navBar", "ytmusic-nav-bar #left-content", {
  32. listener: (logoElem) => logoElem.insertAdjacentElement("afterend", watermark),
  33. });
  34. log("Added watermark element");
  35. }
  36. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  37. export async function improveLogo() {
  38. try {
  39. if(improveLogoCalled)
  40. return;
  41. improveLogoCalled = true;
  42. const res = await fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  43. const svg = await res.text();
  44. addSelectorListener("navBar", "ytmusic-logo a", {
  45. listener: (logoElem) => {
  46. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  47. logoElem.innerHTML = svg;
  48. logoElem.querySelectorAll("ellipse").forEach((e) => {
  49. e.classList.add("bytm-mod-logo-ellipse");
  50. });
  51. logoElem.querySelector("path")?.classList.add("bytm-mod-logo-path");
  52. log("Swapped logo to inline SVG");
  53. },
  54. });
  55. }
  56. catch(err) {
  57. error("Couldn't improve logo due to an error:", err);
  58. }
  59. }
  60. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  61. function exchangeLogo() {
  62. addSelectorListener("navBar", ".bytm-mod-logo", {
  63. listener: async (logoElem) => {
  64. if(logoElem.classList.contains("bytm-logo-exchanged"))
  65. return;
  66. logoExchanged = true;
  67. logoElem.classList.add("bytm-logo-exchanged");
  68. const iconUrl = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  69. const newLogo = document.createElement("img");
  70. newLogo.classList.add("bytm-mod-logo-img");
  71. newLogo.src = iconUrl;
  72. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  73. document.head.querySelectorAll<HTMLLinkElement>("link[rel=\"icon\"]").forEach((e) => {
  74. e.href = iconUrl;
  75. });
  76. setTimeout(() => {
  77. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  78. }, 1000);
  79. },
  80. });
  81. }
  82. /** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */
  83. export async function addConfigMenuOptionYTM(container: HTMLElement) {
  84. const cfgOptElem = document.createElement("div");
  85. cfgOptElem.classList.add("bytm-cfg-menu-option");
  86. const cfgOptItemElem = document.createElement("div");
  87. cfgOptItemElem.classList.add("bytm-cfg-menu-option-item");
  88. cfgOptItemElem.role = "button";
  89. cfgOptItemElem.tabIndex = 0;
  90. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
  91. onInteraction(cfgOptItemElem, async (e: MouseEvent | KeyboardEvent) => {
  92. const settingsBtnElem = document.querySelector<HTMLElement>("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  93. settingsBtnElem?.click();
  94. await pauseFor(20);
  95. if((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  96. openCfgMenu();
  97. if(!logoExchanged && (e.shiftKey || e.ctrlKey))
  98. exchangeLogo();
  99. });
  100. const cfgOptIconElem = document.createElement("img");
  101. cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon");
  102. cfgOptIconElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  103. const cfgOptTextElem = document.createElement("div");
  104. cfgOptTextElem.classList.add("bytm-cfg-menu-option-text");
  105. cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
  106. cfgOptItemElem.appendChild(cfgOptIconElem);
  107. cfgOptItemElem.appendChild(cfgOptTextElem);
  108. cfgOptElem.appendChild(cfgOptItemElem);
  109. container.appendChild(cfgOptElem);
  110. improveLogo();
  111. log("Added BYTM-Configuration button to menu popover");
  112. }
  113. /** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */
  114. export async function addConfigMenuOptionYT(container: HTMLElement) {
  115. const cfgOptWrapperElem = document.createElement("div");
  116. cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore");
  117. cfgOptWrapperElem.role = "button";
  118. cfgOptWrapperElem.tabIndex = 0;
  119. cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name);
  120. const cfgOptElem = document.createElement("div");
  121. cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner");
  122. const cfgOptImgElem = document.createElement("img");
  123. cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon");
  124. cfgOptImgElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
  125. const cfgOptItemElem = document.createElement("div");
  126. cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item");
  127. cfgOptItemElem.textContent = scriptInfo.name;
  128. cfgOptElem.appendChild(cfgOptImgElem);
  129. cfgOptElem.appendChild(cfgOptItemElem);
  130. cfgOptWrapperElem.appendChild(cfgOptElem);
  131. onInteraction(cfgOptWrapperElem, openCfgMenu);
  132. const firstChild = container?.firstElementChild;
  133. if(firstChild)
  134. container.insertBefore(cfgOptWrapperElem, firstChild);
  135. else
  136. return error("Couldn't add config menu option to YT titlebar - couldn't find container element");
  137. }
  138. //#region anchor impr.
  139. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  140. export async function addAnchorImprovements() {
  141. try {
  142. const css = await fetchCss("css-anchor_improvements");
  143. if(css)
  144. addStyle(css, "anchor-improvements");
  145. }
  146. catch(err) {
  147. error("Couldn't add anchor improvements CSS due to an error:", err);
  148. }
  149. //#region carousel shelves
  150. try {
  151. const preventDefault = (e: MouseEvent) => e.preventDefault();
  152. /** Adds anchor improvements to &lt;ytmusic-responsive-list-item-renderer&gt; */
  153. const addListItemAnchors = (items: NodeListOf<HTMLElement>) => {
  154. for(const item of items) {
  155. if(item.classList.contains("bytm-anchor-improved"))
  156. continue;
  157. item.classList.add("bytm-anchor-improved");
  158. const thumbnailElem = item.querySelector<HTMLElement>(".left-items");
  159. const titleElem = item.querySelector<HTMLAnchorElement>(".title-column .title a");
  160. if(!thumbnailElem || !titleElem)
  161. continue;
  162. const anchorElem = document.createElement("a");
  163. anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
  164. anchorElem.href = titleElem?.href ?? "#";
  165. anchorElem.target = "_self";
  166. anchorElem.role = "button";
  167. anchorElem.addEventListener("click", preventDefault);
  168. addParent(thumbnailElem, anchorElem);
  169. }
  170. };
  171. // home page
  172. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
  173. continuous: true,
  174. all: true,
  175. listener: addListItemAnchors,
  176. });
  177. // related tab in /watch
  178. addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
  179. continuous: true,
  180. all: true,
  181. listener: addListItemAnchors,
  182. });
  183. // playlists
  184. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
  185. continuous: true,
  186. all: true,
  187. listener: addListItemAnchors,
  188. });
  189. // generic shelves
  190. addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
  191. continuous: true,
  192. all: true,
  193. listener: addListItemAnchors,
  194. });
  195. }
  196. catch(err) {
  197. error("Couldn't improve carousel shelf anchors due to an error:", err);
  198. }
  199. //#region sidebar
  200. try {
  201. const addSidebarAnchors = (sidebarCont: HTMLElement) => {
  202. const items = sidebarCont.parentNode!.querySelectorAll<HTMLElement>("ytmusic-guide-entry-renderer tp-yt-paper-item");
  203. improveSidebarAnchors(items);
  204. return items.length;
  205. };
  206. addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  207. listener: (sidebarCont) => {
  208. const itemsAmt = addSidebarAnchors(sidebarCont);
  209. log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
  210. },
  211. });
  212. addSelectorListener("sideBarMini", "ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  213. listener: (miniSidebarCont) => {
  214. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  215. log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
  216. },
  217. });
  218. }
  219. catch(err) {
  220. error("Couldn't add anchors to sidebar items due to an error:", err);
  221. }
  222. }
  223. const sidebarPaths = [
  224. "/",
  225. "/explore",
  226. "/library",
  227. ];
  228. /**
  229. * Adds anchors to the sidebar items so they can be opened in a new tab
  230. * @param sidebarItem
  231. */
  232. function improveSidebarAnchors(sidebarItems: NodeListOf<HTMLElement>) {
  233. sidebarItems.forEach((item, i) => {
  234. const anchorElem = document.createElement("a");
  235. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  236. anchorElem.role = "button";
  237. anchorElem.target = "_self";
  238. anchorElem.href = sidebarPaths[i] ?? "#";
  239. anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
  240. anchorElem.addEventListener("click", (e) => {
  241. e.preventDefault();
  242. });
  243. addParent(item, anchorElem);
  244. });
  245. }
  246. //#region share track par.
  247. /** Removes the ?si tracking parameter from share URLs */
  248. export async function initRemShareTrackParam() {
  249. const removeSiParam = (inputElem: HTMLInputElement) => {
  250. try {
  251. if(!inputElem.value.match(/(&|\?)si=/i))
  252. return;
  253. const url = new URL(inputElem.value);
  254. url.searchParams.delete("si");
  255. inputElem.value = String(url);
  256. log(`Removed tracking parameter from share link -> ${url}`);
  257. }
  258. catch(err) {
  259. warn("Couldn't remove tracking parameter from share link due to error:", err);
  260. }
  261. };
  262. const [sharePanelSel, inputSel] = (() => {
  263. switch(getDomain()) {
  264. case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"];
  265. case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"];
  266. }
  267. })();
  268. addSelectorListener("body", sharePanelSel, {
  269. listener: (sharePanelEl) => {
  270. const obs = new MutationObserver(() => {
  271. const inputElem = sharePanelEl.querySelector<HTMLInputElement>(inputSel);
  272. inputElem && removeSiParam(inputElem);
  273. });
  274. obs.observe(sharePanelEl, {
  275. childList: true,
  276. subtree: true,
  277. characterData: true,
  278. attributeFilter: ["aria-hidden", "aria-checked", "checked"],
  279. });
  280. },
  281. });
  282. }
  283. //#region fix spacing
  284. /** Applies global CSS to fix various spacings */
  285. export async function fixSpacing() {
  286. if(!await addStyleFromResource("css-fix_spacing"))
  287. error("Couldn't fix spacing");
  288. }
  289. //#region ab.queue btns
  290. export async function initAboveQueueBtns() {
  291. const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures();
  292. const contBtns = [
  293. {
  294. condition: scrollToActiveSongBtn,
  295. id: "scroll-to-active",
  296. resourceName: "icon-skip_to",
  297. titleKey: "scroll_to_playing",
  298. async interaction(evt: KeyboardEvent | MouseEvent) {
  299. const activeItem = document.querySelector<HTMLElement>("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
  300. if(!activeItem)
  301. return;
  302. activeItem.scrollIntoView({
  303. behavior: evt.shiftKey ? "instant" : "smooth",
  304. block: evt.ctrlKey || evt.altKey ? "end" : "center",
  305. inline: "center",
  306. });
  307. },
  308. },
  309. {
  310. condition: clearQueueBtn,
  311. id: "clear-queue",
  312. resourceName: "icon-clear_list",
  313. titleKey: "clear_list",
  314. async interaction(evt: KeyboardEvent | MouseEvent) {
  315. try {
  316. // TODO: better confirmation dialog?
  317. if(evt.shiftKey || confirm(t("clear_list_confirm"))) {
  318. const url = new URL(location.href);
  319. url.searchParams.delete("list");
  320. url.searchParams.set("time_continue", String(await getVideoTime(0)));
  321. location.assign(url);
  322. }
  323. }
  324. catch(err) {
  325. error("Couldn't clear queue due to an error:", err);
  326. }
  327. },
  328. },
  329. ];
  330. if(!contBtns.some(b => Boolean(b.condition)))
  331. return;
  332. addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", {
  333. async listener(rightBtnsEl) {
  334. try {
  335. const aboveQueueBtnCont = document.createElement("div");
  336. aboveQueueBtnCont.id = "bytm-above-queue-btn-cont";
  337. addParent(rightBtnsEl, aboveQueueBtnCont);
  338. const headerEl = rightBtnsEl.closest<HTMLElement>("ytmusic-queue-header-renderer");
  339. if(!headerEl)
  340. return error("Couldn't find queue header element while adding above queue buttons");
  341. siteEvents.on("fullscreenToggled", (isFullscreen) => {
  342. headerEl.classList[isFullscreen ? "add" : "remove"]("hidden");
  343. });
  344. if(!await addStyleFromResource("css-above_queue_btns"))
  345. return error("Couldn't add CSS for above queue buttons");
  346. const wrapperElem = document.createElement("div");
  347. wrapperElem.id = "bytm-above-queue-btn-wrapper";
  348. for(const item of contBtns) {
  349. if(Boolean(item.condition) === false)
  350. continue;
  351. const btnElem = await createCircularBtn({
  352. resourceName: item.resourceName as ResourceKey & `icon-${string}`,
  353. onClick: item.interaction,
  354. title: t(item.titleKey),
  355. });
  356. btnElem.id = `bytm-${item.id}-btn`;
  357. btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn");
  358. wrapperElem.appendChild(btnElem);
  359. }
  360. rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem);
  361. }
  362. catch(err) {
  363. error("Couldn't add above queue buttons due to an error:", err);
  364. }
  365. },
  366. });
  367. }
  368. //#region thumb.overlay
  369. /** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
  370. let invertOverlay = false;
  371. export async function initThumbnailOverlay() {
  372. const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown");
  373. if(getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown)
  374. return;
  375. // so the script init doesn't keep waiting until a /watch page is loaded
  376. waitVideoElementReady().then(() => {
  377. const playerSelector = "ytmusic-player#player";
  378. const playerEl = document.querySelector<HTMLElement>(playerSelector);
  379. if(!playerEl)
  380. return error("Couldn't find video player element while adding thumbnail overlay");
  381. /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
  382. const updateOverlayVisibility = async () => {
  383. if(!domLoaded)
  384. return;
  385. const behavior = getFeature("thumbnailOverlayBehavior");
  386. let showOverlay = behavior === "always";
  387. const isVideo = currentMediaType() === "video";
  388. if(behavior === "videosOnly" && isVideo)
  389. showOverlay = true;
  390. else if(behavior === "songsOnly" && !isVideo)
  391. showOverlay = true;
  392. showOverlay = invertOverlay ? !showOverlay : showOverlay;
  393. const overlayElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay");
  394. const thumbElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-img");
  395. const indicatorElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-indicator");
  396. if(overlayElem)
  397. overlayElem.style.display = showOverlay ? "block" : "none";
  398. if(thumbElem)
  399. thumbElem.ariaHidden = String(!showOverlay);
  400. if(indicatorElem) {
  401. indicatorElem.style.display = showOverlay ? "block" : "none";
  402. indicatorElem.ariaHidden = String(!showOverlay);
  403. }
  404. if(getFeature("thumbnailOverlayToggleBtnShown")) {
  405. addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", {
  406. async listener(toggleBtnElem) {
  407. const toggleBtnImgElem = toggleBtnElem.querySelector<HTMLImageElement>("img");
  408. if(toggleBtnImgElem)
  409. toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}` as "icon-image" | "icon-image_filled");
  410. if(toggleBtnElem)
  411. toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
  412. },
  413. });
  414. }
  415. };
  416. const applyThumbUrl = async (watchId: string) => {
  417. const thumbUrl = await getBestThumbnailUrl(watchId);
  418. if(thumbUrl) {
  419. const toggleBtnElem = document.querySelector<HTMLAnchorElement>("#bytm-thumbnail-overlay-toggle");
  420. const thumbImgElem = document.querySelector<HTMLImageElement>("#bytm-thumbnail-overlay-img");
  421. if(toggleBtnElem)
  422. toggleBtnElem.href = thumbUrl;
  423. if(thumbImgElem)
  424. thumbImgElem.src = thumbUrl;
  425. log("Applied thumbnail URL to overlay:", thumbUrl);
  426. }
  427. else error("Couldn't get thumbnail URL for watch ID", watchId);
  428. };
  429. const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => {
  430. unsubWatchIdChanged();
  431. addSelectorListener("body", "#bytm-thumbnail-overlay", {
  432. listener: () => {
  433. applyThumbUrl(watchId);
  434. updateOverlayVisibility();
  435. },
  436. });
  437. });
  438. const createElements = async () => {
  439. // overlay
  440. const overlayElem = document.createElement("div");
  441. overlayElem.id = "bytm-thumbnail-overlay";
  442. overlayElem.title = ""; // prevent child titles from propagating
  443. overlayElem.classList.add("bytm-no-select");
  444. overlayElem.style.display = "none";
  445. let indicatorElem: HTMLImageElement | undefined;
  446. if(getFeature("thumbnailOverlayShowIndicator")) {
  447. indicatorElem = document.createElement("img");
  448. indicatorElem.id = "bytm-thumbnail-overlay-indicator";
  449. indicatorElem.src = await getResourceUrl("icon-image");
  450. indicatorElem.role = "presentation";
  451. indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip");
  452. indicatorElem.ariaHidden = "true";
  453. indicatorElem.style.display = "none";
  454. indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100);
  455. }
  456. const thumbImgElem = document.createElement("img");
  457. thumbImgElem.id = "bytm-thumbnail-overlay-img";
  458. thumbImgElem.role = "presentation";
  459. thumbImgElem.ariaHidden = "true";
  460. thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit");
  461. overlayElem.appendChild(thumbImgElem);
  462. playerEl.appendChild(overlayElem);
  463. indicatorElem && playerEl.appendChild(indicatorElem);
  464. siteEvents.on("watchIdChanged", async (watchId) => {
  465. invertOverlay = false;
  466. applyThumbUrl(watchId);
  467. updateOverlayVisibility();
  468. });
  469. const params = new URL(location.href).searchParams;
  470. if(params.has("v")) {
  471. applyThumbUrl(params.get("v")!);
  472. updateOverlayVisibility();
  473. }
  474. // toggle button
  475. if(toggleBtnShown) {
  476. const toggleBtnElem = createRipple(document.createElement("a"));
  477. toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
  478. toggleBtnElem.role = "button";
  479. toggleBtnElem.tabIndex = 0;
  480. toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
  481. onInteraction(toggleBtnElem, (e) => {
  482. if(e.shiftKey)
  483. return openInTab(toggleBtnElem.href, false);
  484. invertOverlay = !invertOverlay;
  485. updateOverlayVisibility();
  486. });
  487. const imgElem = document.createElement("img");
  488. imgElem.classList.add("bytm-generic-btn-img");
  489. toggleBtnElem.appendChild(imgElem);
  490. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", {
  491. listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem),
  492. });
  493. }
  494. log("Added thumbnail overlay");
  495. };
  496. addSelectorListener("mainPanel", playerSelector, {
  497. listener(playerEl) {
  498. if(playerEl.getAttribute("player-ui-state") === "INACTIVE") {
  499. const obs = new MutationObserver(() => {
  500. if(playerEl.getAttribute("player-ui-state") === "INACTIVE")
  501. return;
  502. createElements();
  503. obs.disconnect();
  504. });
  505. obs.observe(playerEl, {
  506. attributes: true,
  507. attributeFilter: ["player-ui-state"],
  508. });
  509. }
  510. else
  511. createElements();
  512. },
  513. });
  514. });
  515. }
  516. //#region idle hide cursor
  517. export async function initHideCursorOnIdle() {
  518. addSelectorListener("mainPanel", "ytmusic-player#player", {
  519. listener(vidContainer) {
  520. const overlaySelector = "ytmusic-player #song-media-window";
  521. const overlayElem = document.querySelector<HTMLElement>(overlaySelector);
  522. if(!overlayElem)
  523. return warn("Couldn't find overlay element while initializing cursor hiding");
  524. /** Timer after which the cursor is hidden */
  525. let cursorHideTimer: ReturnType<typeof setTimeout>;
  526. /** Timer for the opacity transition while switching to the hidden state */
  527. let hideTransTimer: ReturnType<typeof setTimeout> | undefined;
  528. const hide = () => {
  529. if(!getFeature("hideCursorOnIdle"))
  530. return;
  531. if(vidContainer.classList.contains("bytm-cursor-hidden"))
  532. return;
  533. overlayElem.style.opacity = ".000001 !important";
  534. hideTransTimer = setTimeout(() => {
  535. overlayElem.style.display = "none";
  536. vidContainer.style.cursor = "none";
  537. vidContainer.classList.add("bytm-cursor-hidden");
  538. hideTransTimer = undefined;
  539. }, 200);
  540. };
  541. const show = () => {
  542. hideTransTimer && clearTimeout(hideTransTimer);
  543. if(!vidContainer.classList.contains("bytm-cursor-hidden"))
  544. return;
  545. vidContainer.classList.remove("bytm-cursor-hidden");
  546. vidContainer.style.cursor = "initial";
  547. overlayElem.style.display = "initial";
  548. overlayElem.style.opacity = "1 !important";
  549. };
  550. const cursorHideTimerCb = () =>
  551. cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000);
  552. const onMove = () => {
  553. cursorHideTimer && clearTimeout(cursorHideTimer);
  554. show();
  555. cursorHideTimerCb();
  556. };
  557. vidContainer.addEventListener("mouseenter", onMove);
  558. vidContainer.addEventListener("mousemove", debounce(onMove, 200, "rising"));
  559. vidContainer.addEventListener("mouseleave", () => {
  560. cursorHideTimer && clearTimeout(cursorHideTimer);
  561. hideTransTimer && clearTimeout(hideTransTimer);
  562. hide();
  563. });
  564. vidContainer.addEventListener("click", () => {
  565. show();
  566. cursorHideTimerCb();
  567. setTimeout(hide, 3000);
  568. });
  569. log("Initialized cursor hiding on idle");
  570. },
  571. });
  572. }
  573. //#region fix HDR
  574. /** Prevents visual issues when using HDR */
  575. export async function fixHdrIssues() {
  576. if(!await addStyleFromResource("css-fix_hdr"))
  577. error("Couldn't load stylesheet to fix HDR issues");
  578. else
  579. log("Fixed HDR issues");
  580. }
  581. //#region show vote nums
  582. /** Shows the amount of likes and dislikes on the current song */
  583. export async function initShowVotes() {
  584. addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", {
  585. async listener(voteCont: HTMLElement): Promise<void> {
  586. try {
  587. const watchId = getWatchId();
  588. if(!watchId) {
  589. await siteEvents.once("watchIdChanged");
  590. return initShowVotes();
  591. }
  592. const voteObj = await fetchVideoVotes(watchId);
  593. if(!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
  594. return error("Couldn't fetch votes from the Return YouTube Dislike API");
  595. if(getFeature("showVotes")) {
  596. addVoteNumbers(voteCont, voteObj);
  597. siteEvents.on("watchIdChanged", async (watchId) => {
  598. const labelLikes = document.querySelector<HTMLElement>("ytmusic-like-button-renderer .bytm-vote-label.likes");
  599. const labelDislikes = document.querySelector<HTMLElement>("ytmusic-like-button-renderer .bytm-vote-label.dislikes");
  600. if(!labelLikes || !labelDislikes)
  601. return error("Couldn't find vote label elements while updating like and dislike counts");
  602. if(labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId)
  603. return log("Vote labels already updated for this video");
  604. const voteObj = await fetchVideoVotes(watchId);
  605. if(!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
  606. return error("Couldn't fetch votes from the Return YouTube Dislike API");
  607. const likesLabelText = tp("vote_label_likes", voteObj.likes, formatVoteNumber(voteObj.likes, "long"));
  608. const dislikesLabelText = tp("vote_label_dislikes", voteObj.dislikes, formatVoteNumber(voteObj.dislikes, "long"));
  609. labelLikes.dataset.watchId = getWatchId() ?? "";
  610. labelLikes.textContent = formatVoteNumber(voteObj.likes);
  611. labelLikes.title = labelLikes.ariaLabel = likesLabelText;
  612. labelDislikes.textContent = formatVoteNumber(voteObj.dislikes);
  613. labelDislikes.title = labelDislikes.ariaLabel = dislikesLabelText;
  614. labelDislikes.dataset.watchId = getWatchId() ?? "";
  615. addSelectorListener("playerBar", "ytmusic-like-button-renderer#like-button-renderer", {
  616. listener: (bar) => upsertVoteBtnLabels(bar, likesLabelText, dislikesLabelText),
  617. });
  618. });
  619. }
  620. }
  621. catch(err) {
  622. error("Couldn't initialize show votes feature due to an error:", err);
  623. }
  624. }
  625. });
  626. }
  627. function addVoteNumbers(voteCont: HTMLElement, voteObj: VideoVotesObj) {
  628. const likeBtn = voteCont.querySelector<HTMLElement>("#button-shape-like");
  629. const dislikeBtn = voteCont.querySelector<HTMLElement>("#button-shape-dislike");
  630. if(!likeBtn || !dislikeBtn)
  631. return error("Couldn't find like or dislike button while adding vote numbers");
  632. const createLabel = (amount: number, type: "likes" | "dislikes"): HTMLElement => {
  633. const label = document.createElement("span");
  634. label.classList.add("bytm-vote-label", "bytm-no-select", type);
  635. label.textContent = String(formatVoteNumber(amount));
  636. label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatVoteNumber(amount, "long"));
  637. label.dataset.watchId = getWatchId() ?? "";
  638. label.addEventListener("click", (e) => {
  639. e.preventDefault();
  640. e.stopPropagation();
  641. (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")?.click();
  642. });
  643. return label;
  644. };
  645. addStyleFromResource("css-show_votes")
  646. .catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e));
  647. const likeLblEl = createLabel(voteObj.likes, "likes");
  648. likeBtn.insertAdjacentElement("afterend", likeLblEl);
  649. const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes");
  650. dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl);
  651. upsertVoteBtnLabels(voteCont, likeLblEl.title, dislikeLblEl.title);
  652. log("Added vote number labels to like and dislike buttons");
  653. }
  654. /** Formats a number formatted based on the config or the passed {@linkcode notation} */
  655. function formatVoteNumber(num: number, notation?: NumberNotation) {
  656. return num.toLocaleString(
  657. getLocale().replace(/_/g, "-"),
  658. (notation ?? getFeature("showVotesFormat")) === "short"
  659. ? {
  660. notation: "compact",
  661. compactDisplay: "short",
  662. maximumFractionDigits: 1,
  663. }
  664. : {
  665. style: "decimal",
  666. maximumFractionDigits: 0,
  667. },
  668. );
  669. }
  670. /** Updates or inserts the labels on the native like and dislike buttons */
  671. function upsertVoteBtnLabels(parentEl: HTMLElement, likesLabelText: string, dislikesLabelText: string) {
  672. const likeBtn = parentEl.querySelector<HTMLElement>("#button-shape-like button");
  673. const dislikeBtn = parentEl.querySelector<HTMLElement>("#button-shape-dislike button");
  674. if(likeBtn)
  675. likeBtn.title = likeBtn.ariaLabel = likesLabelText;
  676. if(dislikeBtn)
  677. dislikeBtn.title = dislikeBtn.ariaLabel = dislikesLabelText;
  678. };