From 4ba2b5645f79b76355213c100ac053bc643a7f28 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 18:11:24 -0700 Subject: [PATCH] Add a generic /overlay/ host for OBS browser sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlays are small widgets served at /overlay/ and added to OBS (or any other streaming app) as a browser source. The host owns what every widget needs — the ?user= identity, one upstream websocket, the version-manifest reload, a transparent page — and renders the widget registered under . An unknown name, or a missing ?user=, renders a diagnostic that lists the names that work, because a URL is an overlay's only interface. Overlays are left running for hours, so they poll the node's build manifest and reload when it changes: that reaches an operator's browser source without them restarting it. The manifest is now served at GET /api/version (version, buildTime, uuid, Cache-Control: no-store) from the build flags the node already has. Both bundled frontends implement the route, since the node picks between them per request: js/app (what stream.place serves today) and js/web. --- .maestro/logged-out/overlay.yaml | 30 ++++ .../components/overlay/overlay-diagnostic.tsx | 76 ++++++++++ js/app/components/overlay/registry.tsx | 24 +++ js/app/components/overlay/status-overlay.tsx | 138 ++++++++++++++++++ js/app/hooks/useOverlayVersion.ts | 68 +++++++++ js/app/src/linking-config.ts | 2 + js/app/src/navigation-helper.tsx | 1 + js/app/src/navigation-types.ts | 1 + js/app/src/screens/overlay.tsx | 94 ++++++++++++ js/app/src/shell.tsx | 6 + js/docs/src/content/docs/features/overlay.md | 59 ++++++++ js/e2e-web/flows/overlay.spec.ts | 104 +++++++++++++ js/web/e2e/embeds.spec.ts | 44 ++++++ .../components/overlay/overlay-diagnostic.tsx | 30 ++++ js/web/src/components/overlay/registry.tsx | 26 ++++ .../src/components/overlay/status-overlay.tsx | 75 ++++++++++ js/web/src/hooks/use-overlay-version.ts | 70 +++++++++ js/web/src/routeTree.gen.ts | 21 +++ js/web/src/routes/overlay/$name.tsx | 81 ++++++++++ pkg/api/api.go | 1 + pkg/api/version.go | 53 +++++++ pkg/api/version_test.go | 49 +++++++ 22 files changed, 1053 insertions(+) create mode 100644 .maestro/logged-out/overlay.yaml create mode 100644 js/app/components/overlay/overlay-diagnostic.tsx create mode 100644 js/app/components/overlay/registry.tsx create mode 100644 js/app/components/overlay/status-overlay.tsx create mode 100644 js/app/hooks/useOverlayVersion.ts create mode 100644 js/app/src/screens/overlay.tsx create mode 100644 js/docs/src/content/docs/features/overlay.md create mode 100644 js/e2e-web/flows/overlay.spec.ts create mode 100644 js/web/src/components/overlay/overlay-diagnostic.tsx create mode 100644 js/web/src/components/overlay/registry.tsx create mode 100644 js/web/src/components/overlay/status-overlay.tsx create mode 100644 js/web/src/hooks/use-overlay-version.ts create mode 100644 js/web/src/routes/overlay/$name.tsx create mode 100644 pkg/api/version.go create mode 100644 pkg/api/version_test.go diff --git a/.maestro/logged-out/overlay.yaml b/.maestro/logged-out/overlay.yaml new file mode 100644 index 000000000..72dccf0d1 --- /dev/null +++ b/.maestro/logged-out/overlay.yaml @@ -0,0 +1,30 @@ +appId: ${APP_ID} +--- +# Overlays (/overlay/) are the generic OBS browser-source host. The same +# route is reachable in the app as a deep link (://overlay/), +# which is what this flow exercises: the registry lookup, and the URL +# diagnostics an operator gets when the name or ?user= is wrong. It needs no +# node — everything it asserts is decided by the URL alone — so it stays +# logged-out and independent of the harness's stream. +- launchApp +# Wait for the app to render before linking into it, so a cold CI emulator's +# startup cannot swallow the link. +- extendedWaitUntil: + visible: + text: "Home" + timeout: 60000 +- openLink: "${APP_ID}://overlay/not-a-widget?user=${ACCOUNT_HANDLE}" +# Maestro matches text as a full-string regex, hence the trailing wildcard. +- extendedWaitUntil: + visible: + text: "No overlay named.*" + timeout: 60000 +- assertVisible: "Available overlays: status" +# A registered overlay renders its widget instead. The streamer it follows +# comes straight from ?user=, so it is on screen before the node answers. +- openLink: "${APP_ID}://overlay/status?user=${ACCOUNT_HANDLE}" +- extendedWaitUntil: + visible: + text: "${ACCOUNT_HANDLE}" + timeout: 60000 +- assertVisible: "Node" diff --git a/js/app/components/overlay/overlay-diagnostic.tsx b/js/app/components/overlay/overlay-diagnostic.tsx new file mode 100644 index 000000000..2fbd465ad --- /dev/null +++ b/js/app/components/overlay/overlay-diagnostic.tsx @@ -0,0 +1,76 @@ +import { + borderAlphas, + borderRadius, + spacing, + surfaces, + textAlphas, + typeScale, + withAlpha, +} from "@streamplace/components"; +import { StyleSheet, Text, View } from "react-native"; +import { OVERLAYS } from "./registry"; + +/** + * What an operator sees in OBS when the overlay URL is wrong. An overlay is + * addressed only by its URL, so there is no UI to complain: this card has to + * be legible over the scene they are building, and it lists the names that + * would work. + */ +export function OverlayDiagnostic({ + title, + detail, +}: { + title: string; + detail: string; +}) { + return ( + + + {title} + {detail} + + Available overlays: {Object.keys(OVERLAYS).sort().join(", ")} + + + + ); +} + +// Composited over the operator's scene rather than a themed app surface, so +// the card is always the dark scheme (see the design skill's OBS-root case). +const styles = StyleSheet.create({ + root: { + // Absolute fill: the diagnostic renders outside the widget wrapper, and + // has to own the window whatever the navigator's flex layout is. + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: "center", + justifyContent: "center", + padding: spacing[4], + }, + card: { + gap: spacing[2], + maxWidth: 420, + padding: spacing[4], + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: borderAlphas.dark.default, + backgroundColor: withAlpha(surfaces.dark[0], 0.72), + }, + title: { + ...typeScale.base, + fontWeight: "600", + color: textAlphas.dark[1], + }, + detail: { + ...typeScale.sm, + color: textAlphas.dark[2], + }, + names: { + ...typeScale.xs, + color: textAlphas.dark[3], + }, +}); diff --git a/js/app/components/overlay/registry.tsx b/js/app/components/overlay/registry.tsx new file mode 100644 index 000000000..7f9963b03 --- /dev/null +++ b/js/app/components/overlay/registry.tsx @@ -0,0 +1,24 @@ +import type { ComponentType } from "react"; +import StatusOverlay from "./status-overlay"; + +/** Everything the overlay shell hands a widget. */ +export interface OverlayProps { + /** The streamer the overlay follows, from ?user=. */ + user: string; + /** The build (`git describe`) serving this overlay, once the node says. */ + version: string | null; + /** Query options from the overlay URL, including the shell's own keys + * (?user=, ?versionPollMs=). Widgets read their own options by name. */ + params: Record; +} + +/** + * Widgets that can be embedded as an OBS browser source at /overlay/. + * The shell (src/screens/overlay.tsx) owns everything they have in common — + * one upstream websocket per overlay, the version-manifest reload, a + * transparent root, the ?user= identity — so a widget only draws its own + * content and reads the live stream from the context the shell provides. + */ +export const OVERLAYS: Record> = { + status: StatusOverlay, +}; diff --git a/js/app/components/overlay/status-overlay.tsx b/js/app/components/overlay/status-overlay.tsx new file mode 100644 index 000000000..4560ced5d --- /dev/null +++ b/js/app/components/overlay/status-overlay.tsx @@ -0,0 +1,138 @@ +import { + borderAlphas, + borderRadius, + spacing, + statusColors, + surfaces, + tabularNums, + textAlphas, + typeScale, + useLivestreamStore, + withAlpha, +} from "@streamplace/components"; +import { StyleSheet, Text, View } from "react-native"; +import type { OverlayProps } from "./registry"; + +/** + * The starter overlay: what this widget is talking to, and whether the + * upstream websocket is up. It is the overlay an operator points OBS at while + * they are setting a scene up, and it exercises both halves of the shell — + * the websocket (connected / live / viewers) and the version manifest (the + * node build it is showing). + */ +export default function StatusOverlay({ user, version }: OverlayProps) { + const websocketConnected = useLivestreamStore((s) => s.websocketConnected); + const profile = useLivestreamStore((s) => s.profile); + const livestream = useLivestreamStore((s) => s.livestream); + const viewers = useLivestreamStore((s) => s.viewers); + + const handle = profile?.handle ?? user; + + return ( + + + + + + {websocketConnected ? "Connected" : "Connecting"} + + + + + + + {viewers !== null && ( + + )} + + + ); +} + +function Row({ + label, + value, + testID, + tabular = false, +}: { + label: string; + value: string; + testID?: string; + tabular?: boolean; +}) { + return ( + + {label} + + {value} + + + ); +} + +// Composited over the operator's scene rather than a themed app surface, so +// the card is always the dark scheme (see the design skill's OBS-root case). +const styles = StyleSheet.create({ + root: { + flex: 1, + alignItems: "flex-start", + justifyContent: "flex-start", + padding: spacing[4], + }, + card: { + gap: spacing[2], + minWidth: 220, + padding: spacing[4], + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: borderAlphas.dark.default, + backgroundColor: withAlpha(surfaces.dark[0], 0.72), + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: spacing[2], + }, + dot: { + width: spacing[2], + height: spacing[2], + borderRadius: borderRadius.full, + }, + headerText: { + ...typeScale.xs, + fontWeight: "600", + letterSpacing: 1, + textTransform: "uppercase", + color: textAlphas.dark[2], + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + gap: spacing[4], + }, + rowLabel: { + ...typeScale.sm, + color: textAlphas.dark[2], + }, + rowValue: { + ...typeScale.sm, + color: textAlphas.dark[1], + flexShrink: 1, + }, +}); diff --git a/js/app/hooks/useOverlayVersion.ts b/js/app/hooks/useOverlayVersion.ts new file mode 100644 index 000000000..5af4dc17e --- /dev/null +++ b/js/app/hooks/useOverlayVersion.ts @@ -0,0 +1,68 @@ +import { useStreamplaceStore } from "@streamplace/components"; +import { useEffect, useState } from "react"; +import { Platform } from "react-native"; + +/** How often an overlay asks the node whether it has been redeployed. */ +export const OVERLAY_VERSION_POLL_MS = 30_000; + +/** + * Overlays (/overlay/) are OBS browser sources: an operator leaves one + * open for hours, so a deploy has to reach it without them restarting the + * source. Poll the node's build manifest (GET /api/version) and reload the + * page when it differs from the manifest this page booted against — the same + * trick as useFrontDoorReload, keyed on the build instead of branding. The + * manifest also names the build being served, which the overlay displays so + * that "which deploy is this?" is answerable at a glance. + * + * Web only: a native build is replaced by OTA, not by its node, and has no + * page to reload. A failed poll is ignored until the next tick, so a node + * hiccup reloads nobody. + */ +export function useOverlayVersion( + pollMs: number = OVERLAY_VERSION_POLL_MS, +): string | null { + const url = useStreamplaceStore((s) => s.url); + const [version, setVersion] = useState(null); + const period = + Number.isFinite(pollMs) && pollMs > 0 ? pollMs : OVERLAY_VERSION_POLL_MS; + + useEffect(() => { + if (Platform.OS !== "web" || !url) return; + let booted: string | null = null; + let stopped = false; + const check = async () => { + let text: string; + try { + const res = await fetch(`${url}/api/version`, { cache: "no-store" }); + if (!res.ok) return; + text = await res.text(); + } catch { + return; + } + if (stopped) return; + if (booted === null) { + booted = text; + try { + const manifest = JSON.parse(text); + setVersion( + typeof manifest?.version === "string" ? manifest.version : null, + ); + } catch { + setVersion(null); + } + return; + } + // Any difference means a different build is now serving this page's + // URL, and the code in memory is the one it replaced. + if (text !== booted) window.location.reload(); + }; + void check(); + const timer = setInterval(check, period); + return () => { + stopped = true; + clearInterval(timer); + }; + }, [url, period]); + + return version; +} diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts index 965e5b1be..88227c735 100644 --- a/js/app/src/linking-config.ts +++ b/js/app/src/linking-config.ts @@ -141,6 +141,7 @@ export const SCREEN_PATHS = { Embed: "embed/:user", InfoWidgetEmbed: "info-widget", DanmuOBS: "widgets/:user/danmu", + Overlay: "overlay/:name", PopoutStreamMonitor: "widgets/stream-monitor", PopoutInfoWidget: "widgets/info", PopoutMultistream: "widgets/multistream", @@ -255,6 +256,7 @@ export const streamplaceLinkingOptions: LinkingOptions, designed to be embedded in OBS + * as a browser source. It owns what every widget needs — the streamer + * identity from ?user=, the upstream websocket, the version-manifest reload, + * a transparent full-bleed root — and renders the widget registered under + * (see components/overlay/registry.tsx). + */ +export default function OverlayScreen({ route }) { + const setSidebarHidden = useStore((state) => state.setSidebarHidden); + const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); + useEffect(() => { + setSidebarHidden(); + return () => setSidebarUnhidden(); + }, [setSidebarHidden, setSidebarUnhidden]); + + // An overlay is composited over the operator's scene, so the page itself + // must not paint a background. The app sets one on and (OBS's + // own default custom CSS only clears ), so clear both while an + // overlay is on screen. + useEffect(() => { + if (Platform.OS !== "web") return; + const html = document.documentElement; + const body = document.body; + const backgrounds = [ + html.style.backgroundColor, + body.style.backgroundColor, + ]; + html.style.backgroundColor = "transparent"; + body.style.backgroundColor = "transparent"; + return () => { + html.style.backgroundColor = backgrounds[0]; + body.style.backgroundColor = backgrounds[1]; + }; + }, []); + + const name = typeof route.params?.name === "string" ? route.params.name : ""; + // On web an overlay is a URL in someone else's page, so read the query + // string like the other embed screens do; on native the linking config + // lands the query in the route params. + const params: Record = {}; + if (Platform.OS === "web") { + new URLSearchParams(window.location.search).forEach((value, key) => { + params[key] = value; + }); + } else { + for (const [key, value] of Object.entries(route.params ?? {})) { + if (key !== "name" && typeof value === "string") params[key] = value; + } + } + + // Reloading on a new deployment is the shell's job, not the widget's; the + // widget only displays the build it is showing. + const version = useOverlayVersion(Number(params.versionPollMs)); + + const Overlay = OVERLAYS[name]; + if (!Overlay) { + return ( + + ); + } + + const user = params.user; + if (!user) { + return ( + to the URL, e.g. /overlay/${name}?user=your.handle`} + /> + ); + } + + return ( + + {/* Absolute fill, so the widget owns the whole viewport no matter how + the navigator lays its screens out (see the danmu OBS screen). */} + + + + + ); +} diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index 3fa4b9aeb..8d398ff22 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -69,6 +69,7 @@ import LiveDashboard from "src/screens/live-dashboard"; import MobileGoLive from "src/screens/mobile-go-live"; import MobileStream from "src/screens/mobile-stream"; import MultiScreen from "src/screens/multi"; +import OverlayScreen from "src/screens/overlay"; import PopoutInfoWidget from "src/screens/popout-info-widget"; import PopoutLivestream from "src/screens/popout-livestream"; import PopoutMultistream from "src/screens/popout-multistream"; @@ -838,6 +839,11 @@ export default function Shell() { component={DanmuOBSScreen} options={{ headerShown: false }} /> + `, built +to be added to OBS (or any other streaming app) as a **browser source**. They +draw on a transparent background, so they sit over your scene. + +The URL looks like this: + +``` +https://stream.place/overlay/?user= +``` + +- `` picks the widget. Run an overlay with a name that doesn't exist and + it tells you which names are available. +- `user` is the account whose stream the overlay follows: your Bluesky handle + (without the `@`) or your DID. It's how the overlay knows which chat and + livestream to listen to. + +Adding it to OBS: + +1. Add a Browser Source to your scene. +2. Set the URL to `https://stream.place/overlay/status?user=your.handle`. +3. Set width and height to match your canvas (e.g., 1920×1080). +4. Check **Shutdown source when not visible**. + +## Available overlays + +### `status` + +A small card showing what the overlay is connected to: the node's build, the +streamer it follows, whether the upstream websocket is connected, and the +current viewer count. It's the overlay to use while you're setting a scene up, +and it's the quickest way to tell a misconfigured URL from a broken node. + +## How overlays stay current + +Overlays are meant to be left running for hours at a time, so they look after +themselves: + +- **They reload when the node is updated.** Every overlay polls the node's + build manifest (`/api/version`) and reloads itself when the build changes, so + a deploy reaches your running browser source without you restarting it. The + poll interval defaults to 30 seconds; `?versionPollMs=5000` overrides it. +- **They hold one upstream websocket.** Each overlay keeps a live connection to + the node (`/api/websocket/`) and reconnects on its own if it drops, so + widgets follow chat, viewer counts and stream state as they happen. + +## URL parameters + +| Parameter | Default | Meaning | +| --------------- | ------- | ------------------------------------------------- | +| `user` | — | Required. Whose stream to follow (handle or DID). | +| `versionPollMs` | `30000` | How often to check for a new build, in ms. | + +Individual widgets may accept their own parameters on top of these; the +`status` overlay doesn't take any. diff --git a/js/e2e-web/flows/overlay.spec.ts b/js/e2e-web/flows/overlay.spec.ts new file mode 100644 index 000000000..0dfa94d2c --- /dev/null +++ b/js/e2e-web/flows/overlay.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@playwright/test"; + +const SERVER_URL = process.env.SERVER_URL; +const ACCOUNT_HANDLE = process.env.ACCOUNT_HANDLE; + +test.skip(!SERVER_URL || !ACCOUNT_HANDLE, "needs the e2e harness node"); + +// Overlays (/overlay/) are OBS browser sources. These flows cover the +// shell every widget sits in: the ?user= identity, the upstream websocket, +// the build manifest the shell reloads on, and the diagnostics an operator +// gets when the URL is wrong. No login: an overlay is an anonymous page. +test.describe("overlays", () => { + test("status overlay shows the node build, the streamer, and a connected socket", async ({ + page, + }) => { + // What the node says its build is, so the assertion is about the manifest + // actually reaching the widget — not about any particular version string. + const manifest = await fetch(`${SERVER_URL}/api/version`); + expect(manifest.ok).toBe(true); + const body: unknown = await manifest.json(); + if ( + !body || + typeof body !== "object" || + !("version" in body) || + typeof body.version !== "string" + ) { + throw new Error("node served no version manifest"); + } + const version = body.version; + + await page.goto(`/overlay/status?user=${ACCOUNT_HANDLE}`); + + await expect(page.getByTestId("overlay-status")).toBeVisible({ + timeout: 30_000, + }); + // An overlay composites over the operator's scene: the page must not + // paint a background of its own (OBS's default custom CSS only clears + // , and the app paints too). + await expect + .poll(() => + page.evaluate( + () => getComputedStyle(document.documentElement).backgroundColor, + ), + ) + .toBe("rgba(0, 0, 0, 0)"); + await expect(page.getByTestId("overlay-streamer")).toContainText( + ACCOUNT_HANDLE as string, + ); + await expect(page.getByTestId("overlay-version")).toHaveText(version); + // The websocket reports itself connected once the node's messages (the + // looping test stream) start arriving. + await expect(page.getByTestId("overlay-upstream")).toHaveText("Connected", { + timeout: 30_000, + }); + }); + + test("status overlay reloads itself when the build manifest changes", async ({ + page, + }) => { + let version = "v-e2e-1"; + await page.route("**/api/version", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + version, + buildTime: "2026-01-01T00:00:00Z", + uuid: "00000000-0000-7000-8000-000000000000", + }), + }), + ); + + await page.goto(`/overlay/status?user=${ACCOUNT_HANDLE}&versionPollMs=500`); + await expect(page.getByTestId("overlay-version")).toHaveText("v-e2e-1"); + + // A deploy replaces the build behind this URL. The next poll must reload, + // so the page comes back up on the new manifest — the displayed version + // only ever changes on a fresh boot, which is what proves the reload. + version = "v-e2e-2"; + await expect(page.getByTestId("overlay-version")).toHaveText("v-e2e-2", { + timeout: 30_000, + }); + }); + + test("unknown overlay name lists the available ones", async ({ page }) => { + await page.goto(`/overlay/not-a-widget?user=${ACCOUNT_HANDLE}`); + + await expect(page.getByTestId("overlay-diagnostic")).toBeVisible(); + await expect(page.getByTestId("overlay-diagnostic")).toContainText( + 'No overlay named "not-a-widget"', + ); + await expect(page.getByTestId("overlay-diagnostic")).toContainText( + "Available overlays: status", + ); + }); + + test("missing ?user= explains what the URL needs", async ({ page }) => { + await page.goto("/overlay/status"); + + await expect(page.getByTestId("overlay-diagnostic")).toBeVisible(); + await expect(page.getByTestId("overlay-diagnostic")).toContainText( + "Add ?user=", + ); + }); +}); diff --git a/js/web/e2e/embeds.spec.ts b/js/web/e2e/embeds.spec.ts index d2d6991bb..579633ee4 100644 --- a/js/web/e2e/embeds.spec.ts +++ b/js/web/e2e/embeds.spec.ts @@ -29,4 +29,48 @@ test.describe("embeds", () => { await page.waitForLoadState("networkidle"); await expect(page.locator("body")).toBeVisible(); }); + + // Overlays (/overlay/) are the generic OBS widget host. The status + // widget shows the node's build, which the page can only know from the + // build manifest — so a version here proves the manifest reached it. + test("status overlay (/overlay/status)", async ({ page }) => { + await page.goto("/overlay/status?user=test-user"); + // Read the manifest from the page's own origin, so the assertion is about + // this frontend reaching the node, not about any particular version. + const version = await page.evaluate(async () => { + const res = await fetch("/api/version"); + const manifest: unknown = await res.json(); + if ( + manifest && + typeof manifest === "object" && + "version" in manifest && + typeof manifest.version === "string" + ) { + return manifest.version; + } + throw new Error("node served no version manifest"); + }); + await expect(page.getByTestId("overlay-status")).toBeVisible(); + // An overlay composites over the operator's scene: neither nor + // may paint a background of its own. + const backgrounds = await page.evaluate(() => [ + getComputedStyle(document.documentElement).backgroundColor, + getComputedStyle(document.body).backgroundColor, + ]); + expect(backgrounds).toEqual(["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0)"]); + await expect(page.getByTestId("overlay-version")).toHaveText(version); + }); + + test("overlay diagnostics (/overlay/)", async ({ page }) => { + // A URL is an overlay's only interface, so a wrong name and a missing + // ?user= both have to say so on screen. + await page.goto("/overlay/not-a-widget?user=test-user"); + await expect(page.getByTestId("overlay-diagnostic")).toContainText( + 'No overlay named "not-a-widget"', + ); + await page.goto("/overlay/status"); + await expect(page.getByTestId("overlay-diagnostic")).toContainText( + "Add ?user=", + ); + }); }); diff --git a/js/web/src/components/overlay/overlay-diagnostic.tsx b/js/web/src/components/overlay/overlay-diagnostic.tsx new file mode 100644 index 000000000..0c1d4a9af --- /dev/null +++ b/js/web/src/components/overlay/overlay-diagnostic.tsx @@ -0,0 +1,30 @@ +import { OVERLAYS } from "./registry"; + +/** + * What an operator sees in OBS when the overlay URL is wrong. An overlay is + * addressed only by its URL, so there is no UI to complain: this card has to + * be legible over the scene they are building, and it lists the names that + * would work. + */ +export function OverlayDiagnostic({ + title, + detail, +}: { + title: string; + detail: string; +}) { + return ( +
+
+

