V2
Some checks failed
Overlay Installer / build-overlay (push) Has been cancelled

This commit is contained in:
2026-05-11 21:24:15 +02:00
parent 1a9185f88f
commit b8a6fdfcc2
12 changed files with 5580 additions and 0 deletions

22
overlay/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src ws://127.0.0.1:8000; script-src 'self'; style-src 'self';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Streamdeck Overlay</title>
<link rel="stylesheet" href="./dist/styles.css" />
</head>
<body>
<main id="overlay" class="overlay hidden" aria-live="polite">
<section class="panel">
<h1 id="profile-name">Profile</h1>
<div id="button-strip" class="button-strip"></div>
</section>
</main>
<script src="./dist/renderer.js"></script>
</body>
</html>

4663
overlay/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
overlay/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "custom-streamdeck-overlay",
"version": "0.1.0",
"private": true,
"description": "Transparent Streamdeck profile overlay.",
"author": "Custom Streamdeck",
"main": "dist/main.js",
"type": "commonjs",
"scripts": {
"build": "npm run build:css && tsc -p tsconfig.json",
"build:css": "tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify",
"dist": "npm run dist:win",
"dist:win": "npm run build && electron-builder --win nsis --x64 --publish never",
"watch:css": "tailwindcss -i ./src/styles.css -o ./dist/styles.css --watch",
"dev": "npm run build && electron .",
"start": "electron ."
},
"build": {
"appId": "com.customstreamdeck.overlay",
"productName": "Custom Streamdeck Overlay",
"asar": true,
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"index.html",
"package.json"
],
"win": {
"signAndEditExecutable": false,
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-${version}-Setup-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": false,
"createStartMenuShortcut": true,
"shortcutName": "Custom Streamdeck Overlay"
}
},
"devDependencies": {
"@tailwindcss/cli": "^4.3.0",
"electron": "^42.0.1",
"electron-builder": "^26.8.1",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3"
}
}

9
overlay/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
type OverlayBridge = {
show: () => void;
hide: () => void;
onReveal: (callback: () => void) => void;
};
interface Window {
streamdeckOverlay?: OverlayBridge;
}

167
overlay/src/main.ts Normal file
View File

@@ -0,0 +1,167 @@
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from "electron";
import path from "node:path";
const OVERLAY_WIDTH = 820;
const OVERLAY_HEIGHT = 150;
const SCREEN_EDGE_MARGIN = 24;
const TASKBAR_GAP = 14;
const APP_USER_MODEL_ID = "com.customstreamdeck.overlay";
const TRAY_ICON_DATA_URL =
"data:image/png;base64," +
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAkUlEQVR4nGNgwAMEJdT/UwPjs4NmlpLlGFpbjtcR9LIcqyPobTmGI0a2AwbKcrgjiFGkdDSOLEwVB5BrObGOGPwOGPAoGBSJcNQBFnt/kIWp4gByLSfWEYPfAQMeBYMiEY464GexGF5MUwcQspxSRwy8A4hpFdHU8gFvEw4KBwx4x2RQdM3o5Qi8ltPKMfjsAABiL54YN/wECQAAAABJRU5ErkJggg==";
let overlayWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let isQuitting = false;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}
function createOverlayWindow(): void {
const display = screen.getPrimaryDisplay();
const width = Math.min(OVERLAY_WIDTH, display.workArea.width - SCREEN_EDGE_MARGIN * 2);
const height = OVERLAY_HEIGHT;
const { x, y } = overlayPosition(display, width, height);
overlayWindow = new BrowserWindow({
width,
height,
x,
y,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
show: false,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
}
});
overlayWindow.setIgnoreMouseEvents(true);
overlayWindow.setAlwaysOnTop(true, "screen-saver");
overlayWindow.loadFile(path.join(__dirname, "..", "index.html"));
overlayWindow.once("ready-to-show", () => {
console.log("[overlay:main] window ready; keeping transparent click-through overlay alive");
overlayWindow?.showInactive();
overlayWindow?.setIgnoreMouseEvents(true);
});
overlayWindow.webContents.on("console-message", details => {
console.log(`[overlay:renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`);
});
overlayWindow.on("closed", () => {
console.log("[overlay:main] window closed");
overlayWindow = null;
});
}
function createTray(): void {
if (tray) {
return;
}
const icon = nativeImage.createFromDataURL(TRAY_ICON_DATA_URL);
tray = new Tray(icon);
tray.setToolTip("Custom Streamdeck Overlay");
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: "Show overlay",
click: revealOverlayFromTray
},
{
label: "Quit",
click: () => {
isQuitting = true;
app.quit();
}
}
])
);
tray.on("click", revealOverlayFromTray);
}
function showOverlay(): void {
if (!overlayWindow) {
console.log("[overlay:main] show requested before window exists");
return;
}
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
const [width, height] = overlayWindow.getSize();
console.log(`[overlay:main] showing overlay at ${width}x${height} on display ${display.id}`);
overlayWindow.setBounds({
width,
height,
...overlayPosition(display, width, height)
});
overlayWindow.showInactive();
overlayWindow.setAlwaysOnTop(true, "screen-saver");
overlayWindow.setIgnoreMouseEvents(true);
}
function hideOverlay(): void {
// Keep the BrowserWindow alive so the hidden renderer continues receiving websocket events.
console.log("[overlay:main] overlay faded; leaving window alive for websocket events");
overlayWindow?.setIgnoreMouseEvents(true);
}
function revealOverlayFromTray(): void {
if (!overlayWindow) {
createOverlayWindow();
}
overlayWindow?.webContents.send("overlay:reveal");
showOverlay();
}
if (hasSingleInstanceLock) {
app.on("second-instance", revealOverlayFromTray);
}
app.whenReady().then(() => {
app.setAppUserModelId(APP_USER_MODEL_ID);
console.log("[overlay:main] app ready");
createTray();
createOverlayWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createOverlayWindow();
}
});
});
app.on("before-quit", () => {
isQuitting = true;
});
app.on("window-all-closed", () => {
if (isQuitting) {
app.quit();
}
});
ipcMain.on("overlay:show", showOverlay);
ipcMain.on("overlay:hide", hideOverlay);
function overlayPosition(display: Electron.Display, width: number, height: number): { x: number; y: number } {
const x = Math.round(display.workArea.x + (display.workArea.width - width) / 2);
const bottomAlignedY = display.workArea.y + display.workArea.height - height - TASKBAR_GAP;
const y = Math.round(Math.max(display.workArea.y + SCREEN_EDGE_MARGIN, bottomAlignedY));
return { x, y };
}

