Files
server/src/main/resources/static/js/admin.js
2026-01-08 13:18:14 +01:00

2456 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const canvas = document.getElementById("admin-canvas");
const ctx = canvas.getContext("2d");
const overlay = document.getElementById("admin-overlay");
let canvasSettings = { width: 1920, height: 1080 };
const assets = new Map();
const mediaCache = new Map();
const renderStates = new Map();
const animatedCache = new Map();
const audioControllers = new Map();
const pendingAudioUnlock = new Set();
const loopPlaybackState = new Map();
const previewCache = new Map();
const previewImageCache = new Map();
const pendingTransformSaves = new Map();
const HANDLE_SIZE = 10;
const ROTATE_HANDLE_OFFSET = 32;
const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100;
const VOLUME_CURVE_STRENGTH = -0.6;
const KEYBOARD_NUDGE_STEP = 5;
const KEYBOARD_NUDGE_FAST_STEP = 20;
const controlsPanel = document.getElementById("asset-controls");
const widthInput = document.getElementById("asset-width");
const heightInput = document.getElementById("asset-height");
const aspectLockInput = document.getElementById("maintain-aspect");
const speedInput = document.getElementById("asset-speed");
const speedLabel = document.getElementById("asset-speed-label");
const volumeInput = document.getElementById("asset-volume");
const volumeLabel = document.getElementById("asset-volume-label");
const selectedZLabel = document.getElementById("asset-z-level");
const playbackSection = document.getElementById("playback-section");
const volumeSection = document.getElementById("volume-section");
const audioSection = document.getElementById("audio-section");
const layoutSection = document.getElementById("layout-section");
const audioLoopInput = document.getElementById("asset-audio-loop");
const audioDelayInput = document.getElementById("asset-audio-delay");
const audioSpeedInput = document.getElementById("asset-audio-speed");
const audioSpeedLabel = document.getElementById("asset-audio-speed-label");
const audioPitchInput = document.getElementById("asset-audio-pitch");
const audioDelayLabel = document.getElementById("asset-audio-delay-label");
const audioPitchLabel = document.getElementById("asset-audio-pitch-label");
const controlsPlaceholder = document.getElementById("asset-controls-placeholder");
const fileNameLabel = document.getElementById("asset-file-name");
const assetInspector = document.getElementById("asset-inspector");
const selectedAssetName = document.getElementById("selected-asset-name");
const selectedAssetMeta = document.getElementById("selected-asset-meta");
const selectedAssetResolution = document.getElementById("selected-asset-resolution");
const selectedAssetIdLabel = document.getElementById("selected-asset-id");
const selectedAssetBadges = document.getElementById("selected-asset-badges");
const selectedEditBtn = document.getElementById("selected-asset-edit");
const selectedVisibilityBtn = document.getElementById("selected-asset-visibility");
const selectedDeleteBtn = document.getElementById("selected-asset-delete");
const assetActionRow = document.getElementById("asset-actions");
const assetActionButtons = Array.from(assetActionRow?.querySelectorAll("button") ?? []);
const canvasResolutionLabel = document.getElementById("canvas-resolution");
const canvasScaleLabel = document.getElementById("canvas-scale");
const aspectLockState = new Map();
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"];
let drawPending = false;
let layerOrder = [];
let pendingUploads = [];
let selectedAssetId = null;
let interactionState = null;
let lastSizeInputChanged = null;
let stompClient;
applyCanvasSettings(canvasSettings);
audioUnlockEvents.forEach((eventName) => {
globalThis.addEventListener(eventName, () => {
if (!pendingAudioUnlock.size) return;
pendingAudioUnlock.forEach((controller) => {
safePlay(controller);
});
pendingAudioUnlock.clear();
});
});
function debounce(fn, wait = 150) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), wait);
};
}
function isFormInputElement(element) {
if (!element) return false;
if (element.isContentEditable) return true;
const tag = element.tagName ? element.tagName.toLowerCase() : "";
return ["input", "textarea", "select", "button", "option"].includes(tag);
}
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) || isCodeAsset(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) && !isCodeAsset(asset);
});
assets.forEach((asset, id) => {
if (isAudioAsset(asset) || isCodeAsset(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 getCodeAssets() {
return Array.from(assets.values())
.filter((asset) => isCodeAsset(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) || isCodeAsset(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)}`,
name,
status: "uploading",
createdAtMs: Date.now(),
};
pendingUploads.push(pending);
renderAssetList();
return pending.id;
}
function updatePendingUpload(id, updates = {}) {
const pending = pendingUploads.find((item) => item.id === id);
if (!pending) return;
Object.assign(pending, updates);
renderAssetList();
}
function removePendingUpload(id) {
const index = pendingUploads.findIndex((item) => item.id === id);
if (index === -1) return;
pendingUploads.splice(index, 1);
renderAssetList();
}
function resolvePendingUploadByName(name) {
if (!name) return;
const index = pendingUploads.findIndex((item) => item.name === name);
if (index === -1) return;
pendingUploads.splice(index, 1);
renderAssetList();
}
function formatDurationLabel(durationMs) {
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
const parts = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0 || hours > 0) {
parts.push(`${minutes}m`);
}
if (seconds > 0 || parts.length === 0) {
parts.push(`${seconds}s`);
}
return parts.join(" ");
}
function recordDuration(assetId, seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return;
}
const asset = assets.get(assetId);
if (!asset) {
return;
}
const nextMs = Math.round(seconds * 1000);
if (asset.durationMs === nextMs) {
return;
}
asset.durationMs = nextMs;
if (asset.id === selectedAssetId) {
updateSelectedAssetSummary(asset);
}
drawAndList();
}
function hasDuration(asset) {
return (
asset &&
Number.isFinite(asset.durationMs) &&
asset.durationMs > 0 &&
(isAudioAsset(asset) || isVideoAsset(asset))
);
}
function getDurationBadge(asset) {
if (!hasDuration(asset)) {
return null;
}
return formatDurationLabel(asset.durationMs);
}
function setSpeedLabel(percent) {
if (!speedLabel) return;
speedLabel.textContent = `${Math.round(percent)}%`;
}
function setAudioSpeedLabel(percentValue) {
if (!audioSpeedLabel) return;
const multiplier = Math.max(0, percentValue) / 100;
const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2);
audioSpeedLabel.textContent = `${formatted}x`;
}
function formatDelayLabel(ms) {
const numeric = Math.max(0, Number.parseInt(ms, 10) || 0);
if (numeric >= 1000) {
const seconds = numeric / 1000;
const decimals = Number.isInteger(seconds) ? 0 : 1;
return `${seconds.toFixed(decimals)}s`;
}
return `${numeric}ms`;
}
function setAudioDelayLabel(value) {
if (!audioDelayLabel) return;
audioDelayLabel.textContent = formatDelayLabel(value);
}
function setAudioPitchLabel(percentValue) {
if (!audioPitchLabel) return;
const numeric = Math.round(Math.max(0, percentValue));
audioPitchLabel.textContent = `${numeric}%`;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function sliderToVolume(sliderValue) {
const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX;
const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized);
return clamp(
curved * SETTINGS.maxAssetVolumeFraction,
SETTINGS.minAssetVolumeFraction,
SETTINGS.maxAssetVolumeFraction,
);
}
function volumeToSlider(volumeValue) {
const target =
clamp(volumeValue ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction) /
SETTINGS.maxAssetVolumeFraction;
let low = 0;
let high = VOLUME_SLIDER_MAX;
for (let i = 0; i < 24; i += 1) {
const mid = (low + high) / 2;
const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction;
if (midNormalized < target) {
low = mid;
} else {
high = mid;
}
}
return Math.round(high);
}
function setVolumeLabel(sliderValue) {
if (!volumeLabel) return;
const volumePercent = Math.round(sliderToVolume(sliderValue) * 100);
volumeLabel.textContent = `${volumePercent}%`;
}
function queueAudioForUnlock(controller) {
if (!controller) return;
pendingAudioUnlock.add(controller);
}
function safePlay(controller) {
if (!controller?.element) return;
const playPromise = controller.element.play();
if (playPromise?.catch) {
playPromise.catch(() => queueAudioForUnlock(controller));
}
}
if (widthInput) widthInput.addEventListener("input", () => handleSizeInputChange("width"));
if (widthInput) widthInput.addEventListener("change", () => commitSizeChange());
if (heightInput) heightInput.addEventListener("input", () => handleSizeInputChange("height"));
if (heightInput) heightInput.addEventListener("change", () => commitSizeChange());
if (speedInput) speedInput.addEventListener("input", updatePlaybackFromInputs);
if (volumeInput) volumeInput.addEventListener("input", updateVolumeFromInput);
if (audioLoopInput) audioLoopInput.addEventListener("change", updateAudioSettingsFromInputs);
if (audioDelayInput)
audioDelayInput.addEventListener("input", () => {
setAudioDelayLabel(audioDelayInput.value);
updateAudioSettingsFromInputs();
});
if (audioSpeedInput)
audioSpeedInput.addEventListener("input", () => {
setAudioSpeedLabel(audioSpeedInput.value);
updateAudioSettingsFromInputs();
});
if (audioPitchInput)
audioPitchInput.addEventListener("input", () => {
setAudioPitchLabel(audioPitchInput.value);
updateAudioSettingsFromInputs();
});
if (selectedDeleteBtn) {
selectedDeleteBtn.addEventListener("click", () => {
const asset = getSelectedAsset();
if (!asset) return;
deleteAsset(asset);
});
}
globalThis.addEventListener("keydown", (event) => {
if (isFormInputElement(event.target)) {
return;
}
const asset = getSelectedAsset();
if ((event.key === "Delete" || event.key === "Backspace") && asset) {
event.preventDefault();
deleteAsset(asset);
return;
}
if (!asset || isAudioAsset(asset)) {
return;
}
const step = event.shiftKey ? KEYBOARD_NUDGE_FAST_STEP : KEYBOARD_NUDGE_STEP;
let moved = false;
switch (event.key) {
case "ArrowUp":
asset.y -= step;
moved = true;
break;
case "ArrowDown":
asset.y += step;
moved = true;
break;
case "ArrowLeft":
asset.x -= step;
moved = true;
break;
case "ArrowRight":
asset.x += step;
moved = true;
break;
default:
break;
}
if (moved) {
event.preventDefault();
updateRenderState(asset);
schedulePersistTransform(asset);
drawAndList();
}
});
function connect() {
const socket = new SockJS("/ws");
stompClient = Stomp.over(socket);
stompClient.connect(
{},
() => {
stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => {
const body = JSON.parse(payload.body);
handleEvent(body);
});
fetchAssets();
},
(error) => {
console.warn("WebSocket connection issue", error);
fetchAssets();
setTimeout(
() => showToast("Live updates connection interrupted. Retrying may be necessary.", "warning"),
1000,
);
},
);
}
function fetchAssets() {
fetch(`/api/channels/${broadcaster}/assets`)
.then((r) => {
if (!r.ok) {
throw new Error("Failed to load assets");
}
return r.json();
})
.then(renderAssets)
.catch(() => showToast("Unable to load assets. Please refresh.", "error"));
}
function fetchCanvasSettings() {
return fetch(`/api/channels/${broadcaster}/canvas`)
.then((r) => {
if (!r.ok) {
throw new Error("Failed to load canvas");
}
return r.json();
})
.then((settings) => {
applyCanvasSettings(settings);
})
.catch(() => {
resizeCanvas();
showToast("Using default canvas size. Unable to load saved settings.", "warning");
});
}
function applyCanvasSettings(settings) {
if (!settings) {
return;
}
const width = Number.isFinite(settings.width) ? settings.width : canvasSettings.width;
const height = Number.isFinite(settings.height) ? settings.height : canvasSettings.height;
canvasSettings = { width, height };
resizeCanvas();
}
function resizeCanvas() {
if (!overlay) {
return;
}
const bounds = overlay.getBoundingClientRect();
const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height);
const displayWidth = canvasSettings.width * scale;
const displayHeight = canvasSettings.height * scale;
canvas.width = canvasSettings.width;
canvas.height = canvasSettings.height;
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
canvas.style.left = `${(bounds.width - displayWidth) / 2}px`;
canvas.style.top = `${(bounds.height - displayHeight) / 2}px`;
if (canvasResolutionLabel) {
canvasResolutionLabel.textContent = `${canvasSettings.width} x ${canvasSettings.height}`;
}
if (canvasScaleLabel) {
canvasScaleLabel.textContent = `${Math.round(scale * 100)}%`;
}
requestDraw();
}
function renderAssets(list) {
layerOrder = [];
list.forEach((item) => storeAsset(item, { placement: "append" }));
drawAndList();
}
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;
const previewChanged = existing && existing.previewUrl !== merged.previewUrl;
if (mediaChanged || previewChanged) {
clearMedia(asset.id);
}
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);
ensureLayerPosition(asset.id, existing ? "keep" : placement);
if (!renderStates.has(asset.id)) {
renderStates.set(asset.id, { ...merged });
}
resolvePendingUploadByName(asset.name);
}
function updateRenderState(asset) {
if (!asset) return;
const state = renderStates.get(asset.id) || {};
state.x = asset.x;
state.y = asset.y;
state.width = asset.width;
state.height = asset.height;
state.rotation = asset.rotation;
renderStates.set(asset.id, state);
}
function handleEvent(event) {
if (event.type === "CANVAS" && event.payload) {
applyCanvasSettings(event.payload);
return;
}
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);
loopPlaybackState.delete(assetId);
cancelPendingTransform(assetId);
if (selectedAssetId === assetId) {
selectedAssetId = null;
}
} else if (event.patch) {
applyPatch(assetId, event.patch);
} else if (event.payload) {
storeAsset(event.payload);
if (!event.payload.hidden && !isVideoAsset(event.payload)) {
ensureMedia(event.payload);
if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) {
loopPlaybackState.set(event.payload.id, true);
}
} else {
clearMedia(event.payload.id);
loopPlaybackState.delete(event.payload.id);
}
}
drawAndList();
}
function applyPatch(assetId, patch) {
if (!assetId || !patch) {
return;
}
const existing = assets.get(assetId);
if (!existing) {
return;
}
const merged = { ...existing, ...patch };
const isAudio = isAudioAsset(merged);
if (patch.hidden) {
clearMedia(assetId);
loopPlaybackState.delete(assetId);
}
let targetLayer;
if (Number.isFinite(patch.layer)) {
targetLayer = patch.layer;
} else if (Number.isFinite(patch.zIndex)) {
targetLayer = patch.zIndex;
} else {
targetLayer = null;
}
if (!isAudio && Number.isFinite(targetLayer)) {
const 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();
renderAssetList();
}
function requestDraw() {
if (drawPending) {
return;
}
drawPending = true;
requestAnimationFrame(() => {
drawPending = false;
draw();
});
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
getRenderOrder().forEach((asset) => drawAsset(asset));
}
function drawAsset(asset) {
const renderState = smoothState(asset);
const halfWidth = renderState.width / 2;
const halfHeight = renderState.height / 2;
ctx.save();
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
ctx.rotate((renderState.rotation * Math.PI) / 180);
if (isCodeAsset(asset)) {
ctx.restore();
return;
}
if (isAudioAsset(asset)) {
autoStartAudio(asset);
ctx.restore();
return;
}
let drawSource = null;
let ready = false;
let showPlayOverlay = false;
if (isVideoAsset(asset) || isGifAsset(asset)) {
drawSource = ensureCanvasPreview(asset);
ready = isDrawable(drawSource);
showPlayOverlay = true;
} else {
const media = ensureMedia(asset);
drawSource = media?.isAnimated ? media.bitmap : media;
ready = isDrawable(media);
}
if (ready && drawSource) {
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
} else {
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
ctx.fillStyle = "rgba(124, 58, 237, 0.35)";
ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
}
if (asset.hidden) {
ctx.fillStyle = "rgba(15, 23, 42, 0.35)";
ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = asset.id === selectedAssetId ? "rgba(124, 58, 237, 0.9)" : "rgba(255, 255, 255, 0.4)";
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
if (showPlayOverlay) {
drawPlayOverlay(renderState);
}
if (asset.id === selectedAssetId) {
drawSelectionOverlay(renderState);
}
ctx.restore();
}
function smoothState(asset) {
const previous = renderStates.get(asset.id) || { ...asset };
const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18;
previous.x = lerp(previous.x, asset.x, factor);
previous.y = lerp(previous.y, asset.y, factor);
previous.width = lerp(previous.width, asset.width, factor);
previous.height = lerp(previous.height, asset.height, factor);
previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor);
renderStates.set(asset.id, previous);
return previous;
}
function smoothAngle(current, target, factor) {
let delta = ((target - current + 180) % 360) - 180;
return current + delta * factor;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function drawPlayOverlay(asset) {
const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2);
ctx.save();
ctx.fillStyle = "rgba(15, 23, 42, 0.35)";
ctx.beginPath();
ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(-size * 0.3, -size * 0.45);
ctx.lineTo(size * 0.55, 0);
ctx.lineTo(-size * 0.3, size * 0.45);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawSelectionOverlay(asset) {
const halfWidth = asset.width / 2;
const halfHeight = asset.height / 2;
ctx.save();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = "rgba(124, 58, 237, 0.9)";
ctx.lineWidth = 1.5;
ctx.strokeRect(-halfWidth, -halfHeight, asset.width, asset.height);
const handles = getHandlePositions(asset);
handles.forEach((handle) => {
drawHandle(handle.x - halfWidth, handle.y - halfHeight, false);
});
drawHandle(0, -halfHeight - ROTATE_HANDLE_OFFSET, true);
ctx.restore();
}
function drawHandle(x, y, isRotation) {
ctx.save();
ctx.setLineDash([]);
ctx.fillStyle = isRotation ? "rgba(96, 165, 250, 0.9)" : "rgba(124, 58, 237, 0.9)";
ctx.strokeStyle = "#0f172a";
ctx.lineWidth = 1;
if (isRotation) {
ctx.beginPath();
ctx.arc(x, y, HANDLE_SIZE * 0.65, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
ctx.strokeRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
}
ctx.restore();
}
function getHandlePositions(asset) {
return [
{ x: 0, y: 0, type: "nw" },
{ x: asset.width / 2, y: 0, type: "n" },
{ x: asset.width, y: 0, type: "ne" },
{ x: asset.width, y: asset.height / 2, type: "e" },
{ x: asset.width, y: asset.height, type: "se" },
{ x: asset.width / 2, y: asset.height, type: "s" },
{ x: 0, y: asset.height, type: "sw" },
{ x: 0, y: asset.height / 2, type: "w" },
];
}
function rotatePoint(x, y, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return {
x: x * cos - y * sin,
y: x * sin + y * cos,
};
}
function pointerToLocal(asset, point) {
const centerX = asset.x + asset.width / 2;
const centerY = asset.y + asset.height / 2;
const dx = point.x - centerX;
const dy = point.y - centerY;
const rotated = rotatePoint(dx, dy, -asset.rotation);
return {
x: rotated.x + asset.width / 2,
y: rotated.y + asset.height / 2,
};
}
function angleFromCenter(asset, point) {
const centerX = asset.x + asset.width / 2;
const centerY = asset.y + asset.height / 2;
return (Math.atan2(point.y - centerY, point.x - centerX) * 180) / Math.PI;
}
function hitHandle(asset, point) {
const local = pointerToLocal(asset, point);
const tolerance = HANDLE_SIZE * 1.2;
const rotationDistance = Math.hypot(local.x - asset.width / 2, local.y + ROTATE_HANDLE_OFFSET);
if (Math.abs(local.y + ROTATE_HANDLE_OFFSET) <= tolerance && rotationDistance <= tolerance * 1.5) {
return "rotate";
}
for (const handle of getHandlePositions(asset)) {
if (Math.abs(local.x - handle.x) <= tolerance && Math.abs(local.y - handle.y) <= tolerance) {
return handle.type;
}
}
return null;
}
function cursorForHandle(handle) {
switch (handle) {
case "nw":
case "se":
return "nwse-resize";
case "ne":
case "sw":
return "nesw-resize";
case "n":
case "s":
return "ns-resize";
case "e":
case "w":
return "ew-resize";
case "rotate":
return "grab";
default:
return "default";
}
}
function resizeFromHandle(state, point) {
const asset = assets.get(state.assetId);
if (!asset) return;
const basis = state.original;
const local = pointerToLocal(basis, point);
const handle = state.handle;
const minSize = 10;
let nextWidth = basis.width;
let nextHeight = basis.height;
let offsetX = 0;
let offsetY = 0;
if (handle.includes("e")) {
nextWidth = basis.width + (local.x - state.startLocal.x);
}
if (handle.includes("s")) {
nextHeight = basis.height + (local.y - state.startLocal.y);
}
if (handle.includes("w")) {
nextWidth = basis.width - (local.x - state.startLocal.x);
}
if (handle.includes("n")) {
nextHeight = basis.height - (local.y - state.startLocal.y);
}
const ratio = isAspectLocked(asset.id)
? getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1)
: null;
if (ratio) {
const widthChanged = handle.includes("e") || handle.includes("w");
const heightChanged = handle.includes("n") || handle.includes("s");
if (widthChanged && !heightChanged) {
nextHeight = nextWidth / ratio;
} else if (!widthChanged && heightChanged) {
nextWidth = nextHeight * ratio;
} else {
if (Math.abs(nextWidth - basis.width) > Math.abs(nextHeight - basis.height)) {
nextHeight = nextWidth / ratio;
} else {
nextWidth = nextHeight * ratio;
}
}
}
nextWidth = Math.max(minSize, nextWidth);
nextHeight = Math.max(minSize, nextHeight);
if (handle.includes("w")) {
offsetX = basis.width - nextWidth;
}
if (handle.includes("n")) {
offsetY = basis.height - nextHeight;
}
const shift = rotatePoint(offsetX, offsetY, basis.rotation);
asset.x = basis.x + shift.x;
asset.y = basis.y + shift.y;
asset.width = nextWidth;
asset.height = nextHeight;
updateRenderState(asset);
requestDraw();
}
function updateHoverCursor(point) {
const asset = getSelectedAsset();
if (asset) {
const handle = hitHandle(asset, point);
if (handle) {
canvas.style.cursor = cursorForHandle(handle);
return;
}
}
const hit = findAssetAtPoint(point.x, point.y);
canvas.style.cursor = hit ? "move" : "default";
}
function isVideoAsset(asset) {
const type = asset?.mediaType || asset?.originalMediaType || "";
return type.startsWith("video/");
}
function isAudioAsset(asset) {
const type = asset?.mediaType || asset?.originalMediaType || "";
return type.startsWith("audio/");
}
function isCodeAsset(asset) {
const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase();
return type.startsWith("application/javascript") || type.startsWith("text/javascript");
}
function isJavaScriptFile(file) {
if (!file) {
return false;
}
const type = (file.type || "").toLowerCase();
if (type.includes("javascript")) {
return true;
}
const name = (file.name || "").toLowerCase();
return name.endsWith(".js") || name.endsWith(".mjs");
}
function isVideoElement(element) {
return element && element.tagName === "VIDEO";
}
function getDisplayMediaType(asset) {
const raw = asset.originalMediaType || asset.mediaType || "";
if (!raw) {
return "Unknown";
}
if (isCodeAsset(asset)) {
return "JavaScript";
}
const parts = raw.split("/");
return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase();
}
function isGifAsset(asset) {
return asset?.mediaType?.toLowerCase() === "image/gif";
}
function getCachedSource(element) {
return element?.dataset?.sourceUrl || element?.src || null;
}
function isDrawable(element) {
if (!element) {
return false;
}
if (element.isAnimated) {
return !!element.bitmap;
}
if (isVideoElement(element)) {
return element.readyState >= 2;
}
if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) {
return true;
}
return !!element.complete;
}
function clearMedia(assetId) {
mediaCache.delete(assetId);
const cachedPreview = previewCache.get(assetId);
if (cachedPreview && cachedPreview.startsWith("blob:")) {
URL.revokeObjectURL(cachedPreview);
}
previewCache.delete(assetId);
previewImageCache.delete(assetId);
const animated = animatedCache.get(assetId);
if (animated) {
animated.cancelled = true;
clearTimeout(animated.timeout);
animated.bitmap?.close?.();
animated.decoder?.close?.();
animatedCache.delete(assetId);
}
const audio = audioControllers.get(assetId);
if (audio) {
if (audio.delayTimeout) {
clearTimeout(audio.delayTimeout);
}
audio.element.pause();
audio.element.currentTime = 0;
audioControllers.delete(assetId);
}
}
function ensureAudioController(asset) {
const cached = audioControllers.get(asset.id);
if (cached && cached.src === asset.url) {
applyAudioSettings(cached, asset);
return cached;
}
if (cached) {
clearMedia(asset.id);
}
const element = new Audio(asset.url);
element.autoplay = true;
element.controls = true;
element.preload = "auto";
element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration));
const controller = {
id: asset.id,
src: asset.url,
element,
delayTimeout: null,
loopEnabled: false,
delayMs: 0,
baseDelayMs: 0,
};
element.onended = () => handleAudioEnded(asset.id);
audioControllers.set(asset.id, controller);
applyAudioSettings(controller, asset, true);
return controller;
}
function applyAudioSettings(controller, asset, resetPosition = false) {
controller.loopEnabled = !!asset.audioLoop;
controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0);
controller.delayMs = controller.baseDelayMs;
const speed = Math.max(0.25, asset.audioSpeed || 1);
const pitch = Math.max(0.5, asset.audioPitch || 1);
controller.element.playbackRate = speed * pitch;
const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
controller.element.volume = volume;
if (resetPosition) {
controller.element.currentTime = 0;
controller.element.pause();
}
}
function handleAudioEnded(assetId) {
const controller = audioControllers.get(assetId);
if (!controller) return;
controller.element.currentTime = 0;
if (controller.delayTimeout) {
clearTimeout(controller.delayTimeout);
}
if (controller.loopEnabled) {
controller.delayTimeout = setTimeout(() => {
safePlay(controller);
}, controller.delayMs);
} else {
controller.element.pause();
}
}
function stopAudio(assetId) {
const controller = audioControllers.get(assetId);
if (!controller) return;
if (controller.delayTimeout) {
clearTimeout(controller.delayTimeout);
}
controller.element.pause();
controller.element.currentTime = 0;
controller.delayTimeout = null;
controller.delayMs = controller.baseDelayMs;
}
function autoStartAudio(asset) {
if (!isAudioAsset(asset) || asset.hidden) {
return;
}
ensureAudioController(asset);
}
function ensureMedia(asset) {
const cached = mediaCache.get(asset.id);
const cachedSource = getCachedSource(cached);
if (cached && cachedSource !== asset.url) {
clearMedia(asset.id);
}
if (cached && cachedSource === asset.url) {
applyMediaSettings(cached, asset);
return cached;
}
if (isAudioAsset(asset)) {
ensureAudioController(asset);
mediaCache.delete(asset.id);
return null;
}
if (isVideoAsset(asset)) {
return null;
}
if (isGifAsset(asset) && "ImageDecoder" in globalThis) {
const animated = ensureAnimatedImage(asset);
if (animated) {
mediaCache.set(asset.id, animated);
return animated;
}
}
const element = isVideoAsset(asset) ? document.createElement("video") : new Image();
element.dataset.sourceUrl = asset.url;
element.crossOrigin = "anonymous";
if (isVideoElement(element)) {
element.loop = true;
const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
element.muted = volume === 0;
element.volume = Math.min(volume, 1);
element.playsInline = true;
element.autoplay = false;
element.preload = "metadata";
element.onloadeddata = requestDraw;
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
element.src = asset.url;
const playback = asset.speed ?? 1;
element.playbackRate = Math.max(playback, 0.01);
element.pause();
} else {
element.onload = requestDraw;
element.src = asset.url;
}
mediaCache.set(asset.id, element);
return element;
}
function ensureAnimatedImage(asset) {
const cached = animatedCache.get(asset.id);
if (cached && cached.url === asset.url) {
return cached;
}
if (cached) {
clearMedia(asset.id);
}
const controller = {
id: asset.id,
url: asset.url,
src: asset.url,
decoder: null,
bitmap: null,
timeout: null,
cancelled: false,
isAnimated: true,
};
fetch(asset.url)
.then((r) => r.blob())
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" }))
.then((decoder) => {
if (controller.cancelled) {
decoder.close?.();
return null;
}
controller.decoder = decoder;
scheduleNextFrame(controller);
return controller;
})
.catch(() => {
animatedCache.delete(asset.id);
});
animatedCache.set(asset.id, controller);
return controller;
}
function scheduleNextFrame(controller) {
if (controller.cancelled || !controller.decoder) {
return;
}
controller.decoder
.decode()
.then(({ image, complete }) => {
if (controller.cancelled) {
image.close?.();
return;
}
controller.bitmap?.close?.();
createImageBitmap(image)
.then((bitmap) => {
controller.bitmap = bitmap;
requestDraw();
})
.finally(() => image.close?.());
const durationMicros = image.duration || 0;
const delay = durationMicros > 0 ? durationMicros / 1000 : 100;
const hasMore = !complete;
controller.timeout = setTimeout(() => {
if (controller.cancelled) {
return;
}
if (hasMore) {
scheduleNextFrame(controller);
} else {
controller.decoder.reset();
scheduleNextFrame(controller);
}
}, delay);
})
.catch(() => {
animatedCache.delete(controller.id);
});
}
function applyMediaSettings(element, asset) {
if (!isVideoElement(element)) {
return;
}
const nextSpeed = asset.speed ?? 1;
const effectiveSpeed = Math.max(nextSpeed, 0.01);
if (element.playbackRate !== effectiveSpeed) {
element.playbackRate = effectiveSpeed;
}
const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
element.muted = volume === 0;
element.volume = Math.min(volume, 1);
if (nextSpeed === 0) {
element.pause();
return;
}
const playPromise = element.play();
if (playPromise?.catch) {
playPromise.catch(() => {});
}
}
function renderAssetList() {
const list = document.getElementById("asset-list");
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) {
controlsPlaceholder.appendChild(controlsPanel);
}
if (controlsPanel) {
controlsPanel.classList.add("hidden");
}
list.innerHTML = "";
const hasAssets = assets.size > 0;
const hasPending = pendingUploads.length > 0;
if (!hasAssets && !hasPending) {
selectedAssetId = null;
if (assetInspector) {
assetInspector.classList.add("hidden");
}
const empty = document.createElement("li");
empty.textContent = "";
list.appendChild(empty);
updateSelectedAssetControls();
return;
}
if (assetInspector) {
assetInspector.classList.toggle("hidden", !hasAssets);
}
const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0));
pendingItems.forEach((pending) => {
list.appendChild(createPendingListItem(pending));
});
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 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 = isCodeAsset(asset)
? "JavaScript"
: `${Math.round(asset.width)}x${Math.round(asset.height)}`;
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 = '<i class="fa-solid fa-code"></i>';
editBtn.title = "Edit script";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
openCodeAssetEditor(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 = `<i class="fa-solid ${asset.hidden ? "fa-eye" : "fa-eye-slash"}"></i>`;
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);
});
updateSelectedAssetControls();
}
function createPendingListItem(pending) {
const li = document.createElement("li");
li.className = "asset-item pending";
const row = document.createElement("div");
row.className = "asset-row";
const preview = document.createElement("div");
preview.className = "asset-preview pending-preview";
preview.innerHTML = '<i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>';
const meta = document.createElement("div");
meta.className = "meta";
const name = document.createElement("strong");
name.textContent = pending?.name || "Uploading asset";
const details = document.createElement("small");
details.textContent = pending.status === "processing" ? "Processing upload…" : "Uploading…";
meta.appendChild(name);
meta.appendChild(details);
const progress = document.createElement("div");
progress.className = "upload-progress";
const bar = document.createElement("div");
bar.className = "upload-progress-bar";
if (pending.status === "processing") {
bar.classList.add("is-processing");
}
progress.appendChild(bar);
meta.appendChild(progress);
row.appendChild(preview);
row.appendChild(meta);
li.appendChild(row);
return li;
}
function createBadge(label, extraClass = "") {
const badge = document.createElement("span");
badge.className = `badge ${extraClass}`.trim();
badge.textContent = label;
return badge;
}
function getLoopPlaybackState(asset) {
if (!isAudioAsset(asset) || !asset.audioLoop) {
return false;
}
if (loopPlaybackState.has(asset.id)) {
return loopPlaybackState.get(asset.id);
}
const isVisible = asset.hidden === false || asset.hidden === undefined;
loopPlaybackState.set(asset.id, isVisible);
return isVisible;
}
function updatePlayButtonIcon(button, isLooping, isPlayingLoop) {
const icon = isLooping ? (isPlayingLoop ? "fa-pause" : "fa-play") : "fa-play";
button.innerHTML = `<i class="fa-solid ${icon}"></i>`;
}
function createPreviewElement(asset) {
if (isCodeAsset(asset)) {
const icon = document.createElement("div");
icon.className = "asset-preview code-icon";
icon.innerHTML = '<i class="fa-solid fa-code" aria-hidden="true"></i>';
return icon;
}
if (isAudioAsset(asset)) {
const icon = document.createElement("div");
icon.className = "asset-preview audio-icon";
icon.innerHTML = '<i class="fa-solid fa-music" aria-hidden="true"></i>';
return icon;
}
if (isVideoAsset(asset) || isGifAsset(asset)) {
const still = document.createElement("div");
still.className = "asset-preview still";
still.setAttribute("aria-label", asset.name || "Asset preview");
const overlay = document.createElement("div");
overlay.className = "preview-overlay";
overlay.innerHTML = '<i class="fa-solid fa-play"></i>';
still.appendChild(overlay);
loadPreviewFrame(asset, still);
return still;
}
const img = document.createElement("img");
img.className = "asset-preview";
img.src = asset.url;
img.alt = asset.name || "Asset preview";
img.loading = "lazy";
return img;
}
function fetchPreviewData(asset) {
if (!asset) return Promise.resolve(null);
const cached = previewCache.get(asset.id);
if (cached) {
return Promise.resolve(cached);
}
const fallback = () => {
const fallbackPromise = isVideoAsset(asset)
? captureVideoFrame(asset)
: isGifAsset(asset)
? captureGifFrame(asset)
: Promise.resolve(null);
return fallbackPromise.then((result) => {
if (!result) {
return null;
}
previewCache.set(asset.id, result);
return result;
});
};
if (!asset.previewUrl) {
return fallback();
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
previewCache.set(asset.id, asset.previewUrl);
resolve(asset.previewUrl);
};
img.onerror = () => fallback().then(resolve);
img.src = asset.previewUrl;
}).catch(() => null);
}
function loadPreviewFrame(asset, element) {
if (!asset || !element) return;
fetchPreviewData(asset)
.then((dataUrl) => {
if (!dataUrl) return;
applyPreviewFrame(element, dataUrl);
})
.catch(() => {});
}
function applyPreviewFrame(element, dataUrl) {
if (!element || !dataUrl) return;
element.style.backgroundImage = `url(${dataUrl})`;
element.classList.add("has-image");
}
function ensureCanvasPreview(asset) {
const cachedData = previewCache.get(asset.id);
const cachedImage = previewImageCache.get(asset.id);
if (cachedData && cachedImage?.src === cachedData) {
return cachedImage.image;
}
if (cachedData) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = requestDraw;
img.src = cachedData;
previewImageCache.set(asset.id, { src: cachedData, image: img });
return img;
}
fetchPreviewData(asset)
.then((dataUrl) => {
if (!dataUrl) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = requestDraw;
img.src = dataUrl;
previewImageCache.set(asset.id, { src: dataUrl, image: img });
})
.catch(() => {});
return null;
}
function captureVideoFrame(asset) {
return new Promise((resolve) => {
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.preload = "auto";
video.muted = true;
video.playsInline = true;
video.src = asset.url;
video.addEventListener("loadedmetadata", () => recordDuration(asset.id, video.duration), { once: true });
const cleanup = () => {
video.pause();
video.removeAttribute("src");
video.load();
};
video.addEventListener(
"loadeddata",
() => {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth || asset.width || 0;
canvas.height = video.videoHeight || asset.height || 0;
if (!canvas.width || !canvas.height) {
cleanup();
resolve(null);
return;
}
const context = canvas.getContext("2d");
context.drawImage(video, 0, 0, canvas.width, canvas.height);
try {
const dataUrl = canvas.toDataURL("image/png");
resolve(dataUrl);
} catch (err) {
resolve(null);
}
cleanup();
},
{ once: true },
);
video.addEventListener(
"error",
() => {
cleanup();
resolve(null);
},
{ once: true },
);
});
}
function captureGifFrame(asset) {
if (!("ImageDecoder" in globalThis)) {
return Promise.resolve(null);
}
return fetch(asset.url)
.then((r) => r.blob())
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" }))
.then((decoder) => decoder.decode({ frameIndex: 0 }))
.then(({ image }) => {
const canvas = document.createElement("canvas");
canvas.width = image.displayWidth || asset.width || 0;
canvas.height = image.displayHeight || asset.height || 0;
const ctx2d = canvas.getContext("2d");
ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height);
image.close?.();
try {
return canvas.toDataURL("image/png");
} catch (err) {
return null;
}
})
.catch(() => null);
}
function getSelectedAsset() {
return selectedAssetId ? assets.get(selectedAssetId) : null;
}
function updateSelectedAssetControls(asset = getSelectedAsset()) {
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) {
controlsPlaceholder.appendChild(controlsPanel);
}
updateSelectedAssetSummary(asset);
if (!controlsPanel || !asset) {
if (controlsPanel) controlsPanel.classList.add("hidden");
return;
}
controlsPanel.classList.remove("hidden");
lastSizeInputChanged = null;
if (selectedZLabel) {
selectedZLabel.textContent = getLayerValue(asset.id);
}
if (widthInput) widthInput.value = Math.round(asset.width);
if (heightInput) heightInput.value = Math.round(asset.height);
if (aspectLockInput) {
aspectLockInput.checked = isAspectLocked(asset.id);
aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked);
}
const hideLayout = isAudioAsset(asset) || isCodeAsset(asset);
if (layoutSection) {
layoutSection.classList.toggle("hidden", hideLayout);
const layoutControls = layoutSection.querySelectorAll("input, button");
layoutControls.forEach((control) => {
control.disabled = hideLayout;
control.classList.toggle("disabled", hideLayout);
});
}
if (assetActionButtons.length) {
assetActionButtons.forEach((button) => {
const allowForAudio = button.dataset.audioEnabled === "true";
const allowForCode = button.dataset.codeEnabled === "true";
const disableButton = hideLayout && !(allowForAudio || allowForCode);
button.disabled = disableButton;
button.classList.toggle("disabled", disableButton);
});
}
if (speedInput) {
const percent = Math.round((asset.speed ?? 1) * 100);
speedInput.value = Math.min(1000, Math.max(0, percent));
setSpeedLabel(speedInput.value);
}
if (playbackSection) {
const shouldShowPlayback = isVideoAsset(asset);
playbackSection.classList.toggle("hidden", !shouldShowPlayback);
speedInput?.classList?.toggle("disabled", !shouldShowPlayback);
}
if (volumeSection) {
const showVolume = isAudioAsset(asset) || isVideoAsset(asset);
volumeSection.classList.toggle("hidden", !showVolume);
const volumeControls = volumeSection.querySelectorAll("input");
volumeControls.forEach((control) => {
control.disabled = !showVolume;
control.classList.toggle("disabled", !showVolume);
});
if (showVolume && volumeInput) {
const sliderValue = volumeToSlider(asset.audioVolume ?? 1);
volumeInput.value = sliderValue;
setVolumeLabel(sliderValue);
}
}
if (audioSection) {
const showAudio = isAudioAsset(asset);
audioSection.classList.toggle("hidden", !showAudio);
const audioInputs = [audioLoopInput, audioDelayInput, audioSpeedInput, audioPitchInput];
audioInputs.forEach((input) => {
if (!input) return;
input.disabled = !showAudio;
input.parentElement?.classList?.toggle("disabled", !showAudio);
});
if (showAudio) {
audioLoopInput.checked = !!asset.audioLoop;
const delayMs = clamp(Math.max(0, asset.audioDelayMillis ?? 0), 0, 30000);
audioDelayInput.value = delayMs;
setAudioDelayLabel(delayMs);
const audioSpeedPercent = clamp(Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100), 25, 400);
audioSpeedInput.value = audioSpeedPercent;
setAudioSpeedLabel(audioSpeedPercent);
const pitchPercent = clamp(Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100), 50, 200);
audioPitchInput.value = pitchPercent;
setAudioPitchLabel(pitchPercent);
}
}
}
function updateSelectedAssetSummary(asset) {
if (assetInspector) {
assetInspector.classList.toggle("hidden", !asset && !assets.size);
}
ensureDurationMetadata(asset);
if (selectedAssetName) {
selectedAssetName.textContent = asset ? asset.name || `Asset ${asset.id.slice(0, 6)}` : "Choose an asset";
}
if (selectedAssetMeta) {
selectedAssetMeta.textContent = asset
? getDisplayMediaType(asset)
: "Pick an asset in the list to adjust its placement and playback.";
}
if (selectedAssetResolution) {
if (asset) {
if (isCodeAsset(asset)) {
selectedAssetResolution.textContent = "";
selectedAssetResolution.classList.add("hidden");
} else {
selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`;
selectedAssetResolution.classList.remove("hidden");
}
} else {
selectedAssetResolution.textContent = "";
selectedAssetResolution.classList.add("hidden");
}
}
if (selectedAssetIdLabel) {
if (asset) {
selectedAssetIdLabel.textContent = `ID: ${asset.id}`;
selectedAssetIdLabel.classList.remove("hidden");
} else {
selectedAssetIdLabel.classList.add("hidden");
selectedAssetIdLabel.textContent = "";
}
}
if (selectedAssetBadges) {
selectedAssetBadges.innerHTML = "";
if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
if (aspectLabel) {
selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle"));
}
const durationLabel = getDurationBadge(asset);
if (durationLabel) {
selectedAssetBadges.appendChild(createBadge(durationLabel, "subtle"));
}
}
}
if (selectedEditBtn) {
selectedEditBtn.disabled = !asset || !isCodeAsset(asset);
selectedEditBtn.onclick = null;
if (asset && isCodeAsset(asset)) {
selectedEditBtn.onclick = () => openCodeAssetEditor(asset);
}
}
if (selectedVisibilityBtn) {
selectedVisibilityBtn.disabled = !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 && isCodeAsset(asset)) {
selectedVisibilityBtn.disabled = true;
selectedVisibilityBtn.title = "Script assets do not render on the canvas";
selectedVisibilityBtn.innerHTML = '<i class="fa-solid fa-eye-slash"></i>';
} 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;
selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset";
}
}
function openCodeAssetEditor(asset) {
if (!asset) {
return;
}
const modal = document.getElementById("custom-asset-modal");
const nameInput = document.getElementById("custom-asset-name");
const codeInput = document.getElementById("custom-asset-code");
const errorWrapper = document.getElementById("custom-asset-error");
const errorTitle = document.getElementById("js-error-title");
const errorDetails = document.getElementById("js-error-details");
if (errorWrapper) {
errorWrapper.classList.add("hidden");
}
if (errorTitle) {
errorTitle.textContent = "";
}
if (errorDetails) {
errorDetails.textContent = "";
}
if (nameInput) {
nameInput.value = asset.name || "";
}
if (codeInput) {
codeInput.value = "";
codeInput.placeholder = "Loading script...";
codeInput.disabled = true;
codeInput.dataset.assetId = asset.id;
}
if (modal) {
modal.classList.remove("hidden");
}
fetch(asset.url)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load script");
}
return response.text();
})
.then((text) => {
if (codeInput) {
codeInput.disabled = false;
codeInput.value = text;
}
})
.catch(() => {
if (codeInput) {
codeInput.disabled = false;
codeInput.value = "";
}
showToast("Unable to load script content.", "error");
});
}
function ensureDurationMetadata(asset) {
if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) {
return;
}
const element = document.createElement(isVideoAsset(asset) ? "video" : "audio");
element.preload = "metadata";
element.muted = true;
element.playsInline = true;
element.src = asset.url;
const cleanup = () => {
element.removeAttribute("src");
element.load();
};
element.addEventListener(
"loadedmetadata",
() => {
recordDuration(asset.id, element.duration);
cleanup();
},
{ once: true },
);
element.addEventListener("error", cleanup, { once: true });
}
function applyTransformFromInputs() {
const asset = getSelectedAsset();
if (!asset) return;
const locked = isAspectLocked(asset.id);
const ratio = getAssetAspectRatio(asset);
let nextWidth = parseFloat(widthInput?.value) || asset.width;
let nextHeight = parseFloat(heightInput?.value) || asset.height;
if (locked && ratio) {
if (lastSizeInputChanged === "height") {
nextWidth = nextHeight * ratio;
if (widthInput) widthInput.value = Math.round(nextWidth);
} else {
nextHeight = nextWidth / ratio;
if (heightInput) heightInput.value = Math.round(nextHeight);
}
}
asset.width = Math.max(10, nextWidth);
asset.height = Math.max(10, nextHeight);
updateRenderState(asset);
persistTransform(asset);
drawAndList();
}
function updatePlaybackFromInputs() {
const asset = getSelectedAsset();
if (!asset || !isVideoAsset(asset)) return;
const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100));
setSpeedLabel(percent);
asset.speed = percent / 100;
updateRenderState(asset);
schedulePersistTransform(asset);
const media = mediaCache.get(asset.id);
if (media) {
applyMediaSettings(media, asset);
}
drawAndList();
}
function updateVolumeFromInput() {
const asset = getSelectedAsset();
if (!asset || !(isVideoAsset(asset) || isAudioAsset(asset))) return;
const sliderValue = Math.max(0, Math.min(VOLUME_SLIDER_MAX, parseFloat(volumeInput?.value) || 100));
const volumeValue = sliderToVolume(sliderValue);
setVolumeLabel(sliderValue);
asset.audioVolume = volumeValue;
const media = mediaCache.get(asset.id);
if (media) {
applyMediaSettings(media, asset);
}
if (isAudioAsset(asset)) {
const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset);
}
schedulePersistTransform(asset);
drawAndList();
}
function updateAudioSettingsFromInputs() {
const asset = getSelectedAsset();
if (!asset || !isAudioAsset(asset)) return;
asset.audioLoop = !!audioLoopInput?.checked;
const delayMs = clamp(Math.max(0, Number.parseInt(audioDelayInput?.value || "0", 10)), 0, 30000);
asset.audioDelayMillis = delayMs;
setAudioDelayLabel(delayMs);
if (audioDelayInput) audioDelayInput.value = delayMs;
const nextAudioSpeedPercent = clamp(Math.max(25, Number.parseInt(audioSpeedInput?.value || "100", 10)), 25, 400);
setAudioSpeedLabel(nextAudioSpeedPercent);
if (audioSpeedInput) audioSpeedInput.value = nextAudioSpeedPercent;
asset.audioSpeed = Math.max(0.25, nextAudioSpeedPercent / 100);
const nextAudioPitchPercent = clamp(Math.max(50, Number.parseInt(audioPitchInput?.value || "100", 10)), 50, 200);
setAudioPitchLabel(nextAudioPitchPercent);
if (audioPitchInput) audioPitchInput.value = nextAudioPitchPercent;
asset.audioPitch = Math.max(0.5, nextAudioPitchPercent / 100);
const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset);
schedulePersistTransform(asset);
drawAndList();
}
function nudgeRotation(delta) {
const asset = getSelectedAsset();
if (!asset) return;
const next = (asset.rotation || 0) + delta;
asset.rotation = next;
updateRenderState(asset);
persistTransform(asset);
drawAndList();
}
function recenterSelectedAsset() {
const asset = getSelectedAsset();
if (!asset) return;
const centerX = (canvas.width - asset.width) / 2;
const centerY = (canvas.height - asset.height) / 2;
asset.x = centerX;
asset.y = centerY;
updateRenderState(asset);
persistTransform(asset);
drawAndList();
}
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);
}
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);
}
function bringToFront() {
const asset = getSelectedAsset();
if (!asset) return;
const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id);
ordered.unshift(asset);
applyLayerOrder(ordered);
}
function sendToBack() {
const asset = getSelectedAsset();
if (!asset) return;
const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id);
ordered.push(asset);
applyLayerOrder(ordered);
}
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();
}
function getAssetAspectRatio(asset) {
const media = ensureMedia(asset);
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
return media.videoWidth / media.videoHeight;
}
if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) {
return media.naturalWidth / media.naturalHeight;
}
if (asset.width && asset.height) {
return asset.width / asset.height;
}
return null;
}
function formatAspectRatioLabel(asset) {
if (isAudioAsset(asset)) {
return "";
}
const ratio = getAssetAspectRatio(asset);
if (!ratio) {
return "";
}
const normalized = ratio >= 1 ? `${ratio.toFixed(2)}:1` : `1:${(1 / ratio).toFixed(2)}`;
return `AR ${normalized}`;
}
function setAspectLock(assetId, locked) {
aspectLockState.set(assetId, locked);
}
function isAspectLocked(assetId) {
return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true;
}
function handleSizeInputChange(type) {
lastSizeInputChanged = type;
const asset = getSelectedAsset();
if (!asset) {
return;
}
if (!isAspectLocked(asset.id)) {
commitSizeChange();
return;
}
const ratio = getAssetAspectRatio(asset);
if (!ratio) {
return;
}
if (type === "width" && widthInput && heightInput) {
const width = parseFloat(widthInput.value);
if (width > 0) {
heightInput.value = Math.round(width / ratio);
}
} else if (type === "height" && widthInput && heightInput) {
const height = parseFloat(heightInput.value);
if (height > 0) {
widthInput.value = Math.round(height * ratio);
}
}
commitSizeChange();
}
function updateVisibility(asset, hidden) {
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/visibility`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
})
.then((r) => {
if (!r.ok) {
throw new Error("Failed to update visibility");
}
return r.json();
})
.then((updated) => {
storeAsset(updated);
let visibilityMessage = null;
if (updated.hidden) {
loopPlaybackState.set(updated.id, false);
stopAudio(updated.id);
showToast("Asset hidden from broadcast.", "info");
} else if (isAudioAsset(updated)) {
playAudioFromCanvas(updated, true);
visibilityMessage = "Asset is now visible and active.";
} else {
visibilityMessage = "Asset is now visible.";
}
if (visibilityMessage) {
showToast(visibilityMessage, "success");
}
updateRenderState(updated);
drawAndList();
})
.catch(() => showToast("Unable to change visibility right now.", "error"));
}
function triggerAudioPlayback(asset, shouldPlay = true) {
if (!asset) return Promise.resolve();
return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ play: shouldPlay }),
})
.then((r) => r.json())
.then((updated) => {
storeAsset(updated);
updateRenderState(updated);
return updated;
});
}
function deleteAsset(asset) {
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: "DELETE" })
.then((response) => {
if (!response.ok) {
throw new Error("Failed to delete asset");
}
clearMedia(asset.id);
assets.delete(asset.id);
renderStates.delete(asset.id);
layerOrder = layerOrder.filter((id) => id !== asset.id);
cancelPendingTransform(asset.id);
if (selectedAssetId === asset.id) {
selectedAssetId = null;
}
drawAndList();
showToast("Asset deleted.", "info");
})
.catch(() => showToast("Unable to delete asset. Please try again.", "error"));
}
function handleFileSelection(input) {
if (!input) return;
const hasFile = input.files && input.files.length;
const name = hasFile ? input.files[0].name : "";
if (fileNameLabel) {
fileNameLabel.textContent = name || "No file chosen";
}
if (hasFile) {
uploadAsset(input.files[0]);
}
}
function uploadAsset(file = null) {
const fileInput = document.getElementById("asset-file");
const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null);
if (!selectedFile) {
showToast("Choose an image, GIF, video, audio, or JavaScript file to upload.", "info");
return;
}
if (selectedFile.size > UPLOAD_LIMIT_BYTES) {
showToast(`File is too large. Maximum upload size is ${UPLOAD_LIMIT_BYTES / 1024 / 1024} MB.`, "error");
return;
}
if (isJavaScriptFile(selectedFile)) {
selectedFile
.text()
.then((source) => {
if (typeof getUserJavaScriptSourceError === "function") {
const error = getUserJavaScriptSourceError(source);
if (error) {
showToast(`JavaScript error: ${error.title}`, "error");
if (fileNameLabel) {
fileNameLabel.textContent = "Upload failed";
}
return;
}
}
beginAssetUpload(selectedFile);
})
.catch(() => {
showToast("Unable to read the JavaScript file. Please try again.", "error");
});
return;
}
beginAssetUpload(selectedFile);
}
function beginAssetUpload(selectedFile) {
const pendingId = addPendingUpload(selectedFile.name);
const data = new FormData();
data.append("file", selectedFile);
const fileInput = document.getElementById("asset-file");
if (fileNameLabel) {
fileNameLabel.textContent = "Uploading...";
}
fetch(`/api/channels/${broadcaster}/assets`, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Upload failed");
}
if (fileInput) {
fileInput.value = "";
handleFileSelection(fileInput);
}
showToast("Upload received. Processing asset...", "success");
updatePendingUpload(pendingId, { status: "processing" });
})
.catch((e) => {
if (fileNameLabel) {
fileNameLabel.textContent = "Upload failed";
}
console.error(e);
removePendingUpload(pendingId);
showToast("Upload failed. Please try again with a supported file.", "error");
});
}
function getCanvasPoint(event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}
function isPointOnAsset(asset, x, y) {
ctx.save();
const halfWidth = asset.width / 2;
const halfHeight = asset.height / 2;
ctx.translate(asset.x + halfWidth, asset.y + halfHeight);
ctx.rotate((asset.rotation * Math.PI) / 180);
const path = new Path2D();
path.rect(-halfWidth, -halfHeight, asset.width, asset.height);
const hit = ctx.isPointInPath(path, x, y);
ctx.restore();
return hit;
}
function findAssetAtPoint(x, y) {
const ordered = getAssetsByLayer();
return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null;
}
function persistTransform(asset, silent = false) {
cancelPendingTransform(asset.id);
const layer = getLayerValue(asset.id);
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
x: asset.x,
y: asset.y,
width: asset.width,
height: asset.height,
rotation: asset.rotation,
speed: asset.speed,
layer,
zIndex: layer,
audioLoop: asset.audioLoop,
audioDelayMillis: asset.audioDelayMillis,
audioSpeed: asset.audioSpeed,
audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume,
}),
})
.then((r) => {
if (!r.ok) {
throw new Error("Transform failed");
}
return r.json();
})
.then((updated) => {
storeAsset(updated);
updateRenderState(updated);
if (!silent) {
drawAndList();
}
})
.catch(() => {
if (!silent) {
showToast("Unable to save changes. Please retry.", "error");
}
});
}
canvas.addEventListener("mousedown", (event) => {
const point = getCanvasPoint(event);
const current = getSelectedAsset();
const handle = current ? hitHandle(current, point) : null;
if (current && handle) {
interactionState =
handle === "rotate"
? {
mode: "rotate",
assetId: current.id,
startAngle: angleFromCenter(current, point),
startRotation: current.rotation || 0,
}
: {
mode: "resize",
assetId: current.id,
handle,
startLocal: pointerToLocal(current, point),
original: { ...current },
};
canvas.style.cursor = cursorForHandle(handle);
drawAndList();
return;
}
const hit = findAssetAtPoint(point.x, point.y);
if (hit) {
selectedAssetId = hit.id;
updateRenderState(hit);
interactionState = {
mode: "move",
assetId: hit.id,
offsetX: point.x - hit.x,
offsetY: point.y - hit.y,
};
canvas.style.cursor = "grabbing";
} else {
selectedAssetId = null;
interactionState = null;
canvas.style.cursor = "default";
}
drawAndList();
});
canvas.addEventListener("mousemove", (event) => {
const point = getCanvasPoint(event);
if (!interactionState) {
updateHoverCursor(point);
return;
}
const asset = assets.get(interactionState.assetId);
if (!asset) {
interactionState = null;
updateHoverCursor(point);
return;
}
if (interactionState.mode === "move") {
asset.x = point.x - interactionState.offsetX;
asset.y = point.y - interactionState.offsetY;
updateRenderState(asset);
canvas.style.cursor = "grabbing";
requestDraw();
} else if (interactionState.mode === "resize") {
resizeFromHandle(interactionState, point);
canvas.style.cursor = cursorForHandle(interactionState.handle);
} else if (interactionState.mode === "rotate") {
const angle = angleFromCenter(asset, point);
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
updateRenderState(asset);
canvas.style.cursor = "grabbing";
requestDraw();
}
});
function endInteraction() {
if (!interactionState) {
return;
}
const asset = assets.get(interactionState.assetId);
interactionState = null;
canvas.style.cursor = "default";
drawAndList();
if (asset) {
persistTransform(asset);
}
}
canvas.addEventListener("mouseup", endInteraction);
canvas.addEventListener("mouseleave", endInteraction);
globalThis.addEventListener("resize", () => {
resizeCanvas();
});
fetchCanvasSettings().finally(() => {
resizeCanvas();
connect();
});