YT: peek-a-pic

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

As of 2019-08-20 14:35:32 UTC. See the latest version.

// ==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.2

// @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 HEIGHT_PCT = 25;
  const HEIGHT_HOVER_THRESHOLD = 1 - HEIGHT_PCT / 100;

  const ELEMENT = document.createElement('div');
  ELEMENT.className = ME;
  ELEMENT.style.cssText = 'opacity:0 !important;';
  ELEMENT.dataset.loading = '';
  ELEMENT.appendChild(document.createElement('div'));

  const queue = new WeakSet();

  document.addEventListener('mouseover', function (e) {
    if (e.target.classList.contains(ME))
      return;
    const thumb = e.target.closest('ytd-thumbnail');
    if (thumb &&
        !queue.has(thumb) &&
        !thumb.getElementsByClassName(ME)[0]) {
      new Storyboard(thumb, e);
    }
  }, {passive: true});

  /** @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) {
      queue.add(this.thumb);

      const y = event.pageY - this.thumb.offsetTop;
      const 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) {
        console.debug(e);
        return;
      }

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

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

      if (inHotArea && this.element.matches(':hover')) {
        Storyboard.onmousemove({target: this.element, offsetX: x});
        setTimeout(Storyboard.resetOpacity, 1000, this.tracker);
      }

      queue.delete(this.thumb);
    }

    show() {
      this.element = ELEMENT.cloneNode(true);
      this.element[SYMBOL] = this;
      this.tracker = this.element.firstElementChild;
      this.thumb.appendChild(this.element);
      setTimeout(Storyboard.setFullOpacity, 0, this.element);
      setTimeout(Storyboard.resetOpacity, 0, 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 = (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;
    }

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

    calcHoveredIndex(offsetX) {
      const index = offsetX / this.thumb.clientWidth * (this.len + 1);
      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;

      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;
        delete e.target[SYMBOL];
      }
    }

    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 setFullOpacity(el) {
      el.style.setProperty('opacity', '1', 'important');
    }

    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: #f003;
          pointer-events: none;
          transition: opacity .5s .25s ease;
          opacity: 0;
        }
        ytd-thumbnail:hover .${ME} {
          pointer-events: auto;
          opacity: 1;
        }
        .${ME}:hover {
          background-color: #f006;
        }
        .${ME}:hover::before {
          position: absolute;
          top: -${((100 - HEIGHT_PCT) / HEIGHT_PCT * 100).toFixed(1)}%;
          left: 0;
          right: 0;
          bottom: 0;
          content: "";
          background-color: #000c;
          pointer-events: none;
          animation: .5s ${ME}-fadein;
          animation-fill-mode: both;
        }
        .${ME}[data-loading]:hover::after {
          content: "Loading...";
          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;
        }
        @keyframes ${ME}-fadein {
          from {
            opacity: 0;
          }
          to {
            opacity: 1;
          }
        }
      `);
      document.head.appendChild(el);
    }
  }

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