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

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;
}