Youtube Fullpage Theater

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

当前为 2024-09-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Youtube Fullpage Theater
  3. // @version 1.8.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. // @match https://www.youtube.com/*
  7. // @exclude https://*.youtube.com/live_chat*
  8. // @exclude https://*.youtube.com/embed*
  9. // @exclude https://*.youtube.com/tv*
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  11. // @grant GM.getValue
  12. // @grant GM.setValue
  13. // @grant unsafeWindow
  14. // @author Fznhq
  15. // @namespace https://github.com/fznhq
  16. // @homepageURL https://github.com/fznhq/userscript-collection
  17. // @license GNU GPLv3
  18. // ==/UserScript==
  19.  
  20. // Icons provided by https://iconmonstr.com/
  21.  
  22. (async function () {
  23. "use strict";
  24.  
  25. /** @type {Window} */
  26. const win = unsafeWindow;
  27. /** @type {HTMLHtmlElement} */
  28. const html = document.documentElement;
  29. /** @type {HTMLBodyElement} */
  30. const body = document.body;
  31.  
  32. let theater = false;
  33.  
  34. /**
  35. * @param {string} attributes
  36. * @returns {SVGSVGElement}
  37. */
  38. function makeIcon(attributes) {
  39. const create = (tagName) =>
  40. document.createElementNS("http://www.w3.org/2000/svg", tagName);
  41. let icon = create("svg");
  42.  
  43. for (const name in attributes) {
  44. const element = create(name);
  45.  
  46. for (const key in attributes[name]) {
  47. element.setAttribute(key, attributes[name][key]);
  48. }
  49.  
  50. if (name == "svg") icon = element;
  51. else icon.append(element);
  52. }
  53.  
  54. return icon;
  55. }
  56.  
  57. /**
  58. * Options must be changed via popup menu,
  59. * just press (v) to open the menu
  60. */
  61. const options = {
  62. auto_theater_mode: {
  63. icon: makeIcon({
  64. svg: { "fill-rule": "evenodd", "clip-rule": "evenodd" },
  65. path: {
  66. d: "M24 22h-24v-20h24v20zm-1-19h-22v18h22v-18zm-4 7h-1v-3.241l-11.241 11.241h3.241v1h-5v-5h1v3.241l11.241-11.241h-3.241v-1h5v5z",
  67. },
  68. }),
  69. label: "Auto Open Theater",
  70. value: false, //fallback
  71. onUpdate() {
  72. if (this.value && !theater) toggleTheater();
  73. },
  74. },
  75. hide_scrollbar: {
  76. icon: makeIcon({
  77. path: {
  78. d: "M14 12c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2zm-3-3.858c.321-.083.653-.142 1-.142s.679.059 1 .142v-2.142h4l-5-6-5 6h4v2.142zm2 7.716c-.321.083-.653.142-1 .142s-.679-.059-1-.142v2.142h-4l5 6 5-6h-4v-2.142z",
  79. },
  80. }),
  81. label: "Theater Hide Scrollbar",
  82. value: true, //fallback
  83. onUpdate() {
  84. if (theater) {
  85. html.toggleAttribute(attr.no_scroll, this.value);
  86. win.dispatchEvent(new Event("resize"));
  87. }
  88. },
  89. },
  90. close_theater_with_esc: {
  91. icon: makeIcon({
  92. svg: {
  93. "clip-rule": "evenodd",
  94. "fill-rule": "evenodd",
  95. "stroke-linejoin": "round",
  96. "stroke-miterlimit": 2,
  97. },
  98. path: {
  99. d: "m21 3.998c0-.478-.379-1-1-1h-16c-.62 0-1 .519-1 1v16c0 .621.52 1 1 1h16c.478 0 1-.379 1-1zm-16.5.5h15v15h-15zm7.491 6.432 2.717-2.718c.146-.146.338-.219.53-.219.404 0 .751.325.751.75 0 .193-.073.384-.22.531l-2.717 2.717 2.728 2.728c.147.147.22.339.22.531 0 .427-.349.75-.75.75-.192 0-.385-.073-.531-.219l-2.728-2.728-2.728 2.728c-.147.146-.339.219-.531.219-.401 0-.75-.323-.75-.75 0-.192.073-.384.22-.531l2.728-2.728-2.722-2.722c-.146-.147-.219-.338-.219-.531 0-.425.346-.749.75-.749.192 0 .384.073.53.219z",
  100. "fill-rule": "nonzero",
  101. },
  102. }),
  103. label: "Close Theater With Esc",
  104. value: true, //fallback
  105. },
  106. hide_card: {
  107. icon: makeIcon({
  108. path: {
  109. d: "M22 6v16H6V6h16zm2-2H4v20h20V4zM0 0v20h2V2h18V0H0zm14.007 11.225C10.853 11.225 9 13.822 9 13.822s2.015 2.953 5.007 2.953c3.222 0 4.993-2.953 4.993-2.953s-1.788-2.597-4.993-2.597zm.042 4.717a1.942 1.942 0 1 1 .002-3.884 1.942 1.942 0 0 1-.002 3.884zM15.141 14a1.092 1.092 0 1 1-2.184 0l.02-.211a.68.68 0 0 0 .875-.863l.197-.019c.603 0 1.092.489 1.092 1.093z",
  110. },
  111. }),
  112. label: "Hide Card Outside Theater Mode",
  113. value: false, //fallback
  114. onUpdate() {
  115. if (!theater) html.toggleAttribute(attr.hide_card, this.value);
  116. },
  117. },
  118. show_header_near: {
  119. icon: makeIcon({
  120. path: {
  121. d: "M5 4.27 15.476 13H8.934L5 18.117V4.27zM3 0v24l6.919-9H21L3 0z",
  122. },
  123. }),
  124. label: "Show Header When Mouse is Near",
  125. value: false, //fallback
  126. },
  127. };
  128.  
  129. /**
  130. * @param {string} name
  131. * @param {boolean} value
  132. * @returns {boolean}
  133. */
  134. function saveOption(name, value) {
  135. GM.setValue(name, value);
  136. return (options[name].value = value);
  137. }
  138.  
  139. for (const name in options) {
  140. const saved_option = await GM.getValue(name);
  141.  
  142. if (saved_option === undefined) {
  143. saveOption(name, options[name].value);
  144. } else {
  145. options[name].value = saved_option;
  146. }
  147. }
  148.  
  149. /**
  150. * @param {string} className
  151. * @param {Array} append
  152. * @returns {HTMLDivElement}
  153. */
  154. function createDiv(className, append = []) {
  155. const element = document.createElement("div");
  156. element.className = className;
  157. if (append.length) element.append(...append);
  158. return element;
  159. }
  160.  
  161. const popup = {
  162. show: false,
  163. menu: (() => {
  164. const menu = createDiv("ytc-menu ytp-panel-menu");
  165. const container = createDiv("ytc-popup-container", [menu]);
  166.  
  167. for (const name in options) {
  168. const option = options[name];
  169. const item = createDiv("ytp-menuitem", [
  170. createDiv("ytp-menuitem-icon", [option.icon]),
  171. createDiv("ytp-menuitem-label", [option.label]),
  172. createDiv("ytp-menuitem-content", [
  173. createDiv("ytp-menuitem-toggle-checkbox"),
  174. ]),
  175. ]);
  176.  
  177. menu.append(item);
  178. item.setAttribute("aria-checked", option.value);
  179. item.addEventListener("click", () => {
  180. const newValue = saveOption(name, !option.value);
  181. item.setAttribute("aria-checked", newValue);
  182. if (option.onUpdate) option.onUpdate();
  183. });
  184. }
  185.  
  186. win.addEventListener("click", (ev) => {
  187. if (popup.show && !menu.contains(ev.target)) {
  188. popup.show = !!container.remove();
  189. }
  190. });
  191.  
  192. return container;
  193. })(),
  194. };
  195.  
  196. win.addEventListener("keydown", (ev) => {
  197. const isV = ev.key.toLowerCase() == "v" || ev.code == "KeyV";
  198.  
  199. if (
  200. (isV && !ev.ctrlKey && !isActiveEditable()) ||
  201. (ev.code == "Escape" && popup.show)
  202. ) {
  203. popup.show = popup.show
  204. ? !!popup.menu.remove()
  205. : !body.append(popup.menu);
  206. }
  207. });
  208.  
  209. /**
  210. * @param {string} query
  211. * @returns {() => HTMLElement | null}
  212. */
  213. function $(query) {
  214. let cache;
  215. return () => cache || (cache = document.querySelector(query));
  216. }
  217.  
  218. /**
  219. * @param {string} css
  220. */
  221. function addStyle(css) {
  222. const style = document.createElement("style");
  223. style.textContent = css;
  224. document.head.append(style);
  225. }
  226.  
  227. let style = /*css*/ `
  228. html[no-scroll],
  229. html[no-scroll] body {
  230. scrollbar-width: none !important;
  231. }
  232.  
  233. html[no-scroll]::-webkit-scrollbar,
  234. html[no-scroll] body::-webkit-scrollbar {
  235. display: none !important;
  236. }
  237. html[masthead-hidden] ytd-watch-flexy[fixed-panels] #chat {
  238. top: 0 !important;
  239. }
  240.  
  241. html[hide-card] ytd-player .ytp-paid-content-overlay,
  242. html[hide-card] ytd-player .iv-branding,
  243. html[hide-card] ytd-player .ytp-ce-element,
  244. html[hide-card] ytd-player .ytp-chrome-top,
  245. html[hide-card] ytd-player .ytp-suggested-action {
  246. display: none !important;
  247. }
  248.  
  249. html[theater][masthead-hidden] #masthead-container {
  250. transform: translateY(-100%) !important;
  251. }
  252.  
  253. html[theater] #page-manager {
  254. margin: 0 !important;
  255. }
  256.  
  257. html[theater] #content #page-manager ytd-watch-flexy #full-bleed-container,
  258. html[theater] #content #page-manager ytd-watch-grid #player-full-bleed-container {
  259. height: 100vh;
  260. min-height: auto;
  261. max-height: none;
  262. }
  263.  
  264. .ytc-popup-container {
  265. position: fixed;
  266. inset: 0;
  267. z-index: 9000;
  268. background: rgba(0, 0, 0, .5);
  269. display: flex;
  270. align-items: center;
  271. justify-content: center;
  272. }
  273.  
  274. .ytc-menu.ytp-panel-menu {
  275. background: #000;
  276. width: 400px;
  277. font-size: 120%;
  278. padding: 10px;
  279. }
  280.  
  281. .ytc-menu svg {
  282. fill: #eee;
  283. }
  284. `;
  285.  
  286. const customAttr = {
  287. hidden_header: "masthead-hidden",
  288. no_scroll: "no-scroll",
  289. hide_card: "hide-card",
  290. };
  291.  
  292. for (const key in customAttr) {
  293. const oldAttr = new RegExp("\\[" + customAttr[key], "g");
  294. const uniqueKey = Math.random().toString(36).slice(2);
  295. customAttr[key] = customAttr[key] + `-${uniqueKey}`;
  296. style = style.replace(oldAttr, "[" + customAttr[key]);
  297. }
  298.  
  299. addStyle(style);
  300.  
  301. const attr = {
  302. video_id: "video-id",
  303. role: "role",
  304. theater: "theater",
  305. fullscreen: "fullscreen",
  306. ...customAttr,
  307. };
  308.  
  309. const element = {
  310. watch: $("ytd-watch-flexy, ytd-watch-grid"), // ytd-watch-grid == trash
  311. search: $("input#search"),
  312. };
  313.  
  314. const keyToggleTheater = new KeyboardEvent("keydown", {
  315. key: "t",
  316. code: "KeyT",
  317. which: 84,
  318. keyCode: 84,
  319. bubbles: true,
  320. cancelable: true,
  321. view: win,
  322. });
  323.  
  324. /**
  325. * @param {MutationCallback} callback
  326. * @param {Node} target
  327. * @param {MutationObserverInit | undefined} options
  328. * @returns {MutationObserver}
  329. */
  330. function observer(callback, target, options) {
  331. const mutation = new MutationObserver(callback);
  332. mutation.observe(target, options || { subtree: true, childList: true });
  333. return mutation;
  334. }
  335.  
  336. /**
  337. * @returns {boolean}
  338. */
  339. function isTheater() {
  340. const watch = element.watch();
  341. return (
  342. watch.getAttribute(attr.role) == "main" &&
  343. watch.hasAttribute(attr.theater) &&
  344. !watch.hasAttribute(attr.fullscreen)
  345. );
  346. }
  347.  
  348. /**
  349. * @returns {boolean}
  350. */
  351. function isActiveEditable() {
  352. const active = document.activeElement;
  353. return (
  354. active.tagName == "TEXTAREA" ||
  355. active.tagName == "INPUT" ||
  356. active.contentEditable == "true"
  357. );
  358. }
  359.  
  360. /**
  361. * @param {boolean} state
  362. * @param {boolean} skipActive
  363. */
  364. function toggleHeader(state, skipActive) {
  365. if (
  366. theater &&
  367. (skipActive || document.activeElement != element.search())
  368. ) {
  369. const scroll = !options.show_header_near.value && win.scrollY;
  370. html.toggleAttribute(attr.hidden_header, !(state || scroll));
  371. }
  372. }
  373.  
  374. let showHeaderTimer = 0;
  375.  
  376. /**
  377. * @param {MouseEvent} ev
  378. */
  379. function mouseShowHeader(ev) {
  380. if (options.show_header_near.value && theater) {
  381. const state = ev.clientY < 200;
  382. if (state) {
  383. clearTimeout(showHeaderTimer);
  384. showHeaderTimer = setTimeout(() => toggleHeader(false), 1500);
  385. }
  386. toggleHeader(!popup.show && state);
  387. }
  388. }
  389.  
  390. function toggleTheater() {
  391. document.dispatchEvent(keyToggleTheater);
  392. }
  393.  
  394. /**
  395. * @param {KeyboardEvent} ev
  396. */
  397. function onEscapePress(ev) {
  398. if (ev.code != "Escape" || !theater || popup.show) return;
  399.  
  400. if (options.close_theater_with_esc.value) {
  401. toggleTheater();
  402. } else {
  403. const input = element.search();
  404. if (document.activeElement != input) input.focus();
  405. else input.blur();
  406. }
  407. }
  408.  
  409. /**
  410. * @param {MutationRecord[]} mutations
  411. */
  412. function autoOpenTheater(mutations) {
  413. const attrs = [attr.role, attr.video_id];
  414. const watch = element.watch();
  415.  
  416. if (
  417. options.auto_theater_mode.value &&
  418. !watch.hasAttribute(attr.theater) &&
  419. !watch.hasAttribute(attr.fullscreen) &&
  420. mutations.some((m) => attrs.includes(m.attributeName))
  421. ) {
  422. setTimeout(toggleTheater, 1);
  423. }
  424. }
  425.  
  426. function registerEventListener() {
  427. element.search().addEventListener("focus", () => {
  428. setTimeout(() => toggleHeader(true, true), 1);
  429. });
  430. element.search().addEventListener("blur", () => {
  431. setTimeout(() => toggleHeader(false), 1);
  432. });
  433. win.addEventListener("scroll", () => {
  434. if (!options.show_header_near.value) toggleHeader();
  435. });
  436. win.addEventListener("mousemove", mouseShowHeader);
  437. win.addEventListener("keydown", onEscapePress, true);
  438. }
  439.  
  440. function applyTheaterMode() {
  441. const state = isTheater();
  442.  
  443. if (theater == state) return;
  444. theater = state;
  445.  
  446. html.toggleAttribute(attr.theater, state);
  447. html.toggleAttribute(attr.hidden_header, state);
  448. html.toggleAttribute(
  449. attr.no_scroll,
  450. state && options.hide_scrollbar.value
  451. );
  452. html.toggleAttribute(attr.hide_card, state || options.hide_card.value);
  453. }
  454.  
  455. observer((_, observe) => {
  456. const watch = element.watch();
  457. if (!watch) return;
  458.  
  459. observer(
  460. (mutations) => {
  461. applyTheaterMode();
  462. autoOpenTheater(mutations);
  463. },
  464. watch,
  465. { attributes: true }
  466. );
  467.  
  468. registerEventListener();
  469. observe.disconnect();
  470. }, body);
  471. })();