// @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" }),
});
--- /dev/null
+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);
+ },
+ }),
+ },
+};
+++ /dev/null
-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();
--- /dev/null
+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);
+ };
+}
--- /dev/null
+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",
+ },
+ });
+};
--- /dev/null
+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",
+ },
+ });
+};
---
-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>
--- /dev/null
+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();
--- /dev/null
+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);
+ }
+ }
+ },
+};
-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);
"@components/*": ["components/*"],
"@layouts/*": ["layouts/*"],
"@libs/*": ["libs/*"],
+ "@server/*": ["server/*"],
"@stores/*": ["stores/*"],
"@styles/*": ["styles/*"],
},