Refactor broadcast page

This commit is contained in:
2026-01-09 00:15:07 +01:00
parent 81c078d95f
commit 773021c456
9 changed files with 1077 additions and 955 deletions

View File

@@ -0,0 +1,334 @@
import { isAudioAsset } from "../media/audio.js";
import { isGifAsset, isVideoAsset, isVideoElement } from "./assetKinds.js";
export function createMediaManager({
state,
audioManager,
draw,
obsBrowser,
supportsAnimatedDecode,
canPlayProbe,
}) {
const { mediaCache, animatedCache, blobCache, animationFailures, videoPlaybackStates } = state;
function clearMedia(assetId) {
const element = mediaCache.get(assetId);
if (isVideoElement(element)) {
element.src = "";
element.remove();
}
mediaCache.delete(assetId);
const animated = animatedCache.get(assetId);
if (animated) {
animated.cancelled = true;
clearTimeout(animated.timeout);
animated.bitmap?.close?.();
animated.decoder?.close?.();
animatedCache.delete(assetId);
}
animationFailures.delete(assetId);
const cachedBlob = blobCache.get(assetId);
if (cachedBlob?.objectUrl) {
URL.revokeObjectURL(cachedBlob.objectUrl);
}
blobCache.delete(assetId);
audioManager.clearAudio(assetId);
}
function ensureMedia(asset) {
const cached = mediaCache.get(asset.id);
const cachedSource = getCachedSource(cached);
if (cached && cachedSource !== asset.url) {
clearMedia(asset.id);
}
if (cached && cachedSource === asset.url) {
applyMediaSettings(cached, asset);
return cached;
}
if (isAudioAsset(asset)) {
audioManager.ensureAudioController(asset);
mediaCache.delete(asset.id);
return null;
}
if (isGifAsset(asset) && supportsAnimatedDecode) {
const animated = ensureAnimatedImage(asset);
if (animated) {
mediaCache.set(asset.id, animated);
return animated;
}
}
const element = isVideoAsset(asset) ? document.createElement("video") : new Image();
element.dataset.sourceUrl = asset.url;
element.crossOrigin = "anonymous";
if (isVideoElement(element)) {
if (!canPlayVideoType(asset.mediaType)) {
return null;
}
element.loop = true;
element.playsInline = true;
element.autoplay = true;
element.controls = false;
element.onloadeddata = draw;
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
element.preload = "auto";
element.addEventListener("error", () => clearMedia(asset.id));
const playbackState = getVideoPlaybackState(element);
element.addEventListener("playing", () => {
playbackState.playRequested = false;
if (playbackState.unmuteOnPlay) {
element.muted = false;
playbackState.unmuteOnPlay = false;
}
});
element.addEventListener("pause", () => {
playbackState.playRequested = false;
});
audioManager.applyMediaVolume(element, asset);
element.muted = true;
setVideoSource(element, asset);
} else {
element.onload = draw;
element.src = asset.url;
}
mediaCache.set(asset.id, element);
return element;
}
function ensureAnimatedImage(asset) {
const failedAt = animationFailures.get(asset.id);
if (failedAt && Date.now() - failedAt < 15000) {
return null;
}
const cached = animatedCache.get(asset.id);
if (cached && cached.url === asset.url) {
return cached;
}
animationFailures.delete(asset.id);
if (cached) {
clearMedia(asset.id);
}
const controller = {
id: asset.id,
url: asset.url,
src: asset.url,
decoder: null,
bitmap: null,
timeout: null,
cancelled: false,
isAnimated: true,
};
fetchAssetBlob(asset)
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" }))
.then((decoder) => {
if (controller.cancelled) {
decoder.close?.();
return null;
}
controller.decoder = decoder;
scheduleNextFrame(controller);
return controller;
})
.catch(() => {
animatedCache.delete(asset.id);
animationFailures.set(asset.id, Date.now());
});
animatedCache.set(asset.id, controller);
return controller;
}
function fetchAssetBlob(asset) {
const cached = blobCache.get(asset.id);
if (cached && cached.url === asset.url && cached.blob) {
return Promise.resolve(cached.blob);
}
if (cached && cached.url === asset.url && cached.pending) {
return cached.pending;
}
const pending = fetch(asset.url)
.then((r) => r.blob())
.then((blob) => {
const previous = blobCache.get(asset.id);
const existingUrl = previous?.url === asset.url ? previous.objectUrl : null;
const objectUrl = existingUrl || URL.createObjectURL(blob);
blobCache.set(asset.id, { url: asset.url, blob, objectUrl });
return blob;
});
blobCache.set(asset.id, { url: asset.url, pending });
return pending;
}
function setVideoSource(element, asset) {
if (!shouldUseBlobUrl(asset)) {
applyVideoSource(element, asset.url, asset);
return;
}
const cached = blobCache.get(asset.id);
if (cached?.url === asset.url && cached.objectUrl) {
applyVideoSource(element, cached.objectUrl, asset);
return;
}
fetchAssetBlob(asset)
.then(() => {
const next = blobCache.get(asset.id);
if (next?.url !== asset.url || !next.objectUrl) {
return;
}
applyVideoSource(element, next.objectUrl, asset);
})
.catch(() => {});
}
function applyVideoSource(element, objectUrl, asset) {
element.src = objectUrl;
startVideoPlayback(element, asset);
}
function shouldUseBlobUrl(asset) {
return !obsBrowser && asset?.mediaType && canPlayVideoType(asset.mediaType);
}
function canPlayVideoType(mediaType) {
if (!mediaType) {
return true;
}
const support = canPlayProbe.canPlayType(mediaType);
return support === "probably" || support === "maybe";
}
function getCachedSource(element) {
return element?.dataset?.sourceUrl || element?.src;
}
function scheduleNextFrame(controller) {
if (controller.cancelled || !controller.decoder) {
return;
}
controller.decoder
.decode()
.then(({ image, complete }) => {
if (controller.cancelled) {
image.close?.();
return;
}
controller.bitmap?.close?.();
createImageBitmap(image)
.then((bitmap) => {
controller.bitmap = bitmap;
draw();
})
.finally(() => image.close?.());
const durationMicros = image.duration || 0;
const delay = durationMicros > 0 ? durationMicros / 1000 : 100;
const hasMore = !complete;
controller.timeout = setTimeout(() => {
if (controller.cancelled) {
return;
}
if (hasMore) {
scheduleNextFrame(controller);
} else {
controller.decoder.reset();
scheduleNextFrame(controller);
}
}, delay);
})
.catch(() => {
animatedCache.delete(controller.id);
animationFailures.set(controller.id, Date.now());
});
}
function applyMediaSettings(element, asset) {
if (!isVideoElement(element)) {
return;
}
startVideoPlayback(element, asset);
}
function startVideoPlayback(element, asset) {
const playbackState = getVideoPlaybackState(element);
const nextSpeed = asset.speed ?? 1;
const effectiveSpeed = Math.max(nextSpeed, 0.01);
if (element.playbackRate !== effectiveSpeed) {
element.playbackRate = effectiveSpeed;
}
const volume = audioManager.applyMediaVolume(element, asset);
const shouldUnmute = volume > 0;
element.muted = true;
if (effectiveSpeed === 0) {
element.pause();
playbackState.playRequested = false;
playbackState.unmuteOnPlay = false;
return;
}
element.play();
if (shouldUnmute) {
if (!element.paused && element.readyState >= 2) {
element.muted = false;
} else {
playbackState.unmuteOnPlay = true;
}
}
if (element.paused || element.ended) {
if (!playbackState.playRequested) {
playbackState.playRequested = true;
const playPromise = element.play();
if (playPromise?.catch) {
playPromise.catch(() => {
playbackState.playRequested = false;
});
}
}
}
}
function recordDuration(assetId, seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return;
}
const asset = state.assets.get(assetId);
if (!asset) {
return;
}
const nextMs = Math.round(seconds * 1000);
if (asset.durationMs === nextMs) {
return;
}
asset.durationMs = nextMs;
}
function getVideoPlaybackState(element) {
if (!element) {
return { playRequested: false, unmuteOnPlay: false };
}
let playbackState = videoPlaybackStates.get(element);
if (!playbackState) {
playbackState = { playRequested: false, unmuteOnPlay: false };
videoPlaybackStates.set(element, playbackState);
}
return playbackState;
}
return {
clearMedia,
ensureMedia,
applyMediaSettings,
canPlayVideoType,
};
}