Files
server/src/main/resources/static/js/admin.js
2025-12-10 09:38:38 +01:00

1420 lines
45 KiB
JavaScript

let stompClient;
const canvas = document.getElementById('admin-canvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('admin-overlay');
const overlayFrame = overlay?.querySelector('iframe');
let canvasSettings = { width: 1920, height: 1080 };
canvas.width = canvasSettings.width;
canvas.height = canvasSettings.height;
const assets = new Map();
const mediaCache = new Map();
const renderStates = new Map();
const animatedCache = new Map();
const audioControllers = new Map();
let drawPending = false;
let zOrderDirty = true;
let zOrderCache = [];
let selectedAssetId = null;
let interactionState = null;
let lastSizeInputChanged = null;
const HANDLE_SIZE = 10;
const ROTATE_HANDLE_OFFSET = 32;
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 muteInput = document.getElementById('asset-muted');
const selectedZLabel = document.getElementById('asset-z-level');
const playbackSection = document.getElementById('playback-section');
const audioSection = document.getElementById('audio-section');
const audioLoopInput = document.getElementById('asset-audio-loop');
const audioDelayInput = document.getElementById('asset-audio-delay');
const audioSpeedInput = document.getElementById('asset-audio-speed');
const audioPitchInput = document.getElementById('asset-audio-pitch');
const audioVolumeInput = document.getElementById('asset-audio-volume');
const controlsPlaceholder = document.getElementById('asset-controls-placeholder');
const fileNameLabel = document.getElementById('asset-file-name');
const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#1f2937" rx="8"/><g fill="#fbbf24" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#fef3c7"/><rect x="45" y="23" width="110" height="5" fill="#fef3c7"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>');
const aspectLockState = new Map();
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
function debounce(fn, wait = 150) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), wait);
};
}
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 (muteInput) muteInput.addEventListener('change', updateMuteFromInput);
if (audioLoopInput) audioLoopInput.addEventListener('change', updateAudioSettingsFromInputs);
if (audioDelayInput) audioDelayInput.addEventListener('input', updateAudioSettingsFromInputs);
if (audioSpeedInput) audioSpeedInput.addEventListener('input', updateAudioSettingsFromInputs);
if (audioPitchInput) audioPitchInput.addEventListener('input', updateAudioSettingsFromInputs);
if (audioVolumeInput) audioVolumeInput.addEventListener('input', updateAudioSettingsFromInputs);
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();
});
}
function fetchAssets() {
fetch(`/api/channels/${broadcaster}/assets`).then((r) => r.json()).then(renderAssets);
}
function fetchCanvasSettings() {
return fetch(`/api/channels/${broadcaster}/canvas`)
.then((r) => r.json())
.then((settings) => {
canvasSettings = settings;
resizeCanvas();
})
.catch(() => 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 (overlayFrame) {
overlayFrame.style.width = `${displayWidth}px`;
overlayFrame.style.height = `${displayHeight}px`;
overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`;
overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`;
}
requestDraw();
}
function renderAssets(list) {
list.forEach(storeAsset);
drawAndList();
}
function storeAsset(asset) {
if (!asset) return;
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
const parsedCreatedAt = asset.createdAt ? new Date(asset.createdAt).getTime() : NaN;
const hasCreatedAtMs = typeof asset.createdAtMs === 'number' && Number.isFinite(asset.createdAtMs);
if (!hasCreatedAtMs) {
asset.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now();
}
assets.set(asset.id, asset);
zOrderDirty = true;
if (!renderStates.has(asset.id)) {
renderStates.set(asset.id, { ...asset });
}
}
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 === 'DELETED') {
assets.delete(event.assetId);
zOrderDirty = true;
clearMedia(event.assetId);
renderStates.delete(event.assetId);
if (selectedAssetId === event.assetId) {
selectedAssetId = null;
}
} else if (event.payload) {
storeAsset(event.payload);
if (!event.payload.hidden) {
ensureMedia(event.payload);
if (isAudioAsset(event.payload) && event.type === 'VISIBILITY') {
playAudioFromCanvas(event.payload, true);
}
} else {
clearMedia(event.payload.id);
}
}
drawAndList();
}
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);
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));
}
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);
const media = ensureMedia(asset);
const drawSource = media?.isAnimated ? media.bitmap : media;
const ready = isAudioAsset(asset) || isDrawable(media);
if (isAudioAsset(asset)) {
autoStartAudio(asset);
}
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 (isAudioAsset(asset)) {
drawAudioIndicators(asset, halfWidth, halfHeight);
}
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 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 drawAudioIndicators(asset, halfWidth, halfHeight) {
const controller = audioControllers.get(asset.id);
const isPlaying = controller && !controller.element.paused && !controller.element.ended;
const hasDelay = !!(controller && controller.delayTimeout);
if (!isPlaying && !hasDelay) {
return;
}
const indicatorSize = 18;
const padding = 10;
let x = -halfWidth + padding + indicatorSize / 2;
const y = -halfHeight + padding + indicatorSize / 2;
ctx.save();
ctx.setLineDash([]);
if (isPlaying) {
ctx.fillStyle = 'rgba(52, 211, 153, 0.9)';
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#0f172a';
ctx.beginPath();
const radius = indicatorSize * 0.22;
ctx.moveTo(x - radius, y - radius * 1.1);
ctx.lineTo(x + radius * 1.2, y);
ctx.lineTo(x - radius, y + radius * 1.1);
ctx.closePath();
ctx.fill();
x += indicatorSize + 4;
}
if (hasDelay) {
ctx.fillStyle = 'rgba(251, 191, 36, 0.9)';
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = '#0f172a';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y - indicatorSize * 0.22);
ctx.moveTo(x, y);
ctx.lineTo(x + indicatorSize * 0.22, y);
ctx.stroke();
}
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 isVideoElement(element) {
return element && element.tagName === 'VIDEO';
}
function getDisplayMediaType(asset) {
const raw = asset.originalMediaType || asset.mediaType || '';
if (!raw) {
return 'Unknown';
}
const parts = raw.split('/');
return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase();
}
function isGifAsset(asset) {
return asset?.mediaType?.toLowerCase() === 'image/gif';
}
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 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.controls = true;
element.preload = 'auto';
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 = Math.max(0, Math.min(1, asset.audioVolume ?? 1));
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(() => {
controller.element.play().catch(() => {});
}, 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 playAudioFromCanvas(asset, resetDelay = false) {
const controller = ensureAudioController(asset);
if (controller.delayTimeout) {
clearTimeout(controller.delayTimeout);
controller.delayTimeout = null;
}
controller.element.currentTime = 0;
controller.delayMs = resetDelay ? 0 : controller.baseDelayMs;
controller.element.play().catch(() => {});
controller.delayMs = controller.baseDelayMs;
requestDraw();
}
function autoStartAudio(asset) {
if (!isAudioAsset(asset) || asset.hidden) {
return;
}
const controller = ensureAudioController(asset);
if (controller.loopEnabled && controller.element.paused && !controller.delayTimeout) {
controller.delayTimeout = setTimeout(() => {
controller.element.play().catch(() => {});
}, controller.delayMs);
}
}
function ensureMedia(asset) {
const cached = mediaCache.get(asset.id);
if (cached && cached.src === asset.url) {
applyMediaSettings(cached, asset);
return cached;
}
if (isAudioAsset(asset)) {
ensureAudioController(asset);
const placeholder = new Image();
placeholder.src = audioPlaceholder;
mediaCache.set(asset.id, placeholder);
return placeholder;
}
if (isGifAsset(asset) && 'ImageDecoder' in window) {
const animated = ensureAnimatedImage(asset);
if (animated) {
mediaCache.set(asset.id, animated);
return animated;
}
}
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
if (isVideoElement(element)) {
element.loop = true;
element.muted = asset.muted ?? true;
element.playsInline = true;
element.autoplay = true;
element.onloadeddata = requestDraw;
element.src = asset.url;
const playback = asset.speed ?? 1;
element.playbackRate = Math.max(playback, 0.01);
if (playback === 0) {
element.pause();
} else {
element.play().catch(() => {});
}
} 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 shouldMute = asset.muted ?? true;
if (element.muted !== shouldMute) {
element.muted = shouldMute;
}
if (nextSpeed === 0) {
element.pause();
} else if (element.paused) {
element.play().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 = '';
if (!assets.size) {
selectedAssetId = null;
const empty = document.createElement('li');
empty.textContent = 'No assets yet. Upload to get started.';
list.appendChild(empty);
updateSelectedAssetControls();
return;
}
const sortedAssets = getChronologicalAssets();
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 = `${Math.round(asset.width)}x${Math.round(asset.height)}`;
meta.appendChild(name);
meta.appendChild(details);
const badges = document.createElement('div');
badges.className = 'badge-row asset-meta-badges';
badges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : ''));
badges.appendChild(createBadge(getDisplayMediaType(asset)));
badges.appendChild(createBadge(`Z ${asset.zIndex ?? 1}`));
const aspectLabel = formatAspectRatioLabel(asset);
if (aspectLabel) {
badges.appendChild(createBadge(aspectLabel, 'subtle'));
}
meta.appendChild(badges);
const actions = document.createElement('div');
actions.className = 'actions';
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);
});
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'ghost danger icon-button';
deleteBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
deleteBtn.title = 'Delete asset';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteAsset(asset);
});
actions.appendChild(toggleBtn);
actions.appendChild(deleteBtn);
row.appendChild(preview);
row.appendChild(meta);
row.appendChild(actions);
li.addEventListener('click', () => {
selectedAssetId = asset.id;
updateRenderState(asset);
drawAndList();
});
li.appendChild(row);
if (asset.id === selectedAssetId && controlsPanel) {
controlsPanel.classList.remove('hidden');
const detail = document.createElement('div');
detail.className = 'asset-detail';
detail.appendChild(controlsPanel);
li.appendChild(detail);
updateSelectedAssetControls(asset);
}
list.appendChild(li);
});
}
function createBadge(label, extraClass = '') {
const badge = document.createElement('span');
badge.className = `badge ${extraClass}`.trim();
badge.textContent = label;
return badge;
}
function createPreviewElement(asset) {
if (isAudioAsset(asset)) {
const audio = document.createElement('audio');
audio.className = 'asset-preview audio-preview';
audio.src = asset.url;
audio.controls = true;
audio.preload = 'metadata';
return audio;
}
if (isVideoAsset(asset)) {
const video = document.createElement('video');
video.className = 'asset-preview';
video.src = asset.url;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.play().catch(() => {});
return video;
}
const img = document.createElement('img');
img.className = 'asset-preview';
img.src = asset.url;
img.alt = asset.name || 'Asset preview';
return img;
}
function getSelectedAsset() {
return selectedAssetId ? assets.get(selectedAssetId) : null;
}
function updateSelectedAssetControls(asset = getSelectedAsset()) {
if (!controlsPanel || !asset) {
if (controlsPanel) controlsPanel.classList.add('hidden');
return;
}
controlsPanel.classList.remove('hidden');
lastSizeInputChanged = null;
if (selectedZLabel) {
selectedZLabel.textContent = asset.zIndex ?? 1;
}
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);
}
if (speedInput) {
const percent = Math.round((asset.speed ?? 1) * 100);
speedInput.value = Math.min(1000, Math.max(0, percent));
}
if (playbackSection) {
const shouldShowPlayback = isVideoAsset(asset);
playbackSection.classList.toggle('hidden', !shouldShowPlayback);
speedInput?.classList?.toggle('disabled', !shouldShowPlayback);
}
if (muteInput) {
muteInput.checked = !!asset.muted;
muteInput.disabled = !isVideoAsset(asset);
muteInput.parentElement?.classList.toggle('disabled', !isVideoAsset(asset));
}
if (audioSection) {
const showAudio = isAudioAsset(asset);
audioSection.classList.toggle('hidden', !showAudio);
if (showAudio) {
audioLoopInput.checked = !!asset.audioLoop;
audioDelayInput.value = Math.max(0, asset.audioDelayMillis ?? 0);
audioSpeedInput.value = Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100);
audioPitchInput.value = Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100);
audioVolumeInput.value = Math.round(Math.max(0, Math.min(1, asset.audioVolume ?? 1)) * 100);
}
}
}
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));
asset.speed = percent / 100;
updateRenderState(asset);
persistTransform(asset);
const media = mediaCache.get(asset.id);
if (media) {
applyMediaSettings(media, asset);
}
drawAndList();
}
function updateMuteFromInput() {
const asset = getSelectedAsset();
if (!asset || !isVideoAsset(asset)) return;
asset.muted = !!muteInput?.checked;
updateRenderState(asset);
persistTransform(asset);
const media = mediaCache.get(asset.id);
if (media) {
applyMediaSettings(media, asset);
}
drawAndList();
}
function updateAudioSettingsFromInputs() {
const asset = getSelectedAsset();
if (!asset || !isAudioAsset(asset)) return;
asset.audioLoop = !!audioLoopInput?.checked;
asset.audioDelayMillis = Math.max(0, parseInt(audioDelayInput?.value || '0', 10));
asset.audioSpeed = Math.max(0.25, (parseInt(audioSpeedInput?.value || '100', 10) / 100));
asset.audioPitch = Math.max(0.5, (parseInt(audioPitchInput?.value || '100', 10) / 100));
asset.audioVolume = Math.max(0, Math.min(1, (parseInt(audioVolumeInput?.value || '100', 10) / 100)));
const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset);
persistTransform(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 = [...getZOrderedAssets()];
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);
}
function bringBackward() {
const asset = getSelectedAsset();
if (!asset) return;
const ordered = [...getZOrderedAssets()];
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);
}
function bringToFront() {
const asset = getSelectedAsset();
if (!asset) return;
const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id);
ordered.push(asset);
applyZOrder(ordered);
}
function sendToBack() {
const asset = getSelectedAsset();
if (!asset) return;
const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id);
ordered.unshift(asset);
applyZOrder(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));
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) {
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) => r.json()).then((updated) => {
storeAsset(updated);
if (updated.hidden) {
stopAudio(updated.id);
} else if (isAudioAsset(updated)) {
playAudioFromCanvas(updated, true);
}
updateRenderState(updated);
drawAndList();
});
}
function deleteAsset(asset) {
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
clearMedia(asset.id);
assets.delete(asset.id);
renderStates.delete(asset.id);
zOrderDirty = true;
if (selectedAssetId === asset.id) {
selectedAssetId = null;
}
drawAndList();
});
}
function handleFileSelection(input) {
if (!input) return;
const name = input.files && input.files.length ? input.files[0].name : '';
if (fileNameLabel) {
fileNameLabel.textContent = name || 'No file chosen';
}
}
function uploadAsset() {
const fileInput = document.getElementById('asset-file');
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
alert('Please choose an image, GIF, video, or audio file to upload.');
return;
}
const data = new FormData();
data.append('file', fileInput.files[0]);
fetch(`/api/channels/${broadcaster}/assets`, {
method: 'POST',
body: data
}).then(() => {
fileInput.value = '';
handleFileSelection(fileInput);
});
}
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 = [...getZOrderedAssets()].reverse();
return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null;
}
function persistTransform(asset, silent = false) {
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
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,
muted: asset.muted,
zIndex: asset.zIndex,
audioLoop: asset.audioLoop,
audioDelayMillis: asset.audioDelayMillis,
audioSpeed: asset.audioSpeed,
audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume
})
}).then((r) => r.json()).then((updated) => {
storeAsset(updated);
updateRenderState(updated);
if (!silent) {
drawAndList();
}
});
}
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) {
if (isAudioAsset(hit) && !handle && event.detail >= 2) {
selectedAssetId = hit.id;
updateRenderState(hit);
playAudioFromCanvas(hit);
drawAndList();
return;
}
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);
window.addEventListener('resize', () => {
resizeCanvas();
});
fetchCanvasSettings().finally(() => {
resizeCanvas();
connect();
});