diff --git a/src/main/java/com/imgfloat/app/config/SchemaMigration.java b/src/main/java/com/imgfloat/app/config/SchemaMigration.java index 24add1f..e42d63b 100644 --- a/src/main/java/com/imgfloat/app/config/SchemaMigration.java +++ b/src/main/java/com/imgfloat/app/config/SchemaMigration.java @@ -61,6 +61,11 @@ public class SchemaMigration implements ApplicationRunner { addColumnIfMissing("assets", columns, "speed", "REAL", "1.0"); addColumnIfMissing("assets", columns, "muted", "BOOLEAN", "0"); addColumnIfMissing("assets", columns, "media_type", "TEXT", "'application/octet-stream'"); + addColumnIfMissing("assets", columns, "audio_loop", "BOOLEAN", "0"); + addColumnIfMissing("assets", columns, "audio_delay_millis", "INTEGER", "0"); + addColumnIfMissing("assets", columns, "audio_speed", "REAL", "1.0"); + addColumnIfMissing("assets", columns, "audio_pitch", "REAL", "1.0"); + addColumnIfMissing("assets", columns, "audio_volume", "REAL", "1.0"); } private void addColumnIfMissing(String tableName, List existingColumns, String columnName, String dataType, String defaultValue) { diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java index cde12a4..c879fd0 100644 --- a/src/main/java/com/imgfloat/app/model/Asset.java +++ b/src/main/java/com/imgfloat/app/model/Asset.java @@ -35,6 +35,11 @@ public class Asset { private String mediaType; private String originalMediaType; private Integer zIndex; + private Boolean audioLoop; + private Integer audioDelayMillis; + private Double audioSpeed; + private Double audioPitch; + private Double audioVolume; private boolean hidden; private Instant createdAt; @@ -80,6 +85,21 @@ public class Asset { if (this.zIndex == null || this.zIndex < 1) { this.zIndex = 1; } + if (this.audioLoop == null) { + this.audioLoop = Boolean.FALSE; + } + if (this.audioDelayMillis == null) { + this.audioDelayMillis = 0; + } + if (this.audioSpeed == null) { + this.audioSpeed = 1.0; + } + if (this.audioPitch == null) { + this.audioPitch = 1.0; + } + if (this.audioVolume == null) { + this.audioVolume = 1.0; + } } public String getId() { @@ -210,6 +230,46 @@ public class Asset { this.zIndex = zIndex == null ? null : Math.max(1, zIndex); } + public boolean isAudioLoop() { + return audioLoop != null && audioLoop; + } + + public void setAudioLoop(Boolean audioLoop) { + this.audioLoop = audioLoop; + } + + public Integer getAudioDelayMillis() { + return audioDelayMillis == null ? 0 : Math.max(0, audioDelayMillis); + } + + public void setAudioDelayMillis(Integer audioDelayMillis) { + this.audioDelayMillis = audioDelayMillis; + } + + public double getAudioSpeed() { + return audioSpeed == null ? 1.0 : Math.max(0.1, audioSpeed); + } + + public void setAudioSpeed(Double audioSpeed) { + this.audioSpeed = audioSpeed; + } + + public double getAudioPitch() { + return audioPitch == null ? 1.0 : Math.max(0.5, audioPitch); + } + + public void setAudioPitch(Double audioPitch) { + this.audioPitch = audioPitch; + } + + public double getAudioVolume() { + return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume)); + } + + public void setAudioVolume(Double audioVolume) { + this.audioVolume = audioVolume; + } + private static String normalize(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } diff --git a/src/main/java/com/imgfloat/app/model/AssetView.java b/src/main/java/com/imgfloat/app/model/AssetView.java index 2a8116e..a6cf708 100644 --- a/src/main/java/com/imgfloat/app/model/AssetView.java +++ b/src/main/java/com/imgfloat/app/model/AssetView.java @@ -17,6 +17,11 @@ public record AssetView( String mediaType, String originalMediaType, Integer zIndex, + Boolean audioLoop, + Integer audioDelayMillis, + Double audioSpeed, + Double audioPitch, + Double audioVolume, boolean hidden, Instant createdAt ) { @@ -36,6 +41,11 @@ public record AssetView( asset.getMediaType(), asset.getOriginalMediaType(), asset.getZIndex(), + asset.isAudioLoop(), + asset.getAudioDelayMillis(), + asset.getAudioSpeed(), + asset.getAudioPitch(), + asset.getAudioVolume(), asset.isHidden(), asset.getCreatedAt() ); diff --git a/src/main/java/com/imgfloat/app/model/TransformRequest.java b/src/main/java/com/imgfloat/app/model/TransformRequest.java index d1627c9..3c4dc48 100644 --- a/src/main/java/com/imgfloat/app/model/TransformRequest.java +++ b/src/main/java/com/imgfloat/app/model/TransformRequest.java @@ -9,6 +9,11 @@ public class TransformRequest { private Double speed; private Boolean muted; private Integer zIndex; + private Boolean audioLoop; + private Integer audioDelayMillis; + private Double audioSpeed; + private Double audioPitch; + private Double audioVolume; public double getX() { return x; @@ -73,4 +78,44 @@ public class TransformRequest { public void setZIndex(Integer zIndex) { this.zIndex = zIndex; } + + public Boolean getAudioLoop() { + return audioLoop; + } + + public void setAudioLoop(Boolean audioLoop) { + this.audioLoop = audioLoop; + } + + public Integer getAudioDelayMillis() { + return audioDelayMillis; + } + + public void setAudioDelayMillis(Integer audioDelayMillis) { + this.audioDelayMillis = audioDelayMillis; + } + + public Double getAudioSpeed() { + return audioSpeed; + } + + public void setAudioSpeed(Double audioSpeed) { + this.audioSpeed = audioSpeed; + } + + public Double getAudioPitch() { + return audioPitch; + } + + public void setAudioPitch(Double audioPitch) { + this.audioPitch = audioPitch; + } + + public Double getAudioVolume() { + return audioVolume; + } + + public void setAudioVolume(Double audioVolume) { + this.audioVolume = audioVolume; + } } diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index bc4086b..d5d3842 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -125,13 +125,18 @@ public class ChannelDirectoryService { .orElse("Asset " + System.currentTimeMillis()); String dataUrl = "data:" + optimized.mediaType() + ";base64," + Base64.getEncoder().encodeToString(optimized.bytes()); - double width = optimized.width() > 0 ? optimized.width() : 640; - double height = optimized.height() > 0 ? optimized.height() : 360; + double width = optimized.width() > 0 ? optimized.width() : (optimized.mediaType().startsWith("audio/") ? 400 : 640); + double height = optimized.height() > 0 ? optimized.height() : (optimized.mediaType().startsWith("audio/") ? 80 : 360); Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height); asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); asset.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); + asset.setAudioLoop(false); + asset.setAudioDelayMillis(0); + asset.setAudioSpeed(1.0); + asset.setAudioPitch(1.0); + asset.setAudioVolume(1.0); asset.setZIndex(nextZIndex(channel.getBroadcaster())); assetRepository.save(asset); @@ -159,6 +164,22 @@ public class ChannelDirectoryService { if (request.getMuted() != null && asset.isVideo()) { asset.setMuted(request.getMuted()); } + if (request.getAudioLoop() != null) { + asset.setAudioLoop(request.getAudioLoop()); + } + if (request.getAudioDelayMillis() != null && request.getAudioDelayMillis() >= 0) { + asset.setAudioDelayMillis(request.getAudioDelayMillis()); + } + if (request.getAudioSpeed() != null && request.getAudioSpeed() >= 0) { + asset.setAudioSpeed(request.getAudioSpeed()); + } + if (request.getAudioPitch() != null && request.getAudioPitch() > 0) { + asset.setAudioPitch(request.getAudioPitch()); + } + if (request.getAudioVolume() != null && request.getAudioVolume() >= 0) { + double clamped = Math.max(0.0, Math.min(1.0, request.getAudioVolume())); + asset.setAudioVolume(clamped); + } assetRepository.save(asset); AssetView view = AssetView.from(normalized, asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); @@ -289,6 +310,9 @@ public class ChannelDirectoryService { case "mp4" -> "video/mp4"; case "webm" -> "video/webm"; case "mov" -> "video/quicktime"; + case "mp3" -> "audio/mpeg"; + case "wav" -> "audio/wav"; + case "ogg" -> "audio/ogg"; default -> "application/octet-stream"; }) .orElse("application/octet-stream"); @@ -324,6 +348,10 @@ public class ChannelDirectoryService { return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height()); } + if (mediaType.startsWith("audio/")) { + return new OptimizedAsset(bytes, mediaType, 0, 0); + } + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); if (image != null) { return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight()); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 7b23dab..0d5f9a0 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -476,7 +476,6 @@ body { background: radial-gradient(circle at 15% 20%, rgba(124, 58, 237, 0.08), transparent 40%), radial-gradient(circle at 85% 0%, rgba(59, 130, 246, 0.06), transparent 45%), #0b1220; - border: 1px solid #312e81; box-shadow: 0 18px 45px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03); } diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 685072e..e44786e 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -10,6 +10,7 @@ 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 = []; @@ -27,8 +28,15 @@ 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); @@ -46,6 +54,11 @@ if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChan 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); @@ -137,7 +150,14 @@ function handleEvent(event) { } } else if (event.payload) { storeAsset(event.payload); - ensureMedia(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(); } @@ -194,8 +214,11 @@ function drawAsset(asset) { const media = ensureMedia(asset); const drawSource = media?.isAnimated ? media.bitmap : media; - const ready = isDrawable(media); - if (ready) { + 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 { @@ -214,6 +237,9 @@ function drawAsset(asset) { 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); } @@ -277,6 +303,59 @@ function drawHandle(x, y, isRotation) { 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' }, @@ -434,6 +513,11 @@ function isVideoAsset(asset) { 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'; } @@ -477,6 +561,112 @@ function clearMedia(assetId) { 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) { @@ -486,6 +676,14 @@ function ensureMedia(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) { @@ -727,6 +925,14 @@ function createBadge(label, extraClass = '') { } 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'; @@ -782,6 +988,17 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { 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() { @@ -836,6 +1053,20 @@ function updateMuteFromInput() { 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; @@ -976,6 +1207,11 @@ function updateVisibility(asset, hidden) { 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(); }); @@ -983,8 +1219,8 @@ function updateVisibility(asset, hidden) { function deleteAsset(asset) { fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => { + clearMedia(asset.id); assets.delete(asset.id); - mediaCache.delete(asset.id); renderStates.delete(asset.id); zOrderDirty = true; if (selectedAssetId === asset.id) { @@ -1005,7 +1241,7 @@ function handleFileSelection(input) { function uploadAsset() { const fileInput = document.getElementById('asset-file'); if (!fileInput || !fileInput.files || fileInput.files.length === 0) { - alert('Please choose an image, GIF, or video to upload.'); + alert('Please choose an image, GIF, video, or audio file to upload.'); return; } const data = new FormData(); @@ -1060,7 +1296,12 @@ function persistTransform(asset, silent = false) { rotation: asset.rotation, speed: asset.speed, muted: asset.muted, - zIndex: asset.zIndex + 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); @@ -1097,6 +1338,13 @@ canvas.addEventListener('mousedown', (event) => { 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 = { diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index af1d8ed..49edd55 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -7,7 +7,9 @@ const assets = new Map(); const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); +const audioControllers = new Map(); let animationFrameId = null; +const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('Audio'); function connect() { const socket = new SockJS('/ws'); @@ -61,6 +63,9 @@ function handleEvent(event) { const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) }; assets.set(payload.id, payload); ensureMedia(payload); + if (isAudioAsset(payload)) { + playAudioImmediately(payload); + } } else if (event.payload && event.payload.hidden) { assets.delete(event.payload.id); clearMedia(event.payload.id); @@ -97,11 +102,18 @@ function drawAsset(asset) { const media = ensureMedia(asset); const drawSource = media?.isAnimated ? media.bitmap : media; - const ready = isDrawable(media); - if (ready) { + const ready = isAudioAsset(asset) || isDrawable(media); + if (isAudioAsset(asset)) { + autoStartAudio(asset); + } + if (ready && drawSource) { ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); } + if (isAudioAsset(asset)) { + drawAudioIndicators(asset, halfWidth, halfHeight); + } + ctx.restore(); } @@ -132,6 +144,10 @@ function isVideoAsset(asset) { return asset?.mediaType?.startsWith('video/'); } +function isAudioAsset(asset) { + return asset?.mediaType?.startsWith('audio/'); +} + function isVideoElement(element) { return element && element.tagName === 'VIDEO'; } @@ -140,6 +156,59 @@ function isGifAsset(asset) { return asset?.mediaType?.toLowerCase() === 'image/gif'; } +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 = 8; + let x = -halfWidth + padding + indicatorSize / 2; + const y = -halfHeight + padding + indicatorSize / 2; + + ctx.save(); + ctx.setLineDash([]); + if (isPlaying) { + ctx.fillStyle = 'rgba(34, 197, 94, 0.9)'; + ctx.strokeStyle = '#020617'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#020617'; + 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(234, 179, 8, 0.9)'; + ctx.strokeStyle = '#020617'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + ctx.strokeStyle = '#020617'; + 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 isDrawable(element) { if (!element) { return false; @@ -166,6 +235,104 @@ function clearMedia(assetId) { 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.preload = 'auto'; + element.controls = false; + 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 playAudioImmediately(asset) { + const controller = ensureAudioController(asset); + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + controller.delayTimeout = null; + } + controller.element.currentTime = 0; + const originalDelay = controller.delayMs; + controller.delayMs = 0; + controller.element.play().catch(() => {}); + controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; +} + +function autoStartAudio(asset) { + if (!isAudioAsset(asset) || asset.hidden) { + return; + } + const controller = ensureAudioController(asset); + if (!controller.element.paused && !controller.element.ended) { + return; + } + if (controller.delayTimeout) { + return; + } + controller.delayTimeout = setTimeout(() => { + controller.element.play().catch(() => {}); + }, controller.delayMs); } function ensureMedia(asset) { @@ -175,6 +342,14 @@ function ensureMedia(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) { diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 4d79ee4..ef4aa5b 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -28,11 +28,11 @@

Overlay assets

Upload overlay visuals and adjust them inline.

- + @@ -94,6 +94,37 @@
+ +