tangled
alpha
login
or
join now
stream.place
/
streamplace
74
fork
atom
Live video on the AT Protocol
74
fork
atom
overview
issues
1
pulls
pipelines
add height/width to images
Natalie B.
2 months ago
1ffe61f5
af880d07
+622
-44
14 changed files
expand all
collapse all
unified
split
js
app
components
settings
branding-admin.tsx
sidebar
sidebar.tsx
src
screens
mobile-stream.tsx
components
src
streamplace-store
branding.tsx
docs
src
content
docs
lex-reference
branding
place-stream-branding-deleteblob.md
place-stream-branding-getbranding.md
place-stream-branding-updateblob.md
openapi.json
lexicons
place
stream
branding
getBranding.json
updateBlob.json
pkg
spxrpc
place_stream_branding.go
statedb
branding.go
streamplace
brandinggetBranding.go
brandingupdateBlob.go
+94
-20
js/app/components/settings/branding-admin.tsx
···
10
10
import {
11
11
useBrandingAsset,
12
12
useFetchBranding,
13
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
42
+
const currentSidebarBg = useSidebarBackgroundImage();
41
43
42
44
// load current branding on mount
43
45
useEffect(() => {
44
46
fetchBranding();
45
45
-
setBroadcasterDID(currentBroadcasterDID || "");
46
47
}, []);
48
48
+
49
49
+
useEffect(() => {
50
50
+
setBroadcasterDID(currentBroadcasterDID || "");
51
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
126
+
// detect image dimensions if it's an image
127
127
+
let width: number | undefined;
128
128
+
let height: number | undefined;
129
129
+
130
130
+
if (file.type.startsWith("image/") && Platform.OS === "web") {
131
131
+
const img = new window.Image();
132
132
+
const imageUrl = URL.createObjectURL(file);
133
133
+
134
134
+
await new Promise<void>((resolve, reject) => {
135
135
+
img.onload = () => {
136
136
+
width = img.naturalWidth;
137
137
+
height = img.naturalHeight;
138
138
+
URL.revokeObjectURL(imageUrl);
139
139
+
resolve();
140
140
+
};
141
141
+
img.onerror = () => {
142
142
+
URL.revokeObjectURL(imageUrl);
143
143
+
reject(new Error("Failed to load image"));
144
144
+
};
145
145
+
img.src = imageUrl;
146
146
+
});
147
147
+
}
148
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
154
+
width,
155
155
+
height,
126
156
});
127
157
128
158
toast.show("Success", `${key} uploaded successfully`, {
···
223
253
)}
224
254
225
255
{/* Broadcaster DID */}
226
226
-
<View style={[zero.gap.all[8]]}>
256
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
241
-
<View style={[zero.gap.all[8]]}>
271
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
248
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
278
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
266
-
<View style={[zero.gap.all[8]]}>
296
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
273
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
303
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
291
-
<View style={[zero.gap.all[8]]}>
321
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
298
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
328
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
316
-
<View style={[zero.gap.all[8]]}>
346
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
323
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
353
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
341
-
<View style={[zero.gap.all[8]]}>
371
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
348
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
378
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
373
-
<View style={[zero.gap.all[8]]}>
403
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
378
-
SVG, PNG, or JPEG (max 500KB) - Web only
408
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
386
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
416
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
409
-
<View style={[zero.gap.all[8]]}>
439
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
414
-
SVG, PNG, or ICO (max 100KB) - Web only
444
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
422
-
<View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}>
452
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
474
+
{/* Sidebar Background Image */}
475
475
+
<View style={[zero.gap.all[1]]}>
476
476
+
<Text size="lg" weight="semibold">
477
477
+
Sidebar Background Image
478
478
+
</Text>
479
479
+
<Text size="sm" color="muted">
480
480
+
SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of
481
481
+
sidebar, full width.
482
482
+
</Text>
483
483
+
<Text size="sm" color="muted">
484
484
+
Upload an image with opacity for best results, as there is not
485
485
+
currently a separate opacity option.
486
486
+
</Text>
487
487
+
{currentSidebarBg?.data && (
488
488
+
<Image
489
489
+
source={{ uri: currentSidebarBg.data }}
490
490
+
style={{ width: 200, height: 200, resizeMode: "contain" }}
491
491
+
/>
492
492
+
)}
493
493
+
<Text>
494
494
+
{currentSidebarBg?.height || "unknown"} x{" "}
495
495
+
{currentSidebarBg?.width || "unknown"}
496
496
+
</Text>
497
497
+
<View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}>
498
498
+
<Button
499
499
+
onPress={() =>
500
500
+
handleFileSelect(
501
501
+
"sidebarBackgroundImage",
502
502
+
"image/svg+xml,image/png,image/jpeg",
503
503
+
)
504
504
+
}
505
505
+
disabled={uploading || Platform.OS !== "web"}
506
506
+
>
507
507
+
Upload Background
508
508
+
</Button>
509
509
+
<Button
510
510
+
variant="destructive"
511
511
+
onPress={() => deleteBlob("sidebarBackgroundImage")}
512
512
+
disabled={uploading}
513
513
+
>
514
514
+
Delete Background
515
515
+
</Button>
516
516
+
</View>
517
517
+
</View>
518
518
+
444
519
<Text size="sm" color="muted" style={{ marginTop: 16 }}>
445
445
-
Note: You must be an authorized admin DID to make changes.
446
520
{Platform.OS !== "web" &&
447
447
-
" Image uploads are only available on web."}
521
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
9
-
import { Text, useMainLogo, useSiteTitle, zero } from "@streamplace/components";
9
9
+
import {
10
10
+
Text,
11
11
+
useMainLogo,
12
12
+
useSidebarBackgroundImage,
13
13
+
useSiteTitle,
14
14
+
zero,
15
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
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
83
+
zero.flex.values[1],
76
84
zero.layout.flex.column,
85
85
+
{ position: "relative" },
77
86
]}
78
87
>
88
88
+
{sidebarBackgroundImageAsset?.data && (
89
89
+
<Image
90
90
+
source={{ uri: sidebarBackgroundImageAsset.data }}
91
91
+
style={{
92
92
+
//opacity: 0.3,
93
93
+
position: "absolute",
94
94
+
bottom: 0,
95
95
+
left: 0,
96
96
+
width: "100%",
97
97
+
height: "auto",
98
98
+
aspectRatio:
99
99
+
sidebarBackgroundImageAsset.width &&
100
100
+
sidebarBackgroundImageAsset.height
101
101
+
? sidebarBackgroundImageAsset.width /
102
102
+
sidebarBackgroundImageAsset.height
103
103
+
: undefined,
104
104
+
resizeMode: "contain",
105
105
+
}}
106
106
+
/>
107
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
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
14
+
width?: number; // image width in pixels
15
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
186
+
}
187
187
+
188
188
+
// convenience hook for sidebar background image
189
189
+
export function useSidebarBackgroundImage(): BrandingAsset | undefined {
190
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
1
+
---
2
2
+
title: place.stream.branding.deleteBlob
3
3
+
description: Reference for the place.stream.branding.deleteBlob lexicon
4
4
+
---
5
5
+
6
6
+
**Lexicon Version:** 1
7
7
+
8
8
+
## Definitions
9
9
+
10
10
+
<a name="main"></a>
11
11
+
12
12
+
### `main`
13
13
+
14
14
+
**Type:** `procedure`
15
15
+
16
16
+
Delete a branding asset blob. Requires admin authorization.
17
17
+
18
18
+
**Parameters:** _(None defined)_
19
19
+
20
20
+
**Input:**
21
21
+
22
22
+
- **Encoding:** `application/json`
23
23
+
- **Schema:**
24
24
+
25
25
+
**Schema Type:** `object`
26
26
+
27
27
+
| Name | Type | Req'd | Description | Constraints |
28
28
+
| ------------- | -------- | ----- | ------------------------------------------------------------------------------- | ------------- |
29
29
+
| `key` | `string` | ✅ | Branding asset key (mainLogo, favicon, siteTitle, etc.) | |
30
30
+
| `broadcaster` | `string` | ❌ | DID of the broadcaster. If not provided, uses the server's default broadcaster. | Format: `did` |
31
31
+
32
32
+
**Output:**
33
33
+
34
34
+
- **Encoding:** `application/json`
35
35
+
- **Schema:**
36
36
+
37
37
+
**Schema Type:** `object`
38
38
+
39
39
+
| Name | Type | Req'd | Description | Constraints |
40
40
+
| --------- | --------- | ----- | ----------- | ----------- |
41
41
+
| `success` | `boolean` | ✅ | | |
42
42
+
43
43
+
**Possible Errors:**
44
44
+
45
45
+
- `Unauthorized`: The authenticated DID is not authorized to modify branding
46
46
+
- `BrandingNotFound`: The requested branding asset does not exist
47
47
+
48
48
+
---
49
49
+
50
50
+
## Lexicon Source
51
51
+
52
52
+
```json
53
53
+
{
54
54
+
"lexicon": 1,
55
55
+
"id": "place.stream.branding.deleteBlob",
56
56
+
"defs": {
57
57
+
"main": {
58
58
+
"type": "procedure",
59
59
+
"description": "Delete a branding asset blob. Requires admin authorization.",
60
60
+
"input": {
61
61
+
"encoding": "application/json",
62
62
+
"schema": {
63
63
+
"type": "object",
64
64
+
"required": ["key"],
65
65
+
"properties": {
66
66
+
"key": {
67
67
+
"type": "string",
68
68
+
"description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)"
69
69
+
},
70
70
+
"broadcaster": {
71
71
+
"type": "string",
72
72
+
"format": "did",
73
73
+
"description": "DID of the broadcaster. If not provided, uses the server's default broadcaster."
74
74
+
}
75
75
+
}
76
76
+
}
77
77
+
},
78
78
+
"output": {
79
79
+
"encoding": "application/json",
80
80
+
"schema": {
81
81
+
"type": "object",
82
82
+
"required": ["success"],
83
83
+
"properties": {
84
84
+
"success": {
85
85
+
"type": "boolean"
86
86
+
}
87
87
+
}
88
88
+
}
89
89
+
},
90
90
+
"errors": [
91
91
+
{
92
92
+
"name": "Unauthorized",
93
93
+
"description": "The authenticated DID is not authorized to modify branding"
94
94
+
},
95
95
+
{
96
96
+
"name": "BrandingNotFound",
97
97
+
"description": "The requested branding asset does not exist"
98
98
+
}
99
99
+
]
100
100
+
}
101
101
+
}
102
102
+
}
103
103
+
```
+16
-6
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getbranding.md
···
42
42
43
43
**Properties:**
44
44
45
45
-
| Name | Type | Req'd | Description | Constraints |
46
46
-
| ---------- | -------- | ----- | ---------------------------------------- | ----------- |
47
47
-
| `key` | `string` | ✅ | Asset key identifier | |
48
48
-
| `mimeType` | `string` | ✅ | MIME type of the asset | |
49
49
-
| `url` | `string` | ❌ | URL to fetch the asset blob (for images) | |
50
50
-
| `data` | `string` | ❌ | Inline data for text assets | |
45
45
+
| Name | Type | Req'd | Description | Constraints |
46
46
+
| ---------- | --------- | ----- | -------------------------------------------------- | ----------- |
47
47
+
| `key` | `string` | ✅ | Asset key identifier | |
48
48
+
| `mimeType` | `string` | ✅ | MIME type of the asset | |
49
49
+
| `url` | `string` | ❌ | URL to fetch the asset blob (for images) | |
50
50
+
| `data` | `string` | ❌ | Inline data for text assets | |
51
51
+
| `width` | `integer` | ❌ | Image width in pixels (optional, for images only) | |
52
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
115
+
},
116
116
+
"width": {
117
117
+
"type": "integer",
118
118
+
"description": "Image width in pixels (optional, for images only)"
119
119
+
},
120
120
+
"height": {
121
121
+
"type": "integer",
122
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
1
+
---
2
2
+
title: place.stream.branding.updateBlob
3
3
+
description: Reference for the place.stream.branding.updateBlob lexicon
4
4
+
---
5
5
+
6
6
+
**Lexicon Version:** 1
7
7
+
8
8
+
## Definitions
9
9
+
10
10
+
<a name="main"></a>
11
11
+
12
12
+
### `main`
13
13
+
14
14
+
**Type:** `procedure`
15
15
+
16
16
+
Update or create a branding asset blob. Requires admin authorization.
17
17
+
18
18
+
**Parameters:** _(None defined)_
19
19
+
20
20
+
**Input:**
21
21
+
22
22
+
- **Encoding:** `application/json`
23
23
+
- **Schema:**
24
24
+
25
25
+
**Schema Type:** `object`
26
26
+
27
27
+
| Name | Type | Req'd | Description | Constraints |
28
28
+
| ------------- | --------- | ----- | ------------------------------------------------------------------------------- | ------------- |
29
29
+
| `key` | `string` | ✅ | Branding asset key (mainLogo, favicon, siteTitle, etc.) | |
30
30
+
| `broadcaster` | `string` | ❌ | DID of the broadcaster. If not provided, uses the server's default broadcaster. | Format: `did` |
31
31
+
| `data` | `string` | ✅ | Base64-encoded blob data | |
32
32
+
| `mimeType` | `string` | ✅ | MIME type of the blob (e.g., image/png, text/plain) | |
33
33
+
| `width` | `integer` | ❌ | Image width in pixels (optional, for images only) | |
34
34
+
| `height` | `integer` | ❌ | Image height in pixels (optional, for images only) | |
35
35
+
36
36
+
**Output:**
37
37
+
38
38
+
- **Encoding:** `application/json`
39
39
+
- **Schema:**
40
40
+
41
41
+
**Schema Type:** `object`
42
42
+
43
43
+
| Name | Type | Req'd | Description | Constraints |
44
44
+
| --------- | --------- | ----- | ----------- | ----------- |
45
45
+
| `success` | `boolean` | ✅ | | |
46
46
+
47
47
+
**Possible Errors:**
48
48
+
49
49
+
- `Unauthorized`: The authenticated DID is not authorized to modify branding
50
50
+
- `BlobTooLarge`: The blob exceeds the maximum size limit
51
51
+
52
52
+
---
53
53
+
54
54
+
## Lexicon Source
55
55
+
56
56
+
```json
57
57
+
{
58
58
+
"lexicon": 1,
59
59
+
"id": "place.stream.branding.updateBlob",
60
60
+
"defs": {
61
61
+
"main": {
62
62
+
"type": "procedure",
63
63
+
"description": "Update or create a branding asset blob. Requires admin authorization.",
64
64
+
"input": {
65
65
+
"encoding": "application/json",
66
66
+
"schema": {
67
67
+
"type": "object",
68
68
+
"required": ["key", "data", "mimeType"],
69
69
+
"properties": {
70
70
+
"key": {
71
71
+
"type": "string",
72
72
+
"description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)"
73
73
+
},
74
74
+
"broadcaster": {
75
75
+
"type": "string",
76
76
+
"format": "did",
77
77
+
"description": "DID of the broadcaster. If not provided, uses the server's default broadcaster."
78
78
+
},
79
79
+
"data": {
80
80
+
"type": "string",
81
81
+
"description": "Base64-encoded blob data"
82
82
+
},
83
83
+
"mimeType": {
84
84
+
"type": "string",
85
85
+
"description": "MIME type of the blob (e.g., image/png, text/plain)"
86
86
+
},
87
87
+
"width": {
88
88
+
"type": "integer",
89
89
+
"description": "Image width in pixels (optional, for images only)"
90
90
+
},
91
91
+
"height": {
92
92
+
"type": "integer",
93
93
+
"description": "Image height in pixels (optional, for images only)"
94
94
+
}
95
95
+
}
96
96
+
}
97
97
+
},
98
98
+
"output": {
99
99
+
"encoding": "application/json",
100
100
+
"schema": {
101
101
+
"type": "object",
102
102
+
"required": ["success"],
103
103
+
"properties": {
104
104
+
"success": {
105
105
+
"type": "boolean"
106
106
+
}
107
107
+
}
108
108
+
}
109
109
+
},
110
110
+
"errors": [
111
111
+
{
112
112
+
"name": "Unauthorized",
113
113
+
"description": "The authenticated DID is not authorized to modify branding"
114
114
+
},
115
115
+
{
116
116
+
"name": "BlobTooLarge",
117
117
+
"description": "The blob exceeds the maximum size limit"
118
118
+
}
119
119
+
]
120
120
+
}
121
121
+
}
122
122
+
}
123
123
+
```
+172
js/docs/src/content/docs/lex-reference/openapi.json
···
891
891
}
892
892
}
893
893
},
894
894
+
"/xrpc/place.stream.branding.deleteBlob": {
895
895
+
"post": {
896
896
+
"summary": "Delete a branding asset blob. Requires admin authorization.",
897
897
+
"operationId": "place.stream.branding.deleteBlob",
898
898
+
"tags": ["place.stream.branding"],
899
899
+
"responses": {
900
900
+
"200": {
901
901
+
"description": "Success",
902
902
+
"content": {
903
903
+
"application/json": {
904
904
+
"schema": {
905
905
+
"type": "object",
906
906
+
"properties": {
907
907
+
"success": {
908
908
+
"type": "boolean"
909
909
+
}
910
910
+
},
911
911
+
"required": ["success"]
912
912
+
}
913
913
+
}
914
914
+
}
915
915
+
},
916
916
+
"400": {
917
917
+
"description": "Bad Request",
918
918
+
"content": {
919
919
+
"application/json": {
920
920
+
"schema": {
921
921
+
"type": "object",
922
922
+
"required": ["error", "message"],
923
923
+
"properties": {
924
924
+
"error": {
925
925
+
"type": "string",
926
926
+
"oneOf": [
927
927
+
{
928
928
+
"const": "Unauthorized"
929
929
+
},
930
930
+
{
931
931
+
"const": "BrandingNotFound"
932
932
+
}
933
933
+
]
934
934
+
},
935
935
+
"message": {
936
936
+
"type": "string"
937
937
+
}
938
938
+
}
939
939
+
}
940
940
+
}
941
941
+
}
942
942
+
}
943
943
+
},
944
944
+
"requestBody": {
945
945
+
"required": true,
946
946
+
"content": {
947
947
+
"application/json": {
948
948
+
"schema": {
949
949
+
"type": "object",
950
950
+
"properties": {
951
951
+
"key": {
952
952
+
"type": "string",
953
953
+
"description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)"
954
954
+
},
955
955
+
"broadcaster": {
956
956
+
"type": "string",
957
957
+
"description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.",
958
958
+
"format": "did"
959
959
+
}
960
960
+
},
961
961
+
"required": ["key"]
962
962
+
}
963
963
+
}
964
964
+
}
965
965
+
}
966
966
+
}
967
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
1072
+
}
1073
1073
+
},
1074
1074
+
"/xrpc/place.stream.branding.updateBlob": {
1075
1075
+
"post": {
1076
1076
+
"summary": "Update or create a branding asset blob. Requires admin authorization.",
1077
1077
+
"operationId": "place.stream.branding.updateBlob",
1078
1078
+
"tags": ["place.stream.branding"],
1079
1079
+
"responses": {
1080
1080
+
"200": {
1081
1081
+
"description": "Success",
1082
1082
+
"content": {
1083
1083
+
"application/json": {
1084
1084
+
"schema": {
1085
1085
+
"type": "object",
1086
1086
+
"properties": {
1087
1087
+
"success": {
1088
1088
+
"type": "boolean"
1089
1089
+
}
1090
1090
+
},
1091
1091
+
"required": ["success"]
1092
1092
+
}
1093
1093
+
}
1094
1094
+
}
1095
1095
+
},
1096
1096
+
"400": {
1097
1097
+
"description": "Bad Request",
1098
1098
+
"content": {
1099
1099
+
"application/json": {
1100
1100
+
"schema": {
1101
1101
+
"type": "object",
1102
1102
+
"required": ["error", "message"],
1103
1103
+
"properties": {
1104
1104
+
"error": {
1105
1105
+
"type": "string",
1106
1106
+
"oneOf": [
1107
1107
+
{
1108
1108
+
"const": "Unauthorized"
1109
1109
+
},
1110
1110
+
{
1111
1111
+
"const": "BlobTooLarge"
1112
1112
+
}
1113
1113
+
]
1114
1114
+
},
1115
1115
+
"message": {
1116
1116
+
"type": "string"
1117
1117
+
}
1118
1118
+
}
1119
1119
+
}
1120
1120
+
}
1121
1121
+
}
1122
1122
+
}
1123
1123
+
},
1124
1124
+
"requestBody": {
1125
1125
+
"required": true,
1126
1126
+
"content": {
1127
1127
+
"application/json": {
1128
1128
+
"schema": {
1129
1129
+
"type": "object",
1130
1130
+
"properties": {
1131
1131
+
"key": {
1132
1132
+
"type": "string",
1133
1133
+
"description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)"
1134
1134
+
},
1135
1135
+
"broadcaster": {
1136
1136
+
"type": "string",
1137
1137
+
"description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.",
1138
1138
+
"format": "did"
1139
1139
+
},
1140
1140
+
"data": {
1141
1141
+
"type": "string",
1142
1142
+
"description": "Base64-encoded blob data"
1143
1143
+
},
1144
1144
+
"mimeType": {
1145
1145
+
"type": "string",
1146
1146
+
"description": "MIME type of the blob (e.g., image/png, text/plain)"
1147
1147
+
},
1148
1148
+
"width": {
1149
1149
+
"type": "integer",
1150
1150
+
"description": "Image width in pixels (optional, for images only)"
1151
1151
+
},
1152
1152
+
"height": {
1153
1153
+
"type": "integer",
1154
1154
+
"description": "Image height in pixels (optional, for images only)"
1155
1155
+
}
1156
1156
+
},
1157
1157
+
"required": ["key", "data", "mimeType"]
1158
1158
+
}
1159
1159
+
}
1160
1160
+
}
1161
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
2599
+
},
2600
2600
+
"width": {
2601
2601
+
"type": "integer",
2602
2602
+
"description": "Image width in pixels (optional, for images only)"
2603
2603
+
},
2604
2604
+
"height": {
2605
2605
+
"type": "integer",
2606
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
57
+
},
58
58
+
"width": {
59
59
+
"type": "integer",
60
60
+
"description": "Image width in pixels (optional, for images only)"
61
61
+
},
62
62
+
"height": {
63
63
+
"type": "integer",
64
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
30
+
},
31
31
+
"width": {
32
32
+
"type": "integer",
33
33
+
"description": "Image width in pixels (optional, for images only)"
34
34
+
},
35
35
+
"height": {
36
36
+
"type": "integer",
37
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
48
-
func (s *Server) getBrandingBlobCached(ctx context.Context, broadcasterID, key string) ([]byte, string, error) {
48
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
54
-
data []byte
55
55
-
mime string
54
54
+
data []byte
55
55
+
mime string
56
56
+
width *int
57
57
+
height *int
56
58
})
57
57
-
return blob.data, blob.mime, nil
59
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
66
-
s.BrandingCache.Set(cacheKey, def, cache.DefaultExpiration)
67
67
-
return def.data, def.mime, nil
68
68
+
cacheData := struct {
69
69
+
data []byte
70
70
+
mime string
71
71
+
width *int
72
72
+
height *int
73
73
+
}{data: def.data, mime: def.mime, width: nil, height: nil}
74
74
+
s.BrandingCache.Set(cacheKey, cacheData, cache.DefaultExpiration)
75
75
+
return def.data, def.mime, nil, nil, nil
68
76
}
69
69
-
return nil, "", fmt.Errorf("unknown branding key: %s", key)
77
77
+
return nil, "", nil, nil, fmt.Errorf("unknown branding key: %s", key)
70
78
}
71
79
if err != nil {
72
72
-
return nil, "", fmt.Errorf("error fetching branding blob: %w", err)
80
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
77
-
data []byte
78
78
-
mime string
79
79
-
}{data: blob.Data, mime: blob.MimeType}
85
85
+
data []byte
86
86
+
mime string
87
87
+
width *int
88
88
+
height *int
89
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
82
-
return blob.Data, blob.MimeType, nil
92
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
92
-
data, _, err := s.getBrandingBlobCached(ctx, broadcasterID, key)
102
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
125
-
data, mimeType, err := s.getBrandingBlobCached(ctx, broadcasterID, key)
135
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
145
+
// add dimensions if available
146
146
+
if width != nil {
147
147
+
w := int64(*width)
148
148
+
asset.Width = &w
149
149
+
}
150
150
+
if height != nil {
151
151
+
h := int64(*height)
152
152
+
asset.Height = &h
153
153
+
}
154
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
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
198
-
err = s.statefulDB.PutBrandingBlob(broadcasterID, input.Key, input.MimeType, data)
219
219
+
var width, height *int
220
220
+
if input.Width != nil {
221
221
+
w := int(*input.Width)
222
222
+
width = &w
223
223
+
}
224
224
+
if input.Height != nil {
225
225
+
h := int(*input.Height)
226
226
+
height = &h
227
227
+
}
228
228
+
229
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
16
+
Width *int // image width in pixels (nullable)
17
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
29
-
func (state *StatefulDB) PutBrandingBlob(broadcasterID, key, mimeType string, data []byte) error {
31
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
43
+
Width: width,
44
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
57
+
existing.Width = width
58
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
17
+
// height: Image height in pixels (optional, for images only)
18
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
25
+
// width: Image width in pixels (optional, for images only)
26
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
19
+
// height: Image height in pixels (optional, for images only)
20
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
25
+
// width: Image width in pixels (optional, for images only)
26
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.