Add fragile layering system

This commit is contained in:
2026-01-15 17:16:55 +01:00
parent 266022e6f6
commit 04800a0c09
12 changed files with 228 additions and 133 deletions

View File

@@ -32,6 +32,9 @@ public class Asset {
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "display_order")
private Integer displayOrder;
public Asset() {}
public Asset(String broadcaster, AssetType assetType) {
@@ -57,6 +60,9 @@ public class Asset {
if (this.assetType == null) {
this.assetType = AssetType.OTHER;
}
if (this.displayOrder != null && this.displayOrder < 1) {
this.displayOrder = 1;
}
}
public String getId() {
@@ -95,6 +101,14 @@ public class Asset {
this.updatedAt = updatedAt;
}
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
private static String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}

View File

@@ -16,7 +16,7 @@ public record AssetPatch(
Double rotation,
Double speed,
Boolean muted,
Integer zIndex,
Integer order,
Boolean hidden,
Boolean audioLoop,
Integer audioDelayMillis,
@@ -37,7 +37,7 @@ public record AssetPatch(
request.getRotation() != null ? changed(before.rotation(), asset.getRotation()) : null,
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null,
request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null,
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null,
request.getOrder() != null ? changed(before.order(), request.getOrder()) : null,
null,
null,
null,
@@ -112,7 +112,7 @@ public record AssetPatch(
double rotation,
double speed,
boolean muted,
int zIndex,
int order,
double audioVolume
) {}

View File

@@ -23,7 +23,6 @@ public record AssetView(
String originalMediaType,
AssetType assetType,
List<ScriptAssetAttachmentView> scriptAttachments,
Integer zIndex,
Boolean audioLoop,
Integer audioDelayMillis,
Double audioSpeed,
@@ -56,7 +55,6 @@ public record AssetView(
visual.getOriginalMediaType(),
asset.getAssetType(),
null,
visual.getZIndex(),
null,
null,
null,
@@ -90,7 +88,6 @@ public record AssetView(
audio.getOriginalMediaType(),
asset.getAssetType(),
null,
null,
audio.isAudioLoop(),
audio.getAudioDelayMillis(),
audio.getAudioSpeed(),
@@ -126,7 +123,6 @@ public record AssetView(
script.getOriginalMediaType(),
asset.getAssetType(),
script.getAttachments(),
script.getZIndex(),
null,
null,
null,

View File

@@ -33,9 +33,6 @@ public class ScriptAsset {
@Column(name = "source_file_id")
private String sourceFileId;
@Column(name = "z_index")
private Integer zIndex;
@Transient
private List<ScriptAssetAttachmentView> attachments = List.of();
@@ -52,9 +49,6 @@ public class ScriptAsset {
if (this.name == null || this.name.isBlank()) {
this.name = this.id;
}
if (this.zIndex == null || this.zIndex < 1) {
this.zIndex = 1;
}
}
public String getId() {
@@ -121,14 +115,6 @@ public class ScriptAsset {
this.sourceFileId = sourceFileId;
}
public Integer getZIndex() {
return zIndex == null ? 1 : Math.max(1, zIndex);
}
public void setZIndex(Integer zIndex) {
this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
}
public List<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments;
}

View File

@@ -24,8 +24,8 @@ public class TransformRequest {
private Boolean muted;
@Positive(message = "zIndex must be at least 1")
private Integer zIndex;
@Positive(message = "Order must be at least 1")
private Integer order;
private Boolean audioLoop;
@@ -100,12 +100,12 @@ public class TransformRequest {
this.muted = muted;
}
public Integer getZIndex() {
return zIndex;
public Integer getOrder() {
return order;
}
public void setZIndex(Integer zIndex) {
this.zIndex = zIndex;
public void setOrder(Integer order) {
this.order = order;
}
public Boolean getAudioLoop() {

View File

@@ -28,7 +28,6 @@ public class VisualAsset {
private Boolean muted;
private String mediaType;
private String originalMediaType;
private Integer zIndex;
private Double audioVolume;
private boolean hidden;
@@ -44,7 +43,6 @@ public class VisualAsset {
this.rotation = 0;
this.speed = 1.0;
this.muted = false;
this.zIndex = 1;
this.audioVolume = 1.0;
this.hidden = true;
}
@@ -58,9 +56,6 @@ public class VisualAsset {
if (this.muted == null) {
this.muted = Boolean.FALSE;
}
if (this.zIndex == null || this.zIndex < 1) {
this.zIndex = 1;
}
if (this.audioVolume == null) {
this.audioVolume = 1.0;
}
@@ -165,14 +160,6 @@ public class VisualAsset {
this.originalMediaType = originalMediaType;
}
public Integer getZIndex() {
return zIndex == null ? 1 : Math.max(1, zIndex);
}
public void setZIndex(Integer zIndex) {
this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
}
public double getAudioVolume() {
return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume));
}

View File

@@ -189,10 +189,11 @@ public class ChannelDirectoryService {
.stream()
.map((visual) -> {
Asset asset = assetById.get(visual.getId());
return asset == null ? null : AssetView.fromVisual(normalized, asset, visual);
return asset == null ? null : Map.entry(asset, visual);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparingInt(AssetView::zIndex).reversed())
.sorted(Comparator.comparingInt((Map.Entry<Asset, VisualAsset> entry) -> displayOrderValue(entry.getKey())).reversed())
.map((entry) -> AssetView.fromVisual(normalized, entry.getKey(), entry.getValue()))
.toList();
}
@@ -318,6 +319,13 @@ public class ChannelDirectoryService {
boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType);
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
Asset asset = new Asset(channel.getBroadcaster(), assetType);
if (!isAudio && isCode) {
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
} else if (!isAudio) {
asset.setDisplayOrder(
nextDisplayOrder(channel.getBroadcaster(), AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
);
}
assetStorageService.storeAsset(
channel.getBroadcaster(),
@@ -340,7 +348,6 @@ public class ChannelDirectoryService {
script.setMediaType(optimized.mediaType());
script.setOriginalMediaType(mediaType);
script.setSourceFileId(asset.getId());
script.setZIndex(nextScriptZIndex(channel.getBroadcaster()));
script.setAttachments(List.of());
scriptAssetRepository.save(script);
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
@@ -358,7 +365,6 @@ public class ChannelDirectoryService {
visual.setOriginalMediaType(mediaType);
visual.setMediaType(optimized.mediaType());
visual.setMuted(optimized.mediaType().startsWith("video/"));
visual.setZIndex(nextZIndex(channel.getBroadcaster()));
assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
visual.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
visualAssetRepository.save(visual);
@@ -383,6 +389,7 @@ public class ChannelDirectoryService {
enforceUploadLimit(bytes.length);
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId());
sourceFile.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
@@ -407,7 +414,6 @@ public class ChannelDirectoryService {
script.setSourceFileId(sourceFile.getId());
script.setDescription(normalizeDescription(request.getDescription()));
script.setPublic(Boolean.TRUE.equals(request.getIsPublic()));
script.setZIndex(nextScriptZIndex(channel.getBroadcaster()));
script.setAttachments(List.of());
scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
@@ -787,6 +793,7 @@ public class ChannelDirectoryService {
}
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId());
sourceFile.setMediaType(sourceContent.mediaType());
@@ -811,7 +818,6 @@ public class ChannelDirectoryService {
script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId());
script.setLogoFileId(sourceScript.getLogoFileId());
script.setZIndex(nextScriptZIndex(targetBroadcaster));
script.setAttachments(List.of());
scriptAssetRepository.save(script);
@@ -858,6 +864,7 @@ public class ChannelDirectoryService {
}
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId());
sourceFile.setMediaType(sourceContent.mediaType());
@@ -881,7 +888,6 @@ public class ChannelDirectoryService {
script.setMediaType(sourceContent.mediaType());
script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId());
script.setZIndex(nextScriptZIndex(targetBroadcaster));
script.setAttachments(List.of());
scriptAssetRepository.save(script);
@@ -1009,14 +1015,19 @@ public class ChannelDirectoryService {
ScriptAsset script = scriptAssetRepository
.findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
int beforeZIndex = script.getZIndex();
if (req.getZIndex() != null) {
if (req.getZIndex() < 1) {
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
Integer beforeOrder = asset.getDisplayOrder();
List<Asset> orderUpdates = List.of();
if (req.getOrder() != null) {
if (req.getOrder() < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
}
script.setZIndex(req.getZIndex());
scriptAssetRepository.save(script);
if (beforeZIndex != script.getZIndex()) {
orderUpdates = updateDisplayOrder(
normalized,
asset,
req.getOrder(),
EnumSet.of(AssetType.SCRIPT)
);
if (!Objects.equals(beforeOrder, asset.getDisplayOrder())) {
AssetPatch patch = new AssetPatch(
asset.getId(),
null,
@@ -1026,7 +1037,7 @@ public class ChannelDirectoryService {
null,
null,
null,
script.getZIndex(),
asset.getDisplayOrder(),
null,
null,
null,
@@ -1038,10 +1049,11 @@ public class ChannelDirectoryService {
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"SCRIPT_LAYER_UPDATED",
formatScriptTransformDetails(asset.getId(), script.getZIndex())
"SCRIPT_ORDER_UPDATED",
formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder())
);
}
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
}
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
return AssetView.fromScript(normalized, asset, script);
@@ -1058,17 +1070,28 @@ public class ChannelDirectoryService {
visual.getRotation(),
visual.getSpeed(),
visual.isMuted(),
visual.getZIndex(),
displayOrderValue(asset),
visual.getAudioVolume()
);
validateVisualTransform(req);
List<Asset> orderUpdates = List.of();
if (req.getX() != null) visual.setX(req.getX());
if (req.getY() != null) visual.setY(req.getY());
if (req.getWidth() != null) visual.setWidth(req.getWidth());
if (req.getHeight() != null) visual.setHeight(req.getHeight());
if (req.getRotation() != null) visual.setRotation(req.getRotation());
if (req.getZIndex() != null) visual.setZIndex(req.getZIndex());
if (req.getOrder() != null) {
orderUpdates = updateDisplayOrder(
normalized,
asset,
req.getOrder(),
EnumSet.of(AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
);
if (asset.getDisplayOrder() != null) {
req.setOrder(asset.getDisplayOrder());
}
}
if (req.getSpeed() != null) visual.setSpeed(req.getSpeed());
if (req.getMuted() != null) visual.setMuted(req.getMuted());
if (req.getAudioVolume() != null) visual.setAudioVolume(req.getAudioVolume());
@@ -1086,6 +1109,7 @@ public class ChannelDirectoryService {
formatVisualTransformDetails(asset.getId(), req)
);
}
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
return view;
});
}
@@ -1113,9 +1137,9 @@ public class ChannelDirectoryService {
if (
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
if (req.getZIndex() != null && req.getZIndex() < 1) throw new ResponseStatusException(
if (req.getOrder() != null && req.getOrder() < 1) throw new ResponseStatusException(
BAD_REQUEST,
"zIndex must be >= 1"
"Order must be >= 1"
);
if (
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
@@ -1580,51 +1604,100 @@ public class ChannelDirectoryService {
return assets
.stream()
.sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments))
.filter(Objects::nonNull)
.sorted(
Comparator.comparingInt((AssetView view) -> view.zIndex() == null ? Integer.MIN_VALUE : view.zIndex())
.reversed()
.thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.toList();
}
private int nextZIndex(String broadcaster) {
private int nextDisplayOrder(String broadcaster, AssetType... types) {
return (
visualAssetRepository
.findByIdIn(
assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
)
assetRepository
.findByBroadcaster(normalize(broadcaster))
.stream()
.mapToInt(VisualAsset::getZIndex)
.filter((asset) -> Arrays.asList(types).contains(asset.getAssetType()))
.map(Asset::getDisplayOrder)
.filter(Objects::nonNull)
.mapToInt(Integer::intValue)
.max()
.orElse(0) +
1
);
}
private int nextScriptZIndex(String broadcaster) {
return (
scriptAssetRepository
.findByIdIn(assetsWithType(normalize(broadcaster), AssetType.SCRIPT))
.stream()
.mapToInt(ScriptAsset::getZIndex)
.max()
.orElse(0) +
1
);
private int displayOrderValue(Asset asset) {
Integer value = asset == null ? null : asset.getDisplayOrder();
return value == null ? Integer.MIN_VALUE : value;
}
private List<String> assetsWithType(String broadcaster, AssetType... types) {
Set<AssetType> typeSet = EnumSet.noneOf(AssetType.class);
typeSet.addAll(Arrays.asList(types));
return assetRepository
private List<Asset> updateDisplayOrder(String broadcaster, Asset target, int desiredOrder, Set<AssetType> types) {
if (target == null || types == null || types.isEmpty()) {
return List.of();
}
List<Asset> ordered = assetRepository
.findByBroadcaster(normalize(broadcaster))
.stream()
.filter((asset) -> typeSet.contains(asset.getAssetType()))
.map(Asset::getId)
.toList();
.filter((asset) -> types.contains(asset.getAssetType()))
.sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.collect(Collectors.toCollection(ArrayList::new));
ordered.removeIf((asset) -> asset.getId().equals(target.getId()));
int insertIndex = Math.max(0, Math.min(ordered.size(), ordered.size() - desiredOrder));
ordered.add(insertIndex, target);
int size = ordered.size();
List<Asset> changed = new ArrayList<>();
for (int index = 0; index < size; index++) {
Asset asset = ordered.get(index);
int nextOrder = size - index;
if (asset.getDisplayOrder() == null || asset.getDisplayOrder() != nextOrder) {
asset.setDisplayOrder(nextOrder);
changed.add(asset);
}
}
if (!changed.isEmpty()) {
assetRepository.saveAll(changed);
}
return changed;
}
private void publishOrderUpdates(String broadcaster, String targetAssetId, List<Asset> updates) {
if (updates == null || updates.isEmpty()) {
return;
}
updates
.stream()
.filter((asset) -> asset.getDisplayOrder() != null)
.filter((asset) -> !asset.getId().equals(targetAssetId))
.forEach((asset) -> {
AssetPatch patch = new AssetPatch(
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
asset.getDisplayOrder(),
null,
null,
null,
null,
null,
null
);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
});
}
private AssetView resolveAssetView(String broadcaster, Asset asset) {
@@ -1818,7 +1891,7 @@ public class ChannelDirectoryService {
if (req.getWidth() != null) parts.add("width=" + req.getWidth());
if (req.getHeight() != null) parts.add("height=" + req.getHeight());
if (req.getRotation() != null) parts.add("rotation=" + req.getRotation());
if (req.getZIndex() != null) parts.add("zIndex=" + req.getZIndex());
if (req.getOrder() != null) parts.add("order=" + req.getOrder());
if (req.getSpeed() != null) parts.add("speed=" + req.getSpeed());
if (req.getMuted() != null) parts.add("muted=" + req.getMuted());
if (req.getAudioVolume() != null) parts.add("audioVolume=" + req.getAudioVolume());
@@ -1835,10 +1908,10 @@ public class ChannelDirectoryService {
return formatTransformDetails("Updated audio asset " + assetId, parts);
}
private String formatScriptTransformDetails(String assetId, Integer zIndex) {
private String formatScriptTransformDetails(String assetId, Integer order) {
String detail = "Updated script asset " + assetId;
if (zIndex != null) {
return detail + " (zIndex=" + zIndex + ")";
if (order != null) {
return detail + " (order=" + order + ")";
}
return detail;
}
@@ -1859,7 +1932,7 @@ public class ChannelDirectoryService {
patch.rotation() != null ||
patch.speed() != null ||
patch.muted() != null ||
patch.zIndex() != null ||
patch.order() != null ||
patch.hidden() != null ||
patch.audioLoop() != null ||
patch.audioDelayMillis() != null ||

View File

@@ -0,0 +1,20 @@
ALTER TABLE assets ADD COLUMN display_order INTEGER;
UPDATE assets
SET display_order = visual_assets.z_index
FROM visual_assets
WHERE assets.id = visual_assets.id
AND assets.asset_type IN ('IMAGE', 'VIDEO', 'MODEL', 'OTHER');
UPDATE assets
SET display_order = script_assets.z_index
FROM script_assets
WHERE assets.id = script_assets.id
AND assets.asset_type = 'SCRIPT';
UPDATE assets
SET display_order = 1
WHERE display_order IS NULL;
ALTER TABLE visual_assets DROP COLUMN z_index;
ALTER TABLE script_assets DROP COLUMN z_index;

View File

@@ -47,7 +47,7 @@ export function createAdminConsole({
const speedLabel = document.getElementById("asset-speed-label");
const volumeInput = document.getElementById("asset-volume");
const volumeLabel = document.getElementById("asset-volume-label");
const selectedZLabel = document.getElementById("asset-z-level");
const selectedOrderLabel = document.getElementById("asset-order-position");
const playbackSection = document.getElementById("playback-section");
const volumeSection = document.getElementById("volume-section");
const audioSection = document.getElementById("audio-section");
@@ -238,6 +238,20 @@ export function createAdminConsole({
return order.length - index;
}
function getLayerPosition(assetId) {
const order = getLayerOrder();
const index = order.indexOf(assetId);
if (index === -1) return 1;
return index + 1;
}
function getScriptLayerPosition(assetId) {
const order = getScriptLayerOrder();
const index = order.indexOf(assetId);
if (index === -1) return 1;
return index + 1;
}
function addPendingUpload(name) {
const pending = {
id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`,
@@ -662,23 +676,16 @@ export function createAdminConsole({
clearMedia(assetId);
loopPlaybackState.delete(assetId);
}
let targetLayer;
if (Number.isFinite(patch.layer)) {
targetLayer = patch.layer;
} else if (Number.isFinite(patch.zIndex)) {
targetLayer = patch.zIndex;
} else {
targetLayer = null;
}
if (!isAudio && Number.isFinite(targetLayer)) {
const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
if (!isAudio && Number.isFinite(targetOrder)) {
if (isScript) {
const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId);
scriptLayerOrder = currentOrder;
} else {
const currentOrder = getLayerOrder().filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId);
layerOrder = currentOrder;
}
@@ -1340,9 +1347,9 @@ export function createAdminConsole({
return null;
}
if (isCodeAsset(asset)) {
return `Script layer ${getScriptLayerValue(asset.id)}`;
return `Script order ${getScriptLayerPosition(asset.id)} (above canvas assets)`;
}
return `Layer ${getLayerValue(asset.id)}`;
return `Order ${getLayerPosition(asset.id)}`;
}
function createSectionHeader(title) {
@@ -1392,11 +1399,16 @@ export function createAdminConsole({
}
if (!isAudioAsset(asset)) {
const ordered = getLayeredAssets(asset);
const orderIndex = ordered.findIndex((item) => item.id === asset.id);
const canMoveUp = orderIndex > 0;
const canMoveDown = orderIndex !== -1 && orderIndex < ordered.length - 1;
const moveUp = document.createElement("button");
moveUp.type = "button";
moveUp.className = "ghost icon-button";
moveUp.innerHTML = '<i class="fa-solid fa-arrow-up"></i>';
moveUp.title = isCodeAsset(asset) ? "Move script up" : "Move layer up";
moveUp.title = isCodeAsset(asset) ? "Move script up" : "Move asset up";
moveUp.disabled = !canMoveUp;
moveUp.addEventListener("click", (e) => {
e.stopPropagation();
moveLayerItem(asset, "up");
@@ -1405,7 +1417,8 @@ export function createAdminConsole({
moveDown.type = "button";
moveDown.className = "ghost icon-button";
moveDown.innerHTML = '<i class="fa-solid fa-arrow-down"></i>';
moveDown.title = isCodeAsset(asset) ? "Move script down" : "Move layer down";
moveDown.title = isCodeAsset(asset) ? "Move script down" : "Move asset down";
moveDown.disabled = !canMoveDown;
moveDown.addEventListener("click", (e) => {
e.stopPropagation();
moveLayerItem(asset, "down");
@@ -1802,8 +1815,10 @@ export function createAdminConsole({
controlsPanel.classList.remove("hidden");
lastSizeInputChanged = null;
if (selectedZLabel) {
selectedZLabel.textContent = getLayerValue(asset.id);
if (selectedOrderLabel) {
selectedOrderLabel.textContent = isCodeAsset(asset)
? getScriptLayerPosition(asset.id)
: getLayerPosition(asset.id);
}
if (widthInput) widthInput.value = Math.round(asset.width);
@@ -1921,9 +1936,12 @@ export function createAdminConsole({
if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
if (isCodeAsset(asset)) {
selectedAssetBadges.appendChild(createBadge(`Script layer ${getScriptLayerValue(asset.id)}`, "subtle"));
selectedAssetBadges.appendChild(
createBadge(`Script order ${getScriptLayerPosition(asset.id)}`, "subtle"),
);
selectedAssetBadges.appendChild(createBadge("Above canvas assets", "subtle"));
} else if (!isAudioAsset(asset)) {
selectedAssetBadges.appendChild(createBadge(`Layer ${getLayerValue(asset.id)}`, "subtle"));
selectedAssetBadges.appendChild(createBadge(`Order ${getLayerPosition(asset.id)}`, "subtle"));
}
const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
if (aspectLabel) {
@@ -2445,18 +2463,17 @@ export function createAdminConsole({
audioVolume: asset.audioVolume,
};
if (isCodeAsset(asset)) {
const layer = getScriptLayerValue(asset.id);
payload.zIndex = layer;
const order = getScriptLayerValue(asset.id);
payload.order = order;
} else if (!isAudioAsset(asset)) {
const layer = getLayerValue(asset.id);
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.layer = layer;
payload.zIndex = layer;
payload.order = order;
if (isVideoAsset(asset)) {
payload.muted = asset.muted;
}

View File

@@ -291,21 +291,17 @@ export class BroadcastRenderer {
this.hideAssetWithTransition(merged);
return;
}
const targetLayer = Number.isFinite(patch.layer)
? patch.layer
: Number.isFinite(patch.zIndex)
? patch.zIndex
: null;
if (Number.isFinite(targetLayer)) {
const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
if (Number.isFinite(targetOrder)) {
if (isScript) {
const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId);
this.state.scriptLayerOrder = currentOrder;
this.applyScriptCanvasOrder();
} else if (isVisual) {
const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId);
this.state.layerOrder = currentOrder;
}
@@ -827,12 +823,14 @@ export class BroadcastRenderer {
return;
}
const ordered = getScriptLayerOrder(this.state);
ordered.forEach((id, index) => {
ordered
.slice()
.reverse()
.forEach((id) => {
const canvas = this.scriptCanvases.get(id);
if (!canvas) {
return;
}
canvas.style.zIndex = `${ordered.length - index}`;
this.scriptLayer.appendChild(canvas);
});
}

View File

@@ -100,6 +100,10 @@
<div class="panel-section" id="layout-section">
<div class="section-header">
<h5>Layout & order</h5>
<p class="subtle-text">
The top of the list renders on top in the broadcast. Script assets always render
above canvas assets.
</p>
</div>
<div class="property-list">
<div class="property-row">
@@ -132,11 +136,11 @@
</label>
</div>
<div class="property-row">
<span class="property-label">Layer</span>
<span class="property-label">Order</span>
<div class="property-control">
<div class="badge-row stacked">
<span class="badge"
>Layer <strong id="asset-z-level">1</strong></span
>Position <strong id="asset-order-position">1</strong></span
>
</div>
</div>