// ==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.5
// @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.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.__data || {};
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) {
console.debug(e);
return;
}
this.element.onmousemove = Storyboard.onmousemove;
delete this.element.dataset.loading;
// 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 = (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));
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;
}
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;
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 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.__data.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 setFullOpacity(el) {
el.style.setProperty('opacity', '1', 'important');
}
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}[data-loading]:hover::after {
content: "Loading...";
position: absolute;
font-weight: bold;
color: #fff;
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;
}
@keyframes ${ME}-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`);
document.head.appendChild(el);
}
}
function important(str) {
return str.replace(/;/g, '!important;');
}
})();