// ==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.16
//
// @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 MAX_THUMB_SIZE = 200; // px
const HEIGHT_PCT = 25;
const HEIGHT_HOVER_THRESHOLD = 1 - HEIGHT_PCT / 100;
const requests = {};
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: #0004;
pointer-events: none;
transition: opacity 1s ${HOVER_DELAY}s ease;
opacity: 0;
z-index: 10000;
}
ytd-thumbnail:hover .${ME} {
pointer-events: auto;
opacity: 1;
}
.${ME}:hover {
background-color: #0004;
}
.${ME}:hover::before {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: ${(100 / HEIGHT_PCT * 100).toFixed(1)}%;
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} 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}-fadeout {
from {
opacity: 1;
}
to {
opacity: .25;
}
}`);
const STYLE_HOVER = /*language=CSS*/ important(`
ytd-thumbnail:not(#\\0):hover a.ytd-thumbnail {
opacity: .2;
transition: opacity .75s .25s;
}
ytd-thumbnail:not(#\\0):hover::before {
background-color: transparent;
}`);
//#endregion
const ELEMENT = document.createElement('div');
ELEMENT.className = ME;
ELEMENT.style.setProperty('opacity', '0', 'important');
ELEMENT.dataset.state = 'loading';
ELEMENT.appendChild(document.createElement('div'));
let elStyle;
let elStyleHover;
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;
el.onmouseleave = () => (elStyleHover.disabled = true);
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;
transform: scale(${MAX_THUMB_SIZE / Math.max(this.w, this.h)});
${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() {
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;
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(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) {
elStyleHover.disabled = false;
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];
elStyleHover.disabled = true;
}
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() {
elStyle = makeStyleElement(elStyle, STYLE_MAIN);
elStyleHover = makeStyleElement(elStyleHover, STYLE_HOVER);
elStyleHover.disabled = true;
}
}
function important(str) {
return str.replace(/;/g, '!important;');
}
function isThumbnail(el) {
return el.localName === 'ytd-thumbnail';
}
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;
}