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