layout.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import { addGlobalStyle, addParent, autoPlural, fetchAdvanced, insertAfter, pauseFor } from "@sv443-network/userutils";
  2. import { getFeatures } from "../config";
  3. import { siteEvents } from "../siteEvents";
  4. import { addSelectorListener } from "../observers";
  5. import { error, getResourceUrl, log, onSelectorOld, warn, t, onInteraction, getWatchId, getBestThumbnailUrl } from "../utils";
  6. import { scriptInfo } from "../constants";
  7. import { openCfgMenu } from "../menu/menu_old";
  8. import "./layout.css";
  9. //#MARKER BYTM-Config buttons
  10. let logoExchanged = false, improveLogoCalled = false;
  11. /** Adds a watermark beneath the logo */
  12. export async function addWatermark() {
  13. const watermark = document.createElement("a");
  14. watermark.role = "button";
  15. watermark.id = "bytm-watermark";
  16. watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
  17. watermark.textContent = scriptInfo.name;
  18. watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
  19. watermark.tabIndex = 0;
  20. improveLogo();
  21. const watermarkOpenMenu = (e: MouseEvent | KeyboardEvent) => {
  22. e.stopPropagation();
  23. if((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  24. openCfgMenu();
  25. if(!logoExchanged && (e.shiftKey || e.ctrlKey))
  26. exchangeLogo();
  27. };
  28. onInteraction(watermark, watermarkOpenMenu);
  29. onSelectorOld("ytmusic-nav-bar #left-content", {
  30. listener: (logoElem) => insertAfter(logoElem, watermark),
  31. });
  32. log("Added watermark element");
  33. }
  34. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  35. export async function improveLogo() {
  36. try {
  37. if(improveLogoCalled)
  38. return;
  39. improveLogoCalled = true;
  40. const res = await fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  41. const svg = await res.text();
  42. onSelectorOld("ytmusic-logo a", {
  43. listener: (logoElem) => {
  44. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  45. logoElem.innerHTML = svg;
  46. logoElem.querySelectorAll("ellipse").forEach((e) => {
  47. e.classList.add("bytm-mod-logo-ellipse");
  48. });
  49. logoElem.querySelector("path")?.classList.add("bytm-mod-logo-path");
  50. log("Swapped logo to inline SVG");
  51. },
  52. });
  53. }
  54. catch(err) {
  55. error("Couldn't improve logo due to an error:", err);
  56. }
  57. }
  58. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  59. function exchangeLogo() {
  60. onSelectorOld(".bytm-mod-logo", {
  61. listener: async (logoElem) => {
  62. if(logoElem.classList.contains("bytm-logo-exchanged"))
  63. return;
  64. logoExchanged = true;
  65. logoElem.classList.add("bytm-logo-exchanged");
  66. const iconUrl = await getResourceUrl("img-logo");
  67. const newLogo = document.createElement("img");
  68. newLogo.className = "bytm-mod-logo-img";
  69. newLogo.src = iconUrl;
  70. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  71. document.head.querySelectorAll<HTMLLinkElement>("link[rel=\"icon\"]").forEach((e) => {
  72. e.href = iconUrl;
  73. });
  74. setTimeout(() => {
  75. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  76. }, 1000);
  77. },
  78. });
  79. }
  80. /** Called whenever the avatar popover menu exists to add a BYTM-Configuration button to the user menu popover */
  81. export async function addConfigMenuOption(container: HTMLElement) {
  82. const cfgOptElem = document.createElement("div");
  83. cfgOptElem.className = "bytm-cfg-menu-option";
  84. const cfgOptItemElem = document.createElement("div");
  85. cfgOptItemElem.className = "bytm-cfg-menu-option-item";
  86. cfgOptItemElem.role = "button";
  87. cfgOptItemElem.tabIndex = 0;
  88. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
  89. onInteraction(cfgOptItemElem, async (e: MouseEvent | KeyboardEvent) => {
  90. const settingsBtnElem = document.querySelector<HTMLElement>("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  91. settingsBtnElem?.click();
  92. await pauseFor(20);
  93. if((!e.shiftKey && !e.ctrlKey) || logoExchanged)
  94. openCfgMenu();
  95. if(!logoExchanged && (e.shiftKey || e.ctrlKey))
  96. exchangeLogo();
  97. });
  98. const cfgOptIconElem = document.createElement("img");
  99. cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
  100. cfgOptIconElem.src = await getResourceUrl("img-logo");
  101. const cfgOptTextElem = document.createElement("div");
  102. cfgOptTextElem.className = "bytm-cfg-menu-option-text";
  103. cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
  104. cfgOptItemElem.appendChild(cfgOptIconElem);
  105. cfgOptItemElem.appendChild(cfgOptTextElem);
  106. cfgOptElem.appendChild(cfgOptItemElem);
  107. container.appendChild(cfgOptElem);
  108. improveLogo();
  109. log("Added BYTM-Configuration button to menu popover");
  110. }
  111. //#MARKER remove upgrade tab
  112. /** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
  113. export async function removeUpgradeTab() {
  114. onSelectorOld("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
  115. listener: (tabElemLarge) => {
  116. tabElemLarge.remove();
  117. log("Removed large upgrade tab");
  118. },
  119. });
  120. onSelectorOld("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
  121. listener: (tabElemSmall) => {
  122. tabElemSmall.remove();
  123. log("Removed small upgrade tab");
  124. },
  125. });
  126. }
  127. //#MARKER anchor improvements
  128. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  129. export async function addAnchorImprovements() {
  130. try {
  131. const css = await (await fetchAdvanced(await getResourceUrl("css-anchor_improvements"))).text();
  132. if(css)
  133. addGlobalStyle(css).id = "bytm-style-anchor-improvements";
  134. }
  135. catch(err) {
  136. error("Couldn't add anchor improvements CSS due to an error:", err);
  137. }
  138. //#SECTION carousel shelves
  139. try {
  140. const preventDefault = (e: MouseEvent) => e.preventDefault();
  141. /** Adds anchor improvements to &lt;ytmusic-responsive-list-item-renderer&gt; */
  142. const addListItemAnchors = (items: NodeListOf<HTMLElement>) => {
  143. for(const item of items) {
  144. if(item.classList.contains("bytm-anchor-improved"))
  145. continue;
  146. item.classList.add("bytm-anchor-improved");
  147. const thumbnailElem = item.querySelector<HTMLElement>(".left-items");
  148. const titleElem = item.querySelector<HTMLAnchorElement>(".title-column .title a");
  149. if(!thumbnailElem || !titleElem)
  150. continue;
  151. const anchorElem = document.createElement("a");
  152. anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
  153. anchorElem.href = titleElem?.href ?? "#";
  154. anchorElem.target = "_self";
  155. anchorElem.role = "button";
  156. anchorElem.addEventListener("click", preventDefault);
  157. addParent(thumbnailElem, anchorElem);
  158. }
  159. };
  160. // home page
  161. onSelectorOld<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
  162. continuous: true,
  163. all: true,
  164. listener: addListItemAnchors,
  165. });
  166. // related tab in /watch
  167. onSelectorOld<HTMLElement>("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
  168. continuous: true,
  169. all: true,
  170. listener: addListItemAnchors,
  171. });
  172. // playlists
  173. onSelectorOld<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
  174. continuous: true,
  175. all: true,
  176. listener: addListItemAnchors,
  177. });
  178. // generic shelves
  179. onSelectorOld<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
  180. continuous: true,
  181. all: true,
  182. listener: addListItemAnchors,
  183. });
  184. }
  185. catch(err) {
  186. error("Couldn't improve carousel shelf anchors due to an error:", err);
  187. }
  188. //#SECTION sidebar
  189. try {
  190. const addSidebarAnchors = (sidebarCont: HTMLElement) => {
  191. const items = sidebarCont.parentNode!.querySelectorAll<HTMLElement>("ytmusic-guide-entry-renderer tp-yt-paper-item");
  192. improveSidebarAnchors(items);
  193. return items.length;
  194. };
  195. onSelectorOld("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  196. listener: (sidebarCont) => {
  197. const itemsAmt = addSidebarAnchors(sidebarCont);
  198. log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
  199. },
  200. });
  201. onSelectorOld("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  202. listener: (miniSidebarCont) => {
  203. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  204. log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
  205. },
  206. });
  207. }
  208. catch(err) {
  209. error("Couldn't add anchors to sidebar items due to an error:", err);
  210. }
  211. }
  212. const sidebarPaths = [
  213. "/",
  214. "/explore",
  215. "/library",
  216. ];
  217. /**
  218. * Adds anchors to the sidebar items so they can be opened in a new tab
  219. * @param sidebarItem
  220. */
  221. function improveSidebarAnchors(sidebarItems: NodeListOf<HTMLElement>) {
  222. sidebarItems.forEach((item, i) => {
  223. const anchorElem = document.createElement("a");
  224. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  225. anchorElem.role = "button";
  226. anchorElem.target = "_self";
  227. anchorElem.href = sidebarPaths[i] ?? "#";
  228. anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
  229. anchorElem.addEventListener("click", (e) => {
  230. e.preventDefault();
  231. });
  232. addParent(item, anchorElem);
  233. });
  234. }
  235. //#MARKER remove share tracking param
  236. let lastShareVal = "";
  237. /** Removes the ?si tracking parameter from share URLs */
  238. export async function removeShareTrackingParam() {
  239. const removeSiParam = (inputElem: HTMLInputElement) => {
  240. try {
  241. if(lastShareVal === inputElem.value)
  242. return;
  243. const url = new URL(inputElem.value);
  244. if(!url.searchParams.has("si"))
  245. return;
  246. lastShareVal = inputElem.value;
  247. url.searchParams.delete("si");
  248. inputElem.value = String(url);
  249. log(`Removed tracking parameter from share link: ${url}`);
  250. }
  251. catch(err) {
  252. warn("Couldn't remove tracking parameter from share link due to error:", err);
  253. }
  254. };
  255. onSelectorOld<HTMLInputElement>("tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", {
  256. listener: (sharePanelEl) => {
  257. const obs = new MutationObserver(() => {
  258. const inputElem = sharePanelEl.querySelector<HTMLInputElement>("input#share-url");
  259. inputElem && removeSiParam(inputElem);
  260. });
  261. obs.observe(sharePanelEl, {
  262. childList: true,
  263. subtree: true,
  264. attributeFilter: ["aria-hidden", "checked"],
  265. });
  266. },
  267. });
  268. }
  269. //#MARKER fix margins
  270. /** Applies global CSS to fix various spacings */
  271. export async function fixSpacing() {
  272. try {
  273. const css = await (await fetchAdvanced(await getResourceUrl("css-fix_spacing"))).text();
  274. if(css)
  275. addGlobalStyle(css).id = "bytm-style-fix-spacing";
  276. }
  277. catch(err) {
  278. error("Couldn't fix spacing due to an error:", err);
  279. }
  280. }
  281. //#MARKER scroll to active song
  282. /** Adds a button to the queue to scroll to the active song */
  283. export async function addScrollToActiveBtn() {
  284. onSelectorOld("#side-panel #tabsContent tp-yt-paper-tab:nth-of-type(1)", {
  285. listener: async (tabElem) => {
  286. const containerElem = document.createElement("div");
  287. containerElem.id = "bytm-scroll-to-active-btn-cont";
  288. const linkElem = document.createElement("div");
  289. linkElem.id = "bytm-scroll-to-active-btn";
  290. linkElem.tabIndex = 0;
  291. linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
  292. linkElem.ariaLabel = linkElem.title = t("scroll_to_playing");
  293. linkElem.role = "button";
  294. const imgElem = document.createElement("img");
  295. imgElem.classList.add("bytm-generic-btn-img");
  296. imgElem.src = await getResourceUrl("icon-skip_to");
  297. const scrollToActiveInteraction = () => {
  298. 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\"]");
  299. if(!activeItem)
  300. return;
  301. activeItem.scrollIntoView({
  302. behavior: "smooth",
  303. block: "center",
  304. inline: "center",
  305. });
  306. };
  307. onInteraction(linkElem, scrollToActiveInteraction, { capture: true });
  308. linkElem.appendChild(imgElem);
  309. containerElem.appendChild(linkElem);
  310. tabElem.appendChild(containerElem);
  311. },
  312. });
  313. }
  314. //#MARKER thumbnail overlay
  315. /** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
  316. let invertOverlay = false;
  317. export async function initThumbnailOverlay() {
  318. const behavior = getFeatures().thumbnailOverlayBehavior;
  319. const toggleBtnShown = getFeatures().thumbnailOverlayToggleBtnShown;
  320. if(behavior === "never" && !toggleBtnShown)
  321. return;
  322. const playerSelector = "ytmusic-player#player";
  323. const playerEl = document.querySelector<HTMLElement>(playerSelector);
  324. if(!playerEl)
  325. return error("Couldn't find video player element while adding thumbnail overlay");
  326. /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
  327. const updateOverlayVisibility = async () => {
  328. let showOverlay = behavior === "always";
  329. const isVideo = document.querySelector<HTMLElement>("ytmusic-player")?.hasAttribute("video-mode") ?? false;
  330. if(behavior === "videosOnly" && isVideo)
  331. showOverlay = true;
  332. else if(behavior === "songsOnly" && !isVideo)
  333. showOverlay = true;
  334. showOverlay = invertOverlay ? !showOverlay : showOverlay;
  335. const overlayElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay");
  336. const thumbElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-img");
  337. const indicatorElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-indicator");
  338. if(overlayElem)
  339. overlayElem.style.display = showOverlay ? "block" : "none";
  340. if(thumbElem)
  341. thumbElem.ariaHidden = String(!showOverlay);
  342. if(indicatorElem) {
  343. indicatorElem.style.display = showOverlay ? "block" : "none";
  344. indicatorElem.ariaHidden = String(!showOverlay);
  345. }
  346. if(getFeatures().thumbnailOverlayToggleBtnShown) {
  347. const toggleBtnElem = document.querySelector<HTMLImageElement>("#bytm-thumbnail-overlay-toggle");
  348. const toggleBtnImgElem = document.querySelector<HTMLImageElement>("#bytm-thumbnail-overlay-toggle > img");
  349. if(!toggleBtnElem || !toggleBtnImgElem)
  350. return error("Couldn't find thumbnail overlay toggle button element while checking visibility");
  351. toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}` as "icon-image" | "icon-image_filled");
  352. toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
  353. }
  354. };
  355. const createElements = async () => {
  356. // overlay
  357. const overlayElem = document.createElement("div");
  358. overlayElem.id = "bytm-thumbnail-overlay";
  359. overlayElem.classList.add("bytm-no-select");
  360. overlayElem.style.display = "none";
  361. let indicatorElem: HTMLImageElement | undefined;
  362. if(getFeatures().thumbnailOverlayShowIndicator) {
  363. indicatorElem = document.createElement("img");
  364. indicatorElem.id = "bytm-thumbnail-overlay-indicator";
  365. indicatorElem.src = await getResourceUrl("icon-image");
  366. indicatorElem.role = "presentation";
  367. indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip");
  368. indicatorElem.ariaHidden = "true";
  369. indicatorElem.style.display = "none";
  370. }
  371. const thumbImgElem = document.createElement("img");
  372. thumbImgElem.id = "bytm-thumbnail-overlay-img";
  373. thumbImgElem.role = "presentation";
  374. thumbImgElem.ariaHidden = "true";
  375. thumbImgElem.style.objectFit = getFeatures().thumbnailOverlayImageFit;
  376. overlayElem.appendChild(thumbImgElem);
  377. playerEl.appendChild(overlayElem);
  378. indicatorElem && playerEl.appendChild(indicatorElem);
  379. siteEvents.on("watchIdChanged", async () => {
  380. const watchId = getWatchId();
  381. if(!watchId)
  382. return error("Couldn't get watch ID while updating thumbnail overlay");
  383. const thumbUrl = await getBestThumbnailUrl(watchId);
  384. if(thumbUrl) {
  385. const toggleBtnElem = document.querySelector<HTMLAnchorElement>("#bytm-thumbnail-overlay-toggle");
  386. if(toggleBtnElem)
  387. toggleBtnElem.href = thumbUrl;
  388. thumbImgElem.src = thumbUrl;
  389. }
  390. invertOverlay = false;
  391. updateOverlayVisibility();
  392. });
  393. // toggle button
  394. if(toggleBtnShown) {
  395. const toggleBtnElem = document.createElement("a");
  396. toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
  397. toggleBtnElem.role = "button";
  398. toggleBtnElem.tabIndex = 0;
  399. toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
  400. onInteraction(toggleBtnElem, async (e) => {
  401. if(e.shiftKey || (e instanceof MouseEvent && e.button === 3)) {
  402. const watchId = getWatchId();
  403. if(!watchId)
  404. return error("Couldn't get watch ID while opening thumbnail in new tab");
  405. const thumbUrl = await getBestThumbnailUrl(watchId);
  406. if(thumbUrl)
  407. return GM.openInTab(thumbUrl);
  408. }
  409. invertOverlay = !invertOverlay;
  410. updateOverlayVisibility();
  411. });
  412. const imgElem = document.createElement("img");
  413. imgElem.classList.add("bytm-generic-btn-img");
  414. toggleBtnElem.appendChild(imgElem);
  415. onSelectorOld(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", {
  416. listener: (likeContainer) => insertAfter(likeContainer, toggleBtnElem),
  417. });
  418. }
  419. updateOverlayVisibility();
  420. log("Added thumbnail overlay");
  421. };
  422. addSelectorListener("body", playerSelector, {
  423. listener(playerEl) {
  424. if(playerEl.getAttribute("player-ui-state") === "INACTIVE") {
  425. const obs = new MutationObserver(() => {
  426. if(playerEl.getAttribute("player-ui-state") === "INACTIVE")
  427. return;
  428. createElements();
  429. obs.disconnect();
  430. });
  431. obs.observe(playerEl, {
  432. attributes: true,
  433. attributeFilter: ["player-ui-state"],
  434. });
  435. }
  436. else
  437. createElements();
  438. },
  439. });
  440. }