YouTube Theater Plus

Make theater mode fill the entire page view with a hidden navbar and auto theater mode (Support new UI)

当前为 2025-02-26 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Theater Plus
  3. // @version 2.1.6
  4. // @description Make theater mode fill the entire page view with a hidden navbar and auto theater mode (Support 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. let theater = false;
  28.  
  29. /**
  30. * Options must be changed via popup menu,
  31. * just press (v) to open the menu
  32. */
  33. const options = {
  34. auto_theater_mode: {
  35. 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"}}`,
  36. label: "Auto Open Theater;", // Remove ";" and change the label to customize your own label.
  37. value: false,
  38. onUpdate() {
  39. if (this.value && !theater) toggleTheater();
  40. },
  41. },
  42. hide_scrollbar: {
  43. 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"}}`,
  44. label: "Theater Hide Scrollbar;", // Remove ";" and change the label to customize your own label.
  45. value: true,
  46. onUpdate() {
  47. if (theater) {
  48. setHtmlAttr(attr.no_scroll, this.value);
  49. resizeWindow();
  50. }
  51. },
  52. },
  53. close_theater_with_esc: {
  54. icon: `{"svg":{"clip-rule":"evenodd","fill-rule":"evenodd","stroke-linejoin":"round","stroke-miterlimit":2},"path":{"d":"M21 4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1zm-16.5.5h15v15h-15zm7.5 6.43 2.7-2.72a.75.75 0 0 1 1.07 1.06L13.05 12l2.73 2.73a.75.75 0 1 1-1.06 1.06l-2.73-2.73-2.73 2.73a.75.75 0 0 1-1.06-1.06L10.93 12 8.21 9.28A.75.75 0 0 1 9.27 8.2z","fill-rule":"nonzero"}}`,
  55. label: "Close Theater With Esc;", // Remove ";" and change the label to customize your own label.
  56. value: true,
  57. },
  58. hide_card: {
  59. icon: `{"path":{"d":"M22 6v16H6V6zm2-2H4v20h20zM0 0v20h2V2h18V0zm14 11.22c-3.15 0-5 2.6-5 2.6s2.02 2.95 5 2.95c3.23 0 5-2.95 5-2.95s-1.79-2.6-5-2.6m.05 4.72a1.94 1.94 0 1 1 0-3.88 1.94 1.94 0 0 1 0 3.88m1.1-1.94a1.1 1.1 0 1 1-2.2 0l.03-.21a.68.68 0 0 0 .87-.86l.2-.02c.6 0 1.1.49 1.1 1.09"}}`,
  60. label: "Hide Card Outside Theater Mode;", // Remove ";" and change the label to customize your own label.
  61. value: false,
  62. onUpdate() {
  63. if (!theater) setHtmlAttr(attr.hide_card, this.value);
  64. },
  65. },
  66. show_header_near: {
  67. icon: `{"path":{"d":"m5 4 10 9H9l-4 5zM3 0v24l7-9h11z"}}`,
  68. label: "Show Header When Mouse is Near;", // Remove ";" and change the label to customize your own label.
  69. value: false,
  70. },
  71. };
  72.  
  73. function resizeWindow() {
  74. document.dispatchEvent(new Event("resize", { bubbles: true }));
  75. }
  76.  
  77. /**
  78. * @param {string} name
  79. * @param {boolean} value
  80. * @returns {boolean}
  81. */
  82. function saveOption(name, value) {
  83. GM.setValue(name, value);
  84. return (options[name].value = value);
  85. }
  86.  
  87. /**
  88. * @param {string} name
  89. * @param {object} attributes
  90. * @param {Array} append
  91. * @returns {SVGElement}
  92. */
  93. function createNS(name, attributes = {}, append = []) {
  94. const el = document.createElementNS("http://www.w3.org/2000/svg", name);
  95. for (const k in attributes) el.setAttributeNS(null, k, attributes[k]);
  96. return el.append(...append), el;
  97. }
  98.  
  99. for (const name in options) {
  100. const saved_option = await GM.getValue(name);
  101. const saved_label = await GM.getValue("label_" + name);
  102. const icon = JSON.parse(options[name].icon);
  103. let label = options[name].label;
  104.  
  105. if (saved_option === undefined) {
  106. saveOption(name, options[name].value);
  107. } else {
  108. options[name].value = saved_option;
  109. }
  110.  
  111. if (!label.endsWith(";")) {
  112. GM.setValue("label_" + name, label);
  113. } else if (saved_label !== undefined) {
  114. label = saved_label;
  115. }
  116.  
  117. options[name].label = label.replace(/;$/, "");
  118. options[name].icon = createNS("svg", icon.svg, [
  119. createNS("path", icon.path),
  120. ]);
  121. }
  122.  
  123. /**
  124. * @param {string} className
  125. * @param {Array} append
  126. * @returns {HTMLDivElement}
  127. */
  128. function createDiv(className, append = []) {
  129. const el = document.createElement("div");
  130. el.className = "ytp-menuitem" + (className ? "-" + className : "");
  131. return el.append(...append), el;
  132. }
  133.  
  134. const popup = {
  135. show: false,
  136. menu: (() => {
  137. const menu = createDiv(" ytc-menu ytp-panel-menu");
  138. const container = createDiv(" ytc-popup-container", [menu]);
  139.  
  140. for (const name in options) {
  141. const option = options[name];
  142. const item = createDiv("", [
  143. createDiv("icon", [option.icon]),
  144. createDiv("label", [option.label]),
  145. createDiv("content", [createDiv("toggle-checkbox")]),
  146. ]);
  147.  
  148. menu.append(item);
  149. item.setAttribute("aria-checked", option.value);
  150. item.addEventListener("click", () => {
  151. const newValue = saveOption(name, !option.value);
  152. item.setAttribute("aria-checked", newValue);
  153. if (option.onUpdate) option.onUpdate();
  154. });
  155. }
  156.  
  157. window.addEventListener("click", (ev) => {
  158. if (popup.show && !menu.contains(ev.target)) {
  159. popup.show = !!container.remove();
  160. }
  161. });
  162.  
  163. return container;
  164. })(),
  165. };
  166.  
  167. window.addEventListener("keydown", (ev) => {
  168. const isPressV = ev.key.toLowerCase() == "v" || ev.code == "KeyV";
  169.  
  170. if (
  171. (isPressV && !ev.ctrlKey && !isActiveEditable()) ||
  172. (ev.code == "Escape" && popup.show)
  173. ) {
  174. popup.show = popup.show
  175. ? !!popup.menu.remove()
  176. : !body.append(popup.menu);
  177. }
  178. });
  179.  
  180. /**
  181. * @param {string} query
  182. * @param {boolean} cache
  183. * @returns {() => HTMLElement | null}
  184. */
  185. function $(query, cache = true) {
  186. let elem = null;
  187. return () => (cache && elem) || (elem = document.querySelector(query));
  188. }
  189.  
  190. const style = document.head.appendChild(document.createElement("style"));
  191. style.textContent = /*css*/ `
  192. html[no-scroll],
  193. html[no-scroll] body {
  194. scrollbar-width: none !important;
  195. }
  196.  
  197. html[no-scroll]::-webkit-scrollbar,
  198. html[no-scroll] body::-webkit-scrollbar,
  199. html[hide-card] ytd-player .ytp-paid-content-overlay,
  200. html[hide-card] ytd-player .iv-branding,
  201. html[hide-card] ytd-player .ytp-ce-element,
  202. html[hide-card] ytd-player .ytp-chrome-top,
  203. html[hide-card] ytd-player .ytp-suggested-action {
  204. display: none !important;
  205. }
  206.  
  207. html[theater][masthead-hidden] #masthead-container {
  208. transform: translateY(-100%) !important;
  209. }
  210.  
  211. html[theater][masthead-hidden] [fixed-panels] #chat {
  212. top: 0 !important;
  213. }
  214.  
  215. html[theater] #page-manager {
  216. margin: 0 !important;
  217. }
  218.  
  219. html[theater] #content #page-manager ytd-watch-flexy #full-bleed-container,
  220. html[theater] #content #page-manager ytd-watch-grid #player-full-bleed-container {
  221. height: 100vh;
  222. min-height: auto;
  223. max-height: none;
  224. }
  225.  
  226. .ytc-popup-container {
  227. position: fixed;
  228. inset: 0;
  229. z-index: 9000;
  230. background: rgba(0, 0, 0, .5);
  231. display: flex;
  232. align-items: center;
  233. justify-content: center;
  234. }
  235.  
  236. .ytc-menu.ytp-panel-menu {
  237. background: #000;
  238. width: 400px;
  239. font-size: 120%;
  240. padding: 10px;
  241. fill: #eee;
  242. }
  243. `;
  244.  
  245. const attrId = "-" + Date.now().toString(36);
  246. const attr = {
  247. video_id: "video-id",
  248. role: "role",
  249. theater: "theater",
  250. fullscreen: "fullscreen",
  251. hidden_header: "masthead-hidden",
  252. no_scroll: "no-scroll",
  253. hide_card: "hide-card",
  254. };
  255.  
  256. for (const key in attr) {
  257. style.textContent = style.textContent.replaceAll(
  258. "[" + attr[key] + "]",
  259. "[" + attr[key] + attrId + "]"
  260. );
  261. }
  262.  
  263. const element = {
  264. watch: $("ytd-watch-flexy, ytd-watch-grid"), // ytd-watch-grid == trash
  265. search: $("form[action*=result] input"),
  266. };
  267.  
  268. const keyToggleTheater = new KeyboardEvent("keydown", {
  269. key: "t",
  270. code: "KeyT",
  271. which: 84,
  272. keyCode: 84,
  273. bubbles: true,
  274. cancelable: true,
  275. });
  276.  
  277. /**
  278. * @param {string} attr
  279. * @param {boolean} state
  280. */
  281. function setHtmlAttr(attr, state) {
  282. document.documentElement.toggleAttribute(attr + attrId, state);
  283. }
  284.  
  285. /**
  286. * @param {MutationCallback} callback
  287. * @param {Node} target
  288. * @param {MutationObserverInit | undefined} options
  289. */
  290. function observer(callback, target, options) {
  291. const mutation = new MutationObserver(callback);
  292. mutation.observe(target, options || { subtree: true, childList: true });
  293. }
  294.  
  295. /**
  296. * @returns {boolean}
  297. */
  298. function isTheater() {
  299. const watch = element.watch();
  300. return (
  301. watch.getAttribute(attr.role) == "main" &&
  302. watch.hasAttribute(attr.theater) &&
  303. !watch.hasAttribute(attr.fullscreen)
  304. );
  305. }
  306.  
  307. /**
  308. * @returns {boolean}
  309. */
  310. function isActiveEditable() {
  311. /** @type {HTMLElement} */
  312. const active = document.activeElement;
  313. return (
  314. active.tagName == "TEXTAREA" ||
  315. active.tagName == "INPUT" ||
  316. active.isContentEditable
  317. );
  318. }
  319.  
  320. /**
  321. * @param {boolean} state
  322. * @param {number} timeout
  323. * @returns {number | boolean}
  324. */
  325. function toggleHeader(state, timeout) {
  326. const toggle = () => {
  327. if (state || document.activeElement != element.search()) {
  328. const scroll =
  329. !options.show_header_near.value && window.scrollY;
  330. setHtmlAttr(attr.hidden_header, !(state || scroll));
  331. }
  332. };
  333. return theater && setTimeout(toggle, timeout || 1);
  334. }
  335.  
  336. let showHeaderTimerId = 0;
  337.  
  338. /**
  339. * @param {MouseEvent} ev
  340. */
  341. function mouseShowHeader(ev) {
  342. if (options.show_header_near.value && theater) {
  343. const state = !popup.show && ev.clientY < 200;
  344. if (state) {
  345. clearTimeout(showHeaderTimerId);
  346. showHeaderTimerId = toggleHeader(false, 1500);
  347. }
  348. toggleHeader(state);
  349. }
  350. }
  351.  
  352. function toggleTheater() {
  353. document.dispatchEvent(keyToggleTheater);
  354. }
  355.  
  356. /**
  357. * @param {KeyboardEvent} ev
  358. */
  359. function onEscapePress(ev) {
  360. if (ev.code != "Escape" || !theater || popup.show) return;
  361.  
  362. if (options.close_theater_with_esc.value) {
  363. toggleTheater();
  364. } else {
  365. const input = element.search();
  366. if (document.activeElement != input) input.focus();
  367. else input.blur();
  368. }
  369. }
  370.  
  371. function registerEventListener() {
  372. window.addEventListener("mousemove", mouseShowHeader);
  373. window.addEventListener("keydown", onEscapePress, true);
  374. window.addEventListener("scroll", () => {
  375. if (!options.show_header_near.value) toggleHeader();
  376. });
  377. element.search().addEventListener("focus", () => toggleHeader(true));
  378. element.search().addEventListener("blur", () => toggleHeader(false));
  379. }
  380.  
  381. function applyTheaterMode() {
  382. const state = isTheater();
  383.  
  384. if (theater == state) return;
  385. theater = state;
  386.  
  387. setHtmlAttr(attr.theater, state);
  388. setHtmlAttr(attr.hidden_header, state);
  389. setHtmlAttr(attr.no_scroll, state && options.hide_scrollbar.value);
  390. setHtmlAttr(attr.hide_card, state || options.hide_card.value);
  391. }
  392.  
  393. /**
  394. * @param {MutationRecord[]} mutations
  395. */
  396. function autoOpenTheater(mutations) {
  397. const attrs = [attr.role, attr.video_id];
  398. const watch = element.watch();
  399.  
  400. if (
  401. !theater &&
  402. options.auto_theater_mode.value &&
  403. !watch.hasAttribute(attr.fullscreen) &&
  404. mutations.some((m) => attrs.includes(m.attributeName))
  405. ) {
  406. setTimeout(toggleTheater, 1);
  407. }
  408. }
  409.  
  410. observer((_, observe) => {
  411. const watch = element.watch();
  412. if (!watch) return;
  413.  
  414. observe.disconnect();
  415. observer(
  416. (mutations) => {
  417. applyTheaterMode();
  418. autoOpenTheater(mutations);
  419. },
  420. watch,
  421. { attributes: true }
  422. );
  423. registerEventListener();
  424. }, body);
  425. })();