9
overlay/src/preload.ts Normal file
View File

@@ -0,0 +1,9 @@
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("streamdeckOverlay", {
show: () => ipcRenderer.send("overlay:show"),
hide: () => ipcRenderer.send("overlay:hide"),
onReveal: (callback: () => void) => {
ipcRenderer.on("overlay:reveal", callback);
}
});

327
overlay/src/renderer.ts Normal file
View File

@@ -0,0 +1,327 @@
type ActionType = "noop" | "key_combo" | "chain" | "app_launch" | "folder" | "folder_rotation" | "plugin";
type Settings = {
active_profile_id: string;
active_folder_id: string;
};
type Profile = {
id: string;
name: string;
};
type Folder = {
id: string;
name: string;
profile_id: string;
};
type ButtonConfig = {
id: string;
folder_id: string;
position: number;
label: string;
color: string;
action_type: ActionType;
action_config: Record<string, unknown>;
};
type AppEntry = {
name: string;
path: string;
};
type PluginAction = {
id: string;
name: string;
};
type PluginInfo = {
id: string;
name: string;
actions: PluginAction[];
};
type DeckState = {
settings: Settings;
profiles: Profile[];
folders: Folder[];
buttons: ButtonConfig[];
apps: AppEntry[];
plugins: PluginInfo[];
};
type WebSocketMessage = {
type: string;
payload?: unknown;
};
type ButtonEventPayload = {
event?: {
button?: unknown;
event?: unknown;
pressed?: unknown;
};
button?: {
position?: unknown;
physical_button?: unknown;
} | null;
};
const WS_URL = "ws://127.0.0.1:8000/ws";
const VISIBLE_MS = 7000;
const FADE_MS = 450;
const RELEASE_HOLD_MS = 120;
const MAX_RECONNECT_MS = 10000;
const overlay = mustFind<HTMLElement>("overlay");
const profileName = mustFind<HTMLElement>("profile-name");
const buttonStrip = mustFind<HTMLElement>("button-strip");
let lastProfileId: string | null = null;
let lastFolderId: string | null = null;
let hideTimer: number | null = null;
let fadeTimer: number | null = null;
let enterTimer: number | null = null;
let reconnectAttempt = 0;
let currentState: DeckState | null = null;
const pressedPositions = new Set<number>();
const releaseTimers = new Map<number, number>();
log("booting renderer");
window.streamdeckOverlay?.onReveal(() => {
if (currentState) {
renderState(currentState);
}
showOverlay();
});
connect();
function connect(): void {
log(`connecting to ${WS_URL}`);
const socket = new WebSocket(WS_URL);
socket.addEventListener("open", () => {
reconnectAttempt = 0;
log("websocket connected");
});
socket.addEventListener("message", event => {
const message = parseMessage(event.data);
log(`websocket message: ${message?.type ?? "unparseable"}`);
if (!message) {
return;
}
if (message.type === "state.updated" && isDeckState(message.payload)) {
handleState(message.payload);
return;
}
if ((message.type === "button.down" || message.type === "button.up") && isButtonEventPayload(message.payload)) {
handleButtonEvent(message.type, message.payload);
}
});
socket.addEventListener("close", scheduleReconnect);
socket.addEventListener("error", () => {
log("websocket error; closing socket so reconnect can run");
socket.close();
});
}
function scheduleReconnect(): void {
const delay = Math.min(500 * 2 ** reconnectAttempt, MAX_RECONNECT_MS);
log(`websocket closed; reconnecting in ${delay}ms`);
reconnectAttempt += 1;
window.setTimeout(connect, delay);
}
function handleState(state: DeckState): void {
currentState = state;
const profileId = state.settings.active_profile_id;
const folderId = state.settings.active_folder_id;
const shouldShow = lastProfileId === null || lastProfileId !== profileId || lastFolderId !== folderId;
log(
`state.updated profile=${shortId(profileId)} folder=${shortId(folderId)} ` +
`lastProfile=${shortId(lastProfileId)} lastFolder=${shortId(lastFolderId)} shouldShow=${shouldShow}`
);
lastProfileId = profileId;
lastFolderId = folderId;
if (!shouldShow) {
return;
}
renderState(state);
showOverlay();
}
function handleButtonEvent(type: "button.down" | "button.up", payload: ButtonEventPayload): void {
const position = buttonPositionFromPayload(payload);
if (position === null) {
return;
}
if (releaseTimers.has(position)) {
window.clearTimeout(releaseTimers.get(position));
releaseTimers.delete(position);
}
if (type === "button.down") {
pressedPositions.add(position);
if (currentState && buttonStrip.children.length === 0) {
renderState(currentState);
}
showOverlay({ animate: false });
updatePressedButtons();
return;
}
releaseTimers.set(
position,
window.setTimeout(() => {
pressedPositions.delete(position);
releaseTimers.delete(position);
updatePressedButtons();
}, RELEASE_HOLD_MS)
);
}
function renderState(state: DeckState): void {
const activeProfile = state.profiles.find(profile => profile.id === state.settings.active_profile_id);
const activeFolder = state.folders.find(folder => folder.id === state.settings.active_folder_id);
const buttons = state.buttons
.filter(button => button.folder_id === state.settings.active_folder_id)
.sort((a, b) => a.position - b.position)
.slice(0, 10);
const profileLabel = activeProfile?.name || "Unknown Profile";
const folderLabel = activeFolder?.name || "Unknown Folder";
log(`rendering profile="${profileLabel}" folder="${folderLabel}" buttons=${buttons.length}`);
profileName.textContent = `${profileLabel} / ${folderLabel}`;
buttonStrip.replaceChildren(...Array.from({ length: 10 }, (_, index) => renderButton(buttons[index], index + 1)));
}
function renderButton(button: ButtonConfig | undefined, fallbackPosition: number): HTMLElement {
const tile = document.createElement("article");
tile.className = "button-tile";
if (pressedPositions.has(fallbackPosition)) {
tile.classList.add("pressed");
}
tile.dataset.position = String(fallbackPosition);
tile.style.setProperty("--button-color", button?.color || "#6b7280");
tile.style.setProperty("--tile-index", String(fallbackPosition - 1));
const label = document.createElement("div");
label.className = "button-label";
label.textContent = button?.label?.trim() || `Button ${fallbackPosition}`;
tile.append(label);
return tile;
}
function updatePressedButtons(): void {
buttonStrip.querySelectorAll<HTMLElement>(".button-tile").forEach(tile => {
const position = numberValue(tile.dataset.position);
tile.classList.toggle("pressed", position !== null && pressedPositions.has(position));
});
}
function showOverlay(options: { animate?: boolean } = {}): void {
log("showing overlay");
const animate = options.animate ?? true;
if (hideTimer !== null) {
window.clearTimeout(hideTimer);
}
if (fadeTimer !== null) {
window.clearTimeout(fadeTimer);
}
if (enterTimer !== null) {
window.clearTimeout(enterTimer);
}
overlay.classList.remove("hidden", "decaying");
overlay.classList.remove("entering");
if (animate) {
void overlay.offsetWidth;
overlay.classList.add("entering");
}
window.streamdeckOverlay?.show();
if (animate) {
enterTimer = window.setTimeout(() => {
overlay.classList.remove("entering");
}, 420);
}
hideTimer = window.setTimeout(() => {
log("starting overlay fade");
overlay.classList.add("decaying");
fadeTimer = window.setTimeout(() => {
log("overlay hidden in DOM");
overlay.classList.add("hidden");
window.streamdeckOverlay?.hide();
}, FADE_MS);
}, VISIBLE_MS - FADE_MS);
}
function parseMessage(data: unknown): WebSocketMessage | null {
if (typeof data !== "string") {
return null;
}
try {
return JSON.parse(data) as WebSocketMessage;
} catch {
return null;
}
}
function isDeckState(value: unknown): value is DeckState {
if (!value || typeof value !== "object") {
return false;
}
const state = value as Partial<DeckState>;
return Boolean(
state.settings &&
Array.isArray(state.profiles) &&
Array.isArray(state.folders) &&
Array.isArray(state.buttons) &&
Array.isArray(state.apps) &&
Array.isArray(state.plugins)
);
}
function isButtonEventPayload(value: unknown): value is ButtonEventPayload {
return Boolean(value && typeof value === "object");
}
function buttonPositionFromPayload(payload: ButtonEventPayload): number | null {
const mappedPosition = numberValue(payload.button?.position);
if (mappedPosition !== null) {
return mappedPosition;
}
return numberValue(payload.event?.button);
}
function numberValue(value: unknown): number | null {
const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
return Number.isInteger(number) && number >= 1 && number <= 10 ? number : null;
}
function shortId(value: string | null): string {
return value ? value.slice(0, 8) : "null";
}
function log(message: string): void {
console.log(`[overlay] ${message}`);
}
function mustFind<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Missing element #${id}`);
}
return element as T;
}

