Improve channel admin page

This commit is contained in:
2025-12-09 16:58:42 +01:00
parent 77c2775b7f
commit 0aac788eda
10 changed files with 434 additions and 218 deletions

View File

@@ -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,7 +94,7 @@ public class ChannelApiController {
}
@GetMapping("/assets")
public Collection<Asset> listAssets(@PathVariable("broadcaster") String broadcaster,
public Collection<AssetView> listAssets(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
@@ -105,7 +105,7 @@ public class ChannelApiController {
}
@GetMapping("/assets/visible")
public Collection<Asset> listVisible(@PathVariable("broadcaster") String broadcaster,
public Collection<AssetView> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
@@ -132,7 +132,7 @@ public class ChannelApiController {
}
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster,
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
@@ -150,7 +150,7 @@ public class ChannelApiController {
}
@PutMapping("/assets/{assetId}/transform")
public ResponseEntity<Asset> transform(@PathVariable("broadcaster") String broadcaster,
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) {
@@ -162,7 +162,7 @@ public class ChannelApiController {
}
@PutMapping("/assets/{assetId}/visibility")
public ResponseEntity<Asset> visibility(@PathVariable("broadcaster") String broadcaster,
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) {
@@ -173,6 +173,19 @@ public class ChannelApiController {
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
}
@GetMapping("/assets/{assetId}/content")
public ResponseEntity<byte[]> 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,

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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()
);
}
}

View File

@@ -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<Asset> getAssetsForAdmin(String broadcaster) {
return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster)));
public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
}
public Collection<Asset> getVisibleAssets(String broadcaster) {
return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
public Collection<AssetView> 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<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
public Optional<AssetView> 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<Asset> updateTransform(String broadcaster, String assetId, TransformRequest request) {
public Optional<AssetView> 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<Asset> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
public Optional<AssetView> 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<AssetContent> 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<Asset> sortByZIndex(Collection<Asset> assets) {
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> 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<AssetContent> 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) { }

View File

@@ -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);

View File

@@ -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.onload = draw;
element.play().catch(() => {});
}
} else {
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 = `<span class="icon" aria-hidden="true">${asset.hidden ? '👁️' : '🙈'}</span><span class="label">${asset.hidden ? 'Show' : 'Hide'}</span>`;
toggleBtn.innerHTML = `<i class="fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}"></i>`;
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 = '<span class="icon" aria-hidden="true">🗑️</span><span class="label">Delete</span>';
deleteBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
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();
});

View File

