8chan YouTube Link Enhancer

Cleans up YouTube links, adds titles, optional thumbnail previews, and settings via STM.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();