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