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 提交的版本,檢視 最新版本

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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;');
  }
})();