- // ==UserScript==
- // @name 8chan YouTube Link Enhancer
- // @namespace nipah-scripts-8chan
- // @version 3.3.1
- // @description Cleans up YouTube links, adds titles, optional thumbnail previews, and settings via STM.
- // @author nipah, Gemini
- // @license MIT
- // @match https://8chan.moe/*
- // @match https://8chan.se/*
- // @grant GM.xmlHttpRequest
- // @grant GM.setValue
- // @grant GM.getValue
- // @grant GM_addStyle
- // @connect youtube.com
- // @connect i.ytimg.com
- // @run-at document-idle
- // ==/UserScript==
-
- (async function() {
- 'use strict';
-
- // --- Constants ---
- const SCRIPT_NAME = 'YTLE';
- const SCRIPT_ID = 'YTLE'; // Unique ID for Settings Tab Manager
- const CACHE_KEY_SETTINGS = 'ytleSettings';
- const CACHE_KEY_TITLES = 'ytleTitleCache';
-
- const DEFAULTS = Object.freeze({
- CACHE_EXPIRY_DAYS: 7,
- SHOW_THUMBNAILS: false,
- API_DELAY_MS: 200,
- CACHE_CLEANUP_PROBABILITY: 0.1, // 10% chance per run
- THUMBNAIL_POPUP_ID: 'ytle-thumbnail-popup',
- THUMBNAIL_HIDE_DELAY_MS: 150,
- });
-
- const REGEX = Object.freeze({
- YOUTUBE: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|live\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[?&#]|$)/i, // Simplified slightly, captures ID
- YOUTUBE_TRACKING_PARAMS: /[?&](si|feature|ref|fsi|source|utm_source|utm_medium|utm_campaign|gclid|gclsrc|fbclid)=[^&]+/gi,
- });
-
- const URL_TEMPLATES = Object.freeze({
- OEMBED: "https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=VIDEO_ID&format=json",
- THUMBNAIL_WEBP: "https://i.ytimg.com/vi_webp/VIDEO_ID/maxresdefault.webp",
- // Fallback might be needed if maxresdefault webp fails often, e.g., mqdefault.jpg
- // THUMBNAIL_JPG_HQ: "https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg",
- });
-
- const PLACEHOLDER_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // Transparent pixel
- const YOUTUBE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="#FF0000" d="M549.7 124.1c-6.3-23.7-24.9-42.4-48.6-48.6C456.5 64 288 64 288 64s-168.5 0-213.1 11.5c-23.7 6.3-42.4 24.9-48.6 48.6C16 168.5 16 256 16 256s0 87.5 10.3 131.9c6.3 23.7 24.9 42.4 48.6 48.6C119.5 448 288 448 288 448s168.5 0 213.1-11.5c23.7-6.3 42.4-24.9 48.6-48.6 10.3-44.4 10.3-131.9 10.3-131.9s0-87.5-10.3-131.9zM232 334.1V177.9L361 256 232 334.1z"/></svg>`;
-
- // --- Utilities ---
- const Logger = {
- prefix: `[${SCRIPT_NAME}]`,
- log: (...args) => console.log(Logger.prefix, ...args),
- warn: (...args) => console.warn(Logger.prefix, ...args),
- error: (...args) => console.error(Logger.prefix, ...args),
- };
-
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- function getVideoId(href) {
- if (!href) return null;
- const match = href.match(REGEX.YOUTUBE);
- return match ? match[1] : null;
- }
-
- // --- Settings Manager ---
- class SettingsManager {
- constructor() {
- this.settings = {
- cacheExpiryDays: DEFAULTS.CACHE_EXPIRY_DAYS,
- showThumbnails: DEFAULTS.SHOW_THUMBNAILS
- };
- }
-
- async load() {
- try {
- const loadedSettings = await GM.getValue(CACHE_KEY_SETTINGS, this.settings);
-
- // Validate and merge loaded settings
- this.settings.cacheExpiryDays = (typeof loadedSettings.cacheExpiryDays === 'number' && Number.isInteger(loadedSettings.cacheExpiryDays) && loadedSettings.cacheExpiryDays > 0)
- ? loadedSettings.cacheExpiryDays
- : DEFAULTS.CACHE_EXPIRY_DAYS;
-
- this.settings.showThumbnails = (typeof loadedSettings.showThumbnails === 'boolean')
- ? loadedSettings.showThumbnails
- : DEFAULTS.SHOW_THUMBNAILS;
-
- Logger.log('Settings loaded:', this.settings);
- } catch (e) {
- Logger.warn('Failed to load settings, using defaults.', e);
- // Reset to defaults on error
- this.settings = {
- cacheExpiryDays: DEFAULTS.CACHE_EXPIRY_DAYS,
- showThumbnails: DEFAULTS.SHOW_THUMBNAILS
- };
- }
- }
-
- async save() {
- try {
- // Ensure types before saving
- this.settings.cacheExpiryDays = Math.max(1, Math.floor(this.settings.cacheExpiryDays || DEFAULTS.CACHE_EXPIRY_DAYS));
- this.settings.showThumbnails = !!this.settings.showThumbnails;
-
- await GM.setValue(CACHE_KEY_SETTINGS, this.settings);
- Logger.log('Settings saved:', this.settings);
- } catch (e) {
- Logger.error('Failed to save settings.', e);
- }
- }
-
- get cacheExpiryDays() {
- return this.settings.cacheExpiryDays;
- }
-
- set cacheExpiryDays(days) {
- const val = parseInt(days, 10);
- if (!isNaN(val) && val > 0) {
- this.settings.cacheExpiryDays = val;
- } else {
- Logger.warn(`Attempted to set invalid cacheExpiryDays: ${days}`);
- }
- }
-
- get showThumbnails() {
- return this.settings.showThumbnails;
- }
-
- set showThumbnails(value) {
- this.settings.showThumbnails = !!value;
- }
- }
-
- // --- Title Cache ---
- class TitleCache {
- constructor(settingsManager) {
- this.settings = settingsManager; // Reference to settings
- this.cache = null; // Lazy loaded
- }
-
- async _loadCache() {
- if (this.cache === null) {
- try {
- this.cache = await GM.getValue(CACHE_KEY_TITLES, {});
- } catch (e) {
- Logger.warn('Failed to load title cache:', e);
- this.cache = {}; // Use empty cache on error
- }
- }
- return this.cache;
- }
-
- async _saveCache() {
- if (this.cache === null) return; // Don't save if never loaded
- try {
- await GM.setValue(CACHE_KEY_TITLES, this.cache);
- } catch (e) {
- Logger.warn('Failed to save title cache:', e);
- }
- }
-
- async getTitle(videoId) {
- const cache = await this._loadCache();
- const item = cache[videoId];
- if (item && typeof item.expiry === 'number' && item.expiry > Date.now()) {
- return item.title;
- }
- // If expired or not found, remove old entry (if exists) and return null
- if (item) {
- delete cache[videoId];
- await this._saveCache(); // Save removal
- }
- return null;
- }
-
- async setTitle(videoId, title) {
- if (!videoId || typeof title !== 'string') return;
- const cache = await this._loadCache();
- const expiryDays = this.settings.cacheExpiryDays;
- const expiryTime = Date.now() + (expiryDays * 24 * 60 * 60 * 1000);
-
- cache[videoId] = { title: title, expiry: expiryTime };
- await this._saveCache();
- }
-
- async clearExpired() {
- // Only run cleanup occasionally
- if (Math.random() >= DEFAULTS.CACHE_CLEANUP_PROBABILITY) return 0;
-
- const cache = await this._loadCache();
- const now = Date.now();
- let changed = false;
- let malformedCount = 0;
- let expiredCount = 0;
-
- for (const videoId in cache) {
- if (Object.hasOwnProperty.call(cache, videoId)) {
- const item = cache[videoId];
- // Check for invalid format or expiry
- if (!item || typeof item.title !== 'string' || typeof item.expiry !== 'number' || item.expiry <= now) {
- delete cache[videoId];
- changed = true;
- if (item && item.expiry <= now) expiredCount++;
- else malformedCount++;
- if (!item || typeof item.title !== 'string' || typeof item.expiry !== 'number') {
- Logger.warn(`Removed malformed cache entry: ${videoId}`);
- }
- }
- }
- }
-
- if (changed) {
- await this._saveCache();
- const totalCleared = malformedCount + expiredCount;
- if (totalCleared > 0) {
- Logger.log(`Cleared ${totalCleared} cache entries (${expiredCount} expired, ${malformedCount} malformed).`);
- }
- }
- return expiredCount + malformedCount;
- }
-
- async purgeAll() {
- try {
- this.cache = {}; // Clear in-memory cache
- await GM.setValue(CACHE_KEY_TITLES, {}); // Clear storage
- Logger.log('Title cache purged successfully.');
- return true;
- } catch (e) {
- Logger.error('Failed to purge title cache:', e);
- return false;
- }
- }
- }
-
- // --- API Fetcher ---
- class ApiFetcher {
- async fetchVideoData(videoId) {
- const url = URL_TEMPLATES.OEMBED.replace('VIDEO_ID', videoId);
- return new Promise((resolve, reject) => {
- GM.xmlHttpRequest({
- method: "GET",
- url: url,
- responseType: "json",
- timeout: 10000,
- onload: (response) => {
- if (response.status === 200 && response.response?.title) {
- resolve(response.response);
- } else if ([401, 403, 404].includes(response.status)) {
- reject(new Error(`Video unavailable (Status: ${response.status})`));
- } else {
- reject(new Error(`oEmbed request failed (${response.statusText || `Status ${response.status}`})`));
- }
- },
- onerror: (err) => reject(new Error(`GM.xmlHttpRequest error: ${err.error || 'Network error'}`)),
- ontimeout: () => reject(new Error('oEmbed request timed out')),
- });
- });
- }
-
- async fetchThumbnailAsDataURL(videoId) {
- const thumbnailUrl = URL_TEMPLATES.THUMBNAIL_WEBP.replace('VIDEO_ID', videoId);
- return new Promise((resolve) => {
- GM.xmlHttpRequest({
- method: "GET",
- url: thumbnailUrl,
- responseType: 'blob',
- timeout: 8000, // Slightly shorter timeout for images
- onload: (response) => {
- if (response.status === 200 && response.response) {
- const reader = new FileReader();
- reader.onloadend = () => resolve(reader.result); // result is the Data URL
- reader.onerror = (err) => {
- Logger.warn(`FileReader error for thumbnail ${videoId}:`, err);
- resolve(null); // Resolve with null on reader error
- };
- reader.readAsDataURL(response.response);
- } else {
- // Log non-200 status for debugging, but still resolve null
- if(response.status !== 404) Logger.warn(`Thumbnail fetch failed for ${videoId} (Status: ${response.status})`);
- resolve(null);
- }
- },
- onerror: (err) => {
- Logger.error(`GM.xmlHttpRequest error fetching thumbnail for ${videoId}:`, err);
- resolve(null);
- },
- ontimeout: () => {
- Logger.warn(`Timeout fetching thumbnail for ${videoId}`);
- resolve(null);
- }
- });
- });
- }
- }
-
- // --- Link Enhancer (DOM Manipulation) ---
- class LinkEnhancer {
- constructor(titleCache, apiFetcher, settingsManager) {
- this.cache = titleCache;
- this.api = apiFetcher;
- this.settings = settingsManager;
- this.styleAdded = false;
- this.processingLinks = new Set(); // Track links currently being fetched
- }
-
- addStyles() {
- if (this.styleAdded) return;
- const encodedSvg = `data:image/svg+xml;base64,${btoa(YOUTUBE_ICON_SVG)}`;
- const styles = `
- .youtubelink {
- position: relative;
- padding-left: 20px; /* Space for icon */
- display: inline-block; /* Prevent line breaks inside link */
- white-space: nowrap;
- text-decoration: none !important;
- /* Optional: slightly adjust vertical alignment if needed */
- /* vertical-align: middle; */
- }
- .youtubelink:hover {
- text-decoration: underline !important;
- }
- .youtubelink::before {
- content: '';
- position: absolute;
- left: 0px;
- top: 50%;
- transform: translateY(-50%);
- width: 16px; /* Icon size */
- height: 16px;
- background-image: url("${encodedSvg}");
- background-repeat: no-repeat;
- background-size: contain;
- background-position: center;
- /* vertical-align: middle; /* Align icon with text */
- }
- /* Thumbnail Popup Styles */
- #${DEFAULTS.THUMBNAIL_POPUP_ID} {
- position: fixed; display: none; z-index: 10000;
- border: 1px solid #555; background-color: #282828;
- padding: 2px; border-radius: 2px;
- box-shadow: 3px 3px 8px rgba(0,0,0,0.4);
- pointer-events: none; /* Don't interfere with mouse events */
- max-width: 320px; max-height: 180px; overflow: hidden;
- }
- #${DEFAULTS.THUMBNAIL_POPUP_ID} img {
- display: block; width: 100%; height: auto;
- max-height: 176px; /* Max height inside padding */
- object-fit: contain; background-color: #111;
- }
- /* Settings Panel Content (Scoped to parent div) */
- #${SCRIPT_ID}-panel-content > div { margin-bottom: 10px; }
- #${SCRIPT_ID}-panel-content input[type="number"] {
- width: 60px; padding: 3px; margin-left: 5px;
- border: 1px solid var(--settings-input-border, #ccc);
- background-color: var(--settings-input-bg, #fff);
- color: var(--settings-text, #000); box-sizing: border-box;
- }
- #${SCRIPT_ID}-panel-content input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
- #${SCRIPT_ID}-panel-content label.small { vertical-align: middle; font-size: 0.95em; }
- #${SCRIPT_ID}-panel-content button { margin-top: 5px; margin-right: 10px; padding: 4px 8px; }
- #${SCRIPT_ID}-save-status, #${SCRIPT_ID}-purge-status {
- margin-left: 10px; font-size: 0.9em;
- color: var(--settings-text, #ccc); font-style: italic;
- }
- `;
- GM_addStyle(styles);
- this.styleAdded = true;
- Logger.log('Styles added.');
- }
-
- cleanLinkUrl(linkElement) {
- if (!linkElement?.href) return;
- const originalHref = linkElement.href;
- let cleanHref = originalHref;
-
- // Normalize youtu.be, /live/, /shorts/ to standard watch?v= format
- if (cleanHref.includes('youtu.be/')) {
- const videoId = getVideoId(cleanHref);
- if (videoId) {
- const url = new URL(cleanHref);
- const timestamp = url.searchParams.get('t');
- cleanHref = `https://www.youtube.com/watch?v=${videoId}${timestamp ? `&t=${timestamp}` : ''}`;
- }
- } else {
- cleanHref = cleanHref.replace('/live/', '/watch?v=')
- .replace('/shorts/', '/watch?v=')
- .replace('/embed/', '/watch?v=')
- .replace('/v/', '/watch?v=');
- }
-
- // Remove tracking parameters more reliably using URL API
- try {
- const url = new URL(cleanHref);
- const paramsToRemove = ['si', 'feature', 'ref', 'fsi', 'source', 'utm_source', 'utm_medium', 'utm_campaign', 'gclid', 'gclsrc', 'fbclid'];
- let changedParams = false;
- paramsToRemove.forEach(param => {
- if (url.searchParams.has(param)) {
- url.searchParams.delete(param);
- changedParams = true;
- }
- });
- if (changedParams) {
- cleanHref = url.toString();
- }
- } catch (e) {
- // Fallback to regex if URL parsing fails (e.g., malformed URL initially)
- cleanHref = cleanHref.replace(REGEX.YOUTUBE_TRACKING_PARAMS, '');
- cleanHref = cleanHref.replace(/(\?|&)$/, ''); // Remove trailing ? or &
- cleanHref = cleanHref.replace('?&', '?'); // Fix "?&" case
- }
-
-
- if (cleanHref !== originalHref) {
- try {
- linkElement.href = cleanHref;
- // Only update text if it exactly matched the old URL
- if (linkElement.textContent.trim() === originalHref.trim()) {
- linkElement.textContent = cleanHref;
- }
- } catch (e) {
- // This can happen if the element is removed from DOM during processing
- Logger.warn("Failed to update link href/text (element might be gone):", linkElement.textContent, e);
- }
- }
- }
-
- findLinksInNode(node) {
- if (!node || node.nodeType !== Node.ELEMENT_NODE) return [];
-
- const links = [];
- // Check if the node itself is a link in the target area
- if (node.matches && node.matches('.divMessage a')) {
- links.push(node);
- }
- // Find links within the node (or descendants) that are inside a .divMessage
- if (node.querySelectorAll) {
- const potentialLinks = node.querySelectorAll('.divMessage a');
- potentialLinks.forEach(link => {
- // Ensure the link is actually *within* a .divMessage that is a descendant of (or is) the input node
- if (node.contains(link) && link.closest('.divMessage')) {
- links.push(link);
- }
- });
- }
- // Return unique links only
- return [...new Set(links)];
- }
-
-
- async processLinks(links) {
- if (!links || links.length === 0) return;
-
- // Perform opportunistic cache cleanup *before* heavy processing
- await this.cache.clearExpired();
-
- const linksToFetch = [];
-
- for (const link of links) {
- // Skip if already enhanced, marked as failed for a different reason, or currently being fetched
- // Note: We specifically allow reprocessing if ytFailed is 'no-id' from a previous incorrect run
- if (link.dataset.ytEnhanced ||
- (link.dataset.ytFailed && link.dataset.ytFailed !== 'no-id') ||
- this.processingLinks.has(link)) {
- continue;
- }
-
- // --- Skip quotelinks ---
- if (link.classList.contains('quoteLink')) {
- // Mark as skipped so we don't check again
- link.dataset.ytFailed = 'skipped-type';
- continue; // Skip this link entirely, don't process further
- }
-
- // --- PRIMARY FIX: Check for Video ID FIRST ---
- const videoId = getVideoId(link.href);
-
- if (!videoId) {
- // It's NOT a YouTube link, or not one we can parse.
- // Mark as failed so we don't re-check it constantly.
- // Crucially, DO NOT call cleanLinkUrl or _applyTitle.
- link.dataset.ytFailed = 'no-id';
- // Optional: Remove old enhancement classes/data if they exist from a bad run
- // link.classList.remove("youtubelink");
- // delete link.dataset.videoId;
- continue; // Move to the next link in the list
- }
-
- // --- If we reach here, it IS a potential YouTube link ---
-
- // Now it's safe to clean the URL (only affects confirmed YT links)
- this.cleanLinkUrl(link);
-
- // Add video ID attribute now that we know it's a YT link
- link.dataset.videoId = videoId;
- // Clear any previous 'no-id' failure flag if it existed
- delete link.dataset.ytFailed;
-
- // Check cache for the title
- const cachedTitle = await this.cache.getTitle(videoId);
-
- if (cachedTitle !== null) {
- // Title found in cache, apply it directly
- this._applyTitle(link, videoId, cachedTitle);
- } else {
- // Title not cached, mark for fetching
- this.processingLinks.add(link);
- linksToFetch.push({ link, videoId });
- }
- } // End of loop through links
-
- // --- Process the batch of links needing API fetches ---
- if (linksToFetch.length === 0) {
- // Log only if there were links initially, but none needed fetching
- if (links.length > 0) Logger.log('No new links require title fetching.');
- return;
- }
-
- Logger.log(`Fetching titles for ${linksToFetch.length} links...`);
-
- // Fetch titles sequentially with delay
- for (let i = 0; i < linksToFetch.length; i++) {
- const { link, videoId } = linksToFetch[i];
-
- // Double check if link still exists in DOM before fetching
- if (!document.body.contains(link)) {
- this.processingLinks.delete(link);
- Logger.warn(`Link removed from DOM before title fetch: ${videoId}`);
- continue;
- }
-
- // Also check if it somehow got enhanced while waiting (e.g., duplicate link processed faster)
- if (link.dataset.ytEnhanced) {
- this.processingLinks.delete(link);
- continue;
- }
-
- try {
- const videoData = await this.api.fetchVideoData(videoId);
- const title = videoData.title.trim() || '[Untitled Video]'; // Handle empty titles
- this._applyTitle(link, videoId, title);
- await this.cache.setTitle(videoId, title);
- } catch (e) {
- Logger.warn(`Failed to enhance link ${videoId}: ${e.message}`);
- // Apply error state visually AND cache it
- this._applyTitle(link, videoId, "[YT Fetch Error]"); // Show error to user
- await this.cache.setTitle(videoId, "[YT Fetch Error]"); // Cache error state
- link.dataset.ytFailed = 'fetch-error'; // Mark specific failure type
- } finally {
- this.processingLinks.delete(link); // Remove from processing set regardless of outcome
- }
-
- // Apply delay between API calls
- if (i < linksToFetch.length - 1) {
- await delay(DEFAULTS.API_DELAY_MS);
- }
- }
- Logger.log(`Finished fetching batch.`);
- }
-
- _applyTitle(link, videoId, title) {
- // Check if link still exists before modifying
- if (!document.body.contains(link)) {
- Logger.warn(`Link removed from DOM before applying title: ${videoId}`);
- return;
- }
- const displayTitle = (title === "[YT Fetch Error]") ? '[YT Error]' : title;
- // Use textContent for security, avoid potential HTML injection from titles
- link.textContent = `${displayTitle} [${videoId}]`;
- link.classList.add("youtubelink");
- link.dataset.ytEnhanced = "true"; // Mark as successfully enhanced
- delete link.dataset.ytFailed; // Remove failed flag if it was set previously
- }
-
- // Force re-enhancement of all currently enhanced/failed links
- async reEnhanceAll() {
- Logger.log('Triggering re-enhancement of all detected YouTube links...');
- const links = document.querySelectorAll('a[data-video-id]');
- links.forEach(link => {
- delete link.dataset.ytEnhanced;
- delete link.dataset.ytFailed;
- // Reset text content only if it looks like our format, otherwise leave user-edited text
- if (link.classList.contains('youtubelink')) {
- const videoId = link.dataset.videoId;
- // Basic reset, might need refinement based on how cleanLinkUrl behaves
- link.textContent = link.href;
- this.cleanLinkUrl(link); // Re-clean the URL just in case
- }
- link.classList.remove('youtubelink');
- });
- await this.processLinks(Array.from(links)); // Process them again
- Logger.log('Re-enhancement process finished.');
- }
- }
-
- // --- Thumbnail Preview ---
- class ThumbnailPreview {
- constructor(settingsManager, apiFetcher) {
- this.settings = settingsManager;
- this.api = apiFetcher;
- this.popupElement = null;
- this.imageElement = null;
- this.currentVideoId = null;
- this.isHovering = false;
- this.hideTimeout = null;
- this.fetchController = null; // AbortController for fetch
- }
-
- createPopupElement() {
- if (document.getElementById(DEFAULTS.THUMBNAIL_POPUP_ID)) {
- this.popupElement = document.getElementById(DEFAULTS.THUMBNAIL_POPUP_ID);
- this.imageElement = this.popupElement.querySelector('img');
- if (!this.imageElement) { // Fix if img somehow got removed
- this.imageElement = document.createElement('img');
- this.imageElement.alt = "YouTube Thumbnail Preview";
- this.popupElement.appendChild(this.imageElement);
- }
- Logger.log('Re-using existing thumbnail popup element.');
- return;
- }
- this.popupElement = document.createElement('div');
- this.popupElement.id = DEFAULTS.THUMBNAIL_POPUP_ID;
-
- this.imageElement = document.createElement('img');
- this.imageElement.alt = "YouTube Thumbnail Preview";
- this.imageElement.src = PLACEHOLDER_IMG_SRC;
- this.imageElement.onerror = () => {
- // Don't log error if we aborted the load or hid the popup
- if (this.isHovering && this.imageElement.src !== PLACEHOLDER_IMG_SRC) {
- Logger.warn(`Thumbnail image failed to load data for video ${this.currentVideoId || '(unknown)'}.`);
- }
- this.hide(); // Hide on error
- };
-
- this.popupElement.appendChild(this.imageElement);
- document.body.appendChild(this.popupElement);
- Logger.log('Thumbnail popup created.');
- }
-
- handleMouseOver(event) {
- if (!this.settings.showThumbnails || !this.popupElement) return;
-
- const link = event.target.closest('.youtubelink[data-video-id]');
- if (!link) return;
-
- const videoId = link.dataset.videoId;
- if (!videoId) return;
-
- // Clear any pending hide action
- if (this.hideTimeout) {
- clearTimeout(this.hideTimeout);
- this.hideTimeout = null;
- }
-
- this.isHovering = true;
-
- // If it's a different video or the popup is hidden, show it
- if (videoId !== this.currentVideoId || this.popupElement.style.display === 'none') {
- this.currentVideoId = videoId;
- // Abort previous fetch if any
- this.fetchController?.abort();
- this.fetchController = new AbortController();
- this.show(event, videoId, this.fetchController.signal);
- }
- }
-
- handleMouseOut(event) {
- if (!this.settings.showThumbnails || !this.isHovering) return;
-
- const link = event.target.closest('.youtubelink[data-video-id]');
- if (!link) return; // Mouse out event not from a target link or its children
-
- // Check if the mouse moved to the popup itself (though pointer-events: none should prevent this)
- // or to another element still within the original link
- if (event.relatedTarget && (link.contains(event.relatedTarget) || this.popupElement?.contains(event.relatedTarget))) {
- return;
- }
-
- // Use a short delay before hiding
- this.hideTimeout = setTimeout(() => {
- this.isHovering = false;
- this.currentVideoId = null;
- this.fetchController?.abort(); // Abort fetch if mouse moves away quickly
- this.fetchController = null;
- this.hide();
- this.hideTimeout = null;
- }, DEFAULTS.THUMBNAIL_HIDE_DELAY_MS);
- }
-
- async show(event, videoId, signal) {
- if (!this.isHovering || !this.popupElement || !this.imageElement) return;
-
- // Reset image while loading
- this.imageElement.src = PLACEHOLDER_IMG_SRC;
- this.popupElement.style.display = 'block'; // Show popup frame immediately
- this.positionPopup(event); // Position based on initial event
-
- try {
- const dataUrl = await this.api.fetchThumbnailAsDataURL(videoId);
-
- // Check if fetch was aborted or if state changed during await
- if (signal?.aborted || !this.isHovering || videoId !== this.currentVideoId) {
- if (this.popupElement.style.display !== 'none') this.hide();
- return;
- }
-
- if (dataUrl) {
- this.imageElement.src = dataUrl;
- // Reposition after image loads, as dimensions might change slightly
- // Use requestAnimationFrame for smoother updates if needed, but direct might be fine
- this.positionPopup(event);
- this.popupElement.style.display = 'block'; // Ensure it's visible
- } else {
- Logger.warn(`No thumbnail data URL received for ${videoId}. Hiding popup.`);
- this.hide();
- }
-
- } catch (error) {
- if (error.name === 'AbortError') {
- Logger.log(`Thumbnail fetch aborted for ${videoId}.`);
- } else {
- Logger.error(`Error fetching thumbnail for ${videoId}:`, error);
- }
- this.hide(); // Hide on error
- }
- }
-
- positionPopup(event) {
- if (!this.popupElement) return;
-
- const offsetX = 15;
- const offsetY = 15;
- const buffer = 5; // Buffer from window edge
-
- // Get potential dimensions (use max dimensions as fallback)
- const popupWidth = this.popupElement.offsetWidth || 320;
- const popupHeight = this.popupElement.offsetHeight || 180;
- const winWidth = window.innerWidth;
- const winHeight = window.innerHeight;
- const mouseX = event.clientX;
- const mouseY = event.clientY;
-
- let x = mouseX + offsetX;
- let y = mouseY + offsetY;
-
- // Adjust horizontal position
- if (x + popupWidth + buffer > winWidth) {
- x = mouseX - popupWidth - offsetX; // Flip to left
- }
- x = Math.max(buffer, x); // Ensure it's not off-screen left
-
- // Adjust vertical position
- if (y + popupHeight + buffer > winHeight) {
- y = mouseY - popupHeight - offsetY; // Flip to top
- }
- y = Math.max(buffer, y); // Ensure it's not off-screen top
-
- this.popupElement.style.left = `${x}px`;
- this.popupElement.style.top = `${y}px`;
- }
-
-
- hide() {
- if (this.popupElement) {
- this.popupElement.style.display = 'none';
- }
- if (this.imageElement) {
- this.imageElement.src = PLACEHOLDER_IMG_SRC; // Reset image
- }
- // Don't reset currentVideoId here, mouseover might happen again quickly
- }
-
- attachListeners() {
- document.body.addEventListener('mouseover', this.handleMouseOver.bind(this));
- document.body.addEventListener('mouseout', this.handleMouseOut.bind(this));
- Logger.log('Thumbnail hover listeners attached.');
- }
- }
-
- // --- Settings UI (STM Integration) ---
- class SettingsUI {
- constructor(settingsManager, titleCache, linkEnhancer) {
- this.settings = settingsManager;
- this.cache = titleCache;
- this.enhancer = linkEnhancer;
- this.stmRegistrationAttempted = false; // Prevent multiple attempts if somehow called again
- }
-
- // Called by STM when the panel needs to be initialized
- initializePanel(panelElement) {
- Logger.log(`STM Initializing panel for ${SCRIPT_ID}`);
- // Use a specific ID for the content wrapper for easier targeting
- panelElement.innerHTML = `
- <div id="${SCRIPT_ID}-panel-content">
- <div>
- <strong>Title Cache:</strong><br>
- <label for="${SCRIPT_ID}-cache-expiry" class="small">Title Cache Expiry (Days):</label>
- <input type="number" id="${SCRIPT_ID}-cache-expiry" min="1" step="1" value="${this.settings.cacheExpiryDays}" title="Number of days to cache YouTube video titles">
- </div>
- <div>
- <button id="${SCRIPT_ID}-purge-cache">Purge Title Cache</button>
- <span id="${SCRIPT_ID}-purge-status"></span>
- </div>
- <hr style="border-color: #444; margin: 10px 0;">
- <div>
- <strong>Thumbnail Preview:</strong><br>
- <input type="checkbox" id="${SCRIPT_ID}-show-thumbnails" ${this.settings.showThumbnails ? 'checked' : ''}>
- <label for="${SCRIPT_ID}-show-thumbnails" class="small">Show Thumbnails on Hover</label>
- </div>
- <hr style="border-color: #444; margin: 15px 0 10px;">
- <div>
- <button id="${SCRIPT_ID}-save-settings">Save Settings</button>
- <span id="${SCRIPT_ID}-save-status"></span>
- </div>
- </div>`;
-
- // Attach listeners using the specific IDs
- panelElement.querySelector(`#${SCRIPT_ID}-save-settings`)?.addEventListener('click', () => this.handleSaveClick(panelElement));
- panelElement.querySelector(`#${SCRIPT_ID}-purge-cache`)?.addEventListener('click', () => this.handlePurgeClick(panelElement));
- }
-
- // Called by STM when the tab is activated
- activatePanel(panelElement) {
- Logger.log(`STM Activating panel for ${SCRIPT_ID}`);
- const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
- if (!contentWrapper) return;
-
- // Update input values from current settings
- const expiryInput = contentWrapper.querySelector(`#${SCRIPT_ID}-cache-expiry`);
- const thumbCheckbox = contentWrapper.querySelector(`#${SCRIPT_ID}-show-thumbnails`);
- const saveStatusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-save-status`);
- const purgeStatusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-purge-status`);
-
- if (expiryInput) expiryInput.value = this.settings.cacheExpiryDays;
- if (thumbCheckbox) thumbCheckbox.checked = this.settings.showThumbnails;
-
- // Clear status messages on activation
- if (saveStatusSpan) saveStatusSpan.textContent = '';
- if (purgeStatusSpan) purgeStatusSpan.textContent = '';
- }
-
- async handleSaveClick(panelElement) {
- const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
- if (!contentWrapper) { Logger.error("Cannot find panel content for saving."); return; }
-
- const expiryInput = contentWrapper.querySelector(`#${SCRIPT_ID}-cache-expiry`);
- const thumbCheckbox = contentWrapper.querySelector(`#${SCRIPT_ID}-show-thumbnails`);
- const statusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-save-status`);
-
- if (!expiryInput || !thumbCheckbox || !statusSpan) { Logger.error("Missing settings elements in panel."); return; }
-
- const days = parseInt(expiryInput.value, 10);
-
- if (isNaN(days) || days <= 0 || !Number.isInteger(days)) {
- this.showStatus(statusSpan, 'Invalid number of days!', 'red');
- Logger.warn('Attempted to save invalid cache expiry days:', expiryInput.value);
- return;
- }
-
- // Update settings via the SettingsManager instance
- this.settings.cacheExpiryDays = days;
- this.settings.showThumbnails = thumbCheckbox.checked;
- await this.settings.save();
-
- this.showStatus(statusSpan, 'Settings saved!', 'lime');
- Logger.log(`Settings saved via UI: Cache expiry ${days} days, Show Thumbnails ${thumbCheckbox.checked}.`);
-
- // Apply thumbnail setting change immediately
- if (!this.settings.showThumbnails) {
- // Hide any currently visible thumbnail popup if setting is disabled
- const thumbnailPreview = window.ytle?.thumbnailPreview; // Access instance if exposed
- thumbnailPreview?.hide();
- }
- }
-
- async handlePurgeClick(panelElement) {
- const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
- if (!contentWrapper) { Logger.error("Cannot find panel content for purging."); return; }
- const statusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-purge-status`);
- if (!statusSpan) { Logger.error("Missing purge status element."); return; }
-
-
- if (!confirm('Are you sure you want to purge the entire YouTube title cache?\nThis cannot be undone and will trigger re-fetching of all titles.')) {
- this.showStatus(statusSpan, 'Purge cancelled.', 'grey');
- return;
- }
-
- this.showStatus(statusSpan, 'Purging cache...', 'orange');
- const success = await this.cache.purgeAll();
-
- if (success) {
- this.showStatus(statusSpan, 'Cache purged! Re-enhancing links...', 'lime');
- // Trigger a re-enhancement of all known links
- await this.enhancer.reEnhanceAll();
- this.showStatus(statusSpan, 'Cache purged! Re-enhancement complete.', 'lime', 3000); // Update message after re-enhancement
- } else {
- this.showStatus(statusSpan, 'Purge failed!', 'red');
- }
- }
-
- showStatus(spanElement, message, color, duration = 3000) {
- if (!spanElement) return;
- spanElement.textContent = message;
- spanElement.style.color = color;
- // Clear message after duration, only if the message hasn't changed
- setTimeout(() => {
- if (spanElement.textContent === message) {
- spanElement.textContent = '';
- spanElement.style.color = 'var(--settings-text, #ccc)'; // Reset color
- }
- }, duration);
- }
-
- // --- Updated STM Registration with Timeout ---
- async registerWithSTM() {
- if (this.stmRegistrationAttempted) {
- Logger.log('STM registration already attempted, skipping.');
- return;
- }
- this.stmRegistrationAttempted = true;
-
- let stmAttempts = 0;
- const MAX_STM_ATTEMPTS = 20; // 20 attempts
- const STM_RETRY_DELAY_MS = 250; // 250ms delay
- const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total
-
- const checkAndRegister = () => {
- stmAttempts++;
- // Use Logger.log for debugging attempts if needed
- // Logger.log(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`);
-
- // *** Check unsafeWindow directly ***
- if (typeof unsafeWindow !== 'undefined'
- && typeof unsafeWindow.SettingsTabManager !== 'undefined'
- && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined')
- {
- Logger.log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...');
- // Found it, call the async registration function, but don't wait for it here.
- // Let the rest of the script initialization continue.
- performStmRegistration().catch(err => {
- Logger.error("Async registration with STM failed after finding it:", err);
- // Even if registration fails *after* finding STM, we proceed without the panel.
- });
- // STM found (or at least its .ready property), stop polling.
- return; // Exit the polling function
- }
-
- // STM not found/ready yet, check if we should give up
- if (stmAttempts >= MAX_STM_ATTEMPTS) {
- Logger.warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`);
- // Give up polling, DO NOT call setTimeout again.
- return; // Exit the polling function
- }
-
- // STM not found, limit not reached, schedule next attempt
- // Optional: Log if STM exists but .ready is missing
- // if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') {
- // Logger.log('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...');
- // } else {
- // Logger.log('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...');
- // }
- setTimeout(checkAndRegister, STM_RETRY_DELAY_MS); // Retry after a delay
- };
-
- const performStmRegistration = async () => {
- // This function now only runs if STM.ready was detected
- try {
- // *** Access via unsafeWindow ***
- // Ensure SettingsTabManager and .ready still exist before awaiting
- if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') {
- // Should not happen if called correctly, but check defensively
- Logger.error('SettingsTabManager.ready disappeared before registration could complete.');
- return; // Cannot register
- }
- const STM = await unsafeWindow.SettingsTabManager.ready;
- // *** End Access via unsafeWindow ***
-
- Logger.log('SettingsTabManager ready, registering tab...');
- const registrationSuccess = STM.registerTab({
- scriptId: SCRIPT_ID,
- tabTitle: SCRIPT_NAME,
- order: 110, // Keep your desired order
- onInit: this.initializePanel.bind(this),
- onActivate: this.activatePanel.bind(this)
- });
-
- if (registrationSuccess) {
- Logger.log(`Tab registration request sent successfully for ${SCRIPT_ID}.`);
- } else {
- Logger.warn(`STM registration for ${SCRIPT_ID} returned false (tab might already exist or other issue).`);
- }
-
- } catch (err) {
- Logger.error('Failed during SettingsTabManager.ready await or registerTab call:', err);
- // No need to retry here, just log the failure.
- }
- };
-
- // Start the check/wait process *asynchronously*.
- // This allows the main script initialization to continue immediately.
- checkAndRegister();
- }
- }
-
- // --- Main Application Class ---
- class YouTubeLinkEnhancerApp {
- constructor() {
- this.settingsManager = new SettingsManager();
- this.titleCache = new TitleCache(this.settingsManager);
- this.apiFetcher = new ApiFetcher();
- this.linkEnhancer = new LinkEnhancer(this.titleCache, this.apiFetcher, this.settingsManager);
- this.thumbnailPreview = new ThumbnailPreview(this.settingsManager, this.apiFetcher);
- this.settingsUI = new SettingsUI(this.settingsManager, this.titleCache, this.linkEnhancer);
- this.observer = null;
-
- // Expose instances for debugging/potential external interaction (optional)
- // Be cautious with exposing internal state/methods
- window.ytle = {
- settings: this.settingsManager,
- cache: this.titleCache,
- enhancer: this.linkEnhancer,
- thumbnailPreview: this.thumbnailPreview,
- ui: this.settingsUI
- };
- }
-
- async initialize() {
- Logger.log('Initializing...');
-
- // 1. Load settings
- await this.settingsManager.load();
-
- // 2. Add styles & create UI elements
- this.linkEnhancer.addStyles();
- this.thumbnailPreview.createPopupElement();
-
- // 3. Attach global listeners
- this.thumbnailPreview.attachListeners();
-
- // 4. Register settings UI
- await this.settingsUI.registerWithSTM();
-
- // 5. Initial scan & process existing links
- Logger.log('Running initial link processing...');
- const initialLinks = this.linkEnhancer.findLinksInNode(document.body);
- await this.linkEnhancer.processLinks(initialLinks);
- Logger.log('Initial processing complete.');
-
- // 6. Setup MutationObserver
- this.setupObserver();
-
- Logger.log('Initialization complete.');
- }
-
- setupObserver() {
- this.observer = new MutationObserver(async (mutationsList) => {
- let linksToProcess = new Set();
-
- for (const mutation of mutationsList) {
- if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
- for (const addedNode of mutation.addedNodes) {
- // Only process element nodes
- if (addedNode.nodeType === Node.ELEMENT_NODE) {
- const foundLinks = this.linkEnhancer.findLinksInNode(addedNode);
- foundLinks.forEach(link => {
- // Add link if it's potentially enhanceable (no videoId yet, or failed/not enhanced)
- if (!link.dataset.videoId || !link.dataset.ytEnhanced || link.dataset.ytFailed) {
- linksToProcess.add(link);
- }
- });
- }
- }
- }
- // Optional: Handle attribute changes if needed (e.g., href changes on existing links)
- // else if (mutation.type === 'attributes' && mutation.attributeName === 'href') {
- // const targetLink = mutation.target;
- // if (targetLink.matches && targetLink.matches('.divMessage a') && targetLink.closest('.divMessage')) {
- // // Handle potential re-enhancement if href changed
- // delete targetLink.dataset.ytEnhanced;
- // delete targetLink.dataset.ytFailed;
- // delete targetLink.dataset.videoId;
- // targetLink.classList.remove('youtubelink');
- // linksToProcess.add(targetLink);
- // }
- //}
- }
-
- if (linksToProcess.size > 0) {
- // Debounce slightly? Or process immediately? Immediate is simpler.
- Logger.log(`Observer detected ${linksToProcess.size} new/updated potential links.`);
- await this.linkEnhancer.processLinks([...linksToProcess]);
- }
- });
-
- this.observer.observe(document.body, {
- childList: true,
- subtree: true,
- // attributes: true, // Uncomment if you want to observe href changes
- // attributeFilter: ['href'] // Only observe href attribute changes
- });
- Logger.log('MutationObserver started.');
- }
- }
-
- // --- Script Entry Point ---
- const app = new YouTubeLinkEnhancerApp();
- app.initialize().catch(err => {
- Logger.error("Initialization failed:", err);
- });
-
- })();