Post Gallery

Transforms post list into a gallery.

// ==UserScript==
// @name         Post Gallery
// @namespace    http://tampermonkey.net/
// @version      1.1.
// @description  Transforms post list into a gallery.
// @author       remuru
// @match        *://kemono.cr/*
// @match        *://coomer.st/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      kemono.cr
// @connect      coomer.st
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 1. Configuration & Settings ---

    const DEFAULT_CONFIG = {
        LAYOUT: { GRID_GAP: "16px", GRID_COLUMN_COUNT: 4 },
    };
    const MAX_CONCURRENT_DOWNLOADS = 10; // Hardcoded download limit
    let settings; // Will hold loaded or default settings
    const currentDomain = window.location.hostname;
    let lastProcessedUrl = null;

    function loadSettings() {
        const savedSettings = localStorage.getItem('gallerySettings');
        const defaults = {
            gridColumnCount: DEFAULT_CONFIG.LAYOUT.GRID_COLUMN_COUNT,
        };
        settings = savedSettings ? { ...defaults, ...JSON.parse(savedSettings) } : defaults;
    }

    function saveSettings() {
        localStorage.setItem('gallerySettings', JSON.stringify(settings));
    }

    // --- 2. UI & Styling ---

    function addGlobalStyles() {
        // Static styles that don't change
        const STYLES = `
            body { position: relative; } /* Needed for fixed panels */
            .post-card { width: 100% !important; margin: 0 !important; break-inside: avoid; background: rgba(30, 32, 34, 0.8); border-radius: 8px; overflow: hidden; height: auto !important; transition: transform 0.2s ease, box-shadow 0.2s ease; }
            .post-card:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
            #gallery-loader { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 20px; border-radius: 8px; z-index: 10001; display: flex; align-items: center; font-family: sans-serif; }
            .loading-spinner { width: 20px; height: 20px; border: 3px solid #fff; border-radius: 50%; border-top-color: transparent; animation: spin 1s linear infinite; margin-right: 10px; }
            @keyframes spin { to { transform: rotate(360deg); } }

            /* Slider Styles */
            .post-card__slider-container { position: relative; width: 100%; height: 0; padding-bottom: 125%; background-color: #000; overflow: hidden; }
            .slider-wrapper { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; transition: transform 0.4s ease-in-out; }
            .slider-media-item { width: 100%; height: 100%; object-fit: cover; flex-shrink: 0; display: block; background-color: #000; }
            .slider-btn { position: absolute; top: 50%; transform: translateY(-50%); z-index: 10; background-color: rgba(20, 20, 20, 0.6); color: white; border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 20px; line-height: 32px; text-align: center; cursor: pointer; opacity: 0; transition: opacity 0.2s ease, background-color 0.2s ease; user-select: none; }
            .post-card:hover .slider-btn { opacity: 1; }
            .slider-btn:hover { background-color: rgba(0, 0, 0, 0.8); }
            .slider-btn-prev { left: 8px; }
            .slider-btn-next { right: 8px; }
            .slider-counter { position: absolute; top: 8px; right: 8px; z-index: 10; background-color: rgba(20, 20, 20, 0.7); color: white; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: bold; user-select: none; }

            /* Paginator Settings Styles */
            .paginator { display: flex; flex-flow: column; align-items: center; justify-content: center; }
            #gallery-paginator-settings { position: relative; margin-left: 20px; }
            #gallery-settings-toggle { background: #3a3f44; color: #ddd; border: 1px solid #555; border-radius: 4px; padding: 5px 10px; cursor: pointer; font-size: 14px; }
            #gallery-settings-toggle:hover { background: #4a4f54; }
            #gallery-settings-dropdown { display: none; position: absolute; margin-top: 10px; left: 50%; transform: translateX(-50%); margin-bottom: 10px; background-color: rgba(30, 32, 34, 0.95); padding: 15px; border-radius: 8px; z-index: 1000; border: 1px solid #444; width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
            #gallery-settings-dropdown input[type=range] { width: 100%; }
        `;
        GM_addStyle(STYLES);

        // Create a dedicated style element for dynamic rules
        const dynamicStyles = document.createElement('style');
        dynamicStyles.id = 'dynamic-gallery-styles';
        document.head.appendChild(dynamicStyles);
    }

    function updateGridStyle() {
        const dynamicStyles = document.getElementById('dynamic-gallery-styles');
        if (dynamicStyles) {
            dynamicStyles.innerHTML = `.card-list--legacy .card-list__items {
                display: grid !important;
                grid-template-columns: repeat(${settings.gridColumnCount}, 1fr);
                gap: ${DEFAULT_CONFIG.LAYOUT.GRID_GAP};
                padding-top: ${DEFAULT_CONFIG.LAYOUT.GRID_GAP};
                width: 100%; margin: 0 auto;
            }`;
        }
    }

    function createPaginatorSettings() {
        if (document.getElementById('gallery-paginator-settings')) return;

        const paginatorMenu = document.querySelector('.paginator menu');
        if (!paginatorMenu) return;

        const container = document.createElement('div');
        container.id = 'gallery-paginator-settings';

        container.innerHTML = `
            <button id="gallery-settings-toggle">
                Колонки: <span id="gallery-column-count-display">${settings.gridColumnCount}</span>
            </button>
            <div id="gallery-settings-dropdown">
                <input type="range" id="column-count-slider" min="1" max="12" step="1" value="${settings.gridColumnCount}">
            </div>
        `;

        paginatorMenu.insertAdjacentElement('afterend', container);

        const toggleBtn = container.querySelector('#gallery-settings-toggle');
        const dropdown = container.querySelector('#gallery-settings-dropdown');
        const slider = container.querySelector('#column-count-slider');
        const display = container.querySelector('#gallery-column-count-display');

        toggleBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            dropdown.style.display = dropdown.style.display === 'block' ? 'none' : 'block';
        });

        document.addEventListener('click', (e) => {
            if (!container.contains(e.target)) {
                dropdown.style.display = 'none';
            }
        });

        slider.addEventListener('input', () => {
            settings.gridColumnCount = parseInt(slider.value, 10);
            display.textContent = settings.gridColumnCount;
            updateGridStyle();
        });

        slider.addEventListener('change', saveSettings);
    }

    function showLoader() {
        if (document.getElementById('gallery-loader')) return;
        const loader = document.createElement('div');
        loader.id = 'gallery-loader';
        loader.innerHTML = `<div class="loading-spinner"></div><span>Gallery: Loading...</span>`;
        document.body.appendChild(loader);
    }

    function hideLoader() {
        const loader = document.getElementById('gallery-loader');
        if (loader) loader.remove();
    }

    // --- 3. API & Data Handling ---

    function determinePageContext() {
        const pathname = window.location.pathname;
        const searchParams = new URLSearchParams(window.location.search);
        const query = searchParams.get('q');
        const userProfileMatch = pathname.match(/^\/([^/]+)\/user\/([^/]+)$/);
        if (userProfileMatch && !query) return { type: 'profile', service: userProfileMatch[1], userId: userProfileMatch[2] };
        if (userProfileMatch && query) return { type: 'user_search', service: userProfileMatch[1], userId: userProfileMatch[2], query };
        if (pathname === '/posts/popular') return { type: 'popular_posts', date: searchParams.get('date') || 'none', period: searchParams.get('period') || 'recent' };
        if (pathname === '/posts') return { type: 'global_search', query: query || null };
        return null;
    }

    function buildApiUrl(context, offset) {
        let baseApiUrl = `https://${currentDomain}/api/v1`;
        let queryParams = [];
        if (!context) return null;
        switch (context.type) {
            case 'profile':
                if (offset > 0) queryParams.push(`o=${offset}`);
                return `${baseApiUrl}/${context.service}/user/${context.userId}/posts?${queryParams.join('&')}`;
            case 'user_search':
                queryParams.push(`q=${encodeURIComponent(context.query)}`);
                if (offset > 0) queryParams.push(`o=${offset}`);
                return `${baseApiUrl}/${context.service}/user/${context.userId}/posts?${queryParams.join('&')}`;
            case 'global_search':
                if (offset > 0) queryParams.push(`o=${offset}`);
                if (context.query) queryParams.push(`q=${encodeURIComponent(context.query)}`);
                return `${baseApiUrl}/posts?${queryParams.join('&')}`;
            case 'popular_posts':
                if (context.date !== 'none' && context.period !== "recent") queryParams.push(`date=${encodeURIComponent(context.date)}`);
                if (offset > 0) queryParams.push(`o=${offset}`);
                queryParams.push(`period=${encodeURIComponent(context.period)}`);
                return `${baseApiUrl}/posts/popular?${queryParams.join('&')}`;
            default:
                return null;
        }
    }

    function fetchData(apiUrl) {
        const headers = { "Accept": "application/json", "Referer": window.location.href, "X-Requested-With": "XMLHttpRequest" };
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET", url: apiUrl, headers: headers,
                onload: resp => {
                    if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(`Error parsing JSON: ${e.message}`); } }
                    else { reject(`API request failed: ${resp.status}`); }
                },
                onerror: resp => reject(`API request error: ${resp.statusText || 'Network error'}`)
            });
        });
    }

    function processApiData(posts) {
        const mediaMap = new Map();
        const videoExtensions = ['.mp4', '.webm', '.mov', '.m4v'];
        const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];

        for (const post of posts) {
            if (!post.id) continue;
            const postVideos = [], postImages = [];
            const allFiles = [post.file, ...post.attachments].filter(f => f && f.path);

            for (const file of allFiles) {
                if (!file.path) continue;
                const fileName = file.name ? file.name.toLowerCase() : '';
                const fileUrl = `https://${currentDomain}/data${file.path}`;

                if (videoExtensions.some(ext => fileName.endsWith(ext))) {
                    postVideos.push({ type: 'video', src: fileUrl });
                } else if (imageExtensions.some(ext => fileName.endsWith(ext))) {
                    const thumbnailUrl = fileUrl.replace(`https://${currentDomain}/`, `https://img.${currentDomain}/thumbnail/`);
                    postImages.push({ type: 'image', thumbnail: thumbnailUrl, full: fileUrl });
                }
            }

            const uniqueVideos = Array.from(new Map(postVideos.map(v => [v.src, v])).values());
            const uniqueImages = Array.from(new Map(postImages.map(img => [img.full, img])).values());
            const posterUrl = uniqueImages.length > 0 ? uniqueImages[0].thumbnail : null;
            uniqueVideos.forEach(video => video.poster = posterUrl);
            const allMedia = [...uniqueVideos, ...uniqueImages];
            mediaMap.set(post.id, { media: allMedia });
        }
        return mediaMap;
    }

    // --- 4. DOM Manipulation & Interactivity ---

    function updateDOM(mediaMap) {
        document.querySelectorAll('.post-card').forEach(card => {
            const postId = card.dataset.id;
            if (!postId) return;

            const data = mediaMap.get(postId);
            if (!data || !data.media || data.media.length === 0) return;

            const mediaItems = data.media;
            const originalContainer = card.querySelector('.post-card__image-container, .post-card__video-container');
            const newMediaContainer = document.createElement('div');
            newMediaContainer.className = 'post-card__slider-container';
            newMediaContainer.dataset.currentIndex = "0";

            const wrapper = document.createElement('div');
            wrapper.className = 'slider-wrapper';

            mediaItems.forEach(item => {
                let mediaElement;
                if (item.type === 'video') {
                    mediaElement = document.createElement('video');
                    mediaElement.className = 'slider-media-item';
                    Object.assign(mediaElement, { loop: true, muted: true, preload: "metadata", controls: true });
                    if (item.poster) mediaElement.poster = item.poster;
                    mediaElement.dataset.src = item.src;
                    lazyLoadObserver.observe(mediaElement);
                } else {
                    mediaElement = document.createElement('img');
                    mediaElement.className = 'slider-media-item';
                    mediaElement.src = item.thumbnail;
                    mediaElement.dataset.fullSrc = item.full;
                }
                wrapper.appendChild(mediaElement);
            });
            newMediaContainer.appendChild(wrapper);

            if (mediaItems.length > 1) {
                newMediaContainer.insertAdjacentHTML('beforeend', `
                    <button class="slider-btn slider-btn-prev">&#10094;</button>
                    <button class="slider-btn slider-btn-next">&#10095;</button>
                    <div class="slider-counter">1 / ${mediaItems.length}</div>
                `);
            }

            if (originalContainer) {
                originalContainer.replaceWith(newMediaContainer);
            } else {
                const header = card.querySelector('.post-card__header');
                if (header) header.insertAdjacentElement('afterend', newMediaContainer);
                else card.prepend(newMediaContainer);
            }
        });

        // Activate all sliders on the page
        document.querySelectorAll('.post-card__slider-container').forEach(slider => {
            const wrapper = slider.querySelector('.slider-wrapper');
            const prevBtn = slider.querySelector('.slider-btn-prev');
            const nextBtn = slider.querySelector('.slider-btn-next');
            const counter = slider.querySelector('.slider-counter');
            const mediaElements = slider.querySelectorAll('.slider-media-item');
            const totalSlides = mediaElements.length;

            if (totalSlides <= 1) return;

            function updateSliderView(previousIndex) {
                const currentIndex = parseInt(slider.dataset.currentIndex, 10);
                wrapper.style.transform = `translateX(-${currentIndex * 100}%)`;
                if (counter) counter.textContent = `${currentIndex + 1} / ${totalSlides}`;
                if (prevBtn) prevBtn.style.display = currentIndex === 0 ? 'none' : 'block';
                if (nextBtn) nextBtn.style.display = currentIndex === totalSlides - 1 ? 'none' : 'block';

                const currentElement = mediaElements[currentIndex];
                if (currentElement.tagName === 'VIDEO') {
                    currentElement.play().catch(e => { /* Play was likely interrupted by user action */ });
                }

                if (previousIndex !== undefined && previousIndex !== currentIndex) {
                    const previousElement = mediaElements[previousIndex];
                    if (previousElement.tagName === 'VIDEO') {
                        previousElement.pause();
                    }
                }
            }

            prevBtn.addEventListener('click', (e) => {
                e.preventDefault(); e.stopPropagation();
                const currentIndex = parseInt(slider.dataset.currentIndex, 10);
                if (currentIndex > 0) {
                    slider.dataset.currentIndex = currentIndex - 1;
                    updateSliderView(currentIndex);
                }
            });

            nextBtn.addEventListener('click', (e) => {
                e.preventDefault(); e.stopPropagation();
                const currentIndex = parseInt(slider.dataset.currentIndex, 10);
                if (currentIndex < totalSlides - 1) {
                    slider.dataset.currentIndex = currentIndex + 1;
                    updateSliderView(currentIndex);
                }
            });

            updateSliderView();
        });
    }

    // --- 5. Performance & Loading ---

    let downloadQueue = [];
    let activeDownloads = 0;

    function getVideoMimeType(url) {
        const extension = url.split('.').pop().toLowerCase();
        switch (extension) { case 'mp4': case 'm4v': return 'video/mp4'; case 'webm': return 'video/webm'; case 'mov': return 'video/quicktime'; default: return 'video/mp4'; }
    }

    function startVideoLoad(video) {
        const onComplete = () => {
            activeDownloads--;
            processQueue();
        };
        video.addEventListener('loadeddata', onComplete, { once: true });
        video.addEventListener('error', () => {
            console.error('Video load error:', video.dataset.src);
            onComplete();
        }, { once: true });

        const source = document.createElement('source');
        source.src = video.dataset.src;
        source.type = getVideoMimeType(video.dataset.src);
        video.appendChild(source);
        video.load();
    }

    function processQueue() {
        while (activeDownloads < MAX_CONCURRENT_DOWNLOADS && downloadQueue.length > 0) {
            activeDownloads++;
            const videoToLoad = downloadQueue.shift();
            startVideoLoad(videoToLoad);
        }
    }

    const lazyLoadObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const mediaElement = entry.target;
                observer.unobserve(mediaElement);
                if (mediaElement.tagName === 'VIDEO') {
                    downloadQueue.push(mediaElement);
                    processQueue();
                }
            }
        });
    }, { rootMargin: "200px" });

    // --- 6. Main Execution Logic ---

    async function runScript() {
        if (!document.querySelector('.card-list__items') || lastProcessedUrl === window.location.href) return;
        lastProcessedUrl = window.location.href;
        showLoader();
        createPaginatorSettings(); // Ensure settings UI is present
        try {
            const context = determinePageContext();
            const offset = new URLSearchParams(window.location.search).get('o') || 0;
            const apiUrl = buildApiUrl(context, offset);
            if (!apiUrl) throw new Error("Could not build API URL.");

            const apiResponse = await fetchData(apiUrl);
            const postsArray = Array.isArray(apiResponse) ? apiResponse : (apiResponse && Array.isArray(apiResponse.posts)) ? apiResponse.posts : [];
            if (postsArray.length === 0) {
                hideLoader();
                return;
            }

            const mediaMap = processApiData(postsArray);
            updateDOM(mediaMap);
        } catch (error) {
            console.error("Gallery script execution failed:", error);
        } finally {
            hideLoader();
        }
    }

    function setupNavigationObserver() {
        const originalPushState = history.pushState;
        history.pushState = function(...args) {
            originalPushState.apply(this, args);
            // Use a small timeout to allow the DOM to update after pushState
            setTimeout(runScript, 100);
        };
        window.addEventListener('popstate', runScript);

        const observer = new MutationObserver((mutations) => {
            // A more robust check for page changes
             for(const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                     if (lastProcessedUrl !== window.location.href && document.querySelector('.card-list__items')) {
                        runScript();
                        break;
                    }
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // --- Script Initialization ---
    function initialize() {
        loadSettings();
        addGlobalStyles();
        updateGridStyle();
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', runScript);
        } else {
            runScript();
        }
        setupNavigationObserver();
    }

    initialize();

})();