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

@@ -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