Live video on the AT Protocol

Merge remote-tracking branch 'origin/next' into eli/rtmp-push

+3093 -79
+9 -7
js/app/components/live-dashboard/stream-key.tsx
··· 93 93 <FormRow> 94 94 <Label>Output Settings</Label> 95 95 <Content> 96 - <Text>Output mode: Advanced</Text> 97 - <Text> 98 - Keyframe Interval: <Code>1s</Code> 99 - </Text> 100 - <Text> 101 - x264 Options: <Code>bframes=0</Code> 102 - </Text> 96 + <Body> 97 + <Text>Output mode: Advanced</Text> 98 + <Text> 99 + Keyframe Interval: <Code>1s</Code> 100 + </Text> 101 + <Text> 102 + x264 Options: <Code>bframes=0</Code> 103 + </Text> 104 + </Body> 103 105 </Content> 104 106 </FormRow> 105 107 </View>
+13 -13
js/app/components/provider/provider.shared.tsx
··· 5 5 } from "@react-navigation/native"; 6 6 import * as Sentry from "@sentry/react-native"; 7 7 import { 8 + BrandedThemeProvider, 8 9 I18nProvider, 9 - ThemeProvider, 10 10 StreamplaceProvider as ZustandStreamplaceProvider, 11 11 } from "@streamplace/components"; 12 12 import { useFonts } from "expo-font"; ··· 85 85 86 86 return ( 87 87 <SafeAreaProvider> 88 - <ThemeProvider forcedTheme="dark"> 89 - <I18nProvider i18n={i18n}> 90 - <NavigationContainer theme={SPDarkTheme} linking={linking}> 91 - <StreamplaceProvider> 92 - <BlueskyProvider> 93 - <NewStreamplaceProvider> 88 + <I18nProvider i18n={i18n}> 89 + <NavigationContainer theme={SPDarkTheme} linking={linking}> 90 + <StreamplaceProvider> 91 + <BlueskyProvider> 92 + <NewStreamplaceProvider> 93 + <BrandedThemeProvider forcedTheme="dark"> 94 94 <FontProvider>{children}</FontProvider> 95 - </NewStreamplaceProvider> 96 - </BlueskyProvider> 97 - </StreamplaceProvider> 98 - </NavigationContainer> 99 - </I18nProvider> 100 - </ThemeProvider> 95 + </BrandedThemeProvider> 96 + </NewStreamplaceProvider> 97 + </BlueskyProvider> 98 + </StreamplaceProvider> 99 + </NavigationContainer> 100 + </I18nProvider> 101 101 </SafeAreaProvider> 102 102 ); 103 103 }
+1 -4
js/app/components/provider/provider.tsx
··· 1 1 import { LinkingOptions } from "@react-navigation/native"; 2 - import { ThemeProvider } from "@streamplace/components"; 3 2 import React, { useEffect } from "react"; 4 3 import { SafeAreaProvider } from "react-native-safe-area-context"; 5 4 import SharedProvider from "./provider.shared"; ··· 21 20 }, []); 22 21 return ( 23 22 <SafeAreaProvider> 24 - <ThemeProvider forcedTheme="dark"> 25 - <SharedProvider linking={linking}>{children}</SharedProvider> 26 - </ThemeProvider> 23 + <SharedProvider linking={linking}>{children}</SharedProvider> 27 24 </SafeAreaProvider> 28 25 ); 29 26 }
+46 -12
js/app/components/settings/about-category-settings.tsx
··· 1 1 import { 2 2 MenuContainer, 3 3 MenuGroup, 4 + MenuLabel, 4 5 MenuSeparator, 5 6 Text, 6 7 useDanmuUnlocked, 8 + useLegalLinks, 7 9 useSetDanmuUnlocked, 8 10 useTheme, 9 11 useToast, ··· 12 14 } from "@streamplace/components"; 13 15 import { useState } from "react"; 14 16 import { useTranslation } from "react-i18next"; 15 - import { ScrollView } from "react-native"; 17 + import { Platform, ScrollView } from "react-native"; 16 18 import { 17 19 SettingsExternalItem, 18 20 SettingsRowItem, ··· 46 48 } 47 49 48 50 const UNLOCK_TAP_COUNT = 5; 51 + 52 + // Check if the webapp is running on a streamplace domain 53 + function isStreamplaceDomain(): boolean { 54 + if (Platform.OS !== "web") { 55 + return true; 56 + } 57 + if (typeof window === "undefined" || !window.location) { 58 + return false; 59 + } 60 + let host = window.location.hostname; 61 + return host.endsWith("://stream.place") || host.endsWith(".stream.place"); 62 + } 63 + 49 64 export function AboutCategorySettings() { 50 65 const { t } = useTranslation("settings"); 51 66 const theme = useTheme(); ··· 55 70 const [tapCount, setTapCount] = useState(0); 56 71 const danmuUnlocked = useDanmuUnlocked(); 57 72 const setDanmuUnlocked = useSetDanmuUnlocked(); 73 + const legalLinks = useLegalLinks(); 74 + const isStreamplace = isStreamplaceDomain(); 58 75 59 76 const handleVersionPress = () => { 60 77 if (danmuUnlocked) { ··· 134 151 <StreamplaceUpdatesRow /> 135 152 </MenuGroup> 136 153 137 - <MenuGroup> 138 - <SettingsExternalItem 139 - title="Terms of Service" 140 - link="OpenSourceLicenses" 141 - /> 142 - <MenuSeparator /> 143 - <SettingsExternalItem 144 - title="Privacy Policy" 145 - link="OpenSourceLicenses" 146 - /> 147 - </MenuGroup> 154 + {isStreamplace && ( 155 + <MenuGroup> 156 + <SettingsExternalItem 157 + title="Privacy Policy" 158 + link="https://privacy.stream.place" 159 + /> 160 + </MenuGroup> 161 + )} 162 + 163 + {!isStreamplace && 164 + Platform.OS === "web" && 165 + legalLinks.length > 0 && ( 166 + <> 167 + <MenuLabel>{t("node-legal-documents")}</MenuLabel> 168 + <MenuGroup> 169 + {legalLinks.map((link, index) => ( 170 + <> 171 + {index > 0 && <MenuSeparator />} 172 + <SettingsExternalItem 173 + key={index} 174 + title={link.text} 175 + link={link.url} 176 + /> 177 + </> 178 + ))} 179 + </MenuGroup> 180 + </> 181 + )} 148 182 </MenuContainer> 149 183 </View> 150 184 </View>
+34
js/app/components/settings/advanced-category-settings.tsx
··· 1 1 import { 2 2 Button, 3 3 Input, 4 + Loader, 4 5 MenuContainer, 5 6 MenuGroup, 6 7 Text, 8 + useFetchBranding, 7 9 View, 8 10 zero, 9 11 } from "@streamplace/components"; 12 + import { Check } from "lucide-react-native"; 10 13 import { useEffect, useState } from "react"; 11 14 import { useTranslation } from "react-i18next"; 12 15 import { ScrollView } from "react-native"; 13 16 import { useStore } from "store"; 14 17 import { DEFAULT_URL } from "store/slices/streamplaceSlice"; 15 18 import { SettingToggle } from "./components/setting-toggle"; 19 + import { SettingsRowItem } from "./components/settings-navigation-item"; 20 + 21 + type Status = "ready" | "active" | "done"; 16 22 17 23 export function AdvancedCategorySettings() { 18 24 const url = useStore((state) => state.url); ··· 21 27 const [newUrl, setNewUrl] = useState(""); 22 28 const [overrideEnabled, setOverrideEnabled] = useState(false); 23 29 const { t } = useTranslation("settings"); 30 + 31 + const fetchBranding = useFetchBranding(); 32 + 33 + const [refreshBranding, setRefreshBranding] = useState<Status>("ready"); 24 34 25 35 useEffect(() => { 26 36 setOverrideEnabled(url !== defaultUrl); ··· 101 111 </Button> 102 112 </View> 103 113 )} 114 + <MenuGroup> 115 + <SettingsRowItem 116 + onPress={() => { 117 + setRefreshBranding("active"); 118 + fetchBranding({ force: true }).then(() => { 119 + setRefreshBranding("done"); 120 + // set back to ready after a short delay 121 + setTimeout(() => { 122 + setRefreshBranding("ready"); 123 + }, 2500); 124 + }); 125 + }} 126 + > 127 + <View style={{ flex: 1 }}> 128 + <Text size="lg">{t("refresh-branding")}</Text> 129 + </View> 130 + <View> 131 + {refreshBranding === "active" && <Loader />} 132 + {refreshBranding === "done" && ( 133 + <Check size={20} color="#4ade80" /> 134 + )} 135 + </View> 136 + </SettingsRowItem> 137 + </MenuGroup> 104 138 </MenuContainer> 105 139 </View> 106 140 </View>
+827
js/app/components/settings/branding-admin.tsx
··· 1 + import { 2 + Button, 3 + Input, 4 + MenuContainer, 5 + MenuGroup, 6 + MenuInfo, 7 + MenuItem, 8 + MenuLabel, 9 + MenuSeparator, 10 + Text, 11 + useStreamplaceStore, 12 + useToast, 13 + useTranslation, 14 + View, 15 + zero, 16 + } from "@streamplace/components"; 17 + import { 18 + useBrandingAsset, 19 + useFetchBranding, 20 + useSidebarBackgroundImage, 21 + } from "@streamplace/components/src/streamplace-store/branding"; 22 + import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 23 + import { useEffect, useState } from "react"; 24 + import { ActivityIndicator, Image, Platform, ScrollView } from "react-native"; 25 + import { SettingsRowItem } from "./components/settings-navigation-item"; 26 + 27 + export function BrandingAdmin() { 28 + const { t } = useTranslation("settings"); 29 + const agent = usePDSAgent(); 30 + const fetchBranding = useFetchBranding(); 31 + const toast = useToast(); 32 + const currentBroadcasterDID = useStreamplaceStore((s) => s.broadcasterDID); 33 + 34 + // state for form inputs 35 + const [siteTitle, setSiteTitle] = useState(""); 36 + const [siteDescription, setSiteDescription] = useState(""); 37 + const [primaryColor, setPrimaryColor] = useState(""); 38 + const [accentColor, setAccentColor] = useState(""); 39 + const [defaultStreamer, setDefaultStreamer] = useState(""); 40 + const [broadcasterDID, setBroadcasterDID] = useState(""); 41 + const [uploading, setUploading] = useState(false); 42 + const [legalLinkText, setLegalLinkText] = useState(""); 43 + const [legalLinkUrl, setLegalLinkUrl] = useState(""); 44 + const [editingLinkIndex, setEditingLinkIndex] = useState<number | null>(null); 45 + 46 + // get current values 47 + const currentTitle = useBrandingAsset("siteTitle"); 48 + const currentDescription = useBrandingAsset("siteDescription"); 49 + const currentPrimaryColor = useBrandingAsset("primaryColor"); 50 + const currentAccentColor = useBrandingAsset("accentColor"); 51 + const currentDefaultStreamer = useBrandingAsset("defaultStreamer"); 52 + const currentLogo = useBrandingAsset("mainLogo"); 53 + const currentFavicon = useBrandingAsset("favicon"); 54 + const currentSidebarBg = useSidebarBackgroundImage(); 55 + const currentLegalLinks = useBrandingAsset("legalLinks"); 56 + 57 + // parse legal links 58 + const legalLinks: { text: string; url: string }[] = currentLegalLinks?.data 59 + ? JSON.parse(currentLegalLinks.data) 60 + : []; 61 + 62 + // load current branding on mount 63 + useEffect(() => { 64 + fetchBranding(); 65 + }, []); 66 + 67 + useEffect(() => { 68 + setBroadcasterDID(currentBroadcasterDID || ""); 69 + }, [currentBroadcasterDID]); 70 + 71 + const uploadText = async (key: string, value: string) => { 72 + if (!agent) { 73 + toast.show( 74 + t("branding-not-authenticated"), 75 + t("branding-not-authenticated"), 76 + { 77 + variant: "error", 78 + }, 79 + ); 80 + return; 81 + } 82 + 83 + if (!value.trim()) { 84 + toast.show(t("branding-empty-value"), t("branding-empty-value"), { 85 + variant: "error", 86 + }); 87 + return; 88 + } 89 + 90 + setUploading(true); 91 + try { 92 + const textBytes = new TextEncoder().encode(value.trim()); 93 + const base64Data = btoa(String.fromCharCode(...textBytes)); 94 + 95 + await agent.place.stream.branding.updateBlob({ 96 + key, 97 + broadcaster: broadcasterDID || undefined, 98 + data: base64Data, 99 + mimeType: "text/plain", 100 + }); 101 + 102 + toast.show( 103 + t("branding-update-success", { key }), 104 + t("branding-update-success", { key }), 105 + { 106 + variant: "success", 107 + }, 108 + ); 109 + 110 + // clear input based on key 111 + switch (key) { 112 + case "siteTitle": 113 + setSiteTitle(""); 114 + break; 115 + case "siteDescription": 116 + setSiteDescription(""); 117 + break; 118 + case "primaryColor": 119 + setPrimaryColor(""); 120 + break; 121 + case "accentColor": 122 + setAccentColor(""); 123 + break; 124 + case "defaultStreamer": 125 + setDefaultStreamer(""); 126 + break; 127 + } 128 + 129 + // reload branding 130 + setTimeout(() => fetchBranding({ force: true }), 500); 131 + } catch (err: any) { 132 + toast.show( 133 + t("branding-upload-failed"), 134 + err.message || t("branding-upload-failed"), 135 + { 136 + variant: "error", 137 + }, 138 + ); 139 + } finally { 140 + setUploading(false); 141 + } 142 + }; 143 + 144 + const uploadFile = async (key: string, file: File) => { 145 + if (!agent) { 146 + toast.show( 147 + t("branding-not-authenticated"), 148 + t("branding-not-authenticated"), 149 + { 150 + variant: "error", 151 + }, 152 + ); 153 + return; 154 + } 155 + 156 + setUploading(true); 157 + try { 158 + const arrayBuffer = await file.arrayBuffer(); 159 + const uint8Array = new Uint8Array(arrayBuffer); 160 + const base64Data = btoa(String.fromCharCode(...uint8Array)); 161 + 162 + // detect image dimensions if it's an image 163 + let width: number | undefined; 164 + let height: number | undefined; 165 + 166 + if (file.type.startsWith("image/") && Platform.OS === "web") { 167 + const img = new window.Image(); 168 + const imageUrl = URL.createObjectURL(file); 169 + 170 + await new Promise<void>((resolve, reject) => { 171 + img.onload = () => { 172 + width = img.naturalWidth; 173 + height = img.naturalHeight; 174 + URL.revokeObjectURL(imageUrl); 175 + resolve(); 176 + }; 177 + img.onerror = () => { 178 + URL.revokeObjectURL(imageUrl); 179 + reject(new Error("Failed to load image")); 180 + }; 181 + img.src = imageUrl; 182 + }); 183 + } 184 + 185 + await agent.place.stream.branding.updateBlob({ 186 + key, 187 + broadcaster: broadcasterDID || undefined, 188 + data: base64Data, 189 + mimeType: file.type, 190 + width, 191 + height, 192 + }); 193 + 194 + toast.show( 195 + t("branding-update-success", { key }), 196 + t("branding-upload-success", { key }), 197 + { 198 + variant: "success", 199 + }, 200 + ); 201 + 202 + // reload branding 203 + setTimeout(() => fetchBranding({ force: true }), 500); 204 + } catch (err: any) { 205 + toast.show( 206 + t("branding-upload-failed"), 207 + err.message || t("branding-upload-failed"), 208 + { 209 + variant: "error", 210 + }, 211 + ); 212 + } finally { 213 + setUploading(false); 214 + } 215 + }; 216 + 217 + const handleFileSelect = (key: string, accept: string) => { 218 + if (Platform.OS !== "web") { 219 + toast.show(t("branding-not-available"), t("branding-not-available"), { 220 + variant: "error", 221 + }); 222 + return; 223 + } 224 + 225 + // TypeScript doesn't know about document in react-native-web context 226 + // @ts-ignore - document exists on web 227 + const input = document.createElement("input"); 228 + input.type = "file"; 229 + input.accept = accept; 230 + input.onchange = (e) => { 231 + const file = (e.target as HTMLInputElement).files?.[0]; 232 + if (file) { 233 + uploadFile(key, file); 234 + } 235 + }; 236 + input.click(); 237 + }; 238 + 239 + const deleteBlob = async (key: string) => { 240 + if (!agent) { 241 + toast.show( 242 + t("branding-not-authenticated"), 243 + t("branding-not-authenticated"), 244 + { 245 + variant: "error", 246 + }, 247 + ); 248 + return; 249 + } 250 + 251 + setUploading(true); 252 + try { 253 + await agent.place.stream.branding.deleteBlob({ 254 + key, 255 + broadcaster: broadcasterDID || undefined, 256 + }); 257 + 258 + toast.show( 259 + t("branding-update-success", { key }), 260 + t("branding-delete-success", { key }), 261 + { 262 + variant: "success", 263 + }, 264 + ); 265 + 266 + // reload branding 267 + setTimeout(() => fetchBranding(), 500); 268 + } catch (err: any) { 269 + toast.show( 270 + t("branding-delete-failed"), 271 + err.message || t("branding-delete-failed"), 272 + { 273 + variant: "error", 274 + }, 275 + ); 276 + } finally { 277 + setUploading(false); 278 + } 279 + }; 280 + 281 + const saveLegalLink = async () => { 282 + if (!legalLinkText.trim() || !legalLinkUrl.trim()) { 283 + toast.show(t("branding-empty-value"), t("branding-empty-value"), { 284 + variant: "error", 285 + }); 286 + return; 287 + } 288 + 289 + const updatedLinks = [...legalLinks]; 290 + const newLink = { text: legalLinkText.trim(), url: legalLinkUrl.trim() }; 291 + 292 + if (editingLinkIndex !== null) { 293 + updatedLinks[editingLinkIndex] = newLink; 294 + } else { 295 + updatedLinks.push(newLink); 296 + } 297 + 298 + await uploadText("legalLinks", JSON.stringify(updatedLinks)); 299 + setLegalLinkText(""); 300 + setLegalLinkUrl(""); 301 + setEditingLinkIndex(null); 302 + }; 303 + 304 + const deleteLegalLink = async (index: number) => { 305 + const updatedLinks = legalLinks.filter((_, i) => i !== index); 306 + if (updatedLinks.length === 0) { 307 + await deleteBlob("legalLinks"); 308 + } else { 309 + await uploadText("legalLinks", JSON.stringify(updatedLinks)); 310 + } 311 + }; 312 + 313 + const startEditingLink = (index: number) => { 314 + setEditingLinkIndex(index); 315 + setLegalLinkText(legalLinks[index].text); 316 + setLegalLinkUrl(legalLinks[index].url); 317 + }; 318 + 319 + const cancelEditingLink = () => { 320 + setEditingLinkIndex(null); 321 + setLegalLinkText(""); 322 + setLegalLinkUrl(""); 323 + }; 324 + 325 + if (!agent) { 326 + return ( 327 + <View style={[zero.layout.flex.align.center, zero.px[16], zero.py[24]]}> 328 + <Text>{t("branding-login-required")}</Text> 329 + </View> 330 + ); 331 + } 332 + 333 + return ( 334 + <ScrollView> 335 + <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 336 + <View style={{ maxWidth: 500, width: "100%" }}> 337 + <MenuContainer> 338 + <View style={[zero.gap.all[2]]}> 339 + <Text size="2xl" weight="bold"> 340 + {t("branding-admin")} 341 + </Text> 342 + <Text color="muted">{t("branding-admin-description")}</Text> 343 + </View> 344 + 345 + {uploading && ( 346 + <View style={[zero.layout.flex.align.center, zero.py[16]]}> 347 + <ActivityIndicator /> 348 + </View> 349 + )} 350 + 351 + <MenuLabel>{t("branding-configuration")}</MenuLabel> 352 + <MenuGroup> 353 + <MenuItem> 354 + <SettingsRowItem> 355 + <View style={[zero.gap.all[2], { flex: 1 }]}> 356 + <Text size="sm" weight="semibold"> 357 + {t("branding-broadcaster-did")} 358 + </Text> 359 + <Input 360 + placeholder={t("branding-default-streamer-placeholder")} 361 + value={broadcasterDID} 362 + onChangeText={setBroadcasterDID} 363 + /> 364 + <MenuInfo 365 + description={t("branding-broadcaster-did-description")} 366 + /> 367 + </View> 368 + </SettingsRowItem> 369 + </MenuItem> 370 + </MenuGroup> 371 + 372 + <MenuLabel>{t("branding-text-settings")}</MenuLabel> 373 + <MenuGroup> 374 + <MenuItem> 375 + <SettingsRowItem> 376 + <View style={[zero.gap.all[2], { flex: 1 }]}> 377 + <Text size="sm" weight="semibold"> 378 + {t("branding-site-title")} 379 + </Text> 380 + <Text size="xs" color="muted"> 381 + {t("branding-current", { 382 + value: currentTitle?.data || "Streamplace", 383 + })} 384 + </Text> 385 + <View 386 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 387 + > 388 + <View style={{ flex: 1 }}> 389 + <Input 390 + placeholder={t("branding-site-title-placeholder")} 391 + value={siteTitle} 392 + onChangeText={setSiteTitle} 393 + /> 394 + </View> 395 + <Button 396 + onPress={() => uploadText("siteTitle", siteTitle)} 397 + disabled={uploading || !siteTitle.trim()} 398 + width="min" 399 + style={{ height: 42 }} 400 + > 401 + {t("update")} 402 + </Button> 403 + </View> 404 + </View> 405 + </SettingsRowItem> 406 + </MenuItem> 407 + <MenuSeparator /> 408 + <MenuItem> 409 + <SettingsRowItem> 410 + <View style={[zero.gap.all[2], { flex: 1 }]}> 411 + <Text size="sm" weight="semibold"> 412 + {t("branding-site-description")} 413 + </Text> 414 + <Text size="xs" color="muted"> 415 + {t("branding-current", { 416 + value: 417 + currentDescription?.data || "Live streaming platform", 418 + })} 419 + </Text> 420 + <View 421 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 422 + > 423 + <View style={{ flex: 1 }}> 424 + <Input 425 + placeholder={t( 426 + "branding-site-description-placeholder", 427 + )} 428 + value={siteDescription} 429 + onChangeText={setSiteDescription} 430 + /> 431 + </View> 432 + <Button 433 + onPress={() => 434 + uploadText("siteDescription", siteDescription) 435 + } 436 + disabled={uploading || !siteDescription.trim()} 437 + width="min" 438 + style={{ height: 42 }} 439 + > 440 + {t("update")} 441 + </Button> 442 + </View> 443 + </View> 444 + </SettingsRowItem> 445 + </MenuItem> 446 + <MenuSeparator /> 447 + <MenuItem> 448 + <SettingsRowItem> 449 + <View style={[zero.gap.all[2], { flex: 1 }]}> 450 + <Text size="sm" weight="semibold"> 451 + {t("branding-default-streamer")} 452 + </Text> 453 + <Text size="xs" color="muted"> 454 + {t("branding-current", { 455 + value: 456 + currentDefaultStreamer?.data || 457 + t("branding-default-streamer-none"), 458 + })} 459 + </Text> 460 + <View 461 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 462 + > 463 + <View style={{ flex: 1 }}> 464 + <Input 465 + placeholder={t( 466 + "branding-default-streamer-placeholder", 467 + )} 468 + value={defaultStreamer} 469 + onChangeText={setDefaultStreamer} 470 + /> 471 + </View> 472 + <Button 473 + onPress={() => 474 + uploadText("defaultStreamer", defaultStreamer) 475 + } 476 + disabled={uploading || !defaultStreamer.trim()} 477 + width="min" 478 + style={{ height: 42 }} 479 + > 480 + {t("update")} 481 + </Button> 482 + </View> 483 + <Button 484 + variant="destructive" 485 + onPress={() => deleteBlob("defaultStreamer")} 486 + disabled={uploading} 487 + > 488 + {t("branding-clear-default-streamer")} 489 + </Button> 490 + </View> 491 + </SettingsRowItem> 492 + </MenuItem> 493 + </MenuGroup> 494 + 495 + <MenuLabel>{t("branding-colors")}</MenuLabel> 496 + <MenuGroup> 497 + <MenuItem> 498 + <SettingsRowItem> 499 + <View style={[zero.gap.all[2], { flex: 1 }]}> 500 + <Text size="sm" weight="semibold"> 501 + {t("branding-primary-color")} 502 + </Text> 503 + <Text size="xs" color="muted"> 504 + {t("branding-current", { 505 + value: currentPrimaryColor?.data || "#6366f1", 506 + })} 507 + </Text> 508 + <View 509 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 510 + > 511 + <View style={{ flex: 1 }}> 512 + <Input 513 + placeholder={t("branding-primary-color-placeholder")} 514 + value={primaryColor} 515 + onChangeText={setPrimaryColor} 516 + /> 517 + </View> 518 + <Button 519 + onPress={() => uploadText("primaryColor", primaryColor)} 520 + disabled={uploading || !primaryColor.trim()} 521 + width="min" 522 + style={{ height: 42 }} 523 + > 524 + {t("update")} 525 + </Button> 526 + </View> 527 + </View> 528 + </SettingsRowItem> 529 + </MenuItem> 530 + <MenuSeparator /> 531 + <MenuItem> 532 + <SettingsRowItem> 533 + <View style={[zero.gap.all[2], { flex: 1 }]}> 534 + <Text size="sm" weight="semibold"> 535 + {t("branding-accent-color")} 536 + </Text> 537 + <Text size="xs" color="muted"> 538 + {t("branding-current", { 539 + value: currentAccentColor?.data || "#8b5cf6", 540 + })} 541 + </Text> 542 + <View 543 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 544 + > 545 + <View style={{ flex: 1 }}> 546 + <Input 547 + placeholder={t("branding-accent-color-placeholder")} 548 + value={accentColor} 549 + onChangeText={setAccentColor} 550 + /> 551 + </View> 552 + <Button 553 + onPress={() => uploadText("accentColor", accentColor)} 554 + disabled={uploading || !accentColor.trim()} 555 + width="min" 556 + style={{ height: 42 }} 557 + > 558 + {t("update")} 559 + </Button> 560 + </View> 561 + </View> 562 + </SettingsRowItem> 563 + </MenuItem> 564 + </MenuGroup> 565 + 566 + <MenuLabel>{t("branding-legal-links")}</MenuLabel> 567 + <MenuGroup> 568 + <MenuItem> 569 + <SettingsRowItem> 570 + <View style={[zero.gap.all[2], { flex: 1 }]}> 571 + <Text size="sm" weight="semibold"> 572 + {editingLinkIndex !== null 573 + ? t("branding-edit-legal-link") 574 + : t("branding-add-legal-link")} 575 + </Text> 576 + <Input 577 + placeholder={t("branding-legal-link-text-placeholder")} 578 + value={legalLinkText} 579 + onChangeText={setLegalLinkText} 580 + /> 581 + <Input 582 + placeholder={t("branding-legal-link-url-placeholder")} 583 + value={legalLinkUrl} 584 + onChangeText={setLegalLinkUrl} 585 + /> 586 + <View 587 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 588 + > 589 + <Button 590 + onPress={saveLegalLink} 591 + disabled={ 592 + uploading || 593 + !legalLinkText.trim() || 594 + !legalLinkUrl.trim() 595 + } 596 + width="min" 597 + style={{ height: 42 }} 598 + > 599 + {editingLinkIndex !== null ? t("update") : t("add")} 600 + </Button> 601 + {editingLinkIndex !== null && ( 602 + <Button 603 + variant="outline" 604 + onPress={cancelEditingLink} 605 + disabled={uploading} 606 + width="min" 607 + style={{ height: 42 }} 608 + > 609 + {t("cancel")} 610 + </Button> 611 + )} 612 + </View> 613 + </View> 614 + </SettingsRowItem> 615 + </MenuItem> 616 + {legalLinks.length > 0 && ( 617 + <> 618 + <MenuSeparator /> 619 + {legalLinks.map((link, index) => ( 620 + <View key={index}> 621 + <MenuItem> 622 + <SettingsRowItem> 623 + <View style={[zero.gap.all[2], { flex: 1 }]}> 624 + <Text size="sm" weight="semibold"> 625 + {link.text} 626 + </Text> 627 + <Text size="xs" color="muted"> 628 + {link.url} 629 + </Text> 630 + <View 631 + style={[ 632 + zero.layout.flex.direction.row, 633 + zero.gap.all[2], 634 + ]} 635 + > 636 + <Button 637 + variant="outline" 638 + onPress={() => startEditingLink(index)} 639 + disabled={uploading} 640 + width="min" 641 + style={{ height: 42 }} 642 + > 643 + {t("edit")} 644 + </Button> 645 + <Button 646 + variant="destructive" 647 + onPress={() => deleteLegalLink(index)} 648 + disabled={uploading} 649 + width="min" 650 + style={{ height: 42 }} 651 + > 652 + {t("delete")} 653 + </Button> 654 + </View> 655 + </View> 656 + </SettingsRowItem> 657 + </MenuItem> 658 + {index < legalLinks.length - 1 && <MenuSeparator />} 659 + </View> 660 + ))} 661 + </> 662 + )} 663 + </MenuGroup> 664 + 665 + <MenuLabel>{t("branding-images")}</MenuLabel> 666 + <MenuGroup> 667 + <MenuItem> 668 + <SettingsRowItem> 669 + <View style={[zero.gap.all[2], { flex: 1 }]}> 670 + <Text size="sm" weight="semibold"> 671 + {t("branding-main-logo")} 672 + </Text> 673 + <MenuInfo 674 + description={t("branding-main-logo-description")} 675 + /> 676 + {currentLogo?.data && ( 677 + <Image 678 + source={{ uri: currentLogo.data }} 679 + style={{ 680 + width: 200, 681 + height: 100, 682 + resizeMode: "contain", 683 + }} 684 + /> 685 + )} 686 + <View 687 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 688 + > 689 + <Button 690 + onPress={() => 691 + handleFileSelect( 692 + "mainLogo", 693 + "image/svg+xml,image/png,image/jpeg,image/webp", 694 + ) 695 + } 696 + disabled={uploading || Platform.OS !== "web"} 697 + width="min" 698 + style={{ height: 42 }} 699 + > 700 + {t("branding-upload-logo")} 701 + </Button> 702 + <Button 703 + variant="destructive" 704 + onPress={() => deleteBlob("mainLogo")} 705 + disabled={uploading} 706 + width="min" 707 + style={{ height: 42 }} 708 + > 709 + {t("branding-delete-logo")} 710 + </Button> 711 + </View> 712 + </View> 713 + </SettingsRowItem> 714 + </MenuItem> 715 + <MenuSeparator /> 716 + <MenuItem> 717 + <SettingsRowItem> 718 + <View style={[zero.gap.all[2], { flex: 1 }]}> 719 + <Text size="sm" weight="semibold"> 720 + {t("branding-favicon")} 721 + </Text> 722 + <MenuInfo description={t("branding-favicon-description")} /> 723 + {currentFavicon?.data && ( 724 + <Image 725 + source={{ uri: currentFavicon.data }} 726 + style={{ width: 64, height: 64, resizeMode: "contain" }} 727 + /> 728 + )} 729 + <View 730 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 731 + > 732 + <Button 733 + onPress={() => 734 + handleFileSelect( 735 + "favicon", 736 + "image/svg+xml,image/png,image/x-icon", 737 + ) 738 + } 739 + disabled={uploading || Platform.OS !== "web"} 740 + width="min" 741 + style={{ height: 42 }} 742 + > 743 + {t("branding-upload-favicon")} 744 + </Button> 745 + <Button 746 + variant="destructive" 747 + onPress={() => deleteBlob("favicon")} 748 + disabled={uploading} 749 + width="min" 750 + style={{ height: 42 }} 751 + > 752 + {t("branding-delete-favicon")} 753 + </Button> 754 + </View> 755 + </View> 756 + </SettingsRowItem> 757 + </MenuItem> 758 + <MenuSeparator /> 759 + <MenuItem> 760 + <View style={[zero.gap.all[2], { flex: 1 }]}> 761 + <Text size="sm" weight="semibold"> 762 + {t("branding-sidebar-bg")} 763 + </Text> 764 + <MenuInfo 765 + description={t("branding-sidebar-bg-description")} 766 + /> 767 + {currentSidebarBg?.data && ( 768 + <> 769 + <Image 770 + source={{ uri: currentSidebarBg.data }} 771 + style={{ 772 + width: 200, 773 + height: 200, 774 + resizeMode: "contain", 775 + }} 776 + /> 777 + <Text size="xs" color="muted"> 778 + {currentSidebarBg?.height || "unknown"} x{" "} 779 + {currentSidebarBg?.width || "unknown"} 780 + </Text> 781 + </> 782 + )} 783 + <View 784 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 785 + > 786 + <Button 787 + onPress={() => 788 + handleFileSelect( 789 + "sidebarBackgroundImage", 790 + "image/svg+xml,image/png,image/jpeg,image/webp", 791 + ) 792 + } 793 + disabled={uploading || Platform.OS !== "web"} 794 + width="min" 795 + style={{ height: 42 }} 796 + > 797 + {t("branding-upload-background")} 798 + </Button> 799 + <Button 800 + variant="destructive" 801 + onPress={() => deleteBlob("sidebarBackgroundImage")} 802 + disabled={uploading} 803 + width="min" 804 + style={{ height: 42 }} 805 + > 806 + {t("branding-delete-background")} 807 + </Button> 808 + </View> 809 + </View> 810 + </MenuItem> 811 + <MenuSeparator /> 812 + {Platform.OS !== "web" && ( 813 + <MenuItem> 814 + <SettingsRowItem> 815 + <Text size="sm" color="muted"> 816 + {t("branding-web-only")} 817 + </Text> 818 + </SettingsRowItem> 819 + </MenuItem> 820 + )} 821 + </MenuGroup> 822 + </MenuContainer> 823 + </View> 824 + </View> 825 + </ScrollView> 826 + ); 827 + }
+2 -1
js/app/components/settings/components/settings-navigation-item.tsx
··· 50 50 51 51 export function SettingsRowItem({ children, onPress }: SettingsRowItemProps) { 52 52 return ( 53 - <Pressable onPress={onPress}> 53 + <Pressable onPress={onPress} style={{ width: "100%" }}> 54 54 {({ pressed }) => ( 55 55 <View 56 56 style={[ 57 57 zero.px[3], 58 58 zero.py[2], 59 + zero.w.percent[100], 59 60 zero.layout.flex.row, 60 61 zero.layout.flex.justify.between, 61 62 zero.layout.flex.align.center,
+18
js/app/components/settings/settings.tsx
··· 5 5 MenuSeparator, 6 6 Text, 7 7 useDanmuUnlocked, 8 + useDID, 9 + useStreamplaceStore, 8 10 useTranslation, 9 11 View, 10 12 zero, ··· 14 16 SettingsRowItem, 15 17 } from "components/settings/components/settings-navigation-item"; 16 18 import { 19 + Brush, 17 20 Globe, 18 21 Info, 19 22 Lock, ··· 44 47 } 45 48 return { name: route.name, params: route.params }; 46 49 }); 50 + 51 + const adminDids = useStreamplaceStore((state) => state.adminDIDs); 52 + const did = useDID(); 53 + 54 + // Determine if the user is an admin 55 + const isAdmin = did && adminDids && adminDids.includes(did) ? true : false; 47 56 48 57 const { t } = useTranslation("settings"); 49 58 ··· 138 147 title={t("danmu")} 139 148 screen="DanmuCategory" 140 149 icon={Mu as any} 150 + /> 151 + </MenuGroup> 152 + )} 153 + {isAdmin && ( 154 + <MenuGroup> 155 + <SettingsNavigationItem 156 + title={t("branding")} 157 + screen="BrandingAdmin" 158 + icon={Brush} 141 159 /> 142 160 </MenuGroup> 143 161 )}
+49 -8
js/app/components/sidebar/sidebar.tsx
··· 6 6 ParamListBase, 7 7 useNavigation, 8 8 } from "@react-navigation/native"; 9 - import { Text, 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"; ··· 46 52 externalItems = [], 47 53 }: SidebarProps) { 48 54 const navigation = useNavigation(); 55 + const siteTitle = useSiteTitle(); 56 + const mainLogo = useMainLogo(); 57 + const sidebarBackgroundImageAsset = useSidebarBackgroundImage(); 58 + 49 59 const animatedSidebarStyle = useAnimatedStyle(() => { 50 60 return { 51 61 minWidth: widthAnim.value, ··· 70 80 animatedSidebarStyle, 71 81 zero.p[2], 72 82 zero.gap.all[2], 83 + zero.flex.values[1], 73 84 zero.layout.flex.column, 85 + { position: "relative" }, 74 86 ]} 75 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 + )} 76 108 <Pressable 77 109 // @ts-ignore This makes it render as <a> on web! 78 110 href={route ? href : undefined} ··· 87 119 }, 88 120 ]} 89 121 > 90 - <Image 91 - source={require("../../assets/images/cube.png")} 92 - height={30} 93 - width={28} 94 - style={{ width: 28, height: 30, resizeMode: "contain" }} 95 - /> 96 - {!collapsed && <Text size="2xl">Streamplace</Text>} 122 + {mainLogo ? ( 123 + <Image 124 + source={{ uri: mainLogo }} 125 + height={30} 126 + width={28} 127 + style={{ width: 28, height: 30, resizeMode: "contain" }} 128 + /> 129 + ) : ( 130 + <Image 131 + source={require("../../assets/images/cube.png")} 132 + height={30} 133 + width={28} 134 + style={{ width: 28, height: 30, resizeMode: "contain" }} 135 + /> 136 + )} 137 + {!collapsed && <Text size="2xl">{siteTitle}</Text>} 97 138 </Pressable> 98 139 99 140 {state.routes.map((route) => {
-2
js/app/features/streamplace/streamplaceProvider.tsx
··· 1 - import { Text } from "@streamplace/components"; 2 1 import Loading from "components/loading/loading"; 3 2 import { createContext, useEffect } from "react"; 4 3 import { View } from "react-native"; ··· 28 27 if (!initialized) { 29 28 return ( 30 29 <View style={[{ flex: 1 }]}> 31 - <Text style={[{ color: "#fff" }]}>StreamplaceProvider loading...</Text> 32 30 <Loading /> 33 31 </View> 34 32 );
+3 -1
js/app/hooks/useTitle.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 + import { useBrandingAsset } from "@streamplace/components"; 2 3 import { useEffect } from "react"; 3 4 4 5 export default function useTitle(user: string) { 5 6 const navigation = useNavigation(); 7 + const title = useBrandingAsset("siteTitle")?.data || "Streamplace"; 6 8 useEffect(() => { 7 9 navigation.setOptions({ 8 - title: `@${user} on Streamplace`, 10 + title: `@${user} on ${title}`, 9 11 }); 10 12 }, [user, navigation]); 11 13 }
+1
js/app/public/index.html
··· 7 7 name="viewport" 8 8 content="width=device-width, initial-scale=1, shrink-to-fit=no" 9 9 /> 10 + <link rel="icon" href="/favicon.ico" /> 10 11 <title>Streamplace</title> 11 12 <style id="expo-reset"> 12 13 html,
+60 -11
js/app/src/router.tsx
··· 15 15 useRoute, 16 16 } from "@react-navigation/native"; 17 17 import { createNativeStackNavigator } from "@react-navigation/native-stack"; 18 - import { Text, useTheme, useToast } from "@streamplace/components"; 18 + import { 19 + Text, 20 + useDefaultStreamer, 21 + useSiteTitle, 22 + useTheme, 23 + useToast, 24 + } from "@streamplace/components"; 19 25 import { Provider, Settings } from "components"; 20 26 import AQLink from "components/aqlink"; 21 27 import Login from "components/login/login"; ··· 72 78 import HomeScreen from "./screens/home"; 73 79 74 80 import { useUrl } from "@streamplace/components"; 81 + import { BrandingAdmin } from "components/settings/branding-admin"; 75 82 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 76 83 import MultistreamManager from "components/settings/multistream-manager"; 77 84 import RecommendationsManager from "components/settings/recommendations-manager"; ··· 125 132 DeveloperSettings: undefined; 126 133 KeyManagement: undefined; 127 134 MultistreamCategory: undefined; 135 + BrandingAdmin: undefined; 128 136 }; 129 137 130 138 type RootStackParamList = { ··· 183 191 MultistreamCategory: "settings/streaming/multistream", 184 192 KeyManagement: "settings/streaming/key-management", 185 193 LanguagesCategory: "settings/languages", 194 + BrandingAdmin: "settings/branding", 186 195 }, 187 196 }, 188 197 KeyManagement: "key-management", ··· 320 329 const useExternalItems = (): ExternalDrawerItem[] => { 321 330 const streamplaceUrl = useUrl(); 322 331 const { theme } = useTheme(); 332 + const defaultStreamer = useDefaultStreamer(); 333 + 334 + if (defaultStreamer) { 335 + return []; 336 + } 337 + 323 338 return [ 324 339 { 325 340 item: React.memo(() => <Book size={24} color={theme.colors.text} />), ··· 400 415 const showLoginModal = useStore((state) => state.showLoginModal); 401 416 const closeLoginModal = useStore((state) => state.closeLoginModal); 402 417 const [livePopup, setLivePopup] = useState(false); 418 + const siteTitle = useSiteTitle(); 419 + const defaultStreamer = useDefaultStreamer(); 403 420 404 421 const sidebar = useSidebarControl(); 405 422 ··· 415 432 const notificationToken = useNotificationToken(); 416 433 const userProfile = useUserProfile(); 417 434 const hydrated = useHydrated(); 435 + 436 + // check if current user is the default streamer 437 + const isDefaultStreamer = 438 + defaultStreamer && userProfile?.did === defaultStreamer; 418 439 useEffect(() => { 419 440 if (notificationToken) { 420 441 registerNotificationToken(); ··· 522 543 options={{ 523 544 drawerIcon: () => <Home color={foregroundColor} size={24} />, 524 545 drawerLabel: () => <Text variant="h5">Home</Text>, 525 - headerTitle: isWeb ? "Home" : "Streamplace", 546 + headerTitle: isWeb ? "Home" : siteTitle, 526 547 headerShown: isWeb, 527 - title: "Streamplace", 548 + title: siteTitle, 528 549 }} 529 550 listeners={{ 530 551 drawerItemPress: (e) => { ··· 553 574 drawerIcon: () => ( 554 575 <ShieldQuestion color={foregroundColor} size={24} /> 555 576 ), 556 - drawerItemStyle: isNative ? { display: "none" } : undefined, 577 + drawerItemStyle: 578 + isNative || defaultStreamer ? { display: "none" } : undefined, 557 579 }} 558 580 /> 559 581 <Drawer.Screen ··· 562 584 options={{ 563 585 drawerLabel: () => <Text variant="h5">Download</Text>, 564 586 drawerIcon: () => <Download color={foregroundColor} size={24} />, 565 - drawerItemStyle: isBrowser ? undefined : { display: "none" }, 587 + drawerItemStyle: 588 + !isBrowser || defaultStreamer ? { display: "none" } : undefined, 566 589 }} 567 590 /> 568 591 <Drawer.Screen ··· 591 614 }, 592 615 }} 593 616 /> 594 - 617 + <Drawer.Screen 618 + name="KeyManagement" 619 + component={KeyManager} 620 + options={{ 621 + drawerLabel: () => <Text variant="h5">Key Manager</Text>, 622 + drawerItemStyle: { display: "none" }, 623 + }} 624 + /> 595 625 <Drawer.Screen 596 626 name="Support" 597 627 component={SupportScreen} ··· 606 636 options={{ 607 637 drawerLabel: () => <Text variant="h5">Live Dashboard</Text>, 608 638 drawerIcon: () => <Video color={foregroundColor} size={24} />, 609 - drawerItemStyle: isNative ? { display: "none" } : undefined, 639 + drawerItemStyle: 640 + isNative || (defaultStreamer && !isDefaultStreamer) 641 + ? { display: "none" } 642 + : undefined, 610 643 }} 611 644 /> 612 645 <Drawer.Screen ··· 632 665 options={{ 633 666 drawerIcon: () => <LogIn color={foregroundColor} size={24} />, 634 667 drawerLabel: () => <Text variant="h5">Login</Text>, 668 + drawerItemStyle: { display: userProfile ? "none" : undefined }, 635 669 }} 636 670 /> 637 671 <Drawer.Screen ··· 676 710 component={MobileGoLive} 677 711 options={{ 678 712 headerTitle: "Go Live", 679 - drawerItemStyle: isNative ? undefined : { display: "none" }, 713 + drawerItemStyle: 714 + !isNative || (defaultStreamer && !isDefaultStreamer) 715 + ? { display: "none" } 716 + : undefined, 680 717 drawerLabel: () => <Text variant="h5">Go Live</Text>, 681 718 title: "Go live", 682 719 drawerIcon: () => <Video color={foregroundColor} size={24} />, ··· 706 743 }; 707 744 708 745 const MainTab = () => { 709 - const theme = useTheme(); 710 746 const { isWeb } = usePlatform(); 747 + const siteTitle = useSiteTitle(); 748 + const defaultStreamer = useDefaultStreamer(); 749 + 711 750 return ( 712 751 <Stack.Navigator 713 752 initialRouteName="StreamList" ··· 721 760 > 722 761 <Stack.Screen 723 762 name="StreamList" 724 - component={HomeScreen} 725 - options={{ headerTitle: "Streamplace", title: "Streamplace" }} 763 + component={ 764 + defaultStreamer && defaultStreamer !== "" ? MobileStream : HomeScreen 765 + } 766 + options={{ headerTitle: siteTitle, title: siteTitle }} 726 767 /> 727 768 <Stack.Screen 728 769 name="Stream" ··· 811 852 name="MultistreamCategory" 812 853 component={MultistreamManager} 813 854 options={{ headerTitle: "Multistream", title: "Multistream" }} 855 + /> 856 + <Drawer.Screen 857 + name="BrandingAdmin" 858 + component={BrandingAdmin} 859 + options={{ 860 + drawerLabel: () => <Text variant="h5">Branding Admin</Text>, 861 + drawerItemStyle: { display: "none" }, 862 + }} 814 863 /> 815 864 </Stack.Navigator> 816 865 );
+1 -7
js/app/src/screens/live-dashboard.tsx
··· 1 1 import { useRoute } from "@react-navigation/native"; 2 - import { 3 - LivestreamProvider, 4 - PlayerProvider, 5 - zero, 6 - } from "@streamplace/components"; 2 + import { LivestreamProvider, PlayerProvider } from "@streamplace/components"; 7 3 import BentoGrid from "components/live-dashboard/bento-grid"; 8 4 import Loading from "components/loading/loading"; 9 5 import { VideoElementProvider } from "contexts/VideoElementContext"; ··· 11 7 import { useCallback, useEffect, useState } from "react"; 12 8 import { useStore } from "store"; 13 9 import { useIsReady, useUserProfile } from "store/hooks"; 14 - 15 - const { flex, bg } = zero; 16 10 17 11 export default function LiveDashboard() { 18 12 const isReady = useIsReady();
+3 -2
js/app/src/screens/mobile-stream.tsx
··· 2 2 KeepAwake, 3 3 LivestreamProvider, 4 4 PlayerProvider, 5 + Text, 5 6 useLivestreamStore, 6 7 } from "@streamplace/components"; 7 8 import { Player } from "components/mobile/player"; 8 9 import { PlayerProps } from "components/player/props"; 9 10 import { FullscreenProvider } from "contexts/FullscreenContext"; 10 11 import useTitle from "hooks/useTitle"; 11 - import { Platform, Text, View } from "react-native"; 12 + import { Platform, View } from "react-native"; 12 13 import { queryToProps } from "./util"; 13 14 14 15 const isWeb = Platform.OS === "web"; ··· 58 59 } 59 60 60 61 export default function MobileStream({ route }) { 61 - const { user, protocol, url } = route.params; 62 + const { user, protocol, url } = route?.params ?? {}; 62 63 let extraProps: Partial<PlayerProps> = {}; 63 64 if (isWeb) { 64 65 extraProps = queryToProps(new URLSearchParams(window.location.search));
+65
js/components/locales/en-US/settings.ftl
··· 166 166 go-to-dashboard = Go to Dashboard 167 167 need-setup-live-dashboard = Need to set up streaming first? Visit the live dashboard 168 168 no-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 -2
js/components/src/components/ui/menu.tsx
··· 224 224 ref={ref as any} 225 225 style={mergeStyles( 226 226 px[4], 227 - py[2], 227 + pt[2], 228 228 { color: theme.colors.textMuted }, 229 229 a.fontSize.base, 230 230 style, ··· 274 274 style={mergeStyles( 275 275 { color: theme.colors.textMuted, marginTop: -8 }, 276 276 pt[1], 277 - pl[4], 278 277 pb[2], 279 278 fontSize.sm, 280 279 style,
+1
js/components/src/hooks/index.ts
··· 1 1 // barrel file :) 2 2 export * from "./useAvatars"; 3 3 export * from "./useCameraToggle"; 4 + export * from "./useDocumentTitle"; 4 5 export * from "./useKeyboard"; 5 6 export * from "./useKeyboardSlide"; 6 7 export * from "./useLivestreamInfo";
+45
js/components/src/hooks/useDocumentTitle.tsx
··· 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 + }
+58
js/components/src/lib/theme/branded-theme-provider.tsx
··· 1 + import { useMemo, type ReactNode } from "react"; 2 + import { 3 + useAccentColor, 4 + usePrimaryColor, 5 + useStreamplaceStore, 6 + } from "../../streamplace-store"; 7 + import { ThemeProvider, type Theme } from "./theme"; 8 + 9 + interface BrandedThemeProviderProps { 10 + children: ReactNode; 11 + defaultTheme?: "light" | "dark" | "system"; 12 + forcedTheme?: "light" | "dark"; 13 + } 14 + 15 + /** 16 + * ThemeProvider wrapper that automatically applies branding colors from the 17 + * broadcaster's branding configuration. 18 + */ 19 + export function BrandedThemeProvider({ 20 + children, 21 + defaultTheme, 22 + forcedTheme, 23 + }: BrandedThemeProviderProps) { 24 + const primaryColor = usePrimaryColor(); 25 + const accentColor = useAccentColor(); 26 + const brandingLoading = useStreamplaceStore((state) => state.brandingLoading); 27 + 28 + // Build color theme overrides from branding 29 + const colorTheme = useMemo<Partial<Theme["colors"]>>(() => { 30 + // don't override until branding is loaded 31 + if (brandingLoading) { 32 + return {}; 33 + } 34 + 35 + const overrides: Partial<Theme["colors"]> = {}; 36 + 37 + if (primaryColor) { 38 + overrides.primary = primaryColor; 39 + overrides.ring = primaryColor; 40 + } 41 + 42 + if (accentColor) { 43 + overrides.accent = accentColor; 44 + } 45 + 46 + return overrides; 47 + }, [primaryColor, accentColor, brandingLoading]); 48 + 49 + return ( 50 + <ThemeProvider 51 + defaultTheme={defaultTheme} 52 + forcedTheme={forcedTheme} 53 + colorTheme={colorTheme} 54 + > 55 + {children} 56 + </ThemeProvider> 57 + ); 58 + }
+3
js/components/src/lib/theme/index.ts
··· 12 12 type ThemeIcons, 13 13 } from "./theme"; 14 14 15 + // Branded theme provider 16 + export { BrandedThemeProvider } from "./branded-theme-provider"; 17 + 15 18 // Design tokens 16 19 export { 17 20 animations,
+23 -4
js/components/src/streamplace-provider/index.tsx
··· 1 1 import { SessionManager } from "@atproto/api/dist/session-manager"; 2 2 import { useEffect, useRef } from "react"; 3 - import { useGetChatProfile } from "../streamplace-store"; 3 + import { useDocumentTitle } from "../hooks"; 4 + import { 5 + useBrandingAutoFetch, 6 + useFetchBroadcasterDID, 7 + useGetChatProfile, 8 + } from "../streamplace-store"; 4 9 import { makeStreamplaceStore } from "../streamplace-store/streamplace-store"; 5 10 import { StreamplaceContext } from "./context"; 6 11 import Poller from "./poller"; ··· 27 32 28 33 return ( 29 34 <StreamplaceContext.Provider value={{ store: store }}> 30 - <ChatProfileCreator oauthSession={oauthSession}> 31 - <Poller>{children}</Poller> 32 - </ChatProfileCreator> 35 + <BrandingFetcher> 36 + <ChatProfileCreator oauthSession={oauthSession}> 37 + <Poller>{children}</Poller> 38 + </ChatProfileCreator> 39 + </BrandingFetcher> 33 40 </StreamplaceContext.Provider> 34 41 ); 42 + } 43 + 44 + export function BrandingFetcher({ children }: { children: React.ReactNode }) { 45 + const fetchBroadcasterDID = useFetchBroadcasterDID(); 46 + useBrandingAutoFetch(); 47 + useDocumentTitle(); 48 + 49 + useEffect(() => { 50 + fetchBroadcasterDID(); 51 + }, [fetchBroadcasterDID]); 52 + 53 + return <>{children}</>; 35 54 } 36 55 37 56 export function ChatProfileCreator({
+216
js/components/src/streamplace-store/branding.tsx
··· 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
··· 1 1 export * from "./block"; 2 + export * from "./branding"; 2 3 export * from "./moderation"; 3 4 export * from "./moderator-management"; 4 5 export * from "./stream";
+15
js/components/src/streamplace-store/streamplace-store.tsx
··· 4 4 import { createStore, StoreApi, useStore } from "zustand"; 5 5 import storage from "../storage"; 6 6 import { StreamplaceContext } from "../streamplace-provider/context"; 7 + import { BrandingAsset } from "./branding"; 7 8 8 9 export interface ContentMetadataResult { 9 10 record: any; ··· 48 49 setBroadcasterDID: (broadcasterDID: string | null) => void; 49 50 serverDID: string | null; 50 51 setServerDID: (serverDID: string | null) => void; 52 + adminDIDs: string[]; 53 + setAdminDIDs: (adminDIDs: string[]) => void; 54 + 55 + // Branding state 56 + branding: Record<string, BrandingAsset> | null; 57 + brandingLoading: boolean; 58 + brandingError: string | null; 51 59 52 60 // Volume state 53 61 volume: number; ··· 111 119 set({ broadcasterDID }), 112 120 serverDID: null, 113 121 setServerDID: (serverDID: string | null) => set({ serverDID }), 122 + adminDIDs: [], 123 + setAdminDIDs: (adminDIDs: string[]) => set({ adminDIDs }), 114 124 115 125 // Content metadata 116 126 contentMetadata: null, 117 127 setContentMetadata: (metadata) => set({ contentMetadata: metadata }), 128 + 129 + // Branding state 130 + branding: null, 131 + brandingLoading: false, 132 + brandingError: null, 118 133 119 134 // Volume state - start with defaults 120 135 volume: 1.0,
+18 -1
js/components/src/streamplace-store/xrpc.tsx
··· 1 1 import { useMemo } from "react"; 2 2 import { StreamplaceAgent } from "streamplace"; 3 - import { useStreamplaceStore } from "."; 3 + import { useStreamplaceStore, useUrl } from "."; 4 4 5 5 export function usePDSAgent(): StreamplaceAgent | null { 6 6 const oauthSession = useStreamplaceStore((state) => state.oauthSession); ··· 20 20 return new StreamplaceAgent(oauthSession); 21 21 }, [oauthSession]); 22 22 } 23 + 24 + // can be unauthed, but will always use the current node URL 25 + export function usePossiblyUnauthedPDSAgent(): StreamplaceAgent | null { 26 + const nodeUrl = useUrl(); 27 + const oauthSession = useStreamplaceStore((state) => state.oauthSession); 28 + // oauthsession is 29 + // - undefined when loading 30 + // - null when logged out, and 31 + // - SessionManager when logged in 32 + return useMemo(() => { 33 + if (!oauthSession) { 34 + return new StreamplaceAgent(nodeUrl); 35 + } 36 + 37 + return new StreamplaceAgent(oauthSession); 38 + }, [oauthSession]); 39 + }
+3
js/docs/src/content/docs/guides/installing/downloading-streamplace.md
··· 34 34 SP_HTTPS_ADDR=:443 35 35 SP_SECURE=true 36 36 37 + # 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 + 37 40 # If you're running Streamplace behind an HTTPS proxy, you'll want 38 41 # SP_SECURE=false 39 42 # SP_BEHIND_HTTPS_PROXY=true
+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 + ```
+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 + ```
+128
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 + | `width` | `integer` | ❌ | Image width in pixels (optional, for images only) | | 52 + | `height` | `integer` | ❌ | Image height in pixels (optional, for images only) | | 53 + 54 + --- 55 + 56 + ## Lexicon Source 57 + 58 + ```json 59 + { 60 + "lexicon": 1, 61 + "id": "place.stream.branding.getBranding", 62 + "defs": { 63 + "main": { 64 + "type": "query", 65 + "description": "Get all branding configuration for the broadcaster.", 66 + "parameters": { 67 + "type": "params", 68 + "required": [], 69 + "properties": { 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 + "output": { 78 + "encoding": "application/json", 79 + "schema": { 80 + "type": "object", 81 + "required": ["assets"], 82 + "properties": { 83 + "assets": { 84 + "type": "array", 85 + "description": "List of available branding assets", 86 + "items": { 87 + "type": "ref", 88 + "ref": "#brandingAsset" 89 + } 90 + } 91 + } 92 + } 93 + }, 94 + "errors": [] 95 + }, 96 + "brandingAsset": { 97 + "type": "object", 98 + "required": ["key", "mimeType"], 99 + "properties": { 100 + "key": { 101 + "type": "string", 102 + "description": "Asset key identifier" 103 + }, 104 + "mimeType": { 105 + "type": "string", 106 + "description": "MIME type of the asset" 107 + }, 108 + "url": { 109 + "type": "string", 110 + "description": "URL to fetch the asset blob (for images)" 111 + }, 112 + "data": { 113 + "type": "string", 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)" 123 + } 124 + } 125 + } 126 + } 127 + } 128 + ```
+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 + ```
+13 -4
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-getbroadcaster.md
··· 24 24 25 25 **Schema Type:** `object` 26 26 27 - | Name | Type | Req'd | Description | Constraints | 28 - | ------------- | -------- | ----- | --------------------------------------------------------------- | ------------- | 29 - | `broadcaster` | `string` | ✅ | DID of the Streamplace broadcaster to which this server belongs | Format: `did` | 30 - | `server` | `string` | ❌ | DID of this particular Streamplace server | Format: `did` | 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------- | ----------------- | ----- | --------------------------------------------------------------- | ------------- | 29 + | `broadcaster` | `string` | ✅ | DID of the Streamplace broadcaster to which this server belongs | Format: `did` | 30 + | `server` | `string` | ❌ | DID of this particular Streamplace server | Format: `did` | 31 + | `admins` | Array of `string` | ❌ | Array of DIDs authorized as admins | | 31 32 32 33 --- 33 34 ··· 61 62 "type": "string", 62 63 "format": "did", 63 64 "description": "DID of this particular Streamplace server" 65 + }, 66 + "admins": { 67 + "type": "array", 68 + "items": { 69 + "type": "string", 70 + "format": "did" 71 + }, 72 + "description": "Array of DIDs authorized as admins" 64 73 } 65 74 } 66 75 }
+308
js/docs/src/content/docs/lex-reference/openapi.json
··· 1511 1511 "type": "string", 1512 1512 "description": "DID of this particular Streamplace server", 1513 1513 "format": "did" 1514 + }, 1515 + "admins": { 1516 + "type": "array", 1517 + "description": "Array of DIDs authorized as admins", 1518 + "items": { 1519 + "type": "string", 1520 + "format": "did" 1521 + } 1514 1522 } 1515 1523 }, 1516 1524 "required": ["broadcaster"] 1517 1525 } 1526 + } 1527 + } 1528 + } 1529 + } 1530 + } 1531 + }, 1532 + "/xrpc/place.stream.branding.deleteBlob": { 1533 + "post": { 1534 + "summary": "Delete a branding asset blob. Requires admin authorization.", 1535 + "operationId": "place.stream.branding.deleteBlob", 1536 + "tags": ["place.stream.branding"], 1537 + "responses": { 1538 + "200": { 1539 + "description": "Success", 1540 + "content": { 1541 + "application/json": { 1542 + "schema": { 1543 + "type": "object", 1544 + "properties": { 1545 + "success": { 1546 + "type": "boolean" 1547 + } 1548 + }, 1549 + "required": ["success"] 1550 + } 1551 + } 1552 + } 1553 + }, 1554 + "400": { 1555 + "description": "Bad Request", 1556 + "content": { 1557 + "application/json": { 1558 + "schema": { 1559 + "type": "object", 1560 + "required": ["error", "message"], 1561 + "properties": { 1562 + "error": { 1563 + "type": "string", 1564 + "oneOf": [ 1565 + { 1566 + "const": "Unauthorized" 1567 + }, 1568 + { 1569 + "const": "BrandingNotFound" 1570 + } 1571 + ] 1572 + }, 1573 + "message": { 1574 + "type": "string" 1575 + } 1576 + } 1577 + } 1578 + } 1579 + } 1580 + } 1581 + }, 1582 + "requestBody": { 1583 + "required": true, 1584 + "content": { 1585 + "application/json": { 1586 + "schema": { 1587 + "type": "object", 1588 + "properties": { 1589 + "key": { 1590 + "type": "string", 1591 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 1592 + }, 1593 + "broadcaster": { 1594 + "type": "string", 1595 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1596 + "format": "did" 1597 + } 1598 + }, 1599 + "required": ["key"] 1600 + } 1601 + } 1602 + } 1603 + } 1604 + } 1605 + }, 1606 + "/xrpc/place.stream.branding.getBlob": { 1607 + "get": { 1608 + "summary": "Get a specific branding asset blob by key.", 1609 + "operationId": "place.stream.branding.getBlob", 1610 + "tags": ["place.stream.branding"], 1611 + "responses": { 1612 + "200": { 1613 + "description": "Raw blob data with appropriate content-type", 1614 + "content": { 1615 + "*/*": { 1616 + "schema": {} 1617 + } 1618 + } 1619 + }, 1620 + "400": { 1621 + "description": "Bad Request", 1622 + "content": { 1623 + "application/json": { 1624 + "schema": { 1625 + "type": "object", 1626 + "required": ["error", "message"], 1627 + "properties": { 1628 + "error": { 1629 + "type": "string", 1630 + "oneOf": [ 1631 + { 1632 + "const": "BrandingNotFound" 1633 + } 1634 + ] 1635 + }, 1636 + "message": { 1637 + "type": "string" 1638 + } 1639 + } 1640 + } 1641 + } 1642 + } 1643 + } 1644 + }, 1645 + "parameters": [ 1646 + { 1647 + "name": "key", 1648 + "in": "query", 1649 + "required": true, 1650 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)", 1651 + "schema": { 1652 + "type": "string", 1653 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 1654 + } 1655 + }, 1656 + { 1657 + "name": "broadcaster", 1658 + "in": "query", 1659 + "required": false, 1660 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1661 + "schema": { 1662 + "type": "string", 1663 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1664 + "format": "did" 1665 + } 1666 + } 1667 + ] 1668 + } 1669 + }, 1670 + "/xrpc/place.stream.branding.getBranding": { 1671 + "get": { 1672 + "summary": "Get all branding configuration for the broadcaster.", 1673 + "operationId": "place.stream.branding.getBranding", 1674 + "tags": ["place.stream.branding"], 1675 + "responses": { 1676 + "200": { 1677 + "description": "Success", 1678 + "content": { 1679 + "application/json": { 1680 + "schema": { 1681 + "type": "object", 1682 + "properties": { 1683 + "assets": { 1684 + "type": "array", 1685 + "description": "List of available branding assets", 1686 + "items": { 1687 + "$ref": "#/components/schemas/place.stream.branding.getBranding_brandingAsset" 1688 + } 1689 + } 1690 + }, 1691 + "required": ["assets"] 1692 + } 1693 + } 1694 + } 1695 + } 1696 + }, 1697 + "parameters": [ 1698 + { 1699 + "name": "broadcaster", 1700 + "in": "query", 1701 + "required": false, 1702 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1703 + "schema": { 1704 + "type": "string", 1705 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1706 + "format": "did" 1707 + } 1708 + } 1709 + ] 1710 + } 1711 + }, 1712 + "/xrpc/place.stream.branding.updateBlob": { 1713 + "post": { 1714 + "summary": "Update or create a branding asset blob. Requires admin authorization.", 1715 + "operationId": "place.stream.branding.updateBlob", 1716 + "tags": ["place.stream.branding"], 1717 + "responses": { 1718 + "200": { 1719 + "description": "Success", 1720 + "content": { 1721 + "application/json": { 1722 + "schema": { 1723 + "type": "object", 1724 + "properties": { 1725 + "success": { 1726 + "type": "boolean" 1727 + } 1728 + }, 1729 + "required": ["success"] 1730 + } 1731 + } 1732 + } 1733 + }, 1734 + "400": { 1735 + "description": "Bad Request", 1736 + "content": { 1737 + "application/json": { 1738 + "schema": { 1739 + "type": "object", 1740 + "required": ["error", "message"], 1741 + "properties": { 1742 + "error": { 1743 + "type": "string", 1744 + "oneOf": [ 1745 + { 1746 + "const": "Unauthorized" 1747 + }, 1748 + { 1749 + "const": "BlobTooLarge" 1750 + } 1751 + ] 1752 + }, 1753 + "message": { 1754 + "type": "string" 1755 + } 1756 + } 1757 + } 1758 + } 1759 + } 1760 + } 1761 + }, 1762 + "requestBody": { 1763 + "required": true, 1764 + "content": { 1765 + "application/json": { 1766 + "schema": { 1767 + "type": "object", 1768 + "properties": { 1769 + "key": { 1770 + "type": "string", 1771 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 1772 + }, 1773 + "broadcaster": { 1774 + "type": "string", 1775 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster.", 1776 + "format": "did" 1777 + }, 1778 + "data": { 1779 + "type": "string", 1780 + "description": "Base64-encoded blob data" 1781 + }, 1782 + "mimeType": { 1783 + "type": "string", 1784 + "description": "MIME type of the blob (e.g., image/png, text/plain)" 1785 + }, 1786 + "width": { 1787 + "type": "integer", 1788 + "description": "Image width in pixels (optional, for images only)" 1789 + }, 1790 + "height": { 1791 + "type": "integer", 1792 + "description": "Image height in pixels (optional, for images only)" 1793 + } 1794 + }, 1795 + "required": ["key", "data", "mimeType"] 1518 1796 } 1519 1797 } 1520 1798 } ··· 2975 3253 } 2976 3254 }, 2977 3255 "required": ["uri", "cid"] 3256 + }, 3257 + "place.stream.branding.getBranding_brandingAsset": { 3258 + "type": "object", 3259 + "properties": { 3260 + "key": { 3261 + "type": "string", 3262 + "description": "Asset key identifier" 3263 + }, 3264 + "mimeType": { 3265 + "type": "string", 3266 + "description": "MIME type of the asset" 3267 + }, 3268 + "url": { 3269 + "type": "string", 3270 + "description": "URL to fetch the asset blob (for images)" 3271 + }, 3272 + "data": { 3273 + "type": "string", 3274 + "description": "Inline data for text assets" 3275 + }, 3276 + "width": { 3277 + "type": "integer", 3278 + "description": "Image width in pixels (optional, for images only)" 3279 + }, 3280 + "height": { 3281 + "type": "integer", 3282 + "description": "Image height in pixels (optional, for images only)" 3283 + } 3284 + }, 3285 + "required": ["key", "mimeType"] 2978 3286 }, 2979 3287 "com.atproto.sync.listRepos_repo": { 2980 3288 "type": "object",
+50
lexicons/place/stream/branding/deleteBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.branding.deleteBlob", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a branding asset blob. Requires admin authorization.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["key"], 13 + "properties": { 14 + "key": { 15 + "type": "string", 16 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 17 + }, 18 + "broadcaster": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 22 + } 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["success"], 31 + "properties": { 32 + "success": { 33 + "type": "boolean" 34 + } 35 + } 36 + } 37 + }, 38 + "errors": [ 39 + { 40 + "name": "Unauthorized", 41 + "description": "The authenticated DID is not authorized to modify branding" 42 + }, 43 + { 44 + "name": "BrandingNotFound", 45 + "description": "The requested branding asset does not exist" 46 + } 47 + ] 48 + } 49 + } 50 + }
+35
lexicons/place/stream/branding/getBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.branding.getBlob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a specific branding asset blob by key.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["key"], 11 + "properties": { 12 + "key": { 13 + "type": "string", 14 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 15 + }, 16 + "broadcaster": { 17 + "type": "string", 18 + "format": "did", 19 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "*/*", 25 + "description": "Raw blob data with appropriate content-type" 26 + }, 27 + "errors": [ 28 + { 29 + "name": "BrandingNotFound", 30 + "description": "The requested branding asset does not exist" 31 + } 32 + ] 33 + } 34 + } 35 + }
+69
lexicons/place/stream/branding/getBranding.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.branding.getBranding", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get all branding configuration for the broadcaster.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [], 11 + "properties": { 12 + "broadcaster": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["assets"], 24 + "properties": { 25 + "assets": { 26 + "type": "array", 27 + "description": "List of available branding assets", 28 + "items": { 29 + "type": "ref", 30 + "ref": "#brandingAsset" 31 + } 32 + } 33 + } 34 + } 35 + }, 36 + "errors": [] 37 + }, 38 + "brandingAsset": { 39 + "type": "object", 40 + "required": ["key", "mimeType"], 41 + "properties": { 42 + "key": { 43 + "type": "string", 44 + "description": "Asset key identifier" 45 + }, 46 + "mimeType": { 47 + "type": "string", 48 + "description": "MIME type of the asset" 49 + }, 50 + "url": { 51 + "type": "string", 52 + "description": "URL to fetch the asset blob (for images)" 53 + }, 54 + "data": { 55 + "type": "string", 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)" 65 + } 66 + } 67 + } 68 + } 69 + }
+66
lexicons/place/stream/branding/updateBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.branding.updateBlob", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update or create a branding asset blob. Requires admin authorization.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["key", "data", "mimeType"], 13 + "properties": { 14 + "key": { 15 + "type": "string", 16 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 17 + }, 18 + "broadcaster": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 22 + }, 23 + "data": { 24 + "type": "string", 25 + "description": "Base64-encoded blob data" 26 + }, 27 + "mimeType": { 28 + "type": "string", 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)" 38 + } 39 + } 40 + } 41 + }, 42 + "output": { 43 + "encoding": "application/json", 44 + "schema": { 45 + "type": "object", 46 + "required": ["success"], 47 + "properties": { 48 + "success": { 49 + "type": "boolean" 50 + } 51 + } 52 + } 53 + }, 54 + "errors": [ 55 + { 56 + "name": "Unauthorized", 57 + "description": "The authenticated DID is not authorized to modify branding" 58 + }, 59 + { 60 + "name": "BlobTooLarge", 61 + "description": "The blob exceeds the maximum size limit" 62 + } 63 + ] 64 + } 65 + } 66 + }
+8
lexicons/place/stream/broadcast/getBroadcaster.json
··· 25 25 "type": "string", 26 26 "format": "did", 27 27 "description": "DID of this particular Streamplace server" 28 + }, 29 + "admins": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "format": "did" 34 + }, 35 + "description": "Array of DIDs authorized as admins" 28 36 } 29 37 } 30 38 }
+12
pkg/api/api.go
··· 21 21 "github.com/bluesky-social/indigo/api/bsky" 22 22 "github.com/google/uuid" 23 23 "github.com/julienschmidt/httprouter" 24 + "github.com/labstack/echo/v4" 24 25 "github.com/rs/cors" 25 26 sloghttp "github.com/samber/slog-http" 26 27 "golang.org/x/time/rate" ··· 61 62 FirebaseNotifier notifications.FirebaseNotifier 62 63 MediaManager *media.MediaManager 63 64 MediaSigner media.MediaSigner 65 + XRPCServer *spxrpc.Server 64 66 // not thread-safe yet 65 67 Aliases map[string]string 66 68 Bus *bus.Bus ··· 154 156 if err != nil { 155 157 return nil, err 156 158 } 159 + a.XRPCServer = xrpc.(*spxrpc.Server) 157 160 router := httprouter.New() 158 161 159 162 // Create our middleware factory with the default settings. ··· 218 221 router.Handler("PUT", "/xrpc/*resource", xrpcHandler) 219 222 router.Handler("PATCH", "/xrpc/*resource", xrpcHandler) 220 223 router.Handler("DELETE", "/xrpc/*resource", xrpcHandler) 224 + // i wonder if there's a better way to do this? 225 + router.GET("/favicon.ico", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 226 + err := a.XRPCServer.HandleFaviconICO(echo.New().NewContext(r, w)) 227 + if err != nil { 228 + log.Error(ctx, "error handling favicon.ico", "error", err) 229 + w.WriteHeader(500) 230 + return 231 + } 232 + }) 221 233 router.GET("/.well-known/did.json", a.HandleDidJSON(ctx)) 222 234 router.GET("/.well-known/atproto-did", a.HandleAtprotoDID(ctx)) 223 235 router.GET("/dl/*params", a.HandleAppDownload(ctx))
+2
pkg/config/config.go
··· 137 137 WebsocketURL string 138 138 BehindHTTPSProxy bool 139 139 SegmentDebugDir string 140 + AdminDIDs []string 140 141 Syndicate []string 141 142 } 142 143 ··· 235 236 cli.StringSliceFlag(fs, &cli.Replicators, "replicators", []string{ReplicatorWebsocket}, "list of replication protocols to use (http, iroh)") 236 237 fs.StringVar(&cli.WebsocketURL, "websocket-url", "", "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)") 237 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") 238 240 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") 239 241 240 242 fs.Bool("external-signing", true, "DEPRECATED, does nothing.")
+266
pkg/spxrpc/place_stream_branding.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + _ "embed" 7 + "encoding/base64" 8 + "fmt" 9 + "io" 10 + "net/http" 11 + 12 + "github.com/labstack/echo/v4" 13 + "github.com/streamplace/oatproxy/pkg/oatproxy" 14 + "gorm.io/gorm" 15 + "stream.place/streamplace/js/app" 16 + "stream.place/streamplace/pkg/log" 17 + placestreamtypes "stream.place/streamplace/pkg/streamplace" 18 + ) 19 + 20 + var defaultBrandingAssets = map[string]struct { 21 + data []byte 22 + mime string 23 + }{ 24 + // "mainLogo": {data: defaultLogoSVG, mime: "image/svg+xml"}, 25 + // "favicon": {data: defaultFaviconSVG, mime: "image/svg+xml"}, 26 + "siteTitle": {data: []byte(""), mime: "text/plain"}, 27 + "siteDescription": {data: []byte(""), mime: "text/plain"}, 28 + "primaryColor": {data: []byte("#6366f1"), mime: "text/plain"}, 29 + "accentColor": {data: []byte("#8b5cf6"), mime: "text/plain"}, 30 + "defaultStreamer": {data: []byte(""), mime: "text/plain"}, 31 + } 32 + 33 + func (s *Server) getBroadcasterID(ctx context.Context, broadcasterDID string) string { 34 + // if broadcaster param provided, use it; otherwise use server's default 35 + if broadcasterDID != "" { 36 + return broadcasterDID 37 + } 38 + return s.cli.BroadcasterHost 39 + } 40 + 41 + func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 + // cache miss - fetch from db 43 + blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 + if err == gorm.ErrRecordNotFound { 45 + // not in db, use default 46 + if def, ok := defaultBrandingAssets[key]; ok { 47 + return def.data, def.mime, nil, nil, nil 48 + } 49 + return nil, "", nil, nil, fmt.Errorf("unknown branding key: %s", key) 50 + } 51 + if err != nil { 52 + return nil, "", nil, nil, fmt.Errorf("error fetching branding blob: %w", err) 53 + } 54 + return blob.Data, blob.MimeType, blob.Width, blob.Height, nil 55 + } 56 + 57 + func (s *Server) handlePlaceStreamBrandingGetBlob(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 58 + return s.HandlePlaceStreamBrandingGetBlobDirect(ctx, broadcasterDID, key) 59 + } 60 + 61 + // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 + func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 + broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 + data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 65 + if err != nil { 66 + return nil, err 67 + } 68 + return bytes.NewReader(data), nil 69 + } 70 + 71 + func (s *Server) handlePlaceStreamBrandingGetBranding(ctx context.Context, broadcasterDID string) (*placestreamtypes.BrandingGetBranding_Output, error) { 72 + return s.HandlePlaceStreamBrandingGetBrandingDirect(ctx, broadcasterDID) 73 + } 74 + 75 + // HandlePlaceStreamBrandingGetBrandingDirect is the exported version for direct calls 76 + func (s *Server) HandlePlaceStreamBrandingGetBrandingDirect(ctx context.Context, broadcasterDID string) (*placestreamtypes.BrandingGetBranding_Output, error) { 77 + broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 78 + 79 + // get all keys from database 80 + dbKeys, err := s.statefulDB.ListBrandingKeys(broadcasterID) 81 + if err != nil { 82 + return nil, fmt.Errorf("error listing branding keys: %w", err) 83 + } 84 + 85 + // build key set including defaults 86 + allKeys := make(map[string]bool) 87 + for _, key := range dbKeys { 88 + allKeys[key] = true 89 + } 90 + for key := range defaultBrandingAssets { 91 + allKeys[key] = true 92 + } 93 + 94 + // build output 95 + assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 + for key := range allKeys { 97 + data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 98 + if err != nil { 99 + continue // skip if error 100 + } 101 + 102 + asset := &placestreamtypes.BrandingGetBranding_BrandingAsset{ 103 + Key: key, 104 + MimeType: mimeType, 105 + } 106 + 107 + // add dimensions if available 108 + if width != nil { 109 + w := int64(*width) 110 + asset.Width = &w 111 + } 112 + if height != nil { 113 + h := int64(*height) 114 + asset.Height = &h 115 + } 116 + 117 + // for text assets, include data inline; for images, provide URL 118 + if mimeType == "text/plain" { 119 + str := string(data) 120 + asset.Data = &str 121 + } else { 122 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", key, broadcasterID) 123 + asset.Url = &url 124 + } 125 + 126 + assets = append(assets, asset) 127 + } 128 + 129 + return &placestreamtypes.BrandingGetBranding_Output{ 130 + Assets: assets, 131 + }, nil 132 + } 133 + 134 + func (s *Server) isAdminDID(did string) bool { 135 + for _, adminDID := range s.cli.AdminDIDs { 136 + if adminDID == did { 137 + return true 138 + } 139 + } 140 + return false 141 + } 142 + 143 + func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context, input *placestreamtypes.BrandingUpdateBlob_Input) (*placestreamtypes.BrandingUpdateBlob_Output, error) { 144 + // check authentication 145 + session, _ := oatproxy.GetOAuthSession(ctx) 146 + if session == nil { 147 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 148 + } 149 + 150 + // check admin authorization 151 + if !s.isAdminDID(session.DID) { 152 + log.Warn(ctx, "unauthorized branding update attempt", "did", session.DID) 153 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "not authorized to modify branding") 154 + } 155 + 156 + var broadcasterDID string 157 + if input.Broadcaster != nil { 158 + broadcasterDID = *input.Broadcaster 159 + } 160 + broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 161 + 162 + // decode base64 data 163 + data, err := base64.StdEncoding.DecodeString(input.Data) 164 + if err != nil { 165 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid base64 data") 166 + } 167 + 168 + // validate size based on key type 169 + maxSize := 500 * 1024 // 500KB default for logos 170 + if input.Key == "favicon" { 171 + maxSize = 100 * 1024 // 100KB for favicons 172 + } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamer" { 173 + maxSize = 1024 // 1KB for text values 174 + } 175 + // sidebarBackgroundImage uses default 500KB limit 176 + if len(data) > maxSize { 177 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("blob too large (max %d bytes)", maxSize)) 178 + } 179 + 180 + // store in database 181 + var width, height *int 182 + if input.Width != nil { 183 + w := int(*input.Width) 184 + width = &w 185 + } 186 + if input.Height != nil { 187 + h := int(*input.Height) 188 + height = &h 189 + } 190 + 191 + err = s.statefulDB.PutBrandingBlob(broadcasterID, input.Key, input.MimeType, data, width, height) 192 + if err != nil { 193 + log.Error(ctx, "failed to store branding blob", "err", err) 194 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to store branding blob") 195 + } 196 + 197 + return &placestreamtypes.BrandingUpdateBlob_Output{ 198 + Success: true, 199 + }, nil 200 + } 201 + 202 + func (s *Server) handlePlaceStreamBrandingDeleteBlob(ctx context.Context, input *placestreamtypes.BrandingDeleteBlob_Input) (*placestreamtypes.BrandingDeleteBlob_Output, error) { 203 + // check authentication 204 + session, _ := oatproxy.GetOAuthSession(ctx) 205 + if session == nil { 206 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 207 + } 208 + 209 + // check admin authorization 210 + if !s.isAdminDID(session.DID) { 211 + log.Warn(ctx, "unauthorized branding delete attempt", "did", session.DID) 212 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "not authorized to modify branding") 213 + } 214 + 215 + var broadcasterDID string 216 + if input.Broadcaster != nil { 217 + broadcasterDID = *input.Broadcaster 218 + } 219 + broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 220 + 221 + err := s.statefulDB.DeleteBrandingBlob(broadcasterID, input.Key) 222 + if err != nil { 223 + if err == gorm.ErrRecordNotFound { 224 + return nil, echo.NewHTTPError(http.StatusNotFound, "branding asset not found") 225 + } 226 + log.Error(ctx, "failed to delete branding blob", "err", err) 227 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to delete branding blob") 228 + } 229 + 230 + return &placestreamtypes.BrandingDeleteBlob_Output{ 231 + Success: true, 232 + }, nil 233 + } 234 + 235 + // HandleFaviconICO serves the favicon at /favicon.ico 236 + func (s *Server) HandleFaviconICO(c echo.Context) error { 237 + ctx := c.Request().Context() 238 + 239 + broadcasterID := s.cli.BroadcasterHost 240 + log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 + data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 + 243 + if err != nil || data == nil { 244 + log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil) 245 + distFiles, fsErr := app.Files() 246 + if fsErr != nil { 247 + return echo.NewHTTPError(http.StatusInternalServerError, "failed to fetch favicon") 248 + } 249 + 250 + faviconFile, fsErr := distFiles.Open("favicon.ico") 251 + if fsErr != nil { 252 + return echo.NewHTTPError(http.StatusNotFound, "favicon not found") 253 + } 254 + defer faviconFile.Close() 255 + 256 + data, fsErr = io.ReadAll(faviconFile) 257 + if fsErr != nil { 258 + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read favicon") 259 + } 260 + 261 + // detect mime type based on file extension (ico) 262 + mimeType = "image/x-icon" 263 + } 264 + 265 + return c.Blob(http.StatusOK, mimeType, data) 266 + }
+1
pkg/spxrpc/place_stream_broadcast.go
··· 13 13 return &placestreamtypes.BroadcastGetBroadcaster_Output{ 14 14 Broadcaster: broadcaster, 15 15 Server: &server, 16 + Admins: s.cli.AdminDIDs, 16 17 }, nil 17 18 }
+69
pkg/spxrpc/stubs.go
··· 262 262 } 263 263 264 264 func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { 265 + e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob) 266 + e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob) 267 + e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding) 268 + e.POST("/xrpc/place.stream.branding.updateBlob", s.HandlePlaceStreamBrandingUpdateBlob) 265 269 e.GET("/xrpc/place.stream.broadcast.getBroadcaster", s.HandlePlaceStreamBroadcastGetBroadcaster) 266 270 e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) 267 271 e.GET("/xrpc/place.stream.live.getLiveUsers", s.HandlePlaceStreamLiveGetLiveUsers) ··· 285 289 e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks) 286 290 e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook) 287 291 return nil 292 + } 293 + 294 + func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error { 295 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingDeleteBlob") 296 + defer span.End() 297 + 298 + var body placestream.BrandingDeleteBlob_Input 299 + if err := c.Bind(&body); err != nil { 300 + return err 301 + } 302 + var out *placestream.BrandingDeleteBlob_Output 303 + var handleErr error 304 + // func (s *Server) handlePlaceStreamBrandingDeleteBlob(ctx context.Context,body *placestream.BrandingDeleteBlob_Input) (*placestream.BrandingDeleteBlob_Output, error) 305 + out, handleErr = s.handlePlaceStreamBrandingDeleteBlob(ctx, &body) 306 + if handleErr != nil { 307 + return handleErr 308 + } 309 + return c.JSON(200, out) 310 + } 311 + 312 + func (s *Server) HandlePlaceStreamBrandingGetBlob(c echo.Context) error { 313 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingGetBlob") 314 + defer span.End() 315 + broadcaster := c.QueryParam("broadcaster") 316 + key := c.QueryParam("key") 317 + var out io.Reader 318 + var handleErr error 319 + // func (s *Server) handlePlaceStreamBrandingGetBlob(ctx context.Context,broadcaster string,key string) (io.Reader, error) 320 + out, handleErr = s.handlePlaceStreamBrandingGetBlob(ctx, broadcaster, key) 321 + if handleErr != nil { 322 + return handleErr 323 + } 324 + return c.Stream(200, "application/octet-stream", out) 325 + } 326 + 327 + func (s *Server) HandlePlaceStreamBrandingGetBranding(c echo.Context) error { 328 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingGetBranding") 329 + defer span.End() 330 + broadcaster := c.QueryParam("broadcaster") 331 + var out *placestream.BrandingGetBranding_Output 332 + var handleErr error 333 + // func (s *Server) handlePlaceStreamBrandingGetBranding(ctx context.Context,broadcaster string) (*placestream.BrandingGetBranding_Output, error) 334 + out, handleErr = s.handlePlaceStreamBrandingGetBranding(ctx, broadcaster) 335 + if handleErr != nil { 336 + return handleErr 337 + } 338 + return c.JSON(200, out) 339 + } 340 + 341 + func (s *Server) HandlePlaceStreamBrandingUpdateBlob(c echo.Context) error { 342 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingUpdateBlob") 343 + defer span.End() 344 + 345 + var body placestream.BrandingUpdateBlob_Input 346 + if err := c.Bind(&body); err != nil { 347 + return err 348 + } 349 + var out *placestream.BrandingUpdateBlob_Output 350 + var handleErr error 351 + // func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context,body *placestream.BrandingUpdateBlob_Input) (*placestream.BrandingUpdateBlob_Output, error) 352 + out, handleErr = s.handlePlaceStreamBrandingUpdateBlob(ctx, &body) 353 + if handleErr != nil { 354 + return handleErr 355 + } 356 + return c.JSON(200, out) 288 357 } 289 358 290 359 func (s *Server) HandlePlaceStreamBroadcastGetBroadcaster(c echo.Context) error {
+89
pkg/statedb/branding.go
··· 1 + package statedb 2 + 3 + import ( 4 + "fmt" 5 + 6 + "gorm.io/gorm" 7 + ) 8 + 9 + // BrandingBlob stores branding assets for broadcasters 10 + type BrandingBlob struct { 11 + gorm.Model 12 + BroadcasterID string `gorm:"index:idx_broadcaster_key,priority:1,unique"` 13 + Key string `gorm:"index:idx_broadcaster_key,priority:2,unique"` // "mainLogo", "favicon", "siteTitle", etc. 14 + MimeType string // "image/svg+xml", "image/png", "text/plain" 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) 18 + } 19 + 20 + // GetBrandingBlob fetches a single branding asset 21 + func (state *StatefulDB) GetBrandingBlob(broadcasterID, key string) (*BrandingBlob, error) { 22 + var blob BrandingBlob 23 + err := state.DB.Where("broadcaster_id = ? AND key = ?", broadcasterID, key).First(&blob).Error 24 + if err != nil { 25 + return nil, err 26 + } 27 + return &blob, nil 28 + } 29 + 30 + // PutBrandingBlob stores or updates a branding asset 31 + func (state *StatefulDB) PutBrandingBlob(broadcasterID, key, mimeType string, data []byte, width, height *int) error { 32 + // try to find existing blob (including soft-deleted ones) 33 + var existing BrandingBlob 34 + err := state.DB.Unscoped().Where("broadcaster_id = ? AND key = ?", broadcasterID, key).First(&existing).Error 35 + 36 + if err == gorm.ErrRecordNotFound { 37 + // create new blob 38 + blob := BrandingBlob{ 39 + BroadcasterID: broadcasterID, 40 + Key: key, 41 + MimeType: mimeType, 42 + Data: data, 43 + Width: width, 44 + Height: height, 45 + } 46 + if err := state.DB.Create(&blob).Error; err != nil { 47 + return fmt.Errorf("error creating branding blob: %w", err) 48 + } 49 + return nil 50 + } else if err != nil { 51 + return fmt.Errorf("error checking for existing branding blob: %w", err) 52 + } 53 + 54 + // update existing blob (restore if soft-deleted) 55 + existing.MimeType = mimeType 56 + existing.Data = data 57 + existing.Width = width 58 + existing.Height = height 59 + existing.DeletedAt = gorm.DeletedAt{} // clear soft delete 60 + if err := state.DB.Unscoped().Save(&existing).Error; err != nil { 61 + return fmt.Errorf("error updating branding blob: %w", err) 62 + } 63 + 64 + return nil 65 + } 66 + 67 + // ListBrandingKeys returns all keys for a broadcaster 68 + func (state *StatefulDB) ListBrandingKeys(broadcasterID string) ([]string, error) { 69 + var blobs []BrandingBlob 70 + err := state.DB.Where("broadcaster_id = ?", broadcasterID).Select("key").Find(&blobs).Error 71 + if err != nil { 72 + return nil, err 73 + } 74 + 75 + keys := make([]string, len(blobs)) 76 + for i, blob := range blobs { 77 + keys[i] = blob.Key 78 + } 79 + return keys, nil 80 + } 81 + 82 + // DeleteBrandingBlob removes a specific asset 83 + func (state *StatefulDB) DeleteBrandingBlob(broadcasterID, key string) error { 84 + err := state.DB.Where("broadcaster_id = ? AND key = ?", broadcasterID, key).Delete(&BrandingBlob{}).Error 85 + if err != nil { 86 + return fmt.Errorf("error deleting branding blob: %w", err) 87 + } 88 + return nil 89 + }
+1
pkg/statedb/statedb.go
··· 51 51 Webhook{}, 52 52 MultistreamTarget{}, 53 53 MultistreamEvent{}, 54 + BrandingBlob{}, 54 55 ModerationAuditLog{}, 55 56 } 56 57
+34
pkg/streamplace/brandingdeleteBlob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.branding.deleteBlob 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // BrandingDeleteBlob_Input is the input argument to a place.stream.branding.deleteBlob call. 14 + type BrandingDeleteBlob_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 + // key: Branding asset key (mainLogo, favicon, siteTitle, etc.) 18 + Key string `json:"key" cborgen:"key"` 19 + } 20 + 21 + // BrandingDeleteBlob_Output is the output of a place.stream.branding.deleteBlob call. 22 + type BrandingDeleteBlob_Output struct { 23 + Success bool `json:"success" cborgen:"success"` 24 + } 25 + 26 + // BrandingDeleteBlob calls the XRPC method "place.stream.branding.deleteBlob". 27 + func BrandingDeleteBlob(ctx context.Context, c lexutil.LexClient, input *BrandingDeleteBlob_Input) (*BrandingDeleteBlob_Output, error) { 28 + var out BrandingDeleteBlob_Output 29 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.branding.deleteBlob", nil, input, &out); err != nil { 30 + return nil, err 31 + } 32 + 33 + return &out, nil 34 + }
+31
pkg/streamplace/brandinggetBlob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.branding.getBlob 4 + 5 + package streamplace 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + // BrandingGetBlob calls the XRPC method "place.stream.branding.getBlob". 15 + // 16 + // broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster. 17 + // key: Branding asset key (mainLogo, favicon, siteTitle, etc.) 18 + func BrandingGetBlob(ctx context.Context, c lexutil.LexClient, broadcaster string, key string) ([]byte, error) { 19 + buf := new(bytes.Buffer) 20 + 21 + params := map[string]interface{}{} 22 + if broadcaster != "" { 23 + params["broadcaster"] = broadcaster 24 + } 25 + params["key"] = key 26 + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.branding.getBlob", params, nil, buf); err != nil { 27 + return nil, err 28 + } 29 + 30 + return buf.Bytes(), nil 31 + }
+50
pkg/streamplace/brandinggetBranding.go
··· 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
··· 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
··· 12 12 13 13 // BroadcastGetBroadcaster_Output is the output of a place.stream.broadcast.getBroadcaster call. 14 14 type BroadcastGetBroadcaster_Output struct { 15 + // admins: Array of DIDs authorized as admins 16 + Admins []string `json:"admins,omitempty" cborgen:"admins,omitempty"` 15 17 // broadcaster: DID of the Streamplace broadcaster to which this server belongs 16 18 Broadcaster string `json:"broadcaster" cborgen:"broadcaster"` 17 19 // server: DID of this particular Streamplace server