From c8de7d65e97c8a9e7b789738ad9b0247869d19b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 10 Dec 2025 14:09:58 +0100 Subject: [PATCH] Fix audio assets --- .../app/controller/ChannelApiController.java | 13 ++ .../com/imgfloat/app/model/AssetEvent.java | 16 ++ .../imgfloat/app/model/PlaybackRequest.java | 13 ++ .../app/service/ChannelDirectoryService.java | 13 ++ src/main/resources/static/css/styles.css | 8 + src/main/resources/static/js/admin.js | 188 ++++++++---------- src/main/resources/static/js/broadcast.js | 143 +++++++------ 7 files changed, 216 insertions(+), 178 deletions(-) create mode 100644 src/main/java/com/imgfloat/app/model/PlaybackRequest.java diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index c78dfd3..624b768 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -3,6 +3,7 @@ package com.imgfloat.app.controller; import com.imgfloat.app.model.AdminRequest; import com.imgfloat.app.model.AssetView; import com.imgfloat.app.model.CanvasSettingsRequest; +import com.imgfloat.app.model.PlaybackRequest; import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TwitchUserProfile; import com.imgfloat.app.model.VisibilityRequest; @@ -175,6 +176,18 @@ public class ChannelApiController { }); } + @PostMapping("/assets/{assetId}/play") + public ResponseEntity play(@PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestBody(required = false) PlaybackRequest request, + OAuth2AuthenticationToken authentication) { + String login = TwitchUser.from(authentication).login(); + ensureAuthorized(broadcaster, login); + return channelDirectoryService.triggerPlayback(broadcaster, assetId, request) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + } + @PutMapping("/assets/{assetId}/visibility") public ResponseEntity visibility(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, diff --git a/src/main/java/com/imgfloat/app/model/AssetEvent.java b/src/main/java/com/imgfloat/app/model/AssetEvent.java index 81219df..c96436f 100644 --- a/src/main/java/com/imgfloat/app/model/AssetEvent.java +++ b/src/main/java/com/imgfloat/app/model/AssetEvent.java @@ -5,6 +5,7 @@ public class AssetEvent { CREATED, UPDATED, VISIBILITY, + PLAY, DELETED } @@ -12,6 +13,7 @@ public class AssetEvent { private String channel; private AssetView payload; private String assetId; + private Boolean play; public static AssetEvent created(String channel, AssetView asset) { AssetEvent event = new AssetEvent(); @@ -22,6 +24,16 @@ public class AssetEvent { return event; } + public static AssetEvent play(String channel, AssetView asset, boolean play) { + AssetEvent event = new AssetEvent(); + event.type = Type.PLAY; + event.channel = channel; + event.payload = asset; + event.assetId = asset.id(); + event.play = play; + return event; + } + public static AssetEvent updated(String channel, AssetView asset) { AssetEvent event = new AssetEvent(); event.type = Type.UPDATED; @@ -63,4 +75,8 @@ public class AssetEvent { public String getAssetId() { return assetId; } + + public Boolean getPlay() { + return play; + } } diff --git a/src/main/java/com/imgfloat/app/model/PlaybackRequest.java b/src/main/java/com/imgfloat/app/model/PlaybackRequest.java new file mode 100644 index 0000000..0d5c73d --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/PlaybackRequest.java @@ -0,0 +1,13 @@ +package com.imgfloat.app.model; + +public class PlaybackRequest { + private Boolean play; + + public Boolean getPlay() { + return play == null ? Boolean.TRUE : play; + } + + public void setPlay(Boolean play) { + this.play = play; + } +} diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index f63165e..759116a 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -5,6 +5,7 @@ import com.imgfloat.app.model.AssetEvent; import com.imgfloat.app.model.Channel; import com.imgfloat.app.model.AssetView; import com.imgfloat.app.model.CanvasSettingsRequest; +import com.imgfloat.app.model.PlaybackRequest; import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.repository.AssetRepository; @@ -187,6 +188,18 @@ public class ChannelDirectoryService { }); } + public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest request) { + String normalized = normalize(broadcaster); + return assetRepository.findById(assetId) + .filter(asset -> normalized.equals(asset.getBroadcaster())) + .map(asset -> { + AssetView view = AssetView.from(normalized, asset); + boolean shouldPlay = request == null || request.getPlay(); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, shouldPlay)); + return view; + }); + } + public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 17e15d7..5dc2551 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -834,6 +834,14 @@ body { flex-shrink: 0; } +.audio-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + color: #fbbf24; +} + .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 defbcf1..51057a0 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -12,6 +12,7 @@ const renderStates = new Map(); const animatedCache = new Map(); const audioControllers = new Map(); const pendingAudioUnlock = new Set(); +const loopPlaybackState = new Map(); let drawPending = false; let zOrderDirty = true; let zOrderCache = []; @@ -43,7 +44,6 @@ const selectedAssetMeta = document.getElementById('selected-asset-meta'); const selectedAssetBadges = document.getElementById('selected-asset-badges'); const selectedVisibilityBtn = document.getElementById('selected-asset-visibility'); const selectedDeleteBtn = document.getElementById('selected-asset-delete'); -const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('Audio'); const aspectLockState = new Map(); const commitSizeChange = debounce(() => applyTransformFromInputs(), 180); const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart']; @@ -127,10 +127,10 @@ 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); +if (audioDelayInput) audioDelayInput.addEventListener('change', updateAudioSettingsFromInputs); +if (audioSpeedInput) audioSpeedInput.addEventListener('change', updateAudioSettingsFromInputs); +if (audioPitchInput) audioPitchInput.addEventListener('change', updateAudioSettingsFromInputs); +if (audioVolumeInput) audioVolumeInput.addEventListener('change', updateAudioSettingsFromInputs); if (selectedVisibilityBtn) { selectedVisibilityBtn.addEventListener('click', () => { const asset = getSelectedAsset(); @@ -258,6 +258,7 @@ function handleEvent(event) { zOrderDirty = true; clearMedia(event.assetId); renderStates.delete(event.assetId); + loopPlaybackState.delete(event.assetId); if (selectedAssetId === event.assetId) { selectedAssetId = null; } @@ -265,11 +266,12 @@ function handleEvent(event) { storeAsset(event.payload); if (!event.payload.hidden) { ensureMedia(event.payload); - if (isAudioAsset(event.payload) && event.type === 'VISIBILITY') { - playAudioFromCanvas(event.payload, true); + if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) { + loopPlaybackState.set(event.payload.id, true); } } else { clearMedia(event.payload.id); + loopPlaybackState.delete(event.payload.id); } } drawAndList(); @@ -325,12 +327,15 @@ function drawAsset(asset) { 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); + ctx.restore(); + return; } + + const media = ensureMedia(asset); + const drawSource = media?.isAnimated ? media.bitmap : media; + const ready = isDrawable(media); if (ready && drawSource) { ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); @@ -350,9 +355,6 @@ 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); } @@ -416,59 +418,6 @@ 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' }, @@ -759,29 +708,11 @@ function stopAudio(assetId) { 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; - safePlay(controller); - 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(() => { - safePlay(controller); - }, controller.delayMs); - } + ensureAudioController(asset); } function ensureMedia(asset) { @@ -793,10 +724,8 @@ function ensureMedia(asset) { if (isAudioAsset(asset)) { ensureAudioController(asset); - const placeholder = new Image(); - placeholder.src = audioPlaceholder; - mediaCache.set(asset.id, placeholder); - return placeholder; + mediaCache.delete(asset.id); + return null; } if (isGifAsset(asset) && 'ImageDecoder' in window) { @@ -821,7 +750,7 @@ function ensureMedia(asset) { if (playback === 0) { element.pause(); } else { - element.play().catch(() => {}); + element.play().catch(() => { }); } } else { element.onload = requestDraw; @@ -924,7 +853,7 @@ function applyMediaSettings(element, asset) { if (nextSpeed === 0) { element.pause(); } else if (element.paused) { - element.play().catch(() => {}); + element.play().catch(() => { }); } } @@ -1006,6 +935,31 @@ function renderAssetList() { updateVisibility(asset, !asset.hidden); }); + if (isAudioAsset(asset)) { + const playBtn = document.createElement('button'); + playBtn.type = 'button'; + playBtn.className = 'ghost icon-button'; + const isLooping = !!asset.audioLoop; + const isPlayingLoop = getLoopPlaybackState(asset); + updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop); + playBtn.title = isLooping + ? (isPlayingLoop ? 'Pause looping audio' : 'Play looping audio') + : 'Play audio'; + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const nextPlay = isLooping + ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) + : true; + if (isLooping) { + loopPlaybackState.set(asset.id, nextPlay); + updatePlayButtonIcon(playBtn, true, nextPlay); + playBtn.title = nextPlay ? 'Pause looping audio' : 'Play looping audio'; + } + triggerAudioPlayback(asset, nextPlay); + }); + actions.appendChild(playBtn); + } + const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'ghost danger icon-button'; @@ -1043,14 +997,29 @@ function createBadge(label, extraClass = '') { return badge; } +function getLoopPlaybackState(asset) { + if (!isAudioAsset(asset) || !asset.audioLoop) { + return false; + } + if (loopPlaybackState.has(asset.id)) { + return loopPlaybackState.get(asset.id); + } + const isVisible = asset.hidden === false || asset.hidden === undefined; + loopPlaybackState.set(asset.id, isVisible); + return isVisible; +} + +function updatePlayButtonIcon(button, isLooping, isPlayingLoop) { + const icon = isLooping ? (isPlayingLoop ? 'fa-pause' : 'fa-play') : 'fa-play'; + button.innerHTML = ``; +} + 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; + const icon = document.createElement('div'); + icon.className = 'asset-preview audio-icon'; + icon.innerHTML = ''; + return icon; } if (isVideoAsset(asset)) { const video = document.createElement('video'); @@ -1060,7 +1029,7 @@ function createPreviewElement(asset) { video.muted = true; video.playsInline = true; video.autoplay = true; - video.play().catch(() => {}); + video.play().catch(() => { }); return video; } @@ -1385,6 +1354,7 @@ function updateVisibility(asset, hidden) { }).then((updated) => { storeAsset(updated); if (updated.hidden) { + loopPlaybackState.set(updated.id, false); stopAudio(updated.id); if (typeof showToast === 'function') { showToast('Asset hidden from broadcast.', 'info'); @@ -1406,6 +1376,19 @@ function updateVisibility(asset, hidden) { }); } +function triggerAudioPlayback(asset, shouldPlay = true) { + if (!asset) return Promise.resolve(); + return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ play: shouldPlay }) + }).then((r) => r.json()).then((updated) => { + storeAsset(updated); + updateRenderState(updated); + return updated; + }); +} + function deleteAsset(asset) { fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }) .then((response) => { @@ -1506,7 +1489,7 @@ function isPointOnAsset(asset, x, y) { function findAssetAtPoint(x, y) { const ordered = [...getZOrderedAssets()].reverse(); - return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null; + return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null; } function persistTransform(asset, silent = false) { @@ -1573,13 +1556,6 @@ 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 6be68b9..9f7e42c 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -17,7 +17,6 @@ let pendingDraw = false; let sortedAssetsCache = []; let assetsDirty = true; let renderIntervalId = null; -const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('Audio'); const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart']; audioUnlockEvents.forEach((eventName) => { @@ -99,6 +98,12 @@ function handleEvent(event) { assets.delete(event.assetId); clearMedia(event.assetId); renderStates.delete(event.assetId); + } else if (event.type === 'PLAY' && event.payload) { + const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) }; + assets.set(payload.id, payload); + if (isAudioAsset(payload)) { + handleAudioPlay(payload, event.play !== false); + } } else if (event.payload && !event.payload.hidden) { const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) }; assets.set(payload.id, payload); @@ -169,18 +174,17 @@ function drawAsset(asset) { 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.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); + ctx.restore(); + return; } - if (isAudioAsset(asset)) { - drawAudioIndicators(asset, halfWidth, halfHeight); + const media = ensureMedia(asset); + const drawSource = media?.isAnimated ? media.bitmap : media; + const ready = isDrawable(media); + if (ready && drawSource) { + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); } ctx.restore(); @@ -253,59 +257,6 @@ 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; @@ -365,6 +316,7 @@ function ensureAudioController(asset) { element, delayTimeout: null, loopEnabled: false, + loopActive: true, delayMs: 0, baseDelayMs: 0 }; @@ -376,19 +328,24 @@ function ensureAudioController(asset) { function applyAudioSettings(controller, asset, resetPosition = false) { controller.loopEnabled = !!asset.audioLoop; + controller.loopActive = controller.loopEnabled && controller.loopActive !== false; 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; + applyAudioElementSettings(controller.element, asset); if (resetPosition) { controller.element.currentTime = 0; controller.element.pause(); } } +function applyAudioElementSettings(element, asset) { + const speed = Math.max(0.25, asset.audioSpeed || 1); + const pitch = Math.max(0.5, asset.audioPitch || 1); + element.playbackRate = speed * pitch; + const volume = Math.max(0, Math.min(1, asset.audioVolume ?? 1)); + element.volume = volume; +} + function handleAudioEnded(assetId) { const controller = audioControllers.get(assetId); if (!controller) return; @@ -396,7 +353,7 @@ function handleAudioEnded(assetId) { if (controller.delayTimeout) { clearTimeout(controller.delayTimeout); } - if (controller.loopEnabled) { + if (controller.loopEnabled && controller.loopActive) { controller.delayTimeout = setTimeout(() => { safePlay(controller); }, controller.delayMs); @@ -405,6 +362,19 @@ function handleAudioEnded(assetId) { } } +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; + controller.loopActive = false; +} + function playAudioImmediately(asset) { const controller = ensureAudioController(asset); if (controller.delayTimeout) { @@ -418,11 +388,42 @@ function playAudioImmediately(asset) { controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; } +function playOverlappingAudio(asset) { + const temp = new Audio(asset.url); + temp.autoplay = true; + temp.preload = 'auto'; + temp.controls = false; + applyAudioElementSettings(temp, asset); + const controller = { element: temp }; + temp.onended = () => { + temp.remove(); + }; + safePlay(controller); +} + +function handleAudioPlay(asset, shouldPlay) { + const controller = ensureAudioController(asset); + controller.loopActive = !!shouldPlay; + if (!shouldPlay) { + stopAudio(asset.id); + return; + } + if (asset.audioLoop) { + controller.delayMs = controller.baseDelayMs; + safePlay(controller); + } else { + playOverlappingAudio(asset); + } +} + function autoStartAudio(asset) { if (!isAudioAsset(asset) || asset.hidden) { return; } const controller = ensureAudioController(asset); + if (!controller.loopEnabled || !controller.loopActive) { + return; + } if (!controller.element.paused && !controller.element.ended) { return; } @@ -443,10 +444,8 @@ function ensureMedia(asset) { if (isAudioAsset(asset)) { ensureAudioController(asset); - const placeholder = new Image(); - placeholder.src = audioPlaceholder; - mediaCache.set(asset.id, placeholder); - return placeholder; + mediaCache.delete(asset.id); + return null; } if (isGifAsset(asset) && 'ImageDecoder' in window) {