diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js
index 6e92a6a..3f6f9cd 100644
--- a/src/main/resources/static/js/admin.js
+++ b/src/main/resources/static/js/admin.js
@@ -16,8 +16,7 @@ const loopPlaybackState = new Map();
const previewCache = new Map();
const previewImageCache = new Map();
let drawPending = false;
-let zOrderDirty = true;
-let zOrderCache = [];
+let layerOrder = [];
let selectedAssetId = null;
let interactionState = null;
let lastSizeInputChanged = null;
@@ -26,6 +25,7 @@ const ROTATE_HANDLE_OFFSET = 32;
const MAX_VOLUME = 2;
const VOLUME_SLIDER_MAX = 200;
const VOLUME_CURVE_STRENGTH = -0.6;
+const pendingTransformSaves = new Map();
const controlsPanel = document.getElementById('asset-controls');
@@ -84,6 +84,85 @@ function debounce(fn, wait = 150) {
};
}
+function schedulePersistTransform(asset, silent = false, delay = 200) {
+ if (!asset?.id) return;
+ cancelPendingTransform(asset.id);
+ const timeout = setTimeout(() => {
+ pendingTransformSaves.delete(asset.id);
+ persistTransform(asset, silent);
+ }, delay);
+ pendingTransformSaves.set(asset.id, timeout);
+}
+
+function cancelPendingTransform(assetId) {
+ const pending = pendingTransformSaves.get(assetId);
+ if (pending) {
+ clearTimeout(pending);
+ pendingTransformSaves.delete(assetId);
+ }
+}
+
+function ensureLayerPosition(assetId, placement = 'keep') {
+ const asset = assets.get(assetId);
+ if (asset && isAudioAsset(asset)) {
+ return;
+ }
+ const existingIndex = layerOrder.indexOf(assetId);
+ if (existingIndex !== -1 && placement === 'keep') {
+ return;
+ }
+ if (existingIndex !== -1) {
+ layerOrder.splice(existingIndex, 1);
+ }
+ if (placement === 'append') {
+ layerOrder.push(assetId);
+ } else {
+ layerOrder.unshift(assetId);
+ }
+ layerOrder = layerOrder.filter((id) => assets.has(id));
+}
+
+function getLayerOrder() {
+ layerOrder = layerOrder.filter((id) => {
+ const asset = assets.get(id);
+ return asset && !isAudioAsset(asset);
+ });
+ assets.forEach((asset, id) => {
+ if (isAudioAsset(asset)) {
+ return;
+ }
+ if (!layerOrder.includes(id)) {
+ layerOrder.unshift(id);
+ }
+ });
+ return layerOrder;
+}
+
+function getAssetsByLayer() {
+ return getLayerOrder().map((id) => assets.get(id)).filter(Boolean);
+}
+
+function getAudioAssets() {
+ return Array.from(assets.values())
+ .filter((asset) => isAudioAsset(asset))
+ .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
+}
+
+function getRenderOrder() {
+ return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean);
+}
+
+function getLayerValue(assetId) {
+ const asset = assets.get(assetId);
+ if (asset && isAudioAsset(asset)) {
+ return 0;
+ }
+ const order = getLayerOrder();
+ 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)}`,
@@ -263,13 +342,6 @@ if (audioPitchInput) audioPitchInput.addEventListener('input', () => {
setAudioPitchLabel(audioPitchInput.value);
updateAudioSettingsFromInputs();
});
-if (selectedVisibilityBtn) {
- selectedVisibilityBtn.addEventListener('click', () => {
- const asset = getSelectedAsset();
- if (!asset) return;
- updateVisibility(asset, !asset.hidden);
- });
-}
if (selectedDeleteBtn) {
selectedDeleteBtn.addEventListener('click', () => {
const asset = getSelectedAsset();
@@ -354,12 +426,14 @@ function resizeCanvas() {
}
function renderAssets(list) {
- list.forEach(storeAsset);
+ layerOrder = [];
+ list.forEach((item) => storeAsset(item, { placement: 'append' }));
drawAndList();
}
-function storeAsset(asset) {
+function storeAsset(asset, options = {}) {
if (!asset) return;
+ const placement = options.placement || 'keep';
const existing = assets.get(asset.id);
const merged = existing ? { ...existing, ...asset } : { ...asset };
const mediaChanged = existing && existing.url !== merged.url;
@@ -367,14 +441,13 @@ function storeAsset(asset) {
if (mediaChanged || previewChanged) {
clearMedia(asset.id);
}
- merged.zIndex = Math.max(1, merged.zIndex ?? 1);
const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN;
const hasCreatedAtMs = typeof merged.createdAtMs === 'number' && Number.isFinite(merged.createdAtMs);
if (!hasCreatedAtMs) {
merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now();
}
assets.set(asset.id, merged);
- zOrderDirty = true;
+ ensureLayerPosition(asset.id, existing ? 'keep' : placement);
if (!renderStates.has(asset.id)) {
renderStates.set(asset.id, { ...merged });
}
@@ -396,10 +469,11 @@ function handleEvent(event) {
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
if (event.type === 'DELETED') {
assets.delete(assetId);
- zOrderDirty = true;
+ layerOrder = layerOrder.filter((id) => id !== assetId);
clearMedia(assetId);
renderStates.delete(assetId);
loopPlaybackState.delete(assetId);
+ cancelPendingTransform(assetId);
if (selectedAssetId === assetId) {
selectedAssetId = null;
}
@@ -429,12 +503,24 @@ function applyPatch(assetId, patch) {
return;
}
const merged = { ...existing, ...patch };
+ const isAudio = isAudioAsset(merged);
if (patch.hidden) {
clearMedia(assetId);
loopPlaybackState.delete(assetId);
}
+ const targetLayer = Number.isFinite(patch.layer)
+ ? patch.layer
+ : (Number.isFinite(patch.zIndex) ? patch.zIndex : 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;
+ }
storeAsset(merged);
- updateRenderState(merged);
+ if (!isAudio) {
+ updateRenderState(merged);
+ }
}
function drawAndList() {
@@ -455,28 +541,7 @@ function requestDraw() {
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
- getZOrderedAssets().forEach((asset) => drawAsset(asset));
-}
-
-function getZOrderedAssets() {
- if (zOrderDirty) {
- zOrderCache = Array.from(assets.values()).sort(zComparator);
- zOrderDirty = false;
- }
- return zOrderCache;
-}
-
-function zComparator(a, b) {
- const aZ = a?.zIndex ?? 1;
- const bZ = b?.zIndex ?? 1;
- if (aZ !== bZ) {
- return aZ - bZ;
- }
- return (a?.createdAtMs || 0) - (b?.createdAtMs || 0);
-}
-
-function getChronologicalAssets() {
- return Array.from(assets.values()).sort((a, b) => (a?.createdAtMs || 0) - (b?.createdAtMs || 0));
+ getRenderOrder().forEach((asset) => drawAsset(asset));
}
function drawAsset(asset) {
@@ -1096,7 +1161,8 @@ function renderAssetList() {
list.appendChild(createPendingListItem(pending));
});
- const sortedAssets = getChronologicalAssets();
+ const audioAssets = getAudioAssets();
+ const sortedAssets = [...audioAssets, ...getAssetsByLayer()];
sortedAssets.forEach((asset) => {
const li = document.createElement('li');
li.className = 'asset-item';
@@ -1119,6 +1185,16 @@ function renderAssetList() {
meta.appendChild(name);
meta.appendChild(details);
+ const badges = document.createElement('div');
+ badges.className = 'badge-row asset-detail';
+ const durationLabel = getDurationBadge(asset);
+ if (durationLabel) {
+ badges.appendChild(createBadge(durationLabel, 'subtle'));
+ }
+ if (badges.children.length > 0) {
+ meta.appendChild(badges);
+ }
+
const actions = document.createElement('div');
actions.className = 'actions';
@@ -1436,7 +1512,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) {
controlsPanel.classList.remove('hidden');
lastSizeInputChanged = null;
if (selectedZLabel) {
- selectedZLabel.textContent = asset.zIndex ?? 1;
+ selectedZLabel.textContent = getLayerValue(asset.id);
}
if (widthInput) widthInput.value = Math.round(asset.width);
@@ -1545,9 +1621,6 @@ function updateSelectedAssetSummary(asset) {
selectedAssetBadges.innerHTML = '';
if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
- if (!isAudioAsset(asset)) {
- selectedAssetBadges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : ''));
- }
const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : '';
if (aspectLabel) {
selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle'));
@@ -1560,10 +1633,33 @@ function updateSelectedAssetSummary(asset) {
}
if (selectedVisibilityBtn) {
selectedVisibilityBtn.disabled = !asset;
- selectedVisibilityBtn.title = asset ? (asset.hidden ? 'Show asset' : 'Hide asset') : 'Toggle visibility';
- selectedVisibilityBtn.innerHTML = asset
- ? ``
- : '';
+ selectedVisibilityBtn.onclick = null;
+ if (asset && isAudioAsset(asset)) {
+ const isLooping = !!asset.audioLoop;
+ const isPlayingLoop = getLoopPlaybackState(asset);
+ updatePlayButtonIcon(selectedVisibilityBtn, isLooping, isPlayingLoop);
+ selectedVisibilityBtn.title = isLooping
+ ? (isPlayingLoop ? 'Pause looping audio' : 'Play looping audio')
+ : 'Play audio';
+ selectedVisibilityBtn.onclick = () => {
+ const nextPlay = isLooping
+ ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset))
+ : true;
+ if (isLooping) {
+ loopPlaybackState.set(asset.id, nextPlay);
+ updatePlayButtonIcon(selectedVisibilityBtn, true, nextPlay);
+ selectedVisibilityBtn.title = nextPlay ? 'Pause looping audio' : 'Play looping audio';
+ }
+ triggerAudioPlayback(asset, nextPlay);
+ };
+ } else if (asset) {
+ selectedVisibilityBtn.title = asset.hidden ? 'Show asset' : 'Hide asset';
+ selectedVisibilityBtn.innerHTML = ``;
+ selectedVisibilityBtn.onclick = () => updateVisibility(asset, !asset.hidden);
+ } else {
+ selectedVisibilityBtn.title = 'Toggle visibility';
+ selectedVisibilityBtn.innerHTML = '';
+ }
}
if (selectedDeleteBtn) {
selectedDeleteBtn.disabled = !asset;
@@ -1603,7 +1699,7 @@ function updatePlaybackFromInputs() {
setSpeedLabel(percent);
asset.speed = percent / 100;
updateRenderState(asset);
- persistTransform(asset);
+ schedulePersistTransform(asset);
const media = mediaCache.get(asset.id);
if (media) {
applyMediaSettings(media, asset);
@@ -1626,7 +1722,7 @@ function updateVolumeFromInput() {
const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset);
}
- persistTransform(asset);
+ schedulePersistTransform(asset);
drawAndList();
}
@@ -1648,7 +1744,7 @@ function updateAudioSettingsFromInputs() {
asset.audioPitch = Math.max(0.5, (nextAudioPitchPercent / 100));
const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset);
- persistTransform(asset);
+ schedulePersistTransform(asset);
drawAndList();
}
@@ -1677,52 +1773,45 @@ function recenterSelectedAsset() {
function bringForward() {
const asset = getSelectedAsset();
if (!asset) return;
- const ordered = [...getZOrderedAssets()];
+ 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]];
- applyZOrder(ordered);
+ if (index <= 0) return;
+ [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]];
+ applyLayerOrder(ordered);
}
function bringBackward() {
const asset = getSelectedAsset();
if (!asset) return;
- const ordered = [...getZOrderedAssets()];
+ 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]];
- applyZOrder(ordered);
+ if (index === -1 || index === ordered.length - 1) return;
+ [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]];
+ applyLayerOrder(ordered);
}
function bringToFront() {
const asset = getSelectedAsset();
if (!asset) return;
- const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id);
- ordered.push(asset);
- applyZOrder(ordered);
+ const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id);
+ ordered.unshift(asset);
+ applyLayerOrder(ordered);
}
function sendToBack() {
const asset = getSelectedAsset();
if (!asset) return;
- const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id);
- ordered.unshift(asset);
- applyZOrder(ordered);
+ const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id);
+ ordered.push(asset);
+ applyLayerOrder(ordered);
}
-function applyZOrder(ordered) {
- const changed = [];
- ordered.forEach((item, index) => {
- const nextIndex = index + 1;
- if ((item.zIndex ?? 1) !== nextIndex) {
- item.zIndex = nextIndex;
- changed.push(item);
- }
- assets.set(item.id, item);
- updateRenderState(item);
- });
- zOrderDirty = true;
- changed.forEach((item) => persistTransform(item, true));
+function applyLayerOrder(ordered) {
+ const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
+ layerOrder = newOrder;
+ const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
+ changed.forEach((item) => updateRenderState(item));
+ changed.forEach((item) => schedulePersistTransform(item, true));
drawAndList();
}
@@ -1845,7 +1934,8 @@ function deleteAsset(asset) {
clearMedia(asset.id);
assets.delete(asset.id);
renderStates.delete(asset.id);
- zOrderDirty = true;
+ layerOrder = layerOrder.filter((id) => id !== asset.id);
+ cancelPendingTransform(asset.id);
if (selectedAssetId === asset.id) {
selectedAssetId = null;
}
@@ -1939,12 +2029,13 @@ function isPointOnAsset(asset, x, y) {
}
function findAssetAtPoint(x, y) {
- const ordered = [...getZOrderedAssets()].reverse();
+ const ordered = getAssetsByLayer();
return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null;
}
function persistTransform(asset, silent = false) {
- asset.zIndex = Math.max(1, asset.zIndex ?? 1);
+ cancelPendingTransform(asset.id);
+ const layer = getLayerValue(asset.id);
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1955,7 +2046,8 @@ function persistTransform(asset, silent = false) {
height: asset.height,
rotation: asset.rotation,
speed: asset.speed,
- zIndex: asset.zIndex,
+ layer,
+ zIndex: layer,
audioLoop: asset.audioLoop,
audioDelayMillis: asset.audioDelayMillis,
audioSpeed: asset.audioSpeed,
diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js
index d66a5aa..1432c8a 100644
--- a/src/main/resources/static/js/broadcast.js
+++ b/src/main/resources/static/js/broadcast.js
@@ -19,10 +19,9 @@ const MIN_FRAME_TIME = 1000 / TARGET_FPS;
let lastRenderTime = 0;
let frameScheduled = false;
let pendingDraw = false;
-let sortedAssetsCache = [];
-let assetsDirty = true;
let renderIntervalId = null;
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
+let layerOrder = [];
audioUnlockEvents.forEach((eventName) => {
window.addEventListener(eventName, () => {
@@ -32,6 +31,46 @@ audioUnlockEvents.forEach((eventName) => {
});
});
+function ensureLayerPosition(assetId, placement = 'keep') {
+ const asset = assets.get(assetId);
+ if (asset && isAudioAsset(asset)) {
+ return;
+ }
+ const existingIndex = layerOrder.indexOf(assetId);
+ if (existingIndex !== -1 && placement === 'keep') {
+ return;
+ }
+ if (existingIndex !== -1) {
+ layerOrder.splice(existingIndex, 1);
+ }
+ if (placement === 'append') {
+ layerOrder.push(assetId);
+ } else {
+ layerOrder.unshift(assetId);
+ }
+ layerOrder = layerOrder.filter((id) => assets.has(id));
+}
+
+function getLayerOrder() {
+ layerOrder = layerOrder.filter((id) => {
+ const asset = assets.get(id);
+ return asset && !isAudioAsset(asset);
+ });
+ assets.forEach((asset, id) => {
+ if (isAudioAsset(asset)) {
+ return;
+ }
+ if (!layerOrder.includes(id)) {
+ layerOrder.unshift(id);
+ }
+ });
+ return layerOrder;
+}
+
+function getRenderOrder() {
+ return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean);
+}
+
function connect() {
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);
@@ -57,14 +96,17 @@ function connect() {
}
function renderAssets(list) {
- list.forEach(asset => {
- asset.zIndex = Math.max(1, asset.zIndex ?? 1);
- assets.set(asset.id, asset);
- });
- assetsDirty = true;
+ layerOrder = [];
+ list.forEach((asset) => storeAsset(asset, 'append'));
draw();
}
+function storeAsset(asset, placement = 'keep') {
+ if (!asset) return;
+ assets.set(asset.id, asset);
+ ensureLayerPosition(asset.id, placement);
+}
+
function fetchCanvasSettings() {
return fetch(`/api/channels/${broadcaster}/canvas`)
.then((r) => {
@@ -102,34 +144,35 @@ function handleEvent(event) {
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
if (event.type === 'DELETED') {
assets.delete(assetId);
+ layerOrder = layerOrder.filter((id) => id !== assetId);
clearMedia(assetId);
renderStates.delete(assetId);
} else if (event.patch) {
applyPatch(assetId, event.patch);
} else if (event.type === 'PLAY' && event.payload) {
const payload = normalizePayload(event.payload);
- assets.set(payload.id, payload);
+ storeAsset(payload);
if (isAudioAsset(payload)) {
handleAudioPlay(payload, event.play !== false);
}
} else if (event.payload && !event.payload.hidden) {
const payload = normalizePayload(event.payload);
- assets.set(payload.id, payload);
+ storeAsset(payload);
ensureMedia(payload);
if (isAudioAsset(payload)) {
playAudioImmediately(payload);
}
} else if (event.payload && event.payload.hidden) {
assets.delete(event.payload.id);
+ layerOrder = layerOrder.filter((id) => id !== event.payload.id);
clearMedia(event.payload.id);
renderStates.delete(event.payload.id);
}
- assetsDirty = true;
draw();
}
function normalizePayload(payload) {
- return { ...payload, zIndex: Math.max(1, payload.zIndex ?? 1) };
+ return { ...payload };
}
function applyPatch(assetId, patch) {
@@ -141,13 +184,24 @@ function applyPatch(assetId, patch) {
return;
}
const merged = normalizePayload({ ...existing, ...patch });
+ const isAudio = isAudioAsset(merged);
if (patch.hidden) {
assets.delete(assetId);
+ layerOrder = layerOrder.filter((id) => id !== assetId);
clearMedia(assetId);
renderStates.delete(assetId);
return;
}
- assets.set(assetId, merged);
+ const targetLayer = Number.isFinite(patch.layer)
+ ? patch.layer
+ : (Number.isFinite(patch.zIndex) ? patch.zIndex : 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;
+ }
+ storeAsset(merged);
ensureMedia(merged);
renderStates.set(assetId, { ...renderStates.get(assetId), ...pickTransform(merged) });
}
@@ -188,24 +242,7 @@ function draw() {
function renderFrame() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
- getZOrderedAssets().forEach(drawAsset);
-}
-
-function getZOrderedAssets() {
- if (assetsDirty) {
- sortedAssetsCache = Array.from(assets.values()).sort(zComparator);
- assetsDirty = false;
- }
- return sortedAssetsCache;
-}
-
-function zComparator(a, b) {
- const aZ = a?.zIndex ?? 1;
- const bZ = b?.zIndex ?? 1;
- if (aZ !== bZ) {
- return aZ - bZ;
- }
- return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0);
+ getRenderOrder().forEach(drawAsset);
}
function drawAsset(asset) {