YouTube Live Filled Up View

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

当前为 2020-01-10 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        YouTube Live Filled Up View
// @name:ja     YouTube Live Filled Up View
// @name:zh-CN  YouTube Live Filled Up View
// @description Get maximized video-and-chat view with no margins on YouTube Live or Premieres.
// @description:ja YouTube Live やプレミア公開のチャット付きビューで、余白を切り詰めて映像を最大化します。
// @description:zh-CN 在油管中的 YouTube Live 或首映公开的带聊天视图中,截取空白以最大化映像。
// @namespace   knoa.jp
// @include     https://www.youtube.com/*
// @version     1
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTID = 'YouTubeLiveFilledUpView';
  const SCRIPTNAME = 'YouTube Live Filled Up View';
  const DEBUG = false;/*
[update]

[bug]

[todo]

[possible]

[memo]
ダークモードはそれ用のユーザースタイルに任せるべき。
  */
  if(window === top && console.time) console.time(SCRIPTID);
  const SECOND = 1000, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  const INTERVAL = 1*SECOND;/*for core.checkUrl*/
  const VIDEOURLS = [/*for core.checkUrl*/
    /^https:\/\/www\.youtube\.com\/watch\?/,
  ];
  const CHATURLS = [/*for core.checkUrl*/
    /^https:\/\/www\.youtube\.com\/live_chat\?/,
    /^https:\/\/www\.youtube\.com\/live_chat_replay\?/,
  ];
  const RETRY = 10;
  let site = {
    videoTargets: {
      video: () => $('#movie_player video'),
      chat: () => $('ytd-live-chat-frame#chat'),
    },
    is: {
      opened: (chat) => (chat.collapsed === false),
    },
    chatTargets: {
      items: () => $('yt-live-chat-item-list-renderer #items'),
    },
  };
  let html, elements = {}, timers = {}, sizes = {};
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTID);
      core.checkUrl();
    },
    checkUrl: function(){
      let previousUrl = '';
      timers.checkUrl = setInterval(function(){
        if(document.hidden) return;
        /* The page is visible, so... */
        if(location.href === previousUrl) return;
        else previousUrl = location.href;
        /* The URL has changed, so... */
        core.clearVideostyle();
        switch(true){
          case(VIDEOURLS.some(url => url.test(location.href))):
            return core.readyForVideo();
          case(CHATURLS.some(url => url.test(location.href))):
            return core.readyForChat();
        }
      }, INTERVAL);
    },
    clearVideostyle: function(){
      if(elements.videoStyle && elements.videoStyle.isConnected){
        document.head.removeChild(elements.videoStyle);
      }
    },
    addVideostyle: function(){
      core.addStyle('videoStyle');
    },
    readyForVideo: function(){
      core.getTargets(site.videoTargets, RETRY).then(() => {
        log("I'm ready for Video.");
        core.observeChatFrame();
      });
    },
    observeChatFrame: function(){
      let chat = elements.chat;
      if(site.is.opened(chat)) core.addVideostyle();
      observe(chat, function(records){
        if(site.is.opened(chat)) core.addVideostyle();
        else core.clearVideostyle();
      }, {attributes: true});
    },
    readyForChat: function(){
      core.getTargets(site.chatTargets, RETRY).then(() => {
        log("I'm ready for Chat.");
        core.addStyle('chatStyle');
      });
    },
    getTargets: function(targets, retry = 0){
      const get = function(resolve, reject, retry){
        for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
          let selected = targets[key]();
          if(selected){
            if(selected.length) selected.forEach((s) => s.dataset.selector = key);
            else selected.dataset.selector = key;
            elements[key] = selected;
          }else{
            if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
            log(`Not found: ${key}, retrying... (left ${retry})`);
            return setTimeout(get, 1000, resolve, reject, retry);
          }
        }
        resolve();
      };
      return new Promise(function(resolve, reject){
        get(resolve, reject, retry);
      });
    },
    addStyle: function(name = 'style'){
      if(core.html[name] === undefined) return;
      let style = createElement(core.html[name]());
      document.head.appendChild(style);
      if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
      elements[name] = style;
    },
    html: {
      videoStyle: () => `
        <style type="text/css">
          /*
          header height: --ytd-watch-flexy-masthead-height
          */
          /* common */
          ytd-watch-flexy{
            --${SCRIPTID}-primary-width: calc(100vw - var(--ytd-watch-flexy-sidebar-width));
            --${SCRIPTID}-secondary-width: var(--ytd-watch-flexy-sidebar-width);
            --${SCRIPTID}-video-height: calc(var(--${SCRIPTID}-primary-width) * (9/16));
            --${SCRIPTID}-info-height: calc(2.4rem + 40px + 29px);
          }
          /* columns */
          #columns{
            max-width: 100% !important;
          }
          #primary{
            max-width: var(--${SCRIPTID}-primary-width) !important;
            min-width: var(--${SCRIPTID}-primary-width) !important;
            padding: 0 !important;
            margin: 0 !important;
          }
          #secondary{
            max-width: var(--${SCRIPTID}-secondary-width) !important;
            min-width: var(--${SCRIPTID}-secondary-width) !important;
            padding: 0 !important;
            margin: 0 !important;
          }
          #player-container-outer,
          yt-live-chat-app{
            max-width: 100% !important;
            min-width: 100% !important;
          }
          #primary-inner > *:not(#player){
            padding: 0 24px 0;
          }
          /* video */
          #movie_player video{
            width: 100% !important;
            height: auto !important;
          }
          #movie_player .ytp-chrome-bottom{
            width: calc(100% - 24px) !important;/*fragile!!*/
          }
          /* chatframe */
          ytd-live-chat-frame#chat{
            height: calc(var(--${SCRIPTID}-video-height) + var(--${SCRIPTID}-info-height)) !important;
            min-height: calc(var(--${SCRIPTID}-video-height) + var(--${SCRIPTID}-info-height)) !important;
            max-height: calc(var(--${SCRIPTID}-video-height) + var(--${SCRIPTID}-info-height)) !important;
            border-right: none;
          }
        </style>
      `,
      chatStyle: () => `
        <style type="text/css">
          /* ヘッダとフッタ */
          yt-live-chat-header-renderer/*ヘッダ*/{
            filter: drop-shadow(0 0 2px rgba(0,0,0,.1));
            z-index: 100;
          }
          #contents > #ticker/*スパチャなど*/{
            filter: drop-shadow(0 0 2px rgba(0,0,0,.1));
          }
          #contents > #ticker/*スパチャなど*/ > yt-live-chat-ticker-renderer > #container > *{
            padding-top: 4px;
            padding-bottom: 4px;
          }
          iron-pages#panel-pages/*フッタ*/{
            filter: drop-shadow(0 0 2px rgba(0,0,0,.1));
            background: white;
          }
          /* 本体 */
          #docked-item.yt-live-chat-docked-message-renderer/*上部固定*/,
          #undocking-item.yt-live-chat-docked-message-renderer/*上部固定*/{
            margin: 8px 0;
          }
          #docked-item.yt-live-chat-docked-message-renderer/*上部固定*/ > *,
          #undocking-item.yt-live-chat-docked-message-renderer/*上部固定*/ > *{
            filter: drop-shadow(0 0 2px rgba(0,0,0,.1));
          }
          #docked-item.yt-live-chat-docked-message-renderer/*上部固定*/ > *,
          #undocking-item.yt-live-chat-docked-message-renderer/*上部固定*/ > *,
          #items.yt-live-chat-item-list-renderer/*一般*/ > *:not(yt-live-chat-placeholder-item-renderer){
            padding: 2px 10px !important;
          }
        </style>
      `,
    },
  };
  const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  const alert = window.alert, confirm = window.confirm, getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  class Storage{
    static key(key){
      return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
    }
    static save(key, value, expire = null){
      key = Storage.key(key);
      localStorage[key] = JSON.stringify({
        value: value,
        saved: Date.now(),
        expire: expire,
      });
    }
    static read(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.value === undefined) return data;
      if(data.expire === undefined) return data;
      if(data.expire === null) return data.value;
      if(data.expire < Date.now()) return localStorage.removeItem(key);
      return data.value;
    }
    static delete(key){
      key = Storage.key(key);
      delete localStorage.removeItem(key);
    }
    static saved(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.saved) return data.saved;
      else return undefined;
    }
  }
  const $ = function(s, f){
    let target = document.querySelector(s);
    if(target === null) return null;
    return f ? f(target) : target;
  };
  const $$ = function(s){return document.querySelectorAll(s)};
  const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  const createElement = function(html = '<span></span>'){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  const getScrollbarWidth = function(){
    let div = document.createElement('div');
    div.textContent = 'dummy';
    document.body.appendChild(div);
    div.style.overflowY = 'scroll';
    let clientWidth = div.clientWidth;
    div.style.overflowY = 'hidden';
    let offsetWidth = div.offsetWidth;
    document.body.removeChild(div);
    return offsetWidth - clientWidth;
  };
  const atLeast = function(min, b){
    return Math.max(min, b);
  };
  const atMost = function(a, max){
    return Math.min(a, max);
  };
  const between = function(min, b, max){
    return Math.min(Math.max(min, b), max);
  };
  const log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
    //console.log(error.stack);
    console.log(
      (SCRIPTID || '') + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + line,
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '') + '()',
      ...arguments
    );
  };
  log.formats = [{
      name: 'Firefox Scratchpad',
      detector: /MARKER@Scratchpad/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Console',
      detector: /MARKER@debugger/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 3',
      detector: /\/gm_scripts\//,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 4+',
      detector: /MARKER@user-script:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Tampermonkey',
      detector: /MARKER@moz-extension:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Chrome Console',
      detector: /at MARKER \(<anonymous>/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
    }, {
      name: 'Chrome Tampermonkey',
      detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
    }, {
      name: 'Chrome Extension',
      detector: /at MARKER \(chrome-extension:/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
    }, {
      name: 'Edge Console',
      detector: /at MARKER \(eval/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
    }, {
      name: 'Edge Tampermonkey',
      detector: /at MARKER \(Function/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
    }, {
      name: 'Safari',
      detector: /^MARKER$/m,
      getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
      getCallers: (e) => e.stack.split('\n'),
    }, {
      name: 'Default',
      detector: /./,
      getLine: (e) => 0,
      getCallers: (e) => [],
    }];
  log.format = log.formats.find(function MARKER(f){
    if(!f.detector.test(new Error().stack)) return false;
    //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
    return true;
  });
  const time = function(label){
    if(!DEBUG) return;
    const BAR = '|', TOTAL = 100;
    switch(true){
      case(label === undefined):/* time() to output total */
        let total = 0;
        Object.keys(time.records).forEach((label) => total += time.records[label].total);
        Object.keys(time.records).forEach((label) => {
          console.log(
            BAR.repeat((time.records[label].total / total) * TOTAL),
            label + ':',
            (time.records[label].total).toFixed(3) + 'ms',
            '(' + time.records[label].count + ')',
          );
        });
        time.records = {};
        break;
      case(!time.records[label]):/* time('label') to create and start the record */
        time.records[label] = {count: 0, from: performance.now(), total: 0};
        break;
      case(time.records[label].from === null):/* time('label') to re-start the lap */
        time.records[label].from = performance.now();
        break;
      case(0 < time.records[label].from):/* time('label') to add lap time to the record */
        time.records[label].total += performance.now() - time.records[label].from;
        time.records[label].from = null;
        time.records[label].count += 1;
        break;
    }
  };
  time.records = {};
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
})();