Preview torrent images on hover with cached topics, separate thumbnail/full-res queues, immediate foreground loads, settings UI, mouse-X navigation, and diagnostics
// ==UserScript==
// @name Pornolab image preview - improved progressive prefetch settings debug
// @description Preview torrent images on hover with cached topics, separate thumbnail/full-res queues, immediate foreground loads, settings UI, mouse-X navigation, and diagnostics
// @namespace https://pornolab.net/forum/index.php
// @version 1.7
// @author tobij12 - pingu2, revised
// @match https://pornolab.net/forum/tracker.php*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect *
// ==/UserScript==
(function () {
'use strict';
const DEBUG = false;
const SETTINGS_KEY = 'pornolabPreviewSettingsV1';
const DEFAULT_SETTINGS = {
debugLogging: false,
previewLeftPx: 350,
defaultPreviewHeight: 400,
minPreviewHeight: 200,
fullUpgradeDelayMs: 100,
minThumbVisibleMs: 500,
useMouseXNavigation: false,
enableTopicPrefetch: true,
initialPrefetchCount: 6,
hoverNearbyPrefetchRadius: 2,
topicPrefetchDelayMs: 700,
maxActiveTopicPrefetches: 2,
maxTopicCacheSize: 300,
prefetchNearbyImages: true,
maxActiveThumbnailPrefetches: 4,
maxActiveImagePrefetches: 2
};
const SETTING_LABELS = {
debugLogging: 'Enable debug logging',
previewLeftPx: 'Preview left position (px)',
defaultPreviewHeight: 'Default preview height (px)',
minPreviewHeight: 'Minimum preview height (px)',
fullUpgradeDelayMs: 'Full image upgrade delay (ms)',
minThumbVisibleMs: 'Minimum thumbnail visible time (ms)',
useMouseXNavigation: 'Use mouse left/right movement instead of mouse wheel',
enableTopicPrefetch: 'Enable topic page prefetch',
initialPrefetchCount: 'Initial visible topics to prefetch',
hoverNearbyPrefetchRadius: 'Nearby topic prefetch radius',
topicPrefetchDelayMs: 'Initial topic prefetch delay (ms)',
maxActiveTopicPrefetches: 'Max active topic prefetches',
maxTopicCacheSize: 'Max topic cache size',
prefetchNearbyImages: 'Prefetch all images for active topic',
maxActiveThumbnailPrefetches: 'Max active thumbnail prefetches',
maxActiveImagePrefetches: 'Max active full-res image prefetches'
};
let settings = loadSettings();
const blockedSources = [
'vsexshop',
'static.pornolab.net',
'yadro',
'vpipi',
'9bee784100d7c6108d51fd70e9b79a50.gif',
'nodrink',
'rimg',
'9ac78b9bb3e82339391d223a64daf18f',
'4f9a8a86a785326a0a3d1560404a6fdc',
'73ea7145a1b7d011589c849c5391c7b6',
'5772513e239a6a8ee48af36544313a06'
];
const topicCache = new Map();
const resolvedImageCache = new Map();
const resolvedImageValueCache = new Map();
const decodedImageCache = new Map();
const decodedImageValueCache = new Map();
const stateByLink = new WeakMap();
const knownLinks = new Set();
let closeOnScrollScheduled = false;
const topicPrefetchQueue = [];
const queuedTopicPrefetchUrls = new Set();
let activeTopicPrefetches = 0;
const thumbnailPrefetchQueue = [];
const queuedThumbnailPrefetchKeys = new Set();
let activeThumbnailPrefetches = 0;
const fullImagePrefetchQueue = [];
const queuedFullImagePrefetchKeys = new Set();
let activeFullImagePrefetches = 0;
injectStyles();
GM_registerMenuCommand('Pornolab Preview Settings', openSettingsPanel);
GM_registerMenuCommand('Pornolab Preview Status', showPreviewStatus);
const links = Array.from(document.querySelectorAll('.med .tLink'));
log('Script started', {
settings,
detectedLinks: links.length,
url: location.href
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeAllPreviews();
}
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
openSettingsPanel();
}
});
window.addEventListener('scroll', scheduleCloseAllPreviewsOnScroll, { passive: true });
document.addEventListener('scroll', scheduleCloseAllPreviewsOnScroll, { passive: true, capture: true });
window.addEventListener('blur', closeAllPreviews);
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
closeAllPreviews();
}
});
links.forEach(setupLink);
scheduleInitialTopicPrefetch();
function loadSettings() {
const saved = GM_getValue(SETTINGS_KEY, {});
return {
...DEFAULT_SETTINGS,
...(saved || {})
};
}
function saveSettings(nextSettings) {
settings = {
...DEFAULT_SETTINGS,
...nextSettings
};
GM_setValue(SETTINGS_KEY, settings);
}
function resetSettings() {
settings = { ...DEFAULT_SETTINGS };
GM_setValue(SETTINGS_KEY, settings);
}
function showPreviewStatus() {
const status = [
'Pornolab Preview Status',
'',
`Settings loaded: ${JSON.stringify(settings, null, 2)}`,
'',
`Detected topic links: ${links.length}`,
'',
`Topic cache size: ${topicCache.size}`,
`Resolved image value cache size: ${resolvedImageValueCache.size}`,
`Decoded image value cache size: ${decodedImageValueCache.size}`,
'',
`Topic prefetch queue: ${topicPrefetchQueue.length}`,
`Active topic prefetches: ${activeTopicPrefetches}`,
`Queued topic URLs: ${queuedTopicPrefetchUrls.size}`,
'',
`Thumbnail prefetch queue: ${thumbnailPrefetchQueue.length}`,
`Active thumbnail prefetches: ${activeThumbnailPrefetches}`,
`Queued thumbnail keys: ${queuedThumbnailPrefetchKeys.size}`,
'',
`Full image prefetch queue: ${fullImagePrefetchQueue.length}`,
`Active full image prefetches: ${activeFullImagePrefetches}`,
`Queued full image keys: ${queuedFullImagePrefetchKeys.size}`
].join('\n');
console.log(status);
alert(status);
}
function openSettingsPanel() {
if (document.querySelector('#pornolab-preview-settings')) return;
const overlay = document.createElement('div');
overlay.id = 'pornolab-preview-settings';
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
`;
const panel = document.createElement('div');
panel.style.cssText = `
width: 620px;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
background: #1f1f1f;
color: #eee;
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0,0,0,0.7);
overflow: hidden;
display: flex;
flex-direction: column;
`;
const header = document.createElement('div');
header.style.cssText = `
padding: 14px 18px;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
background: #252525;
`;
const title = document.createElement('div');
title.textContent = 'Pornolab Preview Settings';
title.style.cssText = `
font-size: 17px;
font-weight: 650;
`;
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
border: none;
background: transparent;
color: #ccc;
font-size: 28px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
`;
closeButton.onclick = () => overlay.remove();
header.appendChild(title);
header.appendChild(closeButton);
const body = document.createElement('div');
body.style.cssText = `
padding: 16px 18px;
overflow: auto;
`;
const form = document.createElement('form');
appendSectionTitle(form, 'Diagnostics');
appendBooleanSetting(form, 'debugLogging');
appendSectionTitle(form, 'Preview position and size');
appendNumberSetting(form, 'previewLeftPx', 0, 3000, 10);
appendNumberSetting(form, 'defaultPreviewHeight', 100, 2000, 25);
appendNumberSetting(form, 'minPreviewHeight', 50, 1000, 25);
appendSectionTitle(form, 'Thumbnail → full image upgrade');
appendNumberSetting(form, 'fullUpgradeDelayMs', 0, 5000, 25);
appendNumberSetting(form, 'minThumbVisibleMs', 0, 5000, 25);
appendSectionTitle(form, 'Image navigation');
appendBooleanSetting(form, 'useMouseXNavigation');
appendSectionTitle(form, 'Topic page prefetch');
appendBooleanSetting(form, 'enableTopicPrefetch');
appendNumberSetting(form, 'initialPrefetchCount', 0, 100, 1);
appendNumberSetting(form, 'hoverNearbyPrefetchRadius', 0, 20, 1);
appendNumberSetting(form, 'topicPrefetchDelayMs', 0, 10000, 100);
appendNumberSetting(form, 'maxActiveTopicPrefetches', 1, 20, 1);
appendNumberSetting(form, 'maxTopicCacheSize', 10, 5000, 10);
appendSectionTitle(form, 'Image prefetch');
appendBooleanSetting(form, 'prefetchNearbyImages');
appendNumberSetting(form, 'maxActiveThumbnailPrefetches', 1, 40, 1);
appendNumberSetting(form, 'maxActiveImagePrefetches', 1, 20, 1);
body.appendChild(form);
const note = document.createElement('div');
note.textContent = 'Thumbnail and full-res image prefetching use separate background queues. The actively viewed image bypasses both queues and starts immediately.';
note.style.cssText = `
color: #aaa;
font-size: 12px;
margin-top: 12px;
line-height: 1.4;
`;
body.appendChild(note);
const footer = document.createElement('div');
footer.style.cssText = `
padding: 12px 18px;
border-top: 1px solid #333;
display: flex;
gap: 10px;
justify-content: flex-end;
background: #252525;
`;
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.textContent = 'Reset defaults';
resetButton.style.cssText = buttonCss('#444');
resetButton.onclick = () => {
if (!confirm('Reset all Pornolab preview settings to defaults?')) return;
resetSettings();
overlay.remove();
location.reload();
};
const saveButton = document.createElement('button');
saveButton.type = 'button';
saveButton.textContent = 'Save & reload';
saveButton.style.cssText = buttonCss('#3f7fd9');
saveButton.onclick = () => {
const next = { ...settings };
for (const key of Object.keys(DEFAULT_SETTINGS)) {
const input = form.querySelector(`[name="${key}"]`);
if (!input) continue;
if (typeof DEFAULT_SETTINGS[key] === 'boolean') {
next[key] = input.checked;
} else {
const parsed = Number(input.value);
next[key] = Number.isFinite(parsed) ? parsed : DEFAULT_SETTINGS[key];
}
}
saveSettings(next);
overlay.remove();
location.reload();
};
footer.appendChild(resetButton);
footer.appendChild(saveButton);
panel.appendChild(header);
panel.appendChild(body);
panel.appendChild(footer);
overlay.appendChild(panel);
document.body.appendChild(overlay);
overlay.addEventListener('click', e => {
if (e.target === overlay) overlay.remove();
});
}
function appendSectionTitle(form, text) {
const section = document.createElement('div');
section.textContent = text;
section.style.cssText = `
margin: 18px 0 10px;
padding-top: 10px;
border-top: 1px solid #333;
color: #ffcc66;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
form.appendChild(section);
}
function appendNumberSetting(form, key, min, max, step) {
const row = createSettingRow(key);
const input = document.createElement('input');
input.name = key;
input.type = 'number';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(settings[key]);
input.style.cssText = inputCss();
row.appendChild(input);
form.appendChild(row);
}
function appendBooleanSetting(form, key) {
const row = createSettingRow(key);
const input = document.createElement('input');
input.name = key;
input.type = 'checkbox';
input.checked = !!settings[key];
input.style.cssText = `
width: 18px;
height: 18px;
accent-color: #3f7fd9;
`;
row.appendChild(input);
form.appendChild(row);
}
function createSettingRow(key) {
const row = document.createElement('label');
row.style.cssText = `
display: grid;
grid-template-columns: 1fr 150px;
align-items: center;
gap: 12px;
margin-bottom: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #292929;
`;
const text = document.createElement('div');
text.textContent = SETTING_LABELS[key] || key;
text.style.cssText = `
font-size: 13px;
color: #eee;
`;
row.appendChild(text);
return row;
}
function inputCss() {
return `
width: 140px;
box-sizing: border-box;
background: #111;
color: #fff;
border: 1px solid #555;
border-radius: 4px;
padding: 5px 7px;
font-size: 13px;
`;
}
function buttonCss(background) {
return `
border: none;
background: ${background};
color: #fff;
padding: 8px 13px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
`;
}
function setupLink(el) {
const state = getState(el);
knownLinks.add(el);
const row = el.closest('tr');
if (row) {
row.addEventListener('pointerenter', function () {
if (!settings.enableTopicPrefetch) return;
const topicUrl = normalizeUrl(el.href, location.href);
log('Row pointerenter prefetch trigger', { topicUrl });
queueTopicPrefetch(topicUrl, 100, 'row-pointerenter-current');
prefetchNearbyTopics(el);
});
}
el.addEventListener('mouseenter', async function (event) {
state.opened = true;
state.index = 0;
state.requestToken++;
state.mouseStartX = event.clientX;
state.mouseCurrentX = event.clientX;
const bounds = calculateMouseNavBounds(el, event.clientX);
state.mouseNavLeftX = bounds.left;
state.mouseNavRightX = bounds.right;
const token = state.requestToken;
const topicUrl = normalizeUrl(el.href, location.href);
log('Link mouseenter', {
topicUrl,
useMouseXNavigation: settings.useMouseXNavigation
});
prefetchNearbyTopics(el);
showSpinner(el, 'topic page');
try {
const images = await getTopicImagesCached(topicUrl);
if (!isCurrent(el, token)) return;
state.images = images;
if (!images.length) {
showMessage(el, 'No preview images found');
return;
}
if (settings.useMouseXNavigation) {
state.index = getIndexFromMouseX(state.mouseCurrentX, state, images.length);
} else {
state.index = 0;
}
showImageAtCurrentIndex(el, token);
} catch (err) {
log('Topic load failed on hover', { topicUrl, err });
if (isCurrent(el, token)) {
showMessage(el, 'Preview load failed');
}
}
});
el.addEventListener('wheel', async function (e) {
if (!state.opened) return;
if (settings.useMouseXNavigation) {
log('Wheel ignored because mouse-X navigation is enabled');
return;
}
e.preventDefault();
e.stopPropagation();
const token = ++state.requestToken;
clearFullUpgradeTimer(state);
try {
if (!state.images) {
const topicUrl = normalizeUrl(el.href, location.href);
state.images = await getTopicImagesCached(topicUrl);
}
if (!state.images.length) return;
if (e.deltaY < 0) {
state.index = Math.max(0, state.index - 1);
} else if (e.deltaY > 0) {
state.index = Math.min(state.images.length - 1, state.index + 1);
}
showImageAtCurrentIndex(el, token);
} catch (err) {
log('Wheel image load failed', err);
}
return false;
}, { passive: false });
if (row) {
row.addEventListener('mousemove', function (event) {
if (!settings.useMouseXNavigation) return;
if (!state.opened) return;
state.mouseCurrentX = event.clientX;
if (!state.images || state.images.length <= 1) return;
const nextIndex = getIndexFromMouseX(event.clientX, state, state.images.length);
if (nextIndex === state.index) return;
state.index = nextIndex;
const token = ++state.requestToken;
clearFullUpgradeTimer(state);
log('Mouse-X image index changed', {
index: nextIndex,
total: state.images.length,
x: event.clientX,
left: state.mouseNavLeftX,
right: state.mouseNavRightX
});
showImageAtCurrentIndex(el, token);
});
row.addEventListener('mouseout', function (event) {
const related = event.relatedTarget;
if (related && row.contains(related)) return;
closePreview(el);
});
row.addEventListener('mouseleave', function () {
closePreview(el);
});
}
}
function getState(el) {
let state = stateByLink.get(el);
if (!state) {
state = {
opened: false,
index: 0,
requestToken: 0,
images: null,
fullLoadTimer: null,
previewContainer: null,
mouseStartX: 0,
mouseCurrentX: 0,
mouseNavLeftX: 0,
mouseNavRightX: 0
};
stateByLink.set(el, state);
}
return state;
}
function isCurrent(el, token) {
const state = getState(el);
return state.opened && state.requestToken === token;
}
function calculateMouseNavBounds(el, startX) {
const topicCell =
el.closest('td.row4.tLeft') ||
el.closest('td') ||
el.closest('tr') ||
el;
const rect = topicCell.getBoundingClientRect();
const left = startX;
const minimumRangePx = 180;
const rightPaddingPx = 20;
const right = Math.max(
rect.right - rightPaddingPx,
startX + minimumRangePx
);
return { left, right };
}
function getIndexFromMouseX(clientX, state, totalImages) {
if (!totalImages || totalImages <= 1) return 0;
const left = state.mouseNavLeftX;
const right = state.mouseNavRightX;
if (!Number.isFinite(left) || !Number.isFinite(right) || right <= left) {
return 0;
}
const t = clamp((clientX - left) / (right - left), 0, 1);
return Math.round(t * (totalImages - 1));
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function scheduleInitialTopicPrefetch() {
if (!settings.enableTopicPrefetch) {
log('Initial topic prefetch disabled');
return;
}
setTimeout(() => {
const visibleLinks = links
.filter(isElementNearViewport)
.slice(0, settings.initialPrefetchCount);
log('Initial topic prefetch starting', {
visibleCount: visibleLinks.length,
initialPrefetchCount: settings.initialPrefetchCount,
delayMs: settings.topicPrefetchDelayMs
});
visibleLinks.forEach((link, i) => {
const topicUrl = normalizeUrl(link.href, location.href);
queueTopicPrefetch(topicUrl, settings.initialPrefetchCount - i, 'initial');
});
}, settings.topicPrefetchDelayMs);
}
function prefetchNearbyTopics(currentEl) {
if (!settings.enableTopicPrefetch) return;
const currentIndex = links.indexOf(currentEl);
if (currentIndex < 0) return;
for (let offset = -settings.hoverNearbyPrefetchRadius; offset <= settings.hoverNearbyPrefetchRadius; offset++) {
if (offset === 0) continue;
const link = links[currentIndex + offset];
if (!link) continue;
const topicUrl = normalizeUrl(link.href, location.href);
const priority = settings.hoverNearbyPrefetchRadius - Math.abs(offset);
queueTopicPrefetch(topicUrl, priority, 'nearby-hover');
}
}
function queueTopicPrefetch(topicUrl, priority = 0, reason = 'unknown') {
if (!settings.enableTopicPrefetch || !topicUrl) {
log('Topic prefetch skipped', {
reason,
enabled: settings.enableTopicPrefetch,
topicUrl
});
return;
}
if (topicCache.has(topicUrl)) {
log('Topic prefetch skipped: already cached/fetching', {
reason,
topicUrl
});
return;
}
if (queuedTopicPrefetchUrls.has(topicUrl)) {
log('Topic prefetch skipped: already queued', {
reason,
topicUrl
});
return;
}
queuedTopicPrefetchUrls.add(topicUrl);
topicPrefetchQueue.push({ topicUrl, priority, reason });
topicPrefetchQueue.sort((a, b) => b.priority - a.priority);
log('Topic prefetch queued', {
reason,
priority,
topicUrl,
queueLength: topicPrefetchQueue.length
});
runTopicPrefetchQueue();
}
function runTopicPrefetchQueue() {
while (
activeTopicPrefetches < settings.maxActiveTopicPrefetches &&
topicPrefetchQueue.length
) {
const job = topicPrefetchQueue.shift();
activeTopicPrefetches++;
log('Topic prefetch started', {
reason: job.reason,
topicUrl: job.topicUrl,
activeTopicPrefetches,
maxActiveTopicPrefetches: settings.maxActiveTopicPrefetches
});
getTopicImagesCached(job.topicUrl)
.then(images => {
log('Topic prefetch finished', {
reason: job.reason,
topicUrl: job.topicUrl,
imageCount: images.length
});
})
.catch(err => {
log('Topic prefetch failed', {
reason: job.reason,
topicUrl: job.topicUrl,
error: err
});
})
.finally(() => {
activeTopicPrefetches--;
queuedTopicPrefetchUrls.delete(job.topicUrl);
runTopicPrefetchQueue();
});
}
}
function isElementNearViewport(el) {
const rect = el.getBoundingClientRect();
const margin = 600;
return rect.bottom >= -margin && rect.top <= window.innerHeight + margin;
}
function showImageAtCurrentIndex(el, token) {
const state = getState(el);
const images = state.images || [];
clearFullUpgradeTimer(state);
removeTopicLoadingNode(state);
if (!images.length) return;
const item = images[state.index];
if (!item || !item.thumbUrl) return;
const index = state.index;
const total = images.length;
const position = calculatePreviewPosition(el);
const hostLabel = getHostLabel(item.thumbUrl, item.parentUrl);
const thumbShownAt = performance.now();
/*
* If the current image was queued in the background, remove it from queues.
* The visible image gets a foreground/immediate path instead.
*/
removeThumbnailPrefetchJob(item);
removeFullImagePrefetchJob(item);
const cachedFullInfo = getCachedDecodedFullImageInfo(item);
if (cachedFullInfo) {
log('Showing cached full image immediately', {
index,
total,
src: cachedFullInfo.src
});
showPreview(el, {
src: cachedFullInfo.src,
index,
total,
height: position.height,
top: position.top,
loadingText: '',
showSpinner: false,
immediate: true
});
if (settings.prefetchNearbyImages) {
prefetchAllTopicImages(images, index);
}
return;
}
showPreview(el, {
src: item.thumbUrl,
index,
total,
height: position.height,
top: position.top,
loadingText: `Preview — upgrading from ${hostLabel}...`,
showSpinner: true,
immediate: true
});
/*
* Foreground thumbnail decode.
* This bypasses thumbnail queue slots.
*/
const thumbInfoPromise = loadCurrentThumbnailImmediate(item).catch(() => null);
if (settings.prefetchNearbyImages) {
prefetchAllTopicImages(images, index);
}
state.fullLoadTimer = setTimeout(async function () {
if (!isCurrent(el, token)) return;
if (getState(el).index !== index) return;
try {
/*
* Foreground full-res load.
* This bypasses full-res queue slots.
*/
const fullResult = await loadCurrentFullImageImmediate(item);
if (!isCurrent(el, token)) return;
if (getState(el).index !== index) return;
if (!fullResult || !fullResult.src) {
hideSpinner(el);
return;
}
const thumbInfo = await thumbInfoPromise;
const fullInfo = fullResult.info;
if (!isCurrent(el, token)) return;
if (getState(el).index !== index) return;
if (isBetterImage(fullInfo, thumbInfo)) {
await waitForMinimumThumbTime(thumbShownAt);
if (!isCurrent(el, token)) return;
if (getState(el).index !== index) return;
showPreview(el, {
src: fullInfo.src,
index,
total,
height: position.height,
top: position.top,
loadingText: '',
showSpinner: false,
immediate: true
});
} else {
showPreview(el, {
src: item.thumbUrl,
index,
total,
height: position.height,
top: position.top,
loadingText: '',
showSpinner: false,
immediate: true
});
}
} catch (err) {
log('Immediate full image upgrade failed', err);
if (isCurrent(el, token)) {
hideSpinner(el);
}
}
}, settings.fullUpgradeDelayMs);
}
async function loadCurrentThumbnailImmediate(item) {
if (!item || !item.thumbUrl) return null;
const key = getThumbnailCacheKey(item);
removeThumbnailPrefetchJob(item);
log('Immediate thumbnail load started', { key });
const info = await loadAndDecodeImageCached(item.thumbUrl);
log('Immediate thumbnail load finished', {
key,
decoded: !!info,
width: info?.width,
height: info?.height
});
return info;
}
async function loadCurrentFullImageImmediate(item) {
if (!item || !item.thumbUrl) return null;
const key = getImageCacheKey(item);
removeFullImagePrefetchJob(item);
log('Immediate full image load started', { key });
const result = await prefetchHighResImage(item);
log('Immediate full image load finished', {
key,
status: result.status,
src: result.src,
width: result.info?.width,
height: result.info?.height
});
return result;
}
function getCachedDecodedFullImageInfo(item) {
if (!item || !item.thumbUrl) return null;
const cacheKey = getImageCacheKey(item);
const finalSrc = resolvedImageValueCache.get(cacheKey);
if (!finalSrc) return null;
return decodedImageValueCache.get(finalSrc) || null;
}
function isBetterImage(candidate, current) {
if (!candidate || !candidate.width || !candidate.height) return false;
if (!current || !current.width || !current.height) return true;
const candidateArea = candidate.width * candidate.height;
const currentArea = current.width * current.height;
return candidateArea > currentArea * 1.15;
}
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForMinimumThumbTime(thumbShownAt) {
const elapsed = performance.now() - thumbShownAt;
const remaining = settings.minThumbVisibleMs - elapsed;
if (remaining > 0) {
await wait(remaining);
}
}
function prefetchAllTopicImages(images, currentIndex) {
if (!settings.prefetchNearbyImages) return;
if (!Array.isArray(images) || !images.length) return;
let thumbQueued = 0;
let fullQueued = 0;
const jobs = images
.map((item, index) => ({
item,
index,
distance: Math.abs(index - currentIndex)
}))
.sort((a, b) => {
if (a.distance !== b.distance) return a.distance - b.distance;
return a.index - b.index;
});
for (const job of jobs) {
if (!job.item || !job.item.thumbUrl) continue;
const priority = Math.max(0, images.length - job.distance);
/*
* Current visible item uses the immediate foreground path.
* Do not queue it.
*/
if (job.index !== currentIndex) {
if (queueThumbnailPrefetch(job.item, priority, `topic-thumb:index-${job.index}`)) {
thumbQueued++;
}
if (queueFullImagePrefetch(job.item, priority, `topic-full:index-${job.index}`)) {
fullQueued++;
}
}
}
log('Aggressive topic image prefetch queued', {
currentIndex,
totalImages: images.length,
thumbnailNewlyQueued: thumbQueued,
fullNewlyQueued: fullQueued,
activeThumbnailPrefetches,
activeFullImagePrefetches,
thumbnailQueueLength: thumbnailPrefetchQueue.length,
fullQueueLength: fullImagePrefetchQueue.length,
maxActiveThumbnailPrefetches: settings.maxActiveThumbnailPrefetches,
maxActiveImagePrefetches: settings.maxActiveImagePrefetches
});
}
function queueThumbnailPrefetch(item, priority, reason = 'unknown') {
if (!settings.prefetchNearbyImages) return false;
if (!item || !item.thumbUrl) return false;
const key = getThumbnailCacheKey(item);
if (decodedImageValueCache.has(item.thumbUrl)) return false;
if (queuedThumbnailPrefetchKeys.has(key)) return false;
queuedThumbnailPrefetchKeys.add(key);
thumbnailPrefetchQueue.push({ item, priority, key, reason });
thumbnailPrefetchQueue.sort((a, b) => b.priority - a.priority);
log('Thumbnail prefetch queued', {
reason,
priority,
key,
queueLength: thumbnailPrefetchQueue.length
});
runThumbnailPrefetchQueue();
return true;
}
function runThumbnailPrefetchQueue() {
while (
activeThumbnailPrefetches < settings.maxActiveThumbnailPrefetches &&
thumbnailPrefetchQueue.length
) {
const job = thumbnailPrefetchQueue.shift();
activeThumbnailPrefetches++;
log('Thumbnail prefetch started', {
reason: job.reason,
key: job.key,
activeThumbnailPrefetches,
maxActiveThumbnailPrefetches: settings.maxActiveThumbnailPrefetches
});
loadAndDecodeImageCached(job.item.thumbUrl)
.then(info => {
log('Thumbnail prefetch finished', {
reason: job.reason,
key: job.key,
decoded: !!info,
width: info?.width,
height: info?.height
});
})
.catch(err => {
log('Thumbnail prefetch failed', {
reason: job.reason,
key: job.key,
error: err
});
})
.finally(() => {
activeThumbnailPrefetches--;
queuedThumbnailPrefetchKeys.delete(job.key);
runThumbnailPrefetchQueue();
});
}
}
function queueFullImagePrefetch(item, priority, reason = 'unknown') {
if (!settings.prefetchNearbyImages) return false;
if (!item || !item.thumbUrl) return false;
const key = getImageCacheKey(item);
if (isFullImageAlreadyDecoded(item)) return false;
if (queuedFullImagePrefetchKeys.has(key)) return false;
queuedFullImagePrefetchKeys.add(key);
fullImagePrefetchQueue.push({ item, priority, key, reason });
fullImagePrefetchQueue.sort((a, b) => b.priority - a.priority);
log('Full image prefetch queued', {
reason,
priority,
key,
queueLength: fullImagePrefetchQueue.length
});
runFullImagePrefetchQueue();
return true;
}
function runFullImagePrefetchQueue() {
while (
activeFullImagePrefetches < settings.maxActiveImagePrefetches &&
fullImagePrefetchQueue.length
) {
const job = fullImagePrefetchQueue.shift();
activeFullImagePrefetches++;
log('Full image prefetch started', {
reason: job.reason,
key: job.key,
activeFullImagePrefetches,
maxActiveImagePrefetches: settings.maxActiveImagePrefetches
});
prefetchHighResImage(job.item)
.then(result => {
log('Full image prefetch finished', {
reason: job.reason,
key: job.key,
status: result.status,
src: result.src,
width: result.info?.width,
height: result.info?.height
});
})
.catch(err => {
log('Full image prefetch failed', {
reason: job.reason,
key: job.key,
error: err
});
})
.finally(() => {
activeFullImagePrefetches--;
queuedFullImagePrefetchKeys.delete(job.key);
runFullImagePrefetchQueue();
});
}
}
async function prefetchHighResImage(item) {
const cacheKey = getImageCacheKey(item);
const cachedSrc = resolvedImageValueCache.get(cacheKey);
if (cachedSrc && decodedImageValueCache.has(cachedSrc)) {
return {
status: 'already-decoded',
src: cachedSrc,
info: decodedImageValueCache.get(cachedSrc)
};
}
const finalSrc = await resolveDisplayImageUrlCached(item);
if (!finalSrc) {
return {
status: 'no-final-src',
src: null,
info: null
};
}
const info = await loadAndDecodeImageCached(finalSrc);
if (!info) {
return {
status: 'decode-failed',
src: finalSrc,
info: null
};
}
return {
status: finalSrc === item.thumbUrl ? 'decoded-resolved-same-as-thumb' : 'decoded-highres',
src: finalSrc,
info
};
}
function isFullImageAlreadyDecoded(item) {
const cacheKey = getImageCacheKey(item);
const finalSrc = resolvedImageValueCache.get(cacheKey);
if (!finalSrc) return false;
return decodedImageValueCache.has(finalSrc);
}
function removeThumbnailPrefetchJob(item) {
if (!item || !item.thumbUrl) return;
const key = getThumbnailCacheKey(item);
const before = thumbnailPrefetchQueue.length;
for (let i = thumbnailPrefetchQueue.length - 1; i >= 0; i--) {
if (thumbnailPrefetchQueue[i].key === key) {
thumbnailPrefetchQueue.splice(i, 1);
}
}
if (before !== thumbnailPrefetchQueue.length) {
queuedThumbnailPrefetchKeys.delete(key);
log('Removed queued thumbnail job for immediate foreground load', { key });
}
}
function removeFullImagePrefetchJob(item) {
if (!item || !item.thumbUrl) return;
const key = getImageCacheKey(item);
const before = fullImagePrefetchQueue.length;
for (let i = fullImagePrefetchQueue.length - 1; i >= 0; i--) {
if (fullImagePrefetchQueue[i].key === key) {
fullImagePrefetchQueue.splice(i, 1);
}
}
if (before !== fullImagePrefetchQueue.length) {
queuedFullImagePrefetchKeys.delete(key);
log('Removed queued full image job for immediate foreground load', { key });
}
}
function scheduleCloseAllPreviewsOnScroll() {
if (closeOnScrollScheduled) return;
closeOnScrollScheduled = true;
requestAnimationFrame(() => {
closeOnScrollScheduled = false;
closeAllPreviews();
});
}
function closePreview(el) {
const state = getState(el);
state.opened = false;
state.index = 0;
state.requestToken++;
clearFullUpgradeTimer(state);
removePreviewNode(state);
}
function closeAllPreviews() {
knownLinks.forEach(closePreview);
document.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
}
function clearFullUpgradeTimer(state) {
if (state.fullLoadTimer) {
clearTimeout(state.fullLoadTimer);
state.fullLoadTimer = null;
}
}
function removePreviewNode(state) {
if (state.previewContainer) {
state.previewContainer.remove();
state.previewContainer = null;
}
}
function removeTopicLoadingNode(state) {
if (
state.previewContainer &&
state.previewContainer.classList.contains('topic-loading-container')
) {
removePreviewNode(state);
}
}
function getTopicImagesCached(topicUrl) {
if (topicCache.has(topicUrl)) {
log('Topic cache hit / existing promise', { topicUrl });
return topicCache.get(topicUrl);
}
log('Topic fetch started', { topicUrl });
const promise = fetchText(topicUrl)
.then(html => {
const images = parseTopicImages(html, topicUrl);
log('Topic fetch parsed', {
topicUrl,
imageCount: images.length
});
return images;
})
.catch(err => {
log('Topic fetch failed', {
topicUrl,
error: err
});
topicCache.delete(topicUrl);
throw err;
});
topicCache.set(topicUrl, promise);
trimMap(topicCache, settings.maxTopicCacheSize);
return promise;
}
function parseTopicImages(html, topicUrl) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const root = doc.querySelector('.post_body') || doc;
const candidates = Array.from(root.querySelectorAll('var.postImg, .postImg'));
const images = [];
for (const node of candidates) {
const thumbUrlRaw = node.getAttribute('title') || node.title || '';
if (!thumbUrlRaw) continue;
const thumbUrl = normalizeUrl(thumbUrlRaw, topicUrl);
if (!thumbUrl) continue;
if (isBlockedSource(thumbUrl)) continue;
const parentAnchor = node.closest('a');
const parentUrl = parentAnchor
? normalizeUrl(parentAnchor.getAttribute('href') || parentAnchor.href, topicUrl)
: null;
const insideLikelyContent =
node.closest('.row1') ||
node.closest('.post_body') ||
root.classList?.contains('post_body');
if (!insideLikelyContent) continue;
images.push({
thumbUrl,
parentUrl
});
}
return dedupeImages(images);
}
function dedupeImages(images) {
const seen = new Set();
const result = [];
for (const item of images) {
const key = getImageCacheKey(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isBlockedSource(url) {
return blockedSources.some(blocked => url.includes(blocked));
}
function getThumbnailCacheKey(item) {
return `thumb:${item.thumbUrl}`;
}
function getImageCacheKey(item) {
return `${item.thumbUrl}|${item.parentUrl || ''}`;
}
function resolveDisplayImageUrlCached(item) {
const cacheKey = getImageCacheKey(item);
if (resolvedImageValueCache.has(cacheKey)) {
return Promise.resolve(resolvedImageValueCache.get(cacheKey));
}
if (resolvedImageCache.has(cacheKey)) {
return resolvedImageCache.get(cacheKey);
}
const promise = resolveDisplayImageUrl(item)
.then(finalSrc => {
if (finalSrc) {
resolvedImageValueCache.set(cacheKey, finalSrc);
}
return finalSrc;
})
.catch(err => {
resolvedImageCache.delete(cacheKey);
throw err;
});
resolvedImageCache.set(cacheKey, promise);
return promise;
}
async function resolveDisplayImageUrl(item) {
const validSource = item.thumbUrl;
const parentUrl = item.parentUrl;
if (!validSource) return null;
if (validSource.includes('.gif')) {
return validSource;
}
if (validSource.includes('imgbox') && validSource.includes('thumb')) {
return validSource
.replace('thumbs', 'images')
.replace('_t', '_o');
}
if (validSource.includes('imgdrive') && validSource.includes('small')) {
return validSource.replace('small', 'big');
}
if (validSource.includes('freescreens.ru') && validSource.includes('thumb')) {
return validSource
.replace('freescreens.', 'picforall.')
.replace('-thumb', '');
}
if (validSource.includes('fastpic.org') && parentUrl) {
return fetchFastpicBigImageObjectUrlCached(parentUrl);
}
if (validSource.includes('imgbox') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('imagevenue.com') && validSource.includes('thumbs') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('turboimg.net') && validSource.includes('/t/') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('picshick') && validSource.includes('/th/') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('picturelol') && validSource.includes('/th/') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
return validSource;
}
function fetchFastpicBigImageObjectUrlCached(viewPageUrl) {
const key = `fastpic:${viewPageUrl}`;
if (resolvedImageValueCache.has(key)) {
return Promise.resolve(resolvedImageValueCache.get(key));
}
if (resolvedImageCache.has(key)) {
return resolvedImageCache.get(key);
}
const promise = fetchFastpicBigImageObjectUrl(viewPageUrl)
.then(finalSrc => {
if (finalSrc) {
resolvedImageValueCache.set(key, finalSrc);
}
return finalSrc;
})
.catch(err => {
resolvedImageCache.delete(key);
throw err;
});
resolvedImageCache.set(key, promise);
return promise;
}
function fetchHostImageObjectUrlCached(viewPageUrl) {
const key = `host:${viewPageUrl}`;
if (resolvedImageValueCache.has(key)) {
return Promise.resolve(resolvedImageValueCache.get(key));
}
if (resolvedImageCache.has(key)) {
return resolvedImageCache.get(key);
}
const promise = fetchHostImageObjectUrl(viewPageUrl)
.then(finalSrc => {
if (finalSrc) {
resolvedImageValueCache.set(key, finalSrc);
}
return finalSrc;
})
.catch(err => {
resolvedImageCache.delete(key);
throw err;
});
resolvedImageCache.set(key, promise);
return promise;
}
async function fetchFastpicBigImageObjectUrl(viewPageUrl) {
const response = await gmRequest({
method: 'GET',
url: viewPageUrl,
responseType: 'blob'
});
const contentType = getContentType(response);
if (contentType.includes('image/')) {
return URL.createObjectURL(response.response);
}
if (!contentType.includes('text/html')) {
return null;
}
const html = await blobToText(response.response);
const doc = new DOMParser().parseFromString(html, 'text/html');
const images = Array.from(doc.querySelectorAll('img'));
const validImage = images.find(img =>
img.src.includes('md5=') && img.src.includes('expires=')
);
if (!validImage) return null;
return fetchImageAsObjectUrl(validImage.src);
}
async function fetchHostImageObjectUrl(viewPageUrl) {
const response = await gmRequest({
method: 'GET',
url: viewPageUrl,
responseType: 'blob'
});
const contentType = getContentType(response);
if (contentType.includes('image/')) {
return URL.createObjectURL(response.response);
}
if (!contentType.includes('text/html')) {
return null;
}
const html = await blobToText(response.response);
const doc = new DOMParser().parseFromString(html, 'text/html');
const images = Array.from(doc.querySelectorAll('img'));
const validImage = images.find(img => {
const src = img.src || '';
return (
(src.includes('turboimg.net') && src.includes('/sp/')) ||
src.includes('cdn-images.imagevenue') ||
src.includes('picshick.com/i') ||
src.includes('picturelol.com/i') ||
src.includes('images2.imgbox.com') ||
src.includes('images.imgbox.com')
);
});
if (!validImage) return null;
return fetchImageAsObjectUrl(validImage.src);
}
async function fetchImageAsObjectUrl(url) {
const response = await gmRequest({
method: 'GET',
url,
responseType: 'blob'
});
if (!response.response) return null;
return URL.createObjectURL(response.response);
}
function loadAndDecodeImageCached(src) {
if (!src) return Promise.resolve(null);
if (decodedImageValueCache.has(src)) {
return Promise.resolve(decodedImageValueCache.get(src));
}
if (decodedImageCache.has(src)) {
return decodedImageCache.get(src);
}
const promise = loadAndDecodeImage(src)
.then(info => {
if (info) {
decodedImageValueCache.set(src, info);
}
return info;
})
.catch(err => {
decodedImageCache.delete(src);
throw err;
});
decodedImageCache.set(src, promise);
return promise;
}
function loadAndDecodeImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = 'async';
img.onload = async function () {
try {
if (img.decode) {
await img.decode();
}
} catch {
// decode() can reject even when image is usable.
}
resolve({
src,
width: img.naturalWidth || img.width || 0,
height: img.naturalHeight || img.height || 0
});
};
img.onerror = reject;
img.src = src;
});
}
function fetchText(url) {
return gmRequest({
method: 'GET',
url,
responseType: 'text',
headers: {
Referer: url,
'User-Agent': navigator.userAgent
}
}).then(response => response.responseText || response.response || '');
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
responseType: options.responseType,
headers: options.headers || undefined,
timeout: options.timeout || 30000,
onload: response => {
if (response.status >= 200 && response.status < 400) {
resolve(response);
} else {
reject(new Error(`HTTP ${response.status} for ${options.url}`));
}
},
ontimeout: () => reject(new Error(`Timeout for ${options.url}`)),
onerror: err => reject(err)
});
});
}
function getContentType(response) {
const headers = (response.responseHeaders || '').toLowerCase();
const line = headers
.split(/\r?\n/)
.find(header => header.startsWith('content-type:'));
return line || '';
}
function blobToText(blob) {
if (!blob) return Promise.resolve('');
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result || '');
reader.onerror = reject;
reader.readAsText(blob);
});
}
function showPreview(el, options) {
const container = getPreviewContainer(el);
container.style.left = `${settings.previewLeftPx}px`;
container.style.top = `${options.top}px`;
container.style.height = `${options.height}px`;
const img = container.querySelector('.preview-img');
const label = container.querySelector('.imageCounterLabel');
const spinnerRow = container.querySelector('.preview-loading');
label.textContent = `Image ${options.index + 1}/${options.total}`;
if (options.showSpinner) {
spinnerRow.style.display = 'flex';
spinnerRow.querySelector('.spinner-text').textContent = options.loadingText || '';
} else {
spinnerRow.style.display = 'none';
}
setPreviewImage(img, options.src, !!options.immediate);
}
function getPreviewContainer(el) {
const state = getState(el);
if (state.previewContainer && !state.previewContainer.classList.contains('topic-loading-container')) {
return state.previewContainer;
}
removePreviewNode(state);
const container = document.createElement('div');
container.className = 'appendedHoverImgContainer';
container.style.cssText = `
position: fixed;
left: ${settings.previewLeftPx}px;
top: 0;
height: ${settings.defaultPreviewHeight}px;
width: auto;
z-index: 9999;
pointer-events: none;
`;
const img = document.createElement('img');
img.className = 'preview-img';
img.decoding = 'async';
img.loading = 'eager';
img.style.cssText = `
height: 100%;
width: auto;
display: block;
background: rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
transition: opacity 120ms ease-in-out;
`;
const label = document.createElement('div');
label.className = 'imageCounterLabel';
label.style.cssText = `
position: absolute;
top: 5px;
left: 5px;
color: #fff;
font-weight: normal;
font-family: verdana, sans-serif;
background-color: rgba(0, 0, 0, 0.6);
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
`;
const loading = document.createElement('div');
loading.className = 'preview-loading';
loading.style.cssText = `
position: absolute;
left: 5px;
bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 6px;
border-radius: 4px;
background: rgba(0,0,0,0.6);
`;
const spinner = document.createElement('div');
spinner.className = 'spinner';
const spinnerText = document.createElement('span');
spinnerText.className = 'spinner-text';
loading.appendChild(spinner);
loading.appendChild(spinnerText);
container.appendChild(img);
container.appendChild(label);
container.appendChild(loading);
document.body.appendChild(container);
state.previewContainer = container;
return container;
}
function setPreviewImage(img, src, immediate = false) {
if (!src) return;
if (img.dataset.currentSrc === src) return;
img.dataset.currentSrc = src;
if (immediate) {
img.style.opacity = '1';
img.src = src;
return;
}
img.style.opacity = '0.65';
const next = new Image();
next.onload = function () {
img.src = src;
requestAnimationFrame(() => {
img.style.opacity = '1';
});
};
next.onerror = function () {
img.style.opacity = '1';
};
next.src = src;
}
function hideSpinner(el) {
const state = getState(el);
const container = state.previewContainer;
if (!container) return;
const spinnerRow = container.querySelector('.preview-loading');
if (spinnerRow) {
spinnerRow.style.display = 'none';
}
}
function showSpinner(el, host) {
const state = getState(el);
if (host === 'topic page') {
removePreviewNode(state);
const position = calculatePreviewPosition(el);
const container = document.createElement('div');
container.className = 'appendedHoverImgContainer topic-loading-container';
container.style.cssText = `
position: fixed;
left: ${settings.previewLeftPx}px;
top: ${position.top}px;
z-index: 9999;
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 4px;
background: rgba(0,0,0,0.75);
color: #eee;
font-family: sans-serif;
font-size: 13px;
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
`;
const spinner = document.createElement('div');
spinner.className = 'spinner';
const text = document.createElement('span');
text.className = 'spinner-text';
text.textContent = 'Loading topic page...';
container.appendChild(spinner);
container.appendChild(text);
document.body.appendChild(container);
state.previewContainer = container;
return;
}
const position = calculatePreviewPosition(el);
showPreview(el, {
src: transparentPixel(),
index: 0,
total: 1,
height: position.height,
top: position.top,
loadingText: host ? `Loading from ${host}...` : 'Loading...',
showSpinner: true,
immediate: true
});
}
function showMessage(el, message) {
const position = calculatePreviewPosition(el);
const container = getPreviewContainer(el);
container.style.left = `${settings.previewLeftPx}px`;
container.style.top = `${position.top}px`;
container.style.height = '32px';
const img = container.querySelector('.preview-img');
const label = container.querySelector('.imageCounterLabel');
const spinnerRow = container.querySelector('.preview-loading');
setPreviewImage(img, transparentPixel(), true);
label.textContent = message;
spinnerRow.style.display = 'none';
}
function calculatePreviewPosition(el) {
const rect = el.getBoundingClientRect();
const y = rect.top;
const windowHeight = window.innerHeight;
let height = settings.defaultPreviewHeight;
let topMargin = 15;
if (windowHeight < y + 1800 - 15) {
height = windowHeight - y - 45;
if (height < settings.minPreviewHeight) {
height = settings.minPreviewHeight;
topMargin = windowHeight - y - 225;
}
}
let top = y + topMargin;
if (top + height > windowHeight - 10) {
top = Math.max(10, windowHeight - height - 10);
}
if (top < 10) {
top = 10;
}
return { height, top };
}
function normalizeUrl(url, baseUrl) {
if (!url) return null;
try {
return new URL(url, baseUrl || location.href).href;
} catch {
return null;
}
}
function getHostLabel(thumbUrl, parentUrl) {
try {
if (parentUrl) return new URL(parentUrl).hostname;
return new URL(thumbUrl).hostname;
} catch {
return 'host';
}
}
function trimMap(map, maxSize) {
while (map.size > maxSize) {
const firstKey = map.keys().next().value;
map.delete(firstKey);
}
}
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
.spinner {
border: 5px solid rgba(255,255,255,0.18);
border-left-color: #aaa;
border-radius: 50%;
width: 14px;
height: 14px;
animation: pornolabPreviewSpin 1s linear infinite;
flex: 0 0 auto;
}
.spinner-text {
font-size: 13px;
color: #eee;
font-weight: normal;
font-family: sans-serif;
white-space: nowrap;
}
@keyframes pornolabPreviewSpin {
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
function transparentPixel() {
return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
}
function log(...args) {
if (settings.debugLogging || DEBUG) {
console.log('[Pornolab preview]', ...args);
}
}
})();