From 2bec770b4e6dcf60e27d9ebbc48825d224a4de91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 10 Dec 2025 14:32:11 +0100 Subject: [PATCH] Add preview --- src/main/resources/static/css/styles.css | 73 +++++++ src/main/resources/static/js/admin.js | 232 ++++++++++++++++++++-- src/main/resources/static/js/broadcast.js | 4 + src/main/resources/templates/admin.html | 2 +- 4 files changed, 297 insertions(+), 14 deletions(-) diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 5dc2551..10d8dbe 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -744,6 +744,12 @@ body { box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.6); } +.asset-item.pending { + cursor: default; + border-style: dashed; + opacity: 0.92; +} + .asset-item.is-hidden { opacity: 0.72; border-style: dashed; @@ -834,6 +840,73 @@ body { flex-shrink: 0; } +.asset-preview.still { + position: relative; + overflow: hidden; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + object-fit: cover; +} + +.asset-preview.still:not(.has-image) { + display: grid; + place-items: center; + color: #cbd5e1; + background: #111827; +} + +.preview-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.4), rgba(15, 23, 42, 0.4)); + color: #e5e7eb; + pointer-events: none; + font-size: 18px; +} + +.pending-preview { + display: grid; + place-items: center; + color: #cbd5e1; + background: rgba(124, 58, 237, 0.08); + border-style: dashed; +} + +.upload-progress { + margin-top: 10px; + width: 100%; + height: 6px; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 999px; + overflow: hidden; +} + +.upload-progress-bar { + width: 100%; + height: 100%; + background: linear-gradient(90deg, rgba(124, 58, 237, 0.8), rgba(99, 102, 241, 0.8), rgba(124, 58, 237, 0.8)); + background-size: 200% 100%; + animation: upload-progress 1.2s linear infinite; +} + +.upload-progress-bar.is-processing { + background: linear-gradient(90deg, rgba(34, 197, 94, 0.85), rgba(52, 211, 153, 0.8), rgba(34, 197, 94, 0.85)); + animation-duration: 1.8s; +} + +@keyframes upload-progress { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + .audio-icon { display: flex; align-items: center; diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 51057a0..562b0ee 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -7,12 +7,14 @@ let canvasSettings = { width: 1920, height: 1080 }; canvas.width = canvasSettings.width; canvas.height = canvasSettings.height; const assets = new Map(); +let pendingUploads = []; const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); const audioControllers = new Map(); const pendingAudioUnlock = new Set(); const loopPlaybackState = new Map(); +const previewCache = new Map(); let drawPending = false; let zOrderDirty = true; let zOrderCache = []; @@ -31,6 +33,7 @@ const muteInput = document.getElementById('asset-muted'); const selectedZLabel = document.getElementById('asset-z-level'); const playbackSection = document.getElementById('playback-section'); const audioSection = document.getElementById('audio-section'); +const layoutSection = document.getElementById('layout-section'); const audioLoopInput = document.getElementById('asset-audio-loop'); const audioDelayInput = document.getElementById('asset-audio-delay'); const audioSpeedInput = document.getElementById('asset-audio-speed'); @@ -66,6 +69,40 @@ function debounce(fn, wait = 150) { }; } +function addPendingUpload(name) { + const pending = { + id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`, + name, + status: 'uploading', + createdAtMs: Date.now() + }; + pendingUploads.push(pending); + renderAssetList(); + return pending.id; +} + +function updatePendingUpload(id, updates = {}) { + const pending = pendingUploads.find((item) => item.id === id); + if (!pending) return; + Object.assign(pending, updates); + renderAssetList(); +} + +function removePendingUpload(id) { + const index = pendingUploads.findIndex((item) => item.id === id); + if (index === -1) return; + pendingUploads.splice(index, 1); + renderAssetList(); +} + +function resolvePendingUploadByName(name) { + if (!name) return; + const index = pendingUploads.findIndex((item) => item.name === name); + if (index === -1) return; + pendingUploads.splice(index, 1); + renderAssetList(); +} + function formatDurationLabel(durationMs) { const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); const seconds = totalSeconds % 60; @@ -228,6 +265,11 @@ function renderAssets(list) { function storeAsset(asset) { if (!asset) return; + const existing = assets.get(asset.id); + if (existing && existing.url !== asset.url) { + clearMedia(asset.id); + previewCache.delete(asset.id); + } asset.zIndex = Math.max(1, asset.zIndex ?? 1); const parsedCreatedAt = asset.createdAt ? new Date(asset.createdAt).getTime() : NaN; const hasCreatedAtMs = typeof asset.createdAtMs === 'number' && Number.isFinite(asset.createdAtMs); @@ -239,6 +281,7 @@ function storeAsset(asset) { if (!renderStates.has(asset.id)) { renderStates.set(asset.id, { ...asset }); } + resolvePendingUploadByName(asset.name); } function updateRenderState(asset) { @@ -615,6 +658,7 @@ function isDrawable(element) { function clearMedia(assetId) { mediaCache.delete(assetId); + previewCache.delete(assetId); const animated = animatedCache.get(assetId); if (animated) { animated.cancelled = true; @@ -717,6 +761,9 @@ function autoStartAudio(asset) { function ensureMedia(asset) { const cached = mediaCache.get(asset.id); + if (cached && cached.src !== asset.url) { + clearMedia(asset.id); + } if (cached && cached.src === asset.url) { applyMediaSettings(cached, asset); return cached; @@ -737,6 +784,7 @@ function ensureMedia(asset) { } const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); + element.crossOrigin = 'anonymous'; if (isVideoElement(element)) { element.loop = true; element.muted = asset.muted ?? true; @@ -867,7 +915,10 @@ function renderAssetList() { } list.innerHTML = ''; - if (!assets.size) { + const hasAssets = assets.size > 0; + const hasPending = pendingUploads.length > 0; + + if (!hasAssets && !hasPending) { selectedAssetId = null; if (assetInspector) { assetInspector.classList.add('hidden'); @@ -880,9 +931,14 @@ function renderAssetList() { } if (assetInspector) { - assetInspector.classList.remove('hidden'); + assetInspector.classList.toggle('hidden', !hasAssets); } + const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0)); + pendingItems.forEach((pending) => { + list.appendChild(createPendingListItem(pending)); + }); + const sortedAssets = getChronologicalAssets(); sortedAssets.forEach((asset) => { const li = document.createElement('li'); @@ -990,6 +1046,43 @@ function renderAssetList() { updateSelectedAssetControls(); } +function createPendingListItem(pending) { + const li = document.createElement('li'); + li.className = 'asset-item pending'; + + const row = document.createElement('div'); + row.className = 'asset-row'; + + const preview = document.createElement('div'); + preview.className = 'asset-preview pending-preview'; + preview.innerHTML = ''; + + const meta = document.createElement('div'); + meta.className = 'meta'; + const name = document.createElement('strong'); + name.textContent = pending?.name || 'Uploading asset'; + const details = document.createElement('small'); + details.textContent = pending.status === 'processing' ? 'Processing upload…' : 'Uploading…'; + meta.appendChild(name); + meta.appendChild(details); + + const progress = document.createElement('div'); + progress.className = 'upload-progress'; + const bar = document.createElement('div'); + bar.className = 'upload-progress-bar'; + if (pending.status === 'processing') { + bar.classList.add('is-processing'); + } + progress.appendChild(bar); + meta.appendChild(progress); + + row.appendChild(preview); + row.appendChild(meta); + li.appendChild(row); + + return li; +} + function createBadge(label, extraClass = '') { const badge = document.createElement('span'); badge.className = `badge ${extraClass}`.trim(); @@ -1021,25 +1114,125 @@ function createPreviewElement(asset) { icon.innerHTML = ''; return icon; } - if (isVideoAsset(asset)) { - const video = document.createElement('video'); - video.className = 'asset-preview'; - video.src = asset.url; - video.loop = true; - video.muted = true; - video.playsInline = true; - video.autoplay = true; - video.play().catch(() => { }); - return video; + if (isVideoAsset(asset) || isGifAsset(asset)) { + const still = document.createElement('div'); + still.className = 'asset-preview still'; + still.setAttribute('aria-label', asset.name || 'Asset preview'); + + const overlay = document.createElement('div'); + overlay.className = 'preview-overlay'; + overlay.innerHTML = ''; + still.appendChild(overlay); + + loadPreviewFrame(asset, still); + return still; } const img = document.createElement('img'); img.className = 'asset-preview'; img.src = asset.url; img.alt = asset.name || 'Asset preview'; + img.loading = 'lazy'; return img; } +function loadPreviewFrame(asset, element) { + if (!asset || !element) return; + const cached = previewCache.get(asset.id); + if (cached) { + applyPreviewFrame(element, cached); + return; + } + + const source = isVideoAsset(asset) + ? captureVideoFrame(asset) + : isGifAsset(asset) + ? captureGifFrame(asset) + : Promise.resolve(null); + + source + .then((dataUrl) => { + if (!dataUrl) { + return; + } + previewCache.set(asset.id, dataUrl); + applyPreviewFrame(element, dataUrl); + }) + .catch(() => { }); +} + +function applyPreviewFrame(element, dataUrl) { + if (!element || !dataUrl) return; + element.style.backgroundImage = `url(${dataUrl})`; + element.classList.add('has-image'); +} + +function captureVideoFrame(asset) { + return new Promise((resolve) => { + const video = document.createElement('video'); + video.crossOrigin = 'anonymous'; + video.preload = 'auto'; + video.muted = true; + video.playsInline = true; + video.src = asset.url; + + const cleanup = () => { + video.pause(); + video.removeAttribute('src'); + video.load(); + }; + + video.addEventListener('loadeddata', () => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth || asset.width || 0; + canvas.height = video.videoHeight || asset.height || 0; + if (!canvas.width || !canvas.height) { + cleanup(); + resolve(null); + return; + } + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + try { + const dataUrl = canvas.toDataURL('image/png'); + resolve(dataUrl); + } catch (err) { + resolve(null); + } + cleanup(); + }, { once: true }); + + video.addEventListener('error', () => { + cleanup(); + resolve(null); + }, { once: true }); + }); +} + +function captureGifFrame(asset) { + if (!('ImageDecoder' in window)) { + return Promise.resolve(null); + } + return fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) + .then((decoder) => decoder.decode({ frameIndex: 0 })) + .then(({ image }) => { + const canvas = document.createElement('canvas'); + canvas.width = image.displayWidth || asset.width || 0; + canvas.height = image.displayHeight || asset.height || 0; + const ctx2d = canvas.getContext('2d'); + ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height); + image.close?.(); + try { + return canvas.toDataURL('image/png'); + } catch (err) { + return null; + } + }) + .catch(() => null); +} + function getSelectedAsset() { return selectedAssetId ? assets.get(selectedAssetId) : null; } @@ -1068,6 +1261,15 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { aspectLockInput.checked = isAspectLocked(asset.id); aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); } + if (layoutSection) { + const hideLayout = isAudioAsset(asset); + layoutSection.classList.toggle('hidden', hideLayout); + const layoutControls = layoutSection.querySelectorAll('input, button'); + layoutControls.forEach((control) => { + control.disabled = hideLayout; + control.classList.toggle('disabled', hideLayout); + }); + } if (speedInput) { const percent = Math.round((asset.speed ?? 1) * 100); speedInput.value = Math.min(1000, Math.max(0, percent)); @@ -1435,6 +1637,8 @@ function uploadAsset(file = null) { } return; } + + const pendingId = addPendingUpload(selectedFile.name); const data = new FormData(); data.append('file', selectedFile); if (fileNameLabel) { @@ -1452,12 +1656,14 @@ function uploadAsset(file = null) { handleFileSelection(fileInput); } if (typeof showToast === 'function') { - showToast('Asset uploaded successfully.', 'success'); + showToast('Upload received. Processing asset...', 'success'); } + updatePendingUpload(pendingId, { status: 'processing' }); }).catch(() => { if (fileNameLabel) { fileNameLabel.textContent = 'Upload failed'; } + removePendingUpload(pendingId); if (typeof showToast === 'function') { showToast('Upload failed. Please try again with a supported file.', 'error'); } diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 9f7e42c..2fa8872 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -437,6 +437,9 @@ function autoStartAudio(asset) { function ensureMedia(asset) { const cached = mediaCache.get(asset.id); + if (cached && cached.src !== asset.url) { + clearMedia(asset.id); + } if (cached && cached.src === asset.url) { applyMediaSettings(cached, asset); return cached; @@ -457,6 +460,7 @@ function ensureMedia(asset) { } const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); + element.crossOrigin = 'anonymous'; if (isVideoElement(element)) { element.loop = true; element.muted = asset.muted ?? true; diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index d4703c7..334060e 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -71,7 +71,7 @@