layout.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. import type { Event } from "@billjs/event-emitter";
  2. import { addGlobalStyle, addParent, autoPlural, fetchAdvanced, insertAfter, onSelector, openInNewTab, pauseFor } from "@sv443-network/userutils";
  3. import type { FeatureConfig } from "../types";
  4. import { scriptInfo } from "../constants";
  5. import { error, getResourceUrl, log } from "../utils";
  6. import { getEvtData, siteEvents } from "../events";
  7. import { openMenu } from "./menu/menu_old";
  8. import { getGeniusUrl, createLyricsBtn, sanitizeArtists, sanitizeSong, getLyricsCacheEntry, splitVideoTitle } from "./lyrics";
  9. import "./layout.css";
  10. import { featInfo } from ".";
  11. let features: FeatureConfig;
  12. export function preInitLayout(feats: FeatureConfig) {
  13. features = feats;
  14. }
  15. //#MARKER BYTM-Config buttons
  16. let menuOpenAmt = 0, logoExchanged = false;
  17. /** Adds a watermark beneath the logo */
  18. export function addWatermark() {
  19. const watermark = document.createElement("a");
  20. watermark.role = "button";
  21. watermark.id = "bytm-watermark";
  22. watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
  23. watermark.innerText = scriptInfo.name;
  24. watermark.title = "Open menu";
  25. watermark.tabIndex = 1000;
  26. improveLogo();
  27. watermark.addEventListener("click", (e) => {
  28. e.stopPropagation();
  29. menuOpenAmt++;
  30. if((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
  31. openMenu();
  32. if((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
  33. exchangeLogo();
  34. });
  35. // when using the tab key to navigate
  36. watermark.addEventListener("keydown", (e) => {
  37. if(e.key === "Enter") {
  38. e.stopPropagation();
  39. menuOpenAmt++;
  40. if((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
  41. openMenu();
  42. if((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
  43. exchangeLogo();
  44. }
  45. });
  46. const logoElem = document.querySelector("#left-content") as HTMLElement;
  47. insertAfter(logoElem, watermark);
  48. log("Added watermark element");
  49. }
  50. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  51. async function improveLogo() {
  52. try {
  53. const res = await fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  54. const svg = await res.text();
  55. onSelector("ytmusic-logo a", {
  56. listener: (logoElem) => {
  57. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  58. logoElem.innerHTML = svg;
  59. logoElem.querySelectorAll("ellipse").forEach((e) => {
  60. e.classList.add("bytm-mod-logo-ellipse");
  61. });
  62. logoElem.querySelector("path")?.classList.add("bytm-mod-logo-path");
  63. log("Swapped logo to inline SVG");
  64. },
  65. });
  66. }
  67. catch(err) {
  68. error("Couldn't improve logo due to an error:", err);
  69. }
  70. }
  71. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  72. function exchangeLogo() {
  73. onSelector(".bytm-mod-logo", {
  74. listener: async (logoElem) => {
  75. if(logoElem.classList.contains("bytm-logo-exchanged"))
  76. return;
  77. logoExchanged = true;
  78. logoElem.classList.add("bytm-logo-exchanged");
  79. const iconUrl = await getResourceUrl("icon");
  80. const newLogo = document.createElement("img");
  81. newLogo.className = "bytm-mod-logo-img";
  82. newLogo.src = iconUrl;
  83. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  84. document.head.querySelectorAll<HTMLLinkElement>("link[rel=\"icon\"]").forEach((e) => {
  85. e.href = iconUrl;
  86. });
  87. setTimeout(() => {
  88. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  89. }, 1000);
  90. },
  91. });
  92. }
  93. /** Called whenever the menu exists to add a BYTM-Configuration button */
  94. export async function addConfigMenuOption(container: HTMLElement) {
  95. const cfgOptElem = document.createElement("div");
  96. cfgOptElem.role = "button";
  97. cfgOptElem.className = "bytm-cfg-menu-option";
  98. const cfgOptItemElem = document.createElement("div");
  99. cfgOptItemElem.className = "bytm-cfg-menu-option-item";
  100. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = "Click to open BetterYTM's configuration menu";
  101. cfgOptItemElem.addEventListener("click", (e) => {
  102. const settingsBtnElem = document.querySelector<HTMLElement>("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  103. settingsBtnElem?.click();
  104. menuOpenAmt++;
  105. if((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
  106. openMenu();
  107. if((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
  108. exchangeLogo();
  109. });
  110. const cfgOptIconElem = document.createElement("img");
  111. cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
  112. cfgOptIconElem.src = await getResourceUrl("icon");
  113. const cfgOptTextElem = document.createElement("div");
  114. cfgOptTextElem.className = "bytm-cfg-menu-option-text";
  115. cfgOptTextElem.innerText = "BetterYTM Configuration";
  116. cfgOptItemElem.appendChild(cfgOptIconElem);
  117. cfgOptItemElem.appendChild(cfgOptTextElem);
  118. cfgOptElem.appendChild(cfgOptItemElem);
  119. container.appendChild(cfgOptElem);
  120. log("Added BYTM-Configuration button to menu popover");
  121. }
  122. //#MARKER remove upgrade tab
  123. /** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
  124. export function removeUpgradeTab() {
  125. onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-child(4)", {
  126. listener: (tabElemLarge) => {
  127. tabElemLarge.remove();
  128. log("Removed large upgrade tab");
  129. },
  130. });
  131. onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-child(4)", {
  132. listener: (tabElemSmall) => {
  133. tabElemSmall.remove();
  134. log("Removed small upgrade tab");
  135. },
  136. });
  137. }
  138. //#MARKER volume slider
  139. export function initVolumeFeatures() {
  140. // not technically an input element but behaves pretty much the same
  141. onSelector<HTMLInputElement>("tp-yt-paper-slider#volume-slider", {
  142. listener: (sliderElem) => {
  143. const volSliderCont = document.createElement("div");
  144. volSliderCont.id = "bytm-vol-slider-cont";
  145. addParent(sliderElem, volSliderCont);
  146. if(typeof features.volumeSliderSize === "number")
  147. setVolSliderSize();
  148. if(features.volumeSliderLabel)
  149. addVolumeSliderLabel(sliderElem, volSliderCont);
  150. setVolSliderStep(sliderElem);
  151. },
  152. });
  153. }
  154. /** Adds a percentage label to the volume slider and tooltip */
  155. function addVolumeSliderLabel(sliderElem: HTMLInputElement, sliderCont: HTMLDivElement) {
  156. const labelElem = document.createElement("div");
  157. labelElem.className = "bytm-vol-slider-label";
  158. labelElem.innerText = `${sliderElem.value}%`;
  159. // prevent video from minimizing
  160. labelElem.addEventListener("click", (e) => e.stopPropagation());
  161. const getLabelTexts = (slider: HTMLInputElement) => {
  162. const labelShort = `${slider.value}%`;
  163. const sensText = features.volumeSliderStep !== featInfo.volumeSliderStep.default ? ` (Sensitivity: ${slider.step}%)` : "";
  164. const labelFull = `Volume: ${labelShort}${sensText}`;
  165. return { labelShort, labelFull };
  166. };
  167. const { labelFull } = getLabelTexts(sliderElem);
  168. sliderCont.setAttribute("title", labelFull);
  169. sliderElem.setAttribute("title", labelFull);
  170. sliderElem.setAttribute("aria-valuetext", labelFull);
  171. const updateLabel = () => {
  172. const { labelShort, labelFull } = getLabelTexts(sliderElem);
  173. sliderCont.setAttribute("title", labelFull);
  174. sliderElem.setAttribute("title", labelFull);
  175. sliderElem.setAttribute("aria-valuetext", labelFull);
  176. const labelElem2 = document.querySelector<HTMLDivElement>(".bytm-vol-slider-label");
  177. if(labelElem2)
  178. labelElem2.innerText = labelShort;
  179. };
  180. sliderElem.addEventListener("change", () => updateLabel());
  181. onSelector("#bytm-vol-slider-cont", {
  182. listener: (volumeCont) => {
  183. volumeCont.appendChild(labelElem);
  184. },
  185. });
  186. let lastSliderVal = Number(sliderElem.value);
  187. // show label if hovering over slider or slider is focused
  188. const sliderHoverObserver = new MutationObserver(() => {
  189. if(sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
  190. labelElem.classList.add("bytm-visible");
  191. else if(labelElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
  192. labelElem.classList.remove("bytm-visible");
  193. if(Number(sliderElem.value) !== lastSliderVal) {
  194. lastSliderVal = Number(sliderElem.value);
  195. updateLabel();
  196. }
  197. });
  198. sliderHoverObserver.observe(sliderElem, {
  199. attributes: true,
  200. });
  201. }
  202. /** Sets the volume slider to a set size */
  203. function setVolSliderSize() {
  204. const { volumeSliderSize: size } = features;
  205. if(typeof size !== "number" || isNaN(Number(size)))
  206. return;
  207. addGlobalStyle(`\
  208. /* BetterYTM - set volume slider size */
  209. #bytm-vol-slider-cont tp-yt-paper-slider#volume-slider {
  210. width: ${size}px !important;
  211. }`);
  212. }
  213. /** Sets the `step` attribute of the volume slider */
  214. function setVolSliderStep(sliderElem: HTMLInputElement) {
  215. sliderElem.setAttribute("step", String(features.volumeSliderStep));
  216. }
  217. //#MARKER queue buttons
  218. export function initQueueButtons() {
  219. const addQueueBtns = (evt: Event) => {
  220. let amt = 0;
  221. for(const queueItm of getEvtData<HTMLElement>(evt).childNodes as NodeListOf<HTMLElement>) {
  222. if(!queueItm.classList.contains("bytm-has-queue-btns")) {
  223. addQueueButtons(queueItm);
  224. amt++;
  225. }
  226. }
  227. if(amt > 0)
  228. log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
  229. };
  230. siteEvents.on("queueChanged", addQueueBtns);
  231. siteEvents.on("autoplayQueueChanged", addQueueBtns);
  232. const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
  233. if(queueItems.length === 0)
  234. return;
  235. queueItems.forEach(itm => addQueueButtons(itm as HTMLElement));
  236. log(`Added buttons to ${queueItems.length} existing queue ${autoPlural("item", queueItems)}`);
  237. }
  238. /**
  239. * Adds the buttons to each item in the current song queue.
  240. * Also observes for changes to add new buttons to new items in the queue.
  241. * TODO:FIXME: deleting an element from the queue shifts the lyrics buttons
  242. * @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
  243. */
  244. async function addQueueButtons(queueItem: HTMLElement) {
  245. //#SECTION general queue item stuff
  246. const queueBtnsCont = document.createElement("div");
  247. queueBtnsCont.className = "bytm-queue-btn-container";
  248. const songInfo = queueItem.querySelector(".song-info") as HTMLElement;
  249. if(!songInfo)
  250. return false;
  251. const [songEl, artistEl] = (songInfo.querySelectorAll("yt-formatted-string") as NodeListOf<HTMLElement>);
  252. const song = songEl.innerText;
  253. const artist = artistEl.innerText;
  254. if(!song || !artist)
  255. return false;
  256. const lyricsIconUrl = await getResourceUrl("lyrics");
  257. const deleteIconUrl = await getResourceUrl("delete");
  258. //#SECTION lyrics btn
  259. const lyricsBtnElem = await createLyricsBtn(undefined, false);
  260. {
  261. lyricsBtnElem.title = "Open this song's lyrics in a new tab";
  262. lyricsBtnElem.style.display = "inline-flex";
  263. lyricsBtnElem.style.visibility = "initial";
  264. lyricsBtnElem.style.pointerEvents = "initial";
  265. lyricsBtnElem.addEventListener("click", async (e) => {
  266. e.stopPropagation();
  267. let lyricsUrl: string | undefined;
  268. const artistsSan = sanitizeArtists(artist);
  269. const songSan = sanitizeSong(song);
  270. const splitTitle = splitVideoTitle(songSan);
  271. const cachedLyricsUrl = songSan.includes("-")
  272. ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
  273. : getLyricsCacheEntry(artistsSan, songSan);
  274. if(cachedLyricsUrl)
  275. lyricsUrl = cachedLyricsUrl;
  276. else if(!songInfo.hasAttribute("data-bytm-loading")) {
  277. const imgEl = lyricsBtnElem.querySelector("img") as HTMLImageElement;
  278. if(!cachedLyricsUrl) {
  279. songInfo.setAttribute("data-bytm-loading", "");
  280. imgEl.src = await getResourceUrl("spinner");
  281. imgEl.classList.add("bytm-spinner");
  282. }
  283. lyricsUrl = cachedLyricsUrl ?? await getGeniusUrl(artistsSan, songSan);
  284. const resetImgElem = () => {
  285. imgEl.src = lyricsIconUrl;
  286. imgEl.classList.remove("bytm-spinner");
  287. };
  288. if(!cachedLyricsUrl) {
  289. songInfo.removeAttribute("data-bytm-loading");
  290. // so the new image doesn't "blink"
  291. setTimeout(resetImgElem, 100);
  292. }
  293. if(!lyricsUrl) {
  294. resetImgElem();
  295. if(confirm("Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?"))
  296. openInNewTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} ${songSan}`)}`);
  297. return;
  298. }
  299. }
  300. lyricsUrl && openInNewTab(lyricsUrl);
  301. });
  302. }
  303. //#SECTION delete from queue btn
  304. const deleteBtnElem = document.createElement("a");
  305. {
  306. Object.assign(deleteBtnElem, {
  307. title: "Remove this song from the queue",
  308. className: "ytmusic-player-bar bytm-delete-from-queue bytm-generic-btn",
  309. role: "button",
  310. });
  311. deleteBtnElem.style.visibility = "initial";
  312. deleteBtnElem.addEventListener("click", async (e) => {
  313. e.stopPropagation();
  314. // container of the queue item popup menu - element gets reused for every queue item
  315. let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown") as HTMLElement;
  316. try {
  317. // three dots button to open the popup menu of a queue item
  318. const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape button") as HTMLButtonElement;
  319. if(queuePopupCont)
  320. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  321. dotsBtnElem.click();
  322. await pauseFor(25);
  323. queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown") as HTMLElement;
  324. if(!queuePopupCont.hasAttribute("data-bytm-hidden"))
  325. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  326. // a little bit janky and unreliable but the only way afaik
  327. const removeFromQueueBtn = queuePopupCont.querySelector("tp-yt-paper-listbox *[role=option]:nth-child(7)") as HTMLElement;
  328. await pauseFor(20);
  329. removeFromQueueBtn.click();
  330. }
  331. catch(err) {
  332. error("Couldn't remove song from queue due to error:", err);
  333. }
  334. finally {
  335. queuePopupCont?.removeAttribute("data-bytm-hidden");
  336. }
  337. });
  338. const imgElem = document.createElement("img");
  339. imgElem.className = "bytm-generic-btn-img";
  340. imgElem.src = deleteIconUrl;
  341. deleteBtnElem.appendChild(imgElem);
  342. }
  343. //#SECTION append elements to DOM
  344. queueBtnsCont.appendChild(lyricsBtnElem);
  345. queueBtnsCont.appendChild(deleteBtnElem);
  346. songInfo.appendChild(queueBtnsCont);
  347. queueItem.classList.add("bytm-has-queue-btns");
  348. return true;
  349. }
  350. //#MARKER better clickable stuff
  351. // TODO: add to thumbnails in "songs" list on channel pages (/channel/$id)
  352. // TODO: add to thumbnails in playlists (/playlist?list=$id)
  353. // TODO:FIXME: only works for the first 7 items of each carousel shelf -> probably needs own mutation observer
  354. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  355. export function addAnchorImprovements() {
  356. //#SECTION carousel shelves
  357. try {
  358. // home page
  359. /** Only adds anchor improvements for carousel shelves that contain the regular list-item-renderer, not the two-row-item-renderer */
  360. const condCarouselImprovements = (el: HTMLElement) => {
  361. const listItemRenderer = el.querySelector("ytmusic-responsive-list-item-renderer");
  362. if(listItemRenderer) {
  363. const itemsElem = el.querySelector<HTMLElement>("ul#items");
  364. if(itemsElem) {
  365. const improvedElems = improveCarouselAnchors(itemsElem);
  366. improvedElems > 0 && log(`Added anchor improvements to ${improvedElems} carousel shelf ${autoPlural("item", improvedElems)}`);
  367. }
  368. }
  369. };
  370. // initial three shelves aren't included in the event fire
  371. onSelector("ytmusic-carousel-shelf-renderer", {
  372. listener: () => {
  373. const carouselShelves = document.body.querySelectorAll<HTMLElement>("ytmusic-carousel-shelf-renderer");
  374. carouselShelves.forEach(condCarouselImprovements);
  375. },
  376. });
  377. // every shelf that's loaded by scrolling:
  378. siteEvents.on("carouselShelvesChanged", (evt) => {
  379. const { addedNodes, removedNodes } = getEvtData<Record<"addedNodes" | "removedNodes", NodeListOf<HTMLElement>>>(evt);
  380. void removedNodes;
  381. if(addedNodes.length > 0)
  382. addedNodes.forEach(condCarouselImprovements);
  383. });
  384. // related tab in /watch
  385. // TODO: items are lazy-loaded so this needs to be done differently
  386. // maybe the onSelectorExists feature can be expanded to conditionally support continuous checking & querySelectorAll
  387. const relatedTabAnchorImprovements = (tabElem: HTMLElement) => {
  388. const relatedCarouselShelves = tabElem?.querySelectorAll<HTMLElement>("ytmusic-carousel-shelf-renderer");
  389. if(relatedCarouselShelves)
  390. relatedCarouselShelves.forEach(condCarouselImprovements);
  391. };
  392. const relatedTabContentsSelector = "ytmusic-section-list-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] #contents";
  393. onSelector("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"]", {
  394. listener: (relatedTabContainer) => {
  395. const relatedTabObserver = new MutationObserver(([ { addedNodes, removedNodes } ]) => {
  396. if(addedNodes.length > 0 || removedNodes.length > 0)
  397. relatedTabAnchorImprovements(document.querySelector<HTMLElement>(relatedTabContentsSelector)!);
  398. });
  399. relatedTabObserver.observe(relatedTabContainer, {
  400. childList: true,
  401. });
  402. },
  403. });
  404. onSelector(relatedTabContentsSelector, {
  405. listener: (relatedTabContents) => {
  406. relatedTabAnchorImprovements(relatedTabContents);
  407. },
  408. });
  409. }
  410. catch(err) {
  411. error("Couldn't improve carousel shelf anchors due to an error:", err);
  412. }
  413. //#SECTION sidebar
  414. try {
  415. const addSidebarAnchors = (sidebarCont: HTMLElement) => {
  416. const items = sidebarCont.parentNode!.querySelectorAll<HTMLElement>("ytmusic-guide-entry-renderer tp-yt-paper-item");
  417. improveSidebarAnchors(items);
  418. return items.length;
  419. };
  420. onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  421. listener: (sidebarCont) => {
  422. const itemsAmt = addSidebarAnchors(sidebarCont);
  423. log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
  424. },
  425. });
  426. onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  427. listener: (miniSidebarCont) => {
  428. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  429. log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
  430. },
  431. });
  432. }
  433. catch(err) {
  434. error("Couldn't add anchors to sidebar items due to an error:", err);
  435. }
  436. }
  437. const sidebarPaths = [
  438. "/",
  439. "/explore",
  440. "/library",
  441. ];
  442. /**
  443. * Adds anchors to the sidebar items so they can be opened in a new tab
  444. * @param sidebarItem
  445. */
  446. function improveSidebarAnchors(sidebarItems: NodeListOf<HTMLElement>) {
  447. sidebarItems.forEach((item, i) => {
  448. const anchorElem = document.createElement("a");
  449. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  450. anchorElem.role = "button";
  451. anchorElem.target = "_self";
  452. anchorElem.href = sidebarPaths[i] ?? "#";
  453. anchorElem.title = "Middle click to open in a new tab";
  454. anchorElem.addEventListener("click", (e) => {
  455. e.preventDefault();
  456. });
  457. addParent(item, anchorElem);
  458. });
  459. }
  460. /**
  461. * Actually adds the anchor improvements to carousel shelf items
  462. * @param itemsElement The container with the selector `ul#items` inside of each `ytmusic-carousel`
  463. */
  464. function improveCarouselAnchors(itemsElement: HTMLElement) {
  465. if(itemsElement.classList.contains("bytm-anchors-improved"))
  466. return 0;
  467. let improvedElems = 0;
  468. try {
  469. const allListItems = itemsElement.querySelectorAll<HTMLElement>("ytmusic-responsive-list-item-renderer");
  470. for(const listItem of allListItems) {
  471. const thumbnailElem = listItem.querySelector<HTMLElement>(".left-items");
  472. const titleElem = listItem.querySelector<HTMLAnchorElement>(".title-column yt-formatted-string.title a");
  473. if(!thumbnailElem || !titleElem) {
  474. error("Couldn't add carousel shelf anchor improvements because either the thumbnail or title element couldn't be found");
  475. continue;
  476. }
  477. const thumbnailAnchor = document.createElement("a");
  478. thumbnailAnchor.className = "bytm-carousel-shelf-anchor bytm-anchor";
  479. thumbnailAnchor.href = titleElem.href;
  480. thumbnailAnchor.target = "_self";
  481. thumbnailAnchor.role = "button";
  482. thumbnailAnchor.addEventListener("click", (e) => {
  483. e.preventDefault();
  484. });
  485. addParent(thumbnailElem, thumbnailAnchor);
  486. improvedElems++;
  487. }
  488. }
  489. catch(err) {
  490. error("Couldn't add anchor improvements due to error:", err);
  491. }
  492. finally {
  493. itemsElement.classList.add("bytm-anchors-improved");
  494. }
  495. return improvedElems;
  496. }
  497. //#MARKER auto close toasts
  498. /** Closes toasts after a set amount of time */
  499. export function initAutoCloseToasts() {
  500. try {
  501. const animTimeout = 300;
  502. const closeTimeout = Math.max(features.closeToastsTimeout * 1000 + animTimeout, animTimeout);
  503. onSelector("tp-yt-paper-toast#toast", {
  504. all: true,
  505. continuous: true,
  506. listener: async (toastElems) => {
  507. for(const toastElem of toastElems) {
  508. if(!toastElem.hasAttribute("allow-click-through"))
  509. continue;
  510. if(toastElem.classList.contains("bytm-closing"))
  511. continue;
  512. toastElem.classList.add("bytm-closing");
  513. await pauseFor(closeTimeout);
  514. toastElem.classList.remove("paper-toast-open");
  515. log(`Automatically closed toast '${toastElem.querySelector<HTMLDivElement>("#text-container yt-formatted-string")?.innerText}' after ${features.closeToastsTimeout * 1000}ms`);
  516. // wait for the transition to finish
  517. await pauseFor(animTimeout);
  518. toastElem.style.display = "none";
  519. }
  520. },
  521. });
  522. log("Initialized automatic toast closing");
  523. }
  524. catch(err) {
  525. error("Error in automatic toast closing:", err);
  526. }
  527. }