diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java index d27a189..c55312c 100644 --- a/src/main/java/com/imgfloat/app/model/Asset.java +++ b/src/main/java/com/imgfloat/app/model/Asset.java @@ -5,6 +5,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; +import jakarta.persistence.PreUpdate; import java.time.Instant; import java.util.Locale; @@ -19,6 +20,9 @@ public class Asset { @Column(nullable = false) private String broadcaster; + @Column(nullable = false) + private String name; + @Column(columnDefinition = "TEXT") private String url; private double x; @@ -32,9 +36,10 @@ public class Asset { public Asset() { } - public Asset(String broadcaster, String url, double width, double height) { + public Asset(String broadcaster, String name, String url, double width, double height) { this.id = UUID.randomUUID().toString(); this.broadcaster = normalize(broadcaster); + this.name = name; this.url = url; this.width = width; this.height = height; @@ -46,6 +51,7 @@ public class Asset { } @PrePersist + @PreUpdate public void prepare() { if (this.id == null) { this.id = UUID.randomUUID().toString(); @@ -54,6 +60,9 @@ public class Asset { this.createdAt = Instant.now(); } this.broadcaster = normalize(broadcaster); + if (this.name == null || this.name.isBlank()) { + this.name = this.id; + } } public String getId() { @@ -68,6 +77,14 @@ public class Asset { this.broadcaster = normalize(broadcaster); } + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + public String getUrl() { return url; } diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 3f9be22..fd52405 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -75,9 +75,13 @@ public class ChannelDirectoryService { if (image == null) { return Optional.empty(); } + String name = Optional.ofNullable(file.getOriginalFilename()) + .map(filename -> filename.replaceAll("^.*[/\\\\]", "")) + .filter(s -> !s.isBlank()) + .orElse("Asset " + System.currentTimeMillis()); String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream"); String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes); - Asset asset = new Asset(channel.getBroadcaster(), dataUrl, image.getWidth(), image.getHeight()); + Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, image.getWidth(), image.getHeight()); assetRepository.save(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset)); return Optional.of(asset); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index a4b06a2..58694e3 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -102,6 +102,21 @@ body { border: 1px solid #1f2937; } +.panel.hidden { + display: none; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.panel-header h4 { + margin: 0; +} + .panel ul { list-style: none; padding: 0; @@ -128,6 +143,7 @@ body { background: #111827; border: 1px solid #1f2937; cursor: pointer; + gap: 12px; } .asset-item.selected { @@ -153,3 +169,49 @@ body { .asset-item.hidden { opacity: 0.6; } + +.asset-preview { + width: 64px; + height: 64px; + object-fit: contain; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; + flex-shrink: 0; +} + +.control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.control-grid label { + display: flex; + flex-direction: column; + gap: 6px; + color: #cbd5e1; +} + +.control-grid input[type="number"], +.control-grid input[type="range"] { + padding: 8px; + border-radius: 6px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; +} + +.control-actions { + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} + +.range-value { + color: #a5b4fc; + font-size: 12px; + margin-top: -4px; +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index ab1b696..815f825 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -5,8 +5,22 @@ canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; const assets = new Map(); const imageCache = new Map(); +const renderStates = new Map(); let selectedAssetId = null; let dragState = null; +let animationFrameId = null; + +const controlsPanel = document.getElementById('asset-controls'); +const widthInput = document.getElementById('asset-width'); +const heightInput = document.getElementById('asset-height'); +const rotationInput = document.getElementById('asset-rotation'); +const rotationDisplay = document.getElementById('rotation-display'); +const selectedAssetName = document.getElementById('selected-asset-name'); +const selectedAssetMeta = document.getElementById('selected-asset-meta'); + +if (rotationInput) { + rotationInput.addEventListener('input', updateRotationDisplay); +} function connect() { const socket = new SockJS('/ws'); @@ -33,6 +47,7 @@ function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); imageCache.delete(event.assetId); + renderStates.delete(event.assetId); if (selectedAssetId === event.assetId) { selectedAssetId = null; } @@ -54,33 +69,68 @@ function draw() { } function drawAsset(asset) { + const renderState = smoothState(asset); ctx.save(); - ctx.translate(asset.x, asset.y); - ctx.rotate(asset.rotation * Math.PI / 180); + ctx.translate(renderState.x, renderState.y); + ctx.rotate(renderState.rotation * Math.PI / 180); const image = ensureImage(asset); if (image?.complete) { ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; - ctx.drawImage(image, 0, 0, asset.width, asset.height); + ctx.drawImage(image, 0, 0, renderState.width, renderState.height); } else { ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; ctx.fillStyle = 'rgba(124, 58, 237, 0.35)'; - ctx.fillRect(0, 0, asset.width, asset.height); + ctx.fillRect(0, 0, renderState.width, renderState.height); } if (asset.hidden) { ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; - ctx.fillRect(0, 0, asset.width, asset.height); + ctx.fillRect(0, 0, renderState.width, renderState.height); } ctx.globalAlpha = 1; ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)'; ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); - ctx.strokeRect(0, 0, asset.width, asset.height); + ctx.strokeRect(0, 0, renderState.width, renderState.height); ctx.restore(); } +function smoothState(asset) { + const previous = renderStates.get(asset.id) || { ...asset }; + const factor = dragState && dragState.assetId === asset.id ? 0.5 : 0.18; + const next = { + x: lerp(previous.x, asset.x, factor), + y: lerp(previous.y, asset.y, factor), + width: lerp(previous.width, asset.width, factor), + height: lerp(previous.height, asset.height, factor), + rotation: smoothAngle(previous.rotation, asset.rotation, factor) + }; + renderStates.set(asset.id, next); + return next; +} + +function smoothAngle(current, target, factor) { + let delta = ((target - current + 180) % 360) - 180; + return current + delta * factor; +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} + +function startRenderLoop() { + if (animationFrameId) { + return; + } + const tick = () => { + draw(); + animationFrameId = requestAnimationFrame(tick); + }; + animationFrameId = requestAnimationFrame(tick); +} + function ensureImage(asset) { const cached = imageCache.get(asset.id); if (cached && cached.src === asset.url) { @@ -102,6 +152,7 @@ function renderAssetList() { const empty = document.createElement('li'); empty.textContent = 'No assets yet. Upload to get started.'; list.appendChild(empty); + updateSelectedAssetControls(); return; } @@ -118,10 +169,15 @@ function renderAssetList() { li.classList.add('hidden'); } + const preview = document.createElement('img'); + preview.className = 'asset-preview'; + preview.src = asset.url; + preview.alt = asset.name || 'Asset preview'; + const meta = document.createElement('div'); meta.className = 'meta'; const name = document.createElement('strong'); - name.textContent = `Asset ${asset.id.slice(0, 6)}`; + name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; const details = document.createElement('small'); details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; meta.appendChild(name); @@ -154,13 +210,76 @@ function renderAssetList() { li.addEventListener('click', () => { selectedAssetId = asset.id; + renderStates.set(asset.id, { ...asset }); drawAndList(); }); + li.appendChild(preview); li.appendChild(meta); li.appendChild(actions); list.appendChild(li); }); + + updateSelectedAssetControls(); +} + +function getSelectedAsset() { + return selectedAssetId ? assets.get(selectedAssetId) : null; +} + +function updateSelectedAssetControls() { + if (!controlsPanel) { + return; + } + const asset = getSelectedAsset(); + if (!asset) { + controlsPanel.classList.add('hidden'); + return; + } + + controlsPanel.classList.remove('hidden'); + selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; + selectedAssetMeta.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; + + if (widthInput) widthInput.value = Math.round(asset.width); + if (heightInput) heightInput.value = Math.round(asset.height); + if (rotationInput) { + rotationInput.value = Math.round(asset.rotation); + updateRotationDisplay(); + } +} + +function applyTransformFromInputs() { + const asset = getSelectedAsset(); + if (!asset) return; + const nextWidth = parseFloat(widthInput?.value) || asset.width; + const nextHeight = parseFloat(heightInput?.value) || asset.height; + const nextRotation = parseFloat(rotationInput?.value) || 0; + + asset.width = Math.max(10, nextWidth); + asset.height = Math.max(10, nextHeight); + asset.rotation = nextRotation; + renderStates.set(asset.id, { ...asset }); + persistTransform(asset); + drawAndList(); +} + +function nudgeRotation(delta) { + const asset = getSelectedAsset(); + if (!asset) return; + const next = (asset.rotation || 0) + delta; + if (rotationInput) rotationInput.value = next; + asset.rotation = next; + renderStates.set(asset.id, { ...asset }); + updateRotationDisplay(); + persistTransform(asset); +} + +function updateRotationDisplay() { + if (rotationDisplay && rotationInput) { + const value = Math.round(parseFloat(rotationInput.value || '0')); + rotationDisplay.textContent = `${value}°`; + } } function updateVisibility(asset, hidden) { @@ -178,6 +297,7 @@ function deleteAsset(asset) { fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => { assets.delete(asset.id); imageCache.delete(asset.id); + renderStates.delete(asset.id); if (selectedAssetId === asset.id) { selectedAssetId = null; } @@ -296,4 +416,5 @@ window.addEventListener('resize', () => { draw(); }); +startRenderLoop(); connect(); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index e523a2e..aae8db3 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -4,6 +4,8 @@ canvas.width = window.innerWidth; canvas.height = window.innerHeight; const assets = new Map(); const imageCache = new Map(); +const renderStates = new Map(); +let animationFrameId = null; function connect() { const socket = new SockJS('/ws'); @@ -26,12 +28,14 @@ function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); imageCache.delete(event.assetId); + renderStates.delete(event.assetId); } else if (event.payload && !event.payload.hidden) { assets.set(event.payload.id, event.payload); ensureImage(event.payload); } else if (event.payload && event.payload.hidden) { assets.delete(event.payload.id); imageCache.delete(event.payload.id); + renderStates.delete(event.payload.id); } draw(); } @@ -42,18 +46,42 @@ function draw() { } function drawAsset(asset) { + const renderState = smoothState(asset); ctx.save(); - ctx.translate(asset.x, asset.y); - ctx.rotate(asset.rotation * Math.PI / 180); + ctx.translate(renderState.x, renderState.y); + ctx.rotate(renderState.rotation * Math.PI / 180); const image = ensureImage(asset); if (image?.complete) { - ctx.drawImage(image, 0, 0, asset.width, asset.height); + ctx.drawImage(image, 0, 0, renderState.width, renderState.height); } ctx.restore(); } +function smoothState(asset) { + const previous = renderStates.get(asset.id) || { ...asset }; + const factor = 0.15; + const next = { + x: lerp(previous.x, asset.x, factor), + y: lerp(previous.y, asset.y, factor), + width: lerp(previous.width, asset.width, factor), + height: lerp(previous.height, asset.height, factor), + rotation: smoothAngle(previous.rotation, asset.rotation, factor) + }; + renderStates.set(asset.id, next); + return next; +} + +function smoothAngle(current, target, factor) { + const delta = ((target - current + 180) % 360) - 180; + return current + delta * factor; +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} + function ensureImage(asset) { const cached = imageCache.get(asset.id); if (cached && cached.src === asset.url) { @@ -67,10 +95,22 @@ function ensureImage(asset) { return image; } +function startRenderLoop() { + if (animationFrameId) { + return; + } + const tick = () => { + draw(); + animationFrameId = requestAnimationFrame(tick); + }; + animationFrameId = requestAnimationFrame(tick); +} + window.addEventListener('resize', () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; draw(); }); +startRenderLoop(); connect(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 058c8d6..90e39b0 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -25,6 +25,32 @@