您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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; }