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" >