layout.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import type { Event } from "@billjs/event-emitter";
  2. import type { FeatureConfig } from "../types";
  3. import { scriptInfo } from "../constants";
  4. import { getFeatures } from "../config";
  5. import { addGlobalStyle, addParent, autoPlural, error, fetchAdvanced, getAssetUrl, insertAfter, log, onSelectorExists, openInNewTab, pauseFor } from "../utils";
  6. import { getEvtData, siteEvents } from "../events";
  7. import { openMenu } from "./menu/menu_old";
  8. import { getGeniusUrl, createLyricsBtn, sanitizeArtists, sanitizeSong, getLyricsCacheEntry } from "./lyrics";
  9. import "./layout.css";
  10. let features: FeatureConfig;
  11. export async function preInitLayout() {
  12. features = await getFeatures();
  13. }
  14. //#MARKER BYTM-Config buttons
  15. let clicksAmt = 0, logoExchanged = false;
  16. /** Adds a watermark beneath the logo */
  17. export function addWatermark() {
  18. const watermark = document.createElement("a");
  19. watermark.role = "button";
  20. watermark.id = "bytm-watermark";
  21. watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
  22. watermark.innerText = scriptInfo.name;
  23. watermark.title = "Open menu";
  24. watermark.tabIndex = 1000;
  25. improveLogo();
  26. watermark.addEventListener("click", (e) => {
  27. e.stopPropagation();
  28. clicksAmt++;
  29. if((!e.shiftKey || logoExchanged) && clicksAmt !== 5)
  30. openMenu();
  31. if((!logoExchanged && e.shiftKey) || clicksAmt === 5)
  32. exchangeLogo();
  33. });
  34. // when using the tab key to navigate
  35. watermark.addEventListener("keydown", (e) => {
  36. if(e.key === "Enter") {
  37. e.stopPropagation();
  38. openMenu();
  39. }
  40. });
  41. const logoElem = document.querySelector("#left-content") as HTMLElement;
  42. insertAfter(logoElem, watermark);
  43. log("Added watermark element", watermark);
  44. }
  45. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  46. async function improveLogo() {
  47. try {
  48. const res = await fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  49. const svg = await res.text();
  50. onSelectorExists("ytmusic-logo a", (logoElem) => {
  51. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  52. logoElem.innerHTML = svg;
  53. logoElem.querySelectorAll("ellipse").forEach((e) => {
  54. e.classList.add("bytm-mod-logo-ellipse");
  55. });
  56. logoElem.querySelector("path")?.classList.add("bytm-mod-logo-path");
  57. log("Swapped logo to inline SVG");
  58. });
  59. }
  60. catch(err) {
  61. error("Couldn't improve logo due to an error:", err);
  62. }
  63. }
  64. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  65. function exchangeLogo() {
  66. onSelectorExists(".bytm-mod-logo", (logoElem) => {
  67. if(logoElem.classList.contains("bytm-logo-exchanged"))
  68. return;
  69. logoExchanged = true;
  70. logoElem.classList.add("bytm-logo-exchanged");
  71. const newLogo = document.createElement("img");
  72. newLogo.className = "bytm-mod-logo-img";
  73. newLogo.src = getAssetUrl("icon/icon.png");
  74. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  75. document.head.querySelectorAll<HTMLLinkElement>("link[rel=\"icon\"]").forEach(e => {
  76. e.href = getAssetUrl("icon/icon.png");
  77. });
  78. setTimeout(() => {
  79. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  80. }, 1000);
  81. });
  82. }
  83. /** Called whenever the menu exists to add a BYTM-Configuration button */
  84. export function addConfigMenuOption(container: HTMLElement) {
  85. const cfgOptElem = document.createElement("a");
  86. cfgOptElem.role = "button";
  87. cfgOptElem.className = "bytm-cfg-menu-option bytm-anchor";
  88. cfgOptElem.ariaLabel = "Click to open BetterYTM's configuration menu";
  89. const cfgOptItemElem = document.createElement("div");
  90. cfgOptItemElem.className = "bytm-cfg-menu-option-item";
  91. cfgOptItemElem.addEventListener("click", () => {
  92. const settingsBtnElem = document.querySelector<HTMLElement>("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  93. settingsBtnElem?.click();
  94. openMenu();
  95. });
  96. const cfgOptIconElem = document.createElement("img");
  97. cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
  98. cfgOptIconElem.src = getAssetUrl("icon/icon.png");
  99. const cfgOptTextElem = document.createElement("div");
  100. cfgOptTextElem.className = "bytm-cfg-menu-option-text";
  101. cfgOptTextElem.innerText = "BetterYTM Configuration";
  102. cfgOptItemElem.appendChild(cfgOptIconElem);
  103. cfgOptItemElem.appendChild(cfgOptTextElem);
  104. cfgOptElem.appendChild(cfgOptItemElem);
  105. container.appendChild(cfgOptElem);
  106. log("Added BYTM-Configuration button to menu popover", cfgOptElem);
  107. }
  108. //#MARKER remove upgrade tab
  109. /** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
  110. export function removeUpgradeTab() {
  111. onSelectorExists("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-child(4)", (tabElemLarge) => {
  112. tabElemLarge.remove();
  113. log("Removed large upgrade tab");
  114. });
  115. onSelectorExists("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer:nth-child(4)", (tabElemSmall) => {
  116. tabElemSmall.remove();
  117. log("Removed small upgrade tab");
  118. });
  119. }
  120. //#MARKER volume slider
  121. /** Sets the volume slider to a set size */
  122. export function setVolSliderSize() {
  123. const { volumeSliderSize: size } = features;
  124. if(typeof size !== "number" || isNaN(Number(size)))
  125. return;
  126. const style = `\
  127. .volume-slider.ytmusic-player-bar, .expand-volume-slider.ytmusic-player-bar {
  128. width: ${size}px !important;
  129. }`;
  130. addGlobalStyle(style, "vol-slider");
  131. }
  132. /** Sets the `step` attribute of the volume slider */
  133. export function setVolSliderStep() {
  134. const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider") as HTMLInputElement;
  135. sliderElem.setAttribute("step", String(features.volumeSliderStep));
  136. }
  137. //#MARKER queue buttons
  138. export function initQueueButtons() {
  139. const addQueueBtns = (evt: Event) => {
  140. let amt = 0;
  141. for(const queueItm of getEvtData<HTMLElement>(evt).childNodes as NodeListOf<HTMLElement>) {
  142. if(!queueItm.classList.contains("bytm-has-queue-btns")) {
  143. addQueueButtons(queueItm);
  144. amt++;
  145. }
  146. }
  147. if(amt > 0)
  148. log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
  149. };
  150. siteEvents.on("queueChanged", addQueueBtns);
  151. siteEvents.on("autoplayQueueChanged", addQueueBtns);
  152. const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
  153. if(queueItems.length === 0)
  154. return;
  155. queueItems.forEach(itm => addQueueButtons(itm as HTMLElement));
  156. log(`Added buttons to ${queueItems.length} existing queue ${autoPlural("item", queueItems)}`);
  157. }
  158. /**
  159. * Adds the buttons to each item in the current song queue.
  160. * Also observes for changes to add new buttons to new items in the queue.
  161. * TODO:FIXME: deleting an element from the queue shifts the lyrics buttons
  162. * @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
  163. */
  164. async function addQueueButtons(queueItem: HTMLElement) {
  165. //#SECTION general queue item stuff
  166. const queueBtnsCont = document.createElement("div");
  167. queueBtnsCont.className = "bytm-queue-btn-container";
  168. const songInfo = queueItem.querySelector(".song-info") as HTMLElement;
  169. if(!songInfo)
  170. return false;
  171. const [songEl, artistEl] = (songInfo.querySelectorAll("yt-formatted-string") as NodeListOf<HTMLElement>);
  172. const song = songEl.innerText;
  173. const artist = artistEl.innerText;
  174. if(!song || !artist)
  175. return false;
  176. //#SECTION lyrics btn
  177. const lyricsBtnElem = createLyricsBtn(undefined, false);
  178. {
  179. lyricsBtnElem.title = "Open this song's lyrics in a new tab";
  180. lyricsBtnElem.style.display = "inline-flex";
  181. lyricsBtnElem.style.visibility = "initial";
  182. lyricsBtnElem.style.pointerEvents = "initial";
  183. lyricsBtnElem.addEventListener("click", async (e) => {
  184. e.stopPropagation();
  185. let lyricsUrl: string | undefined;
  186. const artistsSan = sanitizeArtists(artist);
  187. const songSan = sanitizeSong(song);
  188. const cachedLyricsUrl = getLyricsCacheEntry(artistsSan, songSan);
  189. if(cachedLyricsUrl)
  190. lyricsUrl = cachedLyricsUrl;
  191. else if(!songInfo.hasAttribute("data-bytm-loading")) {
  192. const imgEl = lyricsBtnElem.querySelector("img") as HTMLImageElement;
  193. if(!cachedLyricsUrl) {
  194. songInfo.setAttribute("data-bytm-loading", "");
  195. imgEl.src = getAssetUrl("spinner.svg");
  196. imgEl.classList.add("bytm-spinner");
  197. }
  198. lyricsUrl = cachedLyricsUrl ?? await getGeniusUrl(artistsSan, songSan);
  199. const resetImgElem = () => {
  200. imgEl.src = getAssetUrl("lyrics.svg");
  201. imgEl.classList.remove("bytm-spinner");
  202. };
  203. if(!cachedLyricsUrl) {
  204. songInfo.removeAttribute("data-bytm-loading");
  205. // so the new image doesn't "blink"
  206. setTimeout(resetImgElem, 100);
  207. }
  208. if(!lyricsUrl) {
  209. resetImgElem();
  210. if(confirm("Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?"))
  211. openInNewTab("https://genius.com/search");
  212. return;
  213. }
  214. }
  215. lyricsUrl && openInNewTab(lyricsUrl);
  216. });
  217. }
  218. //#SECTION delete from queue btn
  219. const deleteBtnElem = document.createElement("a");
  220. {
  221. Object.assign(deleteBtnElem, {
  222. title: "Remove this song from the queue",
  223. className: "ytmusic-player-bar bytm-delete-from-queue bytm-generic-btn",
  224. role: "button",
  225. });
  226. deleteBtnElem.style.visibility = "initial";
  227. deleteBtnElem.addEventListener("click", async (e) => {
  228. e.stopPropagation();
  229. // container of the queue item popup menu - element gets reused for every queue item
  230. let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown") as HTMLElement;
  231. try {
  232. // three dots button to open the popup menu of a queue item
  233. const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape button") as HTMLButtonElement;
  234. if(queuePopupCont)
  235. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  236. dotsBtnElem.click();
  237. await pauseFor(25);
  238. queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown") as HTMLElement;
  239. if(!queuePopupCont.hasAttribute("data-bytm-hidden"))
  240. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  241. // a little bit janky and unreliable but the only way afaik
  242. const removeFromQueueBtn = queuePopupCont.querySelector("tp-yt-paper-listbox *[role=option]:nth-child(7)") as HTMLElement;
  243. await pauseFor(20);
  244. removeFromQueueBtn.click();
  245. }
  246. catch(err) {
  247. error("Couldn't remove song from queue due to error:", err);
  248. }
  249. finally {
  250. queuePopupCont?.removeAttribute("data-bytm-hidden");
  251. }
  252. });
  253. const imgElem = document.createElement("img");
  254. imgElem.className = "bytm-generic-btn-img";
  255. imgElem.src = getAssetUrl("delete.svg");
  256. deleteBtnElem.appendChild(imgElem);
  257. }
  258. //#SECTION append elements to DOM
  259. queueBtnsCont.appendChild(lyricsBtnElem);
  260. queueBtnsCont.appendChild(deleteBtnElem);
  261. songInfo.appendChild(queueBtnsCont);
  262. queueItem.classList.add("bytm-has-queue-btns");
  263. return true;
  264. }
  265. //#MARKER better clickable stuff
  266. // TODO: add to thumbnails in "songs" list on channel pages (/channel/$id)
  267. // TODO: add to thumbnails in playlists (/playlist?list=$id)
  268. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  269. export function addAnchorImprovements() {
  270. //#SECTION carousel shelves
  271. try {
  272. // home page
  273. /** Only adds anchor improvements for carousel shelves that contain the regular list-item-renderer, not the two-row-item-renderer */
  274. const condCarouselImprovements = (el: HTMLElement) => {
  275. const listItemRenderer = el.querySelector("ytmusic-responsive-list-item-renderer");
  276. if(listItemRenderer) {
  277. const itemsElem = el.querySelector<HTMLElement>("ul#items");
  278. if(itemsElem) {
  279. const improvedElems = improveCarouselAnchors(itemsElem);
  280. improvedElems > 0 && log(`Added anchor improvements to ${improvedElems} carousel shelf ${autoPlural("item", improvedElems)}`);
  281. }
  282. }
  283. };
  284. // initial three shelves aren't included in the event fire
  285. onSelectorExists("ytmusic-carousel-shelf-renderer", () => {
  286. const carouselShelves = document.body.querySelectorAll<HTMLElement>("ytmusic-carousel-shelf-renderer");
  287. carouselShelves.forEach(condCarouselImprovements);
  288. });
  289. // every shelf that's loaded by scrolling:
  290. siteEvents.on("carouselShelvesChanged", (evt) => {
  291. const { addedNodes, removedNodes } = getEvtData<Record<"addedNodes" | "removedNodes", NodeListOf<HTMLElement>>>(evt);
  292. void removedNodes;
  293. if(addedNodes.length > 0)
  294. addedNodes.forEach(condCarouselImprovements);
  295. });
  296. // related tab in /watch
  297. // TODO: items are lazy-loaded so this needs to be done differently
  298. // maybe the onSelectorExists feature can be expanded to conditionally support continuous checking & querySelectorAll
  299. const relatedTabAnchorImprovements = (tabElem: HTMLElement) => {
  300. const relatedCarouselShelves = tabElem?.querySelectorAll<HTMLElement>("ytmusic-carousel-shelf-renderer");
  301. if(relatedCarouselShelves)
  302. relatedCarouselShelves.forEach(condCarouselImprovements);
  303. };
  304. const relatedTabContentsSelector = "ytmusic-section-list-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] #contents";
  305. onSelectorExists("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"]", (relatedTabContainer) => {
  306. const relatedTabObserver = new MutationObserver(([ { addedNodes, removedNodes } ]) => {
  307. if(addedNodes.length > 0 || removedNodes.length > 0)
  308. relatedTabAnchorImprovements(document.querySelector<HTMLElement>(relatedTabContentsSelector)!);
  309. });
  310. relatedTabObserver.observe(relatedTabContainer, {
  311. childList: true,
  312. });
  313. });
  314. onSelectorExists(relatedTabContentsSelector, (relatedTabContents) => {
  315. relatedTabAnchorImprovements(relatedTabContents);
  316. });
  317. }
  318. catch(err) {
  319. error("Couldn't improve carousel shelf anchors due to an error:", err);
  320. }
  321. //#SECTION sidebar
  322. try {
  323. const addSidebarAnchors = (sidebarCont: HTMLElement) => {
  324. const items = sidebarCont.parentNode!.querySelectorAll<HTMLElement>("ytmusic-guide-entry-renderer tp-yt-paper-item");
  325. improveSidebarAnchors(items);
  326. return items.length;
  327. };
  328. onSelectorExists("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", (sidebarCont) => {
  329. const itemsAmt = addSidebarAnchors(sidebarCont);
  330. log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
  331. });
  332. onSelectorExists("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", (miniSidebarCont) => {
  333. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  334. log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
  335. });
  336. }
  337. catch(err) {
  338. error("Couldn't add anchors to sidebar items due to an error:", err);
  339. }
  340. }
  341. const sidebarPaths = [
  342. "/",
  343. "/explore",
  344. "/library",
  345. ];
  346. /**
  347. * Adds anchors to the sidebar items so they can be opened in a new tab
  348. * @param sidebarItem
  349. */
  350. function improveSidebarAnchors(sidebarItems: NodeListOf<HTMLElement>) {
  351. sidebarItems.forEach((item, i) => {
  352. const anchorElem = document.createElement("a");
  353. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  354. anchorElem.role = "button";
  355. anchorElem.target = "_self";
  356. anchorElem.href = sidebarPaths[i] ?? "#";
  357. anchorElem.title = "Middle click to open in a new tab";
  358. anchorElem.addEventListener("click", (e) => {
  359. e.preventDefault();
  360. });
  361. addParent(item, anchorElem);
  362. });
  363. }
  364. /**
  365. * Actually adds the anchor improvements to carousel shelf items
  366. * @param itemsElement The container with the selector `ul#items` inside of each `ytmusic-carousel`
  367. */
  368. function improveCarouselAnchors(itemsElement: HTMLElement) {
  369. if(itemsElement.classList.contains("bytm-anchors-improved"))
  370. return 0;
  371. let improvedElems = 0;
  372. try {
  373. const allListItems = itemsElement.querySelectorAll<HTMLElement>("ytmusic-responsive-list-item-renderer");
  374. for(const listItem of allListItems) {
  375. const thumbnailElem = listItem.querySelector<HTMLElement>(".left-items");
  376. const titleElem = listItem.querySelector<HTMLAnchorElement>(".title-column yt-formatted-string.title a");
  377. if(!thumbnailElem || !titleElem) {
  378. error("Couldn't add carousel shelf anchor improvements because either the thumbnail or title element couldn't be found");
  379. continue;
  380. }
  381. const thumbnailAnchor = document.createElement("a");
  382. thumbnailAnchor.className = "bytm-carousel-shelf-anchor bytm-anchor";
  383. thumbnailAnchor.href = titleElem.href;
  384. thumbnailAnchor.target = "_self";
  385. thumbnailAnchor.role = "button";
  386. thumbnailAnchor.addEventListener("click", (e) => {
  387. e.preventDefault();
  388. });
  389. addParent(thumbnailElem, thumbnailAnchor);
  390. improvedElems++;
  391. }
  392. }
  393. catch(err) {
  394. error("Couldn't add anchor improvements due to error:", err);
  395. }
  396. finally {
  397. itemsElement.classList.add("bytm-anchors-improved");
  398. }
  399. return improvedElems;
  400. }