From 5f77890fff62ac99c53beb8900941524955d1271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Mon, 12 Jan 2026 17:16:45 +0100 Subject: [PATCH] Migrate client code to local repo --- README.md | 8 +- src/broadcast.html | 16 + src/css/broadcast.css | 20 ++ src/css/index.css | 172 +++++++++ src/css/toast.css | 90 +++++ src/index.html | 65 ++++ src/js/broadcast.js | 31 ++ 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 +++ src/main.js | 151 ++++---- src/preload.js | 7 + src/store.js | 18 + 22 files changed, 2087 insertions(+), 85 deletions(-) create mode 100644 src/broadcast.html create mode 100644 src/css/broadcast.css create mode 100644 src/css/index.css create mode 100644 src/css/toast.css create mode 100644 src/index.html create mode 100644 src/js/broadcast.js create mode 100644 src/js/broadcast/assetKinds.js create mode 100644 src/js/broadcast/audio.js create mode 100644 src/js/broadcast/audioManager.js create mode 100644 src/js/broadcast/constants.js create mode 100644 src/js/broadcast/layers.js create mode 100644 src/js/broadcast/mediaManager.js create mode 100644 src/js/broadcast/renderer.js create mode 100644 src/js/broadcast/script-worker.js create mode 100644 src/js/broadcast/state.js create mode 100644 src/js/broadcast/visibility.js create mode 100644 src/js/ipc.js create mode 100644 src/js/toast.js create mode 100644 src/preload.js create mode 100644 src/store.js diff --git a/README.md b/README.md index 4640904..64fd037 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# TODO +# Client + +Electron based desktop client for viewing the imgfloat broadcast dashboard. + +## "Why not use a web source?" + +TODO diff --git a/src/broadcast.html b/src/broadcast.html new file mode 100644 index 0000000..aef3254 --- /dev/null +++ b/src/broadcast.html @@ -0,0 +1,16 @@ + + + + + Imgfloat Broadcast + + + + + + + + + + + diff --git a/src/css/broadcast.css b/src/css/broadcast.css new file mode 100644 index 0000000..fd87c51 --- /dev/null +++ b/src/css/broadcast.css @@ -0,0 +1,20 @@ +.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/index.css b/src/css/index.css new file mode 100644 index 0000000..b2a42b4 --- /dev/null +++ b/src/css/index.css @@ -0,0 +1,172 @@ +* { + box-sizing: border-box; + color: white; +} + +p { + margin: 0; +} + +.hidden { + display: none !important; +} + +body { + font-family: Arial, sans-serif; + background: #0f172a; + color: #e2e8f0; + margin: 0; + padding: 0; +} + +.channels-body { + min-height: 100vh; + background: + radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%), + radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), #0f172a; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(24px, 4vw, 48px); +} + +.channels-shell { + width: 100%; + display: flex; + flex-direction: column; + gap: 20px; + max-width: 700px; +} + +.channels-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.channels-main { + display: flex; + justify-content: center; +} + +.channel-card { + width: 100%; + background: rgba(11, 18, 32, 0.95); + border: 1px solid #1f2937; + border-radius: 16px; + padding: clamp(20px, 3vw, 32px); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; +} + +.channel-card h1 { + margin: 6px 0 4px; +} + +.channel-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 40px; + height: 40px; + display: grid; + place-items: center; + font-weight: 700; + letter-spacing: 0.5px; +} + +.brand-title { + font-weight: 700; + font-size: 18px; +} + +.brand-subtitle { + color: #94a3b8; + font-size: 13px; +} + +.text-input { + width: 100%; + padding: 12px; + border-radius: 10px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; + font-size: 15px; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.text-input:focus { + outline: none; + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); +} + +.text-input:disabled, +.text-input[aria-disabled="true"] { + background: #020617; + border-color: #334155; + color: #64748b; + cursor: not-allowed; + box-shadow: none; +} + +.text-input:disabled::placeholder { + color: #475569; +} + +.button, +button { + background: #7c3aed; + color: white; + padding: 10px 16px; + border: none; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 600; + box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25); +} + +.button:disabled, +button:disabled, +.button[aria-disabled="true"] { + background: #a78bfa; + color: #e5e7eb; + cursor: not-allowed; + box-shadow: none; + opacity: 0.7; +} + +.button:disabled:hover, +button:disabled:hover { + transform: none; +} + +.button.block { + width: 100%; +} + +.muted { + color: #94a3b8; + font-size: 0.9em; +} diff --git a/src/css/toast.css b/src/css/toast.css new file mode 100644 index 0000000..9edb837 --- /dev/null +++ b/src/css/toast.css @@ -0,0 +1,90 @@ +.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 new file mode 100644 index 0000000..ec0b7a1 --- /dev/null +++ b/src/index.html @@ -0,0 +1,65 @@ + + + + + Browse channels - Imgfloat + + + +
+
+
+ brand +
+
Imgfloat
+
Twitch overlay manager
+
+
+
+ +
+
+

