Add advanced gallery features: lazy loading, sorting, and loading animations

- Implement lazy loading for video thumbnails using Intersection Observer
  - Only load thumbnails when they enter viewport (50px margin)
  - Smooth fade-in transition when thumbnails load
  - Significantly reduces initial page load time

- Add shimmer loading animation for thumbnail containers
  - Gradient shimmer effect while videos are loading
  - Automatic removal once content loads
  - Professional loading state feedback

- Add video sorting functionality
  - Sort by: Newest/Oldest, Name (A-Z/Z-A), Size (Largest/Smallest)
  - Client-side sorting for instant results
  - Maintains lazy loading when re-rendering sorted videos

- Improve error handling
  - Graceful fallback with placeholder icon for failed thumbnails
  - Better UX when videos fail to load

Performance improvements especially noticeable with large video collections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-25 19:50:36 -08:00
parent 50e44f7c24
commit 2d71a13621
1 changed files with 182 additions and 19 deletions

View File

@ -610,6 +610,30 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
header { text-align: center; margin-bottom: 40px; padding: 20px; }
h1 { font-size: 2.5rem; margin-bottom: 10px; }
.subtitle { color: #aaa; font-size: 1.1rem; }
.controls {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 20px;
flex-wrap: wrap;
}
.sort-select {
padding: 10px 20px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: white;
font-size: 0.95rem;
cursor: pointer;
transition: border-color 0.2s;
}
.sort-select:hover {
border-color: #3ea6ff;
}
.sort-select:focus {
outline: none;
border-color: #3ea6ff;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
@ -631,15 +655,30 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
background: linear-gradient(90deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
cursor: pointer;
overflow: hidden;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.video-thumbnail.loaded {
animation: none;
background: #000;
}
.video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.video-thumbnail video.loaded {
opacity: 1;
}
.play-overlay {
position: absolute;
@ -706,11 +745,33 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
<header>
<h1>🎥 Video Gallery</h1>
<p class="subtitle">${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available</p>
${shareableVideos.length > 0 ? `
<div class="controls">
<select class="sort-select" id="sortSelect" onchange="sortVideos(this.value)">
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="size-desc">Largest First</option>
<option value="size-asc">Smallest First</option>
</select>
</div>
` : ''}
</header>
<div class="video-grid">
<div class="video-grid" id="videoGrid">
${emptyState}
</div>
<script>
// Store videos for sorting
let allVideos = ${JSON.stringify(shareableVideos.map(v => ({
key: v.key,
size: v.size,
uploaded: v.uploaded,
sizeInMB: (v.size / (1024 * 1024)).toFixed(2),
uploadDate: new Date(v.uploaded).toLocaleDateString(),
encodedKey: encodeURIComponent(v.key)
})))};
function playVideo(encodedKey) {
window.location.href = '/watch/' + encodedKey;
}
@ -720,28 +781,130 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
}
// Load thumbnails on page load
document.addEventListener('DOMContentLoaded', function() {
function sortVideos(sortBy) {
const sorted = [...allVideos];
switch(sortBy) {
case 'newest':
sorted.sort((a, b) => new Date(b.uploaded) - new Date(a.uploaded));
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.uploaded) - new Date(b.uploaded));
break;
case 'name-asc':
sorted.sort((a, b) => a.key.localeCompare(b.key));
break;
case 'name-desc':
sorted.sort((a, b) => b.key.localeCompare(a.key));
break;
case 'size-desc':
sorted.sort((a, b) => b.size - a.size);
break;
case 'size-asc':
sorted.sort((a, b) => a.size - b.size);
break;
}
renderVideos(sorted);
}
function renderVideos(videos) {
const grid = document.getElementById('videoGrid');
grid.innerHTML = videos.map(obj => \`
<div class="video-item">
<div class="video-thumbnail" onclick="playVideo('\${obj.encodedKey}')">
<video id="thumb-\${obj.encodedKey}" preload="metadata" muted>
<source src="/\${obj.key}#t=0.5" type="\${getContentType(obj.key)}">
</video>
<div class="play-overlay">
<div class="play-button"></div>
</div>
</div>
<div class="video-info">
<h3 onclick="playVideo('\${obj.encodedKey}')" style="cursor: pointer;">\${obj.key}</h3>
<p>Size: \${obj.sizeInMB} MB | Uploaded: \${obj.uploadDate}</p>
<div class="button-group">
<button onclick="playVideo('\${obj.encodedKey}')">Watch</button>
<button onclick="copyLink('\${obj.key}')">Copy Link</button>
</div>
</div>
</div>
\`).join('\\n');
// Re-initialize lazy loading for new thumbnails
initLazyLoading();
}
function getContentType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const types = {
'mp4': 'video/mp4',
'mkv': 'video/x-matroska',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
'webm': 'video/webm',
'flv': 'video/x-flv',
'wmv': 'video/x-ms-wmv'
};
return types[ext] || 'video/mp4';
}
// Lazy load thumbnails using Intersection Observer
function initLazyLoading() {
const videos = document.querySelectorAll('.video-thumbnail video');
videos.forEach(video => {
// Set up error handling for videos that fail to load
video.addEventListener('error', function() {
// Hide the video element and show a placeholder
const thumbnail = this.closest('.video-thumbnail');
const overlay = thumbnail.querySelector('.play-overlay');
this.style.display = 'none';
thumbnail.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)';
// Add a video icon placeholder
const icon = document.createElement('div');
icon.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 3rem; opacity: 0.3;';
icon.textContent = '🎬';
thumbnail.insertBefore(icon, overlay);
// Create intersection observer for lazy loading
const observerOptions = {
root: null,
rootMargin: '50px',
threshold: 0.01
};
const videoObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const video = entry.target;
// Set up error handling for videos that fail to load
video.addEventListener('error', function() {
// Hide the video element and show a placeholder
const thumbnail = this.closest('.video-thumbnail');
const overlay = thumbnail.querySelector('.play-overlay');
this.style.display = 'none';
thumbnail.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)';
// Add a video icon placeholder
const icon = document.createElement('div');
icon.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 3rem; opacity: 0.3;';
icon.textContent = '🎬';
thumbnail.insertBefore(icon, overlay);
});
// Show video when loaded
video.addEventListener('loadeddata', function() {
this.classList.add('loaded');
const thumbnail = this.closest('.video-thumbnail');
thumbnail.classList.add('loaded');
});
// Try to load the video
video.load();
// Stop observing this video
observer.unobserve(video);
}
});
}, observerOptions);
// Try to load the video
video.load();
// Observe all video elements
videos.forEach(video => {
videoObserver.observe(video);
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initLazyLoading();
});
</script>
</body>