BetterYTM.user.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. // ==UserScript==
  2. // @name BetterYTM
  3. // @name:de BetterYTM
  4. // @namespace https://github.com/Sv443/BetterYTM#readme
  5. // @version 0.2.0
  6. // @license MIT
  7. // @author Sv443
  8. // @copyright Sv443 <[email protected]> (https://github.com/Sv443)
  9. // @description Improvements for YouTube Music
  10. // @description:de Verbesserungen für YouTube Music
  11. // @match https://music.youtube.com/*
  12. // @match https://www.youtube.com/*
  13. // @icon https://www.google.com/s2/favicons?domain=music.youtube.com
  14. // @run-at document-start
  15. // @connect self
  16. // @connect *.youtube.com
  17. // @downloadURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/BetterYTM.user.js
  18. // @updateURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/BetterYTM.user.js
  19. // ==/UserScript==
  20. /* Disclaimer: I am not affiliated with YouTube, Google, Alphabet or anyone else */
  21. /* C&D this, Susan 🖕 */
  22. (() => {
  23. "use-strict";
  24. /*
  25. █▀▀█ ▄▄▄ █ █ ▀ ▄▄▄ ▄▄▄▄ ▄▄▄
  26. ▀▀▄▄ █▄█ █▀ █▀ ▀█ █ █ █ ▄▄ █▄▄ ▀
  27. █▄▄█ █▄▄ █▄▄ █▄▄ ▄█▄ █ █ █▄▄█ ▄▄█ ▄
  28. */
  29. /**
  30. * This is where you can enable or disable features
  31. * If this userscript ever becomes something I might add like a menu to toggle these
  32. */
  33. const features = Object.freeze({
  34. // --- Quality of Life ---
  35. /** Whether arrow keys should skip forwards and backwards by 10 seconds */
  36. arrowKeySupport: true,
  37. /** Whether to remove the "Upgrade" / YT Music Premium tab */
  38. removeUpgradeTab: true,
  39. // --- Extra Features ---
  40. /** Whether to add a button or key combination (TODO) to switch between the YT and YTM sites on a video */
  41. switchBetweenSites: true,
  42. // --- Other ---
  43. /** Set to true to remove the watermark next to the YTM logo */
  44. removeWatermark: false,
  45. // /** The theme color - accepts any CSS color value - default is "#ff0000" */
  46. // themeColor: "#0f0",
  47. });
  48. /** Set to true to enable debug mode for more output in the JS console */
  49. const dbg = false;
  50. //#MARKER types
  51. /** @typedef {"yt"|"ytm"} Domain */
  52. //#MARKER init
  53. const info = Object.freeze({
  54. name: GM.info.script.name, // eslint-disable-line no-undef
  55. version: GM.info.script.version, // eslint-disable-line no-undef
  56. namespace: GM.info.script.namespace, // eslint-disable-line no-undef
  57. });
  58. function init()
  59. {
  60. try
  61. {
  62. console.log(`${info.name} v${info.version} - ${info.namespace}`);
  63. document.addEventListener("DOMContentLoaded", onDomLoad);
  64. }
  65. catch(err)
  66. {
  67. console.error("BetterYTM - General Error:", err);
  68. }
  69. }
  70. //#MARKER events
  71. /**
  72. * Called when the DOM has finished loading (after `DOMContentLoaded` is emitted)
  73. */
  74. function onDomLoad()
  75. {
  76. const domain = getDomain();
  77. dbg && console.info(`BetterYTM: Initializing features for domain '${domain}'`);
  78. try
  79. {
  80. // YTM-specific
  81. if(domain === "ytm")
  82. {
  83. if(features.arrowKeySupport)
  84. {
  85. document.addEventListener("keydown", onKeyDown);
  86. dbg && console.info(`BetterYTM: Added key press listener`);
  87. }
  88. if(features.removeUpgradeTab)
  89. removeUpgradeTab();
  90. if(!features.removeWatermark)
  91. addWatermark();
  92. }
  93. // Both YTM and YT
  94. if(features.switchBetweenSites)
  95. initSiteSwitch(domain);
  96. }
  97. catch(err)
  98. {
  99. console.error(`BetterYTM: General error while executing feature:`, err);
  100. }
  101. // if(features.themeColor != "#f00" && features.themeColor != "#ff0000")
  102. // applyTheme();
  103. }
  104. //#MARKER features
  105. //#SECTION arrow key skip
  106. /**
  107. * Called when the user presses keys
  108. * @param {KeyboardEvent} evt
  109. */
  110. function onKeyDown(evt)
  111. {
  112. if(["ArrowLeft", "ArrowRight"].includes(evt.code))
  113. {
  114. dbg && console.info(`BetterYTM: Captured key '${evt.code}' in proxy listener`);
  115. // ripped this stuff from the console, most of these are probably unnecessary but this was finnicky af and I am sick and tired of trial and error
  116. const defaultProps = {
  117. altKey: false,
  118. bubbles: true,
  119. cancelBubble: false,
  120. cancelable: true,
  121. charCode: 0,
  122. composed: true,
  123. ctrlKey: false,
  124. currentTarget: null,
  125. defaultPrevented: evt.defaultPrevented,
  126. explicitOriginalTarget: document.body,
  127. isTrusted: true,
  128. metaKey: false,
  129. originalTarget: document.body,
  130. repeat: false,
  131. shiftKey: false,
  132. srcElement: document.body,
  133. target: document.body,
  134. type: "keydown",
  135. view: window,
  136. };
  137. let invalidKey = false;
  138. let keyProps = {};
  139. switch(evt.code)
  140. {
  141. case "ArrowLeft":
  142. keyProps = {
  143. code: "KeyH",
  144. key: "h",
  145. keyCode: 72,
  146. which: 72,
  147. };
  148. break;
  149. case "ArrowRight":
  150. keyProps = {
  151. code: "KeyL",
  152. key: "l",
  153. keyCode: 76,
  154. which: 76,
  155. };
  156. break;
  157. default:
  158. // console.warn("BetterYTM - Unknown key", evt.code);
  159. invalidKey = true;
  160. break;
  161. }
  162. if(!invalidKey)
  163. {
  164. const proxyProps = { ...defaultProps, ...keyProps };
  165. document.body.dispatchEvent(new KeyboardEvent("keydown", proxyProps));
  166. dbg && console.info(`BetterYTM: Dispatched proxy keydown event [${evt.code}] -> [${proxyProps.code}]`);
  167. }
  168. else if(dbg)
  169. console.warn(`BetterYTM: Captured key '${evt.code}' has no defined behavior`);
  170. }
  171. }
  172. //#SECTION site switch
  173. /**
  174. * Initializes the site switch feature
  175. * @param {Domain} domain
  176. */
  177. function initSiteSwitch(domain)
  178. {
  179. // TODO:
  180. // extra features:
  181. // - keep video time
  182. document.addEventListener("keydown", (e) => {
  183. if(e.key == "F9")
  184. switchSite(domain === "yt" ? "ytm" : "yt");
  185. });
  186. dbg && console.info(`BetterYTM: Initialized site switch listener`);
  187. }
  188. /**
  189. * Switches to the other site (between YT and YTM)
  190. * @param {Domain} newDomain
  191. */
  192. function switchSite(newDomain)
  193. {
  194. dbg && console.info(`BetterYTM: Switching from domain '${getDomain()}' to '${newDomain}'`);
  195. try
  196. {
  197. let subdomain;
  198. if(newDomain === "ytm")
  199. subdomain = "music";
  200. else if(newDomain === "yt")
  201. subdomain = "www";
  202. if(!subdomain)
  203. throw new TypeError(`Unrecognized domain '${newDomain}'`);
  204. const { pathname, search, hash } = new URL(location.href);
  205. const vt = getVideoTime() ?? 0;
  206. dbg && console.info(`BetterYTM: Found video time of ${vt} seconds`);
  207. const newSearch = search.includes("?") ? `${search}&t=${vt}` : `?t=${vt}`;
  208. const url = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  209. console.info(`BetterYTM - switching to domain '${newDomain}' at ${url}`);
  210. location.href = url;
  211. }
  212. catch(err)
  213. {
  214. console.error(`BetterYTM: Error while switching site:`, err);
  215. }
  216. }
  217. //#SECTION remove upgrade tab
  218. let removeUpgradeTries = 0;
  219. /**
  220. * Removes the "Upgrade" / YT Music Premium tab from the title / nav bar
  221. */
  222. function removeUpgradeTab()
  223. {
  224. const tabElem = document.querySelector(`.ytmusic-nav-bar ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]`);
  225. if(tabElem)
  226. {
  227. tabElem.remove();
  228. dbg && console.info(`BetterYTM: Removed upgrade tab after ${removeUpgradeTries} tries`);
  229. }
  230. else if(removeUpgradeTries < 10)
  231. {
  232. setTimeout(removeUpgradeTab, 250); // TODO: improve this
  233. removeUpgradeTries++;
  234. }
  235. else if(dbg)
  236. console.info(`BetterYTM: Couldn't find upgrade tab to remove after ${removeUpgradeTries} tries`);
  237. }
  238. //#SECTION add watermark
  239. /**
  240. * Adds a watermark beneath the logo
  241. */
  242. function addWatermark()
  243. {
  244. const watermark = document.createElement("a");
  245. watermark.id = "betterytm-watermark";
  246. watermark.className = "style-scope ytmusic-nav-bar";
  247. watermark.innerText = info.name;
  248. watermark.title = `${info.name} v${info.version}`;
  249. watermark.href = info.namespace;
  250. watermark.target = "_blank";
  251. watermark.rel = "noopener noreferrer";
  252. const style = `
  253. #betterytm-watermark {
  254. position: absolute;
  255. left: 45px;
  256. top: 43px;
  257. z-index: 10;
  258. color: white;
  259. text-decoration: none;
  260. cursor: pointer;
  261. }
  262. #betterytm-watermark:hover {
  263. text-decoration: underline;
  264. }
  265. `;
  266. const styleElem = document.createElement("style");
  267. styleElem.id = "betterytm-watermark-style";
  268. if(styleElem.styleSheet)
  269. styleElem.styleSheet.cssText = style;
  270. else
  271. styleElem.appendChild(document.createTextNode(style));
  272. document.querySelector("head").appendChild(styleElem);
  273. const logoElem = document.querySelector("#left-content");
  274. logoElem.parentNode.insertBefore(watermark, logoElem.nextSibling);
  275. dbg && console.info(`BetterYTM: Added watermark element:`, watermark);
  276. }
  277. //#MARKER other
  278. /**
  279. * Returns the current domain as a constant string representation
  280. * @returns {Domain}
  281. */
  282. function getDomain()
  283. {
  284. const { hostname } = new URL(location.href);
  285. return hostname.toLowerCase().includes("music") ? "ytm" : "yt"; // other cases are caught by the `@match`es at the top
  286. }
  287. /**
  288. * Returns the current video time in seconds
  289. * @returns {number|null} Returns null if video time is unavailable
  290. */
  291. function getVideoTime()
  292. {
  293. const domain = getDomain();
  294. try
  295. {
  296. if(domain === "ytm")
  297. {
  298. const pbEl = document.querySelector("#progress-bar");
  299. return pbEl.value ?? null;
  300. }
  301. else if(domain === "yt") // YT doesn't update the progress bar when it's hidden (YTM doesn't hide it) so TODO: come up with some solution here
  302. return 0;
  303. return null;
  304. }
  305. catch(err)
  306. {
  307. console.error("BetterYTM: Couldn't get video time due to error:", err);
  308. return null;
  309. }
  310. }
  311. init(); // call init() when script is loaded
  312. })();