// ==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 // @downloadURL none // ==/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 \(/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \()/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); })();