Live video on the AT Protocol

add height/width to images

+622 -44
+94 -20
js/app/components/settings/branding-admin.tsx
··· 10 10 import { 11 11 useBrandingAsset, 12 12 useFetchBranding, 13 + useSidebarBackgroundImage, 13 14 } from "@streamplace/components/src/streamplace-store/branding"; 14 15 import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 15 16 import { useEffect, useState } from "react"; ··· 38 39 const currentDefaultStreamer = useBrandingAsset("defaultStreamer"); 39 40 const currentLogo = useBrandingAsset("mainLogo"); 40 41 const currentFavicon = useBrandingAsset("favicon"); 42 + const currentSidebarBg = useSidebarBackgroundImage(); 41 43 42 44 // load current branding on mount 43 45 useEffect(() => { 44 46 fetchBranding(); 45 - setBroadcasterDID(currentBroadcasterDID || ""); 46 47 }, []); 48 + 49 + useEffect(() => { 50 + setBroadcasterDID(currentBroadcasterDID || ""); 51 + }, [currentBroadcasterDID]); 47 52 48 53 const uploadText = async (key: string, value: string) => { 49 54 if (!agent) { ··· 118 123 const uint8Array = new Uint8Array(arrayBuffer); 119 124 const base64Data = btoa(String.fromCharCode(...uint8Array)); 120 125 126 + // detect image dimensions if it's an image 127 + let width: number | undefined; 128 + let height: number | undefined; 129 + 130 + if (file.type.startsWith("image/") && Platform.OS === "web") { 131 + const img = new window.Image(); 132 + const imageUrl = URL.createObjectURL(file); 133 + 134 + await new Promise<void>((resolve, reject) => { 135 + img.onload = () => { 136 + width = img.naturalWidth; 137 + height = img.naturalHeight; 138 + URL.revokeObjectURL(imageUrl); 139 + resolve(); 140 + }; 141 + img.onerror = () => { 142 + URL.revokeObjectURL(imageUrl); 143 + reject(new Error("Failed to load image")); 144 + }; 145 + img.src = imageUrl; 146 + }); 147 + } 148 + 121 149 await agent.place.stream.branding.updateBlob({ 122 150 key, 123 151 broadcaster: broadcasterDID || undefined, 124 152 data: base64Data, 125 153 mimeType: file.type, 154 + width, 155 + height, 126 156 }); 127 157 128 158 toast.show("Success", `${key} uploaded successfully`, { ··· 223 253 )} 224 254 225 255 {/* Broadcaster DID */} 226 - <View style={[zero.gap.all[8]]}> 256 + <View style={[zero.gap.all[2]]}> 227 257 <Text size="lg" weight="semibold"> 228 258 Broadcaster DID 229 259 </Text> ··· 238 268 </View> 239 269 240 270 {/* Site Title */} 241 - <View style={[zero.gap.all[8]]}> 271 + <View style={[zero.gap.all[2]]}> 242 272 <Text size="lg" weight="semibold"> 243 273 Site Title 244 274 </Text> 245 275 <Text size="sm" color="muted"> 246 276 Current: {currentTitle?.data || "Streamplace"} 247 277 </Text> 248 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 278 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 249 279 <View style={{ flex: 1 }}> 250 280 <Input 251 281 placeholder="Enter new site title" ··· 263 293 </View> 264 294 265 295 {/* Site Description */} 266 - <View style={[zero.gap.all[8]]}> 296 + <View style={[zero.gap.all[2]]}> 267 297 <Text size="lg" weight="semibold"> 268 298 Site Description 269 299 </Text> 270 300 <Text size="sm" color="muted"> 271 301 Current: {currentDescription?.data || "Live streaming platform"} 272 302 </Text> 273 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 303 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 274 304 <View style={{ flex: 1 }}> 275 305 <Input 276 306 placeholder="Enter site description" ··· 288 318 </View> 289 319 290 320 {/* Primary Color */} 291 - <View style={[zero.gap.all[8]]}> 321 + <View style={[zero.gap.all[2]]}> 292 322 <Text size="lg" weight="semibold"> 293 323 Primary Color 294 324 </Text> 295 325 <Text size="sm" color="muted"> 296 326 Current: {currentPrimaryColor?.data || "#6366f1"} 297 327 </Text> 298 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 328 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 299 329 <View style={{ flex: 1 }}> 300 330 <Input 301 331 placeholder="#6366f1" ··· 313 343 </View> 314 344 315 345 {/* Accent Color */} 316 - <View style={[zero.gap.all[8]]}> 346 + <View style={[zero.gap.all[2]]}> 317 347 <Text size="lg" weight="semibold"> 318 348 Accent Color 319 349 </Text> 320 350 <Text size="sm" color="muted"> 321 351 Current: {currentAccentColor?.data || "#8b5cf6"} 322 352 </Text> 323 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 353 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 324 354 <View style={{ flex: 1 }}> 325 355 <Input 326 356 placeholder="#8b5cf6" ··· 338 368 </View> 339 369 340 370 {/* Default Streamer */} 341 - <View style={[zero.gap.all[8]]}> 371 + <View style={[zero.gap.all[2]]}> 342 372 <Text size="lg" weight="semibold"> 343 373 Default Streamer 344 374 </Text> 345 375 <Text size="sm" color="muted"> 346 376 Current: {currentDefaultStreamer?.data || "None"} 347 377 </Text> 348 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 378 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 349 379 <View style={{ flex: 1 }}> 350 380 <Input 351 381 placeholder="did:plc:..." ··· 370 400 </View> 371 401 372 402 {/* Main Logo */} 373 - <View style={[zero.gap.all[8]]}> 403 + <View style={[zero.gap.all[2]]}> 374 404 <Text size="lg" weight="semibold"> 375 405 Main Logo 376 406 </Text> 377 407 <Text size="sm" color="muted"> 378 - SVG, PNG, or JPEG (max 500KB) - Web only 408 + SVG, PNG, or JPEG (max 500KB) 379 409 </Text> 380 410 {currentLogo?.data && ( 381 411 <Image ··· 383 413 style={{ width: 200, height: 100, resizeMode: "contain" }} 384 414 /> 385 415 )} 386 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 416 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 387 417 <Button 388 418 onPress={() => 389 419 handleFileSelect( ··· 406 436 </View> 407 437 408 438 {/* Favicon */} 409 - <View style={[zero.gap.all[8]]}> 439 + <View style={[zero.gap.all[2]]}> 410 440 <Text size="lg" weight="semibold"> 411 441 Favicon 412 442 </Text> 413 443 <Text size="sm" color="muted"> 414 - SVG, PNG, or ICO (max 100KB) - Web only 444 + SVG, PNG, or ICO (max 100KB) 415 445 </Text> 416 446 {currentFavicon?.data && ( 417 447 <Image ··· 419 449 style={{ width: 64, height: 64, resizeMode: "contain" }} 420 450 /> 421 451 )} 422 - <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 452 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 423 453 <Button 424 454 onPress={() => 425 455 handleFileSelect( ··· 441 471 </View> 442 472 </View> 443 473 474 + {/* Sidebar Background Image */} 475 + <View style={[zero.gap.all[1]]}> 476 + <Text size="lg" weight="semibold"> 477 + Sidebar Background Image 478 + </Text> 479 + <Text size="sm" color="muted"> 480 + SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of 481 + sidebar, full width. 482 + </Text> 483 + <Text size="sm" color="muted"> 484 + Upload an image with opacity for best results, as there is not 485 + currently a separate opacity option. 486 + </Text> 487 + {currentSidebarBg?.data && ( 488 + <Image 489 + source={{ uri: currentSidebarBg.data }} 490 + style={{ width: 200, height: 200, resizeMode: "contain" }} 491 + /> 492 + )} 493 + <Text> 494 + {currentSidebarBg?.height || "unknown"} x{" "} 495 + {currentSidebarBg?.width || "unknown"} 496 + </Text> 497 + <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 498 + <Button 499 + onPress={() => 500 + handleFileSelect( 501 + "sidebarBackgroundImage", 502 + "image/svg+xml,image/png,image/jpeg", 503 + ) 504 + } 505 + disabled={uploading || Platform.OS !== "web"} 506 + > 507 + Upload Background 508 + </Button> 509 + <Button 510 + variant="destructive" 511 + onPress={() => deleteBlob("sidebarBackgroundImage")} 512 + disabled={uploading} 513 + > 514 + Delete Background 515 + </Button> 516 + </View> 517 + </View> 518 + 444 519 <Text size="sm" color="muted" style={{ marginTop: 16 }}> 445 - Note: You must be an authorized admin DID to make changes. 446 520 {Platform.OS !== "web" && 447 - " Image uploads are only available on web."} 521 + "Image uploads are only available on web."} 448 522 </Text> 449 523 </View> 450 524 </View>
+30 -1
js/app/components/sidebar/sidebar.tsx
··· 6 6 ParamListBase, 7 7 useNavigation, 8 8 } from "@react-navigation/native"; 9 - import { Text, useMainLogo, useSiteTitle, zero } from "@streamplace/components"; 9 + import { 10 + Text, 11 + useMainLogo, 12 + useSidebarBackgroundImage, 13 + useSiteTitle, 14 + zero, 15 + } from "@streamplace/components"; 10 16 import { useAQLinkHref } from "components/aqlink"; 11 17 import React from "react"; 12 18 import { Image, Platform, Pressable, View } from "react-native"; ··· 48 54 const navigation = useNavigation(); 49 55 const siteTitle = useSiteTitle(); 50 56 const mainLogo = useMainLogo(); 57 + const sidebarBackgroundImageAsset = useSidebarBackgroundImage(); 51 58 52 59 const animatedSidebarStyle = useAnimatedStyle(() => { 53 60 return { ··· 73 80 animatedSidebarStyle, 74 81 zero.p[2], 75 82 zero.gap.all[2], 83 + zero.flex.values[1], 76 84 zero.layout.flex.column, 85 + { position: "relative" }, 77 86 ]} 78 87 > 88 + {sidebarBackgroundImageAsset?.data && ( 89 + <Image 90 + source={{ uri: sidebarBackgroundImageAsset.data }} 91 + style={{ 92 + //opacity: 0.3, 93 + position: "absolute", 94 + bottom: 0, 95 + left: 0, 96 + width: "100%", 97 + height: "auto", 98 + aspectRatio: 99 + sidebarBackgroundImageAsset.width && 100 + sidebarBackgroundImageAsset.height 101 + ? sidebarBackgroundImageAsset.width / 102 + sidebarBackgroundImageAsset.height 103 + : undefined, 104 + resizeMode: "contain", 105 + }} 106 + /> 107 + )} 79 108 <Pressable 80 109 // @ts-ignore This makes it render as <a> on web! 81 110 href={route ? href : undefined}
-1
js/app/src/screens/mobile-stream.tsx
··· 64 64 65 65 const defaultStreamer = useDefaultStreamer(); 66 66 67 - 68 67 if (!user) user = defaultStreamer; 69 68 let extraProps: Partial<PlayerProps> = {}; 70 69 if (isWeb) {
+7
js/components/src/streamplace-store/branding.tsx
··· 11 11 mimeType: string; 12 12 url?: string; // URL for images 13 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 14 16 } 15 17 16 18 // helper to convert blob to base64 ··· 181 183 export function useDefaultStreamer(): string | undefined { 182 184 const asset = useBrandingAsset("defaultStreamer"); 183 185 return asset?.data || undefined; 186 + } 187 + 188 + // convenience hook for sidebar background image 189 + export function useSidebarBackgroundImage(): BrandingAsset | undefined { 190 + return useBrandingAsset("sidebarBackgroundImage"); 184 191 } 185 192 186 193 // hook to auto-fetch branding when broadcaster changes
+103
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-deleteblob.md
··· 1 + --- 2 + title: place.stream.branding.deleteBlob 3 + description: Reference for the place.stream.branding.deleteBlob lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Delete a branding asset blob. Requires admin authorization. 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Input:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------- | -------- | ----- | ------------------------------------------------------------------------------- | ------------- | 29 + | `key` | `string` | ✅ | Branding asset key (mainLogo, favicon, siteTitle, etc.) | | 30 + | `broadcaster` | `string` | ❌ | DID of the broadcaster. If not provided, uses the server's default broadcaster. | Format: `did` | 31 + 32 + **Output:** 33 + 34 + - **Encoding:** `application/json` 35 + - **Schema:** 36 + 37 + **Schema Type:** `object` 38 + 39 + | Name | Type | Req'd | Description | Constraints | 40 + | --------- | --------- | ----- | ----------- | ----------- | 41 + | `success` | `boolean` | ✅ | | | 42 + 43 + **Possible Errors:** 44 + 45 + - `Unauthorized`: The authenticated DID is not authorized to modify branding 46 + - `BrandingNotFound`: The requested branding asset does not exist 47 + 48 + --- 49 + 50 + ## Lexicon Source 51 + 52 + ```json 53 + { 54 + "lexicon": 1, 55 + "id": "place.stream.branding.deleteBlob", 56 + "defs": { 57 + "main": { 58 + "type": "procedure", 59 + "description": "Delete a branding asset blob. Requires admin authorization.", 60 + "input": { 61 + "encoding": "application/json", 62 + "schema": { 63 + "type": "object", 64 + "required": ["key"], 65 + "properties": { 66 + "key": { 67 + "type": "string", 68 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 69 + }, 70 + "broadcaster": { 71 + "type": "string", 72 + "format": "did", 73 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 74 + } 75 + } 76 + } 77 + }, 78 + "output": { 79 + "encoding": "application/json", 80 + "schema": { 81 + "type": "object", 82 + "required": ["success"], 83 + "properties": { 84 + "success": { 85 + "type": "boolean" 86 + } 87 + } 88 + } 89 + }, 90 + "errors": [ 91 + { 92 + "name": "Unauthorized", 93 + "description": "The authenticated DID is not authorized to modify branding" 94 + }, 95 + { 96 + "name": "BrandingNotFound", 97 + "description": "The requested branding asset does not exist" 98 + } 99 + ] 100 + } 101 + } 102 + } 103 + ```
+16 -6
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getbranding.md
··· 42 42 43 43 **Properties:** 44 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 | | 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 + | `width` | `integer` | ❌ | Image width in pixels (optional, for images only) | | 52 + | `height` | `integer` | ❌ | Image height in pixels (optional, for images only) | | 51 53 52 54 --- 53 55 ··· 110 112 "data": { 111 113 "type": "string", 112 114 "description": "Inline data for text assets" 115 + }, 116 + "width": { 117 + "type": "integer", 118 + "description": "Image width in pixels (optional, for images only)" 119 + }, 120 + "height": { 121 + "type": "integer", 122 + "description": "Image height in pixels (optional, for images only)" 113 123 } 114 124 } 115 125 }
+123
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-updateblob.md
··· 1 + --- 2 + title: place.stream.branding.updateBlob 3 + description: Reference for the place.stream.branding.updateBlob lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Update or create a branding asset blob. Requires admin authorization. 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Input:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------- | --------- | ----- | ------------------------------------------------------------------------------- | ------------- | 29 + | `key` | `string` | ✅ | Branding asset key (mainLogo, favicon, siteTitle, etc.) | | 30 + | `broadcaster` | `string` | ❌ | DID of the broadcaster. If not provided, uses the server's default broadcaster. | Format: `did` | 31 + | `data` | `string` | ✅ | Base64-encoded blob data | | 32 + | `mimeType` | `string` | ✅ | MIME type of the blob (e.g., image/png, text/plain) | | 33 + | `width` | `integer` | ❌ | Image width in pixels (optional, for images only) | | 34 + | `height` | `integer` | ❌ | Image height in pixels (optional, for images only) | | 35 + 36 + **Output:** 37 + 38 + - **Encoding:** `application/json` 39 + - **Schema:** 40 + 41 + **Schema Type:** `object` 42 + 43 + | Name | Type | Req'd | Description | Constraints | 44 + | --------- | --------- | ----- | ----------- | ----------- | 45 + | `success` | `boolean` | ✅ | | | 46 + 47 + **Possible Errors:** 48 + 49 + - `Unauthorized`: The authenticated DID is not authorized to modify branding 50 + - `BlobTooLarge`: The blob exceeds the maximum size limit 51 + 52 + --- 53 + 54 + ## Lexicon Source 55 + 56 + ```json 57 + { 58 + "lexicon": 1, 59 + "id": "place.stream.branding.updateBlob", 60 + "defs": { 61 + "main": { 62 + "type": "procedure", 63 + "description": "Update or create a branding asset blob. Requires admin authorization.", 64 + "input": { 65 + "encoding": "application/json", 66 + "schema": { 67 + "type": "object", 68 + "required": ["key", "data", "mimeType"], 69 + "properties": { 70 + "key": { 71 + "type": "string", 72 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 73 + }, 74 + "broadcaster": { 75 + "type": "string", 76 + "format": "did", 77 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 78 + }, 79 + "data": { 80 + "type": "string", 81 + "description": "Base64-encoded blob data" 82 + }, 83 + "mimeType": { 84 + "type": "string", 85 + "description": "MIME type of the blob (e.g., image/png, text/plain)" 86 + }, 87 + "width": { 88 + "type": "integer", 89 + "description": "Image width in pixels (optional, for images only)" 90 + }, 91 + "height": { 92 + "type": "integer", 93 + "description": "Image height in pixels (optional, for images only)" 94 + } 95 + } 96 + } 97 + }, 98 + "output": { 99 + "encoding": "application/json", 100 + "schema": { 101 + "type": "object", 102 + "required": ["success"], 103 + "properties": { 104 + "success": { 105 + "type": "boolean" 106 + } 107 + } 108 + } 109 + }, 110 + "errors": [ 111 + { 112 + "name": "Unauthorized", 113 + "description": "The authenticated DID is not authorized to modify branding" 114 + }, 115 + { 116 + "name": "BlobTooLarge", 117 + "description": "The blob exceeds the maximum size limit" 118 + } 119 + ] 120 + } 121 + } 122 + } 123 + ```
+172
js/docs/src/content/docs/lex-reference/openapi.json
··· 891 891 } 892 892 } 893 893 }, 894 + "/xrpc/place.stream.branding.deleteBlob": { 895 + "post": { 896 + "summary": "Delete a branding asset blob. Requires admin authorization.", 897 + "operationId": "place.stream.branding.deleteBlob", 898 + "tags": ["place.stream.branding"], 899 + "responses": { 900 + "200": { 901 + "description": "Success", 902 + "content": { 903 + "application/json": { 904 + "schema": { 905 + "type": "object", 906 + "properties": { 907 + "success": { 908 + "type": "boolean" 909 + } 910 + }, 911 + "required": ["success"] 912 + } 913 + } 914 + } 915 + }, 916 + "400": { 917 + "description": "Bad Request", 918 + "content": { 919 + "application/json": { 920 + "schema": { 921 + "type": "object", 922 + "required": ["error", "message"], 923 + "properties": { 924 + "error": { 925 + "type": "string", 926 + "oneOf": [ 927 + { 928 + "const": "Unauthorized" 929 + }, 930 + { 931 + "const": "BrandingNotFound" 932 + } 933 + ] 934 + }, 935 + "message": { 936 + "type": "string" 937 + } 938 + } 939 + } 940 + } 941 + } 942 + } 943 + }, 944 + "requestBody": { 945 + "required": true, 946 + "content": { 947 + "application/json": { 948 + "schema": { 949 + "type": "object", 950 + "properties": { 951 + "key": { 952 + "type": "string", 953 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 954 + }, 955 + "broadcaster": { 956 + "type": "string", 957 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 958 + "format": "did" 959 + } 960 + }, 961 + "required": ["key"] 962 + } 963 + } 964 + } 965 + } 966 + } 967 + }, 894 968 "/xrpc/place.stream.branding.getBlob": { 895 969 "get": { 896 970 "summary": "Get a specific branding asset blob by key.", ··· 995 1069 } 996 1070 } 997 1071 ] 1072 + } 1073 + }, 1074 + "/xrpc/place.stream.branding.updateBlob": { 1075 + "post": { 1076 + "summary": "Update or create a branding asset blob. Requires admin authorization.", 1077 + "operationId": "place.stream.branding.updateBlob", 1078 + "tags": ["place.stream.branding"], 1079 + "responses": { 1080 + "200": { 1081 + "description": "Success", 1082 + "content": { 1083 + "application/json": { 1084 + "schema": { 1085 + "type": "object", 1086 + "properties": { 1087 + "success": { 1088 + "type": "boolean" 1089 + } 1090 + }, 1091 + "required": ["success"] 1092 + } 1093 + } 1094 + } 1095 + }, 1096 + "400": { 1097 + "description": "Bad Request", 1098 + "content": { 1099 + "application/json": { 1100 + "schema": { 1101 + "type": "object", 1102 + "required": ["error", "message"], 1103 + "properties": { 1104 + "error": { 1105 + "type": "string", 1106 + "oneOf": [ 1107 + { 1108 + "const": "Unauthorized" 1109 + }, 1110 + { 1111 + "const": "BlobTooLarge" 1112 + } 1113 + ] 1114 + }, 1115 + "message": { 1116 + "type": "string" 1117 + } 1118 + } 1119 + } 1120 + } 1121 + } 1122 + } 1123 + }, 1124 + "requestBody": { 1125 + "required": true, 1126 + "content": { 1127 + "application/json": { 1128 + "schema": { 1129 + "type": "object", 1130 + "properties": { 1131 + "key": { 1132 + "type": "string", 1133 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 1134 + }, 1135 + "broadcaster": { 1136 + "type": "string", 1137 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1138 + "format": "did" 1139 + }, 1140 + "data": { 1141 + "type": "string", 1142 + "description": "Base64-encoded blob data" 1143 + }, 1144 + "mimeType": { 1145 + "type": "string", 1146 + "description": "MIME type of the blob (e.g., image/png, text/plain)" 1147 + }, 1148 + "width": { 1149 + "type": "integer", 1150 + "description": "Image width in pixels (optional, for images only)" 1151 + }, 1152 + "height": { 1153 + "type": "integer", 1154 + "description": "Image height in pixels (optional, for images only)" 1155 + } 1156 + }, 1157 + "required": ["key", "data", "mimeType"] 1158 + } 1159 + } 1160 + } 1161 + } 998 1162 } 999 1163 }, 1000 1164 "/xrpc/com.atproto.sync.getRecord": { ··· 2432 2596 "data": { 2433 2597 "type": "string", 2434 2598 "description": "Inline data for text assets" 2599 + }, 2600 + "width": { 2601 + "type": "integer", 2602 + "description": "Image width in pixels (optional, for images only)" 2603 + }, 2604 + "height": { 2605 + "type": "integer", 2606 + "description": "Image height in pixels (optional, for images only)" 2435 2607 } 2436 2608 }, 2437 2609 "required": ["key", "mimeType"]
+8
lexicons/place/stream/branding/getBranding.json
··· 54 54 "data": { 55 55 "type": "string", 56 56 "description": "Inline data for text assets" 57 + }, 58 + "width": { 59 + "type": "integer", 60 + "description": "Image width in pixels (optional, for images only)" 61 + }, 62 + "height": { 63 + "type": "integer", 64 + "description": "Image height in pixels (optional, for images only)" 57 65 } 58 66 } 59 67 }
+8
lexicons/place/stream/branding/updateBlob.json
··· 27 27 "mimeType": { 28 28 "type": "string", 29 29 "description": "MIME type of the blob (e.g., image/png, text/plain)" 30 + }, 31 + "width": { 32 + "type": "integer", 33 + "description": "Image width in pixels (optional, for images only)" 34 + }, 35 + "height": { 36 + "type": "integer", 37 + "description": "Image height in pixels (optional, for images only)" 30 38 } 31 39 } 32 40 }
+46 -15
pkg/spxrpc/place_stream_branding.go
··· 45 45 return s.cli.BroadcasterHost 46 46 } 47 47 48 - func (s *Server) getBrandingBlobCached(ctx context.Context, broadcasterID, key string) ([]byte, string, error) { 48 + func (s *Server) getBrandingBlobCached(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 49 49 cacheKey := fmt.Sprintf("%s:%s", broadcasterID, key) 50 50 51 51 // check cache first 52 52 if cached, found := s.BrandingCache.Get(cacheKey); found { 53 53 blob := cached.(struct { 54 - data []byte 55 - mime string 54 + data []byte 55 + mime string 56 + width *int 57 + height *int 56 58 }) 57 - return blob.data, blob.mime, nil 59 + return blob.data, blob.mime, blob.width, blob.height, nil 58 60 } 59 61 60 62 // cache miss - fetch from db ··· 63 65 // not in db, use default 64 66 if def, ok := defaultBrandingAssets[key]; ok { 65 67 // cache the default too 66 - s.BrandingCache.Set(cacheKey, def, cache.DefaultExpiration) 67 - return def.data, def.mime, nil 68 + cacheData := struct { 69 + data []byte 70 + mime string 71 + width *int 72 + height *int 73 + }{data: def.data, mime: def.mime, width: nil, height: nil} 74 + s.BrandingCache.Set(cacheKey, cacheData, cache.DefaultExpiration) 75 + return def.data, def.mime, nil, nil, nil 68 76 } 69 - return nil, "", fmt.Errorf("unknown branding key: %s", key) 77 + return nil, "", nil, nil, fmt.Errorf("unknown branding key: %s", key) 70 78 } 71 79 if err != nil { 72 - return nil, "", fmt.Errorf("error fetching branding blob: %w", err) 80 + return nil, "", nil, nil, fmt.Errorf("error fetching branding blob: %w", err) 73 81 } 74 82 75 83 // store in cache 76 84 cacheData := struct { 77 - data []byte 78 - mime string 79 - }{data: blob.Data, mime: blob.MimeType} 85 + data []byte 86 + mime string 87 + width *int 88 + height *int 89 + }{data: blob.Data, mime: blob.MimeType, width: blob.Width, height: blob.Height} 80 90 s.BrandingCache.Set(cacheKey, cacheData, cache.DefaultExpiration) 81 91 82 - return blob.Data, blob.MimeType, nil 92 + return blob.Data, blob.MimeType, blob.Width, blob.Height, nil 83 93 } 84 94 85 95 func (s *Server) handlePlaceStreamBrandingGetBlob(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { ··· 89 99 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 90 100 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 91 101 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 92 - data, _, err := s.getBrandingBlobCached(ctx, broadcasterID, key) 102 + data, _, _, _, err := s.getBrandingBlobCached(ctx, broadcasterID, key) 93 103 if err != nil { 94 104 return nil, err 95 105 } ··· 122 132 // build output 123 133 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 124 134 for key := range allKeys { 125 - data, mimeType, err := s.getBrandingBlobCached(ctx, broadcasterID, key) 135 + data, mimeType, width, height, err := s.getBrandingBlobCached(ctx, broadcasterID, key) 126 136 if err != nil { 127 137 continue // skip if error 128 138 } ··· 132 142 MimeType: mimeType, 133 143 } 134 144 145 + // add dimensions if available 146 + if width != nil { 147 + w := int64(*width) 148 + asset.Width = &w 149 + } 150 + if height != nil { 151 + h := int64(*height) 152 + asset.Height = &h 153 + } 154 + 135 155 // for text assets, include data inline; for images, provide URL 136 156 if mimeType == "text/plain" { 137 157 str := string(data) ··· 190 210 } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamKey" || input.Key == "defaultStreamer" { 191 211 maxSize = 1024 // 1KB for text values 192 212 } 213 + // sidebarBackgroundImage uses default 500KB limit 193 214 if len(data) > maxSize { 194 215 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("blob too large (max %d bytes)", maxSize)) 195 216 } 196 217 197 218 // store in database 198 - err = s.statefulDB.PutBrandingBlob(broadcasterID, input.Key, input.MimeType, data) 219 + var width, height *int 220 + if input.Width != nil { 221 + w := int(*input.Width) 222 + width = &w 223 + } 224 + if input.Height != nil { 225 + h := int(*input.Height) 226 + height = &h 227 + } 228 + 229 + err = s.statefulDB.PutBrandingBlob(broadcasterID, input.Key, input.MimeType, data, width, height) 199 230 if err != nil { 200 231 log.Error(ctx, "failed to store branding blob", "err", err) 201 232 return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to store branding blob")
+7 -1
pkg/statedb/branding.go
··· 13 13 Key string `gorm:"index:idx_broadcaster_key,priority:2,unique"` // "mainLogo", "favicon", "siteTitle", etc. 14 14 MimeType string // "image/svg+xml", "image/png", "text/plain" 15 15 Data []byte `gorm:"type:bytea"` // actual blob data 16 + Width *int // image width in pixels (nullable) 17 + Height *int // image height in pixels (nullable) 16 18 } 17 19 18 20 // GetBrandingBlob fetches a single branding asset ··· 26 28 } 27 29 28 30 // PutBrandingBlob stores or updates a branding asset 29 - func (state *StatefulDB) PutBrandingBlob(broadcasterID, key, mimeType string, data []byte) error { 31 + func (state *StatefulDB) PutBrandingBlob(broadcasterID, key, mimeType string, data []byte, width, height *int) error { 30 32 // try to find existing blob (including soft-deleted ones) 31 33 var existing BrandingBlob 32 34 err := state.DB.Unscoped().Where("broadcaster_id = ? AND key = ?", broadcasterID, key).First(&existing).Error ··· 38 40 Key: key, 39 41 MimeType: mimeType, 40 42 Data: data, 43 + Width: width, 44 + Height: height, 41 45 } 42 46 if err := state.DB.Create(&blob).Error; err != nil { 43 47 return fmt.Errorf("error creating branding blob: %w", err) ··· 50 54 // update existing blob (restore if soft-deleted) 51 55 existing.MimeType = mimeType 52 56 existing.Data = data 57 + existing.Width = width 58 + existing.Height = height 53 59 existing.DeletedAt = gorm.DeletedAt{} // clear soft delete 54 60 if err := state.DB.Unscoped().Save(&existing).Error; err != nil { 55 61 return fmt.Errorf("error updating branding blob: %w", err)
+4
pkg/streamplace/brandinggetBranding.go
··· 14 14 type BrandingGetBranding_BrandingAsset struct { 15 15 // data: Inline data for text assets 16 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"` 17 19 // key: Asset key identifier 18 20 Key string `json:"key" cborgen:"key"` 19 21 // mimeType: MIME type of the asset 20 22 MimeType string `json:"mimeType" cborgen:"mimeType"` 21 23 // url: URL to fetch the asset blob (for images) 22 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"` 23 27 } 24 28 25 29 // BrandingGetBranding_Output is the output of a place.stream.branding.getBranding call.
+4
pkg/streamplace/brandingupdateBlob.go
··· 16 16 Broadcaster *string `json:"broadcaster,omitempty" cborgen:"broadcaster,omitempty"` 17 17 // data: Base64-encoded blob data 18 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"` 19 21 // key: Branding asset key (mainLogo, favicon, siteTitle, etc.) 20 22 Key string `json:"key" cborgen:"key"` 21 23 // mimeType: MIME type of the blob (e.g., image/png, text/plain) 22 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"` 23 27 } 24 28 25 29 // BrandingUpdateBlob_Output is the output of a place.stream.branding.updateBlob call.