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('Audio'); 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 = ``; 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 = ''; 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(); });