12 Commits

14 changed files with 584 additions and 95 deletions

1
.gitattributes vendored
View File

@@ -10,3 +10,4 @@ res/icon/linux/32x32.png filter=lfs diff=lfs merge=lfs -text
res/icon/linux/48x48.png filter=lfs diff=lfs merge=lfs -text res/icon/linux/48x48.png filter=lfs diff=lfs merge=lfs -text
res/icon/linux/512x512.png filter=lfs diff=lfs merge=lfs -text res/icon/linux/512x512.png filter=lfs diff=lfs merge=lfs -text
res/icon/linux/64x64.png filter=lfs diff=lfs merge=lfs -text res/icon/linux/64x64.png filter=lfs diff=lfs merge=lfs -text
res/icon/brand.png filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,5 +1,5 @@
{ {
"plugins": ["prettier-plugin-java"], "plugins": [],
"printWidth":120, "printWidth":120,
"tabWidth":4, "tabWidth":4,
"useTabs":false, "useTabs":false,

View File

@@ -15,17 +15,16 @@ node_modules: package-lock.json
.PHONY: run .PHONY: run
run: run:
$(ELECTRON) src/main.js LOCAL_DOMAIN=1 $(ELECTRON) src/main.js
.PHONY: run-x .PHONY: run-x
run-x: run-x:
./util/run-xorg $(ELECTRON) LOCAL_DOMAIN=1 ./util/run-xorg $(ELECTRON)
.PHONY: run-wl .PHONY: run-wl
run-wl: run-wl:
./util/run-wl $(ELECTRON) LOCAL_DOMAIN=1 ./util/run-wl $(ELECTRON)
.PHONY: fix .PHONY: fix
fix: node_modules fix: node_modules
./node_modules/.bin/prettier --write src ./node_modules/.bin/prettier --write src

View File

@@ -1 +1,7 @@
# TODO # Client
Electron based desktop client for viewing the imgfloat broadcast dashboard.
## "Why not use a web source?"
TODO

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "imgfloat-client", "name": "imgfloat-client",
"version": "1.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imgfloat-client", "name": "imgfloat-client",
"version": "1.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"electron-updater": "^6.7.3" "electron-updater": "^6.7.3"
}, },

View File

