Greasy Fork is available in English.

YouTube Theater Plus

Enhances YouTube Theater with features like Fullpage Theater, Auto Open Theater, and more, including support for the new UI.

  1. // ==UserScript==
  2. // @name YouTube Theater Plus
  3. // @version 2.2.7
  4. // @description Enhances YouTube Theater with features like Fullpage Theater, Auto Open Theater, and more, including support for the new UI.
  5. // @run-at document-body
  6. // @inject-into content
  7. // @match https://www.youtube.com/*
  8. // @exclude https://*.youtube.com/live_chat*
  9. // @exclude https://*.youtube.com/embed*
  10. // @exclude https://*.youtube.com/tv*
  11. // @exclude https:/tv.youtube.com/*
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @author Fznhq
  16. // @namespace https://github.com/fznhq
  17. // @homepageURL https://github.com/fznhq/userscript-collection
  18. // @license GNU GPLv3
  19. // ==/UserScript==
  20.  
  21. // Icons provided by https://iconmonstr.com/
  22.  
  23. (async function () {
  24. "use strict";
  25.  
  26. const body = document.body;
  27.  
  28. let theater = false;
  29. let fullpage = true;
  30.  
  31. /**
  32. * Options must be changed via popup menu,
  33. * just press (v) to open the menu
  34. */
  35. const options = {
  36. fullpage_theater: {
  37. icon: `{"path":{"d":"M22 4v12H2V4zm1-1H1v14h22zm-6 17H7v1h10z"}}`,
  38. label: "Fullpage Theater;", // Remove ";" and change the label to customize your own label.
  39. value: true,
  40. onUpdate() {
  41. applyTheaterMode(true);
  42. },
  43. },
  44. auto_theater_mode: {
  45. icon: `{"svg":{"fill-rule":"evenodd","clip-rule":"evenodd"},"path":{"d":"M24 22H0V2h24zm-7-1V6H1v15zm1 0h5V3H1v2h17zm-6-6h-1v-3l-7 7-1-1 7-7H7v-1h5z"}}`,
  46. label: "Auto Open Theater;", // Remove ";" and change the label to customize your own label.
  47. value: false,
  48. onUpdate() {
  49. if (this.value && !theater) toggleTheater();
  50. },
  51. },
  52. hide_scrollbar: {
  53. icon: `{"path":{"d":"M14 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0m-3-4h2V6h4l-5-6-5 6h4zm2 8h-2v2H7l5 6 5-6h-4z"}}`,
  54. label: "Theater Hide Scrollbar;", // Remove ";" and change the label to customize your own label.
  55. value: true,
  56. onUpdate() {
  57. if (theater) {
  58. setHtmlAttr(attr.no_scroll, this.value);
  59. resizeWindow();
  60. }
  61. },
  62. },
  63. close_theater_with_esc: {
  64. icon: `{"svg":{"clip-rule":"evenodd","fill-rule":"evenodd","stroke-linejoin":"round","stroke-miterlimit":2},"path":{"d":"M21 3.998c0-.478-.379-1-1-1H5c-.62 0-1 .519-1 1v15c0 .621.52 1 1 1h15c.478 0 1-.379 1-1zm-16 0h15v15H5zm7.491 6.432 2.717-2.718a.75.75 0 0 1 1.061 1.062l-2.717 2.717 2.728 2.728a.75.75 0 1 1-1.061 1.062l-2.728-2.728-2.728 2.728a.751.751 0 0 1-1.061-1.062l2.728-2.728-2.722-2.722a.75.75 0 0 1 1.061-1.061z","fill-rule":"nonzero"}}`,
  65. label: "Close Theater With Esc;", // Remove ";" and change the label to customize your own label.
  66. value: true,
  67. },
  68. hide_cards: {
  69. icon: `{"path":{"d":"M22 6v16H6V6zm1-1H5v18h18zM2 2v20h1V3h18V2zm12 9c-3 0-5 3-5 3s2 3 5 3 5-3 5-3-2-3-5-3m0 5a2 2 0 1 1 0-4 2 2 0 0 1 0 4m1-2a1 1 0 1 1-2 0 1 1 0 0 0 1-1l1 1"}}`,
  70. label: "Hide Cards;", // Remove ";" and change the label to customize your own label.
  71. value: true,
  72. onUpdate() {
  73. setHtmlAttr(attr.hide_card, this.value);
  74. },
  75. },
  76. show_header_near: {
  77. icon: `{"path":{"d":"M5 4.27 15.476 13H8.934L5 18.117zm-1 0v17l5.5-7h9L4 1.77z"}}`,
  78. label: "Show Header When Mouse is Near;", // Remove ";" and change the label to customize your own label.
  79. value: false,
  80. },
  81. };
  82.  
  83. function resizeWindow() {
  84. document.dispatchEvent(new Event("resize", { bubbles: true }));
  85. }
  86.  
  87. /**
  88. * @param {string} name
  89. * @param {boolean} value
  90. * @returns {boolean}
  91. */
  92. function saveOption(name, value) {
  93. GM.setValue(name, value);
  94. return (options[name].value = value);
  95. }
  96.  
  97. /**
  98. * @param {string} name
  99. * @param {object} attributes
  100. * @param {Array} append
  101. * @returns {SVGElement}
  102. */
  103. function createNS(name, attributes = {}, append = []) {
  104. const el = document.createElementNS("http://www.w3.org/2000/svg", name);
  105. for (const k in attributes) el.setAttributeNS(null, k, attributes[k]);
  106. return el.append(...append), el;
  107. }
  108.  
  109. for (const name in options) {
  110. const saved_option = await GM.getValue(name);
  111. const saved_label = await GM.getValue("label_" + name);
  112. const icon = JSON.parse(options[name].icon);
  113. let label = options[name].label;
  114.  
  115. if (saved_option === undefined) {
  116. saveOption(name, options[name].value);
  117. } else {
  118. options[name].value = saved_option;
  119. }
  120.  
  121. if (!label.endsWith(";")) {
  122. GM.setValue("label_" + name, label);
  123. } else if (saved_label !== undefined) {
  124. label = saved_label;
  125. }
  126.  
  127. options[name].label = label.replace(/;$/, "");
  128. options[name].icon = createNS("svg", icon.svg, [
  129. createNS("path", icon.path),
  130. ]);
  131. }
  132.  
  133. /**
  134. * @param {string} className
  135. * @param {Array} append
  136. * @returns {HTMLDivElement}
  137. */
  138. function createDiv(className, append = []) {
  139. const el = document.createElement("div");
  140. el.className = "ytp-menuitem" + (className ? "-" + className : "");
  141. return el.append(...append), el;
  142. }
  143.  
  144. const popup = {
  145. show: false,
  146. menu: (() => {
  147. const menu = createDiv(" ytc-menu ytp-panel-menu");
  148. const container = createDiv(" ytc-popup-container", [menu]);
  149.  
  150. for (const name in options) {
  151. const option = options[name];
  152. const item = createDiv("", [
  153. createDiv("icon", [option.icon]),
  154. createDiv("label", [option.label]),
  155. createDiv("content", [createDiv("toggle-checkbox")]),
  156. ]);
  157.  
  158. menu.append(item);
  159. item.setAttribute("aria-checked", option.value);
  160. item.addEventListener("click", () => {
  161. const checked = saveOption(name, !option.value);
  162. item.setAttribute("aria-checked", checked);
  163. if (option.onUpdate) option.onUpdate();
  164. });
  165. }
  166.  
  167. window.addEventListener("click", (ev) => {
  168. if (popup.show && !menu.contains(ev.target)) {
  169. popup.show = !!container.remove();
  170. }
  171. });
  172.  
  173. return container;
  174. })(),
  175. };
  176.  
  177. window.addEventListener("keydown", (ev) => {
  178. const isPressV = ev.key.toLowerCase() == "v" || ev.code == "KeyV";
  179.  
  180. if (
  181. (isPressV && !ev.ctrlKey && !isActiveEditable()) ||
  182. (ev.code == "Escape" && popup.show)
  183. ) {
  184. popup.show = popup.show
  185. ? !!popup.menu.remove()
  186. : !body.append(popup.menu);
  187. }
  188. });
  189.  
  190. /**
  191. * @param {string} query
  192. * @returns {() => HTMLElement | null}
  193. */
  194. function $(query) {
  195. let element = null;
  196. return () => element || (element = document.querySelector(query));
  197. }
  198.  
  199. const style = document.head.appendChild(document.createElement("style"));
  200. style.textContent = /*css*/ `
  201. html[no-scroll],
  202. html[no-scroll] body {
  203. scrollbar-width: none !important;
  204. }
  205.  
  206. html[no-scroll]::-webkit-scrollbar,
  207. html[no-scroll] body::-webkit-scrollbar,
  208. html[hide-card] ytd-player .ytp-paid-content-overlay,
  209. html[hide-card] ytd-player .iv-branding,
  210. html[hide-card] ytd-player .ytp-ce-element,
  211. html[hide-card] ytd-player .ytp-chrome-top,
  212. html[hide-card] ytd-player .ytp-suggested-action {
  213. display: none !important;
  214. }
  215.  
  216. html[theater][masthead-hidden] #masthead-container {
  217. transform: translateY(-100%) !important;
  218. }
  219.  
  220. html[theater][masthead-hidden] [fixed-panels] #chat {
  221. top: 0 !important;
  222. }
  223.  
  224. html[theater] #page-manager {
  225. margin: 0 !important;
  226. }
  227.  
  228. html[theater] #content #page-manager ytd-watch-flexy #full-bleed-container,
  229. html[theater] #content #page-manager ytd-watch-grid #player-full-bleed-container {
  230. height: 100vh;
  231. min-height: auto;
  232. max-height: none;
  233. }
  234.  
  235. .ytc-popup-container {
  236. position: fixed;
  237. inset: 0;
  238. z-index: 9000;
  239. background: rgba(0, 0, 0, .5);
  240. display: flex;
  241. align-items: center;
  242. justify-content: center;
  243. }
  244.  
  245. .ytc-menu.ytp-panel-menu {
  246. background: #000;
  247. width: 400px;
  248. font-size: 120%;
  249. padding: 10px;
  250. fill: #eee;
  251. }
  252. `;
  253.  
  254. const prefix = "yttp_";
  255. const attrId = "-" + Date.now().toString(36);
  256. const attr = {
  257. video_id: "video-id",
  258. role: "role",
  259. theater: "theater",
  260. fullscreen: "fullscreen",
  261. hidden_header: "masthead-hidden",
  262. no_scroll: "no-scroll",
  263. hide_card: "hide-card",
  264. trigger: prefix + "trigger" + attrId, // Internal only
  265. };
  266.  
  267. for (const key in attr) {
  268. style.textContent = style.textContent.replaceAll(
  269. "[" + attr[key] + "]",
  270. "[" + prefix + attr[key] + attrId + "]"
  271. );
  272. }
  273.  
  274. const element = {
  275. watch: $("ytd-watch-flexy, ytd-watch-grid"), // ytd-watch-grid == trash
  276. search: $("form[action*=result] input"),
  277. };
  278.  
  279. const keyToggleTheater = new KeyboardEvent("keydown", {
  280. key: "t",
  281. code: "KeyT",
  282. which: 84,
  283. keyCode: 84,
  284. bubbles: true,
  285. cancelable: true,
  286. });
  287.  
  288. /**
  289. * @param {string} attr
  290. * @param {boolean} state
  291. */
  292. function setHtmlAttr(attr, state) {
  293. document.documentElement.toggleAttribute(prefix + attr + attrId, state);
  294. }
  295.  
  296. /**
  297. * @param {MutationCallback} callback
  298. * @param {Node} target
  299. * @param {MutationObserverInit | undefined} options
  300. */
  301. function observer(callback, target, options) {
  302. const mutation = new MutationObserver(callback);
  303. mutation.observe(target, options || { subtree: true, childList: true });
  304. }
  305.  
  306. /**
  307. * @returns {boolean}
  308. */
  309. function isTheater() {
  310. const watch = element.watch();
  311. return (
  312. watch.getAttribute(attr.role) == "main" &&
  313. watch.hasAttribute(attr.theater) &&
  314. !watch.hasAttribute(attr.fullscreen)
  315. );
  316. }
  317.  
  318. /**
  319. * @returns {boolean}
  320. */
  321. function isActiveEditable() {
  322. /** @type {HTMLElement} */
  323. const active = document.activeElement;
  324. return (
  325. active.tagName == "TEXTAREA" ||
  326. active.tagName == "INPUT" ||
  327. active.isContentEditable
  328. );
  329. }
  330.  
  331. /**
  332. * @param {boolean} state
  333. * @param {number} timeout
  334. * @returns {number | boolean}
  335. */
  336. function toggleHeader(state, timeout) {
  337. const toggle = () => {
  338. if (state || document.activeElement != element.search()) {
  339. const scroll =
  340. !options.show_header_near.value && window.scrollY;
  341. setHtmlAttr(attr.hidden_header, !(state || scroll));
  342. }
  343. };
  344. return fullpage && setTimeout(toggle, timeout || 1);
  345. }
  346.  
  347. let showHeaderTimerId = 0;
  348.  
  349. /**
  350. * @param {MouseEvent} ev
  351. */
  352. function mouseShowHeader(ev) {
  353. if (options.show_header_near.value && fullpage) {
  354. const state = !popup.show && ev.clientY < 200;
  355. if (state) {
  356. clearTimeout(showHeaderTimerId);
  357. showHeaderTimerId = toggleHeader(false, 1500);
  358. }
  359. toggleHeader(state);
  360. }
  361. }
  362.  
  363. function toggleTheater() {
  364. document.dispatchEvent(keyToggleTheater);
  365. }
  366.  
  367. /**
  368. * @param {KeyboardEvent} ev
  369. */
  370. function onEscapePress(ev) {
  371. if (ev.code != "Escape" || !theater || popup.show) return;
  372.  
  373. if (options.close_theater_with_esc.value) {
  374. toggleTheater();
  375. } else {
  376. const input = element.search();
  377. if (document.activeElement != input) input.focus();
  378. else input.blur();
  379. }
  380. }
  381.  
  382. function registerEventListener() {
  383. window.addEventListener("mousemove", mouseShowHeader);
  384. window.addEventListener("keydown", onEscapePress, true);
  385. window.addEventListener("scroll", () => {
  386. if (!options.show_header_near.value) toggleHeader();
  387. });
  388. element.search().addEventListener("focus", () => toggleHeader(true));
  389. element.search().addEventListener("blur", () => toggleHeader(false));
  390. }
  391.  
  392. /**
  393. * @param {true | undefined} force
  394. */
  395. function applyTheaterMode(force) {
  396. const state = isTheater();
  397.  
  398. if (theater == state && (!state || !force)) return;
  399. theater = state;
  400. fullpage = theater && options.fullpage_theater.value;
  401.  
  402. setHtmlAttr(attr.theater, fullpage);
  403. setHtmlAttr(attr.hidden_header, fullpage);
  404. setHtmlAttr(attr.no_scroll, theater && options.hide_scrollbar.value);
  405. setHtmlAttr(attr.hide_card, options.hide_cards.value);
  406. resizeWindow();
  407. }
  408.  
  409. /**
  410. * @param {MutationRecord[]} mutations
  411. */
  412. function autoOpenTheater(mutations) {
  413. const attrs = [attr.role, attr.video_id, attr.trigger];
  414. const watch = element.watch();
  415.  
  416. if (
  417. !theater &&
  418. options.auto_theater_mode.value &&
  419. watch.getAttribute(attr.video_id) &&
  420. !watch.hasAttribute(attr.fullscreen) &&
  421. mutations.some((m) => attrs.includes(m.attributeName))
  422. ) {
  423. setTimeout(toggleTheater, 1);
  424. }
  425. }
  426.  
  427. observer((_, observe) => {
  428. const watch = element.watch();
  429. if (!watch) return;
  430.  
  431. observe.disconnect();
  432. observer(
  433. (mutations) => {
  434. applyTheaterMode();
  435. autoOpenTheater(mutations);
  436. },
  437. watch,
  438. { attributes: true }
  439. );
  440. watch.setAttribute(attr.trigger, "");
  441. registerEventListener();
  442. }, body);
  443. })();