Pawchive Video Filter

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Pawchive Video Filter
// @namespace    http://tampermonkey.net/
// @version      1.1.3
// @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 ----------
    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();
    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);

    // ---------- Styles ----------
    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;
        overflow: hidden;
        max-height: 120px;
        opacity: 1;
        transition: max-height 0.12s ease, opacity 0.12s ease, padding 0.12s ease, margin 0.12s ease;
      }
      .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;
      }
      /* Animated hide while video is playing */
      .post-card.post-card--preview.video-filter-post.video-playing .post-card__header,
      .post-card.post-card--preview.video-filter-post.video-playing .post-card__footer {
        max-height: 0 !important;
        opacity: 0;
        padding-top: 0 !important;
        padding-bottom: 0 !important;
        margin-top: 0 !important;
        margin-bottom: 0 !important;
      }
    `;
    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();
                    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');

        if (path === '/posts/popular' || path.startsWith('/posts/popular')) {
            return null;
        }

        if (path === '/posts' || path === '/posts/') {
            if (tag) return null;
            return {
                type: 'global_search',
                query: q,
                tag: null
            };
        }

        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}`];
        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;
        }

        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;
                }

                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++;
            }
        }

        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);
            attachVideoPlayHideHandlers(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) {
            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');
            }
        }
    }

    // ---------- Hide header/footer while video plays ----------
    function attachVideoPlayHideHandlers(video) {
        if (video.dataset.hideHandlersAttached === '1') return;
        video.dataset.hideHandlersAttached = '1';

        const card = video.closest('article.post-card.video-filter-post');
        if (!card) return;

        const hideHeaderFooter = () => card.classList.add('video-playing');
        const showHeaderFooter = () => card.classList.remove('video-playing');

        video.addEventListener('play', hideHeaderFooter);
        video.addEventListener('playing', hideHeaderFooter);
        video.addEventListener('ended', showHeaderFooter);
    }

    // ---------- Miscellaneous ----------
    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}`;
        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">`;
        }

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

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