@@ -1,13 +1,13 @@
{ {
"name": "imgfloat-client", "name": "imgfloat-client",
"version": "1.0.1", "version": "1.0.3",
"description": "Electron wrapper for the Imgfloat overlay", "description": "Electron wrapper for the Imgfloat overlay",
"main": "src/main.js", "main": "src/main.js",
"build": { "build": {
"appId": "dev.kruhlmann.imgfloat", "appId": "dev.kruhlmann.imgfloat",
"productName": "Imgfloat", "productName": "Imgfloat",
"files": [ "files": [
"src/main.js", "src/**",
"res/**" "res/**"
], ],
"publish": { "publish": {
@@ -28,10 +28,12 @@
}, },
"win": { "win": {
"target": [ "target": [
"nsis" "nsis",
"portable"
], ],
"icon": "res/icon/appicon.ico" "icon": "res/icon/appicon.ico"
}, },
"portable": { "artifactName": "Imgfloat.exe" },
"nsis": { "nsis": {
"oneClick": true, "oneClick": true,
"perMachine": true, "perMachine": true,

BIN
res/banner.png LFS

Binary file not shown.

90
res/browser_api_test.html Normal file
View File

@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>OBS Browser API Check</title>
<style>
body {
background: #111;
color: #eee;
font-family: monospace;
padding: 16px;
}
h1 {
color: #6cf;
}
.fail {
color: #f66;
}
.ok {
color: #6f6;
}
pre {
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>OBS Browser Source Missing API Report</h1>
<pre id="log"></pre>
<script>
const logEl = document.getElementById("log");
function log(line, cls = "") {
const span = document.createElement("div");
if (cls) span.className = cls;
span.textContent = line;
logEl.appendChild(span);
}
const apiChecks = {
"window.chrome": () => window.chrome,
"navigator.mediaDevices": () => navigator.mediaDevices,
"navigator.mediaDevices.getUserMedia": () => navigator.mediaDevices?.getUserMedia,
RTCPeerConnection: () => window.RTCPeerConnection,
WebSocket: () => window.WebSocket,
Notification: () => window.Notification,
PaymentRequest: () => window.PaymentRequest,
SharedArrayBuffer: () => SharedArrayBuffer,
ServiceWorker: () => navigator.serviceWorker,
"Clipboard API": () => navigator.clipboard,
WebGL2: () => {
const c = document.createElement("canvas");
return c.getContext("webgl2");
},
AudioWorklet: () => window.AudioWorklet,
"Gamepad API": () => navigator.getGamepads,
"Screen Orientation API": () => screen.orientation,
"Permissions API": () => navigator.permissions,
"File System Access API": () => window.showOpenFilePicker,
"WebRTC DataChannel": () => window.RTCPeerConnection && RTCPeerConnection.prototype.createDataChannel,
IndexedDB: () => window.indexedDB,
BroadcastChannel: () => window.BroadcastChannel,
"Web MIDI": () => navigator.requestMIDIAccess,
};
log("Running API availability check...\n");
let missing = 0;
for (const [name, test] of Object.entries(apiChecks)) {
let result;
try {
result = test();
} catch (e) {
result = undefined;
}
if (result === undefined || result === null) {
log(`${name} → undefined / unavailable`, "fail");
missing++;
} else {
log(`${name} → OK`, "ok");
}
}
log(`\nSummary: ${missing} APIs missing or unavailable.`);
</script>
</body>
</html>

BIN
res/icon/brand.png LFS Normal file

Binary file not shown.

235
src/css/index.css Normal file
View File

@@ -0,0 +1,235 @@
* {
box-sizing: border-box;
color: white;
}
:root {
--window-frame-height: 36px;
}
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);
}
.window-frame {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: var(--window-frame-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: rgba(15, 23, 42, 0.9);
border-bottom: 1px solid rgba(30, 41, 59, 0.7);
-webkit-app-region: drag;
}
.window-frame-title {
font-size: 12px;
letter-spacing: 0.3px;
color: #cbd5f5;
text-transform: uppercase;
}
.window-frame-controls {
display: flex;
align-items: center;
gap: 6px;
-webkit-app-region: no-drag;
}
.window-control {
width: 30px;
height: 24px;
padding: 0;
border-radius: 6px;
background: rgba(148, 163, 184, 0.2);
border: 1px solid rgba(148, 163, 184, 0.25);
color: #e2e8f0;
font-size: 16px;
line-height: 1;
box-shadow: none;
}
.window-control:hover {
background: rgba(148, 163, 184, 0.35);
}
.window-control:active {
transform: none;
}
.window-control-close {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.4);
}
.window-control-close:hover {
background: rgba(239, 68, 68, 0.5);
}
.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;
}

123
src/index.html Normal file
View File

@@ -0,0 +1,123 @@
<!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="window-frame" role="presentation">
<div class="window-frame-title">Imgfloat</div>
<div class="window-frame-controls">
<button class="window-control" type="button" data-window-action="minimize" aria-label="Minimize">
&minus;
</button>
<button
class="window-control window-control-close"
type="button"
data-window-action="close"
aria-label="Close"
>
&times;
</button>
</div>
</div>
<div class="channels-shell">
<header class="channels-header">
<div class="brand">
<img class="brand-mark" alt="brand" src="../res/icon/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"
/>
<label class="sr-only" for="domain-input">Server domain</label>
<input
id="domain-input"
name="domain"
class="text-input"
type="url"
autocomplete="off"
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");
const domainInput = document.getElementById("domain-input");
const windowActionButtons = document.querySelectorAll("[data-window-action]");
window.store.loadBroadcaster().then((value) => {
if (value && input.value === "") {
input.value = value;
}
});
Promise.all([window.store.loadDomain(), window.store.loadDefaultDomain()]).then(
([savedDomain, defaultDomain]) => {
domainInput.value = savedDomain || defaultDomain;
domainInput.placeholder = defaultDomain;
},
);
domainInput.addEventListener("change", () => {
const trimmedDomain = domainInput.value.trim();
if (trimmedDomain) {
window.store.saveDomain(trimmedDomain);
}
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const channel = input.value.trim();
const fallbackDomain = domainInput.placeholder || "";
const domain = domainInput.value.trim() || fallbackDomain;
if (!channel) {
return;
}
const params = new URLSearchParams({ broadcaster: channel });
window.store.saveDomain(domain);
window.location.href = `${domain}/view/${encodeURIComponent(channel)}/broadcast`;
});
windowActionButtons.forEach((button) => {
button.addEventListener("click", () => {
const action = button.dataset.windowAction;
if (action === "minimize") {
window.store.minimizeWindow();
}
if (action === "close") {
window.store.closeWindow();
}
});
});
</script>
</body>
</html>

View File

@@ -1,95 +1,99 @@
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;
const LOCAL_DOMAIN = "http://localhost:8080";
const DEFAULT_DOMAIN = "https://imgfloat.kruhlmann.dev";
const RUNTIME_DOMAIN = resolveDefaultDomain();
let ELECTRON_WINDOW;
let canvasSizeInterval; function normalizeDomain(domain) {
function clearCanvasSizeInterval() { return domain?.trim()?.replace(/\/+$/, "");
if (canvasSizeInterval) {
clearInterval(canvasSizeInterval);
canvasSizeInterval = undefined;
}
} }
async function autoResizeWindow(win, lastSize) { function resolveDefaultDomain() {
if (win.isDestroyed()) { if (process.env.LOCAL_DOMAIN) {
return lastSize; return normalizeDomain(LOCAL_DOMAIN);
} }
const newSize = await win.webContents.executeJavaScript(`(() => { const buildTimeDomain = process.env.IMGFLOAT_DOMAIN || DEFAULT_DOMAIN;
const canvas = document.getElementById('broadcast-canvas'); return normalizeDomain(buildTimeDomain);
if (!canvas) {
return null;
} }
const rect = canvas.getBoundingClientRect();
function createWindowOptions() {
return { return {
width: Math.round(rect.width), width: INITIAL_WINDOW_WIDTH_PX,
height: Math.round(rect.height), 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"),
},
}; };
})();`);
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 createWindow(version) { function createWindow(version) {
const win = new BrowserWindow({ const windowOptions = createWindowOptions();
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.setFullScreenable(false);
win.setFullScreen(false);
win.setResizable(false);
win.setTitle(`Imgfloat Client v${version}`); win.setTitle(`Imgfloat Client v${version}`);
return win; return win;
} }
ipcMain.handle("set-window-size", (_, width, height) => {
if (ELECTRON_WINDOW && !ELECTRON_WINDOW.isDestroyed()) {
ELECTRON_WINDOW.setContentSize(width, height, false);
}
});
ipcMain.handle("minimize-window", () => {
if (ELECTRON_WINDOW && !ELECTRON_WINDOW.isDestroyed()) {
ELECTRON_WINDOW.minimize();
}
});
ipcMain.handle("close-window", () => {
if (ELECTRON_WINDOW && !ELECTRON_WINDOW.isDestroyed()) {
ELECTRON_WINDOW.close();
}
});
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 ?? "";
});
ipcMain.handle("save-domain", (_, domain) => {
const store = readStore(STORE_PATH);
store.lastDomain = normalizeDomain(domain);
writeStore(STORE_PATH, store);
});
ipcMain.handle("load-domain", () => {
const store = readStore(STORE_PATH);
return normalizeDomain(store.lastDomain) || RUNTIME_DOMAIN;
});
ipcMain.handle("load-default-domain", () => RUNTIME_DOMAIN);
app.whenReady().then(() => { app.whenReady().then(() => {
if (process.env.CI) { if (process.env.CI) {
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
@@ -100,13 +104,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); }
}); });

12
src/preload.js Normal file
View File

@@ -0,0 +1,12 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("store", {
saveBroadcaster: (value) => ipcRenderer.invoke("save-broadcaster", value),
loadBroadcaster: () => ipcRenderer.invoke("load-broadcaster"),
saveDomain: (value) => ipcRenderer.invoke("save-domain", value),
loadDomain: () => ipcRenderer.invoke("load-domain"),
loadDefaultDomain: () => ipcRenderer.invoke("load-default-domain"),
setWindowSize: (width, height) => ipcRenderer.invoke("set-window-size", width, height),
minimizeWindow: () => ipcRenderer.invoke("minimize-window"),
closeWindow: () => ipcRenderer.invoke("close-window"),
});

18
src/store.js Normal file
View 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,
};