Broadcaster audio

This commit is contained in:
2025-12-10 13:49:40 +01:00
parent 6a15569d0c
commit 8444f1873a
2 changed files with 130 additions and 6 deletions

View File

@@ -11,6 +11,7 @@ const mediaCache = new Map();
const renderStates = new Map(); const renderStates = new Map();
const animatedCache = new Map(); const animatedCache = new Map();
const audioControllers = new Map(); const audioControllers = new Map();
const pendingAudioUnlock = new Set();
let drawPending = false; let drawPending = false;
let zOrderDirty = true; let zOrderDirty = true;
let zOrderCache = []; let zOrderCache = [];
@@ -45,6 +46,17 @@ const selectedDeleteBtn = document.getElementById('selected-asset-delete');
const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#1f2937" rx="8"/><g fill="#fbbf24" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#fef3c7"/><rect x="45" y="23" width="110" height="5" fill="#fef3c7"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>'); const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#1f2937" rx="8"/><g fill="#fbbf24" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#fef3c7"/><rect x="45" y="23" width="110" height="5" fill="#fef3c7"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>');
const aspectLockState = new Map(); const aspectLockState = new Map();
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180); 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) { function debounce(fn, wait = 150) {
let timeout; 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('input', () => handleSizeInputChange('width'));
if (widthInput) widthInput.addEventListener('change', () => commitSizeChange()); if (widthInput) widthInput.addEventListener('change', () => commitSizeChange());
if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height'));
@@ -604,8 +670,10 @@ function ensureAudioController(asset) {
} }
const element = new Audio(asset.url); const element = new Audio(asset.url);
element.autoplay = true;
element.controls = true; element.controls = true;
element.preload = 'auto'; element.preload = 'auto';
element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration));
const controller = { const controller = {
id: asset.id, id: asset.id,
src: asset.url, src: asset.url,
@@ -645,7 +713,7 @@ function handleAudioEnded(assetId) {
} }
if (controller.loopEnabled) { if (controller.loopEnabled) {
controller.delayTimeout = setTimeout(() => { controller.delayTimeout = setTimeout(() => {
controller.element.play().catch(() => {}); safePlay(controller);
}, controller.delayMs); }, controller.delayMs);
} else { } else {
controller.element.pause(); controller.element.pause();
@@ -672,7 +740,7 @@ function playAudioFromCanvas(asset, resetDelay = false) {
} }
controller.element.currentTime = 0; controller.element.currentTime = 0;
controller.delayMs = resetDelay ? 0 : controller.baseDelayMs; controller.delayMs = resetDelay ? 0 : controller.baseDelayMs;
controller.element.play().catch(() => {}); safePlay(controller);
controller.delayMs = controller.baseDelayMs; controller.delayMs = controller.baseDelayMs;
requestDraw(); requestDraw();
} }
@@ -684,7 +752,7 @@ function autoStartAudio(asset) {
const controller = ensureAudioController(asset); const controller = ensureAudioController(asset);
if (controller.loopEnabled && controller.element.paused && !controller.delayTimeout) { if (controller.loopEnabled && controller.element.paused && !controller.delayTimeout) {
controller.delayTimeout = setTimeout(() => { controller.delayTimeout = setTimeout(() => {
controller.element.play().catch(() => {}); safePlay(controller);
}, controller.delayMs); }, controller.delayMs);
} }
} }
@@ -719,6 +787,7 @@ function ensureMedia(asset) {
element.playsInline = true; element.playsInline = true;
element.autoplay = true; element.autoplay = true;
element.onloadeddata = requestDraw; element.onloadeddata = requestDraw;
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
element.src = asset.url; element.src = asset.url;
const playback = asset.speed ?? 1; const playback = asset.speed ?? 1;
element.playbackRate = Math.max(playback, 0.01); element.playbackRate = Math.max(playback, 0.01);
@@ -890,6 +959,10 @@ function renderAssetList() {
if (aspectLabel) { if (aspectLabel) {
badges.appendChild(createBadge(aspectLabel, 'subtle')); badges.appendChild(createBadge(aspectLabel, 'subtle'));
} }
const durationLabel = getDurationBadge(asset);
if (durationLabel) {
badges.appendChild(createBadge(durationLabel, 'subtle'));
}
meta.appendChild(badges); meta.appendChild(badges);
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -1016,6 +1089,12 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) {
if (audioSection) { if (audioSection) {
const showAudio = isAudioAsset(asset); const showAudio = isAudioAsset(asset);
audioSection.classList.toggle('hidden', !showAudio); 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) { if (showAudio) {
audioLoopInput.checked = !!asset.audioLoop; audioLoopInput.checked = !!asset.audioLoop;
audioDelayInput.value = Math.max(0, asset.audioDelayMillis ?? 0); audioDelayInput.value = Math.max(0, asset.audioDelayMillis ?? 0);
@@ -1048,6 +1127,10 @@ function updateSelectedAssetSummary(asset) {
if (aspectLabel) { if (aspectLabel) {
selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle')); selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle'));
} }
const durationLabel = getDurationBadge(asset);
if (durationLabel) {
selectedAssetBadges.appendChild(createBadge(durationLabel, 'subtle'));
}
} }
} }
if (selectedVisibilityBtn) { if (selectedVisibilityBtn) {

View File

@@ -8,6 +8,7 @@ const mediaCache = new Map();
const renderStates = new Map(); const renderStates = new Map();
const animatedCache = new Map(); const animatedCache = new Map();
const audioControllers = new Map(); const audioControllers = new Map();
const pendingAudioUnlock = new Set();
const TARGET_FPS = 60; const TARGET_FPS = 60;
const MIN_FRAME_TIME = 1000 / TARGET_FPS; const MIN_FRAME_TIME = 1000 / TARGET_FPS;
let lastRenderTime = 0; let lastRenderTime = 0;
@@ -17,6 +18,15 @@ let sortedAssetsCache = [];
let assetsDirty = true; let assetsDirty = true;
let renderIntervalId = null; let renderIntervalId = null;
const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#0f172a" rx="8"/><g fill="#22d3ee" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#a5f3fc"/><rect x="45" y="23" width="110" height="5" fill="#a5f3fc"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>'); const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#0f172a" rx="8"/><g fill="#22d3ee" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#a5f3fc"/><rect x="45" y="23" width="110" height="5" fill="#a5f3fc"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>');
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
audioUnlockEvents.forEach((eventName) => {
window.addEventListener(eventName, () => {
if (!pendingAudioUnlock.size) return;
pendingAudioUnlock.forEach((controller) => safePlay(controller));
pendingAudioUnlock.clear();
});
});
function connect() { function connect() {
const socket = new SockJS('/ws'); const socket = new SockJS('/ws');
@@ -177,6 +187,34 @@ function lerp(a, b, t) {
return a + (b - a) * 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) { function isVideoAsset(asset) {
return asset?.mediaType?.startsWith('video/'); return asset?.mediaType?.startsWith('video/');
} }
@@ -295,8 +333,10 @@ function ensureAudioController(asset) {
} }
const element = new Audio(asset.url); const element = new Audio(asset.url);
element.autoplay = true;
element.preload = 'auto'; element.preload = 'auto';
element.controls = false; element.controls = false;
element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration));
const controller = { const controller = {
id: asset.id, id: asset.id,
src: asset.url, src: asset.url,
@@ -336,7 +376,7 @@ function handleAudioEnded(assetId) {
} }
if (controller.loopEnabled) { if (controller.loopEnabled) {
controller.delayTimeout = setTimeout(() => { controller.delayTimeout = setTimeout(() => {
controller.element.play().catch(() => {}); safePlay(controller);
}, controller.delayMs); }, controller.delayMs);
} else { } else {
controller.element.pause(); controller.element.pause();
@@ -352,7 +392,7 @@ function playAudioImmediately(asset) {
controller.element.currentTime = 0; controller.element.currentTime = 0;
const originalDelay = controller.delayMs; const originalDelay = controller.delayMs;
controller.delayMs = 0; controller.delayMs = 0;
controller.element.play().catch(() => {}); safePlay(controller);
controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0;
} }
@@ -368,7 +408,7 @@ function autoStartAudio(asset) {
return; return;
} }
controller.delayTimeout = setTimeout(() => { controller.delayTimeout = setTimeout(() => {
controller.element.play().catch(() => {}); safePlay(controller);
}, controller.delayMs); }, controller.delayMs);
} }
@@ -402,6 +442,7 @@ function ensureMedia(asset) {
element.playsInline = true; element.playsInline = true;
element.autoplay = true; element.autoplay = true;
element.onloadeddata = draw; element.onloadeddata = draw;
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
element.src = asset.url; element.src = asset.url;
const playback = asset.speed ?? 1; const playback = asset.speed ?? 1;
element.playbackRate = Math.max(playback, 0.01); element.playbackRate = Math.max(playback, 0.01);