156
overlay/src/styles.css Normal file
View File

@@ -0,0 +1,156 @@
@import "tailwindcss";
@source "../index.html";
@source "./**/*.ts";
@theme {
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", sans-serif;
}
@layer base {
:root {
color-scheme: dark;
font-family: var(--font-sans);
}
* {
box-sizing: border-box;
}
html,
body {
@apply h-full overflow-hidden bg-transparent select-none;
}
body {
@apply m-0 flex items-center justify-center;
}
}
@layer components {
.overlay {
@apply flex h-full w-full items-center justify-center p-2 opacity-100;
transform: translateY(0) scale(1);
transition:
opacity 180ms ease,
transform 180ms ease;
}
.overlay.hidden {
@apply opacity-0;
transform: translateY(7px) scale(0.985);
}
.overlay.decaying {
@apply opacity-0;
transform: translateY(6px) scale(0.992);
}
.panel {
@apply w-full max-w-full overflow-hidden text-white;
background: rgba(58, 59, 61, 0.95);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 18px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3);
padding: 17px 18px 18px;
}
h1 {
@apply mb-4 overflow-hidden text-center text-lg leading-tight text-ellipsis whitespace-nowrap text-white;
font-weight: 650;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.28);
}
.button-strip {
@apply grid grid-cols-10 gap-[5px];
}
.button-tile {
@apply relative flex min-h-[54px] min-w-0 items-center overflow-hidden px-1.5 pt-[7px] pb-2;
background: rgba(255, 255, 255, 0.095);
border-radius: 6px;
transition:
background-color 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
}
.button-tile::before {
@apply absolute top-0 right-0 left-0 content-[''];
background: var(--button-color);
height: 4px;
opacity: 0.68;
transition:
height 120ms ease,
opacity 120ms ease;
}
.button-label {
@apply relative z-10 line-clamp-2 w-full min-w-0 overflow-hidden text-center text-xs leading-tight text-ellipsis text-white;
display: -webkit-box;
font-weight: 650;
-webkit-box-orient: vertical;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.24);
}
.overlay.entering .panel {
animation: osd-pop 280ms cubic-bezier(0.2, 0.95, 0.22, 1) both;
}
.overlay.entering .button-tile {
animation: tile-rise 220ms ease-out both;
animation-delay: calc(45ms + (var(--tile-index) * 16ms));
}
.button-tile.pressed {
background: rgba(255, 255, 255, 0.22);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 2px 7px rgba(255, 255, 255, 0.08),
0 1px 2px rgba(0, 0, 0, 0.22);
transform: translateY(1px) scale(0.975);
}
.button-tile.pressed::before {
height: 6px;
opacity: 1;
}
}
@keyframes osd-pop {
0% {
opacity: 0;
transform: translateY(8px) scale(0.975);
}
62% {
opacity: 1;
transform: translateY(-1px) scale(1.006);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes tile-rise {
0% {
opacity: 0;
transform: translateY(5px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.overlay,
.panel,
.button-tile {
animation: none;
transition: opacity 120ms ease;
}
}

15
overlay/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}