Filters posts with videos/GIFs on Pawchive. Supports posts, search, user profiles.
// ==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, '<').replace(/>/g, '>');
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();
});
})();