@@ -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;
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(() => {});
}
}

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<title>Imgfloat Admin</title>
<link rel="stylesheet" href="/css/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
@@ -21,43 +22,35 @@
<section class="controls">
<div>
<h3>Overlay assets</h3>
<p>Upload images to place on the broadcaster's overlay. Changes are visible to the broadcaster instantly.</p>
<p>Upload overlay visuals and adjust them inline.</p>
<input id="asset-file" type="file" accept="image/*,video/*" />
<button onclick="uploadAsset()">Upload</button>
<ul id="asset-list" class="asset-list"></ul>
<div id="asset-controls-placeholder" class="hidden"></div>
<div id="asset-controls" class="panel hidden asset-settings">
<div class="panel-header">
<div class="selected-asset-banner">
<div class="selected-asset-main">
<p class="eyebrow subtle">Asset settings</p>
<div class="title-row">
<h4 id="selected-asset-name">Selected asset</h4>
<span class="badge subtle" id="selected-asset-visibility">Visible</span>
</div>
<p class="muted meta-text" id="selected-asset-meta"></p>
</div>
<h4 id="selected-asset-name">Asset settings</h4>
<div class="selected-asset-actions">
<button type="button" id="selected-asset-toggle" class="ghost icon-button" title="Toggle visibility">
<span class="icon" aria-hidden="true">🙈</span>
<span class="label">Hide</span>
<i class="fa-solid fa-eye" aria-hidden="true"></i>
<span class="sr-only">Toggle visibility</span>
</button>
<button type="button" id="selected-asset-delete" class="ghost danger icon-button" title="Delete asset">
<span class="icon" aria-hidden="true">🗑️</span>
<span class="label">Delete</span>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="sr-only">Delete asset</span>
</button>
</div>
</div>
<div class="panel-summary">
<p class="muted">Fine-tune the selected overlay item with sizing, playback, and layering controls.</p>
</div>
<div class="badge-row stacked">
<span class="badge subtle" id="selected-asset-visibility">Visible</span>
<span class="badge subtle" id="asset-type-label"></span>
</div>
<div class="panel-section">
<div class="section-header">
<h5>Size & placement</h5>
<p class="muted">Keep your overlay crisp by locking aspect ratio while resizing.</p>
<h5>Layout & order</h5>
</div>
<div class="control-grid condensed">
<div class="control-grid condensed three-col">
<label>
Width
<input id="asset-width" class="number-input" type="number" min="10" step="5" />
@@ -68,56 +61,47 @@
</label>
<label class="checkbox-inline">
<input id="maintain-aspect" type="checkbox" checked />
Maintain aspect ratio
Maintain aspect
</label>
</div>
</div>
<div class="panel-section two-col">
<div>
<div class="section-header">
<h5>Playback</h5>
<p class="muted">Tweak animation speed or mute video assets.</p>
</div>
<div class="control-grid condensed">
<label>
Animation speed (% of original)
<input id="asset-speed" class="number-input" type="number" min="10" max="400" step="5" value="100" />
Layer (Z)
<div class="badge-row stacked">
<span class="badge">Layer <strong id="asset-z-level">1</strong></span>
</div>
</label>
</div>
<div class="control-actions filled compact">
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back"><i class="fa-solid fa-angles-down"></i></button>
<button type="button" onclick="bringBackward()" class="secondary" title="Move backward"><i class="fa-solid fa-arrow-down"></i></button>
<button type="button" onclick="bringForward()" class="secondary" title="Move forward"><i class="fa-solid fa-arrow-up"></i></button>
<button type="button" onclick="bringToFront()" class="secondary" title="Bring to front"><i class="fa-solid fa-angles-up"></i></button>
<button type="button" onclick="recenterSelectedAsset()" class="secondary" title="Center on canvas"><i class="fa-solid fa-bullseye"></i></button>
</div>
</div>
<div class="panel-section" id="playback-section">
<div class="section-header">
<h5>Playback</h5>
</div>
<div class="control-grid condensed">
<label>
Animation speed
<input id="asset-speed" class="range-input" type="range" min="0" max="1000" step="10" value="100" />
</label>
<div class="range-meta"><span>0%</span><span>1000%</span></div>
<label class="checkbox-inline">
<input id="asset-muted" type="checkbox" />
Mute
</label>
</div>
</div>
<div>
<div class="section-header">
<h5>Layering</h5>
<p class="muted">Reorder assets to fit your scene.</p>
</div>
<div class="control-grid condensed">
<label>
Layer (Z)
<div class="badge-row stacked">
<span class="badge">Layer <strong id="asset-z-level">0</strong></span>
<span class="badge subtle" id="asset-type-label"></span>
</div>
</label>
</div>
</div>
</div>
<div class="control-actions filled">
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button>
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate right</button>
<button type="button" onclick="applyTransformFromInputs()">Apply size</button>
<button type="button" onclick="recenterSelectedAsset()" class="secondary">Re-center asset</button>
</div>
<div class="control-actions filled">
<button type="button" onclick="sendToBack()" class="secondary">Send to back</button>
<button type="button" onclick="bringBackward()" class="secondary">Bring backward</button>
<button type="button" onclick="bringForward()" class="secondary">Bring forward</button>
<button type="button" onclick="bringToFront()" class="secondary">Bring to front</button>
<div class="control-actions filled compact">
<button type="button" onclick="nudgeRotation(-5)" class="secondary" title="Rotate left"><i class="fa-solid fa-rotate-left"></i></button>
<button type="button" onclick="nudgeRotation(5)" class="secondary" title="Rotate right"><i class="fa-solid fa-rotate-right"></i></button>
<button type="button" onclick="applyTransformFromInputs()" title="Apply size"><i class="fa-solid fa-floppy-disk"></i><span class="sr-only">Apply size</span></button>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ package com.imgfloat.app;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetView;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.repository.AssetRepository;
import com.imgfloat.app.repository.ChannelRepository;
@@ -51,7 +52,7 @@ class ChannelDirectoryServiceTest {
void createsAssetsAndBroadcastsEvents() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
Optional<?> created = service.createAsset("caster", file);
Optional<AssetView> created = service.createAsset("caster", file);
assertThat(created).isPresent();
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
@@ -61,7 +62,7 @@ class ChannelDirectoryServiceTest {
void updatesTransformAndVisibility() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
String channel = "caster";
String id = service.createAsset(channel, file).orElseThrow().getId();
String id = service.createAsset(channel, file).orElseThrow().id();
TransformRequest transform = new TransformRequest();
transform.setX(10);