diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java index 0b9f873..0a42b51 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java @@ -34,7 +34,9 @@ public class AssetStorageService { Map.entry("audio/wav", ".wav"), Map.entry("audio/ogg", ".ogg"), Map.entry("audio/webm", ".webm"), - Map.entry("audio/flac", ".flac") + Map.entry("audio/flac", ".flac"), + Map.entry("application/javascript", ".js"), + Map.entry("text/javascript", ".js") ); private final Path assetRoot; diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 8528dcd..8406fce 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -155,8 +155,9 @@ public class ChannelDirectoryService { .orElse("asset_" + System.currentTimeMillis()); boolean isAudio = optimized.mediaType().startsWith("audio/"); - double defaultWidth = isAudio ? 400 : 640; - double defaultHeight = isAudio ? 80 : 360; + boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType); + double defaultWidth = isAudio ? 400 : isCode ? 480 : 640; + double defaultHeight = isAudio ? 80 : isCode ? 270 : 360; double width = optimized.width() > 0 ? optimized.width() : defaultWidth; double height = optimized.height() > 0 ? optimized.height() : defaultHeight; @@ -362,6 +363,14 @@ public class ChannelDirectoryService { return value == null ? null : value.toLowerCase(Locale.ROOT); } + private boolean isCodeMediaType(String mediaType) { + if (mediaType == null || mediaType.isBlank()) { + return false; + } + String normalized = mediaType.toLowerCase(Locale.ROOT); + return normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript"); + } + private String topicFor(String broadcaster) { return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java index 8a5cf17..64bf2a8 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java @@ -26,7 +26,9 @@ public class MediaDetectionService { Map.entry("mov", "video/quicktime"), Map.entry("mp3", "audio/mpeg"), Map.entry("wav", "audio/wav"), - Map.entry("ogg", "audio/ogg") + Map.entry("ogg", "audio/ogg"), + Map.entry("js", "application/javascript"), + Map.entry("mjs", "text/javascript") ); private static final Set ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java index 272b730..dff200b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java @@ -72,6 +72,10 @@ public class MediaOptimizationService { return new OptimizedAsset(bytes, mediaType, 0, 0, null); } + if (mediaType.startsWith("application/javascript") || mediaType.startsWith("text/javascript")) { + return new OptimizedAsset(bytes, mediaType, 0, 0, null); + } + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); if (image != null) { return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 441a784..accf392 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1755,6 +1755,14 @@ button:disabled:hover { color: #fbbf24; } +.code-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #60a5fa; +} + .sr-only { position: absolute; width: 1px; diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index a3a4150..657b6df 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -46,6 +46,7 @@ const selectedAssetMeta = document.getElementById("selected-asset-meta"); const selectedAssetResolution = document.getElementById("selected-asset-resolution"); const selectedAssetIdLabel = document.getElementById("selected-asset-id"); const selectedAssetBadges = document.getElementById("selected-asset-badges"); +const selectedEditBtn = document.getElementById("selected-asset-edit"); const selectedVisibilityBtn = document.getElementById("selected-asset-visibility"); const selectedDeleteBtn = document.getElementById("selected-asset-delete"); const assetActionRow = document.getElementById("asset-actions"); @@ -111,7 +112,7 @@ function cancelPendingTransform(assetId) { function ensureLayerPosition(assetId, placement = "keep") { const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { + if (asset && (isAudioAsset(asset) || isCodeAsset(asset))) { return; } const existingIndex = layerOrder.indexOf(assetId); @@ -132,10 +133,10 @@ function ensureLayerPosition(assetId, placement = "keep") { function getLayerOrder() { layerOrder = layerOrder.filter((id) => { const asset = assets.get(id); - return asset && !isAudioAsset(asset); + return asset && !isAudioAsset(asset) && !isCodeAsset(asset); }); assets.forEach((asset, id) => { - if (isAudioAsset(asset)) { + if (isAudioAsset(asset) || isCodeAsset(asset)) { return; } if (!layerOrder.includes(id)) { @@ -157,6 +158,12 @@ function getAudioAssets() { .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); } +function getCodeAssets() { + return Array.from(assets.values()) + .filter((asset) => isCodeAsset(asset)) + .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); +} + function getRenderOrder() { return [...getLayerOrder()] .reverse() @@ -166,7 +173,7 @@ function getRenderOrder() { function getLayerValue(assetId) { const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { + if (asset && (isAudioAsset(asset) || isCodeAsset(asset))) { return 0; } const order = getLayerOrder(); @@ -645,6 +652,11 @@ function drawAsset(asset) { ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.rotate((renderState.rotation * Math.PI) / 180); + if (isCodeAsset(asset)) { + ctx.restore(); + return; + } + if (isAudioAsset(asset)) { autoStartAudio(asset); ctx.restore(); @@ -930,6 +942,23 @@ function isAudioAsset(asset) { return type.startsWith("audio/"); } +function isCodeAsset(asset) { + const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); + return type.startsWith("application/javascript") || type.startsWith("text/javascript"); +} + +function isJavaScriptFile(file) { + if (!file) { + return false; + } + const type = (file.type || "").toLowerCase(); + if (type.includes("javascript")) { + return true; + } + const name = (file.name || "").toLowerCase(); + return name.endsWith(".js") || name.endsWith(".mjs"); +} + function isVideoElement(element) { return element && element.tagName === "VIDEO"; } @@ -939,6 +968,9 @@ function getDisplayMediaType(asset) { if (!raw) { return "Unknown"; } + if (isCodeAsset(asset)) { + return "JavaScript"; + } const parts = raw.split("/"); return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); } @@ -1265,8 +1297,9 @@ function renderAssetList() { list.appendChild(createPendingListItem(pending)); }); + const codeAssets = getCodeAssets(); const audioAssets = getAudioAssets(); - const sortedAssets = [...audioAssets, ...getAssetsByLayer()]; + const sortedAssets = [...codeAssets, ...audioAssets, ...getAssetsByLayer()]; sortedAssets.forEach((asset) => { const li = document.createElement("li"); li.className = "asset-item"; @@ -1285,13 +1318,28 @@ function renderAssetList() { 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)}`; + details.textContent = isCodeAsset(asset) + ? "JavaScript" + : `${Math.round(asset.width)}x${Math.round(asset.height)}`; meta.appendChild(name); meta.appendChild(details); const actions = document.createElement("div"); actions.className = "actions"; + if (isCodeAsset(asset)) { + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.className = "ghost icon-button"; + editBtn.innerHTML = ''; + editBtn.title = "Edit script"; + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + openCodeAssetEditor(asset); + }); + actions.appendChild(editBtn); + } + if (isAudioAsset(asset)) { const playBtn = document.createElement("button"); playBtn.type = "button"; @@ -1313,7 +1361,7 @@ function renderAssetList() { actions.appendChild(playBtn); } - if (!isAudioAsset(asset)) { + if (!isAudioAsset(asset) && !isCodeAsset(asset)) { const toggleBtn = document.createElement("button"); toggleBtn.type = "button"; toggleBtn.className = "ghost icon-button"; @@ -1406,6 +1454,12 @@ function updatePlayButtonIcon(button, isLooping, isPlayingLoop) { } function createPreviewElement(asset) { + if (isCodeAsset(asset)) { + const icon = document.createElement("div"); + icon.className = "asset-preview code-icon"; + icon.innerHTML = ''; + return icon; + } if (isAudioAsset(asset)) { const icon = document.createElement("div"); icon.className = "asset-preview audio-icon"; @@ -1621,7 +1675,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { aspectLockInput.checked = isAspectLocked(asset.id); aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); } - const hideLayout = isAudioAsset(asset); + const hideLayout = isAudioAsset(asset) || isCodeAsset(asset); if (layoutSection) { layoutSection.classList.toggle("hidden", hideLayout); const layoutControls = layoutSection.querySelectorAll("input, button"); @@ -1633,7 +1687,8 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { if (assetActionButtons.length) { assetActionButtons.forEach((button) => { const allowForAudio = button.dataset.audioEnabled === "true"; - const disableButton = hideLayout && !allowForAudio; + const allowForCode = button.dataset.codeEnabled === "true"; + const disableButton = hideLayout && !(allowForAudio || allowForCode); button.disabled = disableButton; button.classList.toggle("disabled", disableButton); }); @@ -1703,8 +1758,13 @@ function updateSelectedAssetSummary(asset) { } if (selectedAssetResolution) { if (asset) { - selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; - selectedAssetResolution.classList.remove("hidden"); + if (isCodeAsset(asset)) { + selectedAssetResolution.textContent = ""; + selectedAssetResolution.classList.add("hidden"); + } else { + selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; + selectedAssetResolution.classList.remove("hidden"); + } } else { selectedAssetResolution.textContent = ""; selectedAssetResolution.classList.add("hidden"); @@ -1723,7 +1783,7 @@ function updateSelectedAssetSummary(asset) { selectedAssetBadges.innerHTML = ""; if (asset) { selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); - const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ""; + const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : ""; if (aspectLabel) { selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle")); } @@ -1733,6 +1793,13 @@ function updateSelectedAssetSummary(asset) { } } } + if (selectedEditBtn) { + selectedEditBtn.disabled = !asset || !isCodeAsset(asset); + selectedEditBtn.onclick = null; + if (asset && isCodeAsset(asset)) { + selectedEditBtn.onclick = () => openCodeAssetEditor(asset); + } + } if (selectedVisibilityBtn) { selectedVisibilityBtn.disabled = !asset; selectedVisibilityBtn.onclick = null; @@ -1754,6 +1821,10 @@ function updateSelectedAssetSummary(asset) { } triggerAudioPlayback(asset, nextPlay); }; + } else if (asset && isCodeAsset(asset)) { + selectedVisibilityBtn.disabled = true; + selectedVisibilityBtn.title = "Script assets do not render on the canvas"; + selectedVisibilityBtn.innerHTML = ''; } else if (asset) { selectedVisibilityBtn.title = asset.hidden ? "Show asset" : "Hide asset"; selectedVisibilityBtn.innerHTML = ``; @@ -1769,6 +1840,61 @@ function updateSelectedAssetSummary(asset) { } } +function openCodeAssetEditor(asset) { + if (!asset) { + return; + } + const modal = document.getElementById("custom-asset-modal"); + const nameInput = document.getElementById("custom-asset-name"); + const codeInput = document.getElementById("custom-asset-code"); + const errorWrapper = document.getElementById("custom-asset-error"); + const errorTitle = document.getElementById("js-error-title"); + const errorDetails = document.getElementById("js-error-details"); + + if (errorWrapper) { + errorWrapper.classList.add("hidden"); + } + if (errorTitle) { + errorTitle.textContent = ""; + } + if (errorDetails) { + errorDetails.textContent = ""; + } + if (nameInput) { + nameInput.value = asset.name || ""; + } + if (codeInput) { + codeInput.value = ""; + codeInput.placeholder = "Loading script..."; + codeInput.disabled = true; + codeInput.dataset.assetId = asset.id; + } + if (modal) { + modal.classList.remove("hidden"); + } + + fetch(asset.url) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load script"); + } + return response.text(); + }) + .then((text) => { + if (codeInput) { + codeInput.disabled = false; + codeInput.value = text; + } + }) + .catch(() => { + if (codeInput) { + codeInput.disabled = false; + codeInput.value = ""; + } + showToast("Unable to load script content.", "error"); + }); +} + function ensureDurationMetadata(asset) { if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) { return; @@ -2092,7 +2218,7 @@ function uploadAsset(file = null) { const fileInput = document.getElementById("asset-file"); const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); if (!selectedFile) { - showToast("Choose an image, GIF, video, or audio file to upload.", "info"); + showToast("Choose an image, GIF, video, audio, or JavaScript file to upload.", "info"); return; } if (selectedFile.size > UPLOAD_LIMIT_BYTES) { @@ -2100,6 +2226,32 @@ function uploadAsset(file = null) { return; } + if (isJavaScriptFile(selectedFile)) { + selectedFile + .text() + .then((source) => { + if (typeof getUserJavaScriptSourceError === "function") { + const error = getUserJavaScriptSourceError(source); + if (error) { + showToast(`JavaScript error: ${error.title}`, "error"); + if (fileNameLabel) { + fileNameLabel.textContent = "Upload failed"; + } + return; + } + } + beginAssetUpload(selectedFile); + }) + .catch(() => { + showToast("Unable to read the JavaScript file. Please try again.", "error"); + }); + return; + } + + beginAssetUpload(selectedFile); +} + +function beginAssetUpload(selectedFile) { const pendingId = addPendingUpload(selectedFile.name); const data = new FormData(); data.append("file", selectedFile); @@ -2121,10 +2273,11 @@ function uploadAsset(file = null) { showToast("Upload received. Processing asset...", "success"); updatePendingUpload(pendingId, { status: "processing" }); }) - .catch(() => { + .catch((e) => { if (fileNameLabel) { fileNameLabel.textContent = "Upload failed"; } + console.error(e); removePendingUpload(pendingId); showToast("Upload failed. Please try again with a supported file.", "error"); }); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 88bfaf3..d28d735 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -37,7 +37,7 @@ audioUnlockEvents.forEach((eventName) => { function ensureLayerPosition(assetId, placement = "keep") { const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { + if (asset && (isAudioAsset(asset) || isCodeAsset(asset))) { return; } const existingIndex = layerOrder.indexOf(assetId); @@ -58,10 +58,10 @@ function ensureLayerPosition(assetId, placement = "keep") { function getLayerOrder() { layerOrder = layerOrder.filter((id) => { const asset = assets.get(id); - return asset && !isAudioAsset(asset); + return asset && !isAudioAsset(asset) && !isCodeAsset(asset); }); assets.forEach((asset, id) => { - if (isAudioAsset(asset)) { + if (isAudioAsset(asset) || isCodeAsset(asset)) { return; } if (!layerOrder.includes(id)) { @@ -345,6 +345,11 @@ function drawAsset(asset) { ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.rotate((renderState.rotation * Math.PI) / 180); + if (isCodeAsset(asset)) { + ctx.restore(); + return; + } + if (isAudioAsset(asset)) { if (!asset.hidden) { autoStartAudio(asset); @@ -433,6 +438,11 @@ function isAudioAsset(asset) { return asset?.mediaType?.startsWith("audio/"); } +function isCodeAsset(asset) { + const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); + return type.startsWith("application/javascript") || type.startsWith("text/javascript"); +} + function isVideoElement(element) { return element?.tagName === "VIDEO"; } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 899f9c5..4bc8ba5 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -51,7 +51,7 @@ id="asset-file" class="file-input-field" type="file" - accept="image/*,video/*,audio/*" + accept="image/*,video/*,audio/*,application/javascript,text/javascript,.js,.mjs" onchange="handleFileSelection(this)" />