]> git.otsuka.systems Git - obs-control-panel/commitdiff
get websocket and sse message passing working initial-astro
authorCameron Otsuka <cameron@otsuka.haus>
Thu, 8 Jan 2026 03:51:42 +0000 (19:51 -0800)
committerCameron Otsuka <cameron@otsuka.haus>
Thu, 8 Jan 2026 03:51:42 +0000 (19:51 -0800)
astro.config.mjs
src/actions/index.ts [new file with mode: 0644]
src/libs/obs.ts [deleted file]
src/libs/sse.ts [new file with mode: 0644]
src/pages/api/control-panel.ts [new file with mode: 0644]
src/pages/api/overlay.ts [new file with mode: 0644]
src/pages/index.astro
src/server/obs-manager.ts [new file with mode: 0644]
src/server/sse.ts [new file with mode: 0644]
src/stores/obs.ts
tsconfig.json

index 87c117a8d6c664a42f28c1c47b741ee86e7b8bdd..0347edce0377f977b764430b077c5ce9f309e73c 100644 (file)
@@ -1,10 +1,7 @@
 // @ts-check
-import { defineConfig } from 'astro/config';
-
-import node from '@astrojs/node';
+import { defineConfig } from "astro/config";
+import node from "@astrojs/node";
 
 export default defineConfig({
-  adapter: node({
-    mode: 'standalone',
-  }),
+  adapter: node({ mode: "standalone" }),
 });
