YouTube ProgressBar Preserver

让你恒常地显示油管上的进度条(显示播放时间比例的红色条)。

当前为 2020-04-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube ProgressBar Preserver
  3. // @name:ja YouTube ProgressBar Preserver
  4. // @name:zh-CN YouTube ProgressBar Preserver
  5. // @description It preserves YouTube's progress bar always visible even if the controls are hidden.
  6. // @description:ja YouTubeのプログレスバー(再生時刻の割合を示す赤いバー)を、隠さず常に表示させるようにします。
  7. // @description:zh-CN 让你恒常地显示油管上的进度条(显示播放时间比例的红色条)。
  8. // @namespace knoa.jp
  9. // @include https://www.youtube.com/*
  10. // @include https://www.youtube-nocookie.com/embed/*
  11. // @version 0.13.0
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. (function(){
  16. const SCRIPTID = 'YouTubeProgressBarPreserver';
  17. const SCRIPTNAME = 'YouTube ProgressBar Preserver';
  18. const DEBUG = false;/*
  19. [update] 0.13.0
  20. Highlight the buffered range, like YouTube's default.
  21.  
  22. [bug]
  23. 一度ビデオが終わって(次の動画に進んで?)から戻ると更新されない?
  24.  
  25. [todo]
  26. カスタマイズ
  27. 表示対象
  28. 通常時
  29. フルスクリーン時
  30. 外部埋め込み時
  31. 表示形式
  32. 色 ←赤でいいのでは
  33. 高さ ←必要?
  34. 透明度
  35. バー用の領域を別途確保する(動画を隠さない) ←ほんとに必要?
  36. 通常時
  37. フルスクリーン時
  38. 時刻表示
  39. 透明度
  40. 位置 ←固定でいいか
  41. 色 ←白(+影)に固定でいいよね
  42.  
  43. [research]
  44. timeupdateの間隔ぶんだけ遅れてしまうのはうまく改善できるかどうか
  45. timeupdateきっかけで250msをキープするような仕組みでいける?
  46. もっとも、時間の短い広告時くらいしか知覚できないけど。
  47.  
  48. [memo]
  49. YouTubeによって隠されているときはオリジナルのバーは更新されないので、独自に作るほうがラク。
  50. 0.9完成後、youtube progressbar で検索したところすでに存在していることを発見\(^o^)/
  51. https://addons.mozilla.org/ja/firefox/addon/progress-bar-for-youtube/
  52. カスタマイズできるが、生放送に対応していない。プログレスが最低0.5秒単位でtransitionもない。
  53. */
  54. if(window === top && console.time) console.time(SCRIPTID);
  55. const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  56. const INTERVAL = 1*SECOND;/*for core.checkUrl*/
  57. const SHORTDURATION = 4*MINUTE / 1000;/*short video should have transition*/
  58. const STARTSWITH = [/*for core.checkUrl*/
  59. 'https://www.youtube.com/watch?',
  60. 'https://www.youtube.com/embed/',
  61. 'https://www.youtube-nocookie.com/embed/',
  62. ];
  63. const RETRY = 10;
  64. let site = {
  65. targets: {
  66. player: () => $('.html5-video-player'),
  67. video: () => $('video[src]'),
  68. time: () => $('.ytp-time-display'),
  69. },
  70. is: {
  71. live: (time) => time.classList.contains('ytp-live'),
  72. },
  73. };
  74. let elements = {}, timers = {};
  75. let core = {
  76. initialize: function(){
  77. elements.html = document.documentElement;
  78. elements.html.classList.add(SCRIPTID);
  79. core.checkUrl();
  80. core.addStyle();
  81. },
  82. checkUrl: function(){
  83. let previousUrl = '';
  84. timers.checkUrl = setInterval(function(){
  85. if(document.hidden) return;
  86. /* The page is visible, so... */
  87. if(location.href === previousUrl) return;
  88. else previousUrl = location.href;
  89. /* The URL has changed, so... */
  90. if(STARTSWITH.some(url => location.href.startsWith(url)) === false) return;
  91. /* This page should be modified, so... */
  92. core.ready();
  93. }, INTERVAL);
  94. },
  95. ready: function(){
  96. core.getTargets(site.targets, RETRY).then(() => {
  97. log("I'm ready.");
  98. core.appendBar();
  99. });
  100. },
  101. appendBar: function(){
  102. if(elements.bar && elements.bar.isConnected) return;
  103. let bar = elements.bar = createElement(html.bar());
  104. let progress = elements.progress = bar.firstElementChild;
  105. let buffer = elements.buffer = bar.lastElementChild;
  106. elements.player.appendChild(bar);
  107. core.observeTime(elements.time, bar);
  108. core.observeVideo(elements.video, progress, buffer);
  109. },
  110. observeTime: function(time, bar){
  111. /* detect live for hiding the bar */
  112. let detect = function(time, bar){
  113. if(site.is.live(time)) bar.classList.remove('active');
  114. else bar.classList.add('active');
  115. };
  116. detect(time, bar);
  117. let observer = observe(time, function(records){
  118. detect(time, bar);
  119. }, {attributes: true});
  120. },
  121. observeVideo: function(video, progress, buffer){
  122. if(video.duration < SHORTDURATION) progress.classList.add('transition');
  123. progress.style.transform = 'scaleX(0)';
  124. video.addEventListener('durationchange', function(e){
  125. if(video.duration < SHORTDURATION) progress.classList.add('transition');
  126. else progress.classList.remove('transition');
  127. });
  128. video.addEventListener('timeupdate', function(e){
  129. progress.style.transform = `scaleX(${video.currentTime / video.duration})`;
  130. });
  131. video.addEventListener('progress', function(e){
  132. for(let i = video.buffered.length - 1; 0 <= i; i--){
  133. if(video.currentTime < video.buffered.start(i)) continue;
  134. buffer.style.transform = `scaleX(${video.buffered.end(i) / video.duration})`;
  135. break;
  136. }
  137. });
  138. video.addEventListener('seeking', function(e){
  139. buffer.style.transform = `scaleX(0)`;
  140. });
  141. },
  142. getTargets: function(targets, retry = 0){
  143. const get = function(resolve, reject, retry){
  144. for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
  145. let selected = targets[key]();
  146. if(selected){
  147. if(selected.length) selected.forEach((s) => s.dataset.selector = key);
  148. else selected.dataset.selector = key;
  149. elements[key] = selected;
  150. }else{
  151. if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
  152. log(`Not found: ${key}, retrying... (left ${retry})`);
  153. return setTimeout(get, 1000, resolve, reject, retry);
  154. }
  155. }
  156. resolve();
  157. };
  158. return new Promise(function(resolve, reject){
  159. get(resolve, reject, retry);
  160. });
  161. },
  162. addStyle: function(name = 'style'){
  163. if(html[name] === undefined) return;
  164. let style = createElement(html[name]());
  165. document.head.appendChild(style);
  166. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  167. elements[name] = style;
  168. },
  169. };
  170. const html = {
  171. bar: () => `<div id="${SCRIPTID}-bar"><div id="${SCRIPTID}-progress"></div><div id="${SCRIPTID}-buffer"></div></div>`,
  172. style: () => `
  173. <style type="text/css">
  174. /* bar */
  175. #${SCRIPTID}-bar{
  176. --height: 3px;
  177. --background: rgba(255,255,255,.2);
  178. --color: #f00;
  179. --ad-color: #fc0;
  180. --buffer-color: rgba(255,255,255,.4);
  181. --transition-bar: opacity .25s cubic-bezier(0.0,0.0,0.2,1);
  182. --transition-progress: transform .25s linear;
  183. --z-index: 100;
  184. }
  185. #${SCRIPTID}-bar{
  186. width: 100%;
  187. height: var(--height);
  188. background: var(--background);
  189. position: absolute;
  190. bottom: 0;
  191. transition: var(--transition-bar);
  192. opacity: 0;
  193. z-index: var(--z-index);
  194. }
  195. #${SCRIPTID}-progress,
  196. #${SCRIPTID}-buffer{
  197. width: 100%;
  198. height: var(--height);
  199. transform-origin: 0 0;
  200. position: absolute;
  201. }
  202. #${SCRIPTID}-progress.transition,
  203. #${SCRIPTID}-buffer{
  204. transition: var(--transition-progress);
  205. }
  206. #${SCRIPTID}-progress{
  207. background: var(--color);
  208. z-index: 1;
  209. }
  210. #${SCRIPTID}-buffer{
  211. background: var(--buffer-color);
  212. }
  213. .ad-interrupting/*advertisement*/ #${SCRIPTID}-progress{
  214. background: var(--ad-color);
  215. }
  216. /* replace the bar */
  217. .ytp-autohide #${SCRIPTID}-bar.active{
  218. opacity: 1;
  219. }
  220. /* replace the bar for an ad */
  221. .ytp-ad-persistent-progress-bar-container/*YouTube offers progress bar only when an ad is showing, but it doesn't have transition animation*/{
  222. display: none
  223. }
  224. </style>
  225. `,
  226. };
  227. 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);
  228. const alert = window.alert.bind(window), confirm = window.confirm.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window);
  229. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  230. class Storage{
  231. static key(key){
  232. return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
  233. }
  234. static save(key, value, expire = null){
  235. key = Storage.key(key);
  236. localStorage[key] = JSON.stringify({
  237. value: value,
  238. saved: Date.now(),
  239. expire: expire,
  240. });
  241. }
  242. static read(key){
  243. key = Storage.key(key);
  244. if(localStorage[key] === undefined) return undefined;
  245. let data = JSON.parse(localStorage[key]);
  246. if(data.value === undefined) return data;
  247. if(data.expire === undefined) return data;
  248. if(data.expire === null) return data.value;
  249. if(data.expire < Date.now()) return localStorage.removeItem(key);
  250. return data.value;
  251. }
  252. static delete(key){
  253. key = Storage.key(key);
  254. delete localStorage.removeItem(key);
  255. }
  256. static saved(key){
  257. key = Storage.key(key);
  258. if(localStorage[key] === undefined) return undefined;
  259. let data = JSON.parse(localStorage[key]);
  260. if(data.saved) return data.saved;
  261. else return undefined;
  262. }
  263. }
  264. const $ = function(s, f){
  265. let target = document.querySelector(s);
  266. if(target === null) return null;
  267. return f ? f(target) : target;
  268. };
  269. const $$ = function(s, f){
  270. let targets = document.querySelectorAll(s);
  271. return f ? Array.from(targets).map(t => f(t)) : targets;
  272. };
  273. const createElement = function(html = '<span></span>'){
  274. let outer = document.createElement('div');
  275. outer.innerHTML = html;
  276. return outer.firstElementChild;
  277. };
  278. const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
  279. let observer = new MutationObserver(callback.bind(element));
  280. observer.observe(element, options);
  281. return observer;
  282. };
  283. const log = function(){
  284. if(!DEBUG) return;
  285. let l = log.last = log.now || new Date(), n = log.now = new Date();
  286. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  287. //console.log(error.stack);
  288. console.log(
  289. (SCRIPTID || '') + ':',
  290. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  291. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  292. /* :00 */ ':' + line,
  293. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  294. /* caller */ (callers[1] || '') + '()',
  295. ...arguments
  296. );
  297. };
  298. log.formats = [{
  299. name: 'Firefox Scratchpad',
  300. detector: /MARKER@Scratchpad/,
  301. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  302. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  303. }, {
  304. name: 'Firefox Console',
  305. detector: /MARKER@debugger/,
  306. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  307. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  308. }, {
  309. name: 'Firefox Greasemonkey 3',
  310. detector: /\/gm_scripts\//,
  311. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  312. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  313. }, {
  314. name: 'Firefox Greasemonkey 4+',
  315. detector: /MARKER@user-script:/,
  316. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  317. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  318. }, {
  319. name: 'Firefox Tampermonkey',
  320. detector: /MARKER@moz-extension:/,
  321. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  322. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  323. }, {
  324. name: 'Chrome Console',
  325. detector: /at MARKER \(<anonymous>/,
  326. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  327. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  328. }, {
  329. name: 'Chrome Tampermonkey',
  330. detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
  331. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
  332. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  333. }, {
  334. name: 'Chrome Extension',
  335. detector: /at MARKER \(chrome-extension:/,
  336. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  337. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  338. }, {
  339. name: 'Edge Console',
  340. detector: /at MARKER \(eval/,
  341. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  342. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  343. }, {
  344. name: 'Edge Tampermonkey',
  345. detector: /at MARKER \(Function/,
  346. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  347. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  348. }, {
  349. name: 'Safari',
  350. detector: /^MARKER$/m,
  351. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  352. getCallers: (e) => e.stack.split('\n'),
  353. }, {
  354. name: 'Default',
  355. detector: /./,
  356. getLine: (e) => 0,
  357. getCallers: (e) => [],
  358. }];
  359. log.format = log.formats.find(function MARKER(f){
  360. if(!f.detector.test(new Error().stack)) return false;
  361. //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
  362. return true;
  363. });
  364. const time = function(label){
  365. if(!DEBUG) return;
  366. const BAR = '|', TOTAL = 100;
  367. switch(true){
  368. case(label === undefined):/* time() to output total */
  369. let total = 0;
  370. Object.keys(time.records).forEach((label) => total += time.records[label].total);
  371. Object.keys(time.records).forEach((label) => {
  372. console.log(
  373. BAR.repeat((time.records[label].total / total) * TOTAL),
  374. label + ':',
  375. (time.records[label].total).toFixed(3) + 'ms',
  376. '(' + time.records[label].count + ')',
  377. );
  378. });
  379. time.records = {};
  380. break;
  381. case(!time.records[label]):/* time('label') to create and start the record */
  382. time.records[label] = {count: 0, from: performance.now(), total: 0};
  383. break;
  384. case(time.records[label].from === null):/* time('label') to re-start the lap */
  385. time.records[label].from = performance.now();
  386. break;
  387. case(0 < time.records[label].from):/* time('label') to add lap time to the record */
  388. time.records[label].total += performance.now() - time.records[label].from;
  389. time.records[label].from = null;
  390. time.records[label].count += 1;
  391. break;
  392. }
  393. };
  394. time.records = {};
  395. core.initialize();
  396. if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
  397. })();