Live video on the AT Protocol
1import { useCallback, useEffect } from "react";
2import storage from "../storage";
3import {
4 getStreamplaceStoreFromContext,
5 useStreamplaceStore,
6} from "./streamplace-store";
7import { usePossiblyUnauthedPDSAgent } from "./xrpc";
8
9export 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
19const 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
28const PropsInHeader = [
29 "siteTitle",
30 "siteDescription",
31 "primaryColor",
32 "accentColor",
33 "defaultStreamer",
34 "mainLogo",
35 "favicon",
36 "sidebarBg",
37 "legalLinks",
38];
39
40function getMetaContent(key: string): BrandingAsset | null {
41 if (typeof window === "undefined" || !window.document) return null;
42 const meta = document.querySelector(`meta[name="internal-brand:${key}`);
43 if (meta && meta.getAttribute("content")) {
44 let content = meta.getAttribute("content");
45 if (content) return JSON.parse(content) as BrandingAsset;
46 }
47
48 return null;
49}
50
51// hook to fetch broadcaster DID (unauthenticated)
52export function useFetchBroadcasterDID() {
53 const streamplaceAgent = usePossiblyUnauthedPDSAgent();
54 const store = getStreamplaceStoreFromContext();
55
56 // prefetch from meta records, if on web
57 useEffect(() => {
58 if (typeof window !== "undefined" && window.document) {
59 try {
60 const metaRecords = PropsInHeader.reduce(
61 (acc, key) => {
62 const meta = document.querySelector(
63 `meta[name="internal-brand:${key}`,
64 );
65 // hrmmmmmmmmmmmm
66 if (meta && meta.getAttribute("content")) {
67 let content = meta.getAttribute("content");
68 if (content) acc[key] = JSON.parse(content) as BrandingAsset;
69 }
70 return acc;
71 },
72 {} as Record<string, BrandingAsset>,
73 );
74
75 console.log("Found meta records for broadcaster DID:", metaRecords);
76 // filter out all non-text values, can get on second fetch?
77 for (const key of Object.keys(metaRecords)) {
78 if (metaRecords[key].mimeType != "text/plain") {
79 delete metaRecords[key];
80 }
81 }
82 } catch (e) {
83 console.warn("Failed to parse broadcaster DID from meta tags", e);
84 }
85 }
86 }, []);
87
88 return useCallback(async () => {
89 try {
90 if (!streamplaceAgent) {
91 throw new Error("Streamplace agent not available");
92 }
93 const result =
94 await streamplaceAgent.place.stream.broadcast.getBroadcaster();
95 store.setState({ broadcasterDID: result.data.broadcaster });
96 if (result.data.server) {
97 store.setState({ serverDID: result.data.server });
98 }
99 if (result.data.admins) {
100 store.setState({ adminDIDs: result.data.admins });
101 }
102 } catch (err) {
103 console.error("Failed to fetch broadcaster DID:", err);
104 }
105 }, [streamplaceAgent, store]);
106}
107
108// hook to fetch branding data from the server
109export function useFetchBranding() {
110 const streamplaceAgent = usePossiblyUnauthedPDSAgent();
111 const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
112 const url = useStreamplaceStore((state) => state.url);
113 const store = getStreamplaceStoreFromContext();
114
115 return useCallback(
116 async ({ force = true } = {}) => {
117 if (!broadcasterDID) return;
118
119 try {
120 store.setState({ brandingLoading: true });
121
122 // check localStorage first
123 const cacheKey = `branding:${broadcasterDID}`;
124 const cached = await storage.getItem(cacheKey);
125 if (!force && cached) {
126 try {
127 const parsed = JSON.parse(cached);
128 // check if cache is less than 1 hour old
129 if (Date.now() - parsed.timestamp < 60 * 60 * 1000) {
130 store.setState({
131 branding: parsed.data,
132 brandingLoading: false,
133 brandingError: null,
134 });
135 return;
136 }
137 } catch (e) {
138 // invalid cache, continue to fetch
139 console.warn("Invalid branding cache, refetching", e);
140 }
141 }
142
143 // fetch branding metadata from server
144 if (!streamplaceAgent) {
145 throw new Error("Streamplace agent not available");
146 }
147 const res = await streamplaceAgent.place.stream.branding.getBranding({
148 broadcaster: broadcasterDID,
149 });
150 const assets = res.data.assets;
151
152 // convert assets array to keyed object and fetch blob data
153 const brandingMap: Record<string, BrandingAsset> = {};
154
155 for (const asset of assets) {
156 brandingMap[asset.key] = { ...asset };
157
158 // if data is already inline (text assets), use it directly
159 if (asset.data) {
160 brandingMap[asset.key].data = asset.data;
161 } else if (asset.url) {
162 // for images, construct full URL and fetch blob
163 const fullUrl = `${url}${asset.url}`;
164 const blobRes = await fetch(fullUrl);
165 const blob = await blobRes.blob();
166 brandingMap[asset.key].data = await blobToBase64(blob);
167 }
168 }
169
170 // cache in localStorage
171 storage.setItem(
172 cacheKey,
173 JSON.stringify({
174 timestamp: Date.now(),
175 data: brandingMap,
176 }),
177 );
178
179 store.setState({
180 branding: brandingMap,
181 brandingLoading: false,
182 brandingError: null,
183 });
184 } catch (err: any) {
185 console.error("Failed to fetch branding:", err);
186 store.setState({
187 brandingLoading: false,
188 brandingError: err.message || "Failed to fetch branding",
189 });
190 }
191 },
192 [broadcasterDID, streamplaceAgent, url, store],
193 );
194}
195
196// hook to get a specific branding asset by key
197export function useBrandingAsset(key: string): BrandingAsset | undefined {
198 return (
199 useStreamplaceStore((state) => state.branding?.[key]) ||
200 getMetaContent(key) ||
201 undefined
202 );
203}
204
205// convenience hook for main logo
206export function useMainLogo(): string | undefined {
207 const asset = useBrandingAsset("mainLogo");
208 return asset?.data;
209}
210
211// convenience hook for favicon
212export function useFavicon(): string | undefined {
213 const asset = useBrandingAsset("favicon");
214 return asset?.data;
215}
216
217// convenience hook for site title
218export function useSiteTitle(): string {
219 const asset = useBrandingAsset("siteTitle");
220 return asset?.data || "My Streamplace Station";
221}
222
223// convenience hook for site description
224export function useSiteDescription(): string {
225 const asset = useBrandingAsset("siteDescription");
226 return asset?.data || "Live streaming platform";
227}
228
229// convenience hook for primary color
230export function usePrimaryColor(): string {
231 const asset = useBrandingAsset("primaryColor");
232 return asset?.data || "#6366f1";
233}
234
235// convenience hook for accent color
236export function useAccentColor(): string {
237 const asset = useBrandingAsset("accentColor");
238 return asset?.data || "#8b5cf6";
239}
240
241// convenience hook for default streamer
242export function useDefaultStreamer(): string | undefined {
243 const asset = useBrandingAsset("defaultStreamer");
244 return asset?.data || undefined;
245}
246
247// convenience hook for sidebar background image
248export function useSidebarBackgroundImage(): BrandingAsset | undefined {
249 return useBrandingAsset("sidebarBackgroundImage");
250}
251
252// convenience hook for legal links
253export function useLegalLinks(): { text: string; url: string }[] {
254 const asset = useBrandingAsset("legalLinks");
255 if (!asset?.data) {
256 return [];
257 }
258 try {
259 return JSON.parse(asset.data);
260 } catch {
261 return [];
262 }
263}
264
265// hook to auto-fetch branding when broadcaster changes
266export function useBrandingAutoFetch() {
267 const fetchBranding = useFetchBranding();
268 const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
269
270 useEffect(() => {
271 if (broadcasterDID) {
272 fetchBranding();
273 }
274 }, [broadcasterDID, fetchBranding]);
275}