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.9
  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. * @typedef {object} Option
  33. * @property {string} icon
  34. * @property {string} label
  35. * @property {any} value
  36. * @property {Function} onUpdate
  37. * @property {Option} sub
  38. */
  39.  
  40. /**
  41. * Options must be changed via popup menu,
  42. * just press (v) to open the menu
  43. */
  44. const subIcon = `{"svg":{"clip-rule":"evenodd","fill-rule":"evenodd","stroke-linejoin":"round","stroke-miterlimit":"2"},"path":{"d":"M10.211 7.155A.75.75 0 0 0 9 7.747v8.501a.75.75 0 0 0 1.212.591l5.498-4.258a.746.746 0 0 0-.001-1.183zm.289 7.563V9.272l3.522 2.719z","fill-rule":"nonzero"}}`;
  45. const options = {
  46. fullpage_theater: {
  47. icon: `{"path":{"d":"M22 4v12H2V4zm1-1H1v14h22zm-6 17H7v1h10z"}}`,
  48. label: "Fullpage Theater;", // Remove ";" to set your own label.
  49. value: true,
  50. onUpdate() {
  51. applyTheaterMode(true);
  52. },
  53. },
  54. auto_theater_mode: {
  55. 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"}}`,
  56. label: "Auto Open Theater;", // Remove ";" to set your own label.
  57. value: false,
  58. onUpdate() {
  59. if (this.value && !theater) toggleTheater();
  60. },
  61. },
  62. hide_scrollbar: {
  63. 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"}}`,
  64. label: "Theater Hide Scrollbar;", // Remove ";" to set your own label.
  65. value: true,
  66. onUpdate() {
  67. if (theater) {
  68. setHtmlAttr(attr.no_scroll, this.value);
  69. resizeWindow();
  70. }
  71. },
  72. },
  73. close_theater_with_esc: {
  74. 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"}}`,
  75. label: "Close Theater With Esc;", // Remove ";" to set your own label.
  76. value: true,
  77. },
  78. hide_cards: {
  79. 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"}}`,
  80. label: "Hide Cards;", // Remove ";" to set your own label.
  81. value: true,
  82. onUpdate() {
  83. setHtmlAttr(attr.hide_card, this.value);
  84. },
  85. },
  86. show_header_near: {
  87. icon: `{"path":{"d":"M5 4.27 15.476 13H8.934L5 18.117zm-1 0v17l5.5-7h9L4 1.77z"}}`,
  88. label: "Show Header When Mouse is Near;", // Remove ";" to set your own label.
  89. value: false,
  90. sub: {
  91. threshold: {
  92. label: "Threshold;", // Remove ";" to set your own label.
  93. value: 200,
  94. },
  95. },
  96. },
  97. };
  98.  
  99. function resizeWindow() {
  100. document.dispatchEvent(new Event("resize", { bubbles: true }));
  101. }
  102.  
  103. /**
  104. * @param {string} name
  105. * @param {object} attributes
  106. * @param {Array} append
  107. * @returns {SVGElement}
  108. */
  109. function createNS(name, attributes = {}, append = []) {
  110. const el = document.createElementNS("http://www.w3.org/2000/svg", name);
  111. for (const k in attributes) el.setAttributeNS(null, k, attributes[k]);
  112. return el.append(...append), el;
  113. }
  114.  
  115. /**
  116. * @param {string} name
  117. * @param {any} value
  118. * @param {Option} option
  119. * @returns {any}
  120. */
  121. function saveOption(name, value, option) {
  122. GM.setValue(name, value);
  123. return (option.value = value);
  124. }
  125.  
  126. /**
  127. * @param {string} name
  128. * @param {string} subName
  129. */
  130. async function loadOption(name, subName) {
  131. const key = subName ? `sub_${name}_${subName}` : name;
  132. const keyLabel = `label_${key}`;
  133. /** @type {Option} */
  134. const option = subName ? options[name].sub[subName] : options[name];
  135. const savedOption = await GM.getValue(key);
  136.  
  137. if (savedOption === undefined) {
  138. saveOption(key, option.value, option);
  139. } else {
  140. option.value = savedOption;
  141. }
  142.  
  143. const icon = JSON.parse(option.icon || subIcon);
  144. const savedLabel = await GM.getValue(keyLabel);
  145. let label = option.label;
  146.  
  147. if (!label.endsWith(";")) {
  148. GM.setValue(keyLabel, label);
  149. } else if (savedLabel !== undefined) {
  150. label = savedLabel;
  151. }
  152.  
  153. option.label = label.replace(/;$/, "");
  154. option.icon = createNS("svg", icon.svg, [createNS("path", icon.path)]);
  155. }
  156.  
  157. for (const name in options) {
  158. await loadOption(name);
  159. for (const subName in options[name].sub) {
  160. await loadOption(name, subName);
  161. }
  162. }
  163.  
  164. /**
  165. * @param {string} className
  166. * @param {Array} append
  167. * @returns {HTMLDivElement}
  168. */
  169. function createDiv(className, append = []) {
  170. const el = document.createElement("div");
  171. el.className = "ytp-menuitem" + (className ? "-" + className : "");
  172. return el.append(...append), el;
  173. }
  174.  
  175. /** @type {Map<HTMLElement, HTMLElement[]>} */
  176. const menuItems = new Map();
  177.  
  178. /**
  179. * @param {string} name
  180. * @param {Option} option
  181. * @returns {HTMLInputElement}
  182. */
  183. function itemInput(name, option) {
  184. const input = document.createElement("input");
  185. const setInput = (value) => (input.value = Number(value));
  186.  
  187. let waitTyping = 0;
  188. let prevValue = setInput(option.value);
  189.  
  190. input.addEventListener("input", (ev) => {
  191. const value = setInput(input.value.replace(/\D*/g, ""));
  192. clearInterval(waitTyping);
  193. waitTyping = setTimeout(() => {
  194. if (prevValue != value) saveOption(name, value, option);
  195. prevValue = value;
  196. }, 500);
  197. });
  198.  
  199. return input;
  200. }
  201.  
  202. /**
  203. * @param {HTMLElement} item
  204. * @param {boolean} checked
  205. */
  206. function toggleItemSub(item, checked) {
  207. for (const itemSub of menuItems.get(item)) {
  208. itemSub.style.display = checked ? "" : "none";
  209. }
  210. }
  211.  
  212. /**
  213. * @param {string} name
  214. * @param {Option} option
  215. * @returns {HTMLElement}
  216. */
  217. function createItem(name, option) {
  218. const checkbox = typeof option.value == "boolean";
  219. const isSub = name.includes("sub_");
  220. const icon = isSub ? [] : [option.icon];
  221. const label = isSub
  222. ? [createDiv("icon", [option.icon]), option.label]
  223. : [option.label];
  224. const content = checkbox
  225. ? [createDiv("toggle-checkbox")]
  226. : [itemInput(name, option)];
  227. const item = createDiv("", [
  228. createDiv("icon", icon),
  229. createDiv("label", label),
  230. createDiv("content", content),
  231. ]);
  232.  
  233. if (checkbox) {
  234. item.setAttribute("aria-checked", option.value);
  235. item.addEventListener("click", () => {
  236. const checked = saveOption(name, !option.value, option);
  237. item.setAttribute("aria-checked", checked);
  238. toggleItemSub(item, checked);
  239. if (option.onUpdate) option.onUpdate();
  240. });
  241. }
  242.  
  243. return item;
  244. }
  245.  
  246. const popup = {
  247. show: false,
  248. menu: (() => {
  249. const menu = createDiv(" ytc-menu ytp-panel-menu");
  250. const container = createDiv(" ytc-popup-container", [menu]);
  251.  
  252. for (const name in options) {
  253. const option = options[name];
  254. const item = createItem(name, option);
  255. menuItems.set(menu.appendChild(item), []);
  256.  
  257. for (const subName in option.sub) {
  258. const subOption = option.sub[subName];
  259. const sub = createItem(`sub_${name}_${subName}`, subOption);
  260. menuItems.get(item).push(menu.appendChild(sub));
  261. }
  262.  
  263. toggleItemSub(item, item.matches("[aria-checked='true']"));
  264. }
  265.  
  266. window.addEventListener("click", (ev) => {
  267. if (popup.show && !menu.contains(ev.target)) {
  268. popup.show = !!container.remove();
  269. }
  270. });
  271.  
  272. return container;
  273. })(),
  274. };
  275.  
  276. window.addEventListener("keydown", (ev) => {
  277. const isPressV = ev.key.toLowerCase() == "v" || ev.code == "KeyV";
  278.  
  279. if (
  280. (isPressV && !ev.ctrlKey && !isActiveEditable()) ||
  281. (ev.code == "Escape" && popup.show)
  282. ) {
  283. popup.show = popup.show
  284. ? !!popup.menu.remove()
  285. : !body.append(popup.menu);
  286. }
  287. });
  288.  
  289. /**
  290. * @param {string} query
  291. * @returns {() => HTMLElement | null}
  292. */
  293. function $(query) {
  294. let element = null;
  295. return () => element || (element = document.querySelector(query));
  296. }
  297.  
  298. const style = document.head.appendChild(document.createElement("style"));
  299. style.textContent = /*css*/ `
  300. html[no-scroll],
  301. html[no-scroll] body {
  302. scrollbar-width: none !important;
  303. }
  304.  
  305. html[no-scroll]::-webkit-scrollbar,
  306. html[no-scroll] body::-webkit-scrollbar,
  307. html[hide-card] ytd-player .ytp-paid-content-overlay,
  308. html[hide-card] ytd-player .iv-branding,
  309. html[hide-card] ytd-player .ytp-ce-element,
  310. html[hide-card] ytd-player .ytp-chrome-top,
  311. html[hide-card] ytd-player .ytp-suggested-action {
  312. display: none !important;
  313. }
  314.  
  315. html[theater][masthead-hidden] #masthead-container {
  316. transform: translateY(-100%) !important;
  317. }
  318.  
  319. html[theater][masthead-hidden] [fixed-panels] #chat {
  320. top: 0 !important;
  321. }
  322.  
  323. html[theater] #page-manager {
  324. margin: 0 !important;
  325. }
  326.  
  327. html[theater] #content #page-manager ytd-watch-flexy #full-bleed-container,
  328. html[theater] #content #page-manager ytd-watch-grid #player-full-bleed-container {
  329. height: 100vh;
  330. min-height: auto;
  331. max-height: none;
  332. }
  333.  
  334. .ytc-popup-container {
  335. position: fixed;
  336. inset: 0;
  337. z-index: 9000;
  338. background: rgba(0, 0, 0, .5);
  339. display: flex;
  340. align-items: center;
  341. justify-content: center;
  342. }
  343.  
  344. .ytc-menu.ytp-panel-menu {
  345. background: #000;
  346. width: 400px;
  347. font-size: 120%;
  348. padding: 10px;
  349. fill: #eee;
  350. }
  351.  
  352. .ytc-menu input {
  353. width: 36px;
  354. text-align: center;
  355. }
  356.  
  357. .ytc-menu .ytp-menuitem-label .ytp-menuitem-icon {
  358. display: inline-block;
  359. padding: 0 10px 0 0;
  360. margin-left: -10px;
  361. }
  362. `;
  363.  
  364. const prefix = "yttp_";
  365. const attrId = "-" + Date.now().toString(36);
  366. const attr = {
  367. video_id: "video-id",
  368. role: "role",
  369. theater: "theater",
  370. fullscreen: "fullscreen",
  371. hidden_header: "masthead-hidden",
  372. no_scroll: "no-scroll",
  373. hide_card: "hide-card",
  374. trigger: prefix + "trigger" + attrId, // Internal only
  375. };
  376.  
  377. for (const key in attr) {
  378. style.textContent = style.textContent.replaceAll(
  379. "[" + attr[key] + "]",
  380. "[" + prefix + attr[key] + attrId + "]"
  381. );
  382. }
  383.  
  384. const element = {
  385. watch: $("ytd-watch-flexy, ytd-watch-grid"), // ytd-watch-grid == trash
  386. search: $("form[action*=result] input"),
  387. };
  388.  
  389. const keyToggleTheater = new KeyboardEvent("keydown", {
  390. key: "t",
  391. code: "KeyT",
  392. which: 84,
  393. keyCode: 84,
  394. bubbles: true,
  395. cancelable: true,
  396. });
  397.  
  398. /**
  399. * @param {string} attr
  400. * @param {boolean} state
  401. */
  402. function setHtmlAttr(attr, state) {
  403. document.documentElement.toggleAttribute(prefix + attr + attrId, state);
  404. }
  405.  
  406. /**
  407. * @param {MutationCallback} callback
  408. * @param {Node} target
  409. * @param {MutationObserverInit | undefined} options
  410. */
  411. function observer(callback, target, options) {
  412. const mutation = new MutationObserver(callback);
  413. mutation.observe(target, options || { subtree: true, childList: true });
  414. }
  415.  
  416. /**
  417. * @returns {boolean}
  418. */
  419. function isTheater() {
  420. const watch = element.watch();
  421. return (
  422. watch.getAttribute(attr.role) == "main" &&
  423. watch.hasAttribute(attr.theater) &&
  424. !watch.hasAttribute(attr.fullscreen)
  425. );
  426. }
  427.  
  428. /**
  429. * @returns {boolean}
  430. */
  431. function isActiveEditable() {
  432. /** @type {HTMLElement} */
  433. const active = document.activeElement;
  434. return (
  435. active.tagName == "TEXTAREA" ||
  436. active.tagName == "INPUT" ||
  437. active.isContentEditable
  438. );
  439. }
  440.  
  441. /**
  442. * @param {boolean} state
  443. * @param {number} timeout
  444. * @returns {number | boolean}
  445. */
  446. function toggleHeader(state, timeout) {
  447. const toggle = () => {
  448. if (state || document.activeElement != element.search()) {
  449. const scroll =
  450. !options.show_header_near.value && window.scrollY;
  451. setHtmlAttr(attr.hidden_header, !(state || scroll));
  452. }
  453. };
  454. return fullpage && setTimeout(toggle, timeout || 1);
  455. }
  456.  
  457. let showHeaderTimerId = 0;
  458.  
  459. /**
  460. * @param {MouseEvent} ev
  461. */
  462. function mouseShowHeader(ev) {
  463. if (options.show_header_near.value && fullpage) {
  464. const threshold = options.show_header_near.sub.threshold.value;
  465. const state = !popup.show && ev.clientY < threshold;
  466. if (state) {
  467. clearTimeout(showHeaderTimerId);
  468. showHeaderTimerId = toggleHeader(false, 1500);
  469. }
  470. toggleHeader(state);
  471. }
  472. }
  473.  
  474. function toggleTheater() {
  475. document.dispatchEvent(keyToggleTheater);
  476. }
  477.  
  478. /**
  479. * @param {KeyboardEvent} ev
  480. */
  481. function onEscapePress(ev) {
  482. if (ev.code != "Escape" || !theater || popup.show) return;
  483.  
  484. if (options.close_theater_with_esc.value) {
  485. toggleTheater();
  486. } else {
  487. const input = element.search();
  488. if (document.activeElement != input) input.focus();
  489. else input.blur();
  490. }
  491. }
  492.  
  493. function registerEventListener() {
  494. window.addEventListener("mousemove", mouseShowHeader);
  495. window.addEventListener("keydown", onEscapePress, true);
  496. window.addEventListener("scroll", () => {
  497. if (!options.show_header_near.value) toggleHeader();
  498. });
  499. element.search().addEventListener("focus", () => toggleHeader(true));
  500. element.search().addEventListener("blur", () => toggleHeader(false));
  501. }
  502.  
  503. /**
  504. * @param {true | undefined} force
  505. */
  506. function applyTheaterMode(force) {
  507. const state = isTheater();
  508.  
  509. if (theater == state && (!state || !force)) return;
  510. theater = state;
  511. fullpage = theater && options.fullpage_theater.value;
  512.  
  513. setHtmlAttr(attr.theater, fullpage);
  514. setHtmlAttr(attr.hidden_header, fullpage);
  515. setHtmlAttr(attr.no_scroll, theater && options.hide_scrollbar.value);
  516. setHtmlAttr(attr.hide_card, options.hide_cards.value);
  517. resizeWindow();
  518. }
  519.  
  520. /**
  521. * @param {MutationRecord[]} mutations
  522. */
  523. function autoOpenTheater(mutations) {
  524. const attrs = [attr.role, attr.video_id, attr.trigger];
  525. const watch = element.watch();
  526.  
  527. if (
  528. !theater &&
  529. options.auto_theater_mode.value &&
  530. watch.getAttribute(attr.video_id) &&
  531. !watch.hasAttribute(attr.fullscreen) &&
  532. mutations.some((m) => attrs.includes(m.attributeName))
  533. ) {
  534. setTimeout(toggleTheater, 1);
  535. }
  536. }
  537.  
  538. observer((_, observe) => {
  539. const watch = element.watch();
  540. if (!watch) return;
  541.  
  542. observe.disconnect();
  543. observer(
  544. (mutations) => {
  545. applyTheaterMode();
  546. autoOpenTheater(mutations);
  547. },
  548. watch,
  549. { attributes: true }
  550. );
  551. watch.setAttribute(attr.trigger, "");
  552. registerEventListener();
  553. }, body);
  554. })();