mirror of
https://github.com/imgfloat/client.git
synced 2026-02-05 03:59:26 +00:00
Migrate client code to local repo
This commit is contained in:
@@ -1 +1,7 @@
|
|||||||
# TODO
|
# Client
|
||||||
|
|
||||||
|
Electron based desktop client for viewing the imgfloat broadcast dashboard.
|
||||||
|
|
||||||
|
## "Why not use a web source?"
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|||||||
16
src/broadcast.html
Normal file
16
src/broadcast.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Imgfloat Broadcast</title>
|
||||||
|
<link rel="stylesheet" href="./css/toast.css" />
|
||||||
|
<link rel="stylesheet" href="./css/broadcast.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="broadcast-body">
|
||||||
|
<canvas id="broadcast-canvas"></canvas>
|
||||||
|
<canvas id="broadcast-script-canvas"></canvas>
|
||||||
|
<script type="module" src="./js/broadcast.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
src/css/broadcast.css
Normal file
20
src/css/broadcast.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
172
src/css/index.css
Normal file
172
src/css/index.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
90
src/css/toast.css
Normal file
90
src/css/toast.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
65
src/index.html
Normal file
65
src/index.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Browse channels - Imgfloat</title>
|
||||||
|
<link rel="stylesheet" href="./css/index.css" />
|
||||||
|
</head>
|
||||||
|
<body class="channels-body">
|
||||||
|
<div class="channels-shell">
|
||||||
|
<header class="channels-header">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="brand-mark" alt="brand" src="https://imgfloat.kruhlmann.dev/img/brand.png" />
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">Imgfloat</div>
|
||||||
|
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="channels-main">
|
||||||
|
<section class="channel-card">
|
||||||
|
<p class="eyebrow subtle">Broadcast overlay</p>
|
||||||
|
<h1>Open a channel</h1>
|
||||||
|
<p class="muted">Type the channel name to jump straight to their overlay.</p>
|
||||||
|
<form id="channel-search-form" class="channel-form">
|
||||||
|
<label class="sr-only" for="channel-search">Channel name</label>
|
||||||
|
<input
|
||||||
|
id="channel-search"
|
||||||
|
name="channel"
|
||||||
|
class="text-input"
|
||||||
|
type="text"
|
||||||
|
list="channel-suggestions"
|
||||||
|
placeholder="Type a channel name"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<datalist id="channel-suggestions"></datalist>
|
||||||
|
<button type="submit" class="button block">Open overlay</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById("channel-search-form");
|
||||||
|
const input = document.getElementById("channel-search");
|
||||||
|
|
||||||
|
window.store.loadBroadcaster().then((value) => {
|
||||||
|
if (value && input.value === "") {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const channel = input.value.trim();
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ broadcaster: channel });
|
||||||
|
window.location.href = `broadcast.html?${params.toString()}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/js/broadcast.js
Normal file
31
src/js/broadcast.js
Normal file
@@ -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");
|
||||||
|
});
|
||||||
39
src/js/broadcast/assetKinds.js
Normal file
39
src/js/broadcast/assetKinds.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
10
src/js/broadcast/audio.js
Normal file
10
src/js/broadcast/audio.js
Normal file
@@ -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/");
|
||||||
|
}
|
||||||
216
src/js/broadcast/audioManager.js
Normal file
216
src/js/broadcast/audioManager.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/js/broadcast/constants.js
Normal file
9
src/js/broadcast/constants.js
Normal file
@@ -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",
|
||||||
|
});
|
||||||
44
src/js/broadcast/layers.js
Normal file
44
src/js/broadcast/layers.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
327
src/js/broadcast/mediaManager.js
Normal file
327
src/js/broadcast/mediaManager.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
562
src/js/broadcast/renderer.js
Normal file
562
src/js/broadcast/renderer.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/js/broadcast/script-worker.js
Normal file
263
src/js/broadcast/script-worker.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
14
src/js/broadcast/state.js
Normal file
14
src/js/broadcast/state.js
Normal file
@@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/js/broadcast/visibility.js
Normal file
33
src/js/broadcast/visibility.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
21
src/js/ipc.js
Normal file
21
src/js/ipc.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
56
src/js/toast.js
Normal file
56
src/js/toast.js
Normal file
@@ -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));
|
||||||
|
}
|
||||||
151
src/main.js
151
src/main.js
@@ -1,95 +1,79 @@
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
const { app, BrowserWindow } = require("electron");
|
const { app, BrowserWindow, ipcMain } = require("electron");
|
||||||
const { autoUpdater } = require("electron-updater");
|
const { autoUpdater } = require("electron-updater");
|
||||||
|
const { readStore, writeStore } = require("./store.js");
|
||||||
|
|
||||||
const initialWindowWidthPx = 960;
|
const STORE_PATH = path.join(app.getPath("userData"), "settings.json");
|
||||||
const initialWindowHeightPx = 640;
|
const INITIAL_WINDOW_WIDTH_PX = 960;
|
||||||
|
const INITIAL_WINDOW_HEIGHT_PX = 640;
|
||||||
|
|
||||||
let canvasSizeInterval;
|
let ELECTRON_WINDOW;
|
||||||
function clearCanvasSizeInterval() {
|
|
||||||
if (canvasSizeInterval) {
|
|
||||||
clearInterval(canvasSizeInterval);
|
|
||||||
canvasSizeInterval = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function autoResizeWindow(win, lastSize) {
|
function createWindowOptionsForPlatform(platform) {
|
||||||
if (win.isDestroyed()) {
|
switch (platform) {
|
||||||
return lastSize;
|
case "darwin":
|
||||||
}
|
case "linux":
|
||||||
const newSize = await win.webContents.executeJavaScript(`(() => {
|
return {
|
||||||
const canvas = document.getElementById('broadcast-canvas');
|
width: INITIAL_WINDOW_WIDTH_PX,
|
||||||
if (!canvas) {
|
height: INITIAL_WINDOW_HEIGHT_PX,
|
||||||
return null;
|
transparent: true,
|
||||||
}
|
frame: true,
|
||||||
const rect = canvas.getBoundingClientRect();
|
backgroundColor: "#00000000",
|
||||||
return {
|
alwaysOnTop: false,
|
||||||
width: Math.round(rect.width),
|
icon: path.join(__dirname, "../res/icon/appicon.ico"),
|
||||||
height: Math.round(rect.height),
|
webPreferences: {
|
||||||
};
|
backgroundThrottling: false,
|
||||||
})();`);
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
},
|
||||||
if (!newSize?.width || !newSize?.height) {
|
};
|
||||||
return lastSize;
|
case "win32":
|
||||||
}
|
return {
|
||||||
if (lastSize.width === newSize.width && lastSize.height === newSize.height) {
|
width: INITIAL_WINDOW_WIDTH_PX,
|
||||||
return lastSize;
|
height: INITIAL_WINDOW_HEIGHT_PX,
|
||||||
}
|
transparent: true,
|
||||||
console.info(
|
frame: false,
|
||||||
`Window size did not match canvas old: ${lastSize.width}x${lastSize.height} new: ${newSize.width}x${newSize.height}. Resizing.`,
|
backgroundColor: "#00000000",
|
||||||
);
|
alwaysOnTop: false,
|
||||||
win.setContentSize(newSize.width, newSize.height, false);
|
icon: path.join(__dirname, "../res/icon/appicon.ico"),
|
||||||
win.setResizable(false);
|
webPreferences: {
|
||||||
return newSize;
|
backgroundThrottling: false,
|
||||||
}
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
},
|
||||||
function onPostNavigationLoad(win, url, broadcastRect) {
|
};
|
||||||
url = url || win.webContents.getURL();
|
default:
|
||||||
let pathname;
|
throw new Error(`Unsupported platform: ${platform}`);
|
||||||
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 createWindow(version) {
|
function createWindow(version) {
|
||||||
const win = new BrowserWindow({
|
const windowOptions = createWindowOptionsForPlatform(process.platform);
|
||||||
width: initialWindowWidthPx,
|
const win = new BrowserWindow(windowOptions);
|
||||||
height: initialWindowHeightPx,
|
|
||||||
transparent: true,
|
|
||||||
frame: true,
|
|
||||||
backgroundColor: "#00000000",
|
|
||||||
alwaysOnTop: false,
|
|
||||||
icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"),
|
|
||||||
webPreferences: { backgroundThrottling: false },
|
|
||||||
});
|
|
||||||
win.setMenu(null);
|
win.setMenu(null);
|
||||||
win.setTitle(`Imgfloat Client v${version}`);
|
win.setTitle(`Imgfloat Client v${version}`);
|
||||||
|
|
||||||
return win;
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
process.on("uncaughtException", (err) => {
|
process.on("uncaughtException", (err) => {
|
||||||
@@ -100,13 +84,12 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
autoUpdater.checkForUpdatesAndNotify();
|
autoUpdater.checkForUpdatesAndNotify();
|
||||||
|
|
||||||
let broadcastRect = { width: 0, height: 0 };
|
|
||||||
const version = app.getVersion();
|
const version = app.getVersion();
|
||||||
const win = createWindow(version);
|
ELECTRON_WINDOW = createWindow(version);
|
||||||
win.loadURL(process.env["IMGFLOAT_CHANNELS_URL"] || "https://imgfloat.kruhlmann.dev/channels");
|
ELECTRON_WINDOW.loadFile(path.join(__dirname, "index.html"));
|
||||||
win.webContents.on("did-finish-load", () => onPostNavigationLoad(win, undefined, broadcastRect));
|
ELECTRON_WINDOW.on("page-title-updated", (e) => e.preventDefault());
|
||||||
win.webContents.on("did-navigate", (_, url) => onPostNavigationLoad(win, url, broadcastRect));
|
|
||||||
win.webContents.on("did-navigate-in-page", (_, url) => onPostNavigationLoad(win, url, broadcastRect));
|
if (process.env.DEVTOOLS) {
|
||||||
win.on("page-title-updated", (e) => e.preventDefault());
|
ELECTRON_WINDOW.webContents.openDevTools({ mode: "detach" });
|
||||||
win.on("closed", clearCanvasSizeInterval);
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
7
src/preload.js
Normal file
7
src/preload.js
Normal file
@@ -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),
|
||||||
|
});
|
||||||
18
src/store.js
Normal file
18
src/store.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user