layout.ts 30 KB

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