Greasy Fork is available in English.

youtube.com - thumbnail

Adds clickable thumbnail

// ==UserScript==
// @name         youtube.com - thumbnail
// @namespace    youtube.com
// @version      2.0.5
// @description  Adds clickable thumbnail
// @author       puzzle
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM_xmlhttpRequest
// @run-at       document-start
// ==/UserScript==

(async function() {
    'use strict';

    const __helper = {
        $: (sel, parent = document) => parent.querySelector(sel),
        $$: (sel, parent = document) => Array.from(parent.querySelectorAll(sel)),
        waitUntilExist(selector) {
            return new Promise((resolve, reject) => {
                let timer = setInterval(function (e) {
                    const el = document.querySelector(selector);
                    if (el) {
                        clearInterval(timer);
                        resolve(el);
                    }
                }, 100);
            });
        }
    }
    const {$, $$, waitUntilExist} = __helper;




    const prefix = 'userscript--youtube-thumbnail--';

    const IDs = {
        container: `${prefix}host-container`,
        style: `${prefix}global-style`,
    };

    function attachGlobalStyle(container) {
        container.insertAdjacentHTML('afterBegin',`
        <style id=${IDs.style}>
            #top-row {
                margin: 10px 0 !important;
                display: grid !important;
                grid-template-areas: "thumb owner"
                                     "thumb actions";
                grid-template-columns: auto 1fr;
                align-items: center !important;
                gap: 15px;
            }

            #top-row #actions #actions-inner {
                width: 100% !important;
            }

            ytd-video-owner-renderer[watch-metadata-refresh] {
                min-width: auto !important;
            }
            #upload-info.ytd-video-owner-renderer {
                flex: 0 0 auto !important;
            }

            #${IDs.container},
            #owner,
            #actions {
                margin: 0px !important;
            }

            #top-row #${IDs.container} {
                grid-area: thumb;
                align-self: center;
            }

            #top-row #owner {
                grid-area: owner;
                display: flex;
                align-self: flex-start;
            }

            #top-row #actions {
                grid-area: actions;
                flex-direction: row !important;
                display: flex;
                align-self: flex-end;
            }
            ytd-watch-metadata[flex-menu-enabled] #actions.ytd-watch-metadata ytd-menu-renderer.ytd-watch-metadata {
                justify-content: flex-start !important;
            }
        </style>`);
    }




    class CustomThumbnail extends HTMLElement {
        static observedAttributes = ["video-id"];

        constructor() {
            super();

            this.id = IDs.container;

            const shadowRoot = this.attachShadow({mode: 'open'});
            shadowRoot.innerHTML = `
                <style>
                    #thumbnail {
                        display: flex;
                        justify-content: center;
                        align-items: center;
                        height: 90px;
                        width: 160px;
                        border-radius: 13px;
                        margin-right: 15px;
                        background-size: cover;
                        background-repeat:no-repeat;
                        background-position: center;
                        background-color: black;
                        box-shadow: 2px 2px 5px 0px black;
                        cursor: pointer;
                        position: relative;
                        zoom: 1.1;
                    }
                    #thumbnail::before {
                        content: '';
                        position: absolute;
                        height: 100%;
                        width: 100%;
                        border-radius: 11px;
                        background: black;
                    }
                    #thumbnail.loaded::before {
                        animation: 4s fadeOut forwards;
                    }
                    #thumbnail.loaded .loader { opacity: 0; }
                    .loader {
                      width: 48px;
                      height: 48px;
                      border-radius: 50%;
                      display: inline-block;
                      position: relative;
                      background: linear-gradient(0deg, rgba(255, 61, 0, 0.2) 33%, #ff3d00 100%);
                      box-sizing: border-box;
                      animation: rotation 1s linear infinite;
                    }
                    .loader::after {
                      content: '';
                      box-sizing: border-box;
                      position: absolute;
                      left: 50%;
                      top: 50%;
                      transform: translate(-50%, -50%);
                      width: 44px;
                      height: 44px;
                      border-radius: 50%;
                      background: black;
                    }
                    @keyframes rotation {
                      0% { transform: rotate(0deg) }
                      100% { transform: rotate(360deg)}
                    }
                    @keyframes fadeIn {
                        0% { opacity: 0; }
                        100% { opacity: 1;}
                    }
                    @keyframes fadeOut {
                        0% { opacity: 1;}
                        100% { opacity: 0;}
                    }
                </style>
                <div id='thumbnail'>
                    <span class='loader'></span>
                </div>`;

            this.thumbnail = shadowRoot.getElementById('thumbnail');
        }

        async filterBiggestThumbnail(videoID) {
            return new Promise( async (resolve, reject) => {
                const resolutions = [
                    { name: "maxres", filename: "maxresdefault.jpg", width: 1280, },
                    { name: "standard", filename: "sddefault.jpg", width: 640, },
                    { name: "high", filename:"hqdefault.jpg", width: 480, },
                    { name: "medium", filename: "mqdefault.jpg", width: 320, },
                    { name: "default", filename: "default.jpg", width: 120, }
                ];

                for (const resolution of resolutions) {
                    const response = await new Promise( (resolve,reject) => {
                        GM_xmlhttpRequest({
                            method: 'get',
                            url: `https://i.ytimg.com/vi/${videoID}/${resolution.filename}`,
                            responseType: 'blob',
                            onload: function(response) {
                                resolve(response);
                            }
                        });
                    })
                    if (response.status === 200) return resolve(response);
                }
            })

        }

        async updateThumbnail(videoID) {
            this.thumbnail.classList.remove('loaded');

            const imageData = await this.filterBiggestThumbnail(videoID);
            const imageBlob = await imageData.response;

            const objectURL = URL.createObjectURL(imageBlob);

            this.thumbnail.onclick = function() {
                window.open(objectURL);
            };
            this.thumbnail.style.backgroundImage = `url('${objectURL}')`;
            this.thumbnail.dataset.url = imageData.finalUrl;
            this.thumbnail.classList.add('loaded');
        }

        connectedCallback() {
            console.log("Custom element added to page.");
        }

        disconnectedCallback() {
            console.log("Custom element removed from page.");
        }

        adoptedCallback() {
            console.log("Custom element moved to new page.");
        }

        attributeChangedCallback(name, oldValue, newValue) {
            console.log(`Attribute ${name} has changed.`);
            if (name === 'video-id') {
                this.updateThumbnail(newValue);
            }
        }
    }




    attachGlobalStyle(document.documentElement);

    customElements.constructor.prototype.define.call(customElements, "custom-thumbnail", CustomThumbnail);

    let customThumbnail = null;



    document.addEventListener('yt-navigate-finish', async function(e) {
        if (location.pathname === '/watch') {
            const videoID = new URLSearchParams(location.search).get('v') || location.pathname.match(/\/shorts\/([\w-_]+)/i)[1];
            if (!customThumbnail) {
                customThumbnail = new CustomThumbnail(videoID);
                const thumbnailContainer = await waitUntilExist('#above-the-fold #top-row');
                thumbnailContainer.prepend(customThumbnail);
            }
            customThumbnail.setAttribute('video-id', videoID);
        }
    });

})();