diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index 853a83a..5f5ffc8 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -1,7 +1,7 @@ package com.imgfloat.app.controller; import com.imgfloat.app.model.AdminRequest; -import com.imgfloat.app.model.Asset; +import com.imgfloat.app.model.AssetView; import com.imgfloat.app.model.CanvasSettingsRequest; import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TwitchUserProfile; @@ -94,8 +94,8 @@ public class ChannelApiController { } @GetMapping("/assets") - public Collection listAssets(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken authentication) { + public Collection listAssets(@PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); if (!channelDirectoryService.isBroadcaster(broadcaster, login) && !channelDirectoryService.isAdmin(broadcaster, login)) { @@ -105,8 +105,8 @@ public class ChannelApiController { } @GetMapping("/assets/visible") - public Collection listVisible(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken authentication) { + public Collection listVisible(@PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); if (!channelDirectoryService.isBroadcaster(broadcaster, login)) { throw new ResponseStatusException(FORBIDDEN, "Only broadcaster can load public overlay"); @@ -132,7 +132,7 @@ public class ChannelApiController { } @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity createAsset(@PathVariable("broadcaster") String broadcaster, + public ResponseEntity createAsset(@PathVariable("broadcaster") String broadcaster, @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); @@ -150,10 +150,10 @@ public class ChannelApiController { } @PutMapping("/assets/{assetId}/transform") - public ResponseEntity transform(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @Valid @RequestBody TransformRequest request, - OAuth2AuthenticationToken authentication) { + public ResponseEntity transform(@PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @Valid @RequestBody TransformRequest request, + OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureAuthorized(broadcaster, login); return channelDirectoryService.updateTransform(broadcaster, assetId, request) @@ -162,10 +162,10 @@ public class ChannelApiController { } @PutMapping("/assets/{assetId}/visibility") - public ResponseEntity visibility(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @RequestBody VisibilityRequest request, - OAuth2AuthenticationToken authentication) { + public ResponseEntity visibility(@PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestBody VisibilityRequest request, + OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureAuthorized(broadcaster, login); return channelDirectoryService.updateVisibility(broadcaster, assetId, request) @@ -173,6 +173,19 @@ public class ChannelApiController { .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); } + @GetMapping("/assets/{assetId}/content") + public ResponseEntity getAssetContent(@PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken authentication) { + String login = TwitchUser.from(authentication).login(); + ensureAuthorized(broadcaster, login); + return channelDirectoryService.getAssetContent(broadcaster, assetId) + .map(content -> ResponseEntity.ok() + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes())) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + } + @DeleteMapping("/assets/{assetId}") public ResponseEntity delete(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java index 684cbd4..cde12a4 100644 --- a/src/main/java/com/imgfloat/app/model/Asset.java +++ b/src/main/java/com/imgfloat/app/model/Asset.java @@ -53,7 +53,7 @@ public class Asset { this.rotation = 0; this.speed = 1.0; this.muted = false; - this.zIndex = 0; + this.zIndex = 1; this.hidden = false; this.createdAt = Instant.now(); } @@ -71,14 +71,14 @@ public class Asset { if (this.name == null || this.name.isBlank()) { this.name = this.id; } - if (this.speed == null || this.speed <= 0) { + if (this.speed == null) { this.speed = 1.0; } if (this.muted == null) { this.muted = Boolean.FALSE; } - if (this.zIndex == null) { - this.zIndex = 0; + if (this.zIndex == null || this.zIndex < 1) { + this.zIndex = 1; } } @@ -203,11 +203,11 @@ public class Asset { } public Integer getZIndex() { - return zIndex == null ? 0 : zIndex; + return zIndex == null ? 1 : Math.max(1, zIndex); } public void setZIndex(Integer zIndex) { - this.zIndex = zIndex; + this.zIndex = zIndex == null ? null : Math.max(1, zIndex); } private static String normalize(String value) { diff --git a/src/main/java/com/imgfloat/app/model/AssetEvent.java b/src/main/java/com/imgfloat/app/model/AssetEvent.java index 2e888b1..81219df 100644 --- a/src/main/java/com/imgfloat/app/model/AssetEvent.java +++ b/src/main/java/com/imgfloat/app/model/AssetEvent.java @@ -10,33 +10,33 @@ public class AssetEvent { private Type type; private String channel; - private Asset payload; + private AssetView payload; private String assetId; - public static AssetEvent created(String channel, Asset asset) { + public static AssetEvent created(String channel, AssetView asset) { AssetEvent event = new AssetEvent(); event.type = Type.CREATED; event.channel = channel; event.payload = asset; - event.assetId = asset.getId(); + event.assetId = asset.id(); return event; } - public static AssetEvent updated(String channel, Asset asset) { + public static AssetEvent updated(String channel, AssetView asset) { AssetEvent event = new AssetEvent(); event.type = Type.UPDATED; event.channel = channel; event.payload = asset; - event.assetId = asset.getId(); + event.assetId = asset.id(); return event; } - public static AssetEvent visibility(String channel, Asset asset) { + public static AssetEvent visibility(String channel, AssetView asset) { AssetEvent event = new AssetEvent(); event.type = Type.VISIBILITY; event.channel = channel; event.payload = asset; - event.assetId = asset.getId(); + event.assetId = asset.id(); return event; } @@ -56,7 +56,7 @@ public class AssetEvent { return channel; } - public Asset getPayload() { + public AssetView getPayload() { return payload; } diff --git a/src/main/java/com/imgfloat/app/model/AssetView.java b/src/main/java/com/imgfloat/app/model/AssetView.java new file mode 100644 index 0000000..2a8116e --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/AssetView.java @@ -0,0 +1,43 @@ +package com.imgfloat.app.model; + +import java.time.Instant; + +public record AssetView( + String id, + String broadcaster, + String name, + String url, + double x, + double y, + double width, + double height, + double rotation, + Double speed, + Boolean muted, + String mediaType, + String originalMediaType, + Integer zIndex, + boolean hidden, + Instant createdAt +) { + public static AssetView from(String broadcaster, Asset asset) { + return new AssetView( + asset.getId(), + asset.getBroadcaster(), + asset.getName(), + "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", + asset.getX(), + asset.getY(), + asset.getWidth(), + asset.getHeight(), + asset.getRotation(), + asset.getSpeed(), + asset.isMuted(), + asset.getMediaType(), + asset.getOriginalMediaType(), + asset.getZIndex(), + asset.isHidden(), + asset.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 13a2744..bc4086b 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -3,6 +3,7 @@ package com.imgfloat.app.service; import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.AssetEvent; import com.imgfloat.app.model.Channel; +import com.imgfloat.app.model.AssetView; import com.imgfloat.app.model.CanvasSettingsRequest; import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.VisibilityRequest; @@ -85,12 +86,14 @@ public class ChannelDirectoryService { return removed; } - public Collection getAssetsForAdmin(String broadcaster) { - return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster))); + public Collection getAssetsForAdmin(String broadcaster) { + String normalized = normalize(broadcaster); + return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized)); } - public Collection getVisibleAssets(String broadcaster) { - return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster))); + public Collection getVisibleAssets(String broadcaster) { + String normalized = normalize(broadcaster); + return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster))); } public CanvasSettingsRequest getCanvasSettings(String broadcaster) { @@ -106,7 +109,7 @@ public class ChannelDirectoryService { return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); } - public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { + public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { Channel channel = getOrCreateChannel(broadcaster); byte[] bytes = file.getBytes(); String mediaType = detectMediaType(file, bytes); @@ -132,11 +135,12 @@ public class ChannelDirectoryService { asset.setZIndex(nextZIndex(channel.getBroadcaster())); assetRepository.save(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset)); - return Optional.of(asset); + AssetView view = AssetView.from(channel.getBroadcaster(), asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); + return Optional.of(view); } - public Optional updateTransform(String broadcaster, String assetId, TransformRequest request) { + public Optional updateTransform(String broadcaster, String assetId, TransformRequest request) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) .filter(asset -> normalized.equals(asset.getBroadcaster())) @@ -149,27 +153,29 @@ public class ChannelDirectoryService { if (request.getZIndex() != null) { asset.setZIndex(request.getZIndex()); } - if (request.getSpeed() != null && request.getSpeed() > 0) { + if (request.getSpeed() != null && request.getSpeed() >= 0) { asset.setSpeed(request.getSpeed()); } if (request.getMuted() != null && asset.isVideo()) { asset.setMuted(request.getMuted()); } assetRepository.save(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset)); - return asset; + AssetView view = AssetView.from(normalized, asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + return view; }); } - public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { + public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) .filter(asset -> normalized.equals(asset.getBroadcaster())) .map(asset -> { asset.setHidden(request.isHidden()); assetRepository.save(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset)); - return asset; + AssetView view = AssetView.from(normalized, asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, view)); + return view; }); } @@ -185,6 +191,13 @@ public class ChannelDirectoryService { .orElse(false); } + public Optional getAssetContent(String broadcaster, String assetId) { + String normalized = normalize(broadcaster); + return assetRepository.findById(assetId) + .filter(asset -> normalized.equals(asset.getBroadcaster())) + .flatMap(this::decodeAssetData); + } + public boolean isBroadcaster(String broadcaster, String username) { return broadcaster != null && broadcaster.equalsIgnoreCase(username); } @@ -215,13 +228,36 @@ public class ChannelDirectoryService { return value == null ? null : value.toLowerCase(); } - private List sortByZIndex(Collection assets) { + private List sortAndMapAssets(String broadcaster, Collection assets) { return assets.stream() .sorted(Comparator.comparingInt(Asset::getZIndex) .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) + .map(asset -> AssetView.from(broadcaster, asset)) .toList(); } + private Optional decodeAssetData(Asset asset) { + String url = asset.getUrl(); + if (url == null || !url.startsWith("data:")) { + return Optional.empty(); + } + int commaIndex = url.indexOf(','); + if (commaIndex < 0) { + return Optional.empty(); + } + String metadata = url.substring(5, commaIndex); + String[] parts = metadata.split(";", 2); + String mediaType = parts.length > 0 && !parts[0].isBlank() ? parts[0] : "application/octet-stream"; + String encoded = url.substring(commaIndex + 1); + try { + byte[] bytes = Base64.getDecoder().decode(encoded); + return Optional.of(new AssetContent(bytes, mediaType)); + } catch (IllegalArgumentException e) { + logger.warn("Unable to decode asset data for {}", asset.getId(), e); + return Optional.empty(); + } + } + private int nextZIndex(String broadcaster) { return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() .mapToInt(Asset::getZIndex) @@ -426,6 +462,8 @@ public class ChannelDirectoryService { return new Dimension(640, 360); } + public record AssetContent(byte[] bytes, String mediaType) { } + private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { } private record GifFrame(BufferedImage image, int delayMs) { } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index f04e496..21668eb 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1,3 +1,11 @@ +* { + box-sizing: border-box; +} + +.hidden { + display: none !important; +} + body { font-family: Arial, sans-serif; background: #0f172a; @@ -565,14 +573,14 @@ body { .asset-item { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + align-items: stretch; padding: 10px 12px; - border-radius: 8px; + border-radius: 10px; background: #111827; border: 1px solid #1f2937; cursor: pointer; - gap: 12px; + gap: 10px; } .asset-item.selected { @@ -580,6 +588,17 @@ body { box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.6); } +.asset-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.asset-row .meta { + flex: 1; +} + .asset-item .meta { display: flex; flex-direction: column; @@ -595,6 +614,10 @@ body { gap: 8px; } +.asset-detail { + margin-top: 4px; +} + .icon-button { display: inline-flex; align-items: center; @@ -641,6 +664,17 @@ body { flex-shrink: 0; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + .control-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); @@ -653,6 +687,10 @@ body { margin-top: 8px; } +.control-grid.three-col { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + .control-grid label { display: flex; flex-direction: column; @@ -669,6 +707,15 @@ body { color: #e2e8f0; } +.range-meta { + display: flex; + justify-content: space-between; + color: #94a3b8; + font-size: 12px; + margin-top: -6px; + padding: 0 2px; +} + .number-input { position: relative; padding-right: 48px !important; @@ -697,6 +744,10 @@ body { flex-wrap: wrap; } +.control-actions.compact button { + padding: 10px 12px; +} + .control-actions.filled { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(124, 58, 237, 0.22); diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 6fbdcdc..ceb278d 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -10,9 +10,11 @@ const assets = new Map(); const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); +let drawPending = false; +let zOrderDirty = true; +let zOrderCache = []; let selectedAssetId = null; let interactionState = null; -let animationFrameId = null; let lastSizeInputChanged = null; const HANDLE_SIZE = 10; const ROTATE_HANDLE_OFFSET = 32; @@ -24,17 +26,18 @@ const aspectLockInput = document.getElementById('maintain-aspect'); const speedInput = document.getElementById('asset-speed'); const muteInput = document.getElementById('asset-muted'); const selectedAssetName = document.getElementById('selected-asset-name'); -const selectedAssetMeta = document.getElementById('selected-asset-meta'); const selectedZLabel = document.getElementById('asset-z-level'); const selectedTypeLabel = document.getElementById('asset-type-label'); const selectedVisibilityBadge = document.getElementById('selected-asset-visibility'); const selectedToggleBtn = document.getElementById('selected-asset-toggle'); const selectedDeleteBtn = document.getElementById('selected-asset-delete'); +const playbackSection = document.getElementById('playback-section'); +const controlsPlaceholder = document.getElementById('asset-controls-placeholder'); const aspectLockState = new Map(); if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); -if (speedInput) speedInput.addEventListener('change', updatePlaybackFromInputs); +if (speedInput) speedInput.addEventListener('input', updatePlaybackFromInputs); if (muteInput) muteInput.addEventListener('change', updateMuteFromInput); if (selectedToggleBtn) selectedToggleBtn.addEventListener('click', (event) => { event.stopPropagation(); @@ -97,50 +100,90 @@ function resizeCanvas() { overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`; overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`; } - draw(); + requestDraw(); } function renderAssets(list) { - list.forEach((asset) => assets.set(asset.id, asset)); + list.forEach(storeAsset); drawAndList(); } +function storeAsset(asset) { + if (!asset) return; + asset.zIndex = Math.max(1, asset.zIndex ?? 1); + if (asset.createdAt && typeof asset.createdAtMs === 'undefined') { + asset.createdAtMs = new Date(asset.createdAt).getTime(); + } + assets.set(asset.id, asset); + zOrderDirty = true; + if (!renderStates.has(asset.id)) { + renderStates.set(asset.id, { ...asset }); + } +} + +function updateRenderState(asset) { + if (!asset) return; + const state = renderStates.get(asset.id) || {}; + state.x = asset.x; + state.y = asset.y; + state.width = asset.width; + state.height = asset.height; + state.rotation = asset.rotation; + renderStates.set(asset.id, state); +} + function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); + zOrderDirty = true; clearMedia(event.assetId); renderStates.delete(event.assetId); if (selectedAssetId === event.assetId) { selectedAssetId = null; } } else if (event.payload) { - assets.set(event.payload.id, event.payload); + storeAsset(event.payload); ensureMedia(event.payload); } drawAndList(); } function drawAndList() { - draw(); + requestDraw(); renderAssetList(); } +function requestDraw() { + if (drawPending) { + return; + } + drawPending = true; + requestAnimationFrame(() => { + drawPending = false; + draw(); + }); +} + function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); getZOrderedAssets().forEach((asset) => drawAsset(asset)); } function getZOrderedAssets() { - return Array.from(assets.values()).sort(zComparator); + if (zOrderDirty) { + zOrderCache = Array.from(assets.values()).sort(zComparator); + zOrderDirty = false; + } + return zOrderCache; } function zComparator(a, b) { - const aZ = a?.zIndex ?? 0; - const bZ = b?.zIndex ?? 0; + const aZ = a?.zIndex ?? 1; + const bZ = b?.zIndex ?? 1; if (aZ !== bZ) { return aZ - bZ; } - return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0); + return (a?.createdAtMs || 0) - (b?.createdAtMs || 0); } function drawAsset(asset) { @@ -181,16 +224,14 @@ function drawAsset(asset) { function smoothState(asset) { const previous = renderStates.get(asset.id) || { ...asset }; - const factor = interactionState && interactionState.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; + const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18; + previous.x = lerp(previous.x, asset.x, factor); + previous.y = lerp(previous.y, asset.y, factor); + previous.width = lerp(previous.width, asset.width, factor); + previous.height = lerp(previous.height, asset.height, factor); + previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor); + renderStates.set(asset.id, previous); + return previous; } function smoothAngle(current, target, factor) { @@ -373,8 +414,8 @@ function resizeFromHandle(state, point) { asset.y = basis.y + shift.y; asset.width = nextWidth; asset.height = nextHeight; - renderStates.set(asset.id, { ...asset }); - draw(); + updateRenderState(asset); + requestDraw(); } function updateHoverCursor(point) { @@ -390,19 +431,9 @@ function updateHoverCursor(point) { canvas.style.cursor = hit ? 'move' : 'default'; } -function startRenderLoop() { - if (animationFrameId) { - return; - } - const tick = () => { - draw(); - animationFrameId = requestAnimationFrame(tick); - }; - animationFrameId = requestAnimationFrame(tick); -} - function isVideoAsset(asset) { - return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/'); + const type = asset?.mediaType || asset?.originalMediaType || ''; + return type.startsWith('video/'); } function isVideoElement(element) { @@ -419,7 +450,7 @@ function getDisplayMediaType(asset) { } function isGifAsset(asset) { - return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif'); + return asset?.mediaType?.toLowerCase() === 'image/gif'; } function isDrawable(element) { @@ -471,12 +502,17 @@ function ensureMedia(asset) { element.muted = asset.muted ?? true; element.playsInline = true; element.autoplay = true; - element.onloadeddata = draw; + element.onloadeddata = requestDraw; element.src = asset.url; - element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1; - element.play().catch(() => {}); + const playback = asset.speed ?? 1; + element.playbackRate = Math.max(playback, 0.01); + if (playback === 0) { + element.pause(); + } else { + element.play().catch(() => {}); + } } else { - element.onload = draw; + element.onload = requestDraw; element.src = asset.url; } mediaCache.set(asset.id, element); @@ -537,7 +573,7 @@ function scheduleNextFrame(controller) { createImageBitmap(image) .then((bitmap) => { controller.bitmap = bitmap; - draw(); + requestDraw(); }) .finally(() => image.close?.()); @@ -564,24 +600,34 @@ function applyMediaSettings(element, asset) { if (!isVideoElement(element)) { return; } - const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1; - if (element.playbackRate !== nextSpeed) { - element.playbackRate = nextSpeed; + const nextSpeed = asset.speed ?? 1; + const effectiveSpeed = Math.max(nextSpeed, 0.01); + if (element.playbackRate !== effectiveSpeed) { + element.playbackRate = effectiveSpeed; } const shouldMute = asset.muted ?? true; if (element.muted !== shouldMute) { element.muted = shouldMute; } - if (element.paused) { + if (nextSpeed === 0) { + element.pause(); + } else if (element.paused) { element.play().catch(() => {}); } } function renderAssetList() { const list = document.getElementById('asset-list'); + if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { + controlsPlaceholder.appendChild(controlsPanel); + } + if (controlsPanel) { + controlsPanel.classList.add('hidden'); + } list.innerHTML = ''; if (!assets.size) { + selectedAssetId = null; const empty = document.createElement('li'); empty.textContent = 'No assets yet. Upload to get started.'; list.appendChild(empty); @@ -600,6 +646,9 @@ function renderAssetList() { li.classList.add('hidden'); } + const row = document.createElement('div'); + row.className = 'asset-row'; + const preview = createPreviewElement(asset); const meta = document.createElement('div'); @@ -607,7 +656,7 @@ function renderAssetList() { const name = document.createElement('strong'); name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; const details = document.createElement('small'); - details.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; + details.textContent = `Z ${asset.zIndex ?? 1} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; meta.appendChild(name); meta.appendChild(details); @@ -617,7 +666,8 @@ function renderAssetList() { const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'ghost icon-button'; - toggleBtn.innerHTML = `${asset.hidden ? 'Show' : 'Hide'}`; + toggleBtn.innerHTML = ``; + toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset'; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); selectedAssetId = asset.id; @@ -627,7 +677,8 @@ function renderAssetList() { const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'ghost danger icon-button'; - deleteBtn.innerHTML = 'Delete'; + deleteBtn.innerHTML = ''; + deleteBtn.title = 'Delete asset'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); deleteAsset(asset); @@ -636,19 +687,29 @@ function renderAssetList() { actions.appendChild(toggleBtn); actions.appendChild(deleteBtn); + row.appendChild(preview); + row.appendChild(meta); + row.appendChild(actions); + li.addEventListener('click', () => { selectedAssetId = asset.id; - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); drawAndList(); }); - li.appendChild(preview); - li.appendChild(meta); - li.appendChild(actions); + li.appendChild(row); + + if (asset.id === selectedAssetId && controlsPanel) { + controlsPanel.classList.remove('hidden'); + const detail = document.createElement('div'); + detail.className = 'asset-detail'; + detail.appendChild(controlsPanel); + li.appendChild(detail); + updateSelectedAssetControls(asset); + } + list.appendChild(li); }); - - updateSelectedAssetControls(); } function createPreviewElement(asset) { @@ -675,22 +736,17 @@ function getSelectedAsset() { return selectedAssetId ? assets.get(selectedAssetId) : null; } -function updateSelectedAssetControls() { - if (!controlsPanel) { - return; - } - const asset = getSelectedAsset(); - if (!asset) { - controlsPanel.classList.add('hidden'); +function updateSelectedAssetControls(asset = getSelectedAsset()) { + if (!controlsPanel || !asset) { + if (controlsPanel) controlsPanel.classList.add('hidden'); return; } controlsPanel.classList.remove('hidden'); lastSizeInputChanged = null; selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; - selectedAssetMeta.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; if (selectedZLabel) { - selectedZLabel.textContent = asset.zIndex ?? 0; + selectedZLabel.textContent = asset.zIndex ?? 1; } if (selectedTypeLabel) { selectedTypeLabel.textContent = getDisplayMediaType(asset); @@ -700,8 +756,11 @@ function updateSelectedAssetControls() { selectedVisibilityBadge.classList.toggle('danger', !!asset.hidden); } if (selectedToggleBtn) { - selectedToggleBtn.querySelector('.label').textContent = asset.hidden ? 'Show' : 'Hide'; - selectedToggleBtn.querySelector('.icon').textContent = asset.hidden ? '👁️' : '🙈'; + const icon = selectedToggleBtn.querySelector('i'); + if (icon) { + icon.className = `fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}`; + } + selectedToggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset'; } if (widthInput) widthInput.value = Math.round(asset.width); @@ -711,7 +770,13 @@ function updateSelectedAssetControls() { aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); } if (speedInput) { - speedInput.value = Math.round((asset.speed && asset.speed > 0 ? asset.speed : 1) * 100); + const percent = Math.round((asset.speed ?? 1) * 100); + speedInput.value = Math.min(1000, Math.max(0, percent)); + } + if (playbackSection) { + const shouldShowPlayback = isVideoAsset(asset); + playbackSection.classList.toggle('hidden', !shouldShowPlayback); + speedInput?.classList?.toggle('disabled', !shouldShowPlayback); } if (muteInput) { muteInput.checked = !!asset.muted; @@ -740,18 +805,22 @@ function applyTransformFromInputs() { asset.width = Math.max(10, nextWidth); asset.height = Math.max(10, nextHeight); - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); persistTransform(asset); drawAndList(); } function updatePlaybackFromInputs() { const asset = getSelectedAsset(); - if (!asset) return; - const percent = Math.max(10, Math.min(400, parseFloat(speedInput?.value) || 100)); + if (!asset || !isVideoAsset(asset)) return; + const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100)); asset.speed = percent / 100; - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); persistTransform(asset); + const media = mediaCache.get(asset.id); + if (media) { + applyMediaSettings(media, asset); + } drawAndList(); } @@ -759,7 +828,7 @@ function updateMuteFromInput() { const asset = getSelectedAsset(); if (!asset || !isVideoAsset(asset)) return; asset.muted = !!muteInput?.checked; - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); persistTransform(asset); const media = mediaCache.get(asset.id); if (media) { @@ -773,7 +842,7 @@ function nudgeRotation(delta) { if (!asset) return; const next = (asset.rotation || 0) + delta; asset.rotation = next; - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); persistTransform(asset); drawAndList(); } @@ -785,7 +854,7 @@ function recenterSelectedAsset() { const centerY = (canvas.height - asset.height) / 2; asset.x = centerX; asset.y = centerY; - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); persistTransform(asset); drawAndList(); } @@ -829,13 +898,15 @@ function sendToBack() { function applyZOrder(ordered) { const changed = []; ordered.forEach((item, index) => { - if ((item.zIndex ?? 0) !== index) { - item.zIndex = index; + const nextIndex = index + 1; + if ((item.zIndex ?? 1) !== nextIndex) { + item.zIndex = nextIndex; changed.push(item); } assets.set(item.id, item); - renderStates.set(item.id, { ...item }); + updateRenderState(item); }); + zOrderDirty = true; changed.forEach((item) => persistTransform(item, true)); drawAndList(); } @@ -891,7 +962,8 @@ function updateVisibility(asset, hidden) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hidden }) }).then((r) => r.json()).then((updated) => { - assets.set(updated.id, updated); + storeAsset(updated); + updateRenderState(updated); drawAndList(); }); } @@ -901,6 +973,7 @@ function deleteAsset(asset) { assets.delete(asset.id); mediaCache.delete(asset.id); renderStates.delete(asset.id); + zOrderDirty = true; if (selectedAssetId === asset.id) { selectedAssetId = null; } @@ -953,6 +1026,7 @@ function findAssetAtPoint(x, y) { } function persistTransform(asset, silent = false) { + asset.zIndex = Math.max(1, asset.zIndex ?? 1); fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -967,7 +1041,8 @@ function persistTransform(asset, silent = false) { zIndex: asset.zIndex }) }).then((r) => r.json()).then((updated) => { - assets.set(updated.id, updated); + storeAsset(updated); + updateRenderState(updated); if (!silent) { drawAndList(); } @@ -1001,7 +1076,7 @@ canvas.addEventListener('mousedown', (event) => { const hit = findAssetAtPoint(point.x, point.y); if (hit) { selectedAssetId = hit.id; - renderStates.set(hit.id, { ...hit }); + updateRenderState(hit); interactionState = { mode: 'move', assetId: hit.id, @@ -1033,18 +1108,18 @@ canvas.addEventListener('mousemove', (event) => { if (interactionState.mode === 'move') { asset.x = point.x - interactionState.offsetX; asset.y = point.y - interactionState.offsetY; - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); canvas.style.cursor = 'grabbing'; - draw(); + requestDraw(); } else if (interactionState.mode === 'resize') { resizeFromHandle(interactionState, point); canvas.style.cursor = cursorForHandle(interactionState.handle); } else if (interactionState.mode === 'rotate') { const angle = angleFromCenter(asset, point); asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); - renderStates.set(asset.id, { ...asset }); + updateRenderState(asset); canvas.style.cursor = 'grabbing'; - draw(); + requestDraw(); } }); @@ -1070,6 +1145,5 @@ window.addEventListener('resize', () => { fetchCanvasSettings().finally(() => { resizeCanvas(); - startRenderLoop(); connect(); }); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 9d01f7e..af1d8ed 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -22,7 +22,10 @@ function connect() { } function renderAssets(list) { - list.forEach(asset => assets.set(asset.id, asset)); + list.forEach(asset => { + asset.zIndex = Math.max(1, asset.zIndex ?? 1); + assets.set(asset.id, asset); + }); draw(); } @@ -55,8 +58,9 @@ function handleEvent(event) { clearMedia(event.assetId); renderStates.delete(event.assetId); } else if (event.payload && !event.payload.hidden) { - assets.set(event.payload.id, event.payload); - ensureMedia(event.payload); + const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) }; + assets.set(payload.id, payload); + ensureMedia(payload); } else if (event.payload && event.payload.hidden) { assets.delete(event.payload.id); clearMedia(event.payload.id); @@ -75,8 +79,8 @@ function getZOrderedAssets() { } function zComparator(a, b) { - const aZ = a?.zIndex ?? 0; - const bZ = b?.zIndex ?? 0; + const aZ = a?.zIndex ?? 1; + const bZ = b?.zIndex ?? 1; if (aZ !== bZ) { return aZ - bZ; } @@ -125,7 +129,7 @@ function lerp(a, b, t) { } function isVideoAsset(asset) { - return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/'); + return asset?.mediaType?.startsWith('video/'); } function isVideoElement(element) { @@ -133,7 +137,7 @@ function isVideoElement(element) { } function isGifAsset(asset) { - return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif'); + return asset?.mediaType?.toLowerCase() === 'image/gif'; } function isDrawable(element) { @@ -187,8 +191,13 @@ function ensureMedia(asset) { element.autoplay = true; element.onloadeddata = draw; element.src = asset.url; - element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1; - element.play().catch(() => {}); + const playback = asset.speed ?? 1; + element.playbackRate = Math.max(playback, 0.01); + if (playback === 0) { + element.pause(); + } else { + element.play().catch(() => {}); + } } else { element.onload = draw; element.src = asset.url; @@ -279,15 +288,18 @@ function applyMediaSettings(element, asset) { if (!isVideoElement(element)) { return; } - const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1; - if (element.playbackRate !== nextSpeed) { - element.playbackRate = nextSpeed; + const nextSpeed = asset.speed ?? 1; + const effectiveSpeed = Math.max(nextSpeed, 0.01); + if (element.playbackRate !== effectiveSpeed) { + element.playbackRate = effectiveSpeed; } const shouldMute = asset.muted ?? true; if (element.muted !== shouldMute) { element.muted = shouldMute; } - if (element.paused) { + if (nextSpeed === 0) { + element.pause(); + } else if (element.paused) { element.play().catch(() => {}); } } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index e23283d..65346be 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -4,6 +4,7 @@ Imgfloat Admin + @@ -21,43 +22,35 @@

Overlay assets

-

Upload images to place on the broadcaster's overlay. Changes are visible to the broadcaster instantly.

+

Upload overlay visuals and adjust them inline.

    +