From 758f0b7526f5e3774fa723433c7cbc47d21badd5 Mon Sep 17 00:00:00 2001 From: Ajay Date: Fri, 10 Mar 2023 03:49:01 -0500 Subject: [PATCH] Show Full-Video Labels on thumbnails Co-authored-by: mini-bomba --- package-lock.json | 14 +-- package.json | 2 +- public/content.css | 39 ++++++++ src/components/CategoryPillComponent.tsx | 25 ++--- src/content.ts | 38 ++++++- src/js-components/previewBar.ts | 3 +- src/utils/thumbnails.ts | 122 +++++++++++++++++++++++ src/utils/videoLabels.ts | 65 ++++++++++++ 8 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 src/utils/thumbnails.ts create mode 100644 src/utils/videoLabels.ts diff --git a/package-lock.json b/package-lock.json index fb1b654f..7803faf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ ], "license": "LGPL-3.0-or-later", "dependencies": { - "@ajayyy/maze-utils": "^1.1.7", + "@ajayyy/maze-utils": "^1.1.8", "content-scripts-register-polyfill": "^4.0.2", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -67,9 +67,9 @@ } }, "node_modules/@ajayyy/maze-utils": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@ajayyy/maze-utils/-/maze-utils-1.1.7.tgz", - "integrity": "sha512-qmakLnRnNJ/CAyDO9ey0ihn71YWoyZfRFxF78ylofA5A+ghBXg4cVVY92iKDN3pivtT2kouLiKDRWgazYKqrOQ==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@ajayyy/maze-utils/-/maze-utils-1.1.8.tgz", + "integrity": "sha512-0LwL+i/JvQZYBu6BadYX77XUoU0QVJZXCaElihVTO7suuZ9rracJX7w7A/p12whR/bQ2pO2bCqIvXuWoOiln0Q==", "funding": [ { "type": "individual", @@ -13858,9 +13858,9 @@ }, "dependencies": { "@ajayyy/maze-utils": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@ajayyy/maze-utils/-/maze-utils-1.1.7.tgz", - "integrity": "sha512-qmakLnRnNJ/CAyDO9ey0ihn71YWoyZfRFxF78ylofA5A+ghBXg4cVVY92iKDN3pivtT2kouLiKDRWgazYKqrOQ==" + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@ajayyy/maze-utils/-/maze-utils-1.1.8.tgz", + "integrity": "sha512-0LwL+i/JvQZYBu6BadYX77XUoU0QVJZXCaElihVTO7suuZ9rracJX7w7A/p12whR/bQ2pO2bCqIvXuWoOiln0Q==" }, "@ampproject/remapping": { "version": "2.2.0", diff --git a/package.json b/package.json index d4399fa3..694d15a4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "background.js", "dependencies": { - "@ajayyy/maze-utils": "^1.1.7", + "@ajayyy/maze-utils": "^1.1.8", "content-scripts-register-polyfill": "^4.0.2", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/public/content.css b/public/content.css index 415d5f43..360d0987 100644 --- a/public/content.css +++ b/public/content.css @@ -803,3 +803,42 @@ input::-webkit-inner-spin-button { color: #fff; opacity: .7; } + +/* full video labels on thumbnails */ +.sponsorThumbnailLabel { + display: none; + position: absolute; + top: 0; + left: 0; + padding: 0.5em; + margin: 0.5em; + border-radius: 2em; + z-index: 1000; + background-color: var(--category-color, #000); + opacity: 70%; + box-shadow: 0 0 8px 2px #333; +} + +.sponsorThumbnailLabel.sponsorThumbnailLabelVisible { + display: flex; +} + +.sponsorThumbnailLabel svg { + height: 2em; + fill: var(--category-text-color, #fff); +} + +.sponsorThumbnailLabel span { + display: none; + padding-left: 0.25em; + font-size: 1.5em; + color: var(--category-text-color, #fff); +} + +.sponsorThumbnailLabel:hover { + border-radius: 0.25em; +} + +.sponsorThumbnailLabel:hover span { + display: inline; +} \ No newline at end of file diff --git a/src/components/CategoryPillComponent.tsx b/src/components/CategoryPillComponent.tsx index a008e396..123f3e93 100644 --- a/src/components/CategoryPillComponent.tsx +++ b/src/components/CategoryPillComponent.tsx @@ -115,28 +115,15 @@ class CategoryPillComponent extends React.Component 128 ? "black" : "white"; - Config.config.categoryPillColors[this.state.segment?.category] = { - lastColor: color, - textColor - }; - - return textColor; - } + // Handled by setCategoryColorCSSVariables() of content.ts + const category = this.state.segment?.category; + return `var(--sb-category-text-preview-${category}, var(--sb-category-text-${category}))`; } private openTooltip(): void { diff --git a/src/content.ts b/src/content.ts index 6e719c31..b73be606 100644 --- a/src/content.ts +++ b/src/content.ts @@ -43,11 +43,16 @@ import { StorageChangesObject } from "@ajayyy/maze-utils/lib/config"; import { findValidElement } from "@ajayyy/maze-utils/lib/dom" import { getHash, HashedValue } from "@ajayyy/maze-utils/lib/hash"; import { generateUserID } from "@ajayyy/maze-utils/lib/setup"; +import { setThumbnailListener, updateAll } from "@ajayyy/maze-utils/lib/thumbnailManagement"; +import { labelThumbnails, setupThumbnailPageLoadListener } from "./utils/thumbnails"; const utils = new Utils(); -// Hack to get the CSS loaded on permission-based sites (Invidious) -utils.wait(() => Config.isReady(), 5000, 10).then(addCSS); +utils.wait(() => Config.isReady(), 5000, 10).then(() => { + // Hack to get the CSS loaded on permission-based sites (Invidious) + addCSS(); + setCategoryColorCSSVariables(); +}); const skipBuffer = 0.003; @@ -108,6 +113,8 @@ setupVideoModule({ }, resetValues }, () => Config); +setThumbnailListener(labelThumbnails); +setupThumbnailPageLoadListener(); //the video id of the last preview bar update let lastPreviewBarUpdate: VideoID; @@ -332,6 +339,12 @@ function contentConfigUpdateListener(changes: StorageChangesObject) { case "categorySelections": sponsorsLookup(); break; + case "barTypes": + setCategoryColorCSSVariables(); + break; + case "fullVideoSegments": + updateAll(); + break; } } } @@ -2466,4 +2479,25 @@ function checkForPreloadedSegment() { Config.config.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting; Config.forceSyncUpdate("unsubmittedSegments"); } +} + +// Generate and inject a stylesheet that creates CSS variables with configured category colors +function setCategoryColorCSSVariables() { + let styleContainer = document.getElementById("sbCategoryColorStyle"); + if (!styleContainer) { + styleContainer = document.createElement("style"); + styleContainer.id = "sbCategoryColorStyle"; + document.head.appendChild(styleContainer) + } + + let css = ":root {" + for (const [category, config] of Object.entries(Config.config.barTypes)) { + css += `--sb-category-${category}: ${config.color};`; + + const luminance = GenericUtils.getLuminance(config.color); + css += `--sb-category-text-${category}: ${luminance > 128 ? "black" : "white"};`; + } + css += "}"; + + styleContainer.innerText = css; } \ No newline at end of file diff --git a/src/js-components/previewBar.ts b/src/js-components/previewBar.ts index 30d54d3a..bec83333 100644 --- a/src/js-components/previewBar.ts +++ b/src/js-components/previewBar.ts @@ -331,7 +331,8 @@ class PreviewBar { const fullCategoryName = (unsubmitted ? 'preview-' : '') + category; bar.setAttribute('sponsorblock-category', fullCategoryName); - bar.style.backgroundColor = Config.config.barTypes[fullCategoryName]?.color; + // Handled by setCategoryColorCSSVariables() of content.ts + bar.style.backgroundColor = `var(--sb-category-${fullCategoryName})`; if (!this.onMobileYouTube) bar.style.opacity = Config.config.barTypes[fullCategoryName]?.opacity; bar.style.position = "absolute"; diff --git a/src/utils/thumbnails.ts b/src/utils/thumbnails.ts new file mode 100644 index 00000000..a0009e7c --- /dev/null +++ b/src/utils/thumbnails.ts @@ -0,0 +1,122 @@ +import { waitFor } from "@ajayyy/maze-utils"; +import { newThumbnails } from "@ajayyy/maze-utils/lib/thumbnailManagement"; +import { isOnInvidious, parseYouTubeVideoIDFromURL } from "@ajayyy/maze-utils/lib/video"; +import Config from "../config"; +import { getVideoLabel } from "./videoLabels"; + +export async function labelThumbnails(thumbnails: HTMLImageElement[]): Promise { + await Promise.all(thumbnails.map((t) => labelThumbnail(t))); +} + +export async function labelThumbnail(thumbnail: HTMLImageElement): Promise { + if (!Config.config?.fullVideoSegments) { + hideThumbnailLabel(thumbnail); + return null; + } + + const link = (isOnInvidious() ? thumbnail.parentElement : thumbnail.querySelector("#thumbnail")) as HTMLAnchorElement + if (!link || link.nodeName !== "A" || !link.href) return null; // no link found + const videoID = parseYouTubeVideoIDFromURL(link.href)?.videoID; + 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; +} + +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"); + + 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 setupThumbnailPageLoadListener(): void { + const onLoad = () => { + insertSBIconDefinition(); + + // Label thumbnails on load if on Invidious (wait for variable initialization before checking) + waitFor(() => isOnInvidious() !== undefined).then(() => { + if (isOnInvidious()) newThumbnails(); + }); + }; + + if (document.readyState === "complete") { + onLoad(); + } else { + window.addEventListener("load", onLoad); + } + + waitFor(() => Config.isReady(), 5000, 10).then(() => { + newThumbnails(); + }); +} \ No newline at end of file diff --git a/src/utils/videoLabels.ts b/src/utils/videoLabels.ts new file mode 100644 index 00000000..ca12c6e4 --- /dev/null +++ b/src/utils/videoLabels.ts @@ -0,0 +1,65 @@ +import { Category, VideoID } from "../types"; +import { getHash } from "@ajayyy/maze-utils/lib/hash"; +import Utils from "../utils"; +import { logWarn } from "./logger"; + +const utils = new Utils(); + +export interface LabelCacheEntry { + timestamp: number; + videos: Record; +} + +const labelCache: Record = {}; +const cacheLimit = 1000; + +async function getLabelHashBlock(hashPrefix: string): Promise { + // Check cache + const cachedEntry = labelCache[hashPrefix]; + if (cachedEntry) { + return cachedEntry; + } + + const response = await utils.asyncRequestToServer("GET", `/api/videoLabels/${hashPrefix}`); + if (response.status !== 200) { + // No video labels or server down + labelCache[hashPrefix] = { + timestamp: Date.now(), + videos: {}, + }; + return null; + } + + try { + const data = JSON.parse(response.responseText); + + const newEntry: LabelCacheEntry = { + timestamp: Date.now(), + videos: Object.fromEntries(data.map(video => [video.videoID, video.segments[0].category])), + }; + labelCache[hashPrefix] = newEntry; + + if (Object.keys(labelCache).length > cacheLimit) { + // Remove oldest entry + const oldestEntry = Object.entries(labelCache).reduce((a, b) => a[1].timestamp < b[1].timestamp ? a : b); + delete labelCache[oldestEntry[0]]; + } + + return newEntry; + } catch (e) { + logWarn(`Error parsing video labels: ${e}`); + + return null; + } +} + +export async function getVideoLabel(videoID: VideoID): Promise { + const prefix = (await getHash(videoID, 1)).slice(0, 3); + const result = await getLabelHashBlock(prefix); + + if (result) { + return result.videos[videoID] ?? null; + } + + return null; +} \ No newline at end of file