diff --git a/src/actions/index.ts b/src/actions/index.ts
new file mode 100644 (file)
index 0000000..052011a
--- /dev/null
@@ -0,0 +1,24 @@
+import { defineAction } from "astro:actions";
+import { z } from "astro:schema";
+import { obsManager } from "@server/obs-manager";
+
+export const server = {
+  obs: {
+    connect: defineAction({
+      handler: async () => {
+        await obsManager.connect();
+      },
+    }),
+    disconnect: defineAction({
+      handler: async () => {
+        await obsManager.disconnect();
+      },
+    }),
+    switchScene: defineAction({
+      input: z.object({ sceneName: z.string() }),
+      handler: async ({ sceneName }) => {
+        await obsManager.switchScene(sceneName);
+      },
+    }),
+  },
+};
diff --git a/src/libs/obs.ts b/src/libs/obs.ts
deleted file mode 100644 (file)
index bb14b33..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-import OBSWebSocket from 'obs-websocket-js';
-import type { OBSEventTypes, OBSRequestTypes, OBSResponseTypes } from 'obs-websocket-js';
-import { $connected, $currentScene, $sceneList } from '@stores/obs';
-
-class OBSClient {
-  private obs: OBSWebSocket;
-  private reconnectTimeout?: NodeJS.Timeout;
-  private host: string = 'localhost';
-  private port: number = 4455;
-  private password: string = '';
-
-  constructor() {
-    this.obs = new OBSWebSocket();
-
-    // event listeners
-    this.obs.on('ConnectionClosed', this.scheduleReconnect);
-    this.obs.on('ConnectionError', this.scheduleReconnect);
-    this.obs.on('Identified', this.initState);
-    this.obs.on('CurrentProgramSceneChanged', this.sceneChanged)
-    this.obs.on('SceneListChanged', this.fetchScenes)
-  }
-
-  private scheduleReconnect() {
-    $connected.set(false);
-    if (this.reconnectTimeout) {
-      clearTimeout(this.reconnectTimeout);
-    }
-    this.reconnectTimeout = setTimeout(() => {
-      this.connect();
-    }, 5000);
-  }
-
-  private async initState() {
-    $connected.set(true);
-    await this.fetchScenes();
-  }
-
-  private async fetchScenes() {
-    try {
-      const {scenes, currentProgramSceneName}: OBSResponseTypes['GetSceneList'] = await this.obs.call('GetSceneList');
-      const sceneList = scenes.map((scene) => ({
-        name: scene.sceneName as string,
-        index: scene.sceneIndex as number
-      })).reverse();
-      $currentScene.set(currentProgramSceneName);
-      $sceneList.set(sceneList);
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  private sceneChanged(event: OBSEventTypes['CurrentProgramSceneChanged']) {
-    $currentScene.set(event.sceneName);
-  }
-
-  async switchScene(switchSceneRequest: OBSRequestTypes['SetCurrentProgramScene']) {
-    try {
-      await this.obs.call('SetCurrentProgramScene', switchSceneRequest);
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  async connect() {
-    const url = `ws://${this.host}:${this.port}`;
-    try {
-      await this.obs.connect(url, this.password);
-    } catch (error) {
-      console.error(error);
-      this.scheduleReconnect();
-    }
-  }
-
-  async disconnect() {
-    if (this.reconnectTimeout) {
-      clearTimeout(this.reconnectTimeout);
-    }
-    await this.obs.disconnect();
-  }
-
-  isConnected(): boolean {
-    return $connected.get();
-  }
-}
-
-// Singleton instance
-export const obsClient = new OBSClient();
diff --git a/src/libs/sse.ts b/src/libs/sse.ts
new file mode 100644 (file)
index 0000000..e85f68a
--- /dev/null
@@ -0,0 +1,13 @@
+export function initSSE(
+  eventSourceEndpoint: string,
+  eventType: string,
+  onmessage: (ev: MessageEvent) => void
+) {
+  const eventSource = new EventSource(eventSourceEndpoint);
+  eventSource.addEventListener(eventType, onmessage);
+
+  eventSource.onerror = () => {
+    eventSource.close();
+    setTimeout(() => initSSE(eventSourceEndpoint, eventType, onmessage), 3000);
+  };
+}
diff --git a/src/pages/api/control-panel.ts b/src/pages/api/control-panel.ts
new file mode 100644 (file)
index 0000000..eee7bdb
--- /dev/null
@@ -0,0 +1,30 @@
+import type { APIRoute } from "astro";
+import { sse } from "@server/sse";
+import { $obs } from "@stores/obs";
+
+export const GET: APIRoute = ({ request }) => {
+  const stream = new ReadableStream({
+    start(controller) {
+      // Send initial OBS state
+      const encoder = new TextEncoder();
+      const initialState = $obs.get();
+      controller.enqueue(
+        encoder.encode(
+          `event: obs\ndata: ${JSON.stringify({ category: "state", data: initialState })}\n\n`,
+        ),
+      );
+
+      sse.connect(controller, ["obs"]);
+      request.signal.addEventListener("abort", () =>
+        sse.disconnect(controller),
+      );
+    },
+  });
+
+  return new Response(stream, {
+    headers: {
+      "Content-Type": "text/event-stream",
+      "Cache-Control": "no-cache",
+    },
+  });
+};
diff --git a/src/pages/api/overlay.ts b/src/pages/api/overlay.ts
new file mode 100644 (file)
index 0000000..678141c
--- /dev/null
@@ -0,0 +1,20 @@
+import type { APIRoute } from "astro";
+import { sse } from "@server/sse";
+
+export const GET: APIRoute = ({ request }) => {
+  const stream = new ReadableStream({
+    start(controller) {
+      sse.connect(controller, ["overlay"]);
+      request.signal.addEventListener("abort", () =>
+        sse.disconnect(controller),
+      );
+    },
+  });
+
+  return new Response(stream, {
+    headers: {
+      "Content-Type": "text/event-stream",
+      "Cache-Control": "no-cache",
+    },
+  });
+};
index c4298205c69fed2e9f683d5d6a476f442586afaf..867fede9259cb8df0c4f14c97392a62d26277177 100644 (file)
@@ -1,15 +1,49 @@
 ---
-import Base from '@layouts/base.astro';
+import Base from "@layouts/base.astro";
 ---
 
 <Base title="OBS Control Panel">
-  <header>
-    <h1>OBS Control Panel</h1>
-  </header>
-  <section>
-    Test content
-  </section>
-  <footer slot="footer">
-    Test Footer
-  </footer>
-</Base>
\ No newline at end of file
+    <header>
+        <h1>OBS Control Panel</h1>
+    </header>
+    <section>
+        <button id="connect-button">Connect</button>
+        <button id="disconnect-button">Disconnect</button>
+    </section>
+    <footer slot="footer">
+        Connected: <span id="connection-status">No</span>
+    </footer>
+</Base>
+
+<script>
+    import { actions } from "astro:actions";
+    import { initSSE } from "@libs/sse";
+
+    const connectionStatus = document.getElementById("connection-status");
+
+    // Initialize SSE connection with message handler
+    initSSE("/api/control-panel", "obs", (msg) => {
+        console.log(msg);
+        const msgData = JSON.parse(msg.data);
+        switch (msgData.category) {
+            case "state":
+                if (connectionStatus) {
+                    connectionStatus.textContent = msgData.data.connected
+                        ? "Yes"
+                        : "No";
+                }
+                break;
+        }
+    });
+
+    // Button handlers
+    const connectButton = document.getElementById("connect-button");
+    connectButton?.addEventListener("click", async () => {
+        await actions.obs.connect();
+    });
+
+    const disconnectButton = document.getElementById("disconnect-button");
+    disconnectButton?.addEventListener("click", async () => {
+        await actions.obs.disconnect();
+    });
+</script>
diff --git a/src/server/obs-manager.ts b/src/server/obs-manager.ts
new file mode 100644 (file)
index 0000000..f3eab1c
--- /dev/null
@@ -0,0 +1,92 @@
+import OBSWebSocket from "obs-websocket-js";
+import type { OBSEventTypes, OBSResponseTypes } from "obs-websocket-js";
+import { $obs, obsInitialState } from "@stores/obs";
+import { sse } from "@server/sse";
+
+// Broadcast store changes via SSE
+$obs.listen((state) => {
+  sse.send("obs", { category: "state", data: state });
+});
+
+class OBSManager {
+  private obs: OBSWebSocket;
+
+  private host = process.env.OBS_HOST ?? "localhost";
+  private port = parseInt(process.env.OBS_PORT ?? "4455");
+  private password = process.env.OBS_PASSWORD ?? "";
+
+  constructor() {
+    this.obs = new OBSWebSocket();
+    this.setupEventListeners();
+  }
+
+  private setupEventListeners() {
+    this.obs.on("ConnectionClosed", this.handleDisconnect);
+    this.obs.on("ConnectionError", this.handleConnectionError);
+    this.obs.on("Identified", this.handleIdentified);
+    this.obs.on("CurrentProgramSceneChanged", this.handleSceneChanged);
+    this.obs.on("SceneListChanged", this.handleSceneListChanged);
+  }
+
+  private handleDisconnect = () => {
+    $obs.set(obsInitialState);
+  };
+
+  private handleConnectionError = (error: Error) => {
+    console.error("OBS Connection Error:", error);
+    $obs.setKey("connected", false);
+  };
+
+  private handleIdentified = async () => {
+    $obs.setKey("connected", true);
+    await this.fetchScenes();
+  };
+
+  private handleSceneChanged = (
+    event: OBSEventTypes["CurrentProgramSceneChanged"],
+  ) => {
+    $obs.setKey("currentScene", event.sceneName);
+  };
+
+  private handleSceneListChanged = async () => {
+    await this.fetchScenes();
+  };
+
+  private async fetchScenes() {
+    try {
+      const response: OBSResponseTypes["GetSceneList"] =
+        await this.obs.call("GetSceneList");
+      const sceneList = response.scenes
+        .map((scene) => ({
+          name: scene.sceneName as string,
+          index: scene.sceneIndex as number,
+        }))
+        .reverse();
+
+      $obs.setKey("currentScene", response.currentProgramSceneName);
+      $obs.setKey("sceneList", sceneList);
+    } catch (error) {
+      console.error("Failed to fetch scenes:", error);
+    }
+  }
+
+  async connect() {
+    const url = `ws://${this.host}:${this.port}`;
+    try {
+      await this.obs.connect(url, this.password);
+    } catch (error) {
+      console.error("Failed to connect to OBS:", error);
+      throw error;
+    }
+  }
+
+  async disconnect() {
+    await this.obs.disconnect();
+  }
+
+  async switchScene(sceneName: string) {
+    await this.obs.call("SetCurrentProgramScene", { sceneName });
+  }
+}
+
+export const obsManager = new OBSManager();
diff --git a/src/server/sse.ts b/src/server/sse.ts
new file mode 100644 (file)
index 0000000..1e45680
--- /dev/null
@@ -0,0 +1,28 @@
+export interface SSEMessage<T = unknown> {
+  category: string;
+  data: T;
+}
+
+type Controller = ReadableStreamDefaultController<Uint8Array>;
+
+const clients = new Map<Controller, Set<string>>();
+const encoder = new TextEncoder();
+
+export const sse = {
+  connect: (c: Controller, eventTypes: string[]) =>
+    clients.set(c, new Set(eventTypes)),
+  disconnect: (c: Controller) => clients.delete(c),
+  send: (type: string, data: unknown) => {
+    const msg = encoder.encode(
+      `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`,
+    );
+    for (const [c, types] of clients) {
+      if (!types.has(type)) continue;
+      try {
+        c.enqueue(msg);
+      } catch {
+        clients.delete(c);
+      }
+    }
+  },
+};
index 71d6176262d028699c1b4bed49f8adf5fa8e14e7..9728a484253ab0533613930200bc4eac7f66592b 100644 (file)
@@ -1,5 +1,15 @@
-import { atom } from 'nanostores';
+import { map } from "nanostores";
 
-export const $connected = atom(false);
-export const $currentScene = atom('');
-export const $sceneList = atom<{name: string; index: number;}[]>([]);
+export interface OBSState {
+  connected: boolean;
+  currentScene: string;
+  sceneList: { name: string; index: number }[];
+}
+
+export const obsInitialState: OBSState = {
+  connected: false,
+  currentScene: "",
+  sceneList: [],
+} as const;
+
+export const $obs = map<OBSState>(obsInitialState);
index 763ae54410cc5a0f567136dd0c4a925040a92d14..c88c46e666a12f2b4edbd577aa21f8bc83a3af18 100644 (file)
@@ -9,6 +9,7 @@
       "@components/*": ["components/*"],
       "@layouts/*": ["layouts/*"],
       "@libs/*": ["libs/*"],
+      "@server/*": ["server/*"],
       "@stores/*": ["stores/*"],
       "@styles/*": ["styles/*"],
     },