diff --git a/js/web/e2e/embeds.spec.ts b/js/web/e2e/embeds.spec.ts index 505cd41ab..d2d6991bb 100644 --- a/js/web/e2e/embeds.spec.ts +++ b/js/web/e2e/embeds.spec.ts @@ -29,102 +29,4 @@ 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: nothing between the - // widget and the page may paint a background, and the page must not wrap - // it in the site's sidebar/header chrome. - const painted = await page.evaluate(() => { - const paintedAncestors: string[] = []; - let el: Element | null = document.querySelector( - '[data-testid="overlay-status"]', - ); - while (el) { - const style = getComputedStyle(el); - if ( - style.backgroundColor !== "rgba(0, 0, 0, 0)" && - style.backgroundColor !== "transparent" - ) { - paintedAncestors.push( - `${el.tagName} background-color ${style.backgroundColor}`, - ); - } - if (style.backgroundImage !== "none") { - paintedAncestors.push( - `${el.tagName} background-image ${style.backgroundImage}`, - ); - } - el = el.parentElement; - } - return paintedAncestors; - }); - expect(painted).toEqual([]); - await expect(page.getByTestId("overlay-version")).toHaveText(version); - }); - - 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", - }), - }), - ); - - // ?versionPollMs is a string on the URL and a number after the router has - // parsed the search; this only reloads if the route passes it through. - await page.goto("/overlay/status?user=test-user&versionPollMs=500"); - await expect(page.getByTestId("overlay-version")).toHaveText("v-e2e-1"); - - version = "v-e2e-2"; - await expect(page.getByTestId("overlay-version")).toHaveText("v-e2e-2", { - timeout: 30_000, - }); - }); - - 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"', - ); - - // A name that exists on Object.prototype must not resolve to a widget. - await page.goto("/overlay/__proto__?user=test-user"); - await expect(page.getByTestId("overlay-diagnostic")).toContainText( - 'No overlay named "__proto__"', - ); - - 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 deleted file mode 100644 index 0c1d4a9af..000000000 --- a/js/web/src/components/overlay/overlay-diagnostic.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 07a2b2651..000000000 --- a/js/web/src/components/overlay/registry.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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, -}; - -/** - * The widget registered under `name`, or undefined. The name comes from the - * URL, so a plain lookup is not enough: `__proto__` and friends resolve to - * something inherited from Object.prototype. - */ -export function overlayFor( - name: string, -): ComponentType | undefined { - return Object.prototype.hasOwnProperty.call(OVERLAYS, name) - ? OVERLAYS[name] - : undefined; -} diff --git a/js/web/src/components/overlay/status-overlay.tsx b/js/web/src/components/overlay/status-overlay.tsx deleted file mode 100644 index 3a668b560..000000000 --- a/js/web/src/components/overlay/status-overlay.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 556118c94..000000000 --- a/js/web/src/hooks/use-overlay-version.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 serving 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. - * - * The manifest comes from the page's own origin, not the configured API node: - * this page's *code* is whatever that origin served, and that is what a reload - * would replace. 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(() => { - if (typeof window === "undefined") return; - const url = window.location.origin; - 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 bc1edbfe0..314f0cb4c 100644 --- a/js/web/src/routeTree.gen.ts +++ b/js/web/src/routeTree.gen.ts @@ -30,7 +30,6 @@ 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' @@ -150,11 +149,6 @@ 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', @@ -233,7 +227,6 @@ 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 @@ -268,7 +261,6 @@ 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 @@ -306,7 +298,6 @@ 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 @@ -345,7 +336,6 @@ export interface FileRouteTypes { | '/chat-popout/$user' | '/dashboard/upload' | '/dashboard/videos' - | '/overlay/$name' | '/settings/about' | '/settings/account' | '/settings/advanced' @@ -380,7 +370,6 @@ export interface FileRouteTypes { | '/chat-popout/$user' | '/dashboard/upload' | '/dashboard/videos' - | '/overlay/$name' | '/settings/about' | '/settings/account' | '/settings/advanced' @@ -417,7 +406,6 @@ export interface FileRouteTypes { | '/chat-popout/$user' | '/dashboard/upload' | '/dashboard/videos' - | '/overlay/$name' | '/settings/about' | '/settings/account' | '/settings/advanced' @@ -453,7 +441,6 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRouteWithChildren VideosRoute: typeof VideosRoute ChatPopoutUserRoute: typeof ChatPopoutUserRoute - OverlayNameRoute: typeof OverlayNameRoute UserIndexRoute: typeof UserIndexRoute UserVideoTidRoute: typeof UserVideoTidRoute EmbedDanmuObsUserRoute: typeof EmbedDanmuObsUserRoute @@ -611,13 +598,6 @@ 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' @@ -782,7 +762,6 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRouteWithChildren, VideosRoute: VideosRoute, ChatPopoutUserRoute: ChatPopoutUserRoute, - OverlayNameRoute: OverlayNameRoute, UserIndexRoute: UserIndexRoute, UserVideoTidRoute: UserVideoTidRoute, EmbedDanmuObsUserRoute: EmbedDanmuObsUserRoute, diff --git a/js/web/src/routes/__root.tsx b/js/web/src/routes/__root.tsx index eefe35381..08ed7b5df 100644 --- a/js/web/src/routes/__root.tsx +++ b/js/web/src/routes/__root.tsx @@ -18,7 +18,7 @@ import { getStoredPreference, syncThemeClass } from "../hooks/use-color-scheme"; import i18next from "../lib/i18n"; /** Routes that should render without sidebar/header chrome. */ -const POPOUT_PREFIXES = ["/chat-popout/", "/embed/", "/overlay/"]; +const POPOUT_PREFIXES = ["/chat-popout/", "/embed/"]; export const Route = createRootRoute({ component: RootLayout, pendingComponent: RouteLoadingSkeleton, diff --git a/js/web/src/routes/overlay/$name.tsx b/js/web/src/routes/overlay/$name.tsx deleted file mode 100644 index 8228ed6f6..000000000 --- a/js/web/src/routes/overlay/$name.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// 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 { overlayFor } 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. - // TanStack's default parser turns numeric-looking values into numbers (and - // "true"/"false" into booleans); widget options are strings, so put them - // back rather than dropping them. - validateSearch: (search: Record): Record => { - const params: Record = {}; - for (const [key, value] of Object.entries(search)) { - if (value === null || typeof value === "object") continue; - params[key] = String(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 = overlayFor(name); - if (!Overlay) { - return ( - - ); - } - - if (!user) { - return ( - to the URL, e.g. /overlay/${name}?user=your.handle`} - /> - ); - } - - if (!store) { - return
; - } - - return ( - - ); -}