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

// @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[SYMBOL] || {};
      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.dataset.state = typeof e === 'string' ? e : 'Error loading storyboard';
        setTimeout(Storyboard.destroy, 1000, this.element);
        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));
      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));
    }

    /**
     * @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 destroy(thumb) {
      thumb.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(event) {
      try {
        const {videoId} = event.target[SYMBOL].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;');
  }
})();