From c90e8cf54e990c68759ec25c6aea04b3c18c1765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 13 Jan 2026 14:51:28 +0100 Subject: [PATCH] Move client back to server --- src/broadcast.html | 16 - src/css/broadcast.css | 20 -- src/css/toast.css | 90 ----- src/index.html | 13 +- src/js/broadcast.js | 38 -- src/js/broadcast/assetKinds.js | 39 --- src/js/broadcast/audio.js | 10 - src/js/broadcast/audioManager.js | 216 ------------ src/js/broadcast/constants.js | 9 - src/js/broadcast/layers.js | 44 --- src/js/broadcast/mediaManager.js | 327 ----------------- src/js/broadcast/renderer.js | 562 ------------------------------ src/js/broadcast/script-worker.js | 263 -------------- src/js/broadcast/state.js | 14 - src/js/broadcast/visibility.js | 33 -- src/js/ipc.js | 21 -- src/js/toast.js | 56 --- 17 files changed, 5 insertions(+), 1766 deletions(-) delete mode 100644 src/broadcast.html delete mode 100644 src/css/broadcast.css delete mode 100644 src/css/toast.css delete mode 100644 src/js/broadcast.js delete mode 100644 src/js/broadcast/assetKinds.js delete mode 100644 src/js/broadcast/audio.js delete mode 100644 src/js/broadcast/audioManager.js delete mode 100644 src/js/broadcast/constants.js delete mode 100644 src/js/broadcast/layers.js delete mode 100644 src/js/broadcast/mediaManager.js delete mode 100644 src/js/broadcast/renderer.js delete mode 100644 src/js/broadcast/script-worker.js delete mode 100644 src/js/broadcast/state.js delete mode 100644 src/js/broadcast/visibility.js delete mode 100644 src/js/ipc.js delete mode 100644 src/js/toast.js diff --git a/src/broadcast.html b/src/broadcast.html deleted file mode 100644 index aef3254..0000000 --- a/src/broadcast.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Imgfloat Broadcast - - - - - - - - - - - diff --git a/src/css/broadcast.css b/src/css/broadcast.css deleted file mode 100644 index fd87c51..0000000 --- a/src/css/broadcast.css +++ /dev/null @@ -1,20 +0,0 @@ -.broadcast-body { - margin: 0; - overflow: hidden; - background: transparent; -} - -.broadcast-body canvas { - position: absolute; - top: 0; - left: 0; - pointer-events: none; -} - -[id="broadcast-canvas"] { - z-index: 1; -} - -[id="broadcast-script-canvas"] { - z-index: 2; -} diff --git a/src/css/toast.css b/src/css/toast.css deleted file mode 100644 index 9edb837..0000000 --- a/src/css/toast.css +++ /dev/null @@ -1,90 +0,0 @@ -.toast-container { - position: fixed; - bottom: 16px; - right: 16px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 10000; - max-width: 360px; -} - -.toast { - display: grid; - grid-template-columns: auto 1fr; - gap: 12px; - align-items: center; - padding: 12px 14px; - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - background: #0b1221; - color: #e5e7eb; - cursor: pointer; - transition: - transform 120ms ease, - opacity 120ms ease; -} - -.toast:hover { - transform: translateY(-2px); -} - -.toast-exit { - opacity: 0; - transform: translateY(-6px); -} - -.toast-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - background: #a5b4fc; - box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16); -} - -.toast-message { - margin: 0; - font-size: 14px; - line-height: 1.4; -} - -.toast-success { - border-color: rgba(34, 197, 94, 0.35); - background: rgba(16, 185, 129, 0.42); -} - -.toast-success .toast-indicator { - background: #34d399; - box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2); -} - -.toast-error { - border-color: rgba(239, 68, 68, 0.35); - background: rgba(248, 113, 113, 0.42); -} - -.toast-error .toast-indicator { - background: #f87171; - box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2); -} - -.toast-warning { - border-color: rgba(251, 191, 36, 0.35); - background: rgba(251, 191, 36, 0.42); -} - -.toast-warning .toast-indicator { - background: #facc15; - box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2); -} - -.toast-info { - border-color: rgba(96, 165, 250, 0.35); - background: rgba(96, 165, 250, 0.12); -} - -.toast-info .toast-indicator { - background: #60a5fa; - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} diff --git a/src/index.html b/src/index.html index 8cdfd84..befe6d0 100644 --- a/src/index.html +++ b/src/index.html @@ -81,16 +81,13 @@ const channel = input.value.trim(); const fallbackDomain = domainInput.placeholder || ""; const domain = domainInput.value.trim() || fallbackDomain; - if (!channel) return; + if (!channel) { + return + }; - if (domain) { - window.store.saveDomain(domain); - } const params = new URLSearchParams({ broadcaster: channel }); - if (domain) { - params.set("domain", domain); - } - window.location.href = `broadcast.html?${params.toString()}`; + window.store.saveDomain(domain); + window.location.href = `${domain}/view/${encodeURIComponent(channel)}/broadcast`; }); diff --git a/src/js/broadcast.js b/src/js/broadcast.js deleted file mode 100644 index b96761b..0000000 --- a/src/js/broadcast.js +++ /dev/null @@ -1,38 +0,0 @@ -import { BroadcastRenderer } from "./broadcast/renderer.js"; -import { saveSelectedBroadcaster } from "./ipc.js"; -import { showToast } from "./toast.js"; - -const normalizeDomain = (value) => value?.trim()?.replace(/\/+$/, ""); -const domainParam = new URL(window.location.href).searchParams.get("domain"); -const defaultDomain = await window.store.loadDefaultDomain(); -const savedDomain = await window.store.loadDomain(); -const domain = normalizeDomain(domainParam || savedDomain || defaultDomain); - -globalThis.onerror = (error, url, line) => { - console.error(error); - showToast(`Runtime error: ${error} (${url}:${line})`, "error"); -}; -globalThis.onunhandledrejection = (error) => { - console.error(error); - showToast(`Unhandled rejection: ${error.reason}`, "error"); -}; - -const broadcaster = new URL(window.location.href).searchParams.get("broadcaster"); -if (!broadcaster) { - throw new Error("No broadcaster"); -} -if (!domain) { - throw new Error("No domain configured"); -} -saveSelectedBroadcaster(broadcaster); - -const renderer = new BroadcastRenderer({ - broadcaster, - domain, - canvas: document.getElementById("broadcast-canvas"), - scriptCanvas: document.getElementById("broadcast-script-canvas"), -}); - -renderer.start().then(() => { - showToast(`Welcome, ${broadcaster}`, "success"); -}); diff --git a/src/js/broadcast/assetKinds.js b/src/js/broadcast/assetKinds.js deleted file mode 100644 index 23aabf0..0000000 --- a/src/js/broadcast/assetKinds.js +++ /dev/null @@ -1,39 +0,0 @@ -import { isAudioAsset } from "./audio.js"; -import { AssetKind } from "./constants.js"; - -export function isCodeAsset(asset) { - if (asset?.assetType) { - return asset.assetType === "SCRIPT"; - } - const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase(); - return type.startsWith("application/javascript") || type.startsWith("text/javascript"); -} - -export function isVideoAsset(asset) { - if (asset?.assetType) { - return asset.assetType === "VIDEO"; - } - return asset?.mediaType?.startsWith("video/"); -} - -export function isVideoElement(element) { - return element?.tagName === "VIDEO"; -} - -export function isGifAsset(asset) { - return asset?.mediaType?.toLowerCase() === "image/gif"; -} - -export function getAssetKind(asset) { - if (isAudioAsset(asset)) { - return AssetKind.AUDIO; - } - if (isCodeAsset(asset)) { - return AssetKind.CODE; - } - return AssetKind.VISUAL; -} - -export function isVisualAsset(asset) { - return getAssetKind(asset) === AssetKind.VISUAL; -} diff --git a/src/js/broadcast/audio.js b/src/js/broadcast/audio.js deleted file mode 100644 index 57f5231..0000000 --- a/src/js/broadcast/audio.js +++ /dev/null @@ -1,10 +0,0 @@ -export function isAudioAsset(asset) { - if (!asset) { - console.warn("isAudioAsset called with null or undefined asset"); - } - if (asset?.assetType) { - return asset.assetType === "AUDIO"; - } - const type = asset?.mediaType || asset?.originalMediaType || ""; - return type.startsWith("audio/"); -} diff --git a/src/js/broadcast/audioManager.js b/src/js/broadcast/audioManager.js deleted file mode 100644 index 213ce24..0000000 --- a/src/js/broadcast/audioManager.js +++ /dev/null @@ -1,216 +0,0 @@ -const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; - -export function createAudioManager({ assets, globalScope = globalThis }) { - const audioControllers = new Map(); - const pendingAudioUnlock = new Set(); - - audioUnlockEvents.forEach((eventName) => { - globalScope.addEventListener(eventName, () => { - if (!pendingAudioUnlock.size) return; - pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock)); - pendingAudioUnlock.clear(); - }); - }); - - function ensureAudioController(asset) { - const cached = audioControllers.get(asset.id); - if (cached && cached.src === asset.url) { - applyAudioSettings(cached, asset); - return cached; - } - - if (cached) { - clearAudio(asset.id); - } - - 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, - element, - delayTimeout: null, - loopEnabled: false, - loopActive: true, - delayMs: 0, - baseDelayMs: 0, - }; - element.onended = () => handleAudioEnded(asset.id); - audioControllers.set(asset.id, controller); - applyAudioSettings(controller, asset, true); - return controller; - } - - 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; - 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(2, asset.audioVolume ?? 1)); - element.volume = Math.min(volume, 1); - } - - function getAssetVolume(asset) { - return Math.max(0, Math.min(2, asset?.audioVolume ?? 1)); - } - - function applyMediaVolume(element, asset) { - if (!element) return 1; - const volume = getAssetVolume(asset); - element.volume = Math.min(volume, 1); - return volume; - } - - function handleAudioEnded(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - controller.element.currentTime = 0; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - if (controller.loopEnabled && controller.loopActive) { - controller.delayTimeout = setTimeout(() => { - safePlay(controller, pendingAudioUnlock); - }, controller.delayMs); - } else { - controller.element.pause(); - } - } - - 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) { - clearTimeout(controller.delayTimeout); - controller.delayTimeout = null; - } - controller.element.currentTime = 0; - const originalDelay = controller.delayMs; - controller.delayMs = 0; - safePlay(controller, pendingAudioUnlock); - 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, pendingAudioUnlock); - } - - 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, pendingAudioUnlock); - } else { - playOverlappingAudio(asset); - } - } - - function autoStartAudio(asset) { - if (asset.hidden) { - return; - } - const controller = ensureAudioController(asset); - if (!controller.loopEnabled || !controller.loopActive) { - return; - } - if (!controller.element.paused && !controller.element.ended) { - return; - } - if (controller.delayTimeout) { - return; - } - controller.delayTimeout = setTimeout(() => { - safePlay(controller, pendingAudioUnlock); - }, controller.delayMs); - } - - 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 clearAudio(assetId) { - const audio = audioControllers.get(assetId); - if (!audio) { - return; - } - if (audio.delayTimeout) { - clearTimeout(audio.delayTimeout); - } - audio.element.pause(); - audio.element.currentTime = 0; - audio.element.src = ""; - audio.element.remove(); - audioControllers.delete(assetId); - } - - return { - ensureAudioController, - applyMediaVolume, - handleAudioPlay, - stopAudio, - playAudioImmediately, - autoStartAudio, - clearAudio, - }; -} - -function safePlay(controller, pendingUnlock) { - if (!controller?.element) return; - const playPromise = controller.element.play(); - if (playPromise?.catch) { - playPromise.catch(() => { - pendingUnlock.add(controller); - }); - } -} diff --git a/src/js/broadcast/constants.js b/src/js/broadcast/constants.js deleted file mode 100644 index 3177362..0000000 --- a/src/js/broadcast/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -export const TARGET_FPS = 60; -export const MIN_FRAME_TIME = 1000 / TARGET_FPS; -export const VISIBILITY_THRESHOLD = 0.01; - -export const AssetKind = Object.freeze({ - AUDIO: "audio", - CODE: "code", - VISUAL: "visual", -}); diff --git a/src/js/broadcast/layers.js b/src/js/broadcast/layers.js deleted file mode 100644 index d7ef59c..0000000 --- a/src/js/broadcast/layers.js +++ /dev/null @@ -1,44 +0,0 @@ -import { isVisualAsset } from "./assetKinds.js"; - -export function ensureLayerPosition(state, assetId, placement = "keep") { - const asset = state.assets.get(assetId); - if (asset && !isVisualAsset(asset)) { - return; - } - const existingIndex = state.layerOrder.indexOf(assetId); - if (existingIndex !== -1 && placement === "keep") { - return; - } - if (existingIndex !== -1) { - state.layerOrder.splice(existingIndex, 1); - } - if (placement === "append") { - state.layerOrder.push(assetId); - } else { - state.layerOrder.unshift(assetId); - } - state.layerOrder = state.layerOrder.filter((id) => state.assets.has(id)); -} - -export function getLayerOrder(state) { - state.layerOrder = state.layerOrder.filter((id) => { - const asset = state.assets.get(id); - return asset && isVisualAsset(asset); - }); - state.assets.forEach((asset, id) => { - if (!isVisualAsset(asset)) { - return; - } - if (!state.layerOrder.includes(id)) { - state.layerOrder.unshift(id); - } - }); - return state.layerOrder; -} - -export function getRenderOrder(state) { - return [...getLayerOrder(state)] - .reverse() - .map((id) => state.assets.get(id)) - .filter(Boolean); -} diff --git a/src/js/broadcast/mediaManager.js b/src/js/broadcast/mediaManager.js deleted file mode 100644 index 5437859..0000000 --- a/src/js/broadcast/mediaManager.js +++ /dev/null @@ -1,327 +0,0 @@ -import { isAudioAsset } from "./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, - }; -} diff --git a/src/js/broadcast/renderer.js b/src/js/broadcast/renderer.js deleted file mode 100644 index 15200d5..0000000 --- a/src/js/broadcast/renderer.js +++ /dev/null @@ -1,562 +0,0 @@ -import { AssetKind, MIN_FRAME_TIME, VISIBILITY_THRESHOLD } from "./constants.js"; -import { createBroadcastState } from "./state.js"; -import { getAssetKind, isCodeAsset, isVisualAsset, isVideoElement } from "./assetKinds.js"; -import { ensureLayerPosition, getLayerOrder, getRenderOrder } from "./layers.js"; -import { getVisibilityState, smoothState } from "./visibility.js"; -import { createAudioManager } from "./audioManager.js"; -import { createMediaManager } from "./mediaManager.js"; -import { showToast } from "../toast.js"; -import { saveCanvasSize } from "../ipc.js"; - -export class BroadcastRenderer { - constructor({ canvas, scriptCanvas, broadcaster, domain }) { - this.canvas = canvas; - this.domain = domain; - this.ctx = canvas.getContext("2d"); - this.scriptCanvas = scriptCanvas; - this.broadcaster = broadcaster; - this.state = createBroadcastState(); - this.lastRenderTime = 0; - this.frameScheduled = false; - this.pendingDraw = false; - this.renderIntervalId = null; - this.scriptWorker = null; - this.scriptWorkerReady = false; - this.scriptErrorKeys = new Set(); - this.scriptAttachmentCache = new Map(); - - this.obsBrowser = !!globalThis.obsstudio; - this.supportsAnimatedDecode = - typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser; - this.canPlayProbe = document.createElement("video"); - - this.audioManager = createAudioManager({ assets: this.state.assets }); - this.mediaManager = createMediaManager({ - state: this.state, - audioManager: this.audioManager, - draw: () => this.draw(), - obsBrowser: this.obsBrowser, - supportsAnimatedDecode: this.supportsAnimatedDecode, - canPlayProbe: this.canPlayProbe, - }); - - this.applyCanvasSettings(this.state.canvasSettings); - globalThis.addEventListener("resize", () => { - this.resizeCanvas(); - }); - } - - async start() { - return this.fetchCanvasSettings().finally(() => { - this.resizeCanvas(); - this.startRenderLoop(); - this.connect(); - }); - } - - connect() { - const socket = new SockJS(`${this.domain}/ws`); - const stompClient = Stomp.over(socket); - stompClient.connect({}, () => { - stompClient.subscribe(`/topic/channel/${this.broadcaster}`, (payload) => { - const body = JSON.parse(payload.body); - this.handleEvent(body); - }); - fetch(`${this.domain}/api/channels/${this.broadcaster}/assets`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load assets"); - } - return r.json(); - }) - .then((assets) => - assets.map((a) => { - if (a.url) { - return { ...a, url: `${this.domain}${a.url}` }; - } - return a; - }), - ) - .then((assets) => this.renderAssets(assets)) - .catch(() => showToast("Unable to load overlay assets. Retrying may help.", "error")); - }); - } - - renderAssets(list) { - this.state.layerOrder = []; - list.forEach((asset) => { - this.storeAsset(asset, "append"); - if (isCodeAsset(asset)) { - this.spawnUserJavaScriptWorker(asset); - } - }); - this.draw(); - } - - storeAsset(asset, placement = "keep") { - if (!asset) return; - console.info(`Storing asset: ${asset.id}`); - const wasExisting = this.state.assets.has(asset.id); - this.state.assets.set(asset.id, asset); - ensureLayerPosition(this.state, asset.id, placement); - if (isCodeAsset(asset)) { - this.updateScriptWorkerAttachments(asset); - } - if (!wasExisting && !this.state.visibilityStates.has(asset.id)) { - const initialAlpha = 0; // Fade in newly discovered assets - this.state.visibilityStates.set(asset.id, { - alpha: initialAlpha, - targetHidden: !!asset.hidden, - }); - } - } - - removeAsset(assetId) { - this.state.assets.delete(assetId); - this.state.layerOrder = this.state.layerOrder.filter((id) => id !== assetId); - this.mediaManager.clearMedia(assetId); - this.stopUserJavaScriptWorker(assetId); - this.state.renderStates.delete(assetId); - this.state.visibilityStates.delete(assetId); - } - - async fetchCanvasSettings() { - return fetch(`${this.domain}/api/channels/${encodeURIComponent(this.broadcaster)}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load canvas"); - } - return r.json(); - }) - .then((settings) => { - this.applyCanvasSettings(settings); - }) - .catch(() => { - this.resizeCanvas(); - showToast("Using default canvas size. Unable to load saved settings.", "warning"); - }); - } - - applyCanvasSettings(settings) { - if (!settings) { - return; - } - const width = Number.isFinite(settings.width) ? settings.width : this.state.canvasSettings.width; - const height = Number.isFinite(settings.height) ? settings.height : this.state.canvasSettings.height; - this.state.canvasSettings = { width, height }; - this.resizeCanvas(); - } - - resizeCanvas() { - if (Number.isFinite(this.state.canvasSettings.width) && Number.isFinite(this.state.canvasSettings.height)) { - this.canvas.width = this.state.canvasSettings.width; - this.canvas.height = this.state.canvasSettings.height; - saveCanvasSize(this.state.canvasSettings.width, this.state.canvasSettings.height); - this.canvas.style.width = `${this.state.canvasSettings.width}px`; - this.canvas.style.height = `${this.state.canvasSettings.height}px`; - if (this.scriptCanvas) { - // TODO: - this.scriptCanvas.width = this.state.canvasSettings.width; - this.scriptCanvas.height = this.state.canvasSettings.height; - this.scriptCanvas.style.width = `${this.state.canvasSettings.width}px`; - this.scriptCanvas.style.height = `${this.state.canvasSettings.height}px`; - } - } - this.updateScriptWorkerCanvas(); - this.draw(); - } - - handleEvent(event) { - if (event.type === "CANVAS" && event.payload) { - this.applyCanvasSettings(event.payload); - return; - } - const assetId = event.assetId || event?.patch?.id || event?.payload?.id; - if (event.type === "VISIBILITY") { - this.handleVisibilityEvent(event); - return; - } - if (event.type === "DELETED") { - this.removeAsset(assetId); - } else if (event.patch) { - this.applyPatch(assetId, event.patch); - if (event.payload) { - const payload = this.normalizePayload(event.payload); - if (payload.hidden) { - this.hideAssetWithTransition(payload); - } else if (!this.state.assets.has(payload.id)) { - this.upsertVisibleAsset(payload, "append"); - } - } - } else if (event.type === "PLAY" && event.payload) { - const payload = this.normalizePayload(event.payload); - this.storeAsset(payload); - if (getAssetKind(payload) === AssetKind.AUDIO) { - this.audioManager.handleAudioPlay(payload, event.play !== false); - } - } else if (event.payload && !event.payload.hidden) { - const payload = this.normalizePayload(event.payload); - this.upsertVisibleAsset(payload); - } else if (event.payload && event.payload.hidden) { - this.hideAssetWithTransition(event.payload); - } - this.draw(); - } - - normalizePayload(payload) { - return { ...payload }; - } - - hideAssetWithTransition(asset) { - const payload = asset ? this.normalizePayload(asset) : null; - if (!payload?.id) { - return; - } - const existing = this.state.assets.get(payload.id); - if ( - !existing && - (!Number.isFinite(payload.x) || - !Number.isFinite(payload.y) || - !Number.isFinite(payload.width) || - !Number.isFinite(payload.height)) - ) { - return; - } - const merged = this.normalizePayload({ ...(existing || {}), ...payload, hidden: true }); - this.storeAsset(merged); - this.stopUserJavaScriptWorker(merged.id); - this.audioManager.stopAudio(payload.id); - } - - upsertVisibleAsset(asset, placement = "keep") { - const payload = asset ? this.normalizePayload(asset) : null; - if (!payload?.id) { - return; - } - const placementMode = this.state.assets.has(payload.id) ? "keep" : placement; - this.storeAsset(payload, placementMode); - this.mediaManager.ensureMedia(payload); - const kind = getAssetKind(payload); - if (kind === AssetKind.AUDIO) { - this.audioManager.playAudioImmediately(payload); - } else if (kind === AssetKind.CODE) { - this.spawnUserJavaScriptWorker(payload); - } - } - - handleVisibilityEvent(event) { - const payload = event.payload ? this.normalizePayload(event.payload) : null; - const patch = event.patch; - const id = payload?.id || patch?.id || event.assetId; - - if (payload?.hidden || patch?.hidden) { - this.hideAssetWithTransition({ id, ...payload, ...patch }); - this.draw(); - return; - } - - if (payload) { - const placement = this.state.assets.has(payload.id) ? "keep" : "append"; - this.upsertVisibleAsset(payload, placement); - } - - if (patch && id) { - this.applyPatch(id, patch); - } - - this.draw(); - } - - applyPatch(assetId, patch) { - if (!assetId || !patch) { - return; - } - const sanitizedPatch = Object.fromEntries( - Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), - ); - const existing = this.state.assets.get(assetId); - if (!existing) { - return; - } - const merged = this.normalizePayload({ ...existing, ...sanitizedPatch }); - console.log(merged); - const isVisual = isVisualAsset(merged); - if (sanitizedPatch.hidden) { - this.hideAssetWithTransition(merged); - return; - } - const targetLayer = Number.isFinite(patch.layer) - ? patch.layer - : Number.isFinite(patch.zIndex) - ? patch.zIndex - : null; - if (isVisual && Number.isFinite(targetLayer)) { - const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId); - const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); - currentOrder.splice(insertIndex, 0, assetId); - this.state.layerOrder = currentOrder; - } - this.storeAsset(merged); - this.mediaManager.ensureMedia(merged); - if (isCodeAsset(merged)) { - console.info(`Spawning JS worker for patched asset: ${merged.id}`); - this.spawnUserJavaScriptWorker(merged); - } - } - - draw() { - if (this.frameScheduled) { - this.pendingDraw = true; - return; - } - this.frameScheduled = true; - requestAnimationFrame((timestamp) => { - const elapsed = timestamp - this.lastRenderTime; - const delay = MIN_FRAME_TIME - elapsed; - const shouldRender = elapsed >= MIN_FRAME_TIME; - - if (shouldRender) { - this.lastRenderTime = timestamp; - this.renderFrame(); - } - - this.frameScheduled = false; - if (this.pendingDraw || !shouldRender) { - this.pendingDraw = false; - setTimeout(() => this.draw(), Math.max(0, delay)); - } - }); - } - - renderFrame() { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - getRenderOrder(this.state).forEach((asset) => this.drawAsset(asset)); - } - - drawAsset(asset) { - const visibility = getVisibilityState(this.state, asset); - if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) { - return; - } - const renderState = smoothState(this.state, asset); - const halfWidth = renderState.width / 2; - const halfHeight = renderState.height / 2; - this.ctx.save(); - this.ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha)); - this.ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); - this.ctx.rotate((renderState.rotation * Math.PI) / 180); - - const kind = getAssetKind(asset); - if (kind === AssetKind.CODE) { - this.ctx.restore(); - return; - } - - if (kind === AssetKind.AUDIO) { - if (!asset.hidden) { - this.audioManager.autoStartAudio(asset); - } - this.ctx.restore(); - return; - } - - const media = this.mediaManager.ensureMedia(asset); - const drawSource = media?.isAnimated ? media.bitmap : media; - const ready = this.isDrawable(media); - if (ready && drawSource) { - this.ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); - } - - this.ctx.restore(); - } - - isDrawable(element) { - if (!element) { - return false; - } - if (element.isAnimated) { - return !!element.bitmap; - } - if (isVideoElement(element)) { - return element.readyState >= 2; - } - if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { - return true; - } - return !!element.complete; - } - - startRenderLoop() { - if (this.renderIntervalId) { - return; - } - this.renderIntervalId = setInterval(() => { - this.draw(); - }, MIN_FRAME_TIME); - } - - ensureScriptWorker() { - if (this.scriptWorker || !this.scriptCanvas) { - return; - } - if (typeof this.scriptCanvas.transferControlToOffscreen !== "function") { - console.warn("OffscreenCanvas is not supported in this environment."); - return; - } - const offscreen = this.scriptCanvas.transferControlToOffscreen(); - this.scriptWorker = new Worker("/js/broadcast/script-worker.js"); - this.scriptWorker.addEventListener("message", (event) => this.handleScriptWorkerMessage(event)); - this.scriptWorker.postMessage( - { - type: "init", - payload: { - canvas: offscreen, - width: this.scriptCanvas.width, - height: this.scriptCanvas.height, - channelName: this.broadcaster, - }, - }, - [offscreen], - ); - this.scriptWorkerReady = true; - } - - updateScriptWorkerCanvas() { - if (!this.scriptWorker || !this.scriptWorkerReady || !this.scriptCanvas) { - return; - } - try { - this.scriptWorker.postMessage({ - type: "resize", - payload: { - width: this.scriptCanvas.width, - height: this.scriptCanvas.height, - }, - }); - } catch (e) { - console.error("Failed to update script worker canvas size", e); - showToast("Script worker canvas resize failed.", "error"); - } - } - - extractScriptErrorLocation(stack, scriptId) { - if (!stack || !scriptId) { - return ""; - } - const label = `user-script-${scriptId}.js`; - const lines = stack.split("\n"); - const matchingLine = lines.find((line) => line.includes(label)); - if (!matchingLine) { - return ""; - } - const match = matchingLine.match(/user-script-[^:]+\.js:(\d+)(?::(\d+))?/); - if (!match) { - return ""; - } - const line = match[1]; - const column = match[2]; - return column ? `line ${line}, col ${column}` : `line ${line}`; - } - - handleScriptWorkerMessage(event) { - const { type, payload } = event.data || {}; - if (type !== "scriptError" || !payload?.id) { - return; - } - const key = `${payload.id}:${payload.stage || "unknown"}`; - if (this.scriptErrorKeys.has(key)) { - return; - } - this.scriptErrorKeys.add(key); - const location = this.extractScriptErrorLocation(payload.stack, payload.id); - const details = payload.message || "Unknown error"; - const detailMessage = location ? `${details} (${location})` : details; - showToast(`Script ${payload.id} ${payload.stage || "error"}: ${detailMessage}`, "error"); - if (payload.stack) { - console.error(`Script ${payload.id} ${payload.stage || "error"}`, payload.stack); - } - } - - async spawnUserJavaScriptWorker(asset) { - if (!asset?.id || !asset?.url) { - return; - } - this.ensureScriptWorker(); - if (!this.scriptWorkerReady) { - return; - } - let assetSource; - try { - const response = await fetch(asset.url); - if (!response.ok) { - throw new Error(`Failed to load script asset ${asset.id}`); - } - assetSource = await response.text(); - } catch (error) { - console.error(`Unable to fetch asset ${asset.id} from ${asset.url}`, error); - return; - } - this.scriptWorker.postMessage({ - type: "addScript", - payload: { - id: asset.id, - source: assetSource, - attachments: await this.resolveScriptAttachments(asset.scriptAttachments), - }, - }); - } - - async updateScriptWorkerAttachments(asset) { - if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) { - return; - } - this.scriptWorker.postMessage({ - type: "updateAttachments", - payload: { - id: asset.id, - attachments: await this.resolveScriptAttachments(asset.scriptAttachments), - }, - }); - } - - stopUserJavaScriptWorker(assetId) { - if (!this.scriptWorker || !assetId) { - return; - } - this.scriptWorker.postMessage({ - type: "removeScript", - payload: { id: assetId }, - }); - } - - async resolveScriptAttachments(attachments) { - if (!Array.isArray(attachments) || attachments.length === 0) { - return []; - } - const resolved = await Promise.all( - attachments.map(async (attachment) => { - if (!attachment?.url || !attachment.mediaType?.startsWith("image/")) { - return attachment; - } - const cacheKey = attachment.id || attachment.url; - const cached = this.scriptAttachmentCache.get(cacheKey); - if (cached?.blob) { - return { ...attachment, blob: cached.blob }; - } - try { - const response = await fetch(`${this.domain}${attachment.url}`); - if (!response.ok) { - throw new Error("Failed to fetch script attachment"); - } - const blob = await response.blob(); - this.scriptAttachmentCache.set(cacheKey, { blob }); - return { ...attachment, blob }; - } catch (error) { - console.error("Unable to load script attachment", error); - return attachment; - } - }), - ); - return resolved; - } -} diff --git a/src/js/broadcast/script-worker.js b/src/js/broadcast/script-worker.js deleted file mode 100644 index 19a2764..0000000 --- a/src/js/broadcast/script-worker.js +++ /dev/null @@ -1,263 +0,0 @@ -const scripts = new Map(); -const allowedFetchUrls = new Set(); -let canvas = null; -let ctx = null; -let channelName = ""; -let tickIntervalId = null; -let lastTick = 0; -let startTime = 0; -const tickIntervalMs = 1000 / 60; -const errorKeys = new Set(); - -function disableNetworkApis() { - const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null; - const blockedApis = { - fetch: (...args) => { - if (!nativeFetch) { - throw new Error("Network access is disabled in asset scripts."); - } - const request = new Request(...args); - const url = normalizeUrl(request.url); - if (!allowedFetchUrls.has(url)) { - throw new Error("Network access is disabled in asset scripts."); - } - return nativeFetch(request); - }, - XMLHttpRequest: undefined, - WebSocket: undefined, - EventSource: undefined, - importScripts: () => { - throw new Error("Network access is disabled in asset scripts."); - }, - }; - - Object.entries(blockedApis).forEach(([key, value]) => { - if (!(key in self)) { - return; - } - try { - Object.defineProperty(self, key, { - value, - writable: false, - configurable: false, - }); - } catch (error) { - try { - self[key] = value; - } catch (_error) { - // ignore if the API cannot be overridden in this environment - } - } - }); -} - -disableNetworkApis(); - -function normalizeUrl(url) { - try { - return new URL(url, self.location?.href || "http://localhost").toString(); - } catch (_error) { - return ""; - } -} - -function refreshAllowedFetchUrls() { - allowedFetchUrls.clear(); - scripts.forEach((script) => { - const assets = script?.context?.assets; - if (!Array.isArray(assets)) { - return; - } - assets.forEach((asset) => { - if (asset?.url) { - const normalized = normalizeUrl(asset.url); - if (normalized) { - allowedFetchUrls.add(normalized); - } - } - }); - }); -} - -function reportScriptError(id, stage, error) { - if (!id) { - return; - } - const key = `${id}:${stage}:${error?.message ?? error}`; - if (errorKeys.has(key)) { - return; - } - errorKeys.add(key); - self.postMessage({ - type: "scriptError", - payload: { - id, - stage, - message: error?.message ?? String(error), - stack: error?.stack || "", - }, - }); -} - -function updateScriptContexts() { - scripts.forEach((script) => { - if (!script.context) { - return; - } - script.context.canvas = canvas; - script.context.ctx = ctx; - script.context.channelName = channelName; - script.context.width = canvas?.width ?? 0; - script.context.height = canvas?.height ?? 0; - }); -} - -function ensureTickLoop() { - if (tickIntervalId) { - return; - } - startTime = performance.now(); - lastTick = startTime; - tickIntervalId = setInterval(() => { - if (!ctx || scripts.size === 0) { - return; - } - const now = performance.now(); - const deltaMs = now - lastTick; - const elapsedMs = now - startTime; - lastTick = now; - - scripts.forEach((script) => { - if (!script.tick) { - return; - } - script.context.now = now; - script.context.deltaMs = deltaMs; - script.context.elapsedMs = elapsedMs; - try { - script.tick(script.context, script.state); - } catch (error) { - console.error(`Script ${script.id} tick failed`, error); - } - }); - }, tickIntervalMs); -} - -function stopTickLoopIfIdle() { - if (scripts.size === 0 && tickIntervalId) { - clearInterval(tickIntervalId); - tickIntervalId = null; - } -} - -function createScriptHandlers(source, context, state, sourceLabel = "") { - const contextPrelude = - "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets } = context;"; - const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : ""; - const factory = new Function( - "context", - "state", - "module", - "exports", - `${contextPrelude}\n${source}${sourceUrl}\nconst resolved = (module && module.exports) || exports || {};\nreturn {\n init: typeof resolved.init === "function" ? resolved.init : typeof init === "function" ? init : null,\n tick: typeof resolved.tick === "function" ? resolved.tick : typeof tick === "function" ? tick : null,\n};`, - ); - const module = { exports: {} }; - const exports = module.exports; - return factory(context, state, module, exports); -} - -self.addEventListener("message", (event) => { - const { type, payload } = event.data || {}; - if (type === "init") { - canvas = payload.canvas; - channelName = payload.channelName || ""; - if (canvas) { - canvas.width = payload.width || canvas.width; - canvas.height = payload.height || canvas.height; - ctx = canvas.getContext("2d"); - } - updateScriptContexts(); - return; - } - - if (type === "resize") { - if (canvas) { - canvas.width = payload.width || canvas.width; - canvas.height = payload.height || canvas.height; - } - updateScriptContexts(); - return; - } - - if (type === "channel") { - channelName = payload.channelName || channelName; - updateScriptContexts(); - return; - } - - if (type === "addScript") { - if (!payload?.id || !payload?.source) { - return; - } - const state = {}; - const context = { - canvas, - ctx, - channelName, - width: canvas?.width ?? 0, - height: canvas?.height ?? 0, - now: 0, - deltaMs: 0, - elapsedMs: 0, - assets: Array.isArray(payload.attachments) ? payload.attachments : [], - }; - let handlers = {}; - try { - handlers = createScriptHandlers(payload.source, context, state, `user-script-${payload.id}.js`); - } catch (error) { - console.error(`Script ${payload.id} failed to initialize`, error); - reportScriptError(payload.id, "initialize", error); - return; - } - const script = { - id: payload.id, - context, - state, - init: handlers.init, - tick: handlers.tick, - }; - scripts.set(payload.id, script); - refreshAllowedFetchUrls(); - if (script.init) { - try { - script.init(script.context, script.state); - } catch (error) { - console.error(`Script ${payload.id} init failed`, error); - reportScriptError(payload.id, "init", error); - } - } - ensureTickLoop(); - return; - } - - if (type === "removeScript") { - if (!payload?.id) { - return; - } - scripts.delete(payload.id); - refreshAllowedFetchUrls(); - stopTickLoopIfIdle(); - } - - if (type === "updateAttachments") { - if (!payload?.id) { - return; - } - const script = scripts.get(payload.id); - if (!script) { - return; - } - script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : []; - refreshAllowedFetchUrls(); - } -}); diff --git a/src/js/broadcast/state.js b/src/js/broadcast/state.js deleted file mode 100644 index 6b8e845..0000000 --- a/src/js/broadcast/state.js +++ /dev/null @@ -1,14 +0,0 @@ -export function createBroadcastState() { - return { - canvasSettings: { width: 1920, height: 1080 }, - assets: new Map(), - mediaCache: new Map(), - renderStates: new Map(), - visibilityStates: new Map(), - animatedCache: new Map(), - blobCache: new Map(), - animationFailures: new Map(), - videoPlaybackStates: new WeakMap(), - layerOrder: [], - }; -} diff --git a/src/js/broadcast/visibility.js b/src/js/broadcast/visibility.js deleted file mode 100644 index 830eccb..0000000 --- a/src/js/broadcast/visibility.js +++ /dev/null @@ -1,33 +0,0 @@ -export function getVisibilityState(state, asset) { - const current = state.visibilityStates.get(asset.id) || {}; - const targetAlpha = asset.hidden ? 0 : 1; - const startingAlpha = Number.isFinite(current.alpha) ? current.alpha : 0; - const factor = asset.hidden ? 0.18 : 0.2; - const nextAlpha = lerp(startingAlpha, targetAlpha, factor); - const nextState = { alpha: nextAlpha, targetHidden: !!asset.hidden }; - state.visibilityStates.set(asset.id, nextState); - return nextState; -} - -export function smoothState(state, asset) { - const previous = state.renderStates.get(asset.id) || { ...asset }; - const factor = 0.15; - const next = { - x: lerp(previous.x, asset.x, factor), - y: lerp(previous.y, asset.y, factor), - width: lerp(previous.width, asset.width, factor), - height: lerp(previous.height, asset.height, factor), - rotation: smoothAngle(previous.rotation, asset.rotation, factor), - }; - state.renderStates.set(asset.id, next); - return next; -} - -function smoothAngle(current, target, factor) { - const delta = ((target - current + 180) % 360) - 180; - return current + delta * factor; -} - -function lerp(a, b, t) { - return a + (b - a) * t; -} diff --git a/src/js/ipc.js b/src/js/ipc.js deleted file mode 100644 index 43b2313..0000000 --- a/src/js/ipc.js +++ /dev/null @@ -1,21 +0,0 @@ -export function saveSelectedBroadcaster(broadcaster) { - window.store.saveBroadcaster(broadcaster); -} - -let memoizedWidth = -1; -let memoizedHeight = -1; -export function saveCanvasSize(width, height) { - console.log({ width, height }); - if (memoizedWidth === -1 && memoizedHeight === -1) { - window.store.setWindowSize(width, height); - return; - } - if (width === memoizedWidth && height === memoizedHeight) { - return; - } - memoizedWidth = width; - memoizedHeight = height; - console.info("Saving canvas size:", width, height); - showToast("Updated canvas size", "info"); - window.store.setWindowSize(width, height); -} diff --git a/src/js/toast.js b/src/js/toast.js deleted file mode 100644 index 3f0f55e..0000000 --- a/src/js/toast.js +++ /dev/null @@ -1,56 +0,0 @@ -const CONTAINER_ID = "toast-container"; -const DEFAULT_DURATION = 4200; - -function ensureContainer() { - let container = document.getElementById(CONTAINER_ID); - if (!container) { - container = document.createElement("div"); - container.id = CONTAINER_ID; - container.className = "toast-container"; - container.setAttribute("aria-live", "polite"); - container.setAttribute("aria-atomic", "true"); - document.body.appendChild(container); - } - return container; -} - -function buildToast(message, type) { - const toast = document.createElement("div"); - toast.className = `toast toast-${type}`; - - const indicator = document.createElement("span"); - indicator.className = "toast-indicator"; - indicator.setAttribute("aria-hidden", "true"); - - const content = document.createElement("div"); - content.className = "toast-message"; - content.textContent = message; - - toast.appendChild(indicator); - toast.appendChild(content); - - return toast; -} - -function removeToast(toast) { - if (!toast) { - return; - } - toast.classList.add("toast-exit"); - setTimeout(() => toast.remove(), 250); -} - -export function showToast(message, type = "info", options = {}) { - if (!message) { - return; - } - - const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info"; - const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION; - const toast = buildToast(message, normalized); - - ensureContainer().appendChild(toast); - - setTimeout(() => removeToast(toast), Math.max(1200, duration)); - toast.addEventListener("click", () => removeToast(toast)); -}