// ==UserScript== // @name YouTube channel description popup on hover // @namespace http://tampermonkey.net/ // @version 0.2 // @description YouTube channel description popup on channel name hover! // @author @dmtri // @match https://www.youtube.com/* // @icon // @grant none // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; const MAGIC_NUMBER = 1500; let allEventHandlers = []; const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const debounce = (mainFunction, delay) => { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => { mainFunction(...args); }, delay); }; }; const initTag = '.youtube-popup-desc-init'; const profileIdentifierUrlContainer = '#container.ytd-channel-name'; // get the url from element with profileIdentifier const getProfileUrl = (profileMetaDataElement) => { const anchor = profileMetaDataElement.getElementsByTagName('a')[0]; return anchor.href; }; const clearEvents = (profileMetaDataElement) => { // clear all existing event handlers allEventHandlers.forEach((handler) => { profileMetaDataElement.forEach((element) => { element.removeEventListener('mouseenter', handler); }); }); allEventHandlers = []; }; // display a popup when hover over the profileMetaData element const init = (force = false) => { if (!force && document.querySelector(initTag)) { return; } const profileMetaDataElement = document.querySelectorAll(profileIdentifierUrlContainer); if (!profileMetaDataElement || !profileMetaDataElement.length) { return; } clearEvents(profileMetaDataElement); const inlineHandler = async (e) => { const element = e.target; let isValidGesture = true; const mouseLeaveHandler = () => { isValidGesture = false; }; element.removeEventListener('mouseleave', mouseLeaveHandler); element.addEventListener('mouseleave', mouseLeaveHandler); await wait(1500); // valid gesture meaning a mouse enter event // is not followed by a mouse leave event // within 1.5 seconds handler(element, isValidGesture); }; allEventHandlers.push(inlineHandler); profileMetaDataElement.forEach((element) => { element.addEventListener('mouseenter', inlineHandler); }); // append init tag to document const initTagElement = document.createElement('div'); initTagElement.classList.add(initTag.replace('.', '')); document.body.appendChild(initTagElement); // init when scrolled down on home page const grid = '#contents.ytd-rich-grid-renderer'; const callback = (mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { debouncedInit(true); } }); }; let observer; if (observer) observer.disconnect(); observer = new MutationObserver(callback); const targetNode = document.querySelector(grid); const config = { childList: true, subtree: true }; observer.observe(targetNode, config); }; const debouncedInit = debounce(init, MAGIC_NUMBER); const handler = async (profileMetaDataElement, isValidGesture) => { if (!isValidGesture) return; // display a native dialog element const dialog = document.createElement('dialog'); const url = getProfileUrl(profileMetaDataElement); let desc = ''; // append a spinner next to a channel name, then close it after 3 sec const spinner = document.createElement('div'); spinner.innerHTML = 'loading...'; profileMetaDataElement.appendChild(spinner); setTimeout(() => { spinner.remove(); }, 3000); await fetch(url) .then((response) => response.text()) .then((html) => { // const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const meta = doc.querySelector('meta[property="og:description"]'); desc = !meta ? (desc = 'No desc available') : meta.getAttribute('content'); }); dialog.innerHTML = `

${desc}

${url}

`; document.body.appendChild(dialog); dialog.showModal(); setTimeout(() => { dialog.close(); }, 3000); }; // while there is no init tag in the document, keep trying to init const tryInit = () => { if (!document.querySelector(initTag)) { setTimeout(() => { init(); tryInit(); }, MAGIC_NUMBER); } }; tryInit(); })();