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 2020-04-01. 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.12

// @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'));

document.addEventListener('mouseover', event => {
  const thumb = !event.target.classList.contains(ME) &&
                event.composedPath().find(isThumbnail);
  if (!thumb)
    return;
  const id = thumb.data.videoId;
  const my = thumb[SYMBOL] || 0;
  if (id === my.id && my.element)
    return;
  setTimeout(start, START_DELAY, thumb);
  thumb[SYMBOL] = {event, id};
  thumb.addEventListener('mousemove', trackThumbCursor, {passive: true});
}, {
  passive: true,
  capture: true,
});

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

function trackThumbCursor(event) {
  this[SYMBOL].event = event;
}

/** @class Storyboard */
class Storyboard {
  /** @param {Element} thumb */
  constructor(thumb) {
    const {event, id} = thumb[SYMBOL] || {};
    if (!id)
      return;
    /** @type {Element} */
    this.thumb = thumb;
    this.id = id;
    this.init(event);
  }

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

    const el = this.show();
    Storyboard.injectStyles();

    try {
      await this.fetchInfo();
      if (thumb.matches(':hover'))
        await this.prefetchImages(x);
    } catch (e) {
      el.dataset.state = typeof e === 'string' ? e : 'Error loading storyboard';
      setTimeout(Storyboard.destroy, 1000, thumb, el);
      console.debug(e);
      return;
    }

    el.onmousemove = Storyboard.onmousemove;
    delete el.dataset.state;

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

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

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

  show() {
    let el = this.thumb.getElementsByClassName(ME)[0];
    if (el)
      el.remove();
    el = this.element = ELEMENT.cloneNode(true);
    el[SYMBOL] = this;
    this.tracker = el.firstElementChild;
    this.thumb.appendChild(el);
    setTimeout(Storyboard.resetOpacity, HOVER_DELAY * 1e3, el);
    return el;
  }

  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.id,
      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));
    if (!info.storyboards)
      throw 'No storyboard in this video';
    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));
  }

  calcTime(index) {
    const sec = index / (this.len - 1 || 1) * this.seconds | 0;
    const h = sec / 3600 | 0;
    const m = (sec / 60) % 60 | 0;
    const s = sec % 60 | 0;
    return `${h ? h + ':' : ''}${m < 10 && h ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`;
  }

  /**
   * @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 = sb.calcTime(i);

    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 destroy(thumb, el) {
    el.remove();
    delete thumb[SYMBOL];
  }

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

  static stopPrefetch() {
    try {
      const {videoId} = this.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}[title] {
        height: ${HEIGHT_PCT / 3}%;
      }
      .${ME}[title]:hover::before {
        height: ${(100 / HEIGHT_PCT * 100 * 3).toFixed(1)}%;
      }
      .${ME}[data-state]:hover::after {
        content: attr(data-state);
        position: absolute;
        font-weight: bold;
        color: #fff8;
        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;');
}

function isThumbnail(el) {
  return el.localName === 'ytd-thumbnail';
}