feat(showcase): improve remote image loading with skeleton animation and fade-in effects

- Add preloadMedia() for async image/video loading before display
- Implement renderLoadingSkeleton() with fa-circle-notch fa-spin animation
- Add fadeIn transition (opacity 0→1) for main media elements
- Remove shimmer gradient animation from thumbnails for cleaner look
- Use solid background color placeholder with subtle fade-in for thumbnails
- Fixes progressive rendering of remote images from top to bottom
- Prevents black flash during loading with proper loading states
This commit is contained in:
Will Miao
2026-02-06 23:49:45 +08:00
parent 469f7a1829
commit 4d9115339b
3 changed files with 133 additions and 11 deletions

View File

@@ -77,7 +77,12 @@
max-height: 70vh;
object-fit: contain;
border-radius: var(--border-radius-sm);
transition: filter 0.2s ease;
transition: filter 0.2s ease, opacity 0.3s ease;
opacity: 0;
}
.showcase__media.loaded {
opacity: 1;
}
.showcase__media.blurred {
@@ -384,6 +389,51 @@
font-size: 2rem;
}
/* Skeleton loading state */
.showcase__skeleton {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--lora-surface);
}
.skeleton-animation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
color: var(--text-color);
opacity: 0.6;
}
.skeleton-spinner {
font-size: 2.5rem;
color: var(--lora-accent);
}
/* Error state */
.showcase__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--lora-error);
gap: var(--space-2);
}
.showcase__error i {
font-size: 2rem;
}
.showcase__error p {
margin: 0;
font-size: 0.9em;
}
/* Empty state */
.showcase__empty {
display: flex;

View File

@@ -36,7 +36,19 @@
position: relative;
border: 2px solid transparent;
transition: border-color 0.2s ease, transform 0.2s ease;
background: var(--bg-color);
background: var(--lora-surface);
}
.thumbnail-rail__item img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease;
}
.thumbnail-rail__item img.loaded {
opacity: 1;
}
.thumbnail-rail__item:hover {
@@ -49,12 +61,6 @@
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
}
.thumbnail-rail__item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* NSFW blur for thumbnails - BEM naming to avoid conflicts with global .nsfw-blur */
.thumbnail-rail__item--nsfw-blurred img {
filter: blur(8px);

View File

@@ -28,6 +28,7 @@ export class Showcase {
this.isUploading = false;
this.localFiles = [];
this.globalBlurEnabled = true; // Will be initialized based on user settings
this.isLoading = false; // Track loading state
}
/**
@@ -155,6 +156,45 @@ export class Showcase {
return 'Mature Content';
}
/**
* Preload media (image or video)
* @param {string} url - Media URL
* @param {boolean} isVideo - Whether media is video
* @returns {Promise} Resolves when media is loaded
*/
preloadMedia(url, isVideo = false) {
return new Promise((resolve, reject) => {
if (isVideo) {
const video = document.createElement('video');
video.preload = 'metadata';
video.src = url;
video.addEventListener('loadeddata', () => resolve(url));
video.addEventListener('error', reject);
} else {
const img = new Image();
img.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
img.onload = () => resolve(url);
img.onerror = reject;
img.src = url;
}
});
}
/**
* Render loading skeleton
* @returns {string} Skeleton HTML
*/
renderLoadingSkeleton() {
return `
<div class="showcase__skeleton">
<div class="skeleton-animation">
<i class="fas fa-circle-notch fa-spin skeleton-spinner"></i>
</div>
</div>
`;
}
/**
* Get the HTML template
*/
@@ -239,6 +279,7 @@ export class Showcase {
const needsBlur = nsfwLevel > NSFW_LEVELS.PG13;
const shouldBlur = this.globalBlurEnabled && needsBlur;
const isVideo = this.isVideo(img, localFile);
const blurClass = shouldBlur ? 'blurred' : '';
return `
<div class="thumbnail-rail__item ${index === 0 ? 'active' : ''} ${shouldBlur ? 'thumbnail-rail__item--nsfw-blurred' : ''}"
@@ -250,7 +291,7 @@ export class Showcase {
<i class="fas fa-play-circle"></i>
</div>
` : ''}
<img src="${url}" loading="lazy" alt="" ${shouldBlur ? 'class="blurred"' : ''}>
<img src="${url}" loading="lazy" alt="" class="${blurClass}" onload="this.classList.add('loaded')">
${shouldBlur ? '<span class="thumbnail-rail__nsfw-badge">NSFW</span>' : ''}
</div>
`;
@@ -539,7 +580,7 @@ export class Showcase {
/**
* Load and display an image by index
*/
loadImage(index) {
async loadImage(index) {
if (index < 0 || index >= this.images.length) return;
this.currentIndex = index;
@@ -556,7 +597,32 @@ export class Showcase {
// Update main media container
const mediaContainer = this.element.querySelector('.showcase__media-container');
if (mediaContainer) {
mediaContainer.innerHTML = this.renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel);
// Show skeleton while loading
mediaContainer.innerHTML = this.renderLoadingSkeleton();
try {
// Preload media
await this.preloadMedia(url, isVideo);
// Render media with fade-in effect
mediaContainer.innerHTML = this.renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel);
// Trigger fade-in animation
const media = mediaContainer.querySelector('.showcase__media');
if (media) {
requestAnimationFrame(() => {
media.classList.add('loaded');
});
}
} catch (error) {
console.error('Failed to load media:', error);
mediaContainer.innerHTML = `
<div class="showcase__error">
<i class="fas fa-exclamation-triangle"></i>
<p>${translate('modals.model.examples.loadError', {}, 'Failed to load media')}</p>
</div>
`;
}
}
// Update global blur toggle button visibility