SubDL Image Preview Beside Titles

Display image previews beside subtitles on subdl.com.

// ==UserScript==
// @name         SubDL Image Preview Beside Titles
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Display image previews beside subtitles on subdl.com.
// @author       dr.bobo0
// @license      MIT
// @match        https://subdl.com/*
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB8AAAAgCAMAAADdXFNzAAAAXVBMVEVHcEz/7ir/7ir/7ir/7ir/7ir/7ir/7in/7ir/7ir/7ir/7ir/7Sr/7ir/7ir/7ir/9Sn/8iopKjMxMTMdIDMKEDSDfDBkXzH/+inBtS3bzSzSxC1GRDLs3SuZkC8BXe1rAAAAD3RSTlMAmpG5qyvRFvBGY9wGycP/EwDKAAABG0lEQVQokX2Ti5KDIAxFUSmgtg1P6/v/P3NFB0y03TvjjPFAIjeEsSzZ8FaBankj2V21gFOivtDnG6jeT4wfcNcD5f6CAer/duMMPzDAgXmOtYnSJsWcZl982DXmBbFClXd31lm7PR+dPlW4euRd12G+/UGDue11PwfEG1Zg7hdjpumsDwUTmLvZwHaC84SCqfxuxrDV7okDCpujh+D8ShcQ8/rVuzBowhWO9Me6sKD6ir1IOpidcyh8sZJgs3jiT4l7r7UGM3nMa+qv1aafqb9neyMfpxHz2GCJuPPeO3fy/aKnDpgh+KiwJl4cFyhZYKZhV8IqDU4+3SGS/fcFRgMg1Y0qOoTFBRfX+ZQcUf5tglldVqIVVYmH9w/WzDC9Fj6LqQAAAABJRU5ErkJggg==
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==
(function() {
    'use strict';

    // Default configuration with persistent storage
    const DEFAULT_CONFIG = {
        imageWidth: 75,
        imageHeight: 112,
        isSquare: false,
        hideDownloadButton: false
    };

    // Get current settings, merging with defaults
    function getSettings() {
        return {
            imageWidth: GM_getValue('imageWidth', DEFAULT_CONFIG.imageWidth),
            imageHeight: GM_getValue('imageHeight', DEFAULT_CONFIG.imageHeight),
            isSquare: GM_getValue('isSquare', DEFAULT_CONFIG.isSquare),
            hideDownloadButton: GM_getValue('hideDownloadButton', DEFAULT_CONFIG.hideDownloadButton)
        };
    }

    // Save settings
    function saveSettings(settings) {
        GM_setValue('imageWidth', settings.imageWidth);
        GM_setValue('imageHeight', settings.imageHeight);
        GM_setValue('isSquare', settings.isSquare);
        GM_setValue('hideDownloadButton', settings.hideDownloadButton);
    }

    // Configuration function
    function getImageConfig() {
        const settings = getSettings();
        return {
            STORAGE_PREFIX: "subdl_image_cache_",
            MAX_CACHE_AGE: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
            IMAGE_STYLES: {
                width: `${settings.imageWidth}px`,
                height: `${settings.imageHeight}px`,
                objectFit: settings.isSquare ? 'cover' : 'contain',
                borderRadius: '4px',
                boxShadow: '0 2px 6px rgba(0, 0, 0, 0.15)',
                marginRight: '8px'
            },
            LINK_STYLES: {
                display: 'flex',
                alignItems: 'center',
                gap: '8px'
            }
        };
    }

    // Utility function to safely parse JSON from localStorage
    function safeJSONParse(key) {
        try {
            const item = localStorage.getItem(key);
            return item ? JSON.parse(item) : null;
        } catch (e) {
            localStorage.removeItem(key);
            return null;
        }
    }

    // Clear old cache entries
    function clearOldCache() {
        const CONFIG = getImageConfig();
        const now = Date.now();
        Object.keys(localStorage)
            .filter(key => key.startsWith(CONFIG.STORAGE_PREFIX))
            .forEach(key => {
                const item = safeJSONParse(key);
                if (!item || now - item.timestamp > CONFIG.MAX_CACHE_AGE) {
                    localStorage.removeItem(key);
                }
            });
    }

    // Apply styles to an element
    function applyStyles(element, styles) {
        Object.entries(styles).forEach(([key, value]) => {
            element.style[key] = value;
        });
    }

    // Helper function for creating elements with styles and attributes
    function createElement(tag, styles = {}, attributes = {}) {
        const element = document.createElement(tag);
        applyStyles(element, styles);
        Object.entries(attributes).forEach(([key, value]) => {
            element[key] = value;
        });
        return element;
    }

    // Fetch image for a specific subtitle link
    async function fetchImage(url, container) {
        const CONFIG = getImageConfig();
        const fullUrl = new URL(url, window.location.origin).href;
        const cacheKey = CONFIG.STORAGE_PREFIX + fullUrl;

        // Check cache
        const cachedImage = safeJSONParse(cacheKey);
        if (cachedImage && Date.now() - cachedImage.timestamp < CONFIG.MAX_CACHE_AGE) {
            displayImage(cachedImage.src, container);
            return;
        }

        // Fetch new image
        try {
            const response = await fetch(fullUrl);
            const html = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');
            const preview = doc.querySelector("div.select-none img");

            if (preview) {
                const src = preview.getAttribute("src");
                if (src) {
                    displayImage(src, container);

                    // Cache the image
                    localStorage.setItem(cacheKey, JSON.stringify({
                        src: src,
                        timestamp: Date.now()
                    }));
                }
            } else {
                console.warn(`No preview image found for ${fullUrl}`);
            }
        } catch (error) {
            console.error(`Failed to fetch image for ${fullUrl}:`, error);
        }
    }

    // Display image beside the title
    function displayImage(src, container) {
        const CONFIG = getImageConfig();
        const img = document.createElement("img");
        img.src = src;
        img.alt = "Preview";
        img.setAttribute('data-subdl-preview', 'true');

        const styles = CONFIG.IMAGE_STYLES;

        Object.entries(styles).forEach(([key, value]) => {
            img.style[key] = value;
        });

        // Add error handling to use poster.jpeg if image fails to load
        img.onerror = () => {
            img.src = 'https://subdl.com/images/poster.jpeg';
        };

        container.parentElement.insertBefore(img, container);
    }

    // Add image previews to links
    function addImagePreviews() {
        const links = document.querySelectorAll('a[href^="/s/info/"]:not([data-image-preview])');
        const CONFIG = getImageConfig();

        links.forEach(link => {
            // Mark as processed
            link.setAttribute('data-image-preview', 'true');

            const container = link.querySelector('h3');
            if (!container) return;

            // Style the link and parent
            applyStyles(link, CONFIG.LINK_STYLES);
            const parentDiv = link.closest('.flex');
            if (parentDiv) {
                applyStyles(parentDiv, {
                    display: 'flex',
                    alignItems: 'center',
                    gap: '12px'
                });
            }

            // Adjust SVG icon if present
            const svgIcon = link.querySelector('svg');
            if (svgIcon) {
                applyStyles(svgIcon, {
                    width: '20px',
                    height: '20px',
                    marginRight: '8px',
                    verticalAlign: 'middle'
                });
            }

            // Fetch and display image
            fetchImage(link.href, container);
        });
    }

    // Elegant color palette
    const COLORS = {
        background: '#1a202c',
        modalBg: '#2d3748',
        primary: '#4299e1',
        secondary: '#718096',
        accent: '#48bb78',
        textPrimary: '#e2e8f0',
        textSecondary: '#a0aec0',
        borderColor: '#4a5568'
    };

    // Create settings modal
    function createSettingsModal() {
        // Create modal backdrop using the helper function
        const backdrop = createElement('div', {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '100%',
            height: '100%',
            background: 'rgba(0,0,0,0.5)',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            zIndex: '10000',
            fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
        });

        // Create modal content
        const modal = createElement('div', {
            background: COLORS.modalBg,
            padding: '30px',
            borderRadius: '16px',
            width: '500px',
            boxShadow: '0 25px 50px -12px rgba(0,0,0,0.15)',
            position: 'relative',
            maxHeight: '80vh',
            overflowY: 'auto'
        });

        // Title
        const title = createElement('h2', {
            color: COLORS.primary,
            marginBottom: '25px',
            textAlign: 'center',
            fontWeight: '700',
            fontSize: '1.5rem'
        }, { textContent: 'Image Preview Settings' });
        modal.appendChild(title);

        // Current settings
        const settings = getSettings();

        // Preview Container
        const previewContainer = createElement('div', {
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            marginBottom: '25px',
            background: COLORS.background,
            padding: '25px',
            borderRadius: '12px'
        });
        modal.appendChild(previewContainer);

        // Preview Image
        const previewImg = createElement('img', {
            transition: 'all 0.3s ease',
            borderRadius: '12px',
            boxShadow: '0 10px 25px rgba(0,0,0,0.1)'
        }, { src: 'https://via.placeholder.com/150x225?text=Preview' });
        previewContainer.appendChild(previewImg);

        // Settings Container
        const settingsContainer = createElement('div', {
            display: 'flex',
            flexDirection: 'column',
            gap: '25px'
        });
        modal.appendChild(settingsContainer);

        // Image Width Slider
        const widthContainer = createElement('div');
        const widthLabel = createElement('label', {
            display: 'flex',
            justifyContent: 'space-between',
            color: COLORS.textPrimary,
            fontWeight: '600'
        });

        const widthLabelText = createElement('span', {}, { textContent: 'Image Width' });
        const widthValue = createElement('span', {}, { textContent: `${settings.imageWidth}px` });
        widthLabel.appendChild(widthLabelText);
        widthLabel.appendChild(widthValue);

        const widthSlider = createElement('input', {
            width: '100%',
            accentColor: COLORS.primary
        }, {
            type: 'range',
            min: '50',
            max: '200',
            value: settings.imageWidth
        });

        // Slider and preview synchronization
        function updatePreview() {
            const width = widthSlider.value;
            const height = width * (squareToggle.checked ? 1 : 1.5);

            widthValue.textContent = `${width}px`;
            previewImg.style.width = `${width}px`;
            previewImg.style.height = `${height}px`;

            // Live modifications to all preview images
            document.querySelectorAll('img[data-subdl-preview]').forEach(img => {
                img.style.width = `${width}px`;
                img.style.height = `${height}px`;
            });
        }

        widthSlider.oninput = updatePreview;

        widthContainer.appendChild(widthLabel);
        widthContainer.appendChild(widthSlider);
        settingsContainer.appendChild(widthContainer);

        // Square Images Toggle
        const squareToggleContainer = createElement('div', {
            display: 'flex',
            alignItems: 'center',
            gap: '12px'
        });

        const squareToggle = createElement('input', {
            accentColor: COLORS.primary
        }, {
            type: 'checkbox',
            id: 'squareImagesToggle',
            checked: settings.isSquare
        });

        const squareToggleLabel = createElement('label', {
            color: COLORS.textSecondary
        }, {
            htmlFor: 'squareImagesToggle',
            textContent: 'Square Images'
        });

        // Square toggle live update
        squareToggle.oninput = () => {
            const width = widthSlider.value;
            const height = width * (squareToggle.checked ? 1 : 1.5);

            previewImg.style.height = `${height}px`;

            // Live modifications to all preview images
            document.querySelectorAll('img[data-subdl-preview]').forEach(img => {
                img.style.height = `${height}px`;
                img.style.objectFit = squareToggle.checked ? 'cover' : 'contain';
            });
        };

        squareToggleContainer.appendChild(squareToggle);
        squareToggleContainer.appendChild(squareToggleLabel);
        settingsContainer.appendChild(squareToggleContainer);

        // Download Button Toggle
        const downloadToggleContainer = createElement('div', {
            display: 'flex',
            alignItems: 'center',
            gap: '12px'
        });

        const downloadToggle = createElement('input', {
            accentColor: COLORS.primary
        }, {
            type: 'checkbox',
            id: 'hideDownloadToggle',
            checked: settings.hideDownloadButton
        });

        const downloadToggleLabel = createElement('label', {
            color: COLORS.textSecondary
        }, {
            htmlFor: 'hideDownloadToggle',
            textContent: 'Hide Download Buttons'
        });

        // Add live preview for download toggle
        downloadToggle.oninput = () => {
            const downloadButtons = document.querySelectorAll('a[href^="https://dl.subdl.com/subtitle/"]');
            downloadButtons.forEach(button => {
                button.style.display = downloadToggle.checked ? 'none' : '';
            });
        };

        downloadToggleContainer.appendChild(downloadToggle);
        downloadToggleContainer.appendChild(downloadToggleLabel);
        settingsContainer.appendChild(downloadToggleContainer);

        // Buttons container
        const buttonContainer = createElement('div', {
            display: 'flex',
            justifyContent: 'space-between',
            marginTop: '25px',
            gap: '15px'
        });

        // Save button
        const saveButton = createElement('button', {
            backgroundColor: COLORS.accent,
            color: 'white',
            border: 'none',
            padding: '12px 20px',
            borderRadius: '8px',
            cursor: 'pointer',
            flexGrow: '1',
            fontWeight: '600'
        }, { textContent: 'Save Settings' });
        saveButton.onclick = () => {
            const newSettings = {
                imageWidth: parseInt(widthSlider.value),
                imageHeight: parseInt(widthSlider.value) * (squareToggle.checked ? 1 : 1.5),
                isSquare: squareToggle.checked,
                hideDownloadButton: downloadToggle.checked
            };
            saveSettings(newSettings);
            document.body.removeChild(backdrop);

            // Apply download button visibility
            const downloadButtons = document.querySelectorAll('a[href^="https://dl.subdl.com/subtitle/"]');
            downloadButtons.forEach(button => {
                button.style.display = newSettings.hideDownloadButton ? 'none' : '';
            });

            // Refresh previews after saving
            document.querySelectorAll('img[data-subdl-preview]').forEach(img => img.remove());
            document.querySelectorAll('a[data-image-preview]').forEach(link => link.removeAttribute('data-image-preview'));
            addImagePreviews();
        };

        // Cancel button
        const cancelButton = createElement('button', {
            backgroundColor: COLORS.background,
            color: COLORS.textSecondary,
            border: `2px solid ${COLORS.background}`,
            padding: '10px 20px',
            borderRadius: '8px',
            cursor: 'pointer',
            flexGrow: '1',
            fontWeight: '600'
        }, { textContent: 'Cancel' });
        cancelButton.onclick = () => {
            document.body.removeChild(backdrop);
        };

        buttonContainer.appendChild(saveButton);
        buttonContainer.appendChild(cancelButton);
        settingsContainer.appendChild(buttonContainer);

        backdrop.appendChild(modal);
        document.body.appendChild(backdrop);

        // Initial setup of preview
        updatePreview();
    }

    // Throttle utility function
    function throttle(func, limit) {
        let lastFunc;
        let lastRan;
        return function() {
            const context = this;
            const args = arguments;
            if (!lastRan) {
                func.apply(context, args);
                lastRan = Date.now();
            } else {
                clearTimeout(lastFunc);
                lastFunc = setTimeout(function() {
                    if (Date.now() - lastRan >= limit) {
                        func.apply(context, args);
                        lastRan = Date.now();
                    }
                }, limit - (Date.now() - lastRan));
            }
        };
    }

    // Add settings menu command
    GM_registerMenuCommand('Configure Image Previews', createSettingsModal);

    // Initial setup
    function applyDownloadButtonVisibility() {
        const settings = getSettings();
        const downloadButtons = document.querySelectorAll('a[href^="https://dl.subdl.com/subtitle/"]');
        downloadButtons.forEach(button => {
            button.style.display = settings.hideDownloadButton ? 'none' : '';
        });
    }

    function init() {
        clearOldCache();
        addImagePreviews();
        applyDownloadButtonVisibility();

        const throttledAddImagePreviews = throttle(() => {
            addImagePreviews();
            applyDownloadButtonVisibility();
        }, 500);

        const observer = new MutationObserver(() => {
            throttledAddImagePreviews();
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // Run the script
    init();
})();