Youtube Fullpage Theater

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

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

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