diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index d6b8777..d412ad1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -9,6 +9,7 @@ import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; @@ -329,8 +330,26 @@ public class ChannelApiController { logBroadcaster, logSessionUsername ); - return createAsset404(); - }); + return createAsset404(); + }); + } + + @PostMapping("/assets/order") + public ResponseEntity reorderAssets( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody AssetOrderRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + LOG.debug("Reordering assets for {} by {}", logBroadcaster, logSessionUsername); + channelDirectoryService.reorderAssets(broadcaster, request.getUpdates(), sessionUsername); + return ResponseEntity.noContent().build(); } @PostMapping("/assets/{assetId}/play") diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/AssetOrderRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/AssetOrderRequest.java new file mode 100644 index 0000000..84ad860 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/AssetOrderRequest.java @@ -0,0 +1,29 @@ +package dev.kruhlmann.imgfloat.model.api.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public class AssetOrderRequest { + + @Valid + @NotEmpty + private List updates; + + public List getUpdates() { + return updates; + } + + public void setUpdates(List updates) { + this.updates = updates; + } + + public static record AssetOrderUpdate( + @NotBlank + String assetId, + @NotNull + Integer order + ) {} +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 610342e..3744a64 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -4,9 +4,11 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; import dev.kruhlmann.imgfloat.model.AssetType; +import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; +import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; @@ -1157,9 +1159,132 @@ public class ChannelDirectoryService { formatVisualTransformDetails(asset.getId(), req) ); } - publishOrderUpdates(broadcaster, asset.getId(), orderUpdates); - return view; - }); + publishOrderUpdates(broadcaster, asset.getId(), orderUpdates); + return view; + }); + } + + @Transactional + public void reorderAssets( + String broadcaster, + List updates, + String actor + ) { + if (updates == null || updates.isEmpty()) { + return; + } + String normalized = normalize(broadcaster); + applyBulkOrderUpdates( + broadcaster, + normalized, + updates, + EnumSet.of(AssetType.SCRIPT), + actor, + true + ); + applyBulkOrderUpdates( + broadcaster, + normalized, + updates, + EnumSet.of(AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER), + actor, + false + ); + } + + private void applyBulkOrderUpdates( + String broadcaster, + String normalized, + List updates, + EnumSet types, + String actor, + boolean script + ) { + if (updates == null || updates.isEmpty()) { + return; + } + List bucket = assetRepository + .findByBroadcaster(normalized) + .stream() + .filter((asset) -> types.contains(asset.getAssetType())) + .sorted( + Comparator.comparingInt(this::displayOrderValue) + .reversed() + .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())) + ) + .toList(); + if (bucket.isEmpty()) { + return; + } + Map desiredOrder = new HashMap<>(); + Set bucketIds = bucket.stream().map(Asset::getId).collect(Collectors.toSet()); + for (AssetOrderRequest.AssetOrderUpdate update : updates) { + if (update == null) { + continue; + } + String assetId = update.assetId(); + if (!bucketIds.contains(assetId)) { + continue; + } + Integer order = update.order(); + if (order == null) { + continue; + } + if (order < 1) { + throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1"); + } + desiredOrder.put(assetId, order); + } + if (desiredOrder.isEmpty()) { + return; + } + Map originalIndex = new HashMap<>(); + for (int index = 0; index < bucket.size(); index++) { + originalIndex.put(bucket.get(index).getId(), index); + } + List ordered = new ArrayList<>(bucket); + ordered.sort((a, b) -> { + int orderA = desiredOrder.getOrDefault(a.getId(), a.getDisplayOrder() != null ? a.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(a.getId(), bucket.size())); + int orderB = desiredOrder.getOrDefault(b.getId(), b.getDisplayOrder() != null ? b.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(b.getId(), bucket.size())); + int cmp = Integer.compare(orderB, orderA); + if (cmp != 0) { + return cmp; + } + return Integer.compare(originalIndex.getOrDefault(a.getId(), Integer.MAX_VALUE), originalIndex.getOrDefault(b.getId(), Integer.MAX_VALUE)); + }); + List changed = new ArrayList<>(); + for (int index = 0; index < ordered.size(); index++) { + Asset asset = ordered.get(index); + int nextOrder = ordered.size() - index; + if (asset.getDisplayOrder() == null || asset.getDisplayOrder() != nextOrder) { + asset.setDisplayOrder(nextOrder); + changed.add(asset); + } + } + if (changed.isEmpty()) { + return; + } + assetRepository.saveAll(changed); + publishOrderUpdates(broadcaster, null, changed); + for (Asset asset : changed) { + if (script) { + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_ORDER_UPDATED", + formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder()) + ); + } else { + TransformRequest logDetails = new TransformRequest(); + logDetails.setOrder(asset.getDisplayOrder()); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "VISUAL_UPDATED", + formatVisualTransformDetails(asset.getId(), logDetails) + ); + } + } } private void validateVisualTransform(TransformRequest req) { @@ -1171,13 +1296,13 @@ public class ChannelDirectoryService { int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); if ( - req.getWidth() == null || req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels + req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) ) throw new ResponseStatusException( BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]" ); if ( - req.getHeight() == null || req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels + req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels) ) throw new ResponseStatusException( BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]" diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js index bce4d82..c5843d6 100644 --- a/src/main/resources/static/js/admin/console.js +++ b/src/main/resources/static/js/admin/console.js @@ -26,6 +26,7 @@ export function createAdminConsole({ const assets = new Map(); const mediaCache = new Map(); const renderStates = new Map(); + const transformBaseline = new Map(); const animatedCache = new Map(); const audioControllers = new Map(); const pendingAudioUnlock = new Set(); @@ -506,7 +507,7 @@ export function createAdminConsole({ event.preventDefault(); updateRenderState(asset); schedulePersistTransform(asset); - drawAndList(); + drawAndList(false); } }); function connect() { @@ -621,6 +622,7 @@ export function createAdminConsole({ if (!renderStates.has(asset.id)) { renderStates.set(asset.id, { ...merged }); } + updateTransformBaseline(merged); resolvePendingUploadByName(asset.name); } @@ -635,6 +637,35 @@ export function createAdminConsole({ renderStates.set(asset.id, state); } + function updateTransformBaseline(asset) { + if (!asset?.id) { + return; + } + const snapshot = {}; + snapshot.audioVolume = Number.isFinite(asset.audioVolume) ? asset.audioVolume : undefined; + if (isAudioAsset(asset)) { + snapshot.audioLoop = asset.audioLoop; + snapshot.audioDelayMillis = Number.isFinite(asset.audioDelayMillis) + ? asset.audioDelayMillis + : undefined; + snapshot.audioSpeed = Number.isFinite(asset.audioSpeed) ? asset.audioSpeed : undefined; + snapshot.audioPitch = Number.isFinite(asset.audioPitch) ? asset.audioPitch : undefined; + } else { + snapshot.x = Number.isFinite(asset.x) ? asset.x : undefined; + snapshot.y = Number.isFinite(asset.y) ? asset.y : undefined; + snapshot.width = Number.isFinite(asset.width) ? asset.width : undefined; + snapshot.height = Number.isFinite(asset.height) ? asset.height : undefined; + snapshot.rotation = Number.isFinite(asset.rotation) ? asset.rotation : undefined; + snapshot.speed = Number.isFinite(asset.speed) ? asset.speed : undefined; + snapshot.muted = asset.muted; + const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id); + if (Number.isFinite(order)) { + snapshot.order = order; + } + } + transformBaseline.set(asset.id, snapshot); + } + function handleEvent(event) { if (event.type === "CANVAS" && event.payload) { applyCanvasSettings(event.payload); @@ -647,6 +678,7 @@ export function createAdminConsole({ scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId); clearMedia(assetId); renderStates.delete(assetId); + transformBaseline.delete(assetId); loopPlaybackState.delete(assetId); cancelPendingTransform(assetId); if (selectedAssetId === assetId) { @@ -666,7 +698,27 @@ export function createAdminConsole({ loopPlaybackState.delete(event.payload.id); } } - drawAndList(); + drawAndList(shouldRenderAssetList(event)); + } + + function shouldRenderAssetList(event) { + if (!event) { + return true; + } + const { type, payload, patch } = event; + if (type === "DELETED" || type === "VISIBILITY") { + return true; + } + if (payload) { + return true; + } + if (patch) { + if (patch.hidden != null || patch.order != null) { + return true; + } + return false; + } + return true; } function applyPatch(assetId, patch) { @@ -706,9 +758,11 @@ export function createAdminConsole({ } } - function drawAndList() { + function drawAndList(renderList = true) { requestDraw(); - renderAssetList(); + if (renderList) { + renderAssetList(); + } } function requestDraw() { @@ -2055,7 +2109,7 @@ export function createAdminConsole({ if (media) { applyMediaSettings(media, asset); } - drawAndList(); + drawAndList(false); } function updateVolumeFromInput() { @@ -2074,7 +2128,7 @@ export function createAdminConsole({ applyAudioSettings(controller, asset); } schedulePersistTransform(asset); - drawAndList(); + drawAndList(false); } function updateAudioSettingsFromInputs() { @@ -2104,7 +2158,7 @@ export function createAdminConsole({ const controller = ensureAudioController(asset); applyAudioSettings(controller, asset); schedulePersistTransform(asset); - drawAndList(); + drawAndList(false); } function nudgeRotation(delta) { @@ -2114,7 +2168,7 @@ export function createAdminConsole({ asset.rotation = next; updateRenderState(asset); persistTransform(asset); - drawAndList(); + drawAndList(false); } function recenterSelectedAsset() { @@ -2126,7 +2180,7 @@ export function createAdminConsole({ asset.y = centerY; updateRenderState(asset); persistTransform(asset); - drawAndList(); + drawAndList(false); } function getLayeredAssets(asset) { @@ -2195,22 +2249,92 @@ export function createAdminConsole({ globalThis.sendToBack = sendToBack; function applyLayerOrder(ordered) { + if (!ordered || !ordered.length) { + return; + } + const previousOrder = [...layerOrder]; const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); layerOrder = newOrder; const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); changed.forEach((item) => updateRenderState(item)); - changed.forEach((item) => schedulePersistTransform(item, true)); + const orderUpdates = buildOrderUpdates(ordered, previousOrder); + sendOrderUpdates(orderUpdates, () => { + layerOrder = previousOrder; + drawAndList(); + }); drawAndList(); } function applyScriptLayerOrder(ordered) { + if (!ordered || !ordered.length) { + return; + } + const previousOrder = [...scriptLayerOrder]; const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); scriptLayerOrder = newOrder; - const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); - changed.forEach((item) => schedulePersistTransform(item, true)); + const orderUpdates = buildOrderUpdates(ordered, previousOrder); + sendOrderUpdates(orderUpdates, () => { + scriptLayerOrder = previousOrder; + drawAndList(); + }); drawAndList(); } + function buildOrderUpdates(ordered, previousOrderIds) { + if (!ordered || !ordered.length) { + return []; + } + const previousLength = previousOrderIds.length || ordered.length; + const previousOrderMap = new Map(); + previousOrderIds.forEach((id, index) => { + previousOrderMap.set(id, previousLength - index); + }); + const updates = []; + const newLength = ordered.length; + ordered.forEach((asset, index) => { + if (!asset) { + return; + } + const newOrderValue = newLength - index; + const previousValue = previousOrderMap.get(asset.id); + if (previousValue !== newOrderValue) { + updates.push({ assetId: asset.id, order: newOrderValue }); + } + }); + return updates; + } + + function sendOrderUpdates(updates, onError) { + if (!updates || !updates.length) { + return; + } + fetch(`/api/channels/${broadcaster}/assets/order`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }) + .then((response) => { + if (!response.ok) { + return extractErrorMessage(response, "Unable to reorder assets right now.").then((message) => { + throw new Error(message); + }); + } + }) + .catch((error) => { + if (onError) { + onError(); + } + const assetDetails = updates + .map((update) => { + const asset = assets.get(update.assetId); + return `${asset?.name ?? "unknown"} (${update.assetId})`; + }) + .join(", "); + console.warn("Asset reorder failed for", assetDetails, error?.message || error); + showToast(error?.message || "Unable to reorder assets right now.", "error"); + }); + } + function getAssetAspectRatio(asset) { const media = ensureMedia(asset); if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { @@ -2315,12 +2439,13 @@ export function createAdminConsole({ throw new Error(message); }); } - clearMedia(asset.id); - assets.delete(asset.id); - renderStates.delete(asset.id); - layerOrder = layerOrder.filter((id) => id !== asset.id); - scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id); - cancelPendingTransform(asset.id); + clearMedia(asset.id); + assets.delete(asset.id); + renderStates.delete(asset.id); + transformBaseline.delete(asset.id); + layerOrder = layerOrder.filter((id) => id !== asset.id); + scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id); + cancelPendingTransform(asset.id); if (selectedAssetId === asset.id) { selectedAssetId = null; } @@ -2452,29 +2577,13 @@ export function createAdminConsole({ } function persistTransform(asset, silent = false) { + if (!asset || !asset.id) { + return Promise.resolve(); + } cancelPendingTransform(asset.id); - const payload = { - audioLoop: asset.audioLoop, - audioDelayMillis: asset.audioDelayMillis, - audioSpeed: asset.audioSpeed, - audioPitch: asset.audioPitch, - audioVolume: asset.audioVolume, - }; - if (isCodeAsset(asset)) { - const order = getScriptLayerValue(asset.id); - payload.order = order; - } else if (!isAudioAsset(asset)) { - const order = getLayerValue(asset.id); - payload.x = asset.x; - payload.y = asset.y; - payload.width = asset.width; - payload.height = asset.height; - payload.rotation = asset.rotation; - payload.speed = asset.speed; - payload.order = order; - if (isVideoAsset(asset)) { - payload.muted = asset.muted; - } + const payload = buildTransformPayload(asset); + if (!Object.keys(payload).length) { + return Promise.resolve(); } fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { method: "PUT", @@ -2493,7 +2602,7 @@ export function createAdminConsole({ storeAsset(updated); updateRenderState(updated); if (!silent) { - drawAndList(); + drawAndList(false); } }) .catch((error) => { @@ -2503,6 +2612,57 @@ export function createAdminConsole({ }); } + function buildTransformPayload(asset) { + if (!asset?.id) { + return {}; + } + const baseline = transformBaseline.get(asset.id) || {}; + const payload = {}; + const addNumber = (key, value) => { + if (!Number.isFinite(value)) { + return; + } + const previous = baseline[key]; + if (Number.isFinite(previous) && previous === value) { + return; + } + payload[key] = value; + }; + const addBoolean = (key, value) => { + if (value == null) { + return; + } + if (baseline[key] === value) { + return; + } + payload[key] = value; + }; + + addBoolean("audioLoop", asset.audioLoop); + addNumber("audioDelayMillis", asset.audioDelayMillis); + addNumber("audioSpeed", asset.audioSpeed); + addNumber("audioPitch", asset.audioPitch); + addNumber("audioVolume", asset.audioVolume); + + if (!isAudioAsset(asset)) { + addNumber("x", asset.x); + addNumber("y", asset.y); + addNumber("width", asset.width); + addNumber("height", asset.height); + addNumber("rotation", asset.rotation); + addNumber("speed", asset.speed); + if (isVideoAsset(asset)) { + addBoolean("muted", asset.muted); + } + const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id); + if (Number.isFinite(order) && baseline.order !== order) { + payload.order = order; + } + } + + return payload; + } + canvas.addEventListener("mousedown", (event) => { const point = getCanvasPoint(event); const current = getSelectedAsset(); diff --git a/src/main/resources/static/js/broadcast/visibility.js b/src/main/resources/static/js/broadcast/visibility.js index 830eccb..1742b84 100644 --- a/src/main/resources/static/js/broadcast/visibility.js +++ b/src/main/resources/static/js/broadcast/visibility.js @@ -10,24 +10,18 @@ export function getVisibilityState(state, asset) { } export function smoothState(state, asset) { - const previous = state.renderStates.get(asset.id) || { ...asset }; - const factor = 0.15; + const previous = state.renderStates.get(asset.id) || {}; 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), + x: Number.isFinite(asset.x) ? asset.x : previous.x ?? 0, + y: Number.isFinite(asset.y) ? asset.y : previous.y ?? 0, + width: Number.isFinite(asset.width) ? asset.width : previous.width ?? 0, + height: Number.isFinite(asset.height) ? asset.height : previous.height ?? 0, + rotation: Number.isFinite(asset.rotation) ? asset.rotation : previous.rotation ?? 0, }; state.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; }