您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
To restore animated thumbnail previews. Requires inline video previews to be disabled in your YouTube user settings (Go to https://www.youtube.com/account_playback and set "video previews" to disabled). Not Greasemonkey compatible. v5 Add new method for getting an_webp GIF-style thumbs when not available in YT's new homepage UI or subscription page UI.
当前为
// ==UserScript== // @name Restore animated thumbnail previews - youtube.com // @namespace Violentmonkey Scripts seekhare // @match *://www.youtube.com/* // @run-at document-start // @grant GM_addStyle // @grant GM_xmlhttpRequest // @version 5.2 // @license MIT // @author seekhare // @description To restore animated thumbnail previews. Requires inline video previews to be disabled in your YouTube user settings (Go to https://www.youtube.com/account_playback and set "video previews" to disabled). Not Greasemonkey compatible. v5 Add new method for getting an_webp GIF-style thumbs when not available in YT's new homepage UI or subscription page UI. // ==/UserScript== const logHeader = 'UserScript Restore YT Animated Thumbs:'; console.log(logHeader, "enabled.") Object.defineProperties(Object.prototype,{isPreviewDisabled:{get:function(){return false}, set:function(){}}}); // original method //2025-07-12 added animatedThumbnailEnabled & inlinePreviewEnabled for new sidebar UI on watch page. Object.defineProperties(Object.prototype,{animatedThumbnailEnabled:{get:function(){return true}, set:function(){}}}); Object.defineProperties(Object.prototype,{inlinePreviewEnabled:{get:function(){return false}, set:function(){}}}); //2025-07-28 Don't enable the below as seems to break things but I'm leaving here in case of future Youtube change, for reference if needed in future fixes. //Object.defineProperties(Object.prototype,{isInlinePreviewEnabled:{get:function(){return true}, set:function(){return true}}}); //Object.defineProperties(Object.prototype,{isInlinePreviewDisabled:{get:function(){return true}, set:function(){return true}}}); //Object.defineProperties(Object.prototype,{inlinePreviewIsActive:{get:function(){return false}, set:function(){}}}); //Object.defineProperties(Object.prototype,{inlinePreviewIsEnabled:{get:function(){return false}, set:function(){}}}); fadeInCSS = `img.animatedThumbTarget { animation: fadeIn 0.5s; object-fit: cover;} @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } `; GM_addStyle(fadeInCSS); const homeUrl = 'https://www.youtube.com/'; const searchUrl = 'https://www.youtube.com/results?search_query='; // use like "https://www.youtube.com/results?search_query=IDabc123", for anonymous requests is rate limited const ytImageRootUrl = 'https://i.ytimg.com/'; const ytImageNames = ['hq1.jpg', 'hq2.jpg', 'hq3.jpg']; // e.g. https://i.ytimg.com/vi/UujGYE5mOnI/hq1.jpg const carouselDelay = 500; //milliseconds, how long to display each image. var an_webpUrlDictionary = {}; //store prefetched an_webp urls for videoIDs in homepage/subscription page of new YT lockup style UI. var an_webpUrlDictionaryFailedCount = {}; //store failed fetch attempt count to give up after certain number. const updateDictionaryBatchSize = 10; const updateDictionaryFailedCountLimit = 5; const an_webpDictionaryWorkerInterval = 3500; //milliseconds async function animatedThumbsEventEnter(event) { //console.debug(logHeader, 'enter', event); var target = event.target; //console.debug(logHeader, 'target', target); //Below are some exceptions where we don't want to apply the carousel fallback and can't except these in the mutation observer as child elements are not present then. if (target.querySelector('yt-lockup-view-model') == null ) { // only apply to new grid tiles UI //skip but don't remove event listeners as if navigate to a watch page then back a new video could get the existing element and need the event listener back. return false } if (target.querySelector('badge-shape.badge-shape-wiz--thumbnail-live') != null) { // don't apply to video tiles that are live. //skip but don't remove event listeners as if navigate to a watch page then back a new video could get the existing element and need the event listener back. return false } else if (target.querySelector('button[title="You\'ll be notified at the scheduled start time."]') != null) { // don't apply to video tiles that upcoming notifications. //skip but don't remove event listeners as if navigate to a watch page then back a new video could get the existing element and need the event listener back. return false } else if (target.querySelector('path[d="M2.81,2.81L1.39,4.22L8,10.83V19l4.99-3.18l6.78,6.78l1.41-1.41L2.81,2.81z M10,15.36v-2.53l1.55,1.55L10,15.36z"]') != null) { // don't apply to video tiles that have inline videos disabled by YT as these have the an_webp thumbs available, these videos have a crossed out play icon SVG but otherwise no other identifier hence the strange selector. //skip but don't remove event listeners as if navigate to a watch page then back a new video could get the existing element and need the event listener back. return false } //Overlay target to attach created image node should be present var overlaytag = target.querySelector('div.yt-thumbnail-view-model__image'); if (overlaytag == null) { //skip but don't remove event listeners as if navigate to a watch page then back a new video could get the existing element and need the event listener back. return false } var atag = target.querySelector('a'); //console.debug(logHeader, 'atag', atag); if (atag.videoId === undefined) { //extract videoId from href and store on an attribute var videoId = atag.getAttribute('href').match(/watch\?v=([^&]*)/)[1]; //the href is like "/watch?v=IDabc123&t=123" so regex. //console.debug(logHeader, 'videoId', videoId); atag.videoId = videoId; } if (atag.animatedThumbType === undefined || atag.animatedThumbType == 'carousel') { //do search url request to get animated thumb URL and store on attribute "srcAnimated" var thumbUrl = getAnimatedThumbURLDictionary(atag.videoId); if (thumbUrl != null) { atag.animatedThumbType = 'an_webp'; atag.srcAnimated = thumbUrl; } else { //if no animated thumb available use carousel fallback. atag.animatedThumbType = 'carousel'; } } var animatedImgNode = document.createElement("img"); animatedImgNode.videoId = atag.videoId; animatedImgNode.setAttribute("id", "thumbnail"); animatedImgNode.setAttribute("class", "style-scope ytd-moving-thumbnail-renderer fade-in animatedThumbTarget"); //animatedThumbTarget is custom class, others are Youtube if (atag.animatedThumbType == 'an_webp') { animatedImgNode.setAttribute("src", atag.srcAnimated); } else if (atag.animatedThumbType == 'carousel') { animatedImgNode.carouselIndex = 0; updateCarousel(animatedImgNode); animatedImgNode.timer = setInterval(updateCarousel, carouselDelay, animatedImgNode); } overlaytag.appendChild(animatedImgNode); return true } async function animatedThumbsEventLeave(event) { //console.debug(logHeader, 'leave', event); try { var animatedImgNodeList = event.target.querySelectorAll('img.animatedThumbTarget'); for (let animatedImgNode of animatedImgNodeList) { clearTimeout(animatedImgNode.timer); animatedImgNode.remove(); } } catch { return false } return true } function updateCarousel(carouselImgNode) { var index = carouselImgNode.carouselIndex; //console.debug(logHeader, 'index', index); var imgURL = ytImageRootUrl + 'vi/' + carouselImgNode.videoId + '/' + ytImageNames[index]; carouselImgNode.setAttribute("src", imgURL); var nextIndex = (index+1) % ytImageNames.length; carouselImgNode.carouselIndex = nextIndex; } function makeGetRequest(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, anonymous: true, // make request anonymous, without cookies so doesn't affect user's search history. onload: response => resolve(response), onerror: error => reject(error) }); }); } function getAnimatedThumbURLDictionary(videoId) { if (an_webpUrlDictionary[videoId] != undefined) { return an_webpUrlDictionary[videoId]['url'] } else { return null } } function runPageCheckForExistingElements() { //Can run this just incase some elements were already created before observer set up. var list = document.getElementsByTagName("ytd-rich-item-renderer"); for (let element of list) { //console.debug(logHeader, element); element.addEventListener('mouseenter', animatedThumbsEventEnter); element.addEventListener('mouseleave', animatedThumbsEventLeave); } } function setupMutationObserver() { console.log(logHeader, "Enabling carousel fallback where an_webp not available.") const targetNode = document; //console.debug('targetNodeInit',targetNode); const config = {attributes: false, childList: true, subtree: true}; const callback = (mutationList, observer) => { for (const mutation of mutationList) { //console.debug(logHeader, "Mutation", mutation); for (const element of mutation.addedNodes) { if (element.nodeName === 'YTD-RICH-ITEM-RENDERER') { //console.debug(logHeader, "Adding event listeners to element", element); element.addEventListener('mouseenter', animatedThumbsEventEnter); element.addEventListener('mouseleave', animatedThumbsEventLeave); } } } } const observer = new MutationObserver(callback); observer.observe(targetNode, config); runPageCheckForExistingElements(); } document.addEventListener("DOMContentLoaded", function(){ setupMutationObserver() const an_webpDictionaryWorker = setInterval(updateDictionary, an_webpDictionaryWorkerInterval); }); async function updateDictionary() { cleanUpOldAnwebpUrls(an_webpUrlDictionary) if (window.location.pathname === '/' || window.location.pathname === '/feed/subscriptions' ) { var list = document.getElementsByTagName("ytd-rich-item-renderer"); var listToProcess = []; //Process upto 10 videoIds at once var dictVideoIdsToReplaceWithTitle = {}; for (let target of list) { if (target.querySelector('yt-lockup-view-model') == null ) { // only apply to new grid tiles UI continue } var atag = target.querySelector('a'); //console.debug(logHeader, 'atag', atag); var videoId = atag.getAttribute('href').match(/watch\?v=([^&]*)/)[1]; //the href is like "/watch?v=IDabc123&t=123" so regex. //console.debug(logHeader, 'videoId', videoId); atag.videoId = videoId; if (atag.videoId != undefined && (an_webpUrlDictionary[atag.videoId] != undefined || an_webpUrlDictionaryFailedCount[atag.videoId] > updateDictionaryFailedCountLimit)) {//if already processed then skip continue } if (target.querySelector('badge-shape.badge-shape-wiz--thumbnail-live') != null) { // don't apply to video tiles that are live. continue } else if (target.querySelector('button[title="You\'ll be notified at the scheduled start time."]') != null) { // don't apply to video tiles that upcoming notifications. continue } else if (target.querySelector('path[d="M2.81,2.81L1.39,4.22L8,10.83V19l4.99-3.18l6.78,6.78l1.41-1.41L2.81,2.81z M10,15.36v-2.53l1.55,1.55L10,15.36z"]') != null) { // don't apply to video tiles that have inline videos disabled by YT as these have the an_webp thumbs available, these videos have a crossed out play icon SVG but otherwise no other identifier hence the strange selector. continue } listToProcess.push(videoId); if (videoId.includes('--') || videoId.includes('-_') || videoId.includes('_-') || videoId.includes('__')) { //console.debug(logHeader, 'videoId includes --|-_|_-|__', videoId); try { var h3tagWithTitle = target.querySelector('h3'); var title = h3tagWithTitle.getAttribute('title'); dictVideoIdsToReplaceWithTitle[videoId] = title; } catch {} } if (listToProcess.length == updateDictionaryBatchSize) { break } } //console.debug(logHeader, 'listToProcess', listToProcess); //console.debug(logHeader, 'dictVideoIdsToReplaceWithTitle', dictVideoIdsToReplaceWithTitle); if (listToProcess.length == 0) { return } var searchQueryString = '"' + listToProcess.join('"|"') +'"'; // %7C = | pipe char, %22 = quote " for (let key_videoId in dictVideoIdsToReplaceWithTitle) { searchQueryString = searchQueryString.replaceAll(key_videoId, dictVideoIdsToReplaceWithTitle[key_videoId]) } //console.debug('searchQueryString', searchQueryString); var response = await makeGetRequest(searchUrl+encodeURIComponent(searchQueryString)); //console.debug('response', response); if (response.status == 200) { for (let videoId of listToProcess) { var trimmedResponseIndex = response.responseText.indexOf('an_webp/'+videoId); if (trimmedResponseIndex == -1) { console.log(logHeader, 'No an_webp url in response for '+videoId); incrementFailedCount(videoId); continue } var trimmedResponse = response.responseText.substring(trimmedResponseIndex, trimmedResponseIndex+106) //106 char is length of an_webp URL path always. //console.debug(logHeader, 'trimmedResponseIndex',trimmedResponseIndex); //console.debug(logHeader, 'trimmedResponse',trimmedResponse); try { var url = ytImageRootUrl+trimmedResponse.replaceAll('\\u0026', '&'); an_webpUrlDictionary[videoId] = {'url': url, 'datetime': Date.now()}; continue } catch { incrementFailedCount(videoId); continue } } if (storageAvailable("localStorage")) { // Yippee! We can use localStorage awesomeness localStorage.setItem("an_webpUrlDictionary", JSON.stringify(an_webpUrlDictionary)); } } } //console.debug(logHeader, 'an_webpUrlDictionary', an_webpUrlDictionary); //console.debug(logHeader, 'an_webpUrlDictionaryFailedCount', an_webpUrlDictionaryFailedCount); } function incrementFailedCount(videoId) { if (an_webpUrlDictionaryFailedCount[videoId] === undefined) { an_webpUrlDictionaryFailedCount[videoId] = 1; } else { an_webpUrlDictionaryFailedCount[videoId] += 1; } } function storageAvailable(type) { let storage; try { storage = window[type]; const x = "__storage_test__"; storage.setItem(x, x); storage.removeItem(x); return true; } catch (e) { return ( e instanceof DOMException && e.name === "QuotaExceededError" && // acknowledge QuotaExceededError only if there's something already stored storage && storage.length !== 0 ); } } if (storageAvailable("localStorage")) { // Yippee! We can use localStorage awesomeness var storedDictionary = localStorage.getItem("an_webpUrlDictionary"); if (storedDictionary != null) { an_webpUrlDictionary = JSON.parse(storedDictionary); cleanUpOldAnwebpUrls(an_webpUrlDictionary); } } function cleanUpOldAnwebpUrls(an_webpUrlDictionary) { for (let key_videoId in an_webpUrlDictionary) { //console.debug(`${key_videoId}: ${an_webpUrlDictionary[key_videoId]}`); try { if (an_webpUrlDictionary[key_videoId]['datetime'] < Date.now() - 10800000) { // 3 hours in ms delete an_webpUrlDictionary[key_videoId]; } } catch { delete an_webpUrlDictionary[key_videoId]; // if issue with datetime check then just delete. } } }