From 7878cd12c547b7b671bba9801481e20ed1166f43 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 16 Sep 2026 02:29:23 -0700 Subject: [PATCH] home: a default video can be the node's front door. Branding key defaultVideo (a video's at:// URI, or its page path /video/) makes the front page that video's page while set, over defaultStreamer: the replay to send everyone to after an event, and clearing it returns the front page to the streamer. The front page's 15-second poll now watches both keys and reloads when either changes, so viewers on the stream page move to the replay within 15 seconds of the operator setting it, and back when it is cleared (an unset vocabulary key reads as empty; a key outside the vocabulary is now a 404 rather than a 500). A Default video block in the branding admin, beside Default Streamer Claude-Session: https://claude.ai/code/session_014aPQ5yqG9QFxKnbwQfCYKa Co-Authored-By: Claude Fable 5.1 --- js/app/components/settings/branding-admin.tsx | 51 +++++++++++++++++ js/app/hooks/useDefaultStreamerReload.ts | 56 +++++++++++++------ js/app/src/screens/home.tsx | 18 ++++-- .../src/streamplace-store/branding.tsx | 35 +++++++++++- js/i18n/locales/en-US/settings.ftl | 4 ++ js/i18n/public/locales/en-US/settings.json | 4 ++ js/web/public/locales/en-US/settings.json | 4 ++ pkg/branding/vocab.go | 1 + pkg/spxrpc/branding_blob_test.go | 42 ++++++++++++++ pkg/spxrpc/place_stream_branding.go | 15 ++++- 10 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 pkg/spxrpc/branding_blob_test.go diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index 89f294f82..bb05a0134 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -106,6 +106,7 @@ export function BrandingAdmin() { const [accentColor, setAccentColor] = useState(""); const [chromeInputs, setChromeInputs] = useState>({}); const [defaultStreamer, setDefaultStreamer] = useState(""); + const [defaultVideo, setDefaultVideo] = useState(""); const [broadcasterDID, setBroadcasterDID] = useState(""); const [uploading, setUploading] = useState(false); const [legalLinkText, setLegalLinkText] = useState(""); @@ -233,6 +234,7 @@ export function BrandingAdmin() { const currentBgLight = useBrandingAsset("backgroundColorLight"); const currentFgLight = useBrandingAsset("foregroundColorLight"); const currentDefaultStreamer = useBrandingAsset("defaultStreamer"); + const currentDefaultVideo = useBrandingAsset("defaultVideo"); const currentLogo = useBrandingAsset("mainLogo"); const currentFavicon = useBrandingAsset("favicon"); const currentSidebarBg = useSidebarBackgroundImage(); @@ -366,6 +368,9 @@ export function BrandingAdmin() { case "defaultStreamer": setDefaultStreamer(""); break; + case "defaultVideo": + setDefaultVideo(""); + break; } // reload branding @@ -2011,6 +2016,52 @@ export function BrandingAdmin() { + + + + + + {t("branding-default-video")} + + + {t("branding-current", { + value: + currentDefaultVideo?.data || + t("branding-default-streamer-none"), + })} + + + {t("branding-default-video-description")} + + + + + + + + + + + {t("branding-colors")} diff --git a/js/app/hooks/useDefaultStreamerReload.ts b/js/app/hooks/useDefaultStreamerReload.ts index 125fb08a1..605190484 100644 --- a/js/app/hooks/useDefaultStreamerReload.ts +++ b/js/app/hooks/useDefaultStreamerReload.ts @@ -3,39 +3,59 @@ import { useEffect } from "react"; import { Platform } from "react-native"; const POLL_MS = 15_000; +const KEYS = ["defaultStreamer", "defaultVideo"] as const; /** - * Single-user node: the front door is whoever the branding key - * defaultStreamer names. Changing that key is how an operator sends - * every viewer to a different stream, so while the front door is showing - * a streamer the page asks the node every 15 seconds which streamer that - * is now and reloads itself when the answer changes. Web only, and only a - * successful answer counts: a node hiccup must not reload everyone. + * The node's front door follows two branding keys: defaultStreamer (a + * stream page) and defaultVideo (a video page, which wins while set). + * Changing either is how an operator sends every viewer somewhere else, so + * while the front page is showing one of them it asks the node every 15 + * seconds what both keys say now (two small text reads) and reloads itself + * when either differs from what it started with. Web only. A 404 means the + * key is unset (that is a change too: a cleared defaultVideo returns the + * front door to the streamer); any other failure is ignored until the next + * tick, so a node hiccup reloads nobody. */ -export function useDefaultStreamerReload(defaultStreamer: string | undefined) { +export function useFrontDoorReload( + defaultStreamer: string | undefined, + defaultVideo: string | undefined, +) { const url = useStreamplaceStore((s) => s.url); const broadcasterDID = useStreamplaceStore((s) => s.broadcasterDID); useEffect(() => { - if (Platform.OS !== "web" || !defaultStreamer || !broadcasterDID) return; - const started = defaultStreamer.trim(); - const endpoint = `${url ?? ""}/xrpc/place.stream.branding.getBlob?key=defaultStreamer&broadcaster=${encodeURIComponent(broadcasterDID)}`; + if (Platform.OS !== "web" || !broadcasterDID) return; + if (!defaultStreamer && !defaultVideo) return; + const started: Record = { + defaultStreamer: (defaultStreamer ?? "").trim(), + defaultVideo: (defaultVideo ?? "").trim(), + }; + const endpoint = (key: string) => + `${url ?? ""}/xrpc/place.stream.branding.getBlob?key=${key}&broadcaster=${encodeURIComponent(broadcasterDID)}`; let stopped = false; - const check = async () => { + // The key's value now: "" when unset (404), undefined when unknown. + const read = async (key: string): Promise => { try { - const res = await fetch(endpoint, { cache: "no-store" }); - if (!res.ok) return; - const now = (await res.text()).trim(); - if (stopped || !now || now === started) return; - window.location.reload(); + const res = await fetch(endpoint(key), { cache: "no-store" }); + if (res.status === 404) return ""; + if (!res.ok) return undefined; + return (await res.text()).trim(); } catch { - // offline or a flaky node: try again next tick + return undefined; } }; + const check = async () => { + const now = await Promise.all(KEYS.map(read)); + if (stopped) return; + const changed = KEYS.some( + (key, i) => now[i] !== undefined && now[i] !== started[key], + ); + if (changed) window.location.reload(); + }; const timer = setInterval(check, POLL_MS); return () => { stopped = true; clearInterval(timer); }; - }, [defaultStreamer, url, broadcasterDID]); + }, [defaultStreamer, defaultVideo, url, broadcasterDID]); } diff --git a/js/app/src/screens/home.tsx b/js/app/src/screens/home.tsx index e6db55979..8a6c59216 100644 --- a/js/app/src/screens/home.tsx +++ b/js/app/src/screens/home.tsx @@ -2,7 +2,9 @@ import { ACTIVITY_LABEL_DISPLAY, Skeleton, Text, + useBrandingAsset, useDefaultStreamer, + useDefaultVideo, useStreamplaceStore, useTheme, zero, @@ -17,12 +19,13 @@ import LiveDot from "components/home/live-dot"; import PullToRefreshScrollView from "components/pull-to-refresh"; import { Image } from "expo-image"; import useAvatars from "hooks/useAvatars"; -import { useDefaultStreamerReload } from "hooks/useDefaultStreamerReload"; +import { useFrontDoorReload } from "hooks/useDefaultStreamerReload"; import { useEffect, useState } from "react"; import { Platform, View, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { place } from "streamplace"; import MobileStream from "./mobile-stream"; +import VideoScreen from "./video"; function getStreamActivity( record: place.stream.livestream.Main, @@ -234,9 +237,13 @@ export default function HomeScreen({ // render: the early returns below come and go with the live-users fetch, // and a hook after them would change the hook count between renders.) const defaultStreamer = useDefaultStreamer(); - // ...and follows it: an operator who points the node at another - // streamer moves everyone on this page there. - useDefaultStreamerReload(defaultStreamer); + // A default video (branding key defaultVideo) is the front door while + // set, over the streamer: a replay to send everyone to after an event. + const defaultVideo = useDefaultVideo(); + const defaultVideoRaw = useBrandingAsset("defaultVideo")?.data; + // ...and the page follows both: an operator who points the node at + // another streamer or a video moves everyone on this page there. + useFrontDoorReload(defaultStreamer, defaultVideoRaw); useEffect(() => { if (!liveUsersLoading) { @@ -244,6 +251,9 @@ export default function HomeScreen({ } }, [liveUsersLoading]); + if (defaultVideo) { + return ; + } if (defaultStreamer) { return ; } diff --git a/js/components/src/streamplace-store/branding.tsx b/js/components/src/streamplace-store/branding.tsx index 411915921..654853b99 100644 --- a/js/components/src/streamplace-store/branding.tsx +++ b/js/components/src/streamplace-store/branding.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { place } from "streamplace"; import storage from "../storage"; import { @@ -420,6 +420,39 @@ export function useDefaultStreamer(): string | undefined { return asset?.data || undefined; } +/** + * The default video (branding key defaultVideo), parsed to the video page's + * route params: the node's front door while set, over defaultStreamer. + */ +export function useDefaultVideo(): { user: string; tid: string } | undefined { + const asset = useBrandingAsset("defaultVideo"); + return useMemo(() => parseDefaultVideo(asset?.data), [asset?.data]); +} + +/** + * Accepts a video's at:// URI (at://did/place.stream.video/rkey) or its page + * path (/video/, with or without a leading slash or a + * full URL around it). Undefined for anything else. + */ +export function parseDefaultVideo( + value?: string, +): { user: string; tid: string } | undefined { + if (!value) return undefined; + let s = value.trim(); + const at = s.match(/^at:\/\/([^/]+)\/place\.stream\.video\/([^/?#]+)$/); + if (at) return { user: at[1], tid: at[2] }; + if (/^https?:\/\//.test(s)) { + try { + s = new URL(s).pathname; + } catch { + return undefined; + } + } + const path = s.match(/^\/?([^/]+)\/video\/([^/?#]+)\/?$/); + if (path) return { user: path[1], tid: path[2] }; + return undefined; +} + // convenience hook for sidebar background image export function useSidebarBackgroundImage(): BrandingAsset | undefined { return useBrandingAsset("sidebarBackgroundImage"); diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl index 4693ebd67..b2ec5bd85 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -342,6 +342,10 @@ branding-login-placeholder-description = The example handle shown in the login f branding-default-streamer = Default Streamer branding-default-streamer-none = None branding-default-streamer-placeholder = did:plc:... +branding-default-video = Default video +branding-default-video-description = While set, the front page shows this video instead of the default streamer's stream: the replay to send everyone to after an event. Its at:// URI, or its page path (handle/video/rkey). Clear it to return the front page to the streamer. +branding-default-video-placeholder = at://did:plc:.../place.stream.video/... or handle/video/rkey +branding-clear-default-video = Clear default video branding-clear-default-streamer = Clear Default Streamer branding-primary-color = Primary Color branding-primary-color-placeholder = #6366f1 diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 5e4b5e20a..6fa4db01b 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -282,6 +282,10 @@ "branding-default-streamer": "Default Streamer", "branding-default-streamer-none": "None", "branding-default-streamer-placeholder": "did:plc:...", + "branding-default-video": "Default video", + "branding-default-video-description": "While set, the front page shows this video instead of the default streamer's stream: the replay to send everyone to after an event. Its at:// URI, or its page path (handle/video/rkey). Clear it to return the front page to the streamer.", + "branding-default-video-placeholder": "at://did:plc:.../place.stream.video/... or handle/video/rkey", + "branding-clear-default-video": "Clear default video", "branding-clear-default-streamer": "Clear Default Streamer", "branding-primary-color": "Primary Color", "branding-primary-color-placeholder": "#6366f1", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 5e4b5e20a..6fa4db01b 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -282,6 +282,10 @@ "branding-default-streamer": "Default Streamer", "branding-default-streamer-none": "None", "branding-default-streamer-placeholder": "did:plc:...", + "branding-default-video": "Default video", + "branding-default-video-description": "While set, the front page shows this video instead of the default streamer's stream: the replay to send everyone to after an event. Its at:// URI, or its page path (handle/video/rkey). Clear it to return the front page to the streamer.", + "branding-default-video-placeholder": "at://did:plc:.../place.stream.video/... or handle/video/rkey", + "branding-clear-default-video": "Clear default video", "branding-clear-default-streamer": "Clear Default Streamer", "branding-primary-color": "Primary Color", "branding-primary-color-placeholder": "#6366f1", diff --git a/pkg/branding/vocab.go b/pkg/branding/vocab.go index 463e9520e..4354b5d86 100644 --- a/pkg/branding/vocab.go +++ b/pkg/branding/vocab.go @@ -73,6 +73,7 @@ var Specs = []Spec{ {Key: "networkProfileUrl", Kind: KindText, MaxSize: textMax, Doc: "Where a chat user's profile link goes, with {handle} and {did} placeholders; default https://bsky.app/profile/{handle}."}, {Key: "loginPlaceholder", Kind: KindText, MaxSize: textMax, Doc: "Example handle shown in the login form's empty handle field."}, {Key: "defaultStreamer", Kind: KindText, MaxSize: textMax, Doc: "Handle or DID whose stream page is the node's front door (single-user nodes)."}, + {Key: "defaultVideo", Kind: KindText, MaxSize: textMax, Doc: "A video whose page is the node's front door while set, over defaultStreamer: its at:// URI (at://did/place.stream.video/rkey) or its path (/video/). Clear it to return the front door to the streamer."}, {Key: "primaryColor", Kind: KindColor, Default: "#6366f1", MaxSize: textMax, Doc: "Buttons, links, focus rings."}, {Key: "accentColor", Kind: KindColor, Default: "#8b5cf6", MaxSize: textMax, Doc: "Secondary surfaces and highlights."}, {Key: "accentColorLight", Kind: KindColor, MaxSize: textMax, Doc: "accentColor override for the light scheme."}, diff --git a/pkg/spxrpc/branding_blob_test.go b/pkg/spxrpc/branding_blob_test.go new file mode 100644 index 000000000..416d81157 --- /dev/null +++ b/pkg/spxrpc/branding_blob_test.go @@ -0,0 +1,42 @@ +package spxrpc + +import ( + "context" + "errors" + "io" + "net/http" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/config" + "stream.place/streamplace/pkg/statedb" +) + +// An unset text key from the vocabulary reads as its default (empty when it +// has none), so a client polling it (the front door's defaultVideo) sees "" +// for "cleared"; a key outside the vocabulary is a 404, not a 500. +func TestGetBlobUnsetKey(t *testing.T) { + ctx := context.Background() + sdb, err := statedb.MakeDB(ctx, &config.CLI{DBURL: ":memory:"}, nil, nil) + require.NoError(t, err) + s := &Server{cli: &config.CLI{BroadcasterHost: "example.com"}, statefulDB: sdb} + + r, err := s.handlePlaceStreamBrandingGetBlob(ctx, "did:web:example.com", "defaultVideo") + require.NoError(t, err, "a vocabulary key with no value reads as empty") + b, err := io.ReadAll(r) + require.NoError(t, err) + require.Empty(t, b) + + _, err = s.handlePlaceStreamBrandingGetBlob(ctx, "did:web:example.com", "noSuchKey") + var he *echo.HTTPError + require.True(t, errors.As(err, &he), "got: %v", err) + require.Equal(t, http.StatusNotFound, he.Code) + + require.NoError(t, sdb.PutBrandingBlob("did:web:example.com", "defaultVideo", "text/plain", []byte("at://did:plc:x/place.stream.video/abc"), nil, nil)) + r, err = s.handlePlaceStreamBrandingGetBlob(ctx, "did:web:example.com", "defaultVideo") + require.NoError(t, err) + b, err = io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, "at://did:plc:x/place.stream.video/abc", string(b)) +} diff --git a/pkg/spxrpc/place_stream_branding.go b/pkg/spxrpc/place_stream_branding.go index e1891c8da..21db053a2 100644 --- a/pkg/spxrpc/place_stream_branding.go +++ b/pkg/spxrpc/place_stream_branding.go @@ -5,6 +5,7 @@ import ( "context" _ "embed" "encoding/base64" + "errors" "fmt" "io" "net/http" @@ -72,7 +73,7 @@ func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) if def, ok := defaultBrandingAssets[key]; ok { return def.data, def.mime, nil, nil, nil } - return nil, "", nil, nil, fmt.Errorf("unknown branding key: %s", key) + return nil, "", nil, nil, fmt.Errorf("%w: %s", ErrBrandingKeyUnset, key) } if err != nil { return nil, "", nil, nil, fmt.Errorf("error fetching branding blob: %w", err) @@ -80,8 +81,18 @@ func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) return blob.Data, blob.MimeType, blob.Width, blob.Height, nil } +// ErrBrandingKeyUnset is returned for a branding key with neither a stored +// value nor a built-in default. +var ErrBrandingKeyUnset = errors.New("branding key is not set") + func (s *Server) handlePlaceStreamBrandingGetBlob(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { - return s.HandlePlaceStreamBrandingGetBlobDirect(ctx, broadcasterDID, key) + r, err := s.HandlePlaceStreamBrandingGetBlobDirect(ctx, broadcasterDID, key) + if errors.Is(err, ErrBrandingKeyUnset) { + // 404, not 500: a client polling a key (the front door's + // defaultVideo) must tell "cleared" from "the node is down". + return nil, echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + return r, err } // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls -- knotmirror 0.0.1