Fix settings

This commit is contained in:
2025-12-10 23:37:08 +01:00
parent d68538d285
commit 96f497c20a
2 changed files with 239 additions and 110 deletions

View File

@@ -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,13 +503,25 @@ 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);
if (!isAudio) {
updateRenderState(merged);
}
}
function drawAndList() {
requestDraw();
@@ -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
? `<i class="fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}"></i>`
: '<i class="fa-solid fa-eye-slash"></i>';
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 = `<i class="fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}"></i>`;
selectedVisibilityBtn.onclick = () => updateVisibility(asset, !asset.hidden);
} else {
selectedVisibilityBtn.title = 'Toggle visibility';
selectedVisibilityBtn.innerHTML = '<i class="fa-solid fa-eye-slash"></i>';
}
}
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,

View File

@@ -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) {