328 lines
8.7 KiB
TypeScript
328 lines
8.7 KiB
TypeScript
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;
|
|
}
|