YouTube ProgressBar Preserver

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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]
No updates on code. Just confirmed to work.

[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);
})();