mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 07:35:44 -03:00
feat: implement lazy loading and image caching for thumbnails
Add lazy loading with skeleton animations and IndexedDB-based image caching to improve thumbnail loading performance. The changes include: - CSS animations for loading states with shimmer effects - Priority-based image loading queue with configurable concurrency - Persistent image cache with automatic cleanup - Error handling and cached image highlighting - Increased concurrent loading from 3 to 6 for faster initial display This reduces network requests and provides smoother user experience when browsing large model collections.
This commit is contained in:
@@ -516,3 +516,51 @@
|
|||||||
right: var(--space-1);
|
right: var(--space-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Lazy Loading Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Thumbnail lazy loading placeholder */
|
||||||
|
.thumbnail-rail__item img {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loaded state */
|
||||||
|
.thumbnail-rail__item img.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state with skeleton animation */
|
||||||
|
.thumbnail-rail__item img.lazy-load {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--lora-surface) 25%,
|
||||||
|
var(--lora-border) 50%,
|
||||||
|
var(--lora-surface) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: lazy-loading-shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lazy-loading-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state for failed loads */
|
||||||
|
.thumbnail-rail__item img.load-error {
|
||||||
|
opacity: 0.3;
|
||||||
|
background: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cached image - subtle highlight */
|
||||||
|
.thumbnail-rail__item img[data-cached="true"] {
|
||||||
|
border: 1px solid var(--lora-accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,191 @@ import { state } from '../../state/index.js';
|
|||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image Loading Queue - Controls concurrent image loading
|
||||||
|
*/
|
||||||
|
class ImageLoadingQueue {
|
||||||
|
constructor(maxConcurrent = 3) {
|
||||||
|
this.maxConcurrent = maxConcurrent;
|
||||||
|
this.running = 0;
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(loadFn, priority = 0) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.queue.push({ loadFn, resolve, reject, priority });
|
||||||
|
this.queue.sort((a, b) => b.priority - a.priority);
|
||||||
|
this.process();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async process() {
|
||||||
|
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running++;
|
||||||
|
const { loadFn, resolve, reject } = this.queue.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await loadFn();
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
} finally {
|
||||||
|
this.running--;
|
||||||
|
this.process();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image Cache using IndexedDB
|
||||||
|
*/
|
||||||
|
class ImageCache {
|
||||||
|
constructor() {
|
||||||
|
this.dbName = 'LoraManagerImageCache';
|
||||||
|
this.storeName = 'images';
|
||||||
|
this.db = null;
|
||||||
|
this.maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
this.maxSize = 500; // Max 500 cached images (~50-100 models worth)
|
||||||
|
this.initPromise = this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, 1);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
resolve(this.db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||||
|
const store = db.createObjectStore(this.storeName, { keyPath: 'url' });
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(url) {
|
||||||
|
await this.initPromise;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const request = store.get(url);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result;
|
||||||
|
if (!result) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is expired
|
||||||
|
const age = Date.now() - result.timestamp;
|
||||||
|
if (age > this.maxAge) {
|
||||||
|
this.delete(url);
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result.blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(url, blob) {
|
||||||
|
await this.initPromise;
|
||||||
|
|
||||||
|
// Check current cache size and cleanup if needed
|
||||||
|
await this.cleanupIfNeeded();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
|
||||||
|
const request = store.put({
|
||||||
|
url,
|
||||||
|
blob,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
size: blob.size
|
||||||
|
});
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(url) {
|
||||||
|
await this.initPromise;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const request = store.delete(url);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupIfNeeded() {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const countRequest = store.count();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
countRequest.onsuccess = async () => {
|
||||||
|
if (countRequest.result >= this.maxSize) {
|
||||||
|
// Delete oldest 20% of entries
|
||||||
|
const index = store.index('timestamp');
|
||||||
|
const cursorRequest = index.openCursor();
|
||||||
|
const toDelete = [];
|
||||||
|
|
||||||
|
cursorRequest.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result;
|
||||||
|
if (cursor && toDelete.length < Math.floor(this.maxSize * 0.2)) {
|
||||||
|
toDelete.push(cursor.value.url);
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
// Delete collected entries
|
||||||
|
toDelete.forEach(url => this.delete(url));
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
await this.initPromise;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instances - Optimized for better performance
|
||||||
|
const imageQueue = new ImageLoadingQueue(6); // Increased from 3 to 6 for faster loading
|
||||||
|
const imageCache = new ImageCache();
|
||||||
|
|
||||||
export class Showcase {
|
export class Showcase {
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
this.element = container;
|
this.element = container;
|
||||||
@@ -29,6 +214,9 @@ export class Showcase {
|
|||||||
this.localFiles = [];
|
this.localFiles = [];
|
||||||
this.globalBlurEnabled = true; // Will be initialized based on user settings
|
this.globalBlurEnabled = true; // Will be initialized based on user settings
|
||||||
this.isLoading = false; // Track loading state
|
this.isLoading = false; // Track loading state
|
||||||
|
|
||||||
|
// Lazy loading observer for thumbnails
|
||||||
|
this.thumbnailObserver = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,12 +238,85 @@ export class Showcase {
|
|||||||
|
|
||||||
this.element.innerHTML = this.getTemplate();
|
this.element.innerHTML = this.getTemplate();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
this.initLazyLoading();
|
||||||
|
|
||||||
if (this.images.length > 0) {
|
if (this.images.length > 0) {
|
||||||
this.loadImage(0);
|
this.loadImage(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize lazy loading for thumbnails using Intersection Observer
|
||||||
|
*/
|
||||||
|
initLazyLoading() {
|
||||||
|
// Disconnect existing observer if any
|
||||||
|
if (this.thumbnailObserver) {
|
||||||
|
this.thumbnailObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new observer
|
||||||
|
this.thumbnailObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const img = entry.target;
|
||||||
|
const src = img.dataset.src;
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
// Load through queue and cache
|
||||||
|
this.loadImageWithCache(src, img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop observing this image
|
||||||
|
this.thumbnailObserver.unobserve(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
root: this.element.querySelector('.thumbnail-rail'),
|
||||||
|
rootMargin: '100px', // Start loading 100px before visible
|
||||||
|
threshold: 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe all lazy-load thumbnails
|
||||||
|
const lazyImages = this.element.querySelectorAll('img[data-src]');
|
||||||
|
lazyImages.forEach(img => this.thumbnailObserver.observe(img));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image with caching support
|
||||||
|
* @param {string} url - Image URL
|
||||||
|
* @param {HTMLImageElement} imgElement - Image element to load into
|
||||||
|
*/
|
||||||
|
async loadImageWithCache(url, imgElement) {
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const cachedBlob = await imageCache.get(url);
|
||||||
|
|
||||||
|
if (cachedBlob) {
|
||||||
|
// Use cached image
|
||||||
|
const objectUrl = URL.createObjectURL(cachedBlob);
|
||||||
|
imgElement.src = objectUrl;
|
||||||
|
imgElement.classList.add('loaded');
|
||||||
|
|
||||||
|
// Clean up object URL after load
|
||||||
|
imgElement.onload = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load through queue (limited concurrency) - pass true to use queue
|
||||||
|
await this.preloadMedia(url, false, true);
|
||||||
|
|
||||||
|
// Set the image src after loading (cache miss case)
|
||||||
|
imgElement.src = url;
|
||||||
|
imgElement.classList.add('loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load image:', error);
|
||||||
|
// Set fallback or error state
|
||||||
|
imgElement.classList.add('load-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch local example files from the server
|
* Fetch local example files from the server
|
||||||
*/
|
*/
|
||||||
@@ -157,28 +418,93 @@ export class Showcase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preload media (image or video)
|
* Transform Civitai URL to optimized version
|
||||||
|
* @param {string} url - Original Civitai URL
|
||||||
|
* @param {boolean} isThumbnail - Whether this is for a thumbnail (smaller size)
|
||||||
|
* @returns {string} Optimized URL or original URL if not from Civitai
|
||||||
|
*/
|
||||||
|
transformCivitaiUrl(url, isThumbnail = false) {
|
||||||
|
if (!url || !url.includes('image.civitai.com')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = isThumbnail ? '320' : '450';
|
||||||
|
|
||||||
|
if (url.includes('.mp4') || url.includes('.webm')) {
|
||||||
|
if (isThumbnail) {
|
||||||
|
return url.replace(/\/original=true\/(.*)$/, `/anim=false,transcode=true,width=${width},original=false,optimized=true/$1`);
|
||||||
|
} else {
|
||||||
|
return url.replace(/\/original=true\/(.*)$/, `/transcode=true,width=${width},optimized=true/$1`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isThumbnail) {
|
||||||
|
return url.replace(/\/original=true\/(.*)$/, `/anim=false,width=${width},optimized=true/$1`);
|
||||||
|
} else {
|
||||||
|
return url.replace(/\/original=true\/(.*)$/, `/width=${width},optimized=true/$1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload media (image or video) with caching support
|
||||||
|
* Main images load immediately without queue to avoid latency
|
||||||
* @param {string} url - Media URL
|
* @param {string} url - Media URL
|
||||||
* @param {boolean} isVideo - Whether media is video
|
* @param {boolean} isVideo - Whether media is video
|
||||||
|
* @param {boolean} useQueue - Whether to use loading queue (thumbnails only)
|
||||||
* @returns {Promise} Resolves when media is loaded
|
* @returns {Promise} Resolves when media is loaded
|
||||||
*/
|
*/
|
||||||
preloadMedia(url, isVideo = false) {
|
async preloadMedia(url, isVideo = false, useQueue = false) {
|
||||||
return new Promise((resolve, reject) => {
|
// For videos, use standard loading without cache
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.preload = 'metadata';
|
video.preload = 'metadata';
|
||||||
video.src = url;
|
video.src = url;
|
||||||
video.addEventListener('loadeddata', () => resolve(url));
|
video.addEventListener('loadeddata', () => resolve(url));
|
||||||
video.addEventListener('error', reject);
|
video.addEventListener('error', reject);
|
||||||
} else {
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cachedBlob = await imageCache.get(url);
|
||||||
|
if (cachedBlob) {
|
||||||
|
return url; // Return original URL, will use blob URL when rendering
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadImage = async () => {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
img.referrerPolicy = 'no-referrer';
|
img.referrerPolicy = 'no-referrer';
|
||||||
img.onload = () => resolve(url);
|
|
||||||
|
img.onload = async () => {
|
||||||
|
// Cache the loaded image
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'omit',
|
||||||
|
referrerPolicy: 'no-referrer'
|
||||||
|
});
|
||||||
|
const blob = await response.blob();
|
||||||
|
await imageCache.set(url, blob);
|
||||||
|
} catch (cacheError) {
|
||||||
|
// Non-fatal: continue even if caching fails
|
||||||
|
console.warn('Failed to cache image:', cacheError);
|
||||||
|
}
|
||||||
|
resolve(url);
|
||||||
|
};
|
||||||
|
|
||||||
img.onerror = reject;
|
img.onerror = reject;
|
||||||
img.src = url;
|
img.src = url;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main images load immediately without queue to avoid any latency
|
||||||
|
// Only thumbnails use queue to prevent network congestion
|
||||||
|
if (useQueue) {
|
||||||
|
return imageQueue.add(loadImage, 0);
|
||||||
|
} else {
|
||||||
|
return loadImage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,7 +605,7 @@ export class Showcase {
|
|||||||
const localFile = this.findLocalFile(img, index);
|
const localFile = this.findLocalFile(img, index);
|
||||||
const remoteUrl = img.url || img;
|
const remoteUrl = img.url || img;
|
||||||
const localUrl = localFile ? localFile.path : '';
|
const localUrl = localFile ? localFile.path : '';
|
||||||
const url = localUrl || remoteUrl;
|
const url = localUrl || this.transformCivitaiUrl(remoteUrl, true);
|
||||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||||
// Check if this specific image needs blur based on global state
|
// Check if this specific image needs blur based on global state
|
||||||
const needsBlur = nsfwLevel > NSFW_LEVELS.PG13;
|
const needsBlur = nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
@@ -287,8 +613,16 @@ export class Showcase {
|
|||||||
const isVideo = this.isVideo(img, localFile);
|
const isVideo = this.isVideo(img, localFile);
|
||||||
const blurClass = shouldBlur ? 'blurred' : '';
|
const blurClass = shouldBlur ? 'blurred' : '';
|
||||||
|
|
||||||
|
// Smart loading: current index and nearby thumbnails load immediately
|
||||||
|
// Others use lazy loading via IntersectionObserver
|
||||||
|
const currentIndex = this.currentIndex || 0;
|
||||||
|
const preloadRange = 2; // Load current +/- 2 thumbnails immediately
|
||||||
|
const shouldPreload = Math.abs(index - currentIndex) <= preloadRange;
|
||||||
|
const srcAttr = shouldPreload ? `src="${url}"` : `data-src="${url}"`;
|
||||||
|
const loadingClass = shouldPreload ? '' : 'lazy-load';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="thumbnail-rail__item ${index === 0 ? 'active' : ''} ${shouldBlur ? 'thumbnail-rail__item--nsfw-blurred' : ''}"
|
<div class="thumbnail-rail__item ${index === currentIndex ? 'active' : ''} ${shouldBlur ? 'thumbnail-rail__item--nsfw-blurred' : ''}"
|
||||||
data-index="${index}"
|
data-index="${index}"
|
||||||
data-action="select-image"
|
data-action="select-image"
|
||||||
data-nsfw-level="${nsfwLevel}">
|
data-nsfw-level="${nsfwLevel}">
|
||||||
@@ -297,7 +631,7 @@ export class Showcase {
|
|||||||
<i class="fas fa-play-circle"></i>
|
<i class="fas fa-play-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<img src="${url}" loading="lazy" alt="" class="${blurClass}" onload="this.classList.add('loaded')">
|
<img ${srcAttr} alt="" class="${blurClass} ${loadingClass}" onload="this.classList.add('loaded')" data-index="${index}">
|
||||||
${shouldBlur ? '<span class="thumbnail-rail__nsfw-badge">NSFW</span>' : ''}
|
${shouldBlur ? '<span class="thumbnail-rail__nsfw-badge">NSFW</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -629,7 +963,7 @@ export class Showcase {
|
|||||||
const localFile = this.findLocalFile(image, index);
|
const localFile = this.findLocalFile(image, index);
|
||||||
const remoteUrl = image.url || image;
|
const remoteUrl = image.url || image;
|
||||||
const localUrl = localFile ? localFile.path : '';
|
const localUrl = localFile ? localFile.path : '';
|
||||||
const url = localUrl || remoteUrl;
|
const url = localUrl || this.transformCivitaiUrl(remoteUrl, false);
|
||||||
const nsfwLevel = image.nsfwLevel !== undefined ? image.nsfwLevel : 0;
|
const nsfwLevel = image.nsfwLevel !== undefined ? image.nsfwLevel : 0;
|
||||||
const shouldBlur = this.shouldBlurContent(nsfwLevel);
|
const shouldBlur = this.shouldBlurContent(nsfwLevel);
|
||||||
const isVideo = this.isVideo(image, localFile);
|
const isVideo = this.isVideo(image, localFile);
|
||||||
@@ -642,11 +976,18 @@ export class Showcase {
|
|||||||
mediaContainer.innerHTML = this.renderLoadingSkeleton();
|
mediaContainer.innerHTML = this.renderLoadingSkeleton();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Preload media
|
// Check cache first for instant display
|
||||||
await this.preloadMedia(url, isVideo);
|
let displayUrl = url;
|
||||||
|
let objectUrl = null;
|
||||||
|
const cachedBlob = !isVideo ? await imageCache.get(url) : null;
|
||||||
|
|
||||||
// Render media with fade-in effect
|
if (cachedBlob) {
|
||||||
mediaContainer.innerHTML = this.renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel);
|
// Use cached image immediately
|
||||||
|
objectUrl = URL.createObjectURL(cachedBlob);
|
||||||
|
displayUrl = objectUrl;
|
||||||
|
|
||||||
|
// Render with cached image
|
||||||
|
mediaContainer.innerHTML = this.renderMediaElement(displayUrl, isVideo, shouldBlur, nsfwText, nsfwLevel);
|
||||||
|
|
||||||
// Trigger fade-in animation
|
// Trigger fade-in animation
|
||||||
const media = mediaContainer.querySelector('.showcase__media');
|
const media = mediaContainer.querySelector('.showcase__media');
|
||||||
@@ -655,6 +996,38 @@ export class Showcase {
|
|||||||
media.classList.add('loaded');
|
media.classList.add('loaded');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Preload media (will cache it)
|
||||||
|
await this.preloadMedia(url, isVideo);
|
||||||
|
|
||||||
|
// Try to get from cache after loading
|
||||||
|
const newlyCachedBlob = !isVideo ? await imageCache.get(url) : null;
|
||||||
|
if (newlyCachedBlob) {
|
||||||
|
objectUrl = URL.createObjectURL(newlyCachedBlob);
|
||||||
|
displayUrl = objectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render media with fade-in effect
|
||||||
|
mediaContainer.innerHTML = this.renderMediaElement(displayUrl, isVideo, shouldBlur, nsfwText, nsfwLevel);
|
||||||
|
|
||||||
|
// Trigger fade-in animation
|
||||||
|
const media = mediaContainer.querySelector('.showcase__media');
|
||||||
|
if (media) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
media.classList.add('loaded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up object URL when image loads (for next navigation)
|
||||||
|
if (objectUrl) {
|
||||||
|
const media = mediaContainer.querySelector('.showcase__media');
|
||||||
|
if (media) {
|
||||||
|
media.onload = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load media:', error);
|
console.error('Failed to load media:', error);
|
||||||
mediaContainer.innerHTML = `
|
mediaContainer.innerHTML = `
|
||||||
@@ -1114,4 +1487,15 @@ export class Showcase {
|
|||||||
showToast('modals.model.examples.deleteFailed', {}, 'error');
|
showToast('modals.model.examples.deleteFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources when component is destroyed
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Disconnect lazy loading observer
|
||||||
|
if (this.thumbnailObserver) {
|
||||||
|
this.thumbnailObserver.disconnect();
|
||||||
|
this.thumbnailObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user