Restore animated thumbnail previews - youtube.com

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.3
// @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; //number of videos per single an_webp search request
const updateDictionaryFailedCountLimit = 6; //number of times to include a video in a search request before giving up.
const an_webpDictionaryWorkerInterval = 3500; //milliseconds
const an_webpUrlExpiryAge = 18000000; // 5 hours in ms, testing shows urls valid for ~6 hours so this should be safe time to cache.

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();
}
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
    );
  }
}
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() - an_webpUrlExpiryAge) {
            delete an_webpUrlDictionary[key_videoId];
            } 
        } catch {
            delete an_webpUrlDictionary[key_videoId]; // if issue with datetime check then just delete.
        }
        
    }
}
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);
  }
}
document.addEventListener("DOMContentLoaded", function(){
    setupMutationObserver()
    const an_webpDictionaryWorker = setInterval(updateDictionary, an_webpDictionaryWorkerInterval);
});