Youtube Utils

A low-tech solution to a high-tech problem!

  1. // ==UserScript==
  2. // @name Youtube Utils
  3. // @version 2.2.1
  4. // @namespace 4f9da7f6dc55e285bc626dc2dfa5fc8fa9855155
  5. // @description A low-tech solution to a high-tech problem!
  6. // @author rigid_function (original code: SteveJobzniak)
  7. // @license https://www.apache.org/licenses/LICENSE-2.0
  8. // @match *://www.youtube.com/*
  9. // @exclude *://www.youtube.com/tv*
  10. // @exclude *://www.youtube.com/embed/*
  11. // @compatible chrome Chrome + Tampermonkey or Violentmonkey
  12. // @compatible firefox Firefox + Greasemonkey or Tampermonkey or Violentmonkey
  13. // @compatible opera Opera + Tampermonkey or Violentmonkey
  14. // @compatible edge Edge + Tampermonkey or Violentmonkey
  15. // @compatible safari Safari + Tampermonkey or Violentmonkey
  16. // @grant window.onurlchange
  17. // @run-at document-start
  18. // @noframes
  19. // ==/UserScript==
  20. //* eslint-env browser, es6, greasemonkey */
  21.  
  22. (() => {
  23. 'use strict';
  24.  
  25. const config = {
  26. enable_dark: false, // Deprecated since Youtube now uses 'prefers-color-scheme'
  27. remove_PlayOnTV: true,
  28. remove_MiniPlayer: true,
  29. hijack_MiniPlayerKeybind: true, // Will disable the keybind only for the "i" character
  30. disable_AutoPlay: true,
  31. shorts_redirect: false, // Redirects shorts to the normal player like any other video
  32. run_on_url_change: true, // If disabled, the script will only run on page load
  33. verbose_logging: false
  34. }
  35.  
  36. const logger = (...args) => {
  37. if (!config.verbose_logging) {
  38. return;
  39. }
  40. console.log('[YU]', ...args);
  41. }
  42.  
  43. if (config.hijack_MiniPlayerKeybind) {
  44. logger('Hijacked miniplayer keybind')
  45. addEventListener("keydown", (event) => {
  46. // console.log('%cCalled!','color:cyan; font-size:2em', event, event.target.tagName);
  47.  
  48. if (event.keyCode === 73) {
  49. if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.contentEditable === "true")
  50. return;
  51.  
  52. event.preventDefault();
  53. event.stopPropagation();
  54. return;
  55. }
  56. }, true);
  57. }
  58.  
  59.  
  60. /* --- START: Utils-MultiRetry v1.1 by SteveJobzniak --- */
  61.  
  62. /* Performs multiple retries of a function call until it either succeeds or has failed all attempts. */
  63. const retryFnCall = function(fnCallback, maxAttempts, waitDelay) {
  64. try {
  65. // Default parameters: 40 * 50ms = Max ~2 seconds of additional retries.
  66. maxAttempts = (typeof maxAttempts !== 'undefined') ? maxAttempts : 40;
  67. waitDelay = (typeof waitDelay !== 'undefined') ? waitDelay : 50;
  68.  
  69. // If we don't succeed immediately, we'll perform multiple retries.
  70. var success = fnCallback();
  71. if (!success) {
  72. var attempt = 0;
  73. var searchTimer = setInterval(() => {
  74. var success = fnCallback();
  75.  
  76. // If we've reached max attempts or found success, we must now stop the interval timer.
  77. if (++attempt >= maxAttempts || success) {
  78. clearInterval(searchTimer);
  79. }
  80. }, waitDelay);
  81. }
  82. }
  83. // Use of try in case of an error the script will not stop.
  84. catch (err) {
  85. console.error(err)
  86. }
  87. };
  88.  
  89. /* --- END: Utils-MultiRetry by SteveJobzniak --- */
  90.  
  91. /* --- START: Utils-ElementFinder v1.3 by SteveJobzniak --- */
  92.  
  93. /* Searches for a specific element. */
  94. const findElement = function(parentElem, elemQuery, expectedLength, selectItem, fnCallback) {
  95. var elems = parentElem.querySelectorAll(elemQuery);
  96. if (elems.length === expectedLength) {
  97. var item = elems[selectItem];
  98. fnCallback(item);
  99. return true;
  100. }
  101.  
  102. //console.log('Debug: Cannot find "'+elemQuery+'".');
  103. return false;
  104. };
  105.  
  106. const retryFindElement = function(parentElem, elemQuery, expectedLength, selectItem, fnCallback, maxAttempts, waitDelay) {
  107. // If we can't find the element immediately, we'll perform multiple retries.
  108. retryFnCall(() => {
  109. return findElement(parentElem, elemQuery, expectedLength, selectItem, fnCallback);
  110. }, maxAttempts, waitDelay);
  111. };
  112.  
  113. /* Searches for multiple different elements and uses the earliest match. */
  114. const multiFindElement = function(queryList, fnCallback) {
  115. for (var i = 0, len = queryList.length; i < len; ++i) {
  116. var query = queryList[i];
  117. var success = findElement(query.parentElem, query.elemQuery, query.expectedLength, query.selectItem, fnCallback);
  118. if (success) {
  119. // Don't try any other queries, since we've found a successful match.
  120. return true;
  121. }
  122. }
  123.  
  124. return false;
  125. };
  126.  
  127. const retryMultiFindElement = function(queryList, fnCallback, maxAttempts, waitDelay) {
  128. // If we can't find any of the elements immediately, we'll perform multiple retries.
  129. retryFnCall(() => {
  130. return multiFindElement(queryList, fnCallback);
  131. }, maxAttempts, waitDelay);
  132. };
  133.  
  134. /* --- END: Utils-ElementFinder by SteveJobzniak --- */
  135.  
  136.  
  137.  
  138. /* Automatically enables YouTube's dark mode theme. */
  139. const enableDark = () => {
  140. // Refuse to proceed if the user is on the old non-Material YouTube theme (which has no dark mode).
  141. // NOTE: This is just to avoid getting "error reports" by people who aren't even on YouTube's new theme.
  142. const oldYouTube = document.getElementById('body-container');
  143. if (oldYouTube && document.body.id === 'body') {
  144. console.error("You are using the old YouTube theme.\nThe Dark Mode only works in Youtube's new version.")
  145. return;
  146. }
  147.  
  148. // Wait until the settings menu is available, to ensure that YouTube's "dark mode state" and code has been loaded...
  149. // Note that this particular menu button always exists (both when logged in and when logged out of your account),
  150. // but its actual icon and the list of submenu choices differ. However, its "dark mode" submenus are the same in either case.
  151. retryFnCall(() => {
  152. // The menu button count varies based on the browser. We expect to find either 2 or 3 buttons, and the settings menu
  153. // is always the last button (even when logged in). Sadly there is no better way to find the correct button,
  154. // since YouTube doesn't have any identifiable language-agnostic labels or icons in the HTML. Sigh...
  155. var buttons = document.querySelectorAll('ytd-topbar-menu-button-renderer button');
  156. if (buttons.length !== 2 && buttons.length !== 3) {
  157. return false; // Failed to find any of the expected menu button counts. Retry...
  158. }
  159. var settingsMenuButton = buttons[buttons.length - 1];
  160.  
  161. // Check the dark mode state "flag" and abort processing if dark mode is already active.
  162. if (document.documentElement.getAttribute('dark') === 'true') {
  163. return true // Stop retrying...
  164. }
  165.  
  166. // We MUST open the "settings" menu, otherwise nothing will react to the "toggle dark mode" event!
  167. settingsMenuButton.click();
  168.  
  169. // Wait a moment for the settings-menu to open up after clicking...
  170. retryFindElement(document, 'div#label.style-scope.ytd-toggle-theme-compact-link-renderer', 1, 0, function(darkModeSubMenuButton) {
  171. // Next, go to the "toggle dark mode" settings sub-page.
  172. darkModeSubMenuButton.click();
  173.  
  174. // Wait a moment for the settings sub-page to switch...
  175. retryFindElement(document, 'ytd-toggle-item-renderer.style-scope.ytd-multi-page-menu-renderer', 1, 0, function(darkModeSubPageContainer) {
  176. // Get a reference to the "activate dark mode" button...
  177. retryFindElement(darkModeSubPageContainer, 'paper-toggle-button.style-scope.ytd-toggle-item-renderer', 1, 0, function(darkModeButton) {
  178. // We MUST now use this very ugly, hardcoded sleep-timer to ensure that YouTube's "activate dark mode" code is fully
  179. // loaded; otherwise, YouTube will be completely BUGGED OUT and WON'T save the fact that we've enabled dark mode!
  180. // Since JavaScript is single-threaded, this timeout simply ensures that we'll leave our current code so that we allow
  181. // YouTube's event handlers to deal with loading the settings-page, and then the timeout gives control back to us.
  182. setTimeout(() => {
  183. // Now simply click YouTube's button to enable their dark mode.
  184. darkModeButton.click();
  185.  
  186. // And lastly, give keyboard focus back to the input search field... (We don't need any setTimeout here...)
  187. retryFindElement(document, 'input#search', 1, 0, function(searchField) {
  188. searchField.click(); // First, click the search-field to force the settings-panel to close...
  189. searchField.focus(); // ...and finally give the search-field focus! Voila!
  190. });
  191. }, 30); // We can use 0ms here for "as soon as possible" instead, but our "at least 30ms" might be safer just in case.
  192. });
  193. });
  194. });
  195.  
  196. return true; // Stop retrying, since we've found and clicked the menu...
  197. }, 120, 50); // 120 * 50ms = ~6 seconds of retries.
  198.  
  199. // Alternative method, which switches using an internal YouTube event instead of clicking
  200. // the menus... I decided to disable this method, since it relies on intricate internal
  201. // details, and it still requires their menu to be open to work anyway (because their
  202. // code for changing theme isn't active until the Dark Mode settings menu is open),
  203. // so we may as well just click the actual menu items. ;-)
  204. /*
  205. var ytDebugMenu = document.querySelectorAll('ytd-debug-menu');
  206. ytDebugMenu = (ytDebugMenu.length === 1 ? ytDebugMenu[0] : undefined);
  207. if( ytDebugMenu ) {
  208. ytDebugMenu.fire(
  209. 'yt-action',
  210. {
  211. actionName:'yt-signal-action-toggle-dark-theme-on',
  212. optionalAction:false,
  213. args:[
  214. {signalAction:{signal:'TOGGLE_DARK_THEME_ON'}},
  215. toggleMenuElem,
  216. undefined
  217. ],
  218. returnValue: []
  219. },
  220. {}
  221. );
  222. }
  223. */
  224.  
  225. // Also note that it may be possible to simply modify the YouTube cookies, by changing
  226. // "PREF=f1=50000000;" to "PREF=f1=50000000&f6=400;" (dark mode on) and then reloading the page.
  227. // However, a reload is always slower than toggling the settings menu, so I didn't do that.
  228. };
  229.  
  230. /* Automatically redirect shorts videos to the normal youtube video URL */
  231. const shortsRedirect = () => {
  232. if(!config.shorts_redirect) return;
  233.  
  234. if (window.location.href.indexOf('youtube.com/shorts') > -1) {
  235. logger('Redirecting short...');
  236. window.location.replace(window.location.toString().replace('/shorts/', '/watch?v='));
  237. }
  238. }
  239.  
  240. /* Automatically removes YouTube's Play on TV button. */
  241. const removePlayOnTV = () => {
  242. logger('Removing PlayOnTV...')
  243. retryFnCall(() => {
  244. // Find the element with the svg's patch id.
  245. const button = document.getElementsByClassName("ytp-remote-button")[0]
  246. if (!button) {
  247. logger('PlayOnTV element not found')
  248. return false // Failed to find any of the expected menu button counts. Retry...
  249. }
  250.  
  251. // Remove the button from the video tab
  252. if (button) {
  253. logger('PlayOnTV Removed')
  254. button.remove();
  255. return true // Stop retrying...
  256. }
  257. })
  258. }
  259.  
  260. /* Automatically removes YouTube's Miniplayer button. */
  261. const removeMiniPlayer = () => {
  262. logger('Removing MiniPlayer...')
  263. retryFnCall(() => {
  264. const button = document.querySelector('[data-tooltip-target-id="ytp-miniplayer-button"]');
  265. if (!button) {
  266. logger('MiniPlayer element not found')
  267. return false // Failed to find any of the expected menu button counts. Retry...
  268. }
  269.  
  270. // Remove the button from the video tab
  271. if (button) {
  272. logger('MiniPlayer Removed')
  273. button.remove()
  274. return true // Stop retrying...
  275. }
  276. })
  277. }
  278.  
  279. /* Automatically disables Autoplay mode. */
  280. const disableAutoPlay = () => {
  281. logger('Disabling Autoplay...')
  282. retryFnCall(() => {
  283. const button = document.querySelector('.ytp-autonav-toggle-button')
  284. const att_to_check = 'aria-checked'
  285.  
  286. // Check the button checked state and abort processing if is already disabled.
  287. // Else, click the button.
  288. if (button) {
  289. if(button.getAttribute(att_to_check) === 'true') {
  290. if (button.parentElement.nodeName === 'BUTTON') {
  291. logger('Clicked autoplay parent button.', button.parentElement)
  292. button.parentElement.click()
  293. } else {
  294. logger('Clicked autoplay button.', button)
  295. button.click()
  296. }
  297. logger('Autoplay disabled!')
  298. return false // Forcefully so, in case isn't loaded yet, will loop again to disable it
  299. } else {
  300. logger('Autoplay is already disabled.')
  301. return true // Stop retrying...
  302. }
  303. } else {
  304. logger('Autoplay element not found')
  305. return false // Not found, retrying
  306. }
  307. })
  308. }
  309.  
  310. const runOnURLChange = () => {
  311. logger('runOnURLChange()')
  312. if (window.onurlchange === null) {
  313. logger('onurlchange supported')
  314. window.addEventListener('urlchange', (info) => {
  315. logger('URL Changed', info)
  316. awaitPageLoad(enable())
  317. })
  318. }
  319. }
  320.  
  321. const enable = () => {
  322. logger('Enabled!')
  323. shortsRedirect();
  324. if(config.enable_dark) enableDark();
  325. if(config.remove_PlayOnTV) removePlayOnTV();
  326. if(config.remove_MiniPlayer) removeMiniPlayer();
  327. if(config.disable_AutoPlay) disableAutoPlay();
  328. }
  329.  
  330. const awaitPageLoad = (callback) => {
  331. if (document.readyState === 'complete') {
  332. logger('Completed!')
  333. callback
  334. } else {
  335. logger('Document not ready, adding Event Listener...')
  336. document.addEventListener('readystatechange', (evt) => {
  337. logger('Completed!')
  338. if (document.readyState === 'complete') {
  339. callback
  340. }
  341. })
  342. }
  343. }
  344.  
  345. shortsRedirect();
  346. awaitPageLoad(enable());
  347. if (config.run_on_url_change) runOnURLChange();
  348. })();