YouTube Click To Play

它禁用自动播放,并启用点击播放。

当前为 2020-07-03 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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);
})();