// ==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;
}