YT: peek-a-pic

Hover a thumbnail at its bottom part and move the mouse horizontally to view the actual screenshots from the video

当前为 2019-08-29 提交的版本,查看 最新版本

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

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           YT: peek-a-pic
// @description    Hover a thumbnail at its bottom part and move the mouse horizontally to view the actual screenshots from the video
// @version        1.0.6

// @match          https://www.youtube.com/*

// @noframes
// @grant          none
// @run-at         document-start

// @author         wOxxOm
// @namespace      wOxxOm.scripts
// @license        MIT License
// ==/UserScript==

'use strict';

(() => {
  const ME = 'yt-peek-a-pic-storyboard';
  const SYMBOL = Symbol(ME);
  const START_DELAY = 100; // ms
  const HOVER_DELAY = .25; // s
  const HEIGHT_PCT = 25;
  const HEIGHT_HOVER_THRESHOLD = 1 - HEIGHT_PCT / 100;

  const ELEMENT = document.createElement('div');
  ELEMENT.className = ME;
  ELEMENT.style.setProperty('opacity', '0', 'important');
  ELEMENT.dataset.state = 'loading';
  ELEMENT.appendChild(document.createElement('div'));

  const queue = new WeakMap();

  document.addEventListener('mouseover', event => {
    if (event.target.classList.contains(ME))
      return;
    const thumb = event.target.closest('ytd-thumbnail');
    if (thumb &&
        !queue.has(thumb) &&
        !thumb.getElementsByClassName(ME)[0]) {
      const timer = setTimeout(start, START_DELAY, thumb);
      queue.set(thumb, {event, timer});
      thumb.addEventListener('mousemove', trackThumbCursor, {passive: true});
    }
  }, {passive: true});

  function start(thumb) {
    if (thumb.matches(':hover'))
      new Storyboard(thumb, queue.get(thumb).event);
    thumb.removeEventListener('mousemove', trackThumbCursor);
    queue.delete(thumb);
  }

  function trackThumbCursor(event) {
    // eslint-disable-next-line no-invalid-this
    queue.get(this).event = event;
  }

  /** @class Storyboard */
  class Storyboard {
    /**
     * @param {Element} thumb
     * @param {MouseEvent} event
     */
    constructor(thumb, event) {
      const {data} = thumb.__data || {};
      if (!data)
        return;
      /** @type {Element} */
      this.thumb = thumb;
      this.data = data;
      this.init(event);
    }

    /**
     * @param {MouseEvent} event
     */
    async init(event) {
      const y = event.pageY - this.thumb.offsetTop;
      let inHotArea = y >= this.thumb.offsetHeight * HEIGHT_HOVER_THRESHOLD;
      const x = inHotArea && event.pageX - this.thumb.offsetLeft;

      this.show();
      Storyboard.injectStyles();

      try {
        await this.fetchInfo();
        if (this.thumb.matches(':hover'))
          await this.prefetchImages(x);
      } catch (e) {
        this.element.title = 'Error loading storyboard';
        delete this.element.dataset.state;
        console.debug(e);
        return;
      }

      this.element.onmousemove = Storyboard.onmousemove;
      delete this.element.dataset.state;

      // recalculate as the mouse cursor may have left the area by now
      inHotArea = this.element.matches(':hover');

      this.tracker.style = important(`
        width: ${this.w - 1}px;
        height: ${this.h}px;
        ${inHotArea ? 'opacity: 1;' : ''}
      `);

      if (inHotArea) {
        Storyboard.onmousemove({target: this.element, offsetX: x});
        setTimeout(Storyboard.resetOpacity, 0, this.tracker);
      }
    }

    show() {
      this.element = ELEMENT.cloneNode(true);
      this.element[SYMBOL] = this;
      this.tracker = this.element.firstElementChild;
      this.thumb.appendChild(this.element);
      setTimeout(Storyboard.resetOpacity, HOVER_DELAY * 1e3, this.element);
    }

    async prefetchImages(x) {
      this.thumb.addEventListener('mouseleave', Storyboard.stopPrefetch, {once: true});
      const hoveredPart = Math.floor(this.calcHoveredIndex(x) / this.partlen);
      await new Promise(resolve => {
        const resolveFirstLoaded = {resolve};
        const numParts = Math.ceil((this.len - 1) / (this.rows * this.cols)) | 0;
        for (let p = 0; p < numParts; p++) {
          const el = document.createElement('link');
          el.as = 'image';
          el.rel = 'prefetch';
          el.href = this.calcPartUrl((hoveredPart + p) % numParts);
          el.onload = Storyboard.onImagePrefetched;
          el[SYMBOL] = resolveFirstLoaded;
          document.head.appendChild(el);
        }
      });
      this.thumb.removeEventListener('mouseleave', Storyboard.stopPrefetch);
    }

    async fetchInfo() {
      const url = 'https://www.youtube.com/get_video_info?' + new URLSearchParams({
        video_id: this.data.videoId,
        hl: 'en_US',
        html5: 1,
        el: 'embedded',
        eurl: location.href,
      }).toString();
      const txt = await (await fetch(url, {credentials: 'omit'})).text();
      // not using URLSearchParams because it's quite slow on long URLs
      const playerResponse = txt.match(/(^|&)player_response=(.+?)(&|$)|$/)[2] || '';
      const info = JSON.parse(decodeURIComponent(playerResponse));
      const [sbUrl, ...specs] = info.storyboards.playerStoryboardSpecRenderer.spec.split('|');
      const lastSpec = specs.pop();
      const numSpecs = specs.length;
      const [w, h, len, rows, cols, ...rest] = lastSpec.split('#');
      const sigh = rest.pop();
      this.w = w | 0;
      this.h = h | 0;
      this.len = len | 0;
      this.rows = rows | 0;
      this.cols = cols | 0;
      this.partlen = rows * cols | 0;
      const u = new URL(sbUrl.replace('$L/$N', `${numSpecs}/M0`));
      u.searchParams.set('sigh', sigh);
      this.url = u.href;
      this.seconds = info.videoDetails.lengthSeconds | 0;
    }

    calcPartUrl(part) {
      return this.url.replace(/M\d+\.jpg\?/, `M${part}.jpg?`);
    }

    calcHoveredIndex(offsetX) {
      const index = offsetX / this.thumb.clientWidth * (this.len + 1) | 0;
      return Math.max(0, Math.min(index, this.len - 1));
    }

    /**
     * @this Storyboard.element
     * @param {MouseEvent} e
     */
    static onmousemove(e) {
      const sb = /** @type {Storyboard} */ e.target[SYMBOL];
      const {style} = sb.tracker;

      const {offsetX} = e;
      const left = Math.min(this.clientWidth - sb.w, Math.max(0, offsetX - sb.w)) | 0;
      if (!style.left || parseInt(style.left) !== left)
        style.setProperty('left', left + 'px', 'important');

      let i = sb.calcHoveredIndex(offsetX);
      if (i === sb.oldIndex)
        return;

      if (sb.seconds)
        sb.tracker.dataset.time = new Date(0, 0, 0, 0, 0, i / (sb.len - 1) * sb.seconds)
          .toLocaleTimeString(undefined, {hourCycle: 'h24'})
          // strip 00:0 at the beginning but leave one 0 for minutes so it looks like 0:07
          .replace(/^0+:0?/, '');

      const part = i / sb.partlen | 0;
      if (!sb.oldIndex || part !== (sb.oldIndex / sb.partlen | 0))
        style.setProperty('background-image', `url(${sb.calcPartUrl(part)})`, 'important');

      sb.oldIndex = i;
      i %= sb.partlen;
      const x = (i % sb.cols) * sb.w;
      const y = (i / sb.cols | 0) * sb.h;
      style.setProperty('background-position', `-${x}px -${y}px`, 'important');
    }

    static onImagePrefetched(e) {
      e.target.remove();
      const r = e.target[SYMBOL];
      if (r && r.resolve) {
        r.resolve();
        delete r.resolve;
      }
    }

    static stopPrefetch(event) {
      try {
        const {videoId} = event.target.__data.data;
        const elements = document.head.querySelectorAll(`link[href*="/${videoId}/storyboard"]`);
        elements.forEach(el => el.remove());
        elements[0].onload();
      } catch (e) {}
    }

    static resetOpacity(el) {
      el.style.removeProperty('opacity');
    }

    static injectStyles() {
      const id = ME + '-style';
      let el = document.getElementById(id);
      if (el)
        return;
      el = document.createElement('style');
      el.id = id;
      el.textContent = /*language=CSS*/ important(`
        .${ME} {
          height: ${HEIGHT_PCT}%;
          max-height: 90px;
          position: absolute;
          left: 0;
          right: 0;
          bottom: 0;
          background-color: #0004;
          pointer-events: none;
          transition: opacity 1s ${HOVER_DELAY}s ease;
          opacity: 0;
        }
        ytd-thumbnail:hover .${ME} {
          pointer-events: auto;
          opacity: 1;
        }
        .${ME}:hover {
          background-color: #8228;
        }
        .${ME}:hover::before {
          position: absolute;
          left: 0;
          right: 0;
          bottom: 0;
          height: ${(100 / HEIGHT_PCT * 100).toFixed(1)}%;
          content: "";
          background-color: #000c;
          pointer-events: none;
          animation: .5s ${ME}-fadein;
          animation-fill-mode: both;
        }
        .${ME}[data-state]:hover::after {
          content: attr(data-state);
          position: absolute;
          font-weight: bold;
          color: #fff;
          bottom: 4px;
          left: 4px;
        }
        .${ME} div {
          position: absolute;
          bottom: 0;
          pointer-events: none;
          box-shadow: 2px 2px 10px 2px black;
          background-color: transparent;
          background-origin: content-box;
          opacity: 0;
          transition: opacity .25s .25s ease;
        }
        .${ME}:hover div {
          opacity: 1;
        }
        .${ME} div::after {
          content: attr(data-time);
          opacity: .5;
          color: #fff;
          background-color: #000;
          font-weight: bold;
          position: absolute;
          bottom: 4px;
          left: 4px;
          padding: 1px 3px;
        }
        @keyframes ${ME}-fadein {
          from {
            opacity: 0;
          }
          to {
            opacity: 1;
          }
        }
      `);
      document.head.appendChild(el);
    }
  }

  function important(str) {
    return str.replace(/;/g, '!important;');
  }
})();