// ==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/*
// @version 0.9
// @grant none
// ==/UserScript==
(function(){
const SCRIPTID = 'YouTubeProgressBarPreserver';
const SCRIPTNAME = 'YouTube ProgressBar Preserver';
const DEBUG = false;/*
[todo]
カスタマイズ(color, height, opacity, 各表示モードでのオンオフ)
うっすら時刻表示オプションほしい?
[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 INTERVAL = 1000;/*for core.checkUrl*/
const STARTSWITH = [/*for core.checkUrl*/
'https://www.youtube.com/watch?v=',
];
const RETRY = 10;
let site = {
targets: {
player: () => $('#movie_player'),
video: () => $('video[src]'),
time: () => $('.ytp-time-display'),
},
is: {
live: (time) => time.classList.contains('ytp-live'),
},
};
let html, elements = {}, timers = {};
let core = {
initialize: function(){
html = document.documentElement;
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(core.html.bar());
let progress = elements.progress = bar.firstElementChild;
elements.player.appendChild(bar);
core.observeTime(elements.time, bar);
core.observeVideo(elements.video, progress);
},
observeTime: function(time, 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){
progress.style.transform = 'scaleX(0)';
video.addEventListener('timeupdate', function(e){
progress.style.transform = `scaleX(${video.currentTime / video.duration})`;
});
},
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: {
bar: () => `<div id="${SCRIPTID}-bar"><div id="${SCRIPTID}-progress"></div></div>`,
style: () => `
<style type="text/css">
#${SCRIPTID}-bar{
--height: 3px;
--background: rgba(255,255,255,.2);
--color: #f00;
--transition-bar: opacity .25s cubic-bezier(0.0,0.0,0.2,1);
--transition-progress: transform .25s linear;
--z-index: 100;
}
#${SCRIPTID}-bar{
width: 100%;
height: var(--height);
background: var(--background);
position: absolute;
bottom: 0;
transition: var(--transition-bar);
opacity: 0;
z-index: var(--z-index);
}
#${SCRIPTID}-progress{
width: 100%;
height: var(--height);
background: var(--color);
transition: var(--transition-progress);
transform-origin: 0 0;
}
.ytp-autohide #${SCRIPTID}-bar.active{
opacity: 1;
}
</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 secondsToTime = function(seconds){
let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0');
let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60);
if(h) return h + '時間' + zero(m) + '分' + zero(s) + '秒';
if(m) return m + '分' + zero(s) + '秒';
if(s) return s + '秒';
};
const timeToSeconds = function(time){
let parts = time.split(':').map(p => parseFloat(p));
switch(parts.length){
case(1): return parts[0];
case(2): return parts[0]*60 + parts[1];
case(3): return parts[0]*60*60 + parts[1]*60 + parts[2];
default: return 0;
}
};
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 toMetric = function(number, decimal = 1){
switch(true){
case(number < 1e3 ): return (number);
case(number < 1e6 ): return (number/1e3 ).toFixed(decimal) + 'K';
case(number < 1e9 ): return (number/1e6 ).toFixed(decimal) + 'M';
case(number < 1e12): return (number/1e9 ).toFixed(decimal) + 'G';
default: return (number/1e12).toFixed(decimal) + 'T';
}
};
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);
})();