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('');
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('');
+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);