import { extractVideoID, isOnInvidious } from "../../maze-utils/src/video"; import Config from "../config"; import { getHasStartSegment, getVideoLabel } from "./videoLabels"; import { getThumbnailSelector, setThumbnailListener } from "../../maze-utils/src/thumbnailManagement"; import { VideoID } from "../types"; import { getSegmentsForVideo } from "./segmentData"; import { onMobile } from "../../maze-utils/src/pageInfo"; export async function handleThumbnails(thumbnails: HTMLImageElement[]): Promise { await Promise.all(thumbnails.map((t) => { labelThumbnail(t); setupThumbnailHover(t); })); } export async function labelThumbnail(thumbnail: HTMLImageElement): Promise { if (!Config.config?.fullVideoSegments || !Config.config?.fullVideoLabelsOnThumbnails) { hideThumbnailLabel(thumbnail); return null; } const videoID = await extractVideoIDFromElement(thumbnail); if (!videoID) { hideThumbnailLabel(thumbnail); return null; } const category = await getVideoLabel(videoID); if (!category) { hideThumbnailLabel(thumbnail); return null; } const { overlay, text } = createOrGetThumbnail(thumbnail); overlay.style.setProperty('--category-color', `var(--sb-category-preview-${category}, var(--sb-category-${category}))`); overlay.style.setProperty('--category-text-color', `var(--sb-category-text-preview-${category}, var(--sb-category-text-${category}))`); text.innerText = chrome.i18n.getMessage(`category_${category}`); overlay.classList.add("sponsorThumbnailLabelVisible"); return overlay; } export async function setupThumbnailHover(thumbnail: HTMLImageElement): Promise { // Cache would be reset every load due to no SPA if (isOnInvidious()) return; const mainElement = thumbnail.closest("#dismissible") as HTMLElement; if (mainElement) { mainElement.removeEventListener("mouseenter", thumbnailHoverListener); mainElement.addEventListener("mouseenter", thumbnailHoverListener); } } function thumbnailHoverListener(e: MouseEvent) { if (!chrome.runtime?.id) return; const thumbnail = (e.target as HTMLElement).querySelector(getThumbnailSelector()) as HTMLImageElement; if (!thumbnail) return; // Pre-fetch data for this video let fetched = false; const preFetch = async () => { fetched = true; const videoID = await extractVideoIDFromElement(thumbnail); if (videoID && await getHasStartSegment(videoID)) { void getSegmentsForVideo(videoID, false); } }; const timeout = setTimeout(preFetch, 100); const onMouseDown = () => { clearTimeout(timeout); if (!fetched) { preFetch(); } }; e.target.addEventListener("mousedown", onMouseDown, { once: true }); e.target.addEventListener("mouseleave", () => { clearTimeout(timeout); e.target.removeEventListener("mousedown", onMouseDown); }, { once: true }); } function getLink(thumbnail: HTMLImageElement): HTMLAnchorElement | null { if (isOnInvidious()) { return thumbnail.parentElement as HTMLAnchorElement | null; } else if (!onMobile()) { const link = thumbnail.querySelector("a#thumbnail, a.reel-item-endpoint, a.yt-lockup-metadata-view-model__title, a.yt-lockup-metadata-view-model__title-link, a.yt-lockup-view-model__content-image, a.yt-lockup-metadata-view-model-wiz__title") as HTMLAnchorElement; if (link) { return link; } else if (thumbnail.nodeName === "YTD-HERO-PLAYLIST-THUMBNAIL-RENDERER" || thumbnail.nodeName === "YT-THUMBNAIL-VIEW-MODEL" ) { return thumbnail.closest("a") as HTMLAnchorElement; } else { return null; } } else { // Big thumbnails, compact thumbnails, shorts, channel feature, playlist header return thumbnail.querySelector("a.media-item-thumbnail-container, a.compact-media-item-image, a.reel-item-endpoint, :scope > a, .amsterdam-playlist-thumbnail-wrapper > a") as HTMLAnchorElement; } } async function extractVideoIDFromElement(thumbnail: HTMLImageElement): Promise { const link = getLink(thumbnail); if (!link || link.nodeName !== "A" || !link.href) return null; // no link found return await extractVideoID(link); } function getOldThumbnailLabel(thumbnail: HTMLImageElement): HTMLElement | null { return thumbnail.querySelector(".sponsorThumbnailLabel") as HTMLElement | null; } function hideThumbnailLabel(thumbnail: HTMLImageElement): void { const oldLabel = getOldThumbnailLabel(thumbnail); if (oldLabel) { oldLabel.classList.remove("sponsorThumbnailLabelVisible"); } } function createOrGetThumbnail(thumbnail: HTMLImageElement): { overlay: HTMLElement; text: HTMLElement } { const oldElement = getOldThumbnailLabel(thumbnail); if (oldElement) { return { overlay: oldElement as HTMLElement, text: oldElement.querySelector("span") as HTMLElement }; } const overlay = document.createElement("div") as HTMLElement; overlay.classList.add("sponsorThumbnailLabel"); // Disable hover autoplay overlay.addEventListener("pointerenter", (e) => { e.stopPropagation(); thumbnail.dispatchEvent(new PointerEvent("pointerleave", { bubbles: true })); }); overlay.addEventListener("pointerleave", (e) => { e.stopPropagation(); thumbnail.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true })); }); const icon = createSBIconElement(); const text = document.createElement("span"); overlay.appendChild(icon); overlay.appendChild(text); thumbnail.appendChild(overlay); return { overlay, text }; } function createSBIconElement(): SVGSVGElement { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 565.15 568"); const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); use.setAttribute("href", "#SponsorBlockIcon"); svg.appendChild(use); return svg; } // Inserts the icon svg definition, so it can be used elsewhere function insertSBIconDefinition() { const container = document.createElement("span"); // svg from /public/icons/PlayerStartIconSponsorBlocker.svg, with useless stuff removed container.innerHTML = ` `; document.body.appendChild(container.children[0]); } export function setupThumbnailListener(): void { setThumbnailListener(handleThumbnails, () => { insertSBIconDefinition(); }, () => Config.isReady()); }