// ==UserScript== // @name YouTube ProgressBar Preserver // @name:ja YouTube ProgressBar Preserver // @name:zh-CN YouTube ProgressBar Preserver // @description It preserves YouTube's progress bar always visible even if the controls are hidden. // @description:ja YouTubeのプログレスバー(再生時刻の割合を示す赤いバー)を、隠さず常に表示させるようにします。 // @description:zh-CN 让你恒常地显示油管上的进度条(显示播放时间比例的红色条)。 // @namespace knoa.jp // @include https://www.youtube.com/* // @include https://www.youtube-nocookie.com/embed/* // @version 0.13.0 // @grant none // @downloadURL none // ==/UserScript== (function(){ const SCRIPTID = 'YouTubeProgressBarPreserver'; const SCRIPTNAME = 'YouTube ProgressBar Preserver'; const DEBUG = false;/* [update] 0.13.0 Highlight the buffered range, like YouTube's default. [bug] 一度ビデオが終わって(次の動画に進んで?)から戻ると更新されない? [todo] カスタマイズ 表示対象 通常時 フルスクリーン時 外部埋め込み時 表示形式 色 ←赤でいいのでは 高さ ←必要? 透明度 バー用の領域を別途確保する(動画を隠さない) ←ほんとに必要? 通常時 フルスクリーン時 時刻表示 透明度 位置 ←固定でいいか 色 ←白(+影)に固定でいいよね [research] timeupdateの間隔ぶんだけ遅れてしまうのはうまく改善できるかどうか timeupdateきっかけで250msをキープするような仕組みでいける? もっとも、時間の短い広告時くらいしか知覚できないけど。 [memo] YouTubeによって隠されているときはオリジナルのバーは更新されないので、独自に作るほうがラク。 0.9完成後、youtube progressbar で検索したところすでに存在していることを発見\(^o^)/ https://addons.mozilla.org/ja/firefox/addon/progress-bar-for-youtube/ カスタマイズできるが、生放送に対応していない。プログレスが最低0.5秒単位でtransitionもない。 */ 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 INTERVAL = 1*SECOND;/*for core.checkUrl*/ const SHORTDURATION = 4*MINUTE / 1000;/*short video should have transition*/ const STARTSWITH = [/*for core.checkUrl*/ 'https://www.youtube.com/watch?', 'https://www.youtube.com/embed/', 'https://www.youtube-nocookie.com/embed/', ]; const RETRY = 10; let site = { targets: { player: () => $('.html5-video-player'), video: () => $('video[src]'), time: () => $('.ytp-time-display'), }, is: { live: (time) => time.classList.contains('ytp-live'), }, }; let elements = {}, timers = {}; let core = { initialize: function(){ elements.html = document.documentElement; elements.html.classList.add(SCRIPTID); core.checkUrl(); core.addStyle(); }, 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... */ if(STARTSWITH.some(url => location.href.startsWith(url)) === false) return; /* This page should be modified, so... */ core.ready(); }, INTERVAL); }, ready: function(){ core.getTargets(site.targets, RETRY).then(() => { log("I'm ready."); core.appendBar(); }); }, appendBar: function(){ if(elements.bar && elements.bar.isConnected) return; let bar = elements.bar = createElement(html.bar()); let progress = elements.progress = bar.firstElementChild; let buffer = elements.buffer = bar.lastElementChild; elements.player.appendChild(bar); core.observeTime(elements.time, bar); core.observeVideo(elements.video, progress, buffer); }, observeTime: function(time, bar){ /* detect live for hiding the bar */ let detect = function(time, bar){ if(site.is.live(time)) bar.classList.remove('active'); else bar.classList.add('active'); }; detect(time, bar); let observer = observe(time, function(records){ detect(time, bar); }, {attributes: true}); }, observeVideo: function(video, progress, buffer){ if(video.duration < SHORTDURATION) progress.classList.add('transition'); progress.style.transform = 'scaleX(0)'; video.addEventListener('durationchange', function(e){ if(video.duration < SHORTDURATION) progress.classList.add('transition'); else progress.classList.remove('transition'); }); video.addEventListener('timeupdate', function(e){ progress.style.transform = `scaleX(${video.currentTime / video.duration})`; }); video.addEventListener('progress', function(e){ for(let i = video.buffered.length - 1; 0 <= i; i--){ if(video.currentTime < video.buffered.start(i)) continue; buffer.style.transform = `scaleX(${video.buffered.end(i) / video.duration})`; break; } }); video.addEventListener('seeking', function(e){ buffer.style.transform = `scaleX(0)`; }); }, 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(html[name] === undefined) return; let style = createElement(html[name]()); document.head.appendChild(style); if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]); elements[name] = style; }, }; const html = { bar: () => `
`, style: () => ` `, }; 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)}}); 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, f){ let targets = document.querySelectorAll(s); return f ? Array.from(targets).map(t => f(t)) : targets; }; const createElement = function(html = ''){ 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 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\?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); })();