···1import { useRoute } from "@react-navigation/native";
2-import {
3- LivestreamProvider,
4- PlayerProvider,
5- zero,
6-} from "@streamplace/components";
7import BentoGrid from "components/live-dashboard/bento-grid";
8import Loading from "components/loading/loading";
9import { VideoElementProvider } from "contexts/VideoElementContext";
···11import { useCallback, useEffect, useState } from "react";
12import { useStore } from "store";
13import { useIsReady, useUserProfile } from "store/hooks";
14-15-const { flex, bg } = zero;
1617export default function LiveDashboard() {
18 const isReady = useIsReady();
···1import { useRoute } from "@react-navigation/native";
2+import { LivestreamProvider, PlayerProvider } from "@streamplace/components";
00003import BentoGrid from "components/live-dashboard/bento-grid";
4import Loading from "components/loading/loading";
5import { VideoElementProvider } from "contexts/VideoElementContext";
···7import { useCallback, useEffect, useState } from "react";
8import { useStore } from "store";
9import { useIsReady, useUserProfile } from "store/hooks";
001011export default function LiveDashboard() {
12 const isReady = useIsReady();
+3-2
js/app/src/screens/mobile-stream.tsx
···2 KeepAwake,
3 LivestreamProvider,
4 PlayerProvider,
05 useLivestreamStore,
6} from "@streamplace/components";
7import { Player } from "components/mobile/player";
8import { PlayerProps } from "components/player/props";
9import { FullscreenProvider } from "contexts/FullscreenContext";
10import useTitle from "hooks/useTitle";
11-import { Platform, Text, View } from "react-native";
12import { queryToProps } from "./util";
1314const isWeb = Platform.OS === "web";
···58}
5960export default function MobileStream({ route }) {
61- const { user, protocol, url } = route.params;
62 let extraProps: Partial<PlayerProps> = {};
63 if (isWeb) {
64 extraProps = queryToProps(new URLSearchParams(window.location.search));
···2 KeepAwake,
3 LivestreamProvider,
4 PlayerProvider,
5+ Text,
6 useLivestreamStore,
7} from "@streamplace/components";
8import { Player } from "components/mobile/player";
9import { PlayerProps } from "components/player/props";
10import { FullscreenProvider } from "contexts/FullscreenContext";
11import useTitle from "hooks/useTitle";
12+import { Platform, View } from "react-native";
13import { queryToProps } from "./util";
1415const isWeb = Platform.OS === "web";
···59}
6061export default function MobileStream({ route }) {
62+ const { user, protocol, url } = route?.params ?? {};
63 let extraProps: Partial<PlayerProps> = {};
64 if (isWeb) {
65 extraProps = queryToProps(new URLSearchParams(window.location.search));
+65
js/components/locales/en-US/settings.ftl
···166go-to-dashboard = Go to Dashboard
167need-setup-live-dashboard = Need to set up streaming first? Visit the live dashboard
168no-languages-found = No languages found
00000000000000000000000000000000000000000000000000000000000000000
···166go-to-dashboard = Go to Dashboard
167need-setup-live-dashboard = Need to set up streaming first? Visit the live dashboard
168no-languages-found = No languages found
169+170+## Branding Administration
171+branding-admin = Branding Administration
172+branding-admin-description = Customize your Streamplace instance. Note that settings may take a few hours to propagate.
173+branding-login-required = Please log in to manage branding
174+branding-configuration = Configuration
175+branding-text-settings = Text Settings
176+branding-colors = Colors
177+branding-legal-links = Legal Links
178+branding-images = Images
179+180+## Branding Fields
181+branding-broadcaster-did = Broadcaster DID
182+branding-broadcaster-did-description = Leave empty to use server default
183+branding-site-title = Site Title
184+branding-site-title-placeholder = Enter new site title
185+branding-site-description = Site Description
186+branding-site-description-placeholder = Enter site description
187+branding-default-streamer = Default Streamer
188+branding-default-streamer-none = None
189+branding-default-streamer-placeholder = did:plc:...
190+branding-clear-default-streamer = Clear Default Streamer
191+branding-primary-color = Primary Color
192+branding-primary-color-placeholder = #6366f1
193+branding-accent-color = Accent Color
194+branding-accent-color-placeholder = #8b5cf6
195+branding-main-logo = Main Logo
196+branding-main-logo-description = SVG, PNG, or JPEG (max 500KB)
197+branding-favicon = Favicon
198+branding-favicon-description = SVG, PNG, or ICO (max 100KB)
199+branding-sidebar-bg = Sidebar Background Image
200+branding-sidebar-bg-description = SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option.
201+branding-current = Current: { $value }
202+branding-dimensions = { $height } x { $width }
203+204+## Branding Actions
205+branding-upload-logo = Upload Logo
206+branding-delete-logo = Delete Logo
207+branding-upload-favicon = Upload Favicon
208+branding-delete-favicon = Delete Favicon
209+branding-upload-background = Upload Background
210+branding-delete-background = Delete Background
211+branding-web-only = Image uploads are only available on web.
212+213+## Branding Legal Links
214+refresh-branding = Refresh branding assets
215+branding-add-legal-link = Add Legal Link
216+branding-edit-legal-link = Edit Legal Link
217+branding-legal-link-text-placeholder = Link text (e.g., Privacy Policy)
218+branding-legal-link-url-placeholder = URL (e.g., https://example.com/privacy)
219+add = Add
220+edit = Edit
221+222+## Branding Toast Messages
223+branding-not-authenticated = Please log in first
224+branding-empty-value = Please enter a value
225+branding-update-success = { $key } updated successfully
226+branding-upload-success = { $key } uploaded successfully
227+branding-delete-success = { $key } deleted successfully
228+branding-upload-failed = Failed to upload
229+branding-delete-failed = Failed to delete
230+branding-not-available = File uploads are only available on web
231+232+## Navigation Categories (About Page)
233+node-legal-documents = Broadcaster-specific Documents
···1// barrel file :)
2export * from "./useAvatars";
3export * from "./useCameraToggle";
04export * from "./useKeyboard";
5export * from "./useKeyboardSlide";
6export * from "./useLivestreamInfo";
···1// barrel file :)
2export * from "./useAvatars";
3export * from "./useCameraToggle";
4+export * from "./useDocumentTitle";
5export * from "./useKeyboard";
6export * from "./useKeyboardSlide";
7export * from "./useLivestreamInfo";
+45
js/components/src/hooks/useDocumentTitle.tsx
···000000000000000000000000000000000000000000000
···1+import { useEffect } from "react";
2+import { Platform } from "react-native";
3+import {
4+ useFavicon,
5+ useSiteDescription,
6+ useSiteTitle,
7+} from "../streamplace-store";
8+9+/**
10+ * Hook to set the document title, description, and favicon on web based on branding.
11+ * No-op on native platforms.
12+ */
13+export function useDocumentTitle() {
14+ const siteTitle = useSiteTitle();
15+ const siteDescription = useSiteDescription();
16+ const favicon = useFavicon();
17+18+ useEffect(() => {
19+ if (Platform.OS === "web" && typeof document !== "undefined") {
20+ // set title
21+ document.title = siteTitle;
22+23+ // set or update meta description
24+ let metaDescription = document.querySelector('meta[name="description"]');
25+ if (!metaDescription) {
26+ metaDescription = document.createElement("meta");
27+ metaDescription.setAttribute("name", "description");
28+ document.head.appendChild(metaDescription);
29+ }
30+ metaDescription.setAttribute("content", siteDescription);
31+32+ // set or update favicon
33+ if (favicon) {
34+ let link: HTMLLinkElement | null =
35+ document.querySelector('link[rel="icon"]');
36+ if (!link) {
37+ link = document.createElement("link");
38+ link.rel = "icon";
39+ document.head.appendChild(link);
40+ }
41+ link.href = favicon;
42+ }
43+ }
44+ }, [siteTitle, siteDescription, favicon]);
45+}
···1+import { useCallback, useEffect } from "react";
2+import storage from "../storage";
3+import {
4+ getStreamplaceStoreFromContext,
5+ useStreamplaceStore,
6+} from "./streamplace-store";
7+import { usePossiblyUnauthedPDSAgent } from "./xrpc";
8+9+export interface BrandingAsset {
10+ key: string;
11+ mimeType: string;
12+ url?: string; // URL for images
13+ data?: string; // inline data for text, or base64 for images
14+ width?: number; // image width in pixels
15+ height?: number; // image height in pixels
16+}
17+18+// helper to convert blob to base64
19+const blobToBase64 = (blob: Blob): Promise<string> => {
20+ return new Promise((resolve, reject) => {
21+ const reader = new FileReader();
22+ reader.onloadend = () => resolve(reader.result as string);
23+ reader.onerror = reject;
24+ reader.readAsDataURL(blob);
25+ });
26+};
27+28+// hook to fetch broadcaster DID (unauthenticated)
29+export function useFetchBroadcasterDID() {
30+ const streamplaceAgent = usePossiblyUnauthedPDSAgent();
31+ const store = getStreamplaceStoreFromContext();
32+33+ return useCallback(async () => {
34+ try {
35+ if (!streamplaceAgent) {
36+ throw new Error("Streamplace agent not available");
37+ }
38+ const result =
39+ await streamplaceAgent.place.stream.broadcast.getBroadcaster();
40+ store.setState({ broadcasterDID: result.data.broadcaster });
41+ if (result.data.server) {
42+ store.setState({ serverDID: result.data.server });
43+ }
44+ if (result.data.admins) {
45+ store.setState({ adminDIDs: result.data.admins });
46+ }
47+ } catch (err) {
48+ console.error("Failed to fetch broadcaster DID:", err);
49+ }
50+ }, [streamplaceAgent, store]);
51+}
52+53+// hook to fetch branding data from the server
54+export function useFetchBranding() {
55+ const streamplaceAgent = usePossiblyUnauthedPDSAgent();
56+ const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
57+ const url = useStreamplaceStore((state) => state.url);
58+ const store = getStreamplaceStoreFromContext();
59+60+ return useCallback(
61+ async ({ force = true } = {}) => {
62+ if (!broadcasterDID) return;
63+64+ try {
65+ store.setState({ brandingLoading: true });
66+67+ // check localStorage first
68+ const cacheKey = `branding:${broadcasterDID}`;
69+ const cached = await storage.getItem(cacheKey);
70+ if (!force && cached) {
71+ try {
72+ const parsed = JSON.parse(cached);
73+ // check if cache is less than 1 hour old
74+ if (Date.now() - parsed.timestamp < 60 * 60 * 1000) {
75+ store.setState({
76+ branding: parsed.data,
77+ brandingLoading: false,
78+ brandingError: null,
79+ });
80+ return;
81+ }
82+ } catch (e) {
83+ // invalid cache, continue to fetch
84+ console.warn("Invalid branding cache, refetching", e);
85+ }
86+ }
87+88+ // fetch branding metadata from server
89+ if (!streamplaceAgent) {
90+ throw new Error("Streamplace agent not available");
91+ }
92+ const res = await streamplaceAgent.place.stream.branding.getBranding({
93+ broadcaster: broadcasterDID,
94+ });
95+ const assets = res.data.assets;
96+97+ // convert assets array to keyed object and fetch blob data
98+ const brandingMap: Record<string, BrandingAsset> = {};
99+100+ for (const asset of assets) {
101+ brandingMap[asset.key] = { ...asset };
102+103+ // if data is already inline (text assets), use it directly
104+ if (asset.data) {
105+ brandingMap[asset.key].data = asset.data;
106+ } else if (asset.url) {
107+ // for images, construct full URL and fetch blob
108+ const fullUrl = `${url}${asset.url}`;
109+ const blobRes = await fetch(fullUrl);
110+ const blob = await blobRes.blob();
111+ brandingMap[asset.key].data = await blobToBase64(blob);
112+ }
113+ }
114+115+ // cache in localStorage
116+ storage.setItem(
117+ cacheKey,
118+ JSON.stringify({
119+ timestamp: Date.now(),
120+ data: brandingMap,
121+ }),
122+ );
123+124+ store.setState({
125+ branding: brandingMap,
126+ brandingLoading: false,
127+ brandingError: null,
128+ });
129+ } catch (err: any) {
130+ console.error("Failed to fetch branding:", err);
131+ store.setState({
132+ brandingLoading: false,
133+ brandingError: err.message || "Failed to fetch branding",
134+ });
135+ }
136+ },
137+ [broadcasterDID, streamplaceAgent, url, store],
138+ );
139+}
140+141+// hook to get a specific branding asset by key
142+export function useBrandingAsset(key: string): BrandingAsset | undefined {
143+ return useStreamplaceStore((state) => state.branding?.[key]);
144+}
145+146+// convenience hook for main logo
147+export function useMainLogo(): string | undefined {
148+ const asset = useBrandingAsset("mainLogo");
149+ return asset?.data;
150+}
151+152+// convenience hook for favicon
153+export function useFavicon(): string | undefined {
154+ const asset = useBrandingAsset("favicon");
155+ return asset?.data;
156+}
157+158+// convenience hook for site title
159+export function useSiteTitle(): string {
160+ const asset = useBrandingAsset("siteTitle");
161+ return asset?.data || "My Streamplace Station";
162+}
163+164+// convenience hook for site description
165+export function useSiteDescription(): string {
166+ const asset = useBrandingAsset("siteDescription");
167+ return asset?.data || "Live streaming platform";
168+}
169+170+// convenience hook for primary color
171+export function usePrimaryColor(): string {
172+ const asset = useBrandingAsset("primaryColor");
173+ return asset?.data || "#6366f1";
174+}
175+176+// convenience hook for accent color
177+export function useAccentColor(): string {
178+ const asset = useBrandingAsset("accentColor");
179+ return asset?.data || "#8b5cf6";
180+}
181+182+// convenience hook for default streamer
183+export function useDefaultStreamer(): string | undefined {
184+ const asset = useBrandingAsset("defaultStreamer");
185+ return asset?.data || undefined;
186+}
187+188+// convenience hook for sidebar background image
189+export function useSidebarBackgroundImage(): BrandingAsset | undefined {
190+ return useBrandingAsset("sidebarBackgroundImage");
191+}
192+193+// convenience hook for legal links
194+export function useLegalLinks(): { text: string; url: string }[] {
195+ const asset = useBrandingAsset("legalLinks");
196+ if (!asset?.data) {
197+ return [];
198+ }
199+ try {
200+ return JSON.parse(asset.data);
201+ } catch {
202+ return [];
203+ }
204+}
205+206+// hook to auto-fetch branding when broadcaster changes
207+export function useBrandingAutoFetch() {
208+ const fetchBranding = useFetchBranding();
209+ const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
210+211+ useEffect(() => {
212+ if (broadcasterDID) {
213+ fetchBranding();
214+ }
215+ }, [broadcasterDID, fetchBranding]);
216+}
+1
js/components/src/streamplace-store/index.tsx
···1export * from "./block";
02export * from "./moderation";
3export * from "./moderator-management";
4export * from "./stream";
···1export * from "./block";
2+export * from "./branding";
3export * from "./moderation";
4export * from "./moderator-management";
5export * from "./stream";
···34SP_HTTPS_ADDR=:443
35SP_SECURE=true
3600037# If you're running Streamplace behind an HTTPS proxy, you'll want
38# SP_SECURE=false
39# SP_BEHIND_HTTPS_PROXY=true
···34SP_HTTPS_ADDR=:443
35SP_SECURE=true
3637+# Set this variable to your did:plc or did:web to have admin access to the node
38+SP_ADMIN_DIDS=did:web:example.com,did:plc:rbvrr34edl5ddpuwcubjiost
39+40# If you're running Streamplace behind an HTTPS proxy, you'll want
41# SP_SECURE=false
42# SP_BEHIND_HTTPS_PROXY=true
···137 WebsocketURL string
138 BehindHTTPSProxy bool
139 SegmentDebugDir string
0140 Syndicate []string
141}
142···235 cli.StringSliceFlag(fs, &cli.Replicators, "replicators", []string{ReplicatorWebsocket}, "list of replication protocols to use (http, iroh)")
236 fs.StringVar(&cli.WebsocketURL, "websocket-url", "", "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)")
237 fs.BoolVar(&cli.BehindHTTPSProxy, "behind-https-proxy", false, "set to true if this node is behind an https proxy and we should report https URLs even though the node isn't serving HTTPS")
0238 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)")
239240 fs.Bool("external-signing", true, "DEPRECATED, does nothing.")
···137 WebsocketURL string
138 BehindHTTPSProxy bool
139 SegmentDebugDir string
140+ AdminDIDs []string
141 Syndicate []string
142}
143···236 cli.StringSliceFlag(fs, &cli.Replicators, "replicators", []string{ReplicatorWebsocket}, "list of replication protocols to use (http, iroh)")
237 fs.StringVar(&cli.WebsocketURL, "websocket-url", "", "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)")
238 fs.BoolVar(&cli.BehindHTTPSProxy, "behind-https-proxy", false, "set to true if this node is behind an https proxy and we should report https URLs even though the node isn't serving HTTPS")
239+ cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations")
240 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)")
241242 fs.Bool("external-signing", true, "DEPRECATED, does nothing.")
···1+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2+3+// Lexicon schema: place.stream.branding.getBranding
4+5+package streamplace
6+7+import (
8+ "context"
9+10+ lexutil "github.com/bluesky-social/indigo/lex/util"
11+)
12+13+// BrandingGetBranding_BrandingAsset is a "brandingAsset" in the place.stream.branding.getBranding schema.
14+type BrandingGetBranding_BrandingAsset struct {
15+ // data: Inline data for text assets
16+ Data *string `json:"data,omitempty" cborgen:"data,omitempty"`
17+ // height: Image height in pixels (optional, for images only)
18+ Height *int64 `json:"height,omitempty" cborgen:"height,omitempty"`
19+ // key: Asset key identifier
20+ Key string `json:"key" cborgen:"key"`
21+ // mimeType: MIME type of the asset
22+ MimeType string `json:"mimeType" cborgen:"mimeType"`
23+ // url: URL to fetch the asset blob (for images)
24+ Url *string `json:"url,omitempty" cborgen:"url,omitempty"`
25+ // width: Image width in pixels (optional, for images only)
26+ Width *int64 `json:"width,omitempty" cborgen:"width,omitempty"`
27+}
28+29+// BrandingGetBranding_Output is the output of a place.stream.branding.getBranding call.
30+type BrandingGetBranding_Output struct {
31+ // assets: List of available branding assets
32+ Assets []*BrandingGetBranding_BrandingAsset `json:"assets" cborgen:"assets"`
33+}
34+35+// BrandingGetBranding calls the XRPC method "place.stream.branding.getBranding".
36+//
37+// broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster.
38+func BrandingGetBranding(ctx context.Context, c lexutil.LexClient, broadcaster string) (*BrandingGetBranding_Output, error) {
39+ var out BrandingGetBranding_Output
40+41+ params := map[string]interface{}{}
42+ if broadcaster != "" {
43+ params["broadcaster"] = broadcaster
44+ }
45+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.branding.getBranding", params, nil, &out); err != nil {
46+ return nil, err
47+ }
48+49+ return &out, nil
50+}
+42
pkg/streamplace/brandingupdateBlob.go
···000000000000000000000000000000000000000000
···1+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2+3+// Lexicon schema: place.stream.branding.updateBlob
4+5+package streamplace
6+7+import (
8+ "context"
9+10+ lexutil "github.com/bluesky-social/indigo/lex/util"
11+)
12+13+// BrandingUpdateBlob_Input is the input argument to a place.stream.branding.updateBlob call.
14+type BrandingUpdateBlob_Input struct {
15+ // broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster.
16+ Broadcaster *string `json:"broadcaster,omitempty" cborgen:"broadcaster,omitempty"`
17+ // data: Base64-encoded blob data
18+ Data string `json:"data" cborgen:"data"`
19+ // height: Image height in pixels (optional, for images only)
20+ Height *int64 `json:"height,omitempty" cborgen:"height,omitempty"`
21+ // key: Branding asset key (mainLogo, favicon, siteTitle, etc.)
22+ Key string `json:"key" cborgen:"key"`
23+ // mimeType: MIME type of the blob (e.g., image/png, text/plain)
24+ MimeType string `json:"mimeType" cborgen:"mimeType"`
25+ // width: Image width in pixels (optional, for images only)
26+ Width *int64 `json:"width,omitempty" cborgen:"width,omitempty"`
27+}
28+29+// BrandingUpdateBlob_Output is the output of a place.stream.branding.updateBlob call.
30+type BrandingUpdateBlob_Output struct {
31+ Success bool `json:"success" cborgen:"success"`
32+}
33+34+// BrandingUpdateBlob calls the XRPC method "place.stream.branding.updateBlob".
35+func BrandingUpdateBlob(ctx context.Context, c lexutil.LexClient, input *BrandingUpdateBlob_Input) (*BrandingUpdateBlob_Output, error) {
36+ var out BrandingUpdateBlob_Output
37+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.branding.updateBlob", nil, input, &out); err != nil {
38+ return nil, err
39+ }
40+41+ return &out, nil
42+}
+2
pkg/streamplace/broadcastgetBroadcaster.go
···1213// BroadcastGetBroadcaster_Output is the output of a place.stream.broadcast.getBroadcaster call.
14type BroadcastGetBroadcaster_Output struct {
0015 // broadcaster: DID of the Streamplace broadcaster to which this server belongs
16 Broadcaster string `json:"broadcaster" cborgen:"broadcaster"`
17 // server: DID of this particular Streamplace server
···1213// BroadcastGetBroadcaster_Output is the output of a place.stream.broadcast.getBroadcaster call.
14type BroadcastGetBroadcaster_Output struct {
15+ // admins: Array of DIDs authorized as admins
16+ Admins []string `json:"admins,omitempty" cborgen:"admins,omitempty"`
17 // broadcaster: DID of the Streamplace broadcaster to which this server belongs
18 Broadcaster string `json:"broadcaster" cborgen:"broadcaster"`
19 // server: DID of this particular Streamplace server