// ==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 // @downloadURL none // ==/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 = ``; // --- 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 = `
Title Cache:

Thumbnail Preview:

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