mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
1420 lines
45 KiB
JavaScript
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();
|
|
});
|