{title}

+

{detail}

+

+ Available overlays: {Object.keys(OVERLAYS).sort().join(", ")} +

+
+
+ ); +} diff --git a/js/web/src/components/overlay/registry.tsx b/js/web/src/components/overlay/registry.tsx new file mode 100644 index 000000000..cd9872178 --- /dev/null +++ b/js/web/src/components/overlay/registry.tsx @@ -0,0 +1,26 @@ +import type { LivestreamStore } from "@streamplace/core"; +import type { ComponentType } from "react"; +import { StatusOverlay } from "./status-overlay"; + +/** Everything the overlay route hands a widget. */ +export interface OverlayProps { + /** The streamer the overlay follows, from ?user=. */ + user: string; + /** The upstream websocket-backed store for that streamer. */ + store: LivestreamStore; + /** The build (`git describe`) serving this overlay, once the node says. */ + version: string | null; + /** Query options from the overlay URL, including the shell's own keys + * (?user=, ?versionPollMs=). Widgets read their own options by name. */ + params: Record; +} + +/** + * Widgets that can be embedded as an OBS browser source at /overlay/. + * The route (src/routes/overlay/$name.tsx) owns everything they have in + * common — the upstream websocket, the version-manifest reload, a transparent + * root, the ?user= identity — so a widget only draws its own content. + */ +export const OVERLAYS: Record> = { + status: StatusOverlay, +}; diff --git a/js/web/src/components/overlay/status-overlay.tsx b/js/web/src/components/overlay/status-overlay.tsx new file mode 100644 index 000000000..3a668b560 --- /dev/null +++ b/js/web/src/components/overlay/status-overlay.tsx @@ -0,0 +1,75 @@ +import { useStore } from "zustand"; +import type { OverlayProps } from "./registry"; + +/** + * The starter overlay: what this widget is talking to, and whether the + * upstream websocket is up. It is the overlay an operator points OBS at while + * they are setting a scene up, and it exercises both halves of the shell — + * the websocket (connected / live / viewers) and the version manifest (the + * node build it is showing). + */ +export function StatusOverlay({ user, store, version }: OverlayProps) { + const websocketConnected = useStore(store, (s) => s.websocketConnected); + const profile = useStore(store, (s) => s.profile); + const livestream = useStore(store, (s) => s.livestream); + const viewers = useStore(store, (s) => s.viewers); + + return ( +
+
+
+
+ + {websocketConnected ? "Connected" : "Connecting"} + +
+ + + + {viewers !== null && } +
+
+ ); +} + +function Row({ + label, + value, + testId, +}: { + label: string; + value: string; + testId?: string; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/js/web/src/hooks/use-overlay-version.ts b/js/web/src/hooks/use-overlay-version.ts new file mode 100644 index 000000000..701ff5bb5 --- /dev/null +++ b/js/web/src/hooks/use-overlay-version.ts @@ -0,0 +1,70 @@ +import { getStreamplaceUrl } from "@/lib/streamplace-url"; +import { useEffect, useState } from "react"; + +/** How often an overlay asks the node whether it has been redeployed. */ +export const OVERLAY_VERSION_POLL_MS = 30_000; + +/** + * Overlays (/overlay/) are OBS browser sources: an operator leaves one + * open for hours, so a deploy has to reach it without them restarting the + * source. Poll the node's build manifest (GET /api/version) and reload the + * page when it differs from the manifest this page booted against — the same + * trick as useFrontDoorReload, keyed on the build instead of branding. The + * manifest also names the build being served, which the overlay displays so + * that "which deploy is this?" is answerable at a glance. A failed poll is + * ignored until the next tick, so a node hiccup reloads nobody. + * + * ?versionPollMs shortens the interval for tests. + */ +export function useOverlayVersion( + pollMs: number = OVERLAY_VERSION_POLL_MS, +): string | null { + const [version, setVersion] = useState(null); + const period = + Number.isFinite(pollMs) && pollMs > 0 ? pollMs : OVERLAY_VERSION_POLL_MS; + + useEffect(() => { + let url: string; + try { + url = getStreamplaceUrl(); + } catch { + return; + } + let booted: string | null = null; + let stopped = false; + const check = async () => { + let text: string; + try { + const res = await fetch(`${url}/api/version`, { cache: "no-store" }); + if (!res.ok) return; + text = await res.text(); + } catch { + return; + } + if (stopped) return; + if (booted === null) { + booted = text; + try { + const manifest = JSON.parse(text); + setVersion( + typeof manifest?.version === "string" ? manifest.version : null, + ); + } catch { + setVersion(null); + } + return; + } + // Any difference means a different build is now serving this page's + // URL, and the code in memory is the one it replaced. + if (text !== booted) window.location.reload(); + }; + void check(); + const timer = setInterval(check, period); + return () => { + stopped = true; + clearInterval(timer); + }; + }, [period]); + + return version; +} diff --git a/js/web/src/routeTree.gen.ts b/js/web/src/routeTree.gen.ts index 314f0cb4c..bc1edbfe0 100644 --- a/js/web/src/routeTree.gen.ts +++ b/js/web/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as SettingsBackupRouteImport } from './routes/settings/backup' import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced' import { Route as SettingsAccountRouteImport } from './routes/settings/account' import { Route as SettingsAboutRouteImport } from './routes/settings/about' +import { Route as OverlayNameRouteImport } from './routes/overlay/$name' import { Route as DashboardVideosRouteImport } from './routes/dashboard/videos' import { Route as DashboardUploadRouteImport } from './routes/dashboard/upload' import { Route as ChatPopoutUserRouteImport } from './routes/chat-popout.$user' @@ -149,6 +150,11 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({ path: '/about', getParentRoute: () => SettingsRoute, } as any) +const OverlayNameRoute = OverlayNameRouteImport.update({ + id: '/overlay/$name', + path: '/overlay/$name', + getParentRoute: () => rootRouteImport, +} as any) const DashboardVideosRoute = DashboardVideosRouteImport.update({ id: '/videos', path: '/videos', @@ -227,6 +233,7 @@ export interface FileRoutesByFullPath { '/chat-popout/$user': typeof ChatPopoutUserRoute '/dashboard/upload': typeof DashboardUploadRoute '/dashboard/videos': typeof DashboardVideosRoute + '/overlay/$name': typeof OverlayNameRoute '/settings/about': typeof SettingsAboutRoute '/settings/account': typeof SettingsAccountRoute '/settings/advanced': typeof SettingsAdvancedRoute @@ -261,6 +268,7 @@ export interface FileRoutesByTo { '/chat-popout/$user': typeof ChatPopoutUserRoute '/dashboard/upload': typeof DashboardUploadRoute '/dashboard/videos': typeof DashboardVideosRoute + '/overlay/$name': typeof OverlayNameRoute '/settings/about': typeof SettingsAboutRoute '/settings/account': typeof SettingsAccountRoute '/settings/advanced': typeof SettingsAdvancedRoute @@ -298,6 +306,7 @@ export interface FileRoutesById { '/chat-popout/$user': typeof ChatPopoutUserRoute '/dashboard/upload': typeof DashboardUploadRoute '/dashboard/videos': typeof DashboardVideosRoute + '/overlay/$name': typeof OverlayNameRoute '/settings/about': typeof SettingsAboutRoute '/settings/account': typeof SettingsAccountRoute '/settings/advanced': typeof SettingsAdvancedRoute @@ -336,6 +345,7 @@ export interface FileRouteTypes { | '/chat-popout/$user' | '/dashboard/upload' | '/dashboard/videos' + | '/overlay/$name' | '/settings/about' | '/settings/account' | '/settings/advanced' @@ -370,6 +380,7 @@ export interface FileRouteTypes { | '/chat-popout/$user' | '/dashboard/upload' | '/dashboard/videos' + | '/overlay/$name' | '/settings/about' | '/settings/account' | '/settings/advanced' @@ -406,6 +417,7 @@ export interface FileRouteTypes { | '/chat-popout/$user' | '/dashboard/upload' | '/dashboard/videos' + | '/overlay/$name' | '/settings/about' | '/settings/account' | '/settings/advanced' @@ -441,6 +453,7 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRouteWithChildren VideosRoute: typeof VideosRoute ChatPopoutUserRoute: typeof ChatPopoutUserRoute + OverlayNameRoute: typeof OverlayNameRoute UserIndexRoute: typeof UserIndexRoute UserVideoTidRoute: typeof UserVideoTidRoute EmbedDanmuObsUserRoute: typeof EmbedDanmuObsUserRoute @@ -598,6 +611,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAboutRouteImport parentRoute: typeof SettingsRoute } + '/overlay/$name': { + id: '/overlay/$name' + path: '/overlay/$name' + fullPath: '/overlay/$name' + preLoaderRoute: typeof OverlayNameRouteImport + parentRoute: typeof rootRouteImport + } '/dashboard/videos': { id: '/dashboard/videos' path: '/videos' @@ -762,6 +782,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRouteWithChildren, VideosRoute: VideosRoute, ChatPopoutUserRoute: ChatPopoutUserRoute, + OverlayNameRoute: OverlayNameRoute, UserIndexRoute: UserIndexRoute, UserVideoTidRoute: UserVideoTidRoute, EmbedDanmuObsUserRoute: EmbedDanmuObsUserRoute, diff --git a/js/web/src/routes/overlay/$name.tsx b/js/web/src/routes/overlay/$name.tsx new file mode 100644 index 000000000..7fdaa26dc --- /dev/null +++ b/js/web/src/routes/overlay/$name.tsx @@ -0,0 +1,81 @@ +// The generic overlay host: /overlay/, designed to be embedded in OBS +// as a browser source. It owns what every widget needs — the streamer +// identity from ?user=, the upstream websocket, the version-manifest reload, +// a transparent full-bleed root — and renders the widget registered under +// (see @/components/overlay/registry). +import { OverlayDiagnostic } from "@/components/overlay/overlay-diagnostic"; +import { OVERLAYS } from "@/components/overlay/registry"; +import { useLivestreamStore } from "@/hooks/use-livestream-store"; +import { useOverlayVersion } from "@/hooks/use-overlay-version"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect } from "react"; + +export const Route = createFileRoute("/overlay/$name")({ + // Widgets read their own options (and the shell reads ?user= and + // ?versionPollMs), so keep every query parameter rather than a fixed set. + validateSearch: (search: Record): Record => { + const params: Record = {}; + for (const [key, value] of Object.entries(search)) { + if (typeof value === "string") params[key] = value; + } + return params; + }, + component: OverlayHost, +}); + +function OverlayHost() { + const { name } = Route.useParams(); + const params = Route.useSearch(); + const user = params.user ?? ""; + + // An overlay is composited over the operator's scene, so the page itself + // must not paint a background. The web app paints one on (see + // styles.css) and OBS's own default custom CSS only clears , so clear + // both while an overlay is on screen. + useEffect(() => { + const html = document.documentElement; + const body = document.body; + const backgrounds = [ + html.style.backgroundColor, + body.style.backgroundColor, + ]; + html.style.backgroundColor = "transparent"; + body.style.backgroundColor = "transparent"; + return () => { + html.style.backgroundColor = backgrounds[0]; + body.style.backgroundColor = backgrounds[1]; + }; + }, []); + + // Reloading on a new deployment is the shell's job, not the widget's; the + // widget only displays the build it is showing. + const version = useOverlayVersion(Number(params.versionPollMs)); + const { store } = useLivestreamStore(user, user !== ""); + + const Overlay = OVERLAYS[name]; + if (!Overlay) { + return ( + + ); + } + + if (!user) { + return ( + to the URL, e.g. /overlay/${name}?user=your.handle`} + /> + ); + } + + if (!store) { + return
; + } + + return ( + + ); +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 402ab0e2d..ea394a5da 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -213,6 +213,7 @@ func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { addHandle(apiRouter, "DELETE", "/api/webrtc/:stream", a.MistProxyHandler(ctx, "/webrtc/%s")) addFunc(apiRouter, "POST", "/api/segment", a.HandleSegment(ctx)) addFunc(apiRouter, "GET", "/api/healthz", a.HandleHealthz(ctx)) + addFunc(apiRouter, "GET", "/api/version", a.HandleVersion(ctx)) // they're jpegs now addHandle(apiRouter, "GET", "/api/playback/:user/stream.jpg", a.HandleThumbnailPlayback(ctx)) // this one is actually a jpeg (used previously and shouldn't remove for historical reasons) diff --git a/pkg/api/version.go b/pkg/api/version.go new file mode 100644 index 000000000..997777422 --- /dev/null +++ b/pkg/api/version.go @@ -0,0 +1,53 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + apierrors "stream.place/streamplace/pkg/errors" + "stream.place/streamplace/pkg/log" +) + +// VersionManifest is the node's build manifest: which build of Streamplace +// this process is serving. The frontend bundles are embedded in the binary, +// so this one document also identifies the app and web bundles, and the +// widgets they serve (see /overlay/): a page that polls it can tell +// that a new deployment has replaced the one it booted against. +type VersionManifest struct { + // Version is `git describe` for the commit the binary was built from. + Version string `json:"version"` + // BuildTime is that commit's timestamp, RFC3339. + BuildTime string `json:"buildTime"` + // UUID is a stable per-build identifier derived from BuildTime. + UUID string `json:"uuid"` +} + +// HandleVersion serves the node's build manifest. Widgets poll it and reload +// when it changes, so an operator's long-lived OBS browser source picks up a +// deploy without being restarted. +func (a *StreamplaceAPI) HandleVersion(ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + build := a.CLI.Build + if build == nil { + apierrors.WriteHTTPInternalServerError(w, "no build information", nil) + return + } + bs, err := json.Marshal(VersionManifest{ + Version: build.Version, + BuildTime: build.BuildTimeStr(), + UUID: build.UUID, + }) + if err != nil { + apierrors.WriteHTTPInternalServerError(w, "could not marshal version manifest", err) + return + } + w.Header().Set("Content-Type", "application/json") + // The whole point is noticing a change, so a cached response would + // pin every widget to the version it booted with. + w.Header().Set("Cache-Control", "no-store") + if _, err := w.Write(bs); err != nil { + log.Error(ctx, "error writing response", "error", err) + } + } +} diff --git a/pkg/api/version_test.go b/pkg/api/version_test.go new file mode 100644 index 000000000..8df3cf470 --- /dev/null +++ b/pkg/api/version_test.go @@ -0,0 +1,49 @@ +package api + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/config" +) + +// The version manifest's shape is a contract with the overlays that poll it +// (/overlay/): they compare the raw response and reload on any change, +// and they display the version. A renamed field or a cached response breaks +// that silently, so pin both here. +func TestHandleVersion(t *testing.T) { + build := &config.BuildFlags{ + Version: "v1.2.3-abcdef0", + BuildTime: time.Date(2026, 10, 9, 15, 4, 41, 0, time.UTC).Unix(), + UUID: "0199c0de-0000-7000-8000-000000000000", + } + a := StreamplaceAPI{CLI: &config.CLI{Build: build}} + + w := httptest.NewRecorder() + a.HandleVersion(context.Background())(w, httptest.NewRequest("GET", "/api/version", nil)) + + require.Equal(t, 200, w.Code) + require.Equal(t, "application/json", w.Header().Get("Content-Type")) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + + var got VersionManifest + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, VersionManifest{ + Version: "v1.2.3-abcdef0", + BuildTime: "2026-10-09T15:04:41Z", + UUID: "0199c0de-0000-7000-8000-000000000000", + }, got) +} + +func TestHandleVersionWithoutBuildFlags(t *testing.T) { + a := StreamplaceAPI{CLI: &config.CLI{}} + + w := httptest.NewRecorder() + a.HandleVersion(context.Background())(w, httptest.NewRequest("GET", "/api/version", nil)) + + require.Equal(t, 500, w.Code) +} -- 2.51.2