Broadcast overlay

+

Open a channel

+

Type the channel name to jump straight to their overlay.

+
+ + + + +
+
+
+
+ + + diff --git a/src/js/broadcast.js b/src/js/broadcast.js new file mode 100644 index 0000000..9368955 --- /dev/null +++ b/src/js/broadcast.js @@ -0,0 +1,31 @@ +import { BroadcastRenderer } from "./broadcast/renderer.js"; +import { saveSelectedBroadcaster } from "./ipc.js"; +import { showToast } from "./toast.js"; + +const domain = "https://imgfloat.kruhlmann.dev"; + +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"); +} +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 new file mode 100644 index 0000000..23aabf0 --- /dev/null +++ b/src/js/broadcast/assetKinds.js @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..57f5231 --- /dev/null +++ b/src/js/broadcast/audio.js @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..213ce24 --- /dev/null +++ b/src/js/broadcast/audioManager.js @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..3177362 --- /dev/null +++ b/src/js/broadcast/constants.js @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..d7ef59c --- /dev/null +++ b/src/js/broadcast/layers.js @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..5437859 --- /dev/null +++ b/src/js/broadcast/mediaManager.js @@ -0,0 +1,327 @@ +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 new file mode 100644 index 0000000..82d0f85 --- /dev/null +++ b/src/js/broadcast/renderer.js @@ -0,0 +1,562 @@ +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(`https://imgfloat.kruhlmann.dev/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(`https://imgfloat.kruhlmann.dev/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 new file mode 100644 index 0000000..19a2764 --- /dev/null +++ b/src/js/broadcast/script-worker.js @@ -0,0 +1,263 @@ +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 new file mode 100644 index 0000000..6b8e845 --- /dev/null +++ b/src/js/broadcast/state.js @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..830eccb --- /dev/null +++ b/src/js/broadcast/visibility.js @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..43b2313 --- /dev/null +++ b/src/js/ipc.js @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..3f0f55e --- /dev/null +++ b/src/js/toast.js @@ -0,0 +1,56 @@ +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)); +} diff --git a/src/main.js b/src/main.js index 3194824..37fbe13 100644 --- a/src/main.js +++ b/src/main.js @@ -1,95 +1,79 @@ const path = require("node:path"); -const { app, BrowserWindow } = require("electron"); +const { app, BrowserWindow, ipcMain } = require("electron"); const { autoUpdater } = require("electron-updater"); +const { readStore, writeStore } = require("./store.js"); -const initialWindowWidthPx = 960; -const initialWindowHeightPx = 640; +const STORE_PATH = path.join(app.getPath("userData"), "settings.json"); +const INITIAL_WINDOW_WIDTH_PX = 960; +const INITIAL_WINDOW_HEIGHT_PX = 640; -let canvasSizeInterval; -function clearCanvasSizeInterval() { - if (canvasSizeInterval) { - clearInterval(canvasSizeInterval); - canvasSizeInterval = undefined; - } -} +let ELECTRON_WINDOW; -async function autoResizeWindow(win, lastSize) { - if (win.isDestroyed()) { - return lastSize; - } - const newSize = await win.webContents.executeJavaScript(`(() => { - const canvas = document.getElementById('broadcast-canvas'); - if (!canvas) { - return null; - } - const rect = canvas.getBoundingClientRect(); - return { - width: Math.round(rect.width), - height: Math.round(rect.height), - }; - })();`); - - if (!newSize?.width || !newSize?.height) { - return lastSize; - } - if (lastSize.width === newSize.width && lastSize.height === newSize.height) { - return lastSize; - } - console.info( - `Window size did not match canvas old: ${lastSize.width}x${lastSize.height} new: ${newSize.width}x${newSize.height}. Resizing.`, - ); - win.setContentSize(newSize.width, newSize.height, false); - win.setResizable(false); - return newSize; -} - -function onPostNavigationLoad(win, url, broadcastRect) { - url = url || win.webContents.getURL(); - let pathname; - try { - pathname = new URL(url).pathname; - } catch (e) { - console.error(`Failed to parse URL: ${url}`, e); - return; - } - const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); - - console.info(`Navigation to ${url} detected. Is broadcast: ${isBroadcast}`); - if (isBroadcast) { - clearCanvasSizeInterval(); - console.info("Setting up auto-resize for broadcast window."); - canvasSizeInterval = setInterval(() => { - autoResizeWindow(win, broadcastRect).then((newSize) => { - broadcastRect = newSize; - }); - }, 750); - autoResizeWindow(win, broadcastRect).then((newSize) => { - broadcastRect = newSize; - }); - } else { - clearCanvasSizeInterval(); - win.setSize(initialWindowWidthPx, initialWindowHeightPx, false); +function createWindowOptionsForPlatform(platform) { + switch (platform) { + case "darwin": + case "linux": + return { + width: INITIAL_WINDOW_WIDTH_PX, + height: INITIAL_WINDOW_HEIGHT_PX, + transparent: true, + frame: true, + backgroundColor: "#00000000", + alwaysOnTop: false, + icon: path.join(__dirname, "../res/icon/appicon.ico"), + webPreferences: { + backgroundThrottling: false, + preload: path.join(__dirname, "preload.js"), + }, + }; + case "win32": + return { + width: INITIAL_WINDOW_WIDTH_PX, + height: INITIAL_WINDOW_HEIGHT_PX, + transparent: true, + frame: false, + backgroundColor: "#00000000", + alwaysOnTop: false, + icon: path.join(__dirname, "../res/icon/appicon.ico"), + webPreferences: { + backgroundThrottling: false, + preload: path.join(__dirname, "preload.js"), + }, + }; + default: + throw new Error(`Unsupported platform: ${platform}`); } } function createWindow(version) { - const win = new BrowserWindow({ - width: initialWindowWidthPx, - height: initialWindowHeightPx, - transparent: true, - frame: true, - backgroundColor: "#00000000", - alwaysOnTop: false, - icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), - webPreferences: { backgroundThrottling: false }, - }); + const windowOptions = createWindowOptionsForPlatform(process.platform); + const win = new BrowserWindow(windowOptions); win.setMenu(null); win.setTitle(`Imgfloat Client v${version}`); return win; } +// TODO: Race condition? +ipcMain.handle("set-window-size", (_, width, height) => { + if (ELECTRON_WINDOW && !ELECTRON_WINDOW.isDestroyed()) { + ELECTRON_WINDOW.setContentSize(width, height, false); + } +}); + +// TODO: Race condition? +ipcMain.handle("save-broadcaster", (_, broadcaster) => { + const store = readStore(STORE_PATH); + store.lastBroadcaster = broadcaster; + writeStore(STORE_PATH, store); +}); + +ipcMain.handle("load-broadcaster", () => { + const store = readStore(STORE_PATH); + return store.lastBroadcaster ?? ""; +}); + app.whenReady().then(() => { if (process.env.CI) { process.on("uncaughtException", (err) => { @@ -100,13 +84,12 @@ app.whenReady().then(() => { } autoUpdater.checkForUpdatesAndNotify(); - let broadcastRect = { width: 0, height: 0 }; const version = app.getVersion(); - const win = createWindow(version); - win.loadURL(process.env["IMGFLOAT_CHANNELS_URL"] || "https://imgfloat.kruhlmann.dev/channels"); - win.webContents.on("did-finish-load", () => onPostNavigationLoad(win, undefined, broadcastRect)); - win.webContents.on("did-navigate", (_, url) => onPostNavigationLoad(win, url, broadcastRect)); - win.webContents.on("did-navigate-in-page", (_, url) => onPostNavigationLoad(win, url, broadcastRect)); - win.on("page-title-updated", (e) => e.preventDefault()); - win.on("closed", clearCanvasSizeInterval); + ELECTRON_WINDOW = createWindow(version); + ELECTRON_WINDOW.loadFile(path.join(__dirname, "index.html")); + ELECTRON_WINDOW.on("page-title-updated", (e) => e.preventDefault()); + + if (process.env.DEVTOOLS) { + ELECTRON_WINDOW.webContents.openDevTools({ mode: "detach" }); + } }); diff --git a/src/preload.js b/src/preload.js new file mode 100644 index 0000000..4a9bb27 --- /dev/null +++ b/src/preload.js @@ -0,0 +1,7 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("store", { + saveBroadcaster: (value) => ipcRenderer.invoke("save-broadcaster", value), + loadBroadcaster: () => ipcRenderer.invoke("load-broadcaster"), + setWindowSize: (width, height) => ipcRenderer.invoke("set-window-size", width, height), +}); diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..10a42a2 --- /dev/null +++ b/src/store.js @@ -0,0 +1,18 @@ +const fs = require("node:fs"); + +function readStore(store_path) { + try { + return JSON.parse(fs.readFileSync(store_path, "utf8")); + } catch { + return {}; + } +} + +function writeStore(store_path, data) { + fs.writeFileSync(store_path, JSON.stringify(data, null, 2)); +} + +module.exports = { + readStore, + writeStore, +};