Greasy Fork is available in English.

YT: peek-a-pic

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==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.1.0
//
// @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 THUMB = ['yt-thumbnail-view-model', 'ytd-thumbnail'];
const START_DELAY = 50; // ms
const HOVER_DELAY = 250; // ms
const HEIGHT_PCT = 25;
const HEIGHT_HOVER_THRESHOLD = 1 - HEIGHT_PCT / 100;
const requests = {};
/** @type {Map<Element,Object|Storyboard>} */
const registry = new WeakMap();
const getVideoId = (el, deep) =>
  (deep ? el = el.querySelector('a[href*="?v="], img[src*="ytimg.com/vi"]') : el) &&
  (el = (el.search || el.src).match(/(?:\?v=|\/vi\w*\/)([^&/]+)/)) &&
  el[1];
let API_DATA, API_URL;

//#region Styles
const STYLE_MAIN = /*language=CSS*/ important(`
  .${ME} {
    height: ${HEIGHT_PCT}%;
    max-height: 90px;
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: #0006;
    pointer-events: none;
    transition: opacity ${HOVER_DELAY}ms ease;
    opacity: 0;
    z-index: 10000;
  }
  .ytp-suggestion-set:hover .${ME},
  :is(:is(${THUMB})):hover .${ME} {
    pointer-events: auto;
  }
  .${ME}:hover {
    opacity: 1;
  }
  .${ME}:hover::before {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: ${100 / HEIGHT_PCT * 100}%;
    content: "";
    pointer-events: none;
  }
  .${ME}[title] {
    height: ${HEIGHT_PCT / 3}%;
  }
  .${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} span {
    position: absolute;
    width: 100%;
    height: 4px;
    bottom: 0;
  }
  .${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;
  }`);
const STYLE_HOVER = /*language=CSS*/ important(`
  :is(${THUMB}):not(#\\0):hover a.ytd-thumbnail {
    opacity: .2;
    transition: opacity .75s .25s;
  }
  :is(${THUMB}):not(#\\0):hover::before {
    background-color: transparent;
  }`);
//#endregion

const ELEMENT = document.createElement('div');
ELEMENT.className = ME;
ELEMENT.dataset.state = 'loading';
ELEMENT.appendChild(document.createElement('div'))
  .appendChild(document.createElement('span'));
importantProp(ELEMENT, 'opacity', '0');
let elStyle;
let elStyleHover;

document.addEventListener('mouseover', event => {
  if (event.target.classList.contains(ME))
    return;
  for (const el of event.composedPath()) {
    let id = el.localName;
    if (THUMB.includes(id) ? id = getVideoId(el, true)
      : id === 'a' && el.classList.contains('ytp-suggestion-set') && (id = getVideoId(el))
    ) {
      let sb = registry.get(el);
      if (sb && sb.id !== id) {
        registry.delete(el);
        sb = null;
      }
      if (!sb) {
        setTimeout(start, START_DELAY, el);
        registry.set(el, {event, id});
        el.addEventListener('mousemove', trackThumbCursor, {passive: true});
      }
      return;
    }
  }
}, {
  passive: true,
  capture: true,
});

function start(thumb) {
  if (thumb.matches(':hover'))
    new Storyboard(thumb);
  else // mouse moved somewhere else
    registry.delete(thumb);
  thumb.removeEventListener('mousemove', trackThumbCursor);
}

function trackThumbCursor(event) {
  (registry.get(this) || {}).event = event;
}

/** @class Storyboard */
class Storyboard {
  /** @param {HTMLElement} thumb */
  constructor(thumb) {
    const {event, id} = registry.get(thumb) || {};
    if (!id)
      return;
    /** @type {HTMLElement} */
    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.offsetX;

    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;
    el.onmouseleave = Storyboard.onmouseleave;
    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}px;
      height: ${this.h}px;
      ${inHotArea ? 'opacity: 1;' : ''}
    `);

    if (inHotArea) {
      Storyboard.onmousemove.call(el, event);
      setTimeout(Storyboard.resetOpacity, 0, this.tracker);
    }
  }

  show() {
    let el = this.thumb.getElementsByClassName(ME)[0];
    if (el)
      el.remove();
    el = this.element = ELEMENT.cloneNode(true);
    registry.set(el, this);
    this.pct = (this.tracker = el.firstElementChild).firstElementChild;
    this.thumb.appendChild(el);
    setTimeout(Storyboard.resetOpacity, HOVER_DELAY, 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;
        registry.set(el, resolveFirstLoaded);
        document.head.appendChild(el);
      }
    });
    this.thumb.removeEventListener('mouseleave', Storyboard.stopPrefetch);
  }

  async fetchInfo() {
    if (!API_DATA) {
      API_DATA = (window.wrappedJSObject || window).ytcfg.data_;
      API_URL = 'https://www.youtube.com/youtubei/v1/player?key=' + API_DATA.INNERTUBE_API_KEY;
    }
    const {id} = this;
    const info = await (requests[id] || (requests[id] = this.fetch()));
    delete requests[id];
    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;
    this.frac100 = len > 1 && (len - 2) / len;
    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;
  }

  async fetch() {
    return (await fetch(API_URL, {
      body: JSON.stringify({
        videoId: this.id,
        context: API_DATA.INNERTUBE_CONTEXT,
      }), method: 'POST',
    })).json();
  }

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

  calcHoveredIndex(fraction) {
    const index = fraction * (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 {HTMLElement}
   * @param {MouseEvent} e
   */
  static onmousemove(e) {
    elStyleHover.disabled = false;
    importantProp(this, 'z-index', '11001'); // "YT: not interested in one click" + 1
    const sb = registry.get(this);
    const {tracker} = sb;
    const {offsetX} = e;
    const thWidth = this.clientWidth;
    const frac = offsetX / thWidth;
    const i = sb.calcHoveredIndex(frac);
    const pct = Math.round(frac >= sb.frac100 ? 100 : frac * 100) + '%';
    const left = Math.max(0, Math.min(thWidth - sb.w, offsetX)).toFixed(0);
    if (left !== /\d+|$/.exec(tracker.style.transform)[0])
      importantProp(tracker, 'transform', `translate(${left}px,0)`);
    if (pct !== /\d+%|$/.exec(sb.pct.style.background)[0])
      importantProp(sb.pct, 'background',
        `linear-gradient(to right,#888 ${pct},#444 calc(${pct} + 1px))`);

    if (i === sb.oldIndex)
      return;

    if (sb.seconds)
      tracker.dataset.time = sb.calcTime(i);

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

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

  /** @this {HTMLElement} */
  static onmouseleave() {
    elStyleHover.disabled = true;
    this.style.removeProperty('z-index');
  }

  static destroy(thumb, el) {
    el.remove();
    registry.delete(thumb);
    elStyleHover.disabled = true;
  }

  static onImagePrefetched(e) {
    e.target.remove();
    const r = registry.get(e.target);
    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() {
    elStyle = makeStyleElement(elStyle, STYLE_MAIN);
    elStyleHover = makeStyleElement(elStyleHover, STYLE_HOVER);
    elStyleHover.disabled = true;
  }
}

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

function importantProp(el, name, value) {
  el.style.setProperty(name, value, 'important');
}

function makeStyleElement(el, css) {
  if (!el)
    el = document.createElement('style');
  if (el.textContent !== css)
    el.textContent = css;
  if (el.parentElement !== document.head)
    document.head.appendChild(el);
  return el;
}