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; }; 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("overlay"); const profileName = mustFind("profile-name"); const buttonStrip = mustFind("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(); const releaseTimers = new Map(); 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(".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; 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(id: string): T { const element = document.getElementById(id); if (!element) { throw new Error(`Missing element #${id}`); } return element as T; }