您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Hover a thumbnail at its bottom part and move the mouse horizontally to view the actual screenshots from the video
当前为
//#region Meta // ==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.13 // @match https://www.youtube.com/* // @noframes // @grant none // @run-at document-start // @author wOxxOm // @namespace wOxxOm.scripts // @license MIT License // ==/UserScript== //#endregion '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; //#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; } 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; ${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() { const url = 'https://www.youtube.com/get_video_info?' + new URLSearchParams({ video_id: this.id, 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)); } 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 = false; } } 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; }
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址