YouTube ProgressBar Preserver

It preserves YouTube's progress bar always visible even if the controls are hidden.

As of 14. 06. 2020. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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/*
// @exclude     https://www.youtube.com/live_chat*
// @exclude     https://www.youtube.com/live_chat_replay*
// @version     1.0.2
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTID = 'YouTubeProgressBarPreserver';
  const SCRIPTNAME = 'YouTube ProgressBar Preserver';
  const DEBUG = false;/*
[update] 1.0.
Thinner shadow.

[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/',
  ];
  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).then(() => {
        log("I'm ready.");
        core.appendBar();
        core.observeTime();
        core.observeVideo();
      }).catch(e => {
        console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
      });
    },
    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);
    },
    observeTime: function(){
      /* detect live for hiding the bar */
      let time = elements.time, bar = elements.bar;
      let detect = function(time, bar){
        if(site.is.live(time)) bar.classList.remove('active');
        else bar.classList.add('active');
      };
      detect(time, bar);
      if(time.isObservingAttributes) return;
      time.isObservingAttributes = true;
      let observer = observe(time, function(records){
        detect(time, bar);
      }, {attributes: true});
    },
    observeVideo: function(){
      let video = elements.video, progress = elements.progress, buffer = elements.buffer;
      if(video.isObservingForProgressBar) return;
      video.isObservingForProgressBar = true;
      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})`;
      });
      let renderBuffer = 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('progress', renderBuffer);
      video.addEventListener('seeking', renderBuffer);
    },
    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)));
    },
    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: () => `<div id="${SCRIPTID}-bar"><div id="${SCRIPTID}-progress"></div><div id="${SCRIPTID}-buffer"></div></div>`,
    style: () => `
      <style type="text/css">
        /* preserved bar */
        #${SCRIPTID}-bar{
          --height: 3px;
          --background: rgba(255,255,255,.2);
          --filter: drop-shadow(0px 0px calc(var(--height)/2) rgba(0,0,0,.5));
          --color: #f00;
          --ad-color: #fc0;
          --buffer-color: rgba(255,255,255,.4);
          --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,
        #${SCRIPTID}-buffer{
          width: 100%;
          height: var(--height);
          transform-origin: 0 0;
          position: absolute;
        }
        #${SCRIPTID}-progress.transition,
        #${SCRIPTID}-buffer{
          transition: var(--transition-progress);
        }
        #${SCRIPTID}-progress{
          background: var(--color);
          filter: var(--filter);
          z-index: 1;
        }
        #${SCRIPTID}-buffer{
          background: var(--buffer-color);
        }
        .ad-interrupting/*advertisement*/ #${SCRIPTID}-progress{
          background: var(--ad-color);
        }
        /* replace the original bar */
        .ytp-autohide #${SCRIPTID}-bar.active{
          opacity: 1;
        }
        /* replace the bar for an ad */
        .ytp-ad-persistent-progress-bar-container/*YouTube offers progress bar only when an ad is showing, but it doesn't have transition animation*/{
          display: none
        }
      </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)}});
  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 = '<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 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;
  });
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
})();