diff --git a/public/s/css/upload.css b/public/s/css/upload.css index ee4a5c1..bd6fe34 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -1261,3 +1261,15 @@ height: 200px; } } + +/* Video thumbnail loading animation */ +.video-thumbnail-loading { + animation: thumbPulse 1.5s ease-in-out infinite; + background: rgba(255, 255, 255, 0.05); +} + +@keyframes thumbPulse { + 0% { opacity: 0.4; } + 50% { opacity: 0.8; } + 100% { opacity: 0.4; } +} diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 406f2f7..b781593 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -7,6 +7,101 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => { .replace(/'/g, "'"); }); +// Throttled queue to capture the first frame of video files asynchronously without blocking the browser +class VideoThumbnailQueue { + constructor(concurrency = 3) { + this.concurrency = concurrency; + this.activeCount = 0; + this.queue = []; + } + + add(file, callback) { + this.queue.push({ file, callback }); + this.next(); + } + + next() { + if (this.activeCount >= this.concurrency || this.queue.length === 0) return; + const { file, callback } = this.queue.shift(); + this.activeCount++; + this.capture(file) + .then(dataUrl => callback(dataUrl)) + .catch(err => { + console.warn('[VideoThumbnailQueue] Error capturing thumbnail:', err); + callback(null); + }) + .finally(() => { + this.activeCount--; + this.next(); + }); + } + + capture(file) { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + const objectUrl = URL.createObjectURL(file); + video.src = objectUrl; + video.muted = true; + video.playsInline = true; + video.preload = 'metadata'; + + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + video.src = ''; + video.load(); + URL.revokeObjectURL(objectUrl); + }; + + video.onloadeddata = () => { + video.currentTime = 0.1; + }; + + video.onseeked = () => { + try { + const canvas = document.createElement('canvas'); + const maxDim = 320; + let width = video.videoWidth || 160; + let height = video.videoHeight || 120; + if (width > maxDim || height > maxDim) { + if (width > height) { + height = Math.round((height * maxDim) / width); + width = maxDim; + } else { + width = Math.round((width * maxDim) / height); + height = maxDim; + } + } + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, width, height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.8); + cleanup(); + resolve(dataUrl); + } catch (e) { + cleanup(); + reject(e); + } + }; + + video.onerror = () => { + cleanup(); + reject(new Error('Video loading failed')); + }; + + setTimeout(() => { + if (!cleanedUp) { + cleanup(); + reject(new Error('Capture timeout')); + } + }, 8000); + }); + } +} +const videoThumbnailQueue = new VideoThumbnailQueue(3); + window.initUploadForm = (selector) => { const form = (typeof selector === 'string') ? document.querySelector(selector) : selector; if (!form) return; @@ -820,9 +915,25 @@ window.initUploadForm = (selector) => { mediaElem = document.createElement('video'); mediaElem.src = URL.createObjectURL(file); mediaElem.muted = true; - mediaElem.autoplay = true; mediaElem.controls = true; mediaElem.loop = true; + + if (isShitpost) { + mediaElem.autoplay = false; + mediaElem.preload = 'none'; + mediaElem.classList.add('video-thumbnail-loading'); + + videoThumbnailQueue.add(file, (dataUrl) => { + if (dataUrl) { + mediaElem.poster = dataUrl; + mediaElem.classList.remove('video-thumbnail-loading'); + } else { + mediaElem.classList.remove('video-thumbnail-loading'); + } + }); + } else { + mediaElem.autoplay = true; + } } else if (file.type.startsWith('audio/')) { mediaElem = document.createElement('audio'); mediaElem.src = URL.createObjectURL(file);