Greasy Fork is available in English.

Youtube HD

Select a youtube resolution and resize the player.

  1. // ==UserScript==
  2. // @name Youtube HD
  3. // @author adisib
  4. // @namespace namespace_adisib
  5. // @description Select a youtube resolution and resize the player.
  6. // @version 2025.04.13
  7. // @match https://*.youtube.com/*
  8. // @noframes
  9. // @grant GM.getValue
  10. // @grant GM.setValue
  11. // ==/UserScript==
  12.  
  13. // The video will only resize when in theater mode on the main youtube website.
  14. // By default only runs on youtube website, not players embeded on other websites, but there is experimental support for embeds.
  15. // To enable experimental support for embedded players outside of YouTube website, do the following steps:
  16. // add " @include * " to the script metadata
  17. // remove " @noframes " from the script metadata
  18.  
  19. // 2025.04.13
  20. // Add requested option "removeBlackBars" which restricts the height of the video if the screen width is smaller than player width
  21.  
  22. // 2025.04.12
  23. // Fix resizing not working with some browsers/userscript managers (worked with greasemonkey and firefox, probably not with anything else)
  24. // Set the default target resolution to 4k now, since it has probably been long enough that the number of complaints about choosing 4k will be minimal
  25. // Thanks to ElectroKnight22 (https://greatest.deepsurf.us/en/scripts/498145-youtube-hd-premium) and all others who provide alternatives while this script was being poorly maintained
  26. // There could be other issues complained about that I didn't fix yet. Please keep complaining to me so that I will fix them.
  27.  
  28.  
  29. (function() {
  30. "use strict";
  31.  
  32. // --- SETTINGS -------
  33.  
  34. // PLEASE NOTE:
  35. // Settings will be saved the first time the script is loaded so that your changes aren't undone by an update.
  36. // If you want to make adjustments, please set "overwriteStoredSettings" to true.
  37. // Otherwise, your settings changes will NOT have an effect because it will used the saved settings.
  38. // After the script has next been run by loading a video with "overwriteStoredSettings" as true, your settings will be updated.
  39. // Then after that you can set it to false again to prevent your settings from being changed by an update.
  40.  
  41. let settings = {
  42.  
  43. // Target Resolution to always set to. If not available, the next best resolution will be used.
  44. changeResolution: true,
  45. preferPremium: true,
  46. targetRes: "hd2160",
  47. // Choices for targetRes are currently:
  48. // "highres" >= ( 8K / 4320p / QUHD )
  49. // "hd2880" = ( 5K / 2880p / UHD+ )
  50. // "hd2160" = ( 4K / 2160p / UHD )
  51. // "hd1440" = ( 1440p / QHD )
  52. // "hd1080" = ( 1080p / FHD )
  53. // "hd720" = ( 720p / HD )
  54. // "large" = ( 480p )
  55. // "medium" = ( 360p )
  56. // "small" = ( 240p )
  57. // "tiny" = ( 144p )
  58.  
  59. // Target Resolution for high framerate (60 fps) videos
  60. // If null, it is the same as targetRes
  61. highFramerateTargetRes: null,
  62.  
  63. // If changePlayerSize is true, then the video's size will be changed on the page
  64. // instead of using youtube's default (if theater mode is enabled).
  65. // If useCustomSize is false, then the player will be resized to try to match the target resolution.
  66. // If true, then it will use the customHeight variables (theater mode is always full page width).
  67. // If removeBlackBars is true, will try to cap the height based on the current video's aspect ratio
  68. // if the screen size is smaller than the requested resolution
  69. changePlayerSize: false,
  70. removeBlackBars: false,
  71. useCustomSize: false,
  72. customHeight: 600,
  73.  
  74. // If autoTheater is true, each video page opened will default to theater mode.
  75. // This means the video will always be resized immediately if you are changing the size.
  76. // NOTE: YouTube will not always allow theater mode immediately, the page must be fully loaded before theater can be set.
  77. autoTheater: false,
  78.  
  79. // If flushBuffer is false, then the first second or so of the video may not always be the desired resolution.
  80. // If true, then the entire video will be guaranteed to be the target resolution, but there may be
  81. // a very small additional delay before the video starts if the buffer needs to be flushed.
  82. flushBuffer: true,
  83.  
  84. // Setting cookies can allow some operations to perform faster or without a delay (e.g. theater mode)
  85. // Some people don't like setting cookies, so this is false by default (which is the same as old behavior)
  86. allowCookies: false,
  87.  
  88. // Tries to set the resolution as early as possible.
  89. // This might cause issues on youtube polymer layout, so disable if videos fail to load.
  90. // If videos load fine, leave as true or resolution may fail to set.
  91. setResolutionEarly: true,
  92.  
  93. // Enables a temporary work around for an issue where users can get the wrong youtube error screen
  94. // (Youtube has two of them for some reason and changing to theater mode moves the wrong one to the front)
  95. // Try disabling if you can't interact with the video or you think you are missing an error message.
  96. enableErrorScreenWorkaround: true,
  97.  
  98. // Use the iframe API to set resolution if possible. Otherwise uses simulated mouse clicks.
  99. useAPI: true,
  100.  
  101. // Overwrite stored settings with the settings coded into the script, to apply changes.
  102. // Set and keep as true to have settings behave like before, where you can just edit the settings here to change them.
  103. overwriteStoredSettings: false
  104.  
  105. };
  106.  
  107. // --------------------
  108.  
  109.  
  110.  
  111.  
  112. // --- GLOBALS --------
  113.  
  114.  
  115. const DEBUG = false;
  116.  
  117. // Possible resolution choices (in decreasing order, i.e. highres is the best):
  118. const resolutions = ['highres', 'hd2880', 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny'];
  119. // youtube has to be at least 480x270 for the player UI
  120. const heights = [4320, 2880, 2160, 1440, 1080, 720, 480, 360, 240, 144];
  121.  
  122. let doc = document, win = window;
  123.  
  124. // ID of the most recently played video
  125. let recentVideo = "";
  126.  
  127. let foundHFR = false;
  128.  
  129. let setHeight = 0;
  130.  
  131.  
  132. // --------------------
  133.  
  134.  
  135. function debugLog(message)
  136. {
  137. if (DEBUG)
  138. {
  139. console.log("YTHD | " + message);
  140. }
  141. }
  142.  
  143.  
  144. // --------------------
  145.  
  146.  
  147. // Used only for compatability with webextensions version of greasemonkey
  148. function unwrapElement(el)
  149. {
  150. if (el && el.wrappedJSObject)
  151. {
  152. return el.wrappedJSObject;
  153. }
  154. return el;
  155. }
  156.  
  157.  
  158. // --------------------
  159.  
  160.  
  161. // Get player object
  162. function getPlayer()
  163. {
  164. let ytPlayer = doc.getElementById("movie_player") || doc.getElementsByClassName("html5-video-player")[0];
  165. return unwrapElement(ytPlayer);
  166. }
  167.  
  168.  
  169. // --------------------
  170.  
  171.  
  172. // Get video ID from the currently loaded video (which might be different than currently loaded page)
  173. function getVideoIDFromURL(ytPlayer)
  174. {
  175. const idMatch = /(?:v=)([\w\-]+)/;
  176. let id = "ERROR: idMatch failed; youtube changed something";
  177. let matches = idMatch.exec(ytPlayer.getVideoUrl());
  178. if (matches)
  179. {
  180. id = matches[1];
  181. }
  182.  
  183. return id;
  184. }
  185.  
  186.  
  187. // --------------------
  188.  
  189.  
  190. // Attempt to set the video resolution to desired quality or the next best quality
  191. function setResolution(ytPlayer, resolutionList)
  192. {
  193. debugLog("Setting Resolution...");
  194.  
  195. const currentQuality = ytPlayer.getPlaybackQuality();
  196. let res = settings.targetRes;
  197.  
  198. if (settings.highFramerateTargetRes && foundHFR)
  199. {
  200. res = settings.highFramerateTargetRes;
  201. }
  202.  
  203. let shouldPremium = settings.preferPremium && [...ytPlayer.getAvailableQualityData()].some(q => q.quality == res && q.qualityLabel.includes("Premium") && q.isPlayable);
  204. let useButtons = !settings.useAPI || shouldPremium;
  205.  
  206. // Youtube doesn't return "auto" for auto, so set to make sure that auto is not set by setting
  207. // even when already at target res or above, but do so without removing the buffer for this quality
  208. if (resolutionList.indexOf(res) < resolutionList.indexOf(currentQuality))
  209. {
  210. const end = resolutionList.length - 1;
  211. let nextBestIndex = Math.max(resolutionList.indexOf(res), 0);
  212. let ytResolutions = ytPlayer.getAvailableQualityLevels();
  213. debugLog("Available Resolutions: " + ytResolutions.join(", "));
  214.  
  215. while ( (ytResolutions.indexOf(resolutionList[nextBestIndex]) === -1) && nextBestIndex < end )
  216. {
  217. ++nextBestIndex;
  218. }
  219.  
  220. if (!useButtons && settings.flushBuffer && currentQuality !== resolutionList[nextBestIndex])
  221. {
  222. let id = getVideoIDFromURL(ytPlayer);
  223. if (id.indexOf("ERROR") === -1)
  224. {
  225. let pos = ytPlayer.getCurrentTime();
  226. ytPlayer.loadVideoById(id, pos, resolutionList[nextBestIndex]);
  227. }
  228.  
  229. debugLog("ID: " + id);
  230. }
  231.  
  232. res = resolutionList[nextBestIndex];
  233. }
  234.  
  235. if (settings.useAPI)
  236. {
  237. if (ytPlayer.setPlaybackQualityRange !== undefined)
  238. {
  239. ytPlayer.setPlaybackQualityRange(res);
  240. }
  241. ytPlayer.setPlaybackQuality(res);
  242. debugLog("(API) Resolution Set To: " + res);
  243. }
  244. if (useButtons)
  245. {
  246. let resLabel = heights[resolutionList.indexOf(res)];
  247. if (shouldPremium)
  248. {
  249. resLabel = [...ytPlayer.getAvailableQualityData()].find(q => q.quality == res && q.qualityLabel.includes("Premium")).qualityLabel;
  250. }
  251.  
  252. let settingsButton = doc.querySelector(".ytp-settings-button:not(#ScaleBtn)")[0];
  253. unwrapElement(settingsButton).click();
  254.  
  255. let qualityMenuButton = document.evaluate('.//*[contains(text(),"Quality")]/ancestor-or-self::*[@class="ytp-menuitem-label"]', ytPlayer, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  256. unwrapElement(qualityMenuButton).click();
  257.  
  258. let qualityButton = document.evaluate('.//*[contains(text(),"' + heights[resolutionList.indexOf(res)] + '") and not(@class)]/ancestor::*[@class="ytp-menuitem"]', ytPlayer, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  259. unwrapElement(qualityButton).click();
  260. debugLog("(Buttons) Resolution Set To: " + res);
  261. }
  262. }
  263.  
  264.  
  265. // --------------------
  266.  
  267.  
  268. // Set resolution, but only when API is ready (it should normally already be ready)
  269. function setResOnReady(ytPlayer, resolutionList)
  270. {
  271. if (settings.useAPI && (ytPlayer.getPlaybackQuality === undefined || ytPlayer.getPlaybackQuality() == "unknown"))
  272. {
  273. win.setTimeout(setResOnReady, 100, ytPlayer, resolutionList);
  274. }
  275. else
  276. {
  277. let framerateUpdate = false;
  278. if (settings.highFramerateTargetRes)
  279. {
  280. let features = ytPlayer.getVideoData().video_quality_features;
  281. if (features)
  282. {
  283. let isHFR = features.includes("hfr");
  284. framerateUpdate = isHFR && !foundHFR;
  285. foundHFR = isHFR;
  286. }
  287. }
  288.  
  289. let curVid = getVideoIDFromURL(ytPlayer);
  290. if ((curVid !== recentVideo) || framerateUpdate)
  291. {
  292. recentVideo = curVid;
  293. setResolution(ytPlayer, resolutionList);
  294.  
  295. let storedQuality = localStorage.getItem("yt-player-quality");
  296. if (!storedQuality || storedQuality.indexOf(settings.targetRes) === -1)
  297. {
  298. let tc = Date.now(), te = tc + 2592000000;
  299. localStorage.setItem("yt-player-quality","{\"data\":\"" + settings.targetRes + "\",\"expiration\":" + te + ",\"creation\":" + tc + "}");
  300. }
  301. }
  302. }
  303. }
  304.  
  305.  
  306. // --------------------
  307.  
  308.  
  309. function setTheaterMode(ytPlayer)
  310. {
  311. debugLog("Setting Theater Mode");
  312.  
  313. if (win.location.href.indexOf("/watch") !== -1)
  314. {
  315. let pageManager = unwrapElement(doc.getElementsByTagName("ytd-watch-flexy")[0]);
  316.  
  317. if (pageManager && !pageManager.hasAttribute("theater"))
  318. {
  319. if (settings.enableErrorScreenWorkaround)
  320. {
  321. const styleContent = "#error-screen { z-index: 42 !important } .ytp-error { display: none !important }";
  322.  
  323. let errorStyle = doc.getElementById("ythdErrorWorkaroundStyleSheet");
  324. if (!errorStyle)
  325. {
  326. errorStyle = doc.createElement("style");
  327. errorStyle.type = "text/css";
  328. errorStyle.id = "ythdStyleSheet";
  329. errorStyle.textContent = styleContent;
  330. doc.head.appendChild(errorStyle);
  331. }
  332. else
  333. {
  334. errorStyle.textContent = styleContent;
  335. }
  336. }
  337.  
  338. try
  339. {
  340. pageManager.setTheaterModeRequested(true);
  341. pageManager.updateTheaterModeState_(true);
  342. pageManager.onTheaterReduxValueUpdate(true);
  343. pageManager.setPlayerTheaterMode_();
  344. pageManager.dispatchEvent(new CustomEvent("yt-set-theater-mode-enabled", { detail: {enabled: true}, bubbles: true, cancelable: false} ));
  345. }
  346. catch {}
  347.  
  348. let theaterButton;
  349. for (let i = 0; i < 3 && !pageManager.theaterValue; ++i)
  350. {
  351. debugLog("Clicking theater button to attempt to notify redux state");
  352. let theaterButton = theaterButton || unwrapElement(doc.getElementsByClassName("ytp-size-button")[0]);
  353. theaterButton.click();
  354. }
  355. }
  356. }
  357. }
  358.  
  359.  
  360. // --------------------
  361.  
  362.  
  363. function computeAndSetPlayerSize()
  364. {
  365. let height = settings.customHeight;
  366. if (!settings.useCustomSize)
  367. {
  368. // don't include youtube search bar as part of the space the video can try to fit in
  369. let heightOffsetEl = doc.getElementById("masthead");
  370. let mastheadContainerEl = doc.getElementById("masthead-container");
  371. let mastheadHeight = 50, mastheadPadding = 16;
  372. if (heightOffsetEl && mastheadContainerEl)
  373. {
  374. mastheadHeight = parseInt(win.getComputedStyle(heightOffsetEl).height, 10);
  375. mastheadPadding = parseInt(win.getComputedStyle(mastheadContainerEl).paddingBottom, 10) * 2;
  376. }
  377.  
  378. let i = Math.max(resolutions.indexOf(settings.targetRes), 0);
  379. height = Math.min(heights[i], win.innerHeight - (mastheadHeight + mastheadPadding));
  380. height = Math.max(height, 270);
  381.  
  382. if (settings.removeBlackBars)
  383. {
  384. let ytPlayer = getPlayer();
  385. if (ytPlayer !== undefined && ytPlayer.getVideoAspectRatio !== undefined)
  386. {
  387. height = Math.min(height, win.innerWidth / ytPlayer.getVideoAspectRatio());
  388. }
  389. }
  390. }
  391.  
  392. resizePlayer(height);
  393. }
  394.  
  395.  
  396. // --------------------
  397.  
  398.  
  399. // resize the player
  400. function resizePlayer(height)
  401. {
  402. debugLog("Setting video player size to " + height);
  403.  
  404. if (setHeight === height)
  405. {
  406. debugLog("Player size already set");
  407. return;
  408. }
  409.  
  410. let styleContent = "\
  411. ytd-watch-flexy[theater]:not([fullscreen]) #player-theater-container.style-scope, \
  412. ytd-watch-flexy[theater]:not([fullscreen]) #player-wide-container.style-scope, \
  413. ytd-watch-flexy[theater]:not([fullscreen]) #full-bleed-container.style-scope { \
  414. min-height: " + height + "px !important; max-height: none !important; height: " + height + "px !important }";
  415.  
  416. let ythdStyle = doc.getElementById("ythdStyleSheet");
  417. if (!ythdStyle)
  418. {
  419. ythdStyle = doc.createElement("style");
  420. ythdStyle.type = "text/css";
  421. ythdStyle.id = "ythdStyleSheet";
  422. ythdStyle.textContent = styleContent;
  423. doc.head.appendChild(ythdStyle);
  424. }
  425. else
  426. {
  427. ythdStyle.textContent = styleContent;
  428. }
  429.  
  430. setHeight = height;
  431.  
  432. win.dispatchEvent(new Event("resize"));
  433. }
  434.  
  435.  
  436. // --- MAIN -----------
  437.  
  438.  
  439. function main()
  440. {
  441. let ytPlayer = getPlayer();
  442.  
  443. if (settings.autoTheater && ytPlayer)
  444. {
  445. if (settings.allowCookies && doc.cookie.indexOf("wide=1") === -1)
  446. {
  447. doc.cookie = "wide=1; domain=.youtube.com";
  448. }
  449.  
  450. setTheaterMode(ytPlayer);
  451. }
  452.  
  453. if (settings.changePlayerSize && win.location.host.indexOf("youtube.com") !== -1 && win.location.host.indexOf("gaming.") === -1)
  454. {
  455. computeAndSetPlayerSize();
  456. window.addEventListener("resize", computeAndSetPlayerSize, true);
  457. }
  458.  
  459. if (settings.changeResolution && settings.setResolutionEarly && ytPlayer)
  460. {
  461. setResOnReady(ytPlayer, resolutions);
  462. }
  463.  
  464. if (settings.changeResolution || settings.autoTheater)
  465. {
  466. win.addEventListener("loadstart", function(e) {
  467. if (!(e.target instanceof win.HTMLMediaElement))
  468. {
  469. return;
  470. }
  471.  
  472. ytPlayer = getPlayer();
  473. if (ytPlayer)
  474. {
  475. debugLog("Loaded new video");
  476. if (settings.changeResolution)
  477. {
  478. setResOnReady(ytPlayer, resolutions);
  479. }
  480. if (settings.autoTheater)
  481. {
  482. setTheaterMode(ytPlayer);
  483. }
  484. }
  485. }, true );
  486. }
  487.  
  488. // This will eventually be changed to use the "once" option, but I want to keep a large range of browser support.
  489. win.removeEventListener("yt-navigate-finish", main, true);
  490. }
  491.  
  492. async function applySettings()
  493. {
  494. if (typeof GM != 'undefined' && GM.getValue && GM.setValue)
  495. {
  496. let settingsSaved = await GM.getValue("SettingsSaved");
  497.  
  498. if (settings.overwriteStoredSettings || !settingsSaved)
  499. {
  500. Object.entries(settings).forEach(([k,v]) => GM.setValue(k, v));
  501.  
  502. await GM.setValue("SettingsSaved", true);
  503. }
  504. else
  505. {
  506. await Promise.all(
  507. Object.keys(settings).map(k => { let newval = GM.getValue(k); return newval.then(v => [k,v]); })
  508. ).then((c) => c.forEach(([nk,nv]) => {
  509. if (settings[nk] !== null && nk !== "overwriteStoredSettings")
  510. {
  511. settings[nk] = nv;
  512. }
  513. }));
  514. }
  515.  
  516. debugLog(Object.entries(settings).map(([k,v]) => k + " | " + v).join(", "));
  517. }
  518. }
  519.  
  520. applySettings().then(() => {
  521. main();
  522. // Youtube doesn't load the page immediately in new version so you can watch before waiting for page load
  523. // But we can only set resolution until the page finishes loading
  524. win.addEventListener("yt-navigate-finish", main, true);
  525. });
  526. })();