diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 2b5cfc6..6579618 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -11,6 +11,7 @@ const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); const audioControllers = new Map(); +const pendingAudioUnlock = new Set(); let drawPending = false; let zOrderDirty = true; let zOrderCache = []; @@ -45,6 +46,17 @@ 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']; + +audioUnlockEvents.forEach((eventName) => { + window.addEventListener(eventName, () => { + if (!pendingAudioUnlock.size) return; + pendingAudioUnlock.forEach((controller) => { + safePlay(controller); + }); + pendingAudioUnlock.clear(); + }); +}); function debounce(fn, wait = 150) { let timeout; @@ -54,6 +66,60 @@ function debounce(fn, wait = 150) { }; } +function formatDurationLabel(durationMs) { + const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function recordDuration(assetId, seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + const asset = assets.get(assetId); + if (!asset) { + return; + } + const nextMs = Math.round(seconds * 1000); + if (asset.durationMs === nextMs) { + return; + } + asset.durationMs = nextMs; + if (asset.id === selectedAssetId) { + updateSelectedAssetSummary(asset); + } + drawAndList(); +} + +function hasDuration(asset) { + return asset && Number.isFinite(asset.durationMs) && asset.durationMs > 0 && (isAudioAsset(asset) || isVideoAsset(asset)); +} + +function getDurationBadge(asset) { + if (!hasDuration(asset)) { + return null; + } + return formatDurationLabel(asset.durationMs); +} + +function queueAudioForUnlock(controller) { + if (!controller) return; + pendingAudioUnlock.add(controller); +} + +function safePlay(controller) { + if (!controller?.element) return; + const playPromise = controller.element.play(); + if (playPromise?.catch) { + playPromise.catch(() => queueAudioForUnlock(controller)); + } +} + if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); if (widthInput) widthInput.addEventListener('change', () => commitSizeChange()); if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); @@ -604,8 +670,10 @@ function ensureAudioController(asset) { } const element = new Audio(asset.url); + element.autoplay = true; element.controls = true; element.preload = 'auto'; + element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration)); const controller = { id: asset.id, src: asset.url, @@ -645,7 +713,7 @@ function handleAudioEnded(assetId) { } if (controller.loopEnabled) { controller.delayTimeout = setTimeout(() => { - controller.element.play().catch(() => {}); + safePlay(controller); }, controller.delayMs); } else { controller.element.pause(); @@ -672,7 +740,7 @@ function playAudioFromCanvas(asset, resetDelay = false) { } controller.element.currentTime = 0; controller.delayMs = resetDelay ? 0 : controller.baseDelayMs; - controller.element.play().catch(() => {}); + safePlay(controller); controller.delayMs = controller.baseDelayMs; requestDraw(); } @@ -684,7 +752,7 @@ function autoStartAudio(asset) { const controller = ensureAudioController(asset); if (controller.loopEnabled && controller.element.paused && !controller.delayTimeout) { controller.delayTimeout = setTimeout(() => { - controller.element.play().catch(() => {}); + safePlay(controller); }, controller.delayMs); } } @@ -719,6 +787,7 @@ function ensureMedia(asset) { element.playsInline = true; element.autoplay = true; element.onloadeddata = requestDraw; + element.onloadedmetadata = () => recordDuration(asset.id, element.duration); element.src = asset.url; const playback = asset.speed ?? 1; element.playbackRate = Math.max(playback, 0.01); @@ -890,6 +959,10 @@ function renderAssetList() { if (aspectLabel) { badges.appendChild(createBadge(aspectLabel, 'subtle')); } + const durationLabel = getDurationBadge(asset); + if (durationLabel) { + badges.appendChild(createBadge(durationLabel, 'subtle')); + } meta.appendChild(badges); const actions = document.createElement('div'); @@ -1016,6 +1089,12 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { if (audioSection) { const showAudio = isAudioAsset(asset); audioSection.classList.toggle('hidden', !showAudio); + const audioInputs = [audioLoopInput, audioDelayInput, audioSpeedInput, audioPitchInput, audioVolumeInput]; + audioInputs.forEach((input) => { + if (!input) return; + input.disabled = !showAudio; + input.parentElement?.classList?.toggle('disabled', !showAudio); + }); if (showAudio) { audioLoopInput.checked = !!asset.audioLoop; audioDelayInput.value = Math.max(0, asset.audioDelayMillis ?? 0); @@ -1048,6 +1127,10 @@ function updateSelectedAssetSummary(asset) { if (aspectLabel) { selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle')); } + const durationLabel = getDurationBadge(asset); + if (durationLabel) { + selectedAssetBadges.appendChild(createBadge(durationLabel, 'subtle')); + } } } if (selectedVisibilityBtn) { diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 7cd4c7f..8024fee 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -8,6 +8,7 @@ const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); const audioControllers = new Map(); +const pendingAudioUnlock = new Set(); const TARGET_FPS = 60; const MIN_FRAME_TIME = 1000 / TARGET_FPS; let lastRenderTime = 0; @@ -17,6 +18,15 @@ 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) => { + window.addEventListener(eventName, () => { + if (!pendingAudioUnlock.size) return; + pendingAudioUnlock.forEach((controller) => safePlay(controller)); + pendingAudioUnlock.clear(); + }); +}); function connect() { const socket = new SockJS('/ws'); @@ -177,6 +187,34 @@ function lerp(a, b, t) { return a + (b - a) * t; } +function queueAudioForUnlock(controller) { + if (!controller) return; + pendingAudioUnlock.add(controller); +} + +function safePlay(controller) { + if (!controller?.element) return; + const playPromise = controller.element.play(); + if (playPromise?.catch) { + playPromise.catch(() => queueAudioForUnlock(controller)); + } +} + +function recordDuration(assetId, seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + const asset = assets.get(assetId); + if (!asset) { + return; + } + const nextMs = Math.round(seconds * 1000); + if (asset.durationMs === nextMs) { + return; + } + asset.durationMs = nextMs; +} + function isVideoAsset(asset) { return asset?.mediaType?.startsWith('video/'); } @@ -295,8 +333,10 @@ function ensureAudioController(asset) { } const element = new Audio(asset.url); + element.autoplay = true; element.preload = 'auto'; element.controls = false; + element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration)); const controller = { id: asset.id, src: asset.url, @@ -336,7 +376,7 @@ function handleAudioEnded(assetId) { } if (controller.loopEnabled) { controller.delayTimeout = setTimeout(() => { - controller.element.play().catch(() => {}); + safePlay(controller); }, controller.delayMs); } else { controller.element.pause(); @@ -352,7 +392,7 @@ function playAudioImmediately(asset) { controller.element.currentTime = 0; const originalDelay = controller.delayMs; controller.delayMs = 0; - controller.element.play().catch(() => {}); + safePlay(controller); controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; } @@ -368,7 +408,7 @@ function autoStartAudio(asset) { return; } controller.delayTimeout = setTimeout(() => { - controller.element.play().catch(() => {}); + safePlay(controller); }, controller.delayMs); } @@ -402,6 +442,7 @@ function ensureMedia(asset) { element.playsInline = true; element.autoplay = true; element.onloadeddata = draw; + element.onloadedmetadata = () => recordDuration(asset.id, element.duration); element.src = asset.url; const playback = asset.speed ?? 1; element.playbackRate = Math.max(playback, 0.01);