- Arm hover cycling only from the bare preview area; hovering the card header or footer disarms the timer, so action buttons and tooltips stay undisturbed - Drop the activation delay from 1.5s to 0.8s now that chrome interactions are protected structurally - Revise cycle interval options to Fast (1.5s) / Normal (2s, default) / Relaxed (3s), dropping the rarely useful 5s/8s choices - Translate the updated cardPreview strings in all 9 locales
267 lines
9.5 KiB
JavaScript
267 lines
9.5 KiB
JavaScript
/**
|
|
* Card preview hover cycler.
|
|
*
|
|
* The single preview image is the memory anchor for a model and never
|
|
* changes implicitly. While the user hovers the bare preview area of a card
|
|
* whose preview is a static image, this module crossfades through the
|
|
* model's example images (CivitAI images + custom images, videos excluded)
|
|
* so multi-concept models can be recognized at a glance. Hovering the card
|
|
* header or footer never starts cycling, so action buttons and tooltips
|
|
* stay undisturbed. Leaving the card restores the anchor preview
|
|
* immediately.
|
|
*
|
|
* Video previews are intentionally excluded: their hover slot already
|
|
* plays the video itself (see configureModelCardVideo).
|
|
*/
|
|
|
|
import { state } from '../../state/index.js';
|
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
|
import { isVideoPreviewUrl } from '../../utils/mediaTypes.js';
|
|
import { getThumbnailUrl } from '../../utils/civitaiUtils.js';
|
|
|
|
// How long the pointer must rest on the bare preview area before cycling
|
|
// mode activates and the card chrome fades out. The card header and footer
|
|
// live inside .card-preview, but hovering them disarms the timer, so chrome
|
|
// interactions (action icons, tooltips) are protected structurally and the
|
|
// delay can stay short. The first example frame is preloaded while the
|
|
// timer runs, so it swaps in the moment the mode activates.
|
|
const CYCLE_MODE_DELAY_MS = 800;
|
|
const DEFAULT_CYCLE_INTERVAL_SECONDS = 2;
|
|
|
|
// Per-model example image URL lists, keyed by file_path. Values are promises
|
|
// so repeated hovers share one fetch for the session.
|
|
const cycleImagesCache = new Map();
|
|
|
|
function isVideoEntry(entry) {
|
|
if (typeof entry?.type === 'string' && entry.type.toLowerCase() === 'video') {
|
|
return true;
|
|
}
|
|
return isVideoPreviewUrl(entry?.url || '');
|
|
}
|
|
|
|
function extractCycleImages(metadata) {
|
|
// fetchModelMetadata returns the civitai section itself (see ModelModal),
|
|
// not a wrapper with a `civitai` key.
|
|
const source = metadata?.images || metadata?.customImages ? metadata : metadata?.civitai || {};
|
|
const entries = [...(source.images || []), ...(source.customImages || [])];
|
|
const showOnlySFW = state.settings.show_only_sfw;
|
|
|
|
return entries
|
|
.filter(entry => entry?.url && !isVideoEntry(entry))
|
|
.filter(entry => !showOnlySFW || (entry.nsfwLevel ?? 0) < NSFW_LEVELS.R)
|
|
// Card-sized frames: width=450 keeps hover loads fast; non-CivitAI
|
|
// URLs (custom images) pass through unchanged.
|
|
.map(entry => getThumbnailUrl(entry.url));
|
|
}
|
|
|
|
function getCycleImages(model) {
|
|
const filePath = model.file_path;
|
|
if (!cycleImagesCache.has(filePath)) {
|
|
const request = getModelApiClient()
|
|
.fetchModelMetadata(filePath)
|
|
.then(metadata => extractCycleImages(metadata))
|
|
.catch(() => {
|
|
// Allow a later hover to retry after a transient failure.
|
|
cycleImagesCache.delete(filePath);
|
|
return [];
|
|
});
|
|
cycleImagesCache.set(filePath, request);
|
|
}
|
|
return cycleImagesCache.get(filePath);
|
|
}
|
|
|
|
function getCycleIntervalMs() {
|
|
const seconds = Number(state.global?.settings?.card_preview_cycle_interval);
|
|
const valid = Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_CYCLE_INTERVAL_SECONDS;
|
|
return valid * 1000;
|
|
}
|
|
|
|
function prefersReducedMotion() {
|
|
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
|
|
}
|
|
|
|
function preloadImage(url) {
|
|
return new Promise(resolve => {
|
|
const probe = new Image();
|
|
probe.onload = () => resolve(url);
|
|
probe.onerror = () => resolve(null);
|
|
probe.src = url;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attach hover cycling to a rendered card. Call only for image previews;
|
|
* video previews keep their autoplay-on-hover behavior.
|
|
*
|
|
* @param {HTMLElement} card - The .model-card element
|
|
* @param {Object} model - The model data backing the card
|
|
*/
|
|
export function attachCardPreviewCycler(card, model) {
|
|
const cardPreview = card.querySelector('.card-preview');
|
|
if (!cardPreview) return;
|
|
|
|
let hovered = false;
|
|
let pointerInChrome = false;
|
|
let hoverDelayTimer = null;
|
|
let cycleTimer = null;
|
|
let buffers = null;
|
|
let imageUrls = [];
|
|
let pendingImages = null;
|
|
let currentIndex = -1;
|
|
let activeBuffer = 0;
|
|
let swapping = false;
|
|
// Resolved preloads persist for the card's lifetime: a re-hover after a
|
|
// drive-by gets an instant first frame, and every shown frame warms the
|
|
// next one so interval ticks never wait on the network.
|
|
const preloads = new Map();
|
|
|
|
const ensurePreloaded = (url) => {
|
|
if (!preloads.has(url)) {
|
|
preloads.set(url, preloadImage(url));
|
|
}
|
|
return preloads.get(url);
|
|
};
|
|
|
|
const createBuffers = () => {
|
|
buffers = [0, 1].map(() => {
|
|
const img = document.createElement('img');
|
|
img.className = 'card-preview-cycle-img';
|
|
img.draggable = false;
|
|
img.alt = '';
|
|
cardPreview.appendChild(img);
|
|
return img;
|
|
});
|
|
};
|
|
|
|
const removeBuffers = () => {
|
|
if (!buffers) return;
|
|
const stale = buffers;
|
|
buffers = null;
|
|
// Fade back to the anchor preview before dropping the overlays.
|
|
stale.forEach(img => img.classList.remove('visible'));
|
|
setTimeout(() => stale.forEach(img => img.remove()), 450);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
if (hoverDelayTimer) {
|
|
clearTimeout(hoverDelayTimer);
|
|
hoverDelayTimer = null;
|
|
}
|
|
if (cycleTimer) {
|
|
clearInterval(cycleTimer);
|
|
cycleTimer = null;
|
|
}
|
|
currentIndex = -1;
|
|
swapping = false;
|
|
pointerInChrome = false;
|
|
card.classList.remove('cycling');
|
|
removeBuffers();
|
|
};
|
|
|
|
const swapToNext = async () => {
|
|
if (!card.isConnected) {
|
|
hovered = false;
|
|
cleanup();
|
|
return;
|
|
}
|
|
if (!hovered || swapping || document.hidden || imageUrls.length === 0) {
|
|
return;
|
|
}
|
|
|
|
swapping = true;
|
|
const nextIndex = (currentIndex + 1) % imageUrls.length;
|
|
const url = await ensurePreloaded(imageUrls[nextIndex]);
|
|
swapping = false;
|
|
|
|
// Broken image URLs are skipped without stalling the cycle.
|
|
currentIndex = nextIndex;
|
|
if (!url || !hovered || !buffers) {
|
|
return;
|
|
}
|
|
|
|
const incoming = buffers[1 - activeBuffer];
|
|
const outgoing = buffers[activeBuffer];
|
|
incoming.src = url;
|
|
incoming.classList.add('visible');
|
|
outgoing.classList.remove('visible');
|
|
activeBuffer = 1 - activeBuffer;
|
|
|
|
// Warm the following frame while this one is on screen.
|
|
ensurePreloaded(imageUrls[(currentIndex + 1) % imageUrls.length]);
|
|
};
|
|
|
|
// Data loading starts on first hover, not when the mode activates: the
|
|
// delay only gates the visual mode switch, and the first frame is
|
|
// preloaded during it so it can fade in instantly on activation.
|
|
const startPrefetch = () => {
|
|
if (pendingImages) return;
|
|
pendingImages = getCycleImages(model).then(urls => {
|
|
// The first CivitAI example doubles as the cover/preview, so the
|
|
// first frame shown is index 1 (the cover rejoins the rotation
|
|
// when the cycle wraps).
|
|
if (urls.length > 1) {
|
|
ensurePreloaded(urls[1]);
|
|
}
|
|
return urls;
|
|
});
|
|
};
|
|
|
|
const activate = async () => {
|
|
hoverDelayTimer = null;
|
|
imageUrls = await pendingImages;
|
|
// A single example is the cover image itself — nothing new to show.
|
|
// The cycleTimer guard catches a second activation that was re-armed
|
|
// while this one was still awaiting its image list.
|
|
if (!hovered || pointerInChrome || !card.isConnected || imageUrls.length < 2 || cycleTimer) {
|
|
return;
|
|
}
|
|
// Index 0 is the anchor's double; treat it as the current frame so
|
|
// the first swap lands on index 1.
|
|
currentIndex = 0;
|
|
card.classList.add('cycling');
|
|
createBuffers();
|
|
swapToNext();
|
|
cycleTimer = setInterval(swapToNext, getCycleIntervalMs());
|
|
};
|
|
|
|
const onMouseOver = (event) => {
|
|
if (!state.global?.settings?.card_preview_cycle) return;
|
|
if (prefersReducedMotion()) return;
|
|
// NSFW-blurred cards stay static until the user explicitly unblurs.
|
|
if (cardPreview.classList.contains('blurred')) return;
|
|
|
|
if (!hovered) {
|
|
hovered = true;
|
|
startPrefetch();
|
|
}
|
|
if (cycleTimer) return;
|
|
|
|
// The card chrome (header/footer with action icons and tooltips) is
|
|
// a child of .card-preview; only the bare preview area arms the
|
|
// activation timer, and moving into the chrome disarms it again.
|
|
pointerInChrome = Boolean(event.target.closest?.('.card-header, .card-footer'));
|
|
if (pointerInChrome) {
|
|
if (hoverDelayTimer) {
|
|
clearTimeout(hoverDelayTimer);
|
|
hoverDelayTimer = null;
|
|
}
|
|
} else if (!hoverDelayTimer) {
|
|
hoverDelayTimer = setTimeout(activate, CYCLE_MODE_DELAY_MS);
|
|
}
|
|
};
|
|
|
|
const onMouseLeave = () => {
|
|
hovered = false;
|
|
cleanup();
|
|
};
|
|
|
|
cardPreview.addEventListener('mouseover', onMouseOver);
|
|
cardPreview.addEventListener('mouseleave', onMouseLeave);
|
|
}
|
|
|
|
/** Test hook: drop cached image lists between tests. */
|
|
export function resetCardPreviewCycleCache() {
|
|
cycleImagesCache.clear();
|
|
}
|