Pawchive Video Filter

Filters posts with videos/GIFs on Pawchive. Supports posts, search, user profiles.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pawchive Video Filter
// @namespace    http://tampermonkey.net/
// @version      1.1.1
// @description  Filters posts with videos/GIFs on Pawchive. Supports posts, search, user profiles.
// @author       ImTrep, DeepSeek
// @match        https://*.pawchive.pw/*
// @match        https://pawchive.pw/*
// @icon         https://i.imgur.com/1V8bLDj.png
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// @license      MIT
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    // ---------- Constants ----------
    const VIDEO_EXTENSIONS = new Set([
        'mp4', 'm4v', 'mov', 'webm', 'avi', 'mkv', 'wmv', 'flv', 'mpg', 'mpeg',
        'm2ts', 'mts', 'ogv', 'qt', 'rm', 'rmvb', 'vob', 'asf', '3gp', 'f4v',
        'mxf', 'swf', 'ts'
    ]);
    const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'bmp', 'webp', 'svg']);

    const POSTS_PER_PAGE = 50;
    const API_DELAY = 1200;
    const MAX_CONCURRENT_REQUESTS = 2;
    const SUBSTRING_TITLE_LENGTH = 100;
    const LS_COLLAPSE_KEY = 'videoFilterPanelCollapsed_v1';
    const LS_PAGES_KEY = 'videoFilterPageRange_v1';
    const LS_CACHE_KEY = 'videoFilterCache_v1';
    const CACHE_TTL = 5 * 60 * 1000;
    const THUMBNAIL_CACHE_TTL = 24 * 60 * 60 * 1000;
    const MAX_THUMBNAIL_RETRIES = 3;
    const THUMBNAIL_RETRY_DELAY = 1000;
    const VIDEO_LOAD_TIMEOUT = 12000;

    const GM_GIFS_KEY = 'videoFilterIncludeGifs';

    // ---------- State Variables ----------
    let currentDomain = window.location.hostname.replace(/^www\./, '');
    const fileDomain = currentDomain.startsWith('file.') ? currentDomain : `file.${currentDomain}`;
    let allFoundVideoUrls = [];
    let videoIntersectionObserver = null;
    let isPanelCollapsed = false;
    let isFiltering = false;
    let activeRequests = 0;
    let requestQueue = [];
    const thumbnailRetryMap = new Map();
    const failedThumbnails = new Set(); // Stores video IDs that failed permanently

    let totalThumbnailsToLoad = 0;
    let thumbnailsLoadedCount = 0;
    let allMediaLoadedShown = false;
    let includeGifs = false;
    let failedThumbnailCount = 0;

    // ---------- UI ----------
    const uiContainer = document.createElement('div');
    uiContainer.id = 'video-filter-ui';
    Object.assign(uiContainer.style, {
        position: 'fixed', bottom: '10px', right: '10px',
        backgroundColor: '#2c2c2e', color: '#e0e0e0', border: '1px solid #444',
        padding: '12px', zIndex: '9999', fontFamily: 'Arial, sans-serif', fontSize: '14px',
        boxShadow: '0 2px 8px rgba(0,0,0,0.5)', borderRadius: '4px',
        transition: 'width 0.2s, height 0.2s, padding 0.2s'
    });

    const collapseButton = document.createElement('button');
    collapseButton.id = 'video-filter-collapse-button';
    collapseButton.innerHTML = '»';
    collapseButton.title = 'Collapse/Expand Panel';
    Object.assign(collapseButton.style, {
        position: 'absolute', top: '12px', bottom: '12px', left: '8px', width: '25px',
        display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0',
        fontSize: '16px', backgroundColor: '#4a4a4c', color: '#f0f0f0',
        border: '1px solid #555', borderRadius: '3px', cursor: 'pointer', zIndex: '1'
    });

    const settingsButton = document.createElement('button');
    settingsButton.id = 'video-filter-settings-button';
    settingsButton.innerHTML = '⋮';
    settingsButton.title = 'Settings';
    Object.assign(settingsButton.style, {
        width: '25px', height: '25px', display: 'flex', alignItems: 'center',
        justifyContent: 'center', padding: '0', fontSize: '18px',
        backgroundColor: 'transparent', color: '#f0f0f0',
        border: '1px solid #555', borderRadius: '3px', cursor: 'pointer', flexShrink: '0'
    });

    const panelMainContent = document.createElement('div');
    panelMainContent.id = 'video-filter-main-content';
    panelMainContent.style.marginLeft = '30px';

    const pageRangeInput = document.createElement('input');
    pageRangeInput.type = 'text';
    pageRangeInput.id = 'video-filter-page-range';
    pageRangeInput.value = '1';
    pageRangeInput.placeholder = 'e.g., 1, 2-5, 7';
    Object.assign(pageRangeInput.style, {
        width: '100px', marginRight: '8px', padding: '6px 8px',
        backgroundColor: '#1e1e1e', color: '#e0e0e0', border: '1px solid #555', borderRadius: '3px'
    });

    const filterButton = document.createElement('button');
    filterButton.id = 'video-filter-button';
    filterButton.textContent = 'Filter Videos/GIFs';

    const copyUrlsButton = document.createElement('button');
    copyUrlsButton.id = 'video-copy-urls-button';
    copyUrlsButton.textContent = 'Copy URLs';
    copyUrlsButton.disabled = true;

    const statusMessage = document.createElement('div');
    statusMessage.id = 'video-filter-status';
    Object.assign(statusMessage.style, { fontSize: '12px', minHeight: '15px', color: '#ccc', flex: '1' });

    const baseButtonBg = '#3a3a3c', hoverButtonBg = '#4a4a4c';
    const disabledButtonBg = '#303030', disabledButtonColor = '#777';

    function styleButton(button, disabled = false) {
        if (disabled) {
            button.style.backgroundColor = disabledButtonBg;
            button.style.color = disabledButtonColor;
            button.style.cursor = 'default';
        } else {
            button.style.backgroundColor = baseButtonBg;
            button.style.color = '#f0f0f0';
            button.style.cursor = 'pointer';
        }
        Object.assign(button.style, {
            marginRight: '8px', padding: '6px 12px',
            border: '1px solid #555', borderRadius: '3px'
        });
    }

    [filterButton, copyUrlsButton].forEach(btn => {
        styleButton(btn, btn.disabled);
        btn.addEventListener('mouseenter', () => { if (!btn.disabled) btn.style.backgroundColor = hoverButtonBg; });
        btn.addEventListener('mouseleave', () => { if (!btn.disabled) btn.style.backgroundColor = baseButtonBg; });
    });
    collapseButton.addEventListener('mouseenter', () => { collapseButton.style.backgroundColor = hoverButtonBg; });
    collapseButton.addEventListener('mouseleave', () => { collapseButton.style.backgroundColor = '#4a4a4c'; });
    settingsButton.addEventListener('mouseenter', () => { settingsButton.style.backgroundColor = hoverButtonBg; });
    settingsButton.addEventListener('mouseleave', () => { settingsButton.style.backgroundColor = 'transparent'; });

    // Settings panel
    const settingsPanel = document.createElement('div');
    settingsPanel.id = 'video-filter-settings-panel';
    Object.assign(settingsPanel.style, {
        position: 'fixed', right: '10px', backgroundColor: '#2c2c2e', color: '#e0e0e0',
        border: '1px solid #444', padding: '12px', zIndex: '10000',
        fontFamily: 'Arial, sans-serif', fontSize: '14px',
        boxShadow: '0 2px 8px rgba(0,0,0,0.5)', borderRadius: '4px',
        display: 'none', width: '220px'
    });

    const settingsTitle = document.createElement('div');
    settingsTitle.textContent = 'Settings';
    Object.assign(settingsTitle.style, {
        fontWeight: 'bold', marginBottom: '12px', borderBottom: '1px solid #555',
        paddingBottom: '8px', fontSize: '15px'
    });

    function makeToggle(id, labelText) {
        const label = document.createElement('label');
        Object.assign(label.style, {
            display: 'flex', alignItems: 'center', gap: '10px',
            cursor: 'pointer', fontSize: '14px', marginBottom: '8px'
        });
        const input = document.createElement('input');
        input.type = 'checkbox';
        input.id = id;
        label.append(input, document.createTextNode(labelText));
        return { label, input };
    }

    const gifsUI = makeToggle('include-gifs-toggle', 'Include GIFs (in addition to videos)');

    settingsPanel.appendChild(settingsTitle);
    settingsPanel.appendChild(gifsUI.label);

    const statusRow = document.createElement('div');
    Object.assign(statusRow.style, {
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        marginTop: '8px', gap: '8px'
    });

    panelMainContent.append(document.createTextNode('Pages: '), pageRangeInput, filterButton, copyUrlsButton, statusRow);
    statusRow.append(statusMessage, settingsButton);
    uiContainer.append(collapseButton, panelMainContent);
    document.body.append(uiContainer, settingsPanel);

    // Ensure media sits between header & footer (no overlap) and scales with card size
    const vfStyle = document.createElement('style');
    vfStyle.id = 'video-filter-card-styles';
    vfStyle.textContent = `
      .post-card.post-card--preview.video-filter-post > .image-link {
        display: flex;
        flex-direction: column;
        height: 100%;
        min-height: 0;
      }
      .post-card.post-card--preview.video-filter-post .post-card__header,
      .post-card.post-card--preview.video-filter-post .post-card__footer {
        flex: 0 0 auto;
      }
      .post-card.post-card--preview.video-filter-post .post-card__image-container {
        flex: 1 1 auto;
        min-height: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden;
        position: relative;
      }
      .post-card.post-card--preview.video-filter-post .post-card__image {
        max-width: 100%;
        max-height: 100%;
        width: auto;
        height: auto;
        object-fit: contain;
        object-position: center;
      }
    `;
    document.head.appendChild(vfStyle);

    // ---------- Cache ----------
    const cache = {
        get(key, customTtl = null) {
            try {
                const raw = localStorage.getItem(`${LS_CACHE_KEY}_${key}`);
                if (!raw) return null;
                const { data, timestamp, ttl } = JSON.parse(raw);
                const effective = ttl || customTtl || CACHE_TTL;
                if (Date.now() - timestamp > effective) {
                    this.delete(key);
                    return null;
                }
                return data;
            } catch {
                return null;
            }
        },
        set(key, data, customTtl = null) {
            try {
                localStorage.setItem(`${LS_CACHE_KEY}_${key}`, JSON.stringify({
                    data,
                    timestamp: Date.now(),
                    ttl: customTtl
                }));
            } catch (e) {
                if (e.name === 'QuotaExceededError') {
                    console.warn('Video Filter: localStorage quota exceeded - cleaning old cache...');
                    this.cleanup();
                    // Retry once after cleanup
                    try {
                        localStorage.setItem(`${LS_CACHE_KEY}_${key}`, JSON.stringify({
                            data,
                            timestamp: Date.now(),
                            ttl: customTtl
                        }));
                    } catch (retryErr) {
                        console.warn('Video Filter: cache set still failed after cleanup', retryErr && (retryErr.message || retryErr));
                    }
                }
            }
        },
        delete(key) {
            try { localStorage.removeItem(`${LS_CACHE_KEY}_${key}`); } catch {}
        },
        cleanup() {
            try {
                const prefix = LS_CACHE_KEY + '_';
                const keys = Object.keys(localStorage);
                for (const k of keys) {
                    if (k.startsWith(prefix)) {
                        const raw = localStorage.getItem(k);
                        if (raw) {
                            try {
                                const { timestamp, ttl } = JSON.parse(raw);
                                const effective = ttl || CACHE_TTL;
                                if (Date.now() - timestamp > effective) {
                                    localStorage.removeItem(k);
                                }
                            } catch {
                                localStorage.removeItem(k);
                            }
                        }
                    }
                }
            } catch {}
        }
    };

    function getFileBaseUrl() {
        return `https://${fileDomain}/data`;
    }

    // ---------- Status & Page Range ----------
    function showStatus(msg, type = 'info') {
        statusMessage.textContent = msg;
        statusMessage.style.color =
            type === 'error' ? '#ff6b6b' :
            type === 'success' ? '#76c7c0' :
            type === 'warning' ? '#ffcc00' : '#ccc';
        if (type === 'success' && msg.includes('Copied')) {
            setTimeout(() => {
                if (statusMessage.textContent === msg) {
                    statusMessage.textContent = '';
                    statusMessage.style.color = '#ccc';
                }
            }, 3000);
        }
    }

    function parsePageRange(str) {
        const pages = new Set();
        if (!str || !str.trim()) {
            showStatus('Error: Page range cannot be empty.', 'error');
            return null;
        }
        for (const part of str.split(',')) {
            const t = part.trim();
            if (t.includes('-')) {
                const [a, b] = t.split('-').map(s => parseInt(s.trim(), 10));
                if (isNaN(a) || isNaN(b) || a <= 0 || b < a) {
                    showStatus(`Error: Invalid range "${t}".`, 'error');
                    return null;
                }
                for (let i = a; i <= b; i++) pages.add(i);
            } else {
                const p = parseInt(t, 10);
                if (isNaN(p) || p <= 0) {
                    showStatus(`Error: Invalid page "${t}".`, 'error');
                    return null;
                }
                pages.add(p);
            }
        }
        return pages.size ? Array.from(pages).sort((a, b) => a - b) : null;
    }

    // ---------- Media Detection ----------
    function extOf(pathOrName) {
        if (!pathOrName || typeof pathOrName !== 'string') return '';
        const clean = pathOrName.split('?')[0];
        const parts = clean.toLowerCase().split('.');
        return parts.length > 1 ? parts.pop() : '';
    }

    function isVideoFile(pathOrName) {
        return VIDEO_EXTENSIONS.has(extOf(pathOrName));
    }

    function isGifFile(pathOrName) {
        return extOf(pathOrName) === 'gif';
    }

    function isImageFile(pathOrName) {
        return IMAGE_EXTENSIONS.has(extOf(pathOrName));
    }

    function getBestMediaUrlFromPost(post, wantGifs) {
        if (!post) return null;
        const base = getFileBaseUrl();

        const candidates = [];

        if (post.file && post.file.path) {
            candidates.push({ path: post.file.path, name: post.file.name || '' });
        }
        if (Array.isArray(post.attachments)) {
            for (const att of post.attachments) {
                if (att && att.path) {
                    candidates.push({ path: att.path, name: att.name || '' });
                }
            }
        }

        for (const c of candidates) {
            if (isVideoFile(c.path) || isVideoFile(c.name)) {
                return { type: 'video', url: base + c.path };
            }
        }
        if (wantGifs) {
            for (const c of candidates) {
                if (isGifFile(c.path) || isGifFile(c.name)) {
                    return { type: 'gif', url: base + c.path };
                }
            }
        }
        return null;
    }

    function getPostPreviewUrl(post) {
        if (!post || !post.id) return null;
        const key = `thumb_${currentDomain}_${post.id}`;
        const cached = cache.get(key, THUMBNAIL_CACHE_TTL);
        if (cached) return cached;

        let preview = null;
        if (post.file && post.file.path && isImageFile(post.file.path)) {
            preview = getFileBaseUrl() + post.file.path;
        } else if (Array.isArray(post.attachments)) {
            for (const att of post.attachments) {
                if (att.path && isImageFile(att.path)) {
                    preview = getFileBaseUrl() + att.path;
                    break;
                }
            }
        }
        if (preview) cache.set(key, preview, THUMBNAIL_CACHE_TTL);
        return preview;
    }

    // ---------- API Helpers ----------
    function fetchData(url, useCache = true) {
        if (useCache) {
            const c = cache.get(url);
            if (c !== null && c !== undefined) return Promise.resolve(c);
        }
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                headers: {
                    Accept: 'application/json',
                    Referer: window.location.href,
                    'User-Agent': navigator.userAgent
                },
                onload(resp) {
                    if (resp.status >= 200 && resp.status < 300) {
                        try {
                            const data = JSON.parse(resp.responseText);
                            cache.set(url, data);
                            resolve(data);
                        } catch (e) {
                            reject(new Error(`JSON parse: ${e.message}`));
                        }
                    } else {
                        reject(new Error(`HTTP ${resp.status}`));
                    }
                },
                onerror: () => reject(new Error('Network error')),
                ontimeout: () => reject(new Error('Timeout')),
                timeout: 30000
            });
        });
    }

    function queueApiRequest(url, useCache = true) {
        return new Promise((resolve, reject) => {
            requestQueue.push({ url, useCache, resolve, reject });
            processQueue();
        });
    }

    function processQueue() {
        if (activeRequests >= MAX_CONCURRENT_REQUESTS || !requestQueue.length) return;
        const next = requestQueue.shift();
        activeRequests++;
        fetchData(next.url, next.useCache)
            .then(next.resolve)
            .catch(next.reject)
            .finally(() => {
                activeRequests--;
                setTimeout(processQueue, API_DELAY);
            });
    }

    // ---------- Context Detection ----------
    function determinePageContext() {
        const path = window.location.pathname;
        const params = new URLSearchParams(window.location.search);
        const q = params.get('q');
        const tag = params.get('tag');

        // Popular page is not supported
        if (path === '/posts/popular' || path.startsWith('/posts/popular')) {
            return null;
        }

        // Global posts: supported only when no tag parameter is present (tag filtering does not work server-side)
        if (path === '/posts' || path === '/posts/') {
            if (tag) {
                return null;
            }
            return {
                type: 'global_search',
                query: q,
                tag: null
            };
        }

        // User profile pages: support tag filtering via client-side post matching
        const userMatch = path.match(/^\/([^/]+)\/user\/([^/]+)\/?$/);
        if (userMatch) {
            return {
                type: (q || tag) ? 'user_search' : 'profile',
                service: userMatch[1],
                userId: userMatch[2],
                query: q,
                tag: tag
            };
        }

        return null;
    }

    function buildListApiUrl(context, offset) {
        const base = `https://${currentDomain}/api/v1`;
        const qs = [`o=${offset}`];

        // Tag is included in the URL but may be ignored; client-side filtering will also be applied
        if (context.tag) qs.push(`tag=${encodeURIComponent(context.tag)}`);
        if (context.query) qs.push(`q=${encodeURIComponent(context.query)}`);

        if (context.type === 'profile' || context.type === 'user_search') {
            return `${base}/${context.service}/user/${context.userId}?${qs.join('&')}`;
        }
        if (context.type === 'global_search') {
            return `${base}/posts?${qs.join('&')}`;
        }
        return null;
    }

    // ---------- Tag Parsing ----------
    function parsePostTags(tagsField) {
        if (!tagsField) return [];
        if (typeof tagsField === 'string') {
            try {
                const parsed = JSON.parse(tagsField);
                if (Array.isArray(parsed)) return parsed.map(t => String(t).trim());
            } catch (e) {
                const match = tagsField.match(/\{(.*?)\}/);
                if (match) {
                    const inner = match[1];
                    const parts = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
                    return parts.filter(p => p.length > 0);
                }
                return tagsField.split(',').map(s => s.trim());
            }
        }
        return [];
    }

    function postMatchesTag(post, tag) {
        if (!tag) return true;
        const tagLower = tag.toLowerCase().trim();
        const title = (post.title || '').toLowerCase();
        const content = (post.content || '').toLowerCase();
        if (title.includes(tagLower) || content.includes(tagLower)) return true;

        const tags = parsePostTags(post.tags);
        for (const t of tags) {
            if (t.toLowerCase().includes(tagLower)) return true;
        }
        return false;
    }

    // ---------- Main Filter ----------
    async function handleFilter() {
        if (isFiltering) {
            showStatus('Already filtering…', 'warning');
            return;
        }
        isFiltering = true;
        filterButton.textContent = 'Filtering…';
        styleButton(filterButton, true);
        filterButton.disabled = true;
        styleButton(copyUrlsButton, true);
        copyUrlsButton.disabled = true;
        allFoundVideoUrls = [];
        requestQueue = [];
        activeRequests = 0;
        thumbnailRetryMap.clear();
        failedThumbnails.clear();
        totalThumbnailsToLoad = 0;
        thumbnailsLoadedCount = 0;
        allMediaLoadedShown = false;
        failedThumbnailCount = 0;

        setupVideoIntersectionObserver();

        const pages = parsePageRange(pageRangeInput.value);
        if (!pages) { resetFilterButton(); return; }
        localStorage.setItem(LS_PAGES_KEY, pageRangeInput.value);

        const context = determinePageContext();
        if (!context) {
            showStatus('Error: Page context not supported.', 'error');
            resetFilterButton();
            return;
        }

        // Find or create container for filtered posts
        let container = document.querySelector('.card-list__items, .post-list, .posts-grid, main > div, .content');
        if (!container) {
            container = document.createElement('div');
            container.className = 'card-list__items';
            container.style.display = 'grid';
            container.style.gridTemplateColumns = 'repeat(auto-fill, minmax(350px, 1fr))';
            container.style.gap = '16px';
            container.style.padding = '16px';
            const main = document.querySelector('main') || document.body;
            main.prepend(container);
        } else {
            container.innerHTML = '';
            container.style.setProperty('--card-size', '350px');
        }

        document.querySelectorAll('.paginator menu, .content > menu.Paginator').forEach(m => {
            m.style.display = 'none';
        });
        const paginatorInfo = document.querySelector('.paginator > small, .content > div > small.subtle-text');
        if (paginatorInfo) paginatorInfo.textContent = 'Filtering posts…';

        let totalVideo = 0, totalGif = 0, errors = 0;
        const mediaLabel = includeGifs ? 'Videos/GIFs' : 'Videos';
        const maxErrors = 6;

        for (let i = 0; i < pages.length; i++) {
            if (errors >= maxErrors) break;
            const pageNum = pages[i];
            filterButton.textContent = `Filtering ${i + 1}/${pages.length}…`;
            showStatus(`Processing page ${pageNum}…`, 'info');

            try {
                const apiUrl = buildListApiUrl(context, (pageNum - 1) * POSTS_PER_PAGE);
                if (!apiUrl) { errors++; continue; }
                const apiResponse = await queueApiRequest(apiUrl, true);
                let posts = Array.isArray(apiResponse)
                    ? apiResponse
                    : (apiResponse.results || apiResponse.posts || []);

                if (!Array.isArray(posts)) {
                    showStatus(`Unexpected API response on page ${pageNum}`, 'warning');
                    continue;
                }

                // Apply client-side tag filtering for all pages (primarily for user profiles)
                let filteredPosts = posts;
                if (context.tag) {
                    filteredPosts = posts.filter(p => postMatchesTag(p, context.tag));
                }

                const fragment = document.createDocumentFragment();
                let pageVideo = 0, pageGif = 0;

                for (const post of filteredPosts) {
                    const media = getBestMediaUrlFromPost(post, includeGifs);
                    if (!media) continue;

                    if (media.type === 'video') {
                        totalVideo++;
                        pageVideo++;
                        totalThumbnailsToLoad++;
                    } else {
                        totalGif++;
                        pageGif++;
                    }
                    allFoundVideoUrls.push(media.url);
                    const preview = getPostPreviewUrl(post);
                    const html = createPostCardHtml(post, media, preview);
                    const tmp = document.createElement('div');
                    tmp.innerHTML = html;
                    if (tmp.firstElementChild) fragment.appendChild(tmp.firstElementChild);
                }

                if (fragment.children.length) container.appendChild(fragment);

                if (paginatorInfo) {
                    const total = totalVideo + totalGif;
                    let extra = '';
                    if (pageVideo && pageGif) extra = ` (+${pageVideo}v +${pageGif}g)`;
                    else if (pageVideo) extra = ` (+${pageVideo} videos)`;
                    else if (pageGif) extra = ` (+${pageGif} GIFs)`;
                    paginatorInfo.textContent = `Showing ${total} ${mediaLabel} posts${extra}.`;
                }
            } catch (e) {
                const errMsg = e && e.message ? e.message : String(e);
                const errStack = e && e.stack ? e.stack : '';
                console.error('Video Filter: Error on page ' + pageNum + ':', errMsg, errStack || e);
                showStatus(`Error on page ${pageNum}: ${errMsg}`, 'error');
                errors++;
            }
        }

        // Final summary
        const total = totalVideo + totalGif;
        if (paginatorInfo) {
            let extra = '';
            if (totalVideo && totalGif) extra = ` (${totalVideo} videos, ${totalGif} GIFs)`;
            else if (totalVideo) extra = ` (${totalVideo} videos)`;
            else if (totalGif) extra = ` (${totalGif} GIFs)`;
            paginatorInfo.textContent = `Showing ${total} ${mediaLabel} posts${extra}.`;
        }

        container.querySelectorAll('video.lazy-load-video').forEach(v => {
            if (v.querySelector('source[data-src]')) videoIntersectionObserver.observe(v);
        });

        finishFiltering(totalVideo, totalGif, mediaLabel);
    }

    function resetFilterButton() {
        isFiltering = false;
        filterButton.textContent = 'Filter Videos/GIFs';
        styleButton(filterButton, false);
        filterButton.disabled = false;
    }

    function finishFiltering(v, g, label) {
        isFiltering = false;
        filterButton.textContent = 'Filter Videos/GIFs';
        styleButton(filterButton, false);
        filterButton.disabled = false;
        const total = v + g;
        if (total > 0) {
            let msg = `Filter complete. Found ${total} ${label} posts`;
            if (v && g) msg += ` (${v} videos, ${g} GIFs)`;
            else if (v) msg += ` (${v} videos)`;
            else msg += ` (${g} GIFs)`;
            if (failedThumbnailCount > 0) {
                msg += ` — ${failedThumbnailCount} thumbnail${failedThumbnailCount > 1 ? 's' : ''} failed to load.`;
                showStatus(msg, 'warning');
            } else {
                showStatus(msg + '.', 'success');
            }
            styleButton(copyUrlsButton, false);
            copyUrlsButton.disabled = false;
        } else {
            showStatus(`Filter complete. No ${label} posts found.`, 'info');
            styleButton(copyUrlsButton, true);
            copyUrlsButton.disabled = true;
        }
    }

    // ---------- Lazy Video Loading ----------
    function setupVideoIntersectionObserver() {
        if (videoIntersectionObserver) videoIntersectionObserver.disconnect();
        videoIntersectionObserver = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;
                const video = entry.target;
                const id = video.dataset.videoId || Math.random().toString(36).slice(2);
                if (!thumbnailRetryMap.has(id)) thumbnailRetryMap.set(id, 0);
                if (failedThumbnails.has(id)) {
                    videoIntersectionObserver.unobserve(video);
                    return;
                }
                const source = video.querySelector('source[data-src]');
                if (!source) return;
                const url = source.getAttribute('data-src');
                setupVideoErrorHandling(video, id, url);
                const to = setTimeout(() => {
                    if (!video.hasAttribute('data-loaded')) {
                        handleVideoLoadError(video, id, url, 'Timeout');
                    }
                }, VIDEO_LOAD_TIMEOUT);
                video.dataset.loadTimeout = to;
                source.setAttribute('src', url);
                video.load();
                source.removeAttribute('data-src');
                videoIntersectionObserver.unobserve(video);
            });
        }, { rootMargin: '200px 0px', threshold: 0.01 });
    }

    function setupVideoErrorHandling(video, id, url) {
        video.onerror = () => handleVideoLoadError(video, id, url, video.error?.message || 'error');
        video.onloadeddata = () => {
            clearTimeout(Number(video.dataset.loadTimeout));
            video.setAttribute('data-loaded', 'true');
            thumbnailsLoadedCount++;
            if (totalThumbnailsToLoad) {
                showStatus(`Loading thumbnails: ${thumbnailsLoadedCount}/${totalThumbnailsToLoad}`, 'info');
            }
            checkAllMediaLoaded();
        };
    }

    function handleVideoLoadError(video, id, url, details) {
        clearTimeout(Number(video.dataset.loadTimeout));
        const retries = thumbnailRetryMap.get(id) || 0;
        if (retries >= MAX_THUMBNAIL_RETRIES) {
            // Mark as permanently failed
            if (!failedThumbnails.has(id)) {
                failedThumbnails.add(id);
                failedThumbnailCount++;
            }
            thumbnailsLoadedCount++;
            if (totalThumbnailsToLoad) {
                showStatus(`Loading thumbnails: ${thumbnailsLoadedCount}/${totalThumbnailsToLoad} (${failedThumbnailCount} failed)`, 'warning');
            }
            checkAllMediaLoaded();
            return;
        }
        thumbnailRetryMap.set(id, retries + 1);
        setTimeout(() => {
            if (failedThumbnails.has(id) || !video.isConnected) return;
            const source = video.querySelector('source');
            if (source) {
                source.setAttribute('src', '');
                source.setAttribute('src', url);
                video.load();
                video.dataset.loadTimeout = setTimeout(() => {
                    if (!video.hasAttribute('data-loaded')) {
                        handleVideoLoadError(video, id, url, 'Retry timeout');
                    }
                }, VIDEO_LOAD_TIMEOUT);
            }
        }, THUMBNAIL_RETRY_DELAY * (retries + 1));
    }

    function checkAllMediaLoaded() {
        if (allMediaLoadedShown) return;
        if (totalThumbnailsToLoad > 0 && thumbnailsLoadedCount >= totalThumbnailsToLoad) {
            allMediaLoadedShown = true;
            if (failedThumbnailCount > 0) {
                showStatus(`All media loaded: ${thumbnailsLoadedCount - failedThumbnailCount} succeeded, ${failedThumbnailCount} failed.`, 'warning');
            } else {
                showStatus('All media loaded successfully!', 'success');
            }
        }
    }

    // ---------- Miscellaneous Handlers ----------
    function handleCopyUrls() {
        if (!allFoundVideoUrls.length) {
            showStatus('No URLs to copy.', 'error');
            return;
        }
        const unique = [...new Set(allFoundVideoUrls)];
        GM_setClipboard(unique.join('\n'));
        copyUrlsButton.textContent = `Copied ${unique.length} URLs!`;
        showStatus(`Copied ${unique.length} unique media URLs!`, 'success');
        setTimeout(() => { copyUrlsButton.textContent = 'Copy URLs'; }, 3000);
    }

    function handleUrlChange() {
        currentDomain = window.location.hostname.replace(/^www\./, '');
        allFoundVideoUrls = [];
        requestQueue = [];
        activeRequests = 0;
        styleButton(copyUrlsButton, true);
        copyUrlsButton.disabled = true;
        if (videoIntersectionObserver) videoIntersectionObserver.disconnect();

        const ctx = determinePageContext();
        if (ctx) {
            showStatus('Filter ready.', 'info');
            styleButton(filterButton, false);
            filterButton.disabled = false;
        } else {
            showStatus('Page context not supported. Filter disabled.', 'error');
            styleButton(filterButton, true);
            filterButton.disabled = true;
        }
    }

    function loadSettings() {
        const saved = localStorage.getItem(LS_PAGES_KEY);
        if (saved) pageRangeInput.value = saved;

        includeGifs = GM_getValue(GM_GIFS_KEY, false);
        gifsUI.input.checked = includeGifs;
    }

    function togglePanelCollapse() {
        isPanelCollapsed = !isPanelCollapsed;
        if (isPanelCollapsed) {
            panelMainContent.style.display = 'none';
            settingsPanel.style.display = 'none';
            collapseButton.innerHTML = '«';
            uiContainer.style.width = '41px';
            uiContainer.style.height = '80px';
            uiContainer.style.padding = '0';
        } else {
            panelMainContent.style.display = 'block';
            collapseButton.innerHTML = '»';
            uiContainer.style.width = '';
            uiContainer.style.height = '';
            uiContainer.style.padding = '12px';
        }
        localStorage.setItem(LS_COLLAPSE_KEY, isPanelCollapsed.toString());
    }

    function toggleSettings() {
        if (settingsPanel.style.display === 'block') {
            settingsPanel.style.display = 'none';
            return;
        }
        const rect = uiContainer.getBoundingClientRect();
        settingsPanel.style.bottom = `${window.innerHeight - rect.top + 6}px`;
        settingsPanel.style.right = '10px';
        settingsPanel.style.display = 'block';
    }

    // ---------- Card HTML ----------
    function createPostCardHtml(post, mediaInfo, previewUrl) {
        const postDate = new Date(post.published || post.added || Date.now());
        const formattedDate = postDate.toLocaleString();
        const attachmentCount = Array.isArray(post.attachments) ? post.attachments.length : 0;
        const attachmentText = attachmentCount === 1 ? '1 Attachment' : `${attachmentCount} Attachments`;

        let displayTitle = (post.title || '').trim();
        if (!displayTitle && post.content) {
            const tmp = document.createElement('div');
            tmp.innerHTML = post.content;
            const t = (tmp.textContent || '').trim();
            if (t) displayTitle = t.substring(0, SUBSTRING_TITLE_LENGTH) + (t.length > SUBSTRING_TITLE_LENGTH ? '...' : '');
        }
        displayTitle = displayTitle || 'No Title';
        displayTitle = displayTitle.replace(/</g, '&lt;').replace(/>/g, '&gt;');

        const postLink = `/${post.service}/user/${post.user}/post/${post.id}`;

        // Background only — size/centering handled by injected CSS so media fits between header and footer
        const mediaBg = 'background:#111;';

        const videoId = `video_${post.id}_${Date.now()}`;
        let mediaHtml = '';
        if (mediaInfo.type === 'video') {
            const poster = previewUrl ? ` poster="${previewUrl}"` : '';
            mediaHtml = `<video class="post-card__image lazy-load-video" data-video-id="${videoId}"${poster} controls muted playsinline preload="none" style="${mediaBg}">
                <source data-src="${mediaInfo.url}" type="video/mp4">
            </video>`;
        } else {
            mediaHtml = `<img class="post-card__image" src="${mediaInfo.url}" alt="GIF" style="${mediaBg}" loading="lazy">`;
        }

        // Uses the site's native post-card structure; CSS flex rules keep media between header and footer
        return `<article class="post-card post-card--preview video-filter-post" data-id="${post.id}" data-service="${post.service || ''}" data-user="${post.user || ''}">
    <a href="${postLink}" class="image-link">
      <header class="post-card__header">
        ${displayTitle}
      </header>
      <div class="post-card__image-container">
        ${mediaHtml}
      </div>
      <footer class="post-card__footer">
        <div>
          <div>
            <time class="timestamp" datetime="${postDate.toISOString()}">${formattedDate}</time>
            <div>${attachmentCount > 0 ? attachmentText : 'No Attachments'}</div>
          </div>
        </div>
      </footer>
    </a>
  </article>`;
    }

    // ---------- Initialisation ----------
    filterButton.addEventListener('click', handleFilter);
    copyUrlsButton.addEventListener('click', handleCopyUrls);
    collapseButton.addEventListener('click', togglePanelCollapse);
    settingsButton.addEventListener('click', e => {
        e.stopImmediatePropagation();
        toggleSettings();
    });

    gifsUI.input.addEventListener('change', () => {
        includeGifs = gifsUI.input.checked;
        GM_setValue(GM_GIFS_KEY, includeGifs);
    });

    // Override history methods to detect navigation events
    const origPush = history.pushState;
    const origReplace = history.replaceState;
    history.pushState = function () {
        origPush.apply(this, arguments);
        window.dispatchEvent(new Event('custompushstate'));
    };
    history.replaceState = function () {
        origReplace.apply(this, arguments);
        window.dispatchEvent(new Event('customreplacestate'));
    };
    window.addEventListener('popstate', handleUrlChange);
    window.addEventListener('custompushstate', handleUrlChange);
    window.addEventListener('customreplacestate', handleUrlChange);

    if (localStorage.getItem(LS_COLLAPSE_KEY) === 'true') togglePanelCollapse();
    loadSettings();
    handleUrlChange();

    window.addEventListener('beforeunload', () => {
        if (videoIntersectionObserver) videoIntersectionObserver.disconnect();
    });
})();