Live video on the AT Protocol

Include text branding data inline and add broadcaster DID field to admin page.

authored by

Natalie Bridgers and committed by
Natalie B.
6938d016 c0a8f46e

+407 -49
+8 -10
js/components/src/streamplace-store/branding.tsx
··· 9 9 export interface BrandingAsset { 10 10 key: string; 11 11 mimeType: string; 12 - url: string; 13 - data?: string; // base64 or text content 12 + url?: string; // URL for images 13 + data?: string; // inline data for text, or base64 for images 14 14 } 15 15 16 16 // helper to convert blob to base64 ··· 94 94 for (const asset of assets) { 95 95 brandingMap[asset.key] = { ...asset }; 96 96 97 - // construct full URL 98 - const fullUrl = `${url}${asset.url}`; 99 - 100 - // fetch blob data for images/text 101 - if (asset.mimeType.startsWith("text/")) { 102 - const textRes = await fetch(fullUrl); 103 - brandingMap[asset.key].data = await textRes.text(); 104 - } else if (asset.mimeType.startsWith("image/")) { 97 + // if data is already inline (text assets), use it directly 98 + if (asset.data) { 99 + brandingMap[asset.key].data = asset.data; 100 + } else if (asset.url) { 101 + // for images, construct full URL and fetch blob 102 + const fullUrl = `${url}${asset.url}`; 105 103 const blobRes = await fetch(fullUrl); 106 104 const blob = await blobRes.blob(); 107 105 brandingMap[asset.key].data = await blobToBase64(blob);
+75
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getblob.md
··· 1 + --- 2 + title: place.stream.branding.getBlob 3 + description: Reference for the place.stream.branding.getBlob lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `query` 15 + 16 + Get a specific branding asset blob by key. 17 + 18 + **Parameters:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ------------- | -------- | ----- | ------------------------------------------------------------------------------- | ------------- | 22 + | `key` | `string` | ✅ | Branding asset key (mainLogo, favicon, siteTitle, etc.) | | 23 + | `broadcaster` | `string` | ❌ | DID of the broadcaster. If not provided, uses the server's default broadcaster. | Format: `did` | 24 + 25 + **Output:** 26 + 27 + - **Encoding:** `*/*` 28 + - **Description:** Raw blob data with appropriate content-type 29 + - **Schema:** 30 + 31 + _Schema not defined._ **Possible Errors:** 32 + 33 + - `BrandingNotFound`: The requested branding asset does not exist 34 + 35 + --- 36 + 37 + ## Lexicon Source 38 + 39 + ```json 40 + { 41 + "lexicon": 1, 42 + "id": "place.stream.branding.getBlob", 43 + "defs": { 44 + "main": { 45 + "type": "query", 46 + "description": "Get a specific branding asset blob by key.", 47 + "parameters": { 48 + "type": "params", 49 + "required": ["key"], 50 + "properties": { 51 + "key": { 52 + "type": "string", 53 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 54 + }, 55 + "broadcaster": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 59 + } 60 + } 61 + }, 62 + "output": { 63 + "encoding": "*/*", 64 + "description": "Raw blob data with appropriate content-type" 65 + }, 66 + "errors": [ 67 + { 68 + "name": "BrandingNotFound", 69 + "description": "The requested branding asset does not exist" 70 + } 71 + ] 72 + } 73 + } 74 + } 75 + ```
+118
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getbranding.md
··· 1 + --- 2 + title: place.stream.branding.getBranding 3 + description: Reference for the place.stream.branding.getBranding lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `query` 15 + 16 + Get all branding configuration for the broadcaster. 17 + 18 + **Parameters:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ------------- | -------- | ----- | ------------------------------------------------------------------------------- | ------------- | 22 + | `broadcaster` | `string` | ❌ | DID of the broadcaster. If not provided, uses the server's default broadcaster. | Format: `did` | 23 + 24 + **Output:** 25 + 26 + - **Encoding:** `application/json` 27 + - **Schema:** 28 + 29 + **Schema Type:** `object` 30 + 31 + | Name | Type | Req'd | Description | Constraints | 32 + | -------- | ------------------------------------------- | ----- | --------------------------------- | ----------- | 33 + | `assets` | Array of [`#brandingAsset`](#brandingasset) | ✅ | List of available branding assets | | 34 + 35 + --- 36 + 37 + <a name="brandingasset"></a> 38 + 39 + ### `brandingAsset` 40 + 41 + **Type:** `object` 42 + 43 + **Properties:** 44 + 45 + | Name | Type | Req'd | Description | Constraints | 46 + | ---------- | -------- | ----- | ---------------------------------------- | ----------- | 47 + | `key` | `string` | ✅ | Asset key identifier | | 48 + | `mimeType` | `string` | ✅ | MIME type of the asset | | 49 + | `url` | `string` | ❌ | URL to fetch the asset blob (for images) | | 50 + | `data` | `string` | ❌ | Inline data for text assets | | 51 + 52 + --- 53 + 54 + ## Lexicon Source 55 + 56 + ```json 57 + { 58 + "lexicon": 1, 59 + "id": "place.stream.branding.getBranding", 60 + "defs": { 61 + "main": { 62 + "type": "query", 63 + "description": "Get all branding configuration for the broadcaster.", 64 + "parameters": { 65 + "type": "params", 66 + "required": [], 67 + "properties": { 68 + "broadcaster": { 69 + "type": "string", 70 + "format": "did", 71 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 72 + } 73 + } 74 + }, 75 + "output": { 76 + "encoding": "application/json", 77 + "schema": { 78 + "type": "object", 79 + "required": ["assets"], 80 + "properties": { 81 + "assets": { 82 + "type": "array", 83 + "description": "List of available branding assets", 84 + "items": { 85 + "type": "ref", 86 + "ref": "#brandingAsset" 87 + } 88 + } 89 + } 90 + } 91 + }, 92 + "errors": [] 93 + }, 94 + "brandingAsset": { 95 + "type": "object", 96 + "required": ["key", "mimeType"], 97 + "properties": { 98 + "key": { 99 + "type": "string", 100 + "description": "Asset key identifier" 101 + }, 102 + "mimeType": { 103 + "type": "string", 104 + "description": "MIME type of the asset" 105 + }, 106 + "url": { 107 + "type": "string", 108 + "description": "URL to fetch the asset blob (for images)" 109 + }, 110 + "data": { 111 + "type": "string", 112 + "description": "Inline data for text assets" 113 + } 114 + } 115 + } 116 + } 117 + } 118 + ```
+128
js/docs/src/content/docs/lex-reference/openapi.json
··· 891 891 } 892 892 } 893 893 }, 894 + "/xrpc/place.stream.branding.getBlob": { 895 + "get": { 896 + "summary": "Get a specific branding asset blob by key.", 897 + "operationId": "place.stream.branding.getBlob", 898 + "tags": ["place.stream.branding"], 899 + "responses": { 900 + "200": { 901 + "description": "Raw blob data with appropriate content-type", 902 + "content": { 903 + "*/*": { 904 + "schema": {} 905 + } 906 + } 907 + }, 908 + "400": { 909 + "description": "Bad Request", 910 + "content": { 911 + "application/json": { 912 + "schema": { 913 + "type": "object", 914 + "required": ["error", "message"], 915 + "properties": { 916 + "error": { 917 + "type": "string", 918 + "oneOf": [ 919 + { 920 + "const": "BrandingNotFound" 921 + } 922 + ] 923 + }, 924 + "message": { 925 + "type": "string" 926 + } 927 + } 928 + } 929 + } 930 + } 931 + } 932 + }, 933 + "parameters": [ 934 + { 935 + "name": "key", 936 + "in": "query", 937 + "required": true, 938 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)", 939 + "schema": { 940 + "type": "string", 941 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 942 + } 943 + }, 944 + { 945 + "name": "broadcaster", 946 + "in": "query", 947 + "required": false, 948 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 949 + "schema": { 950 + "type": "string", 951 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 952 + "format": "did" 953 + } 954 + } 955 + ] 956 + } 957 + }, 958 + "/xrpc/place.stream.branding.getBranding": { 959 + "get": { 960 + "summary": "Get all branding configuration for the broadcaster.", 961 + "operationId": "place.stream.branding.getBranding", 962 + "tags": ["place.stream.branding"], 963 + "responses": { 964 + "200": { 965 + "description": "Success", 966 + "content": { 967 + "application/json": { 968 + "schema": { 969 + "type": "object", 970 + "properties": { 971 + "assets": { 972 + "type": "array", 973 + "description": "List of available branding assets", 974 + "items": { 975 + "$ref": "#/components/schemas/place.stream.branding.getBranding_brandingAsset" 976 + } 977 + } 978 + }, 979 + "required": ["assets"] 980 + } 981 + } 982 + } 983 + } 984 + }, 985 + "parameters": [ 986 + { 987 + "name": "broadcaster", 988 + "in": "query", 989 + "required": false, 990 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 991 + "schema": { 992 + "type": "string", 993 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 994 + "format": "did" 995 + } 996 + } 997 + ] 998 + } 999 + }, 894 1000 "/xrpc/com.atproto.sync.getRecord": { 895 1001 "get": { 896 1002 "summary": "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", ··· 2307 2413 } 2308 2414 }, 2309 2415 "required": ["uri", "cid"] 2416 + }, 2417 + "place.stream.branding.getBranding_brandingAsset": { 2418 + "type": "object", 2419 + "properties": { 2420 + "key": { 2421 + "type": "string", 2422 + "description": "Asset key identifier" 2423 + }, 2424 + "mimeType": { 2425 + "type": "string", 2426 + "description": "MIME type of the asset" 2427 + }, 2428 + "url": { 2429 + "type": "string", 2430 + "description": "URL to fetch the asset blob (for images)" 2431 + }, 2432 + "data": { 2433 + "type": "string", 2434 + "description": "Inline data for text assets" 2435 + } 2436 + }, 2437 + "required": ["key", "mimeType"] 2310 2438 }, 2311 2439 "com.atproto.sync.listRepos_repo": { 2312 2440 "type": "object",
+6 -2
lexicons/place/stream/branding/getBranding.json
··· 37 37 }, 38 38 "brandingAsset": { 39 39 "type": "object", 40 - "required": ["key", "mimeType", "url"], 40 + "required": ["key", "mimeType"], 41 41 "properties": { 42 42 "key": { 43 43 "type": "string", ··· 49 49 }, 50 50 "url": { 51 51 "type": "string", 52 - "description": "URL to fetch the asset blob" 52 + "description": "URL to fetch the asset blob (for images)" 53 + }, 54 + "data": { 55 + "type": "string", 56 + "description": "Inline data for text assets" 53 57 } 54 58 } 55 59 }
+54 -28
pkg/api/branding-admin.html
··· 167 167 <h1>Branding Administration</h1> 168 168 <p class="subtitle">Customize your Streamplace instance</p> 169 169 170 + <!-- Broadcaster DID --> 171 + <div class="section"> 172 + <div class="section-title">Broadcaster DID</div> 173 + <div class="section-subtitle">Leave empty to use server default</div> 174 + <div class="input-group"> 175 + <input 176 + type="text" 177 + id="broadcasterDIDInput" 178 + placeholder="did:plc:..." 179 + value="" 180 + /> 181 + </div> 182 + </div> 183 + 170 184 <!-- Site Title --> 171 185 <div class="section"> 172 186 <div class="section-title">Site Title</div> ··· 293 307 <script> 294 308 let currentBranding = {}; 295 309 310 + function getBroadcasterDID() { 311 + return document.getElementById("broadcasterDIDInput").value.trim(); 312 + } 313 + 314 + function getBroadcasterParam() { 315 + const did = getBroadcasterDID(); 316 + return did ? `?broadcaster=${encodeURIComponent(did)}` : ""; 317 + } 318 + 296 319 async function loadBranding() { 297 320 try { 298 321 // fetch branding metadata 299 322 const response = await fetch( 300 - "/xrpc/place.stream.branding.getBranding", 323 + `/xrpc/place.stream.branding.getBranding${getBroadcasterParam()}`, 301 324 ); 302 325 if (!response.ok) { 303 326 throw new Error("Failed to load branding"); ··· 306 329 const data = await response.json(); 307 330 currentBranding = {}; 308 331 309 - // fetch actual data for text assets 332 + // process assets - use inline data for text, URLs for images 310 333 for (const asset of data.assets) { 311 - if (asset.mimeType === "text/plain") { 312 - try { 313 - const blobResponse = await fetch(asset.url); 314 - if (blobResponse.ok) { 315 - currentBranding[asset.key] = await blobResponse.text(); 316 - } 317 - } catch (e) { 318 - console.warn(`Failed to fetch ${asset.key}:`, e); 319 - } 320 - } else { 321 - // for images, store the URL 334 + if (asset.data) { 335 + // text asset with inline data 336 + currentBranding[asset.key] = asset.data; 337 + } else if (asset.url) { 338 + // image asset with URL 322 339 currentBranding[asset.key] = asset.url; 323 340 } 324 341 } ··· 370 387 } 371 388 372 389 try { 373 - const response = await fetch(`/branding/${key}`, { 374 - method: "PUT", 375 - headers: { 376 - "Content-Type": "text/plain", 390 + const response = await fetch( 391 + `/branding/${key}${getBroadcasterParam()}`, 392 + { 393 + method: "PUT", 394 + headers: { 395 + "Content-Type": "text/plain", 396 + }, 397 + body: value.trim(), 377 398 }, 378 - body: value.trim(), 379 - }); 399 + ); 380 400 381 401 if (!response.ok) { 382 402 throw new Error("Upload failed: " + response.statusText); ··· 402 422 } 403 423 404 424 try { 405 - const response = await fetch(`/branding/${key}`, { 406 - method: "PUT", 407 - headers: { 408 - "Content-Type": file.type, 425 + const response = await fetch( 426 + `/branding/${key}${getBroadcasterParam()}`, 427 + { 428 + method: "PUT", 429 + headers: { 430 + "Content-Type": file.type, 431 + }, 432 + body: file, 409 433 }, 410 - body: file, 411 - }); 434 + ); 412 435 413 436 if (!response.ok) { 414 437 throw new Error("Upload failed: " + response.statusText); ··· 432 455 } 433 456 434 457 try { 435 - const response = await fetch(`/branding/${key}`, { 436 - method: "DELETE", 437 - }); 458 + const response = await fetch( 459 + `/branding/${key}${getBroadcasterParam()}`, 460 + { 461 + method: "DELETE", 462 + }, 463 + ); 438 464 439 465 if (!response.ok) { 440 466 throw new Error("Delete failed: " + response.statusText);
+14 -7
pkg/spxrpc/place_stream_branding.go
··· 116 116 // build output 117 117 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 118 118 for key := range allKeys { 119 - _, mimeType, err := s.getBrandingBlobCached(ctx, broadcasterID, key) 119 + data, mimeType, err := s.getBrandingBlobCached(ctx, broadcasterID, key) 120 120 if err != nil { 121 121 continue // skip if error 122 122 } 123 123 124 - // construct URL - need to get base URL from echo context 125 - url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s", key) 126 - 127 - assets = append(assets, &placestreamtypes.BrandingGetBranding_BrandingAsset{ 124 + asset := &placestreamtypes.BrandingGetBranding_BrandingAsset{ 128 125 Key: key, 129 126 MimeType: mimeType, 130 - Url: url, 131 - }) 127 + } 128 + 129 + // for text assets, include data inline; for images, provide URL 130 + if mimeType == "text/plain" { 131 + str := string(data) 132 + asset.Data = &str 133 + } else { 134 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s", key) 135 + asset.Url = &url 136 + } 137 + 138 + assets = append(assets, asset) 132 139 } 133 140 134 141 return &placestreamtypes.BrandingGetBranding_Output{
+4 -2
pkg/streamplace/brandinggetBranding.go
··· 12 12 13 13 // BrandingGetBranding_BrandingAsset is a "brandingAsset" in the place.stream.branding.getBranding schema. 14 14 type BrandingGetBranding_BrandingAsset struct { 15 + // data: Inline data for text assets 16 + Data *string `json:"data,omitempty" cborgen:"data,omitempty"` 15 17 // key: Asset key identifier 16 18 Key string `json:"key" cborgen:"key"` 17 19 // mimeType: MIME type of the asset 18 20 MimeType string `json:"mimeType" cborgen:"mimeType"` 19 - // url: URL to fetch the asset blob 20 - Url string `json:"url" cborgen:"url"` 21 + // url: URL to fetch the asset blob (for images) 22 + Url *string `json:"url,omitempty" cborgen:"url,omitempty"` 21 23 } 22 24 23 25 // BrandingGetBranding_Output is the output of a place.stream.branding.getBranding call.