YouTube Live Filled Up View

在油管中的 YouTube Live 或首映公开的带聊天视图中,截取空白以最大化映像。

目前为 2020-11-03 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Live Filled Up View
  3. // @name:ja YouTube Live Filled Up View
  4. // @name:zh-CN YouTube Live Filled Up View
  5. // @description Get maximized video-and-chat view with no margins on YouTube Live or Premieres.
  6. // @description:ja YouTube Live やプレミア公開のチャット付きビューで、余白を切り詰めて映像を最大化します。
  7. // @description:zh-CN 在油管中的 YouTube Live 或首映公开的带聊天视图中,截取空白以最大化映像。
  8. // @namespace knoa.jp
  9. // @include https://www.youtube.com/*
  10. // @version 1.2.3
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. (function(){
  15. const SCRIPTID = 'YouTubeLiveFilledUpView';
  16. const SCRIPTNAME = 'YouTube Live Filled Up View';
  17. const DEBUG = false;/*
  18. [update] 1.2.3
  19. Automatically open the chat on Premier videos.
  20.  
  21. [bug]
  22. やっぱ再生終了後のおすすめサムネ一覧がズレる。このスクリプトだとは思うんだけど再現条件は謎。
  23.  
  24. [todo]
  25.  
  26. [possible]
  27.  
  28. [research]
  29. infoの高さが変わりうるwindow.onresizeに対応か(パフォーマンスはスロットルすれば平気か)
  30.  
  31. [memo]
  32. ダークモードはそれ用のユーザースタイルに任せるべき。
  33. YouTubeのヘッダが常時表示なのはちょっと気にかかるが、高さにはまだ余裕がある。
  34. 横幅1920時のinnerHeight: 910px, ChromeのUI: 約100px, Windowsタスクバー: 約40px
  35. */
  36. if(window === top && console.time) console.time(SCRIPTID);
  37. const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  38. const INTERVAL = 1*SECOND;/*for core.checkUrl*/
  39. const DEFAULTRATIO = 9/16;/*for waiting page*/
  40. const VIDEOURLS = [/*for core.checkUrl*/
  41. /^https:\/\/www\.youtube\.com\/watch\?/,
  42. /^https:\/\/www\.youtube\.com\/channel\/[^/]+\/live/,
  43. ];
  44. const CHATURLS = [/*for core.checkUrl*/
  45. /^https:\/\/www\.youtube\.com\/live_chat\?/,
  46. /^https:\/\/www\.youtube\.com\/live_chat_replay\?/,
  47. ];
  48. let site = {
  49. videoTargets: {
  50. watchFlexy: () => $('ytd-watch-flexy'),
  51. video: () => $('#movie_player video'),
  52. info: () => $('ytd-video-primary-info-renderer'),
  53. chat: () => $('ytd-live-chat-frame#chat'),
  54. },
  55. is: {
  56. video: () => VIDEOURLS.some(url => url.test(location.href)),
  57. chat: () => CHATURLS.some(url => url.test(location.href)),
  58. opened: (chat) => (chat.isConnected === true && chat.collapsed === false),
  59. theater: () => elements.watchFlexy.theater,/* YouTube uses this property */
  60. },
  61. get: {
  62. sizeButton: () => $('button.ytp-size-button'),
  63. chatFrameToggleButton: () => $('ytd-live-chat-frame paper-button'),
  64. },
  65. chatTargets: {
  66. items: () => $('yt-live-chat-item-list-renderer #items'),
  67. },
  68. };
  69. let elements = {}, timers = {}, sizes = {};
  70. let core = {
  71. initialize: function(){
  72. elements.html = document.documentElement;
  73. elements.html.classList.add(SCRIPTID);
  74. if(site.is.chat()){
  75. core.readyForChat();
  76. }else{
  77. core.checkUrl();
  78. }
  79. },
  80. checkUrl: function(){
  81. let previousUrl = '';
  82. timers.checkUrl = setInterval(function(){
  83. if(document.hidden) return;
  84. /* The page is visible, so... */
  85. if(location.href === previousUrl) return;
  86. else previousUrl = location.href;
  87. /* The URL has changed, so... */
  88. core.clearVideoStyle();
  89. if(site.is.video()) return core.readyForVideo();
  90. }, INTERVAL);
  91. },
  92. clearVideoStyle: function(){
  93. if(elements.videoStyle && elements.videoStyle.isConnected){
  94. document.head.removeChild(elements.videoStyle);
  95. }
  96. },
  97. addVideoStyle: function(e){
  98. if(site.is.opened(chat) === false) return;
  99. /* it also replaces old style */
  100. let video = elements.video;
  101. /* adapt to the aspect ratio */
  102. if(video.videoWidth){
  103. sizes.videoAspectRatio = video.videoHeight / video.videoWidth;
  104. core.addStyle('videoStyle');
  105. }else{
  106. sizes.videoAspectRatio = DEFAULTRATIO;
  107. core.addStyle('videoStyle');
  108. video.addEventListener('canplay', core.addVideoStyle, {once: true});
  109. }
  110. /* for Ads replacing */
  111. if(video.dataset.observed === 'true') return;
  112. video.dataset.observed = 'true';
  113. observe(video, function(records){
  114. video.addEventListener('canplay', core.addVideoStyle, {once: true});
  115. }, {attributes: true, attributeFilter: ['src']});
  116. },
  117. readyForVideo: function(){
  118. core.getTargets(site.videoTargets).then(() => {
  119. log("I'm ready for Video with Chat.");
  120. sizes.scrollbarWidth = getScrollbarWidth();
  121. core.getInfoHeight();
  122. core.replacePlayerSize();
  123. core.observeChatFrame();
  124. core.enterDefaultMode();
  125. }).catch(e => {
  126. console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
  127. });
  128. },
  129. getInfoHeight: function(){
  130. let info = elements.info;
  131. if(info.offsetHeight === 0) return setTimeout(core.getInfoHeight, 1000);
  132. sizes.infoHeight = elements.info.offsetHeight;
  133. },
  134. replacePlayerSize: function(){
  135. /* update the size */
  136. setTimeout(() => window.dispatchEvent(new Event('resize')), 1000);
  137. let watchFlexy = elements.watchFlexy;
  138. if(watchFlexy.calculateNormalPlayerSize_original) return;
  139. /* Thanks for Iridium.user.js > initializeBypasses */
  140. watchFlexy.calculateNormalPlayerSize_original = watchFlexy.calculateNormalPlayerSize_;
  141. watchFlexy.calculateCurrentPlayerSize_original = watchFlexy.calculateCurrentPlayerSize_;
  142. watchFlexy.calculateNormalPlayerSize_ = watchFlexy.calculateCurrentPlayerSize_ = function(){
  143. let video = elements.video, chat = elements.chat;
  144. if(site.is.opened(chat) === false){/* chat is closed */
  145. return watchFlexy.calculateCurrentPlayerSize_original();
  146. }else{
  147. if(watchFlexy.theater) return {width: NaN, height: NaN};
  148. else return {width: video.offsetWidth, height: video.offsetHeight};
  149. }
  150. };
  151. },
  152. observeChatFrame: function(){
  153. let chat = elements.chat, isOpened = site.is.opened(chat), button = site.get.chatFrameToggleButton();
  154. if(!isOpened && button){
  155. button.click();
  156. isOpened = !isOpened;
  157. }
  158. core.addVideoStyle();
  159. observe(chat, function(records){
  160. if(site.is.opened(chat) === isOpened) return;
  161. isOpened = !isOpened;
  162. window.dispatchEvent(new Event('resize'));/*for updating controls tooltip positions*/
  163. if(isOpened) return core.addVideoStyle();
  164. else return core.clearVideoStyle();
  165. }, {attributes: true});
  166. },
  167. enterDefaultMode: function(){
  168. if(site.is.theater() === false) return;/* already in default mode */
  169. let sizeButton = site.get.sizeButton();
  170. if(sizeButton) sizeButton.click();
  171. },
  172. readyForChat: function(){
  173. core.getTargets(site.chatTargets).then(() => {
  174. log("I'm ready for Chat.");
  175. core.addStyle('chatStyle');
  176. }).catch(e => {
  177. console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
  178. });
  179. },
  180. getTarget: function(selector, retry = 10, interval = 1*SECOND){
  181. const key = selector.name;
  182. const get = function(resolve, reject){
  183. let selected = selector();
  184. if(selected && selected.length > 0) selected.forEach((s) => s.dataset.selector = key);/* elements */
  185. else if(selected instanceof HTMLElement) selected.dataset.selector = key;/* element */
  186. else if(--retry) return log(`Not found: ${key}, retrying... (${retry})`), setTimeout(get, interval, resolve, reject);
  187. else return reject(new Error(`Not found: ${selector.name}, I give up.`));
  188. elements[key] = selected;
  189. resolve(selected);
  190. };
  191. return new Promise(function(resolve, reject){
  192. get(resolve, reject);
  193. });
  194. },
  195. getTargets: function(selectors, retry = 10, interval = 1*SECOND){
  196. return Promise.all(Object.values(selectors).map(selector => core.getTarget(selector, retry, interval)));
  197. },
  198. addStyle: function(name = 'style'){
  199. if(html[name] === undefined) return;
  200. let style = createElement(html[name]());
  201. document.head.appendChild(style);
  202. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  203. elements[name] = style;
  204. },
  205. };
  206. const html = {
  207. videoStyle: () => `
  208. <style type="text/css" id="${SCRIPTID}-videoStyle">
  209. /* common */
  210. ytd-watch-flexy{
  211. --${SCRIPTID}-header-height: var(--ytd-watch-flexy-masthead-height);
  212. --${SCRIPTID}-info-height: ${sizes.infoHeight}px;
  213. --ytd-watch-flexy-width-ratio: 1 !important;/*fix for Iridium bug*/
  214. --ytd-watch-width-ratio: 1 !important;/*fix for Iridium bug*/
  215. --ytd-watch-flexy-height-ratio: ${sizes.videoAspectRatio} !important;/*fix for Iridium bug*/
  216. --ytd-watch-height-ratio: ${sizes.videoAspectRatio} !important;/*fix for Iridium bug*/
  217. }
  218. ytd-watch-flexy[is-two-columns_]{
  219. --${SCRIPTID}-primary-width: calc(100vw - ${sizes.scrollbarWidth}px - var(--ytd-watch-flexy-sidebar-width));
  220. --${SCRIPTID}-secondary-width: var(--ytd-watch-flexy-sidebar-width);
  221. --${SCRIPTID}-video-height: calc(var(--${SCRIPTID}-primary-width) * ${sizes.videoAspectRatio});
  222. }
  223. ytd-watch-flexy:not([is-two-columns_]){
  224. --${SCRIPTID}-primary-width: 100vw;
  225. --${SCRIPTID}-secondary-width: 100vw;
  226. --${SCRIPTID}-video-height: calc(100vw * ${sizes.videoAspectRatio});
  227. }
  228. /* columns */
  229. #columns{
  230. max-width: 100% !important;
  231. }
  232. #primary{
  233. max-width: var(--${SCRIPTID}-primary-width) !important;
  234. min-width: var(--${SCRIPTID}-primary-width) !important;
  235. padding: 0 !important;
  236. margin: 0 !important;
  237. }
  238. #secondary{
  239. max-width: var(--${SCRIPTID}-secondary-width) !important;
  240. min-width: var(--${SCRIPTID}-secondary-width) !important;
  241. padding: 0 !important;
  242. margin: 0 !important;
  243. }
  244. #player-container-outer,
  245. yt-live-chat-app{
  246. max-width: 100% !important;
  247. min-width: 100% !important;
  248. }
  249. #primary-inner > *:not(#player){
  250. padding: 0 24px 0;
  251. }
  252. /* video */
  253. #player,
  254. #player *{
  255. max-height: calc(100vh - var(--${SCRIPTID}-header-height)) !important;
  256. }
  257. #movie_player .html5-video-container{
  258. height: 100% !important;
  259. }
  260. #movie_player .ytp-chrome-bottom/*controls*/{
  261. width: calc(100% - 24px) !important;/*fragile!!*/
  262. max-width: calc(100% - 24px) !important;/*fragile!!*/
  263. }
  264. #movie_player video{
  265. max-width: 100% !important;
  266. width: 100% !important;
  267. height: 100% !important;
  268. left: 0px !important;
  269. background: black;
  270. }
  271. .ended-mode video{
  272. display: none !important;/*avoid conflicting with Iridium*/
  273. }
  274. /* chatframe */
  275. ytd-watch-flexy[is-two-columns_] ytd-live-chat-frame#chat{
  276. height: calc(var(--${SCRIPTID}-video-height) + var(--${SCRIPTID}-info-height)) !important;
  277. min-height: auto !important;
  278. max-height: calc(100vh - var(--${SCRIPTID}-header-height)) !important;
  279. border-right: none;
  280. }
  281. ytd-watch-flexy:not([is-two-columns_]) ytd-live-chat-frame#chat{
  282. padding: 0 !important;
  283. margin: 0 !important;
  284. height: calc(100vh - var(--${SCRIPTID}-header-height) - var(--${SCRIPTID}-video-height)) !important;
  285. min-height: auto !important;
  286. border-top: none;
  287. }
  288. </style>
  289. `,
  290. chatStyle: () => `
  291. <style type="text/css" id="${SCRIPTID}-chatStyle">
  292. /* common */
  293. :root{
  294. --${SCRIPTID}-slight-shadow: drop-shadow(0 0 1px rgba(0,0,0,.1));
  295. }
  296. /* header and footer */
  297. yt-live-chat-header-renderer/*header*/{
  298. filter: var(--${SCRIPTID}-slight-shadow);
  299. z-index: 100;
  300. }
  301. #contents > #ticker/*superchat*/{
  302. filter: var(--${SCRIPTID}-slight-shadow);
  303. }
  304. #contents > #ticker/*superchat*/ > yt-live-chat-ticker-renderer > #container > *{
  305. padding-top: 4px;
  306. padding-bottom: 4px;
  307. }
  308. iron-pages#panel-pages/*footer*/{
  309. filter: var(--${SCRIPTID}-slight-shadow);
  310. background: white;
  311. }
  312. /* body */
  313. #docked-item.yt-live-chat-docked-message-renderer/*sticky on the top*/,
  314. #undocking-item.yt-live-chat-docked-message-renderer/*sticky on the top*/{
  315. margin: 8px 0;
  316. }
  317. #docked-item.yt-live-chat-docked-message-renderer/*sticky on the top*/ > *,
  318. #undocking-item.yt-live-chat-docked-message-renderer/*sticky on the top*/ > *{
  319. filter: var(--${SCRIPTID}-slight-shadow);
  320. }
  321. #docked-item.yt-live-chat-docked-message-renderer/*sticky on the top*/ > *,
  322. #undocking-item.yt-live-chat-docked-message-renderer/*sticky on the top*/ > *,
  323. #items.yt-live-chat-item-list-renderer/*normal chats*/ > *:not(yt-live-chat-placeholder-item-renderer){
  324. padding: 2px 10px !important;
  325. }
  326. </style>
  327. `,
  328. };
  329. const setTimeout = window.setTimeout.bind(window), clearTimeout = window.clearTimeout.bind(window), setInterval = window.setInterval.bind(window), clearInterval = window.clearInterval.bind(window), requestAnimationFrame = window.requestAnimationFrame.bind(window);
  330. const alert = window.alert.bind(window), confirm = window.confirm.bind(window), prompt = window.prompt.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window);
  331. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  332. class Storage{
  333. static key(key){
  334. return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
  335. }
  336. static save(key, value, expire = null){
  337. key = Storage.key(key);
  338. localStorage[key] = JSON.stringify({
  339. value: value,
  340. saved: Date.now(),
  341. expire: expire,
  342. });
  343. }
  344. static read(key){
  345. key = Storage.key(key);
  346. if(localStorage[key] === undefined) return undefined;
  347. let data = JSON.parse(localStorage[key]);
  348. if(data.value === undefined) return data;
  349. if(data.expire === undefined) return data;
  350. if(data.expire === null) return data.value;
  351. if(data.expire < Date.now()) return localStorage.removeItem(key);
  352. return data.value;
  353. }
  354. static delete(key){
  355. key = Storage.key(key);
  356. delete localStorage.removeItem(key);
  357. }
  358. static saved(key){
  359. key = Storage.key(key);
  360. if(localStorage[key] === undefined) return undefined;
  361. let data = JSON.parse(localStorage[key]);
  362. if(data.saved) return data.saved;
  363. else return undefined;
  364. }
  365. }
  366. const $ = function(s, f){
  367. let target = document.querySelector(s);
  368. if(target === null) return null;
  369. return f ? f(target) : target;
  370. };
  371. const $$ = function(s){return document.querySelectorAll(s)};
  372. const createElement = function(html = '<span></span>'){
  373. let outer = document.createElement('div');
  374. outer.innerHTML = html;
  375. return outer.firstElementChild;
  376. };
  377. const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
  378. let observer = new MutationObserver(callback.bind(element));
  379. observer.observe(element, options);
  380. return observer;
  381. };
  382. const getScrollbarWidth = function(){
  383. let div = document.createElement('div');
  384. div.textContent = 'dummy';
  385. document.body.appendChild(div);
  386. div.style.overflowY = 'scroll';
  387. let clientWidth = div.clientWidth;
  388. div.style.overflowY = 'hidden';
  389. let offsetWidth = div.offsetWidth;
  390. document.body.removeChild(div);
  391. return offsetWidth - clientWidth;
  392. };
  393. const log = function(){
  394. if(!DEBUG) return;
  395. let l = log.last = log.now || new Date(), n = log.now = new Date();
  396. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  397. //console.log(error.stack);
  398. console.log(
  399. (SCRIPTID || '') + ':',
  400. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  401. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  402. /* :00 */ ':' + line,
  403. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  404. /* caller */ (callers[1] || '') + '()',
  405. ...arguments
  406. );
  407. };
  408. log.formats = [{
  409. name: 'Firefox Scratchpad',
  410. detector: /MARKER@Scratchpad/,
  411. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  412. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  413. }, {
  414. name: 'Firefox Console',
  415. detector: /MARKER@debugger/,
  416. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  417. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  418. }, {
  419. name: 'Firefox Greasemonkey 3',
  420. detector: /\/gm_scripts\//,
  421. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  422. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  423. }, {
  424. name: 'Firefox Greasemonkey 4+',
  425. detector: /MARKER@user-script:/,
  426. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  427. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  428. }, {
  429. name: 'Firefox Tampermonkey',
  430. detector: /MARKER@moz-extension:/,
  431. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  432. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  433. }, {
  434. name: 'Chrome Console',
  435. detector: /at MARKER \(<anonymous>/,
  436. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  437. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  438. }, {
  439. name: 'Chrome Tampermonkey',
  440. detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?name=/,
  441. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 1,
  442. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  443. }, {
  444. name: 'Chrome Extension',
  445. detector: /at MARKER \(chrome-extension:/,
  446. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  447. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  448. }, {
  449. name: 'Edge Console',
  450. detector: /at MARKER \(eval/,
  451. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  452. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  453. }, {
  454. name: 'Edge Tampermonkey',
  455. detector: /at MARKER \(Function/,
  456. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  457. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  458. }, {
  459. name: 'Safari',
  460. detector: /^MARKER$/m,
  461. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  462. getCallers: (e) => e.stack.split('\n'),
  463. }, {
  464. name: 'Default',
  465. detector: /./,
  466. getLine: (e) => 0,
  467. getCallers: (e) => [],
  468. }];
  469. log.format = log.formats.find(function MARKER(f){
  470. if(!f.detector.test(new Error().stack)) return false;
  471. //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
  472. return true;
  473. });
  474. const time = function(label){
  475. if(!DEBUG) return;
  476. const BAR = '|', TOTAL = 100;
  477. switch(true){
  478. case(label === undefined):/* time() to output total */
  479. let total = 0;
  480. Object.keys(time.records).forEach((label) => total += time.records[label].total);
  481. Object.keys(time.records).forEach((label) => {
  482. console.log(
  483. BAR.repeat((time.records[label].total / total) * TOTAL),
  484. label + ':',
  485. (time.records[label].total).toFixed(3) + 'ms',
  486. '(' + time.records[label].count + ')',
  487. );
  488. });
  489. time.records = {};
  490. break;
  491. case(!time.records[label]):/* time('label') to create and start the record */
  492. time.records[label] = {count: 0, from: performance.now(), total: 0};
  493. break;
  494. case(time.records[label].from === null):/* time('label') to re-start the lap */
  495. time.records[label].from = performance.now();
  496. break;
  497. case(0 < time.records[label].from):/* time('label') to add lap time to the record */
  498. time.records[label].total += performance.now() - time.records[label].from;
  499. time.records[label].from = null;
  500. time.records[label].count += 1;
  501. break;
  502. }
  503. };
  504. time.records = {};
  505. core.initialize();
  506. if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
  507. })();