diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c2b41cc..6af01ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,14 +39,46 @@ jobs:
timeout-minutes: 10
runs-on: ubuntu-latest
needs: build
+ outputs:
+ pom_version: ${{ steps.read_version.outputs.pom_version }}
+ version_changed: ${{ steps.version_check.outputs.version_changed }}
steps:
- uses: actions/checkout@v4
with:
lfs: true
+ - name: Check for pom.xml changes
+ id: version_check
+ run: |
+ if [ "${{ github.event_name }}" != "push" ]; then
+ echo "version_changed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- pom.xml | grep -q '^pom.xml$'; then
+ echo "version_changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "version_changed=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Read pom.xml version
+ id: read_version
+ run: |
+ VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version)
+ echo "pom_version=$VERSION" >> "$GITHUB_OUTPUT"
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+ - name: Set Docker tags
+ id: docker_tags
+ run: |
+ TAGS="kruhlmann/imgfloat-j:latest,kruhlmann/imgfloat-j:${{ github.sha }}"
+ if [ "${{ steps.version_check.outputs.version_changed }}" = "true" ]; then
+ TAGS="$TAGS,kruhlmann/imgfloat-j:${{ steps.read_version.outputs.pom_version }}"
+ fi
+ echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
+
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: docker/login-action@v3
@@ -59,6 +91,21 @@ jobs:
with:
context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
- tags: |
- kruhlmann/imgfloat-j:latest
- kruhlmann/imgfloat-j:${{ github.sha }}
+ tags: ${{ steps.docker_tags.outputs.tags }}
+
+ release:
+ timeout-minutes: 10
+ runs-on: ubuntu-latest
+ needs: docker
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.docker.outputs.version_changed == 'true'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ lfs: false
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ needs.docker.outputs.pom_version }}
+ name: v${{ needs.docker.outputs.pom_version }}
+ generate_release_notes: true
diff --git a/pom.xml b/pom.xml
index 9b0fbf5..4d6a5f6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
dev.kruhlmann
imgfloat
- 0.0.1
+ 1.0.0
Imgfloat
Livestream overlay with Twitch-authenticated channel admins and broadcasters.
jar
diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java
index 05e5b1c..12a004a 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java
@@ -126,7 +126,7 @@ public record AssetView(
script.getOriginalMediaType(),
asset.getAssetType(),
script.getAttachments(),
- null,
+ script.getZIndex(),
null,
null,
null,
diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java
index 7a5b399..ed1ce01 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java
@@ -33,6 +33,9 @@ public class ScriptAsset {
@Column(name = "source_file_id")
private String sourceFileId;
+ @Column(name = "z_index")
+ private Integer zIndex;
+
@Transient
private List attachments = List.of();
@@ -49,6 +52,9 @@ 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() {
@@ -115,6 +121,14 @@ 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 getAttachments() {
return attachments == null ? List.of() : attachments;
}
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java
index 52c68e7..e2b750a 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java
@@ -175,7 +175,7 @@ public class ChannelDirectoryService {
return asset == null ? null : AssetView.fromVisual(normalized, asset, visual);
})
.filter(Objects::nonNull)
- .sorted(Comparator.comparingInt(AssetView::zIndex))
+ .sorted(Comparator.comparingInt(AssetView::zIndex).reversed())
.toList();
}
@@ -273,6 +273,7 @@ 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);
@@ -333,6 +334,7 @@ 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);
@@ -711,6 +713,7 @@ 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);
@@ -770,6 +773,7 @@ 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);
@@ -891,6 +895,34 @@ 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");
+ }
+ script.setZIndex(req.getZIndex());
+ scriptAssetRepository.save(script);
+ if (beforeZIndex != script.getZIndex()) {
+ AssetPatch patch = new AssetPatch(
+ asset.getId(),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ script.getZIndex(),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
+ }
+ }
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
return AssetView.fromScript(normalized, asset, script);
}
@@ -1377,9 +1409,9 @@ public class ChannelDirectoryService {
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments))
.filter(Objects::nonNull)
.sorted(
- Comparator.comparing((AssetView view) ->
- view.zIndex() == null ? Integer.MAX_VALUE : view.zIndex()
- ).thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder()))
+ Comparator.comparingInt((AssetView view) -> view.zIndex() == null ? Integer.MIN_VALUE : view.zIndex())
+ .reversed()
+ .thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.toList();
}
@@ -1398,6 +1430,18 @@ public class ChannelDirectoryService {
);
}
+ private int nextScriptZIndex(String broadcaster) {
+ return (
+ scriptAssetRepository
+ .findByIdIn(assetsWithType(normalize(broadcaster), AssetType.SCRIPT))
+ .stream()
+ .mapToInt(ScriptAsset::getZIndex)
+ .max()
+ .orElse(0) +
+ 1
+ );
+ }
+
private List assetsWithType(String broadcaster, AssetType... types) {
Set typeSet = EnumSet.noneOf(AssetType.class);
typeSet.addAll(Arrays.asList(types));
diff --git a/src/main/resources/db/migration/V5__script_asset_layer.sql b/src/main/resources/db/migration/V5__script_asset_layer.sql
new file mode 100644
index 0000000..fbd5a5e
--- /dev/null
+++ b/src/main/resources/db/migration/V5__script_asset_layer.sql
@@ -0,0 +1,5 @@
+ALTER TABLE script_assets ADD COLUMN IF NOT EXISTS z_index INTEGER NOT NULL DEFAULT 1;
+
+UPDATE script_assets
+SET z_index = 1
+WHERE z_index IS NULL;
diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css
index b73a271..8dc5eeb 100644
--- a/src/main/resources/static/css/styles.css
+++ b/src/main/resources/static/css/styles.css
@@ -1740,10 +1740,19 @@ button:disabled:hover {
.asset-list {
display: flex;
flex-direction: column;
+ list-style: none;
padding: 0;
margin: 0;
}
+.asset-section {
+ font-size: 12px;
+ letter-spacing: 0.3px;
+ text-transform: uppercase;
+ color: rgba(148, 163, 184, 0.9);
+ padding: 4px 10px 0;
+}
+
.asset-item {
display: flex;
flex-direction: column;
diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js
index 121fbd5..89dd766 100644
--- a/src/main/resources/static/js/admin/console.js
+++ b/src/main/resources/static/js/admin/console.js
@@ -5,6 +5,7 @@ import {
ensureLayerPosition as ensureLayerPositionForState,
getLayerOrder as getLayerOrderForState,
getRenderOrder as getRenderOrderForState,
+ getScriptLayerOrder as getScriptLayerOrderForState,
} from "../broadcast/layers.js";
export function createAdminConsole({
@@ -80,6 +81,7 @@ export function createAdminConsole({
let drawPending = false;
let layerOrder = [];
+ let scriptLayerOrder = [];
const layerState = {
assets,
get layerOrder() {
@@ -88,6 +90,12 @@ export function createAdminConsole({
set layerOrder(value) {
layerOrder = value;
},
+ get scriptLayerOrder() {
+ return scriptLayerOrder;
+ },
+ set scriptLayerOrder(value) {
+ scriptLayerOrder = value;
+ },
};
let pendingUploads = [];
let selectedAssetId = null;
@@ -178,12 +186,22 @@ export function createAdminConsole({
return getLayerOrderForState(layerState);
}
+ function getScriptLayerOrder() {
+ return getScriptLayerOrderForState(layerState);
+ }
+
function getAssetsByLayer() {
return getLayerOrder()
.map((id) => assets.get(id))
.filter(Boolean);
}
+ function getScriptAssetsByLayer() {
+ return getScriptLayerOrder()
+ .map((id) => assets.get(id))
+ .filter(Boolean);
+ }
+
function getAudioAssets() {
return Array.from(assets.values())
.filter((asset) => isAudioAsset(asset))
@@ -191,9 +209,7 @@ export function createAdminConsole({
}
function getCodeAssets() {
- return Array.from(assets.values())
- .filter((asset) => isCodeAsset(asset))
- .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
+ return getScriptAssetsByLayer();
}
function getRenderOrder() {
@@ -211,6 +227,17 @@ export function createAdminConsole({
return order.length - index;
}
+ function getScriptLayerValue(assetId) {
+ const asset = assets.get(assetId);
+ if (!asset || !isCodeAsset(asset)) {
+ return 0;
+ }
+ const order = getScriptLayerOrder();
+ const index = order.indexOf(assetId);
+ if (index === -1) return 1;
+ return order.length - index;
+ }
+
function addPendingUpload(name) {
const pending = {
id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`,
@@ -547,6 +574,7 @@ export function createAdminConsole({
function renderAssets(list) {
layerOrder = [];
+ scriptLayerOrder = [];
list.forEach((item) => storeAsset(item, { placement: "append" }));
drawAndList();
}
@@ -594,6 +622,7 @@ export function createAdminConsole({
if (event.type === "DELETED") {
assets.delete(assetId);
layerOrder = layerOrder.filter((id) => id !== assetId);
+ scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId);
clearMedia(assetId);
renderStates.delete(assetId);
loopPlaybackState.delete(assetId);
@@ -628,6 +657,7 @@ export function createAdminConsole({
}
const merged = { ...existing, ...patch };
const isAudio = isAudioAsset(merged);
+ const isScript = isCodeAsset(merged);
if (patch.hidden) {
clearMedia(assetId);
loopPlaybackState.delete(assetId);
@@ -641,10 +671,17 @@ export function createAdminConsole({
targetLayer = null;
}
if (!isAudio && Number.isFinite(targetLayer)) {
- const currentOrder = getLayerOrder().filter((id) => id !== assetId);
- const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
- currentOrder.splice(insertIndex, 0, assetId);
- layerOrder = currentOrder;
+ if (isScript) {
+ const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId);
+ const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
+ 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));
+ currentOrder.splice(insertIndex, 0, assetId);
+ layerOrder = currentOrder;
+ }
}
storeAsset(merged);
if (!isAudio) {
@@ -1298,6 +1335,138 @@ export function createAdminConsole({
}
}
+ function getLayerDetail(asset) {
+ if (!asset || isAudioAsset(asset)) {
+ return null;
+ }
+ if (isCodeAsset(asset)) {
+ return `Script layer ${getScriptLayerValue(asset.id)}`;
+ }
+ return `Layer ${getLayerValue(asset.id)}`;
+ }
+
+ function createSectionHeader(title) {
+ const li = document.createElement("li");
+ li.className = "asset-section";
+ li.textContent = title;
+ return li;
+ }
+
+ function appendAssetListItem(list, asset) {
+ const li = document.createElement("li");
+ li.className = "asset-item";
+ if (asset.id === selectedAssetId) {
+ li.classList.add("selected");
+ }
+ li.classList.toggle("is-hidden", !!asset.hidden);
+
+ const row = document.createElement("div");
+ row.className = "asset-row";
+
+ const preview = createPreviewElement(asset);
+
+ const meta = document.createElement("div");
+ meta.className = "meta";
+ const name = document.createElement("strong");
+ name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
+ const details = document.createElement("small");
+ const layerDetail = getLayerDetail(asset);
+ details.textContent = layerDetail ? `${getAssetTypeLabel(asset)} ยท ${layerDetail}` : getAssetTypeLabel(asset);
+ meta.appendChild(name);
+ meta.appendChild(details);
+
+ const actions = document.createElement("div");
+ actions.className = "actions";
+
+ if (isCodeAsset(asset)) {
+ const editBtn = document.createElement("button");
+ editBtn.type = "button";
+ editBtn.className = "ghost icon-button";
+ editBtn.innerHTML = '';
+ editBtn.title = "Edit script";
+ editBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ customAssetModal?.openEditor?.(asset);
+ });
+ actions.appendChild(editBtn);
+ }
+
+ if (!isAudioAsset(asset)) {
+ const moveUp = document.createElement("button");
+ moveUp.type = "button";
+ moveUp.className = "ghost icon-button";
+ moveUp.innerHTML = '';
+ moveUp.title = isCodeAsset(asset) ? "Move script up" : "Move layer up";
+ moveUp.addEventListener("click", (e) => {
+ e.stopPropagation();
+ moveLayerItem(asset, "up");
+ });
+ const moveDown = document.createElement("button");
+ moveDown.type = "button";
+ moveDown.className = "ghost icon-button";
+ moveDown.innerHTML = '';
+ moveDown.title = isCodeAsset(asset) ? "Move script down" : "Move layer down";
+ moveDown.addEventListener("click", (e) => {
+ e.stopPropagation();
+ moveLayerItem(asset, "down");
+ });
+ actions.appendChild(moveUp);
+ actions.appendChild(moveDown);
+ }
+
+ if (isAudioAsset(asset)) {
+ const playBtn = document.createElement("button");
+ playBtn.type = "button";
+ playBtn.className = "ghost icon-button";
+ const isLooping = !!asset.audioLoop;
+ const isPlayingLoop = getLoopPlaybackState(asset);
+ updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop);
+ playBtn.title = isLooping
+ ? isPlayingLoop
+ ? "Pause looping audio"
+ : "Play looping audio"
+ : "Play audio";
+ playBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true;
+ if (isLooping) {
+ loopPlaybackState.set(asset.id, nextPlay);
+ updatePlayButtonIcon(playBtn, true, nextPlay);
+ playBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio";
+ }
+ triggerAudioPlayback(asset, nextPlay);
+ });
+ actions.appendChild(playBtn);
+ }
+
+ if (!isAudioAsset(asset) && !isCodeAsset(asset)) {
+ const toggleBtn = document.createElement("button");
+ toggleBtn.type = "button";
+ toggleBtn.className = "ghost icon-button";
+ toggleBtn.innerHTML = ``;
+ toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset";
+ toggleBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ selectedAssetId = asset.id;
+ updateVisibility(asset, !asset.hidden);
+ });
+ actions.appendChild(toggleBtn);
+ }
+
+ row.appendChild(preview);
+ row.appendChild(meta);
+ row.appendChild(actions);
+
+ li.addEventListener("click", () => {
+ selectedAssetId = asset.id;
+ updateRenderState(asset);
+ drawAndList();
+ });
+
+ li.appendChild(row);
+ list.appendChild(li);
+ }
+
function renderAssetList() {
const list = document.getElementById("asset-list");
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) {
@@ -1334,99 +1503,20 @@ export function createAdminConsole({
const codeAssets = getCodeAssets();
const audioAssets = getAudioAssets();
- const sortedAssets = [...codeAssets, ...audioAssets, ...getAssetsByLayer()];
- sortedAssets.forEach((asset) => {
- const li = document.createElement("li");
- li.className = "asset-item";
- if (asset.id === selectedAssetId) {
- li.classList.add("selected");
- }
- li.classList.toggle("is-hidden", !!asset.hidden);
+ const visualAssets = getAssetsByLayer();
- const row = document.createElement("div");
- row.className = "asset-row";
-
- const preview = createPreviewElement(asset);
-
- const meta = document.createElement("div");
- meta.className = "meta";
- const name = document.createElement("strong");
- name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
- const details = document.createElement("small");
- details.textContent = getAssetTypeLabel(asset);
- meta.appendChild(name);
- meta.appendChild(details);
-
- const actions = document.createElement("div");
- actions.className = "actions";
-
- if (isCodeAsset(asset)) {
- const editBtn = document.createElement("button");
- editBtn.type = "button";
- editBtn.className = "ghost icon-button";
- editBtn.innerHTML = '';
- editBtn.title = "Edit script";
- editBtn.addEventListener("click", (e) => {
- e.stopPropagation();
- customAssetModal?.openEditor?.(asset);
- });
- actions.appendChild(editBtn);
- }
-
- if (isAudioAsset(asset)) {
- const playBtn = document.createElement("button");
- playBtn.type = "button";
- playBtn.className = "ghost icon-button";
- const isLooping = !!asset.audioLoop;
- const isPlayingLoop = getLoopPlaybackState(asset);
- updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop);
- playBtn.title = isLooping
- ? isPlayingLoop
- ? "Pause looping audio"
- : "Play looping audio"
- : "Play audio";
- playBtn.addEventListener("click", (e) => {
- e.stopPropagation();
- const nextPlay = isLooping
- ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset))
- : true;
- if (isLooping) {
- loopPlaybackState.set(asset.id, nextPlay);
- updatePlayButtonIcon(playBtn, true, nextPlay);
- playBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio";
- }
- triggerAudioPlayback(asset, nextPlay);
- });
- actions.appendChild(playBtn);
- }
-
- if (!isAudioAsset(asset) && !isCodeAsset(asset)) {
- const toggleBtn = document.createElement("button");
- toggleBtn.type = "button";
- toggleBtn.className = "ghost icon-button";
- toggleBtn.innerHTML = ``;
- toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset";
- toggleBtn.addEventListener("click", (e) => {
- e.stopPropagation();
- selectedAssetId = asset.id;
- updateVisibility(asset, !asset.hidden);
- });
- actions.appendChild(toggleBtn);
- }
-
- row.appendChild(preview);
- row.appendChild(meta);
- row.appendChild(actions);
-
- li.addEventListener("click", () => {
- selectedAssetId = asset.id;
- updateRenderState(asset);
- drawAndList();
- });
-
- li.appendChild(row);
- list.appendChild(li);
- });
+ if (visualAssets.length) {
+ list.appendChild(createSectionHeader("Canvas assets"));
+ visualAssets.forEach((asset) => appendAssetListItem(list, asset));
+ }
+ if (audioAssets.length) {
+ list.appendChild(createSectionHeader("Audio assets"));
+ audioAssets.forEach((asset) => appendAssetListItem(list, asset));
+ }
+ if (codeAssets.length) {
+ list.appendChild(createSectionHeader("Script assets (always on top)"));
+ codeAssets.forEach((asset) => appendAssetListItem(list, asset));
+ }
updateSelectedAssetControls();
}
@@ -1830,6 +1920,11 @@ export function createAdminConsole({
selectedAssetBadges.innerHTML = "";
if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
+ if (isCodeAsset(asset)) {
+ selectedAssetBadges.appendChild(createBadge(`Script layer ${getScriptLayerValue(asset.id)}`, "subtle"));
+ } else if (!isAudioAsset(asset)) {
+ selectedAssetBadges.appendChild(createBadge(`Layer ${getLayerValue(asset.id)}`, "subtle"));
+ }
const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
if (aspectLabel) {
selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle"));
@@ -2028,40 +2123,61 @@ export function createAdminConsole({
drawAndList();
}
+ function getLayeredAssets(asset) {
+ if (!asset) {
+ return [];
+ }
+ return isCodeAsset(asset) ? getScriptAssetsByLayer() : getAssetsByLayer();
+ }
+
+ function applyOrderForAsset(asset, ordered) {
+ if (!asset) return;
+ if (isCodeAsset(asset)) {
+ applyScriptLayerOrder(ordered);
+ } else {
+ applyLayerOrder(ordered);
+ }
+ }
+
+ function moveLayerItem(asset, direction) {
+ if (!asset) return;
+ const ordered = getLayeredAssets(asset);
+ const index = ordered.findIndex((item) => item.id === asset.id);
+ if (index === -1) return;
+ const nextIndex = direction === "up" ? index - 1 : index + 1;
+ if (nextIndex < 0 || nextIndex >= ordered.length) {
+ return;
+ }
+ [ordered[index], ordered[nextIndex]] = [ordered[nextIndex], ordered[index]];
+ applyOrderForAsset(asset, ordered);
+ }
+
function bringForward() {
const asset = getSelectedAsset();
- if (!asset) return;
- const ordered = getAssetsByLayer();
- const index = ordered.findIndex((item) => item.id === asset.id);
- if (index <= 0) return;
- [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]];
- applyLayerOrder(ordered);
+ if (!asset || isAudioAsset(asset)) return;
+ moveLayerItem(asset, "up");
}
function bringBackward() {
const asset = getSelectedAsset();
- if (!asset) return;
- const ordered = getAssetsByLayer();
- const index = ordered.findIndex((item) => item.id === asset.id);
- if (index === -1 || index === ordered.length - 1) return;
- [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]];
- applyLayerOrder(ordered);
+ if (!asset || isAudioAsset(asset)) return;
+ moveLayerItem(asset, "down");
}
function bringToFront() {
const asset = getSelectedAsset();
- if (!asset) return;
- const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id);
+ if (!asset || isAudioAsset(asset)) return;
+ const ordered = getLayeredAssets(asset).filter((item) => item.id !== asset.id);
ordered.unshift(asset);
- applyLayerOrder(ordered);
+ applyOrderForAsset(asset, ordered);
}
function sendToBack() {
const asset = getSelectedAsset();
- if (!asset) return;
- const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id);
+ if (!asset || isAudioAsset(asset)) return;
+ const ordered = getLayeredAssets(asset).filter((item) => item.id !== asset.id);
ordered.push(asset);
- applyLayerOrder(ordered);
+ applyOrderForAsset(asset, ordered);
}
globalThis.handleFileSelection = handleFileSelection;
@@ -2081,6 +2197,14 @@ export function createAdminConsole({
drawAndList();
}
+ function applyScriptLayerOrder(ordered) {
+ 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));
+ drawAndList();
+ }
+
function getAssetAspectRatio(asset) {
const media = ensureMedia(asset);
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
@@ -2202,6 +2326,7 @@ export function createAdminConsole({
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);
if (selectedAssetId === asset.id) {
selectedAssetId = null;
@@ -2319,7 +2444,10 @@ export function createAdminConsole({
audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume,
};
- if (!isAudioAsset(asset) && !isCodeAsset(asset)) {
+ if (isCodeAsset(asset)) {
+ const layer = getScriptLayerValue(asset.id);
+ payload.zIndex = layer;
+ } else if (!isAudioAsset(asset)) {
const layer = getLayerValue(asset.id);
payload.x = asset.x;
payload.y = asset.y;
diff --git a/src/main/resources/static/js/broadcast/layers.js b/src/main/resources/static/js/broadcast/layers.js
index d7ef59c..742fdb2 100644
--- a/src/main/resources/static/js/broadcast/layers.js
+++ b/src/main/resources/static/js/broadcast/layers.js
@@ -1,41 +1,83 @@
-import { isVisualAsset } from "./assetKinds.js";
+import { isCodeAsset, isVisualAsset } from "./assetKinds.js";
+
+function isScriptAsset(asset) {
+ return isCodeAsset(asset);
+}
+
+function isLayerableVisual(asset) {
+ return isVisualAsset(asset) && !isScriptAsset(asset);
+}
+
+function getLayerBucket(state, asset) {
+ if (isScriptAsset(asset)) {
+ if (!Array.isArray(state.scriptLayerOrder)) {
+ state.scriptLayerOrder = [];
+ }
+ return state.scriptLayerOrder;
+ }
+ if (isLayerableVisual(asset)) {
+ return state.layerOrder;
+ }
+ return null;
+}
+
+function normalizeOrder(state, predicate, existing) {
+ const filtered = existing.filter((id) => {
+ const asset = state.assets.get(id);
+ return asset && predicate(asset);
+ });
+ state.assets.forEach((asset, id) => {
+ if (!predicate(asset)) {
+ return;
+ }
+ if (!filtered.includes(id)) {
+ filtered.push(id);
+ }
+ });
+ return filtered;
+}
export function ensureLayerPosition(state, assetId, placement = "keep") {
const asset = state.assets.get(assetId);
- if (asset && !isVisualAsset(asset)) {
+ if (!asset) {
return;
}
- const existingIndex = state.layerOrder.indexOf(assetId);
+ const bucket = getLayerBucket(state, asset);
+ if (!bucket) {
+ return;
+ }
+ const existingIndex = bucket.indexOf(assetId);
if (existingIndex !== -1 && placement === "keep") {
return;
}
if (existingIndex !== -1) {
- state.layerOrder.splice(existingIndex, 1);
+ bucket.splice(existingIndex, 1);
}
if (placement === "append") {
- state.layerOrder.push(assetId);
+ bucket.push(assetId);
} else {
- state.layerOrder.unshift(assetId);
+ bucket.unshift(assetId);
+ }
+ if (bucket === state.layerOrder) {
+ state.layerOrder = normalizeOrder(state, isLayerableVisual, bucket);
+ } else {
+ state.scriptLayerOrder = normalizeOrder(state, isScriptAsset, bucket);
}
- state.layerOrder = state.layerOrder.filter((id) => state.assets.has(id));
}
export function getLayerOrder(state) {
- state.layerOrder = state.layerOrder.filter((id) => {
- const asset = state.assets.get(id);
- return asset && isVisualAsset(asset);
- });
- state.assets.forEach((asset, id) => {
- if (!isVisualAsset(asset)) {
- return;
- }
- if (!state.layerOrder.includes(id)) {
- state.layerOrder.unshift(id);
- }
- });
+ state.layerOrder = normalizeOrder(state, isLayerableVisual, state.layerOrder);
return state.layerOrder;
}
+export function getScriptLayerOrder(state) {
+ if (!Array.isArray(state.scriptLayerOrder)) {
+ state.scriptLayerOrder = [];
+ }
+ state.scriptLayerOrder = normalizeOrder(state, isScriptAsset, state.scriptLayerOrder);
+ return state.scriptLayerOrder;
+}
+
export function getRenderOrder(state) {
return [...getLayerOrder(state)]
.reverse()
diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js
index ce78df1..5e1bfbc 100644
--- a/src/main/resources/static/js/broadcast/renderer.js
+++ b/src/main/resources/static/js/broadcast/renderer.js
@@ -1,7 +1,7 @@
import { AssetKind, MIN_FRAME_TIME, VISIBILITY_THRESHOLD } from "./constants.js";
import { createBroadcastState } from "./state.js";
import { getAssetKind, isCodeAsset, isModelAsset, isVisualAsset, isVideoElement } from "./assetKinds.js";
-import { ensureLayerPosition, getLayerOrder, getRenderOrder } from "./layers.js";
+import { ensureLayerPosition, getLayerOrder, getRenderOrder, getScriptLayerOrder } from "./layers.js";
import { getVisibilityState, smoothState } from "./visibility.js";
import { createAudioManager } from "./audioManager.js";
import { createMediaManager } from "./mediaManager.js";
@@ -89,6 +89,7 @@ export class BroadcastRenderer {
renderAssets(list) {
this.state.layerOrder = [];
+ this.state.scriptLayerOrder = [];
list.forEach((asset) => {
this.storeAsset(asset, "append");
if (isCodeAsset(asset)) {
@@ -119,6 +120,7 @@ export class BroadcastRenderer {
removeAsset(assetId) {
this.state.assets.delete(assetId);
this.state.layerOrder = this.state.layerOrder.filter((id) => id !== assetId);
+ this.state.scriptLayerOrder = this.state.scriptLayerOrder.filter((id) => id !== assetId);
this.mediaManager.clearMedia(assetId);
this.modelManager.clearModel(assetId);
this.stopUserJavaScriptWorker(assetId);
@@ -284,6 +286,7 @@ export class BroadcastRenderer {
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
console.log(merged);
const isVisual = isVisualAsset(merged);
+ const isScript = isCodeAsset(merged);
if (sanitizedPatch.hidden) {
this.hideAssetWithTransition(merged);
return;
@@ -293,11 +296,19 @@ export class BroadcastRenderer {
: Number.isFinite(patch.zIndex)
? patch.zIndex
: null;
- if (isVisual && Number.isFinite(targetLayer)) {
- const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId);
- const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
- currentOrder.splice(insertIndex, 0, assetId);
- this.state.layerOrder = currentOrder;
+ if (Number.isFinite(targetLayer)) {
+ if (isScript) {
+ const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId);
+ const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
+ 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));
+ currentOrder.splice(insertIndex, 0, assetId);
+ this.state.layerOrder = currentOrder;
+ }
}
this.storeAsset(merged);
this.mediaManager.ensureMedia(merged);
@@ -767,6 +778,7 @@ export class BroadcastRenderer {
}
const existing = this.scriptCanvases.get(assetId);
if (existing) {
+ this.applyScriptCanvasOrder();
return existing;
}
const canvas = document.createElement("canvas");
@@ -775,6 +787,7 @@ export class BroadcastRenderer {
this.applyScriptCanvasSize(canvas);
this.scriptLayer.appendChild(canvas);
this.scriptCanvases.set(assetId, canvas);
+ this.applyScriptCanvasOrder();
return canvas;
}
@@ -785,12 +798,14 @@ export class BroadcastRenderer {
}
canvas.remove();
this.scriptCanvases.delete(assetId);
+ this.applyScriptCanvasOrder();
}
resizeScriptCanvases() {
this.scriptCanvases.forEach((canvas) => {
this.applyScriptCanvasSize(canvas);
});
+ this.applyScriptCanvasOrder();
}
applyScriptCanvasSize(canvas) {
@@ -807,6 +822,21 @@ export class BroadcastRenderer {
}
}
+ applyScriptCanvasOrder() {
+ if (!this.scriptLayer) {
+ return;
+ }
+ const ordered = getScriptLayerOrder(this.state);
+ ordered.forEach((id, index) => {
+ const canvas = this.scriptCanvases.get(id);
+ if (!canvas) {
+ return;
+ }
+ canvas.style.zIndex = `${ordered.length - index}`;
+ this.scriptLayer.appendChild(canvas);
+ });
+ }
+
async resolveScriptAttachments(attachments) {
if (!Array.isArray(attachments) || attachments.length === 0) {
return [];
diff --git a/src/main/resources/static/js/broadcast/state.js b/src/main/resources/static/js/broadcast/state.js
index 6b8e845..8ece1e4 100644
--- a/src/main/resources/static/js/broadcast/state.js
+++ b/src/main/resources/static/js/broadcast/state.js
@@ -10,5 +10,6 @@ export function createBroadcastState() {
animationFailures: new Map(),
videoPlaybackStates: new WeakMap(),
layerOrder: [],
+ scriptLayerOrder: [],
};
}
diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html
index 06a3688..625bc6b 100644
--- a/src/main/resources/templates/admin.html
+++ b/src/main/resources/templates/admin.html
@@ -251,6 +251,7 @@
onclick="sendToBack()"
class="secondary"
title="Send to back"
+ data-code-enabled="true"
>
@@ -259,6 +260,7 @@
onclick="bringBackward()"
class="secondary"
title="Move backward"
+ data-code-enabled="true"
>
@@ -267,6 +269,7 @@
onclick="bringForward()"
class="secondary"
title="Move forward"
+ data-code-enabled="true"
>
@@ -275,6 +278,7 @@
onclick="bringToFront()"
class="secondary"
title="Bring to front"
+ data-code-enabled="true"
>