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