This commit is contained in:
327
overlay/src/renderer.ts
Normal file
327
overlay/src/renderer.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user