YouTube Click To Play

It disables autoplay and enables click to play.

Versione datata 03/07/2020. Vedi la nuova versione l'ultima versione.

// ==UserScript==
// @name        YouTube Click To Play
// @name:ja     YouTube Click To Play
// @name:zh-CN  YouTube Click To Play
// @namespace   knoa.jp
// @description It disables autoplay and enables click to play.
// @description:ja 自動再生を無効にし、クリックで再生するようにします。
// @description:zh-CN 它禁用自动播放,并启用点击播放。
// @include     https://www.youtube.com/*
// @noframes
// @run-at      document-start
// @grant       none
// @version     1
// ==/UserScript==

(function(){
  const SCRIPTID = 'YouTubeClickToPlay';
  const SCRIPTNAME = 'YouTube Click To Play';
  const DEBUG = false;/*
[update]

[bug]

[todo]

[possible]
channel/ と watch/ は個別に設定可能とか
  => channelだけで動作する別スクリプトがある
document.hidden でのみ作動するオプションとか
0秒で常にサムネに戻る仕様(seekingイベントでよい)

[research]
シアターモードの切り替えで再生してしまう件(そこまで気にしなくてもいい気もする)
t=4 以下で seek 後にサムネイルが消えてしまう問題
たまにぐるぐるが止まらない問題 t 指定とキャッシュに関係ある?

[memo]
本スクリプト仕様:
  サムネになってほしい: チャンネルホーム, ビデオページ
  再生してほしい: LIVE, 広告, 途中広告からの復帰
  要確認: 各ページの行き来, 再生で即停止しないこと, シアターモードの切り替え, 背面タブでの起動
  (YouTubeによるあっぱれなユーザー体験の追究のおかげで、初回読み込み時に限り再生開始済みのvideo要素が即出現する)
YouTube仕様:
  画面更新(URL Enter, S-Reload, Reload に本質的な差異なし)
  新規タブ(開いた直後, 読み込み完了後, title変更後 に本質的な差異なし)
    video:   body ... video ... loadstart ... で必ず play() されるのでダミーと入れ替えておけばよい。
      video要素は #player-api 内に出現した後に ytd-watch-flexy 内に移動する。その際に play() されるようだ。
      t=123 のような時刻指定があると seeking 後にもう一度 play() される。
        thumbnail は t=4 以下だとなぜか消えてしまう。(seekじゃなくてadvanceだとみなされるせい?)
    channel: body ... video ... loadstart で即 pause() 可能。(playは踏まれない)
  画面遷移(動画 <=> LIVE <=> チャンネル)
    video:   yt-navigate-start ... loadstart で即 pause() 可能。(playは踏まれない)
  広告
    冒頭広告: .ad-showing 依存だが判定できる。
    広告明け: 少しだけ泥臭いが、そのURLで一度でも本編が再生されていれば広告明けとみなす。
参考:
  Channelトップの動画でのみ機能するスクリプト
  https://greasyfork.org/ja/scripts/399862-kill-youtube-channel-video-autoplay
  */
  if(window === top && console.time) console.time(SCRIPTID);
  const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  const FLAGNAME = SCRIPTID.toLowerCase();
  const site = {
    targets: {
      body: () => $('body'),
    },
    get: {
      video: () => $(`video:not([data-${FLAGNAME}])`),
      startTime: () => {
        /* t=1h0m0s or t=3600 */
        let t = (new URL(location)).searchParams.get('t');
        if(t === null) return;
        let [h, m, s] = t.match(/^(?:([0-9]+)h)?(?:([0-9]+)m)?(?:([0-9]+)s?)?$/).slice(1).map(n => parseInt(n || 0));
        return 60*60*h + 60*m + s;
      },
    },
    is: {
      immediate: (video) => ($('#player-api') && $('#player-api').contains(video)),
      live: () => $('.ytp-time-display.ytp-live') !== null,
      ad: () => $('#movie_player.ad-showing') !== null,
    },
  };
  let elements = {}, flags = {}, view;
  const core = {
    initialize: function(){
      elements.html = document.documentElement;
      elements.html.classList.add(SCRIPTID);
      core.ready();
    },
    ready: function(){
      core.getTargets(site.targets).then(() => {
        log("I'm ready.");
        core.findVideo();
      }).catch(e => {
        console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
      });
    },
    findVideo: function(){
      const found = function(video){
        //log(video);
        if(video.dataset[FLAGNAME]) return;
        video.dataset[FLAGNAME] = 'found';
        core.listenVideo(video);
      };
      /* if a video already exists */
      let video = site.get.video();
      if(video) found(video);
      /* unavoidably observate body for immediate catch */
      observe(elements.body, function(records){
        let video = site.get.video();
        if(video) found(video);
      }, {childList: true, subtree: true});
    },
    listenVideo: function(video){
      /* for the very immediate time */
      //log(video.currentSrc, video.paused, video.currentTime);
      core.stopAutoplay(video);
      core.stopImmediateAutoplay(video);
      /* the video element just changes its src attribute on any case */
      video.addEventListener('loadstart', function(e){
        //log(e.type, video.currentSrc, video.paused, video.currentTime);
        if(site.is.live()) return log('this is a live and should start playing');
        if(site.is.ad()) return log('this is an ad and should start playing.');
        if(flags.playedOnce === location.href) return log('the ad has just closed and video should continue playing.');
        /* then it should be stopped */
        core.stopAutoplay(video);
      });
      /* memorize played status for restarting playing or not on after ads */
      video.addEventListener('playing', function(e){
        //log(e.type, video.currentTime);
        if(site.is.ad()) return;
        if(flags.playedOnce === location.href) return;
        flags.playedOnce = location.href;/* played once on the current location */
      });
      if(flags.listeningNavigation === undefined){
        flags.listeningNavigation = true;
        document.addEventListener('yt-navigate-start', function(e){
          //log(e, location.href);
          delete flags.playedOnce;/* reset the played once status */
        });
      }
    },
    stopAutoplay: function(video){
      //log();
      video.autoplay = false;
      video.pause();
    },
    stopImmediateAutoplay: function(video){
      let count = 0, isImmediate = site.is.immediate(video), startTime = site.get.startTime();
      //log(isImmediate, startTime);
      if(isImmediate) count++;/* for the very first view of the YouTube which plays a video automatically for immediate user experience */
      if(startTime) count++;/* for starting again from middle after seeking with query like t=123 */
      if(count){
        video.originalPlay = video.play;
        video.play = function(){
          log('(play)', count, video.currentTime);
          if(site.is.ad()) return video.originalPlay();
          if(--count === 0) video.play = video.originalPlay;
        };
      }
      /* I don't know why but on t < 5, it'll surely be paused but player UI is remained playing. So... */
      if(startTime && startTime < 5) video.addEventListener('seeked', function(e){
        //log(e.type, video.currentTime);
        video.play();
        video.pause();
      }, {once: true});
    },
    getTarget: function(selector, retry = 10, interval = 1*SECOND){
      const key = selector.name;
      const get = function(resolve, reject){
        let selected = selector();
        if(selected && selected.length > 0) selected.forEach((s) => s.dataset.selector = key);/* elements */
        else if(selected instanceof HTMLElement) selected.dataset.selector = key;/* element */
        else if(--retry) return log(`Not found: ${key}, retrying... (${retry})`), setTimeout(get, interval, resolve, reject);
        else return reject(new Error(`Not found: ${selector.name}, I give up.`));
        elements[key] = selected;
        resolve(selected);
      };
      return new Promise(function(resolve, reject){
        get(resolve, reject);
      });
    },
    getTargets: function(selectors, retry = 10, interval = 1*SECOND){
      return Promise.all(Object.values(selectors).map(selector => core.getTarget(selector, retry, interval)));
    },
  };
  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);
  const alert = window.alert.bind(window), confirm = window.confirm.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window);
  if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  const $ = function(s, f){
    let target = document.querySelector(s);
    if(target === null) return null;
    return f ? f(target) : target;
  };
  const $$ = function(s, f){
    let targets = document.querySelectorAll(s);
    return f ? Array.from(targets).map(t => f(t)) : targets;
  };
  const observe = function(element, callback, options = {childList: true, characterData: false, subtree: false, attributes: false, attributeFilter: undefined}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  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\?name=/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 1,
      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;
  });
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
})();