// ==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);
}
});
})();