Greasy Fork

Greasy Fork is available in English.

YouTube ProgressBar Preserver

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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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/*
// @version     0.13.0
// @grant       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: () => `<div id="${SCRIPTID}-bar"><div id="${SCRIPTID}-progress"></div><div id="${SCRIPTID}-buffer"></div></div>`,
    style: () => `
      <style type="text/css">
        /* bar */
        #${SCRIPTID}-bar{
          --height: 3px;
          --background: rgba(255,255,255,.2);
          --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);
          z-index: 1;
        }
        #${SCRIPTID}-buffer{
          background: var(--buffer-color);
        }
        .ad-interrupting/*advertisement*/ #${SCRIPTID}-progress{
          background: var(--ad-color);
        }
        /* replace the 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)}});
  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 = '<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;
  });
  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);
})();