Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+10789 -2655
+6
.prettierrc
··· 6 6 "options": { 7 7 "proseWrap": "preserve" 8 8 } 9 + }, 10 + { 11 + "files": "*.md", 12 + "options": { 13 + "proseWrap": "preserve" 14 + } 9 15 } 10 16 ], 11 17 "plugins": ["prettier-plugin-organize-imports"]
+8 -5
Makefile
··· 177 177 -D "gst-plugins-good:videobox=enabled" \ 178 178 -D "gst-plugins-good:jpeg=enabled" \ 179 179 -D "gst-plugins-good:audioparsers=enabled" \ 180 - -D "gst-plugins-good:flv=enabled" \ 181 180 -D "gst-plugins-bad:videoparsers=enabled" \ 182 181 -D "gst-plugins-bad:mpegtsmux=enabled" \ 183 182 -D "gst-plugins-bad:mpegtsdemux=enabled" \ 184 183 -D "gst-plugins-bad:codectimestamper=enabled" \ 185 184 -D "gst-plugins-bad:opus=enabled" \ 185 + -D "gst-plugins-bad:rtmp2=enabled" \ 186 + -D "gst-plugins-good:flv=enabled" \ 186 187 -D "gst-plugins-ugly:x264=enabled" \ 187 188 -D "gst-plugins-ugly:gpl=enabled" \ 188 189 -D "x264:asm=enabled" \ ··· 381 382 && sed -i.bak "s/^..port.*com\/atproto.*//g" $$(find ./js/streamplace/src/lexicons -type f) \ 382 383 && sed -i.bak "s/\(..port .*\)\.js\(.*\)/\1\2/g" $$(find ./js/streamplace/src/lexicons -type f) \ 383 384 && sed -i.bak 's/AppBskyGraphBlock\.Main/AppBskyGraphBlock\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 385 + && sed -i.bak 's/PlaceStreamMultistreamTarget\.Main/PlaceStreamMultistreamTarget\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 384 386 && sed -i.bak 's/PlaceStreamChatProfile\.Main/PlaceStreamChatProfile\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 385 387 && for x in $$(find ./js/streamplace/src/lexicons -type f -name '*.ts'); do \ 386 - echo 'import { AppBskyRichtextFacet, AppBskyGraphBlock, ComAtprotoRepoStrongRef, AppBskyActorDefs, ComAtprotoSyncListRepos, AppBskyActorGetProfile, AppBskyFeedGetFeedSkeleton, ComAtprotoIdentityResolveHandle, ComAtprotoModerationCreateReport, ComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoDescribeRepo, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord, ComAtprotoRepoUploadBlob, ComAtprotoServerDescribeServer, ComAtprotoSyncGetRecord, ComAtprotoSyncListReposComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoIdentityRefreshIdentity } from "@atproto/api"' >> $$x; \ 388 + echo 'import { ComAtprotoSyncGetRepo, AppBskyRichtextFacet, AppBskyGraphBlock, ComAtprotoRepoStrongRef, AppBskyActorDefs, ComAtprotoSyncListRepos, AppBskyActorGetProfile, AppBskyFeedGetFeedSkeleton, ComAtprotoIdentityResolveHandle, ComAtprotoModerationCreateReport, ComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoDescribeRepo, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord, ComAtprotoRepoUploadBlob, ComAtprotoServerDescribeServer, ComAtprotoSyncGetRecord, ComAtprotoSyncListReposComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoIdentityRefreshIdentity } from "@atproto/api"' >> $$x; \ 387 389 done \ 388 - && npx prettier --write $$(find ./js/streamplace/src/lexicons -type f -name '*.ts') \ 390 + && npx prettier --ignore-unknown --write $$(find ./js/streamplace/src/lexicons -type f -name '*.ts') \ 389 391 && find . | grep bak$$ | xargs rm 390 392 391 393 .PHONY: md-lexicons 392 394 md-lexicons: 393 - pnpm exec lexmd \ 395 + find "js/docs/src/content/docs/lex-reference" -type f -name '*.md' -delete \ 396 + && pnpm exec lexmd \ 394 397 ./lexicons \ 395 398 .build/temp \ 396 399 subprojects/atproto/lexicons \ ··· 435 438 .PHONY: ci-lexicons 436 439 ci-lexicons: 437 440 $(MAKE) lexicons \ 438 - && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; exit 1; fi 441 + && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; git diff; exit 1; fi 439 442 440 443 # _______ ______ _____ _______ _____ _ _ _____ 441 444 # |__ __| ____|/ ____|__ __|_ _| \ | |/ ____|
+1
docker/local.Dockerfile
··· 5 5 COPY build-linux-amd64/streamplace /usr/local/bin/streamplace 6 6 7 7 ENV PATH="/usr/local/bin:$PATH" 8 + ENV SP_DATA_DIR=/var/lib/streamplace 8 9 9 10 CMD ["streamplace"]
+3 -3
go.mod
··· 19 19 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d 20 20 github.com/bluenviron/gortmplib v0.1.2 21 21 github.com/bluenviron/gortsplib/v5 v5.2.1 22 - github.com/bluenviron/mediacommon/v2 v2.5.2 23 22 github.com/bluesky-social/indigo v0.0.0-20251206005924-d49b45419635 24 23 github.com/cenkalti/backoff v2.2.1+incompatible 25 24 github.com/cenkalti/backoff/v5 v5.0.2 ··· 55 54 github.com/pion/webrtc/v4 v4.0.11 56 55 github.com/piprate/json-gold v0.5.0 57 56 github.com/prometheus/client_golang v1.23.0 57 + github.com/rivo/uniseg v0.4.7 58 58 github.com/rs/cors v1.11.1 59 59 github.com/samber/slog-http v1.4.0 60 60 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 61 61 github.com/slok/go-http-metrics v0.13.0 62 62 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 63 63 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 64 - github.com/streamplace/oatproxy v0.0.0-20251207230012-236e9bd6dae6 64 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b 65 65 github.com/stretchr/testify v1.11.1 66 66 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 67 67 github.com/whyrusleeping/cbor-gen v0.3.1 ··· 163 163 github.com/bkielbasa/cyclop v1.2.3 // indirect 164 164 github.com/blizzy78/varnamelen v0.8.0 // indirect 165 165 github.com/bluenviron/gortsplib/v4 v4.12.3 // indirect 166 + github.com/bluenviron/mediacommon/v2 v2.5.2 // indirect 166 167 github.com/bombsimon/wsl/v4 v4.7.0 // indirect 167 168 github.com/breml/bidichk v0.3.3 // indirect 168 169 github.com/breml/errchkjson v0.4.1 // indirect ··· 425 426 github.com/rabbitmq/amqp091-go v1.8.0 // indirect 426 427 github.com/rabbitmq/rabbitmq-stream-go-client v1.1.1 // indirect 427 428 github.com/raeperd/recvcheck v0.2.0 // indirect 428 - github.com/rivo/uniseg v0.4.7 // indirect 429 429 github.com/rogpeppe/go-internal v1.14.1 // indirect 430 430 github.com/rs/xid v1.5.0 // indirect 431 431 github.com/russross/blackfriday/v2 v2.1.0 // indirect
+4 -2
go.sum
··· 1317 1317 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= 1318 1318 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= 1319 1319 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1320 - github.com/streamplace/oatproxy v0.0.0-20251207230012-236e9bd6dae6 h1:Y81F18H+qQGWk58Vqangsw75XQ6G1shJOsUEqgKQdYI= 1321 - github.com/streamplace/oatproxy v0.0.0-20251207230012-236e9bd6dae6/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1320 + github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= 1321 + github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= 1323 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 1324 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1323 1325 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1324 1326 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+2 -1
js/app/components/follow-button.tsx
··· 123 123 disabled={isFollowing === null} 124 124 loading={isFollowing === null} 125 125 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />} 126 + hoverStyle={isFollowing ? { backgroundColor: "#dc2626" } : undefined} 126 127 > 127 128 {isFollowing === null 128 129 ? "Loading..." 129 130 : isFollowing 130 - ? "Unfollow" 131 + ? "Following" 131 132 : "Follow"} 132 133 </Button> 133 134 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>}
+9 -5
js/app/components/home/cards.tsx
··· 49 49 style={[ 50 50 zero.flex.values[1], 51 51 { 52 + borderCurve: "continuous", 52 53 backgroundColor: theme.colors.muted, 53 54 borderRadius, 54 55 overflow: "hidden", 55 56 borderColor: theme.colors.mutedForeground + 80, 56 - borderWidth: 2, 57 + borderWidth: isWeb ? 1 : 0, 57 58 alignItems: layoutHorizontal ? "center" : "stretch", 58 59 flexDirection: layoutHorizontal ? "row" : "column", 59 60 }, 60 61 ]} 61 62 > 62 - {/* Thumbnail Section */} 63 + {/* Thumbnail */} 63 64 <View 64 65 style={[ 65 66 { ··· 67 68 minWidth: layoutHorizontal ? "63%" : "100%", 68 69 // native seems to be unable to adjust widths properly? 69 70 maxHeight: !isWeb ? "76.5%" : "100%", 70 - borderRadius, 71 - overflow: "hidden", 72 71 position: "relative", 73 72 alignSelf: layoutHorizontal ? "auto" : "center", 74 73 backgroundColor: theme.colors.card, ··· 77 76 > 78 77 <Image 79 78 source={{ uri: `${url}/${thumbnailUrl}`, width: 160, height: 90 }} 80 - style={{ width: "100%", height: "100%", aspectRatio: 16 / 9 }} 79 + style={{ 80 + width: "100%", 81 + height: "100%", 82 + aspectRatio: 16 / 9, 83 + }} 81 84 resizeMode="contain" 82 85 /> 83 86 {isLive && ( ··· 193 196 ]} 194 197 numberOfLines={1} 195 198 ellipsizeMode="tail" 199 + leading="tight" 196 200 > 197 201 @{streamerName} 198 202 </Text>
+10 -2
js/app/components/live-dashboard/bento-grid.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 2 import { 3 + borders, 3 4 Button, 4 5 Dashboard, 5 6 useLivestreamStore, ··· 20 21 import { useSafeAreaInsets } from "react-native-safe-area-context"; 21 22 import { useEmojiData } from "utils/emoji"; 22 23 import LivestreamPanel from "./livestream-panel"; 24 + import MultistreamStatus from "./multistream-status"; 23 25 import StreamMonitor from "./stream-monitor"; 24 26 25 27 const { flex, p, gap, layout, bg } = zero; ··· 153 155 streamTitle={ 154 156 profile?.displayName || profile?.handle || "Live Stream" 155 157 } 156 - viewers={viewers || 0} 157 158 uptime={getUptime()} 158 159 bitrate={getBitrate()} 159 160 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} ··· 192 193 { maxWidth: isWeb ? 600 : "100%" }, 193 194 ]} 194 195 > 196 + <View 197 + style={[ 198 + borders.bottom.width.thin, 199 + borders.bottom.color.neutral[700], 200 + ]} 201 + > 202 + <MultistreamStatus /> 203 + </View> 195 204 <Dashboard.ChatPanel 196 205 isLive={isLive} 197 206 isConnected={isConnected} ··· 225 234 streamTitle={ 226 235 profile?.displayName || profile?.handle || "Live Stream" 227 236 } 228 - viewers={viewers || 0} 229 237 uptime={getUptime()} 230 238 bitrate={getBitrate()} 231 239 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0}
+7 -3
js/app/components/live-dashboard/live-selector.tsx
··· 57 57 if (selectedMode === "streamkey") { 58 58 return ( 59 59 <View flex={1} style={[flex.grow[1], { width: "100%" }]}> 60 - <View padding="md" direction="row" justify="between" align="end"> 61 - <Button variant="ghost" onPress={() => setSelectedMode(null)}> 60 + <View padding="md" direction="row" justify="around" align="start"> 61 + <Button 62 + variant="ghost" 63 + width="min" 64 + onPress={() => setSelectedMode(null)} 65 + > 62 66 โ† Back 63 67 </Button> 64 68 <Text variant="h4" weight="bold"> 65 69 Stream from OBS 66 70 </Text> 67 - <Button variant="ghost" style={{ opacity: 0 }}> 71 + <Button variant="ghost" width="min" style={{ opacity: 0 }}> 68 72 โ† Back 69 73 </Button> 70 74 </View>
+82 -14
js/app/components/live-dashboard/livestream-panel.tsx
··· 1 1 import { 2 + Admonition, 2 3 Button, 3 4 Checkbox, 4 5 ContentMetadataForm, 5 6 Dashboard, 6 7 formatHandle, 7 8 formatHandleWithAt, 9 + getBlob, 8 10 Input, 11 + resolveDIDDocument, 12 + Text, 9 13 Textarea, 10 14 Tooltip, 11 15 useCreateStreamRecord, ··· 15 19 useUrl, 16 20 zero, 17 21 } from "@streamplace/components"; 18 - import { ImagePlus, X } from "lucide-react-native"; 22 + import { ArrowRight, ImagePlus, X } from "lucide-react-native"; 19 23 import { useCallback, useEffect, useMemo, useState } from "react"; 20 24 import { 21 25 Image, 22 26 Platform, 27 + Pressable, 23 28 ScrollView, 24 - Text, 25 29 TouchableOpacity, 26 30 View, 27 31 } from "react-native"; ··· 80 84 selectedImage, 81 85 onImageSelect, 82 86 onImageRemove, 87 + onUseLastImage, 88 + hasLastImage, 89 + onGoToMetadata, 83 90 }: { 84 91 selectedImage?: string | File | Blob; 85 92 onImageSelect?: () => void; 86 93 onImageRemove?: () => void; 94 + onUseLastImage?: () => void; 95 + hasLastImage?: boolean; 96 + onGoToMetadata?: () => void; 87 97 }) => { 88 98 const imageUrl = useMemo(() => { 89 99 if (!selectedImage) return undefined; ··· 150 160 </TouchableOpacity> 151 161 </View> 152 162 ) : ( 153 - <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 154 - <ImagePlus size={48} color="#6b7280" /> 155 - <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 156 - Add thumbnail image 157 - </Text> 158 - <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 159 - Optional โ€ข JPG, PNG up to 975KB 160 - </Text> 161 - </TouchableOpacity> 163 + <> 164 + <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 165 + <ImagePlus size={48} color="#6b7280" /> 166 + <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 167 + Add thumbnail image 168 + </Text> 169 + <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 170 + Optional โ€ข JPG, PNG up to 975KB 171 + </Text> 172 + </TouchableOpacity> 173 + {hasLastImage && ( 174 + <Button 175 + variant="secondary" 176 + size="sm" 177 + onPress={onUseLastImage} 178 + style={[{ marginTop: 8 }]} 179 + > 180 + <Text style={[text.gray[300], { fontSize: 14 }]}> 181 + Use Last Image 182 + </Text> 183 + </Button> 184 + )} 185 + </> 162 186 )} 187 + <View style={{ marginTop: 8 }}> 188 + <Admonition variant="info" size="sm"> 189 + <Text size="sm"> 190 + You are required to disclose if your content is not suitable for 191 + certain viewers. 192 + </Text> 193 + <Pressable onPress={onGoToMetadata}> 194 + <Text size="sm" color={zero.colors.blue[400]}> 195 + Go to the metadata page{" "} 196 + <ArrowRight size="14" style={{ marginVertical: -2 }} /> 197 + </Text> 198 + </Pressable> 199 + </Admonition> 200 + </View> 163 201 </View> 164 202 ); 165 203 }; ··· 185 223 186 224 const [createPost, setCreatePost] = useState(true); 187 225 const [sendPushNotification, setSendPushNotification] = useState(true); 188 - const [canonicalUrl, setCanonicalUrl] = useState<string>( 189 - livestream?.record.canonicalUrl || "", 190 - ); 226 + const [canonicalUrl, setCanonicalUrl] = useState<string>(""); 191 227 const defaultCanonicalUrl = useMemo(() => { 192 228 return `${url}/${profile && formatHandle(profile)}`; 193 229 }, [url, profile?.handle]); ··· 196 232 if (!livestream) { 197 233 return; 198 234 } 235 + 236 + // Prefill title with previous stream's title 237 + if (livestream.record.title) { 238 + setTitle(livestream.record.title); 239 + } 240 + 241 + // Prefill canonical URL 199 242 if ( 200 243 livestream.record.canonicalUrl && 201 244 livestream.record.canonicalUrl !== defaultCanonicalUrl 202 245 ) { 203 246 setCanonicalUrl(livestream.record.canonicalUrl); 204 247 } 248 + 249 + // Prefill notification settings 205 250 if ( 206 251 typeof livestream.record.notificationSettings?.pushNotification === 207 252 "boolean" ··· 210 255 livestream.record.notificationSettings.pushNotification, 211 256 ); 212 257 } 258 + 259 + // Prefill post creation preference 213 260 setCreatePost(typeof livestream.record.post !== "undefined"); 214 261 }, [livestream, defaultCanonicalUrl]); 215 262 ··· 319 366 const handleImageRemove = useCallback(() => { 320 367 setSelectedImage(undefined); 321 368 }, []); 369 + 370 + const handleUseLastImage = useCallback(async () => { 371 + if (!livestream?.record.thumb) return; 372 + 373 + try { 374 + const did = livestream.uri.split("/")[2]; 375 + const cid = (livestream.record.thumb.ref as any).$link; 376 + 377 + const didDoc = await resolveDIDDocument(did); 378 + const blob = await getBlob(did, cid, didDoc); 379 + setSelectedImage(blob); 380 + } catch (error) { 381 + console.error("Failed to fetch last image:", error); 382 + toast.show("Error", "Failed to load previous thumbnail", { 383 + duration: 3, 384 + }); 385 + } 386 + }, [livestream, toast]); 322 387 323 388 const disabled = useMemo( 324 389 () => !userIsLive || loading || title.trim() === "", ··· 569 634 selectedImage={selectedImage} 570 635 onImageSelect={handleImageSelect} 571 636 onImageRemove={handleImageRemove} 637 + onUseLastImage={handleUseLastImage} 638 + hasLastImage={!!livestream?.record.thumb} 639 + onGoToMetadata={() => handleModeChange("metadata")} 572 640 /> 573 641 )} 574 642
+209
js/app/components/live-dashboard/multistream-status.tsx
··· 1 + import { Loader, Text, View, zero } from "@streamplace/components"; 2 + import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 3 + import { useCallback, useEffect, useState } from "react"; 4 + import { Switch } from "react-native"; 5 + import Animated, { 6 + cancelAnimation, 7 + useAnimatedStyle, 8 + useSharedValue, 9 + withRepeat, 10 + withTiming, 11 + } from "react-native-reanimated"; 12 + import { PlaceStreamMultistreamDefs } from "streamplace"; 13 + 14 + const { flex, p, gap, layout, bg, borders, text, r } = zero; 15 + 16 + interface MultistreamTargetViewHydrated 17 + extends PlaceStreamMultistreamDefs.TargetView { 18 + record: any; 19 + } 20 + 21 + export default function MultistreamStatus() { 22 + const agent = usePDSAgent(); 23 + const [targets, setTargets] = useState<MultistreamTargetViewHydrated[]>([]); 24 + const [loading, setLoading] = useState(true); 25 + const [togglingTargets, setTogglingTargets] = useState<Set<string>>( 26 + new Set(), 27 + ); 28 + 29 + // Reanimated animation for connecting states 30 + const opacity = useSharedValue(1); 31 + 32 + const animatedStyle = useAnimatedStyle(() => ({ 33 + opacity: opacity.value, 34 + })); 35 + 36 + useEffect(() => { 37 + const hasConnectingTargets = targets.some( 38 + (t) => t.record.active && t.latestEvent?.status === "pending", 39 + ); 40 + 41 + if (hasConnectingTargets) { 42 + opacity.value = withRepeat(withTiming(0.3, { duration: 1000 }), -1, true); 43 + } else { 44 + cancelAnimation(opacity); 45 + opacity.value = withTiming(1, { duration: 200 }); 46 + } 47 + }, [targets, opacity]); 48 + 49 + const loadTargets = useCallback(async () => { 50 + if (!agent) return; 51 + 52 + try { 53 + setLoading(true); 54 + const targetViews = await agent.place.stream.multistream.listTargets({ 55 + limit: 50, 56 + }); 57 + setTargets(targetViews.data.targets as MultistreamTargetViewHydrated[]); 58 + } catch (error) { 59 + console.error("Failed to load multistream targets:", error); 60 + setTargets([]); 61 + } finally { 62 + setLoading(false); 63 + } 64 + }, [agent]); 65 + 66 + const toggleTarget = useCallback( 67 + async (target: MultistreamTargetViewHydrated, newActiveState: boolean) => { 68 + if (!agent) return; 69 + try { 70 + setTogglingTargets((prev) => new Set(prev).add(target.uri)); 71 + await agent.place.stream.multistream.putTarget({ 72 + multistreamTarget: { 73 + ...target.record, 74 + active: newActiveState, 75 + }, 76 + rkey: target.uri.split("/").pop() || "", 77 + }); 78 + await loadTargets(); 79 + } catch (error) { 80 + console.error("Failed to toggle multistream target:", error); 81 + } finally { 82 + setTogglingTargets((prev) => { 83 + const newSet = new Set(prev); 84 + newSet.delete(target.uri); 85 + return newSet; 86 + }); 87 + } 88 + }, 89 + [agent, loadTargets], 90 + ); 91 + 92 + useEffect(() => { 93 + loadTargets(); 94 + }, [loadTargets]); 95 + 96 + const activeTargets = targets.filter((t) => t.record.active); 97 + const inactiveTargets = targets.filter((t) => !t.record.active); 98 + 99 + const getTargetName = (target: MultistreamTargetViewHydrated) => { 100 + if (target.record.name) return target.record.name; 101 + if (target.record.url) { 102 + try { 103 + const u = new URL(target.record.url); 104 + return u.host; 105 + } catch { 106 + return "Untitled Target"; 107 + } 108 + } 109 + return "Untitled Target"; 110 + }; 111 + 112 + const getTargetHostname = (target: MultistreamTargetViewHydrated) => { 113 + if (!target.record.url) return null; 114 + try { 115 + const u = new URL(target.record.url); 116 + return u.host.split(":")[0]; 117 + } catch { 118 + return null; 119 + } 120 + }; 121 + 122 + const getStatusColor = (target: MultistreamTargetViewHydrated) => { 123 + if (!target.record.active) return text.gray[500]; 124 + 125 + switch (target.latestEvent?.status) { 126 + case "active": 127 + return text.green[400]; 128 + case "error": 129 + return text.red[400]; 130 + case "pending": 131 + return text.yellow[400]; 132 + default: 133 + return text.gray[600]; 134 + } 135 + }; 136 + 137 + if (loading && targets.length === 0) { 138 + return ( 139 + <View style={[p[3]]}> 140 + <Text style={[text.gray[400], { fontSize: 14 }]}> 141 + Loading multistream... 142 + </Text> 143 + </View> 144 + ); 145 + } 146 + 147 + if (targets.length === 0) { 148 + return ( 149 + <View style={[p[3]]}> 150 + <Text style={[text.gray[400], { fontSize: 14 }]}> 151 + No multistream targets configured 152 + </Text> 153 + </View> 154 + ); 155 + } 156 + 157 + return ( 158 + <View style={[p[3], gap.all[2]]}> 159 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 160 + <Text size="xl" style={[text.white]}> 161 + Multistream {loading && <Loader size="small" />} 162 + </Text> 163 + </View> 164 + 165 + {targets.map((target) => ( 166 + <View 167 + key={target.uri} 168 + style={[ 169 + layout.flex.row, 170 + layout.flex.alignCenter, 171 + layout.flex.spaceBetween, 172 + p[2], 173 + bg.neutral[800], 174 + r.md, 175 + borders.width.thin, 176 + borders.color.neutral[700], 177 + ]} 178 + > 179 + <View style={[flex.values[1]]}> 180 + <View 181 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]} 182 + > 183 + <Text>{getTargetName(target)}</Text> 184 + {target.record.name && getTargetHostname(target) && ( 185 + <Text color="muted">{getTargetHostname(target)}</Text> 186 + )} 187 + </View> 188 + {target.latestEvent && ( 189 + <Animated.Text 190 + style={[ 191 + getStatusColor(target), 192 + { fontSize: 11 }, 193 + target.latestEvent.status === "pending" && animatedStyle, 194 + ]} 195 + > 196 + {target.latestEvent.status} 197 + </Animated.Text> 198 + )} 199 + </View> 200 + <Switch 201 + value={target.record.active} 202 + onValueChange={(active) => toggleTarget(target, active)} 203 + disabled={togglingTargets.has(target.uri)} 204 + /> 205 + </View> 206 + ))} 207 + </View> 208 + ); 209 + }
+23 -3
js/app/components/live-dashboard/stream-key.tsx
··· 8 8 useTheme, 9 9 useToast, 10 10 View, 11 + zero, 11 12 } from "@streamplace/components"; 12 13 import Loading from "components/loading/loading"; 13 14 import { Clipboard, ClipboardCheck } from "lucide-react-native"; ··· 68 69 <View fullWidth style={{ maxWidth: 600 }}> 69 70 <FormRow> 70 71 <Button 72 + width="min" 71 73 variant={protocol !== "rtmp" ? "secondary" : "primary"} 72 74 onPress={() => setProtocol("rtmp")} 73 75 style={{ ··· 78 80 RTMP 79 81 </Button> 80 82 <Button 83 + width="min" 81 84 variant={protocol !== "whip" ? "secondary" : "primary"} 82 85 onPress={() => setProtocol("whip")} 83 86 style={{ ··· 93 96 <FormRow> 94 97 <Label>Output Settings</Label> 95 98 <Content> 96 - <Body> 99 + <View style={[zero.mt[2]]}> 97 100 <Text>Output mode: Advanced</Text> 98 101 <Text> 99 102 Keyframe Interval: <Code>1s</Code> 100 103 </Text> 101 104 <Text> 102 - x264 Options: <Code>bframes=0</Code> 105 + x264 Options:{" "} 106 + <Code 107 + style={{ 108 + paddingHorizontal: 4, 109 + backgroundColor: "#648800", 110 + }} 111 + > 112 + bframes=0 113 + </Code> 103 114 </Text> 104 - </Body> 115 + <Text 116 + underline 117 + style={{ 118 + fontWeight: "bold", 119 + }} 120 + > 121 + (Very important!) 122 + </Text> 123 + </View> 105 124 </Content> 106 125 </FormRow> 107 126 </View> ··· 271 290 selectTextOnFocus={true} 272 291 /> 273 292 <Button 293 + width="min" 274 294 onPress={handleCopy} 275 295 style={[ 276 296 {
+6 -1
js/app/components/live-dashboard/stream-monitor.tsx
··· 98 98 borders.width.thin, 99 99 borders.color.neutral[700], 100 100 layout.flex.column, 101 + { overflow: "hidden" }, 101 102 ]} 102 103 > 103 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 104 105 {isLive && userProfile ? ( 105 106 isStreamVisible ? ( 106 - <Player src={userProfile.did} name={userProfile.handle}> 107 + <Player 108 + src={userProfile.did} 109 + name={userProfile.handle} 110 + muted={true} 111 + > 107 112 <DesktopUi /> 108 113 <PlayerUI.ViewerLoadingOverlay /> 109 114 <OfflineCounter isMobile={true} />
+11 -4
js/app/components/login/login-form.tsx
··· 20 20 21 21 interface LoginFormProps { 22 22 onSuccess?: () => void; 23 + onCloseModal?: () => void; 24 + onOpenPdsModal?: () => void; 23 25 } 24 26 25 - export default function LoginForm({ onSuccess }: LoginFormProps) { 27 + export default function LoginForm({ 28 + onSuccess, 29 + onCloseModal, 30 + onOpenPdsModal, 31 + }: LoginFormProps) { 26 32 const { theme } = useTheme(); 27 33 const loginAction = useStore((state) => state.login); 28 34 const openLoginLink = useStore((state) => state.openLoginLink); ··· 74 80 }; 75 81 76 82 const onSignup = () => { 77 - loginAction("https://bsky.social", openLoginLink); 83 + onCloseModal?.(); 84 + onOpenPdsModal?.(); 78 85 }; 79 86 80 87 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; ··· 283 290 ]} 284 291 > 285 292 <Button width="min" onPress={() => onSignup()} variant="ghost"> 286 - <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 293 + <Text style={[{ color: "white" }]}>Sign Up</Text> 287 294 </Button> 288 295 <Button 289 296 onPress={submit} ··· 292 299 width="min" 293 300 loading={loginState.loading} 294 301 > 295 - <Text style={[{ color: "white" }]}>Log in</Text> 302 + <Text style={[{ color: "white" }]}>Log In</Text> 296 303 </Button> 297 304 </View> 298 305 </>
+15 -2
js/app/components/login/login-modal.tsx
··· 6 6 interface LoginModalProps { 7 7 visible: boolean; 8 8 onClose: () => void; 9 + onOpenPdsModal: () => void; 9 10 } 10 11 11 - export default function LoginModal({ visible, onClose }: LoginModalProps) { 12 + export default function LoginModal({ 13 + visible, 14 + onClose, 15 + onOpenPdsModal, 16 + }: LoginModalProps) { 12 17 const { theme, zero: z } = useTheme(); 18 + 19 + if (!visible) { 20 + return null; 21 + } 13 22 14 23 return ( 15 24 <Modal ··· 64 73 </TouchableOpacity> 65 74 </View> 66 75 67 - <LoginForm onSuccess={onClose} /> 76 + <LoginForm 77 + onSuccess={onClose} 78 + onCloseModal={onClose} 79 + onOpenPdsModal={onOpenPdsModal} 80 + /> 68 81 </Pressable> 69 82 </View> 70 83 </Modal>
+3 -1
js/app/components/login/login.tsx
··· 12 12 export default function Login() { 13 13 const { theme } = useTheme(); 14 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 15 + const openPdsModal = useStore((state) => state.openPdsModal); 15 16 const userProfile = useUserProfile(); 16 17 const navigation = useNavigation(); 17 18 const isReady = useIsReady(); ··· 26 27 27 28 // check for stored return route on mount 28 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 29 31 storage.getItem("returnRoute").then((stored) => { 30 32 if (stored) { 31 33 try { ··· 103 105 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 104 106 Log in 105 107 </Text> 106 - <LoginForm /> 108 + <LoginForm onOpenPdsModal={openPdsModal} /> 107 109 </View> 108 110 </View> 109 111 </ScrollView>
+364
js/app/components/login/pds-host-selector-modal.tsx
··· 1 + import { 2 + Admonition, 3 + Button, 4 + Checkbox, 5 + Input, 6 + ResponsiveDialog, 7 + Trans as T, 8 + Text, 9 + useTheme, 10 + useTranslation, 11 + zero, 12 + } from "@streamplace/components"; 13 + import { Check, ExternalLink } from "lucide-react-native"; 14 + import React, { useState } from "react"; 15 + import { Linking, Pressable, View } from "react-native"; 16 + 17 + interface PdsHost { 18 + value: string; 19 + label: string; 20 + description: string; 21 + handlePolicyDocs?: string; 22 + terms: string; 23 + privacy: string; 24 + } 25 + 26 + const PDS_HOSTS = [ 27 + { 28 + value: "https://selfhosted.social", 29 + label: "selfhosted.social", 30 + description: "A popular community-run PDS", 31 + terms: "https://selfhosted.social/legal#terms", 32 + privacy: "https://selfhosted.social/legal", 33 + }, 34 + { 35 + // will redirect to https://bsky.social for sign in :thumb: 36 + value: "https://witchesbutter.us-west.host.bsky.network", 37 + label: "Bluesky", 38 + description: "The main Bluesky PDS instance", 39 + terms: "https://bsky.social/about/support/tos", 40 + privacy: "https://bsky.social/about/support/privacy-policy", 41 + }, 42 + { 43 + value: "https://blacksky.app", 44 + label: "Blacksky PDS", 45 + description: "A PDS service by Blacksky Algorithms", 46 + terms: "https://blackskyweb.xyz/about/support/tos", 47 + privacy: "https://blackskyweb.xyz/about/support/privacy-policy/", 48 + handlePolicyDocs: 49 + "https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services", 50 + }, 51 + { 52 + value: "https://pds.tophhie.cloud", 53 + label: "Tophhie Cloud", 54 + description: "A PDS service by Tophhie", 55 + terms: "https://blog.tophhie.cloud/atproto-tos/", 56 + privacy: "https://blog.tophhie.cloud/atproto-privacy-policy/", 57 + }, 58 + ]; 59 + 60 + // Shuffle the hosts 61 + // items with handle policies should never be first ! 62 + const shuffleArray = <T,>(array: T[]): T[] => { 63 + const arr = [...array]; 64 + for (let i = arr.length - 1; i > 0; i--) { 65 + const j = Math.floor(Math.random() * (i + 1)); 66 + [arr[i], arr[j]] = [arr[j], arr[i]]; 67 + } 68 + return arr; 69 + }; 70 + 71 + const SHUFFLED_PDS_HOSTS = (() => { 72 + const withPolicies = PDS_HOSTS.filter((h) => h.handlePolicyDocs); 73 + const [first, ...withoutPolicies] = PDS_HOSTS.filter( 74 + (h) => !h.handlePolicyDocs, 75 + ); 76 + return [first, ...shuffleArray(withPolicies.concat(withoutPolicies))]; 77 + })(); 78 + 79 + interface PdsHostSelectorModalProps { 80 + open: boolean; 81 + onOpenChange: (open: boolean) => void; 82 + onSubmit: (pdsHost: string) => void; 83 + } 84 + 85 + export const PdsHostSelectorModal: React.FC<PdsHostSelectorModalProps> = ({ 86 + open, 87 + onOpenChange, 88 + onSubmit, 89 + }) => { 90 + const [selectedHost, setSelectedHost] = useState<string | null>( 91 + SHUFFLED_PDS_HOSTS[0].value, 92 + ); 93 + const [customHost, setCustomHost] = useState<string>(""); 94 + const [useCustom, setUseCustom] = useState(false); 95 + const [handlePolicyChecked, hasCheckedHandlePolicy] = useState(false); 96 + 97 + const { theme } = useTheme(); 98 + const { t } = useTranslation(); 99 + 100 + const selectedHostObj = 101 + SHUFFLED_PDS_HOSTS.find((host) => host.value === selectedHost) || 102 + SHUFFLED_PDS_HOSTS[0]; 103 + 104 + const handleCancel = () => { 105 + setSelectedHost(SHUFFLED_PDS_HOSTS[0].value); 106 + setCustomHost(""); 107 + setUseCustom(false); 108 + onOpenChange(false); 109 + }; 110 + 111 + const handleSubmit = () => { 112 + const hostToUse = useCustom ? customHost : selectedHost; 113 + if (!hostToUse) return; 114 + 115 + onSubmit(hostToUse); 116 + handleCancel(); 117 + }; 118 + 119 + const handleLearnMore = () => { 120 + Linking.openURL("https://atproto.com/guides/self-hosting"); 121 + }; 122 + const handleTOS = () => { 123 + Linking.openURL(selectedHostObj.terms); 124 + }; 125 + const handlePrivacy = () => { 126 + Linking.openURL(selectedHostObj.privacy); 127 + }; 128 + 129 + const handleSelectHost = (value: string) => { 130 + setSelectedHost(value); 131 + setUseCustom(false); 132 + }; 133 + 134 + const handleSelectCustom = () => { 135 + setUseCustom(true); 136 + }; 137 + 138 + return ( 139 + <ResponsiveDialog 140 + open={open} 141 + onOpenChange={onOpenChange} 142 + showCloseButton={false} 143 + variant="default" 144 + size="sm" 145 + dismissible={false} 146 + position="center" 147 + > 148 + <View style={[{ maxWidth: 500 }]}> 149 + <View style={[zero.my[4]]}> 150 + <Text size="2xl" style={[zero.mb[2]]}> 151 + {t("pds-selector-title")} 152 + </Text> 153 + <Text style={[{ color: theme.colors.textMuted }]}> 154 + {t("pds-selector-description")} 155 + </Text> 156 + </View> 157 + <View style={[zero.pb[2]]}> 158 + {SHUFFLED_PDS_HOSTS.map((host, index) => ( 159 + <Pressable 160 + key={host.value} 161 + onPress={() => handleSelectHost(host.value)} 162 + style={[ 163 + zero.py[2], 164 + zero.px[3], 165 + zero.r.lg, 166 + { 167 + borderWidth: 1, 168 + borderColor: 169 + !useCustom && selectedHost === host.value 170 + ? theme.colors.primary 171 + : theme.colors.border, 172 + backgroundColor: 173 + !useCustom && selectedHost === host.value 174 + ? "rgba(0, 122, 255, 0.05)" 175 + : "transparent", 176 + }, 177 + index > 0 && zero.mt[2], 178 + ]} 179 + > 180 + <View 181 + style={[ 182 + zero.layout.flex.row, 183 + zero.layout.flex.spaceBetween, 184 + zero.layout.flex.alignCenter, 185 + ]} 186 + > 187 + <View style={[zero.flex[1]]}> 188 + <Text>{host.label}</Text> 189 + <Text 190 + style={[ 191 + zero.mt[1], 192 + { fontSize: 14, color: theme.colors.textMuted }, 193 + ]} 194 + > 195 + {host.description} 196 + </Text> 197 + </View> 198 + {!useCustom && selectedHost === host.value && ( 199 + <Check size={20} color={theme.colors.primary} /> 200 + )} 201 + </View> 202 + </Pressable> 203 + ))} 204 + 205 + <Pressable 206 + onPress={handleSelectCustom} 207 + style={[ 208 + zero.py[2], 209 + zero.px[3], 210 + zero.r.lg, 211 + zero.mt[2], 212 + { 213 + borderWidth: 1, 214 + borderColor: useCustom 215 + ? theme.colors.primary 216 + : theme.colors.border, 217 + backgroundColor: useCustom 218 + ? "rgba(0, 122, 255, 0.05)" 219 + : "transparent", 220 + }, 221 + ]} 222 + > 223 + <View 224 + style={[ 225 + zero.layout.flex.row, 226 + zero.layout.flex.spaceBetween, 227 + zero.layout.flex.alignCenter, 228 + ]} 229 + > 230 + <View style={[zero.flex[1]]}> 231 + <Text>{t("pds-selector-custom-label")}</Text> 232 + <Text 233 + style={[ 234 + zero.mt[1], 235 + { fontSize: 14, color: theme.colors.textMuted }, 236 + ]} 237 + > 238 + {t("pds-selector-custom-description")} 239 + </Text> 240 + </View> 241 + {useCustom && <Check size={20} color={theme.colors.primary} />} 242 + </View> 243 + </Pressable> 244 + 245 + <View style={[zero.mt[4]]}> 246 + <Pressable 247 + onPress={handleLearnMore} 248 + style={[ 249 + zero.layout.flex.row, 250 + zero.gap.all[1], 251 + zero.layout.flex.alignCenter, 252 + ]} 253 + > 254 + <Text style={[{ color: theme.colors.ring, fontSize: 14 }]}> 255 + {t("pds-selector-learn-more")} 256 + </Text> 257 + <ExternalLink size={16} color={theme.colors.ring} /> 258 + </Pressable> 259 + </View> 260 + 261 + {useCustom && ( 262 + <View style={[zero.mt[3]]}> 263 + <Text style={[zero.mb[2], { color: theme.colors.textMuted }]}> 264 + {t("pds-selector-custom-url-label")} 265 + </Text> 266 + <Input 267 + value={customHost} 268 + onChangeText={setCustomHost} 269 + placeholder={t("pds-selector-custom-url-placeholder")} 270 + autoCapitalize="none" 271 + autoCorrect={false} 272 + keyboardType="url" 273 + /> 274 + </View> 275 + )} 276 + <Admonition variant="info" style={[zero.my[4]] as any}> 277 + <Text style={[zero.mb[2]]}>{t("pds-selector-info")}</Text> 278 + {!useCustom && ( 279 + <Text style={[zero.mb[2]]}> 280 + <T 281 + i18nKey="pds-selector-read-policies" 282 + values={{ label: selectedHostObj?.label }} 283 + components={{ 284 + tosLink: ( 285 + <Text 286 + onPress={handleTOS} 287 + style={[{ color: theme.colors.ring }]} 288 + /> 289 + ), 290 + privacyLink: ( 291 + <Text 292 + onPress={handlePrivacy} 293 + style={[{ color: theme.colors.ring }]} 294 + /> 295 + ), 296 + }} 297 + /> 298 + </Text> 299 + )} 300 + </Admonition> 301 + {!useCustom && selectedHostObj.handlePolicyDocs && ( 302 + <View 303 + style={[ 304 + zero.layout.flex.row, 305 + zero.layout.flex.align.center, 306 + zero.layout.flex.justify.start, 307 + zero.gap.all[2], 308 + zero.mb[4], 309 + zero.mt[2], 310 + ]} 311 + > 312 + <Checkbox 313 + checked={handlePolicyChecked} 314 + onCheckedChange={hasCheckedHandlePolicy} 315 + /> 316 + <Text style={[zero.flex[1]]}> 317 + <T 318 + i18nKey="pds-selector-handle-policy-checkbox" 319 + components={{ 320 + policyLink: ( 321 + <Text 322 + onPress={() => 323 + Linking.openURL(selectedHostObj.handlePolicyDocs!) 324 + } 325 + style={[{ color: theme.colors.ring }]} 326 + > 327 + {selectedHostObj.label} guidelines and handle policy 328 + </Text> 329 + ), 330 + }} 331 + /> 332 + </Text> 333 + </View> 334 + )} 335 + </View> 336 + <View 337 + style={[ 338 + zero.flex[1], 339 + zero.layout.flex.row, 340 + zero.layout.flex.justify.end, 341 + zero.gap.all[2], 342 + ]} 343 + > 344 + <Button width="min" variant="secondary" onPress={handleCancel}> 345 + <Text>{t("cancel")}</Text> 346 + </Button> 347 + <Button 348 + width="min" 349 + variant="primary" 350 + onPress={handleSubmit} 351 + disabled={ 352 + (useCustom && !customHost.trim()) || 353 + (!handlePolicyChecked && !!selectedHostObj.handlePolicyDocs) 354 + } 355 + > 356 + <Text>{t("continue")}</Text> 357 + </Button> 358 + </View> 359 + </View> 360 + </ResponsiveDialog> 361 + ); 362 + }; 363 + 364 + export default PdsHostSelectorModal;
+2
js/app/components/mobile/bottom-metadata.tsx
··· 17 17 import FollowButton from "components/follow-button"; 18 18 import { ChevronLeft, ChevronRight } from "lucide-react-native"; 19 19 import { Image, Linking, Pressable, View } from "react-native"; 20 + import { KebabMenu } from "./desktop-ui/kebab"; 20 21 const { gap, px, py, colors } = zero; 21 22 22 23 export function BottomMetadata({ ··· 119 120 <View style={[layout.flex.row, layout.flex.align.center, gap.all[4]]}> 120 121 <PlayerUI.Viewers /> 121 122 <ShareSheet /> 123 + <KebabMenu /> 122 124 <View> 123 125 <Button 124 126 variant="outline"
+46 -1
js/app/components/mobile/chat.tsx
··· 3 3 ChatBox, 4 4 Loader, 5 5 Resizable, 6 + StreamNotificationProvider, 6 7 Text, 7 8 useHandle, 8 9 useLivestreamInfo, ··· 54 55 { translateX: sidebarOffset.value }, 55 56 { translateY: -kb.keyboardHeight }, 56 57 ], 57 - opacity: sidebarOpacity.value, 58 + })); 59 + 60 + const notificationOffsetStyle = useAnimatedStyle(() => ({ 61 + transform: [{ translateX: -sidebarOffset.value }], 58 62 })); 59 63 60 64 return ( ··· 88 92 ]} 89 93 > 90 94 <View style={{ flex: 1, position: "relative" }}> 95 + <Animated.View 96 + style={[ 97 + { 98 + position: "absolute", 99 + top: 1, 100 + right: 1, 101 + zIndex: 2, 102 + width: "100%", 103 + minWidth: 350, 104 + pointerEvents: "none", 105 + transformOrigin: "top right", 106 + }, 107 + notificationOffsetStyle, 108 + ]} 109 + > 110 + <StreamNotificationProvider position="top" /> 111 + </Animated.View> 91 112 <ChatPanel /> 92 113 </View> 93 114 </Animated.View> ··· 110 131 <Resizable 111 132 isPlayerRatioGreater={isPlayerRatioGreater} 112 133 startingPercentage={0.4} 134 + renderAbove={(isCollapsed) => ( 135 + <StreamNotificationProvider position="bottom" /> 136 + )} 113 137 > 114 138 <ChatPanel /> 115 139 </Resizable> ··· 149 173 <Chat /> 150 174 </View> 151 175 <View style={[layout.flex.column, gap.all[2]]}> 176 + {/* 177 + // in case one needs this again 178 + 179 + <Pressable 180 + onPress={() => 181 + StreamNotifications.activate("Stream notification activated!") 182 + } 183 + style={[ 184 + layout.flex.row, 185 + layout.flex.center, 186 + { 187 + padding: 12, 188 + borderRadius: borderRadius.xl, 189 + backgroundColor: "rgba(255, 255, 255, 0.05)", 190 + }, 191 + ]} 192 + > 193 + <Text style={{ color: "rgba(255, 255, 255, 0.7)", fontSize: 12 }}> 194 + Activate Stream Notification 195 + </Text> 196 + </Pressable>*/} 152 197 {agent?.did ? ( 153 198 <ChatBox 154 199 emojiData={emojiData}
+5 -5
js/app/components/mobile/desktop-ui/bottom-controls.tsx
··· 83 83 /> 84 84 </Pressable> 85 85 )} 86 + {ingest === null && ( 87 + <PlayerUI.ContextMenu 88 + dropdownPortalContainer={dropdownPortalContainer} 89 + /> 90 + )} 86 91 {Platform.OS === "web" && ( 87 92 <Pressable 88 93 onPress={() => { ··· 96 101 <Fullscreen color={theme.colors.text} /> 97 102 )} 98 103 </Pressable> 99 - )} 100 - {ingest === null && ( 101 - <PlayerUI.ContextMenu 102 - dropdownPortalContainer={dropdownPortalContainer} 103 - /> 104 104 )} 105 105 {/* if not web, then add the collapse chat controls here */} 106 106 {Platform.OS !== "web" && setShowChat && (
+1
js/app/components/mobile/desktop-ui/index.ts
··· 1 1 export { BottomControlBar } from "./bottom-controls"; 2 + export { KebabMenu } from "./kebab"; 2 3 export { LiveBubble } from "./live-bubble"; 3 4 export { MuteOverlay } from "./mute-overlay"; 4 5 export { TopControlBar } from "./top-controls";
+203
js/app/components/mobile/desktop-ui/kebab.tsx
··· 1 + import { 2 + AppBskyActorDefs, 3 + ComAtprotoModerationCreateReport, 4 + } from "@atproto/api"; 5 + import { useRootContext } from "@rn-primitives/dropdown-menu"; 6 + import { 7 + DropdownMenu, 8 + DropdownMenuGroup, 9 + DropdownMenuItem, 10 + DropdownMenuTrigger, 11 + ResponsiveDropdownMenuContent, 12 + Text, 13 + UpdateStreamTitleDialog, 14 + useCanModerate, 15 + useLivestream, 16 + useLivestreamInfo, 17 + useLivestreamStore, 18 + usePlayerStore, 19 + useTheme, 20 + useUpdateLivestreamRecord, 21 + } from "@streamplace/components"; 22 + import { EllipsisVertical } from "lucide-react-native"; 23 + import { useState } from "react"; 24 + import Animated, { 25 + Easing, 26 + useAnimatedStyle, 27 + withTiming, 28 + } from "react-native-reanimated"; 29 + import { LivestreamViewHydrated } from "streamplace"; 30 + 31 + type ReportSubject = 32 + | ComAtprotoModerationCreateReport.InputSchema["subject"] 33 + | null; 34 + 35 + interface KebabMenuProps { 36 + dropdownPortalContainer?: string; 37 + } 38 + 39 + export function KebabMenu({ dropdownPortalContainer }: KebabMenuProps) { 40 + const th = useTheme(); 41 + const [isOpen, setIsOpen] = useState(false); 42 + const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 43 + 44 + const livestreamFromStore = useLivestreamStore((x) => x.livestream); 45 + const livestream = useLivestream(); 46 + const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 47 + const setReportSubject = usePlayerStore((x) => x.setReportSubject); 48 + const { profile } = useLivestreamInfo(); 49 + 50 + // Get the streamer's DID from the profile 51 + const streamerDID = profile?.did; 52 + // Check moderation permissions for the current user on this streamer's channel 53 + const modPermissions = useCanModerate(streamerDID); 54 + const { updateLivestream, isLoading: isUpdateTitleLoading } = 55 + useUpdateLivestreamRecord(); 56 + 57 + const iconRotate = useAnimatedStyle(() => { 58 + return { 59 + transform: [ 60 + { 61 + rotateZ: withTiming(isOpen ? "5deg" : "0deg", { 62 + duration: 200, 63 + easing: Easing.out(Easing.ease), 64 + }), 65 + }, 66 + ], 67 + }; 68 + }); 69 + 70 + return ( 71 + <> 72 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 73 + <DropdownMenuTrigger> 74 + <Animated.View style={[iconRotate]}> 75 + <EllipsisVertical color={th.theme.colors.foreground} /> 76 + </Animated.View> 77 + </DropdownMenuTrigger> 78 + <ResponsiveDropdownMenuContent 79 + side="top" 80 + align="end" 81 + portalHost={dropdownPortalContainer} 82 + > 83 + {modPermissions.canManageLivestream && ( 84 + <DropdownMenuGroup title="Stream Settings"> 85 + <UpdateStreamTitleItem 86 + setShowUpdateTitleDialog={setShowUpdateTitleDialog} 87 + isUpdateTitleLoading={isUpdateTitleLoading} 88 + livestream={livestream} 89 + /> 90 + </DropdownMenuGroup> 91 + )} 92 + <DropdownMenuGroup title="Report"> 93 + <ReportStreamItem 94 + livestream={livestreamFromStore} 95 + setReportModalOpen={setReportModalOpen} 96 + setReportSubject={setReportSubject} 97 + /> 98 + <ReportUserItem 99 + profile={profile} 100 + setReportModalOpen={setReportModalOpen} 101 + setReportSubject={setReportSubject} 102 + /> 103 + </DropdownMenuGroup> 104 + </ResponsiveDropdownMenuContent> 105 + </DropdownMenu> 106 + 107 + {showUpdateTitleDialog && ( 108 + <UpdateStreamTitleDialog 109 + livestream={livestream} 110 + streamerDID={streamerDID} 111 + updateLivestream={updateLivestream} 112 + isLoading={isUpdateTitleLoading} 113 + onClose={() => setShowUpdateTitleDialog(false)} 114 + /> 115 + )} 116 + </> 117 + ); 118 + } 119 + 120 + function ReportStreamItem({ 121 + livestream, 122 + setReportModalOpen, 123 + setReportSubject, 124 + }: { 125 + livestream: LivestreamViewHydrated | null; 126 + setReportModalOpen: (open: boolean) => void; 127 + setReportSubject: (subject: ReportSubject) => void; 128 + }) { 129 + const { onOpenChange } = useRootContext(); 130 + 131 + return ( 132 + <DropdownMenuItem 133 + onPress={() => { 134 + if (!livestream) return; 135 + onOpenChange?.(false); 136 + setReportModalOpen(true); 137 + setReportSubject({ 138 + $type: "com.atproto.repo.strongRef", 139 + uri: livestream.uri, 140 + cid: livestream.cid, 141 + }); 142 + }} 143 + disabled={!livestream} 144 + > 145 + <Text>Report Livestream...</Text> 146 + </DropdownMenuItem> 147 + ); 148 + } 149 + 150 + function ReportUserItem({ 151 + profile, 152 + setReportModalOpen, 153 + setReportSubject, 154 + }: { 155 + profile: AppBskyActorDefs.ProfileViewBasic | null; 156 + setReportModalOpen: (open: boolean) => void; 157 + setReportSubject: (subject: ReportSubject) => void; 158 + }) { 159 + const { onOpenChange } = useRootContext(); 160 + 161 + return ( 162 + <DropdownMenuItem 163 + onPress={() => { 164 + if (!profile?.did) return; 165 + onOpenChange?.(false); 166 + setReportModalOpen(true); 167 + setReportSubject({ 168 + $type: "com.atproto.admin.defs#repoRef", 169 + did: profile.did, 170 + }); 171 + }} 172 + disabled={!profile?.did} 173 + > 174 + <Text>Report User...</Text> 175 + </DropdownMenuItem> 176 + ); 177 + } 178 + 179 + function UpdateStreamTitleItem({ 180 + setShowUpdateTitleDialog, 181 + isUpdateTitleLoading, 182 + livestream, 183 + }: { 184 + setShowUpdateTitleDialog: (show: boolean) => void; 185 + isUpdateTitleLoading: boolean; 186 + livestream: any; 187 + }) { 188 + const { onOpenChange } = useRootContext(); 189 + 190 + return ( 191 + <DropdownMenuItem 192 + onPress={() => { 193 + onOpenChange?.(false); 194 + setShowUpdateTitleDialog(true); 195 + }} 196 + disabled={isUpdateTitleLoading || !livestream} 197 + > 198 + <Text> 199 + {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 200 + </Text> 201 + </DropdownMenuItem> 202 + ); 203 + }
+117 -107
js/app/components/mobile/desktop-ui.tsx
··· 1 1 import { 2 2 PlayerUI, 3 + PortalHost, 3 4 Toast, 4 5 useLivestreamInfo, 5 6 useOffline, ··· 59 60 } = useLivestreamInfo(); 60 61 const { width, height } = usePlayerDimensions(); 61 62 const { shouldShowFloatingMetrics } = useResponsiveLayout(); 63 + const playerId = usePlayerStore((state) => state.id); 62 64 63 65 const originalSafeAreaInsets = useSafeAreaInsets(); 64 66 ··· 67 69 const pipAction = usePlayerStore((state) => state.pipAction); 68 70 const videoRef = usePlayerStore((state) => state.videoRef); 69 71 const embedded = usePlayerStore((state) => state.embedded); 72 + 73 + const fullscreen = usePlayerStore((state) => state.fullscreen); 70 74 71 75 const safeAreaInsets = embedded 72 76 ? { ...originalSafeAreaInsets, top: 0 } ··· 79 83 const [pipActive, setPipActive] = useState(false); 80 84 const fadeOpacity = useSharedValue(1); 81 85 const fadeTimeout = useRef<NodeJS.Timeout | null>(null); 82 - const FADE_OUT_DELAY = 500; 86 + const FADE_OUT_DELAY = 2500; 83 87 84 88 const isSelfAndNotLive = ingest === "new"; 85 89 const isActivelyLive = ingest !== null && ingest !== "new"; ··· 161 165 162 166 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 163 167 168 + const portalContainerID = "desktop-ui-dropdown-portal-" + playerId; 169 + 164 170 return ( 165 - <GestureDetector gesture={hover}> 166 - <View 167 - style={[layout.position.absolute, h.percent[100], w.percent[100]]} 168 - collapsable={false} 169 - > 170 - <MuteOverlay /> 171 - <PlayerUI.AutoplayButton /> 172 - <PlayerUI.ViewerLoadingOverlay /> 173 - <Animated.View 174 - style={[ 175 - layout.position.absolute, 176 - w.percent[100], 177 - { 178 - top: safeAreaInsets.top, 179 - paddingHorizontal: 16, 180 - paddingVertical: 16, 181 - }, 182 - animatedFadeStyle, 183 - ]} 171 + <> 172 + <GestureDetector gesture={hover}> 173 + <View 174 + style={[layout.position.absolute, h.percent[100], w.percent[100]]} 175 + collapsable={false} 184 176 > 185 - <TopControlBar 186 - offline={offline} 187 - isActivelyLive={isActivelyLive} 188 - ingest={ingest} 189 - isChatOpen={isChatOpen || false} 190 - onToggleChat={toggleChat} 191 - embedded={embedded} 192 - /> 193 - </Animated.View> 194 - 195 - {isActivelyLive && isControlsVisible && ( 196 - <View 177 + <MuteOverlay /> 178 + <PlayerUI.AutoplayButton /> 179 + <PlayerUI.ViewerLoadingOverlay /> 180 + <Animated.View 197 181 style={[ 198 182 layout.position.absolute, 183 + w.percent[100], 199 184 { 200 - transform: [{ translateX: -100 }, { translateY: -25 }], 185 + top: safeAreaInsets.top, 186 + paddingHorizontal: 16, 187 + paddingVertical: 16, 201 188 }, 189 + animatedFadeStyle, 202 190 ]} 203 191 > 204 - <Animated.View 192 + <TopControlBar 193 + offline={offline} 194 + isActivelyLive={isActivelyLive} 195 + ingest={ingest} 196 + isChatOpen={isChatOpen || false} 197 + onToggleChat={toggleChat} 198 + embedded={embedded} 199 + /> 200 + </Animated.View> 201 + 202 + {isActivelyLive && isControlsVisible && ( 203 + <View 205 204 style={[ 205 + layout.position.absolute, 206 206 { 207 - padding: 12, 208 - backgroundColor: "rgba(0, 0, 0, 0.5)", 207 + transform: [{ translateX: -100 }, { translateY: -25 }], 209 208 }, 210 - r[3], 211 - animatedFadeStyle, 212 209 ]} 213 210 > 214 - <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 215 - </Animated.View> 216 - </View> 217 - )} 218 - 219 - <Animated.View 220 - style={[ 221 - layout.position.absolute, 222 - position.bottom[0], 223 - w.percent[100], 224 - { 225 - backgroundColor: "rgba(0, 0, 0, 0.6)", 226 - paddingHorizontal: 16, 227 - paddingVertical: 2, 228 - paddingBottom: 2, 229 - }, 230 - animatedFadeStyle, 231 - ]} 232 - > 233 - <BottomControlBar 234 - ingest={ingest} 235 - pipSupported={pipSupported} 236 - pipActive={pipActive} 237 - onHandlePip={handlePip} 238 - dropdownPortalContainer={dropdownPortalContainer} 239 - showChat={isChatOpen || false} 240 - setShowChat={setIsChatOpen || undefined} 241 - /> 242 - </Animated.View> 243 - 244 - {isSelfAndNotLive && ( 245 - <PlayerUI.InputPanel 246 - title={title} 247 - setTitle={setTitle} 248 - ingestStarting={ingestStarting} 249 - toggleGoLive={toggleGoLive} 250 - /> 251 - )} 252 - 253 - <PlayerUI.CountdownOverlay 254 - visible={showCountdown} 255 - width={width} 256 - height={height} 257 - onDone={() => { 258 - setShowCountdown(false); 259 - }} 260 - /> 211 + <Animated.View 212 + style={[ 213 + { 214 + padding: 12, 215 + backgroundColor: "rgba(0, 0, 0, 0.5)", 216 + }, 217 + r[3], 218 + animatedFadeStyle, 219 + ]} 220 + > 221 + <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 222 + </Animated.View> 223 + </View> 224 + )} 261 225 262 - <Toast 263 - open={recordSubmitted} 264 - onOpenChange={setRecordSubmitted} 265 - title="You're live!" 266 - description="We're notifying your followers that you just went live." 267 - duration={5} 268 - /> 269 - {showMetrics && ( 270 - <View 226 + <Animated.View 271 227 style={[ 272 228 layout.position.absolute, 273 - position.top[20], 274 - position.left[4], 275 - px[4], 276 - py[2], 229 + position.bottom[0], 230 + w.percent[100], 277 231 { 278 - backgroundColor: "rgba(0, 0, 0, 0.7)", 279 - borderRadius: 8, 280 - borderWidth: 1, 281 - borderColor: "#374151", 232 + backgroundColor: "rgba(0, 0, 0, 0.6)", 233 + paddingHorizontal: 16, 234 + paddingVertical: 2, 235 + paddingBottom: 2, 282 236 }, 237 + animatedFadeStyle, 283 238 ]} 284 239 > 285 - <PlayerUI.MetricsPanel showMetrics={showMetrics} /> 286 - </View> 287 - )} 288 - </View> 289 - </GestureDetector> 240 + <BottomControlBar 241 + ingest={ingest} 242 + pipSupported={pipSupported} 243 + pipActive={pipActive} 244 + onHandlePip={handlePip} 245 + dropdownPortalContainer={fullscreen && portalContainerID} 246 + showChat={isChatOpen || false} 247 + setShowChat={setIsChatOpen || undefined} 248 + /> 249 + </Animated.View> 250 + 251 + {isSelfAndNotLive && ( 252 + <PlayerUI.InputPanel 253 + title={title} 254 + setTitle={setTitle} 255 + ingestStarting={ingestStarting} 256 + toggleGoLive={toggleGoLive} 257 + isLive={isActivelyLive} 258 + /> 259 + )} 260 + 261 + <PlayerUI.CountdownOverlay 262 + visible={showCountdown} 263 + width={width} 264 + height={height} 265 + onDone={() => { 266 + setShowCountdown(false); 267 + }} 268 + /> 269 + 270 + <Toast 271 + open={recordSubmitted} 272 + onOpenChange={setRecordSubmitted} 273 + title="You're live!" 274 + description="We're notifying your followers that you just went live." 275 + duration={5} 276 + /> 277 + {showMetrics && ( 278 + <View 279 + style={[ 280 + layout.position.absolute, 281 + position.top[20], 282 + position.left[4], 283 + px[4], 284 + py[2], 285 + { 286 + backgroundColor: "rgba(0, 0, 0, 0.7)", 287 + borderRadius: 8, 288 + borderWidth: 1, 289 + borderColor: "#374151", 290 + }, 291 + ]} 292 + > 293 + <PlayerUI.MetricsPanel showMetrics={showMetrics} /> 294 + </View> 295 + )} 296 + </View> 297 + </GestureDetector> 298 + {fullscreen && <PortalHost name={portalContainerID} />} 299 + </> 290 300 ); 291 301 }
+41 -23
js/app/components/mobile/player.tsx
··· 43 43 export function Player( 44 44 props: Partial<PlayerProps> & { 45 45 setFullscreen?: (fullscreen: boolean) => void; 46 + onTeleport?: (targetHandle: string, targetDID: string) => void; 46 47 }, 47 48 ) { 48 49 return ( ··· 60 61 function PlayerWithProvider( 61 62 props: Partial<PlayerProps> & { 62 63 setFullscreen?: (fullscreen: boolean) => void; 64 + onTeleport?: (targetHandle: string, targetDID: string) => void; 63 65 }, 64 66 ) { 65 67 const [showChat, setShowChat] = useState(true); ··· 188 190 ); 189 191 } 190 192 193 + const defaultHandleTeleport = (targetHandle: string, targetDID: string) => { 194 + navigation.navigate("Home", { 195 + screen: "Stream", 196 + params: { user: targetHandle }, 197 + }); 198 + }; 199 + 200 + const handleTeleport = props.onTeleport || defaultHandleTeleport; 201 + 191 202 return ( 192 - <View 193 - style={{ 194 - flexDirection: chatVisible ? "row" : "column", 195 - flex: 1, 196 - width: "100%", 197 - height: "100%", 198 - }} 199 - > 200 - <PlayerInner 201 - {...props} 202 - showChat={showChat} 203 - setShowChat={setShowChat} 204 - showUnavailable={showUnavailable} 205 - /> 206 - {shouldShowChatSidePanel ? ( 207 - <DesktopChatPanel 208 - chatVisible={chatVisible} 209 - chatPanelWidth={chatPanelWidth} 210 - /> 211 - ) : ( 212 - !showUnavailable && <MobileUi /> 213 - )} 214 - </View> 203 + <RotationProvider enabled={Platform.OS !== "web"}> 204 + <LivestreamProvider src={props.src ?? ""} onTeleport={handleTeleport}> 205 + <StatusBar hidden={true} /> 206 + <PlayerProvider defaultId={props.playerId || undefined}> 207 + <View 208 + style={{ 209 + flexDirection: chatVisible ? "row" : "column", 210 + flex: 1, 211 + width: "100%", 212 + height: "100%", 213 + }} 214 + > 215 + <PlayerInner 216 + {...props} 217 + showChat={showChat} 218 + setShowChat={setShowChat} 219 + showUnavailable={showUnavailable} 220 + /> 221 + {shouldShowChatSidePanel ? ( 222 + <DesktopChatPanel 223 + chatVisible={chatVisible} 224 + chatPanelWidth={chatPanelWidth} 225 + /> 226 + ) : ( 227 + !showUnavailable && <MobileUi /> 228 + )} 229 + </View> 230 + </PlayerProvider> 231 + </LivestreamProvider> 232 + </RotationProvider> 215 233 ); 216 234 } 217 235
+16 -3
js/app/components/mobile/ui.tsx
··· 72 72 ingestStarting, 73 73 setIngestStarting, 74 74 toggleGoLive, 75 + toggleStopStream, 75 76 } = useLivestreamInfo(); 76 77 const { width, height } = usePlayerDimensions(); 77 78 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 102 103 103 104 const isSelfAndNotLive = ingest === "new"; 104 105 const isLive = ingest !== null && ingest !== "new"; 106 + 107 + useEffect(() => { 108 + if (isLive && ingestStarting) { 109 + setIngestStarting(false); 110 + } 111 + }, [isLive, ingestStarting, setIngestStarting]); 105 112 106 113 const FADE_OUT_DELAY = 4000; 107 114 const fadeOpacity = useSharedValue(1); ··· 222 229 <View 223 230 style={[ 224 231 layout.position.absolute, 225 - position.top[28], 232 + position.top[32], 226 233 position.left[0], 227 234 position.right[0], 228 235 layout.flex.column, ··· 230 237 ]} 231 238 > 232 239 <PlayerUI.MetricsPanel 233 - showMetrics={isLive || isSelfAndNotLive} 240 + showMetrics={shouldShowFloatingMetrics} 234 241 /> 235 242 </View> 236 243 )} ··· 241 248 setTitle={setTitle} 242 249 ingestStarting={ingestStarting} 243 250 toggleGoLive={toggleGoLive} 251 + isLive={isLive} 244 252 /> 245 253 )} 246 254 ··· 468 476 <Pressable onPress={doSetIngestCamera}> 469 477 <SwitchCamera color={theme.colors.foreground} size={20} /> 470 478 </Pressable> 479 + {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} 471 480 </> 472 481 )} 473 482 {Platform.OS === "web" ? ( ··· 515 524 )} 516 525 </Pressable> 517 526 )} 518 - <PlayerUI.ContextMenu /> 527 + {ingest === null ? ( 528 + <PlayerUI.ContextMenu /> 529 + ) : ( 530 + <PlayerUI.StreamContextMenu /> 531 + )} 519 532 </View> 520 533 )} 521 534 {shouldShowChatSidePanel && setShowChat && (
-64
js/app/components/provider/CurrentToast.tsx
··· 1 - import { Text, zero } from "@streamplace/components"; 2 - import { Platform, Pressable, View } from "react-native"; 3 - 4 - const isWeb = Platform.OS === "web"; 5 - 6 - // Note: Toast functionality removed - this is now a placeholder implementation 7 - // In a real app, you might want to use a toast library like react-native-toast-message 8 - // or implement a simple alert/modal system 9 - 10 - export function CurrentToast() { 11 - // Toast functionality removed - would need replacement with simple modal or alert 12 - return null; 13 - } 14 - 15 - export function ToastControl() { 16 - // Note: This was a demo component for testing toasts 17 - return ( 18 - <View style={[{ gap: 8 }, zero.layout.flex.alignCenter]}> 19 - <Text style={[{ fontSize: 18, fontWeight: "bold" }]}> 20 - Toast demo (disabled) 21 - </Text> 22 - <View 23 - style={[ 24 - zero.layout.flex.row, 25 - { gap: 8 }, 26 - zero.layout.flex.justifyCenter, 27 - ]} 28 - > 29 - <Pressable 30 - style={[ 31 - { 32 - backgroundColor: "#0066cc", 33 - padding: 12, 34 - borderRadius: 8, 35 - alignItems: "center", 36 - }, 37 - ]} 38 - onPress={() => { 39 - // Would show toast: "Successfully saved!" with message: "Don't worry, we've got your data." 40 - console.log("Toast would show: Successfully saved!"); 41 - }} 42 - > 43 - <Text style={{ color: "white" }}>Show</Text> 44 - </Pressable> 45 - <Pressable 46 - style={[ 47 - { 48 - backgroundColor: "#666", 49 - padding: 12, 50 - borderRadius: 8, 51 - alignItems: "center", 52 - }, 53 - ]} 54 - onPress={() => { 55 - // Would hide toast 56 - console.log("Toast would hide"); 57 - }} 58 - > 59 - <Text style={{ color: "white" }}>Hide</Text> 60 - </Pressable> 61 - </View> 62 - </View> 63 - ); 64 - }
+619
js/app/components/settings/multistream-manager.tsx
··· 1 + import { 2 + Button, 3 + Dialog, 4 + DialogFooter, 5 + Input, 6 + Text, 7 + TFunction, 8 + useTranslation, 9 + zero, 10 + } from "@streamplace/components"; 11 + import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 12 + import { gap, layout, mb, mt, text, w } from "@streamplace/components/src/ui"; 13 + import Loading from "components/loading/loading"; 14 + import { Plus, RefreshCw } from "lucide-react-native"; 15 + import { useEffect, useState } from "react"; 16 + import { Alert, ScrollView, Switch, View } from "react-native"; 17 + import { 18 + PlaceStreamMultistreamDefs, 19 + PlaceStreamMultistreamTarget, 20 + } from "streamplace"; 21 + import { timeAgo } from "utils/timeAgo"; 22 + import { SettingsListItem } from "./settings-list-item"; 23 + 24 + interface MultistreamTargetViewHydrated 25 + extends PlaceStreamMultistreamDefs.TargetView { 26 + record: PlaceStreamMultistreamTarget.Record; 27 + } 28 + 29 + const redactMultistreamTargetURL = (url: string) => { 30 + try { 31 + const u = new URL(url); 32 + return `${u.protocol}//${u.host}/redacted`; 33 + } catch (error) { 34 + return "parsing failed"; 35 + } 36 + }; 37 + 38 + const multistreamTitle = ( 39 + target: MultistreamTargetViewHydrated | undefined, 40 + t: TFunction, 41 + ) => { 42 + if (!target) { 43 + return t("untitled-multistream-target"); 44 + } 45 + if (target.record.name) { 46 + return target.record.name; 47 + } 48 + if (target.record.url) { 49 + try { 50 + const u = new URL(target.record.url); 51 + return u.host; 52 + } catch (error) { 53 + return t("untitled-multistream-target"); 54 + } 55 + } 56 + return t("untitled-multistream-target"); 57 + }; 58 + 59 + export default function MultistreamManager() { 60 + const { theme } = zero.useTheme(); 61 + const { t } = useTranslation("settings"); 62 + const agent = usePDSAgent(); 63 + const [loading, setLoading] = useState(true); 64 + const [targets, setTargets] = useState< 65 + MultistreamTargetViewHydrated[] | null 66 + >(null); 67 + const [editingTarget, setEditingTarget] = useState< 68 + MultistreamTargetViewHydrated | undefined 69 + >(undefined); 70 + const [showForm, setShowForm] = useState(false); 71 + const [formLoading, setFormLoading] = useState(false); 72 + const [deleteDialog, setDeleteDialog] = useState<{ 73 + isVisible: boolean; 74 + target: MultistreamTargetViewHydrated | null; 75 + isLoading: boolean; 76 + }>({ isVisible: false, target: null, isLoading: false }); 77 + const [deletingTargets, setDeletingTargets] = useState<Set<string>>( 78 + new Set(), 79 + ); 80 + const [togglingTargets, setTogglingTargets] = useState<Set<string>>( 81 + new Set(), 82 + ); 83 + const [formError, setFormError] = useState<string>(""); 84 + 85 + const loadMultistreamTargets = async () => { 86 + if (!agent) return; 87 + 88 + try { 89 + setLoading(true); 90 + const targetViews = await agent.place.stream.multistream.listTargets({ 91 + limit: 50, 92 + }); 93 + setTargets(targetViews.data.targets as MultistreamTargetViewHydrated[]); 94 + } catch (error) { 95 + console.error("Failed to load multistream targets:", error); 96 + Alert.alert("Error", t("failed-load-multistream-targets")); 97 + } finally { 98 + setLoading(false); 99 + } 100 + }; 101 + 102 + const createMultistreamTarget = async ( 103 + record: PlaceStreamMultistreamTarget.Record, 104 + ) => { 105 + if (!agent) return; 106 + try { 107 + setFormError(""); 108 + setFormLoading(true); 109 + await agent.place.stream.multistream.createTarget({ 110 + multistreamTarget: { 111 + ...record, 112 + createdAt: new Date().toISOString(), 113 + }, 114 + }); 115 + setShowForm(false); 116 + await loadMultistreamTargets(); 117 + setFormLoading(false); 118 + } catch (error) { 119 + setFormError(error.message); 120 + } finally { 121 + setFormLoading(false); 122 + } 123 + }; 124 + 125 + const editMultistreamTarget = async ( 126 + uri: string, 127 + record: PlaceStreamMultistreamTarget.Record, 128 + ) => { 129 + if (!agent) return; 130 + try { 131 + setFormError(""); 132 + setFormLoading(true); 133 + await agent.place.stream.multistream.putTarget({ 134 + multistreamTarget: record, 135 + rkey: uri.split("/").pop() || "", 136 + }); 137 + setShowForm(false); 138 + await loadMultistreamTargets(); 139 + setFormLoading(false); 140 + } catch (error) { 141 + console.error("Failed to edit multistream target:", error); 142 + setFormError(error.message); 143 + } finally { 144 + setFormLoading(false); 145 + } 146 + }; 147 + 148 + const toggleMultistreamTarget = async ( 149 + target: MultistreamTargetViewHydrated, 150 + newActiveState: boolean, 151 + ) => { 152 + if (!agent) return; 153 + try { 154 + setTogglingTargets((prev) => new Set(prev).add(target.uri)); 155 + await agent.place.stream.multistream.putTarget({ 156 + multistreamTarget: { 157 + ...target.record, 158 + active: newActiveState, 159 + }, 160 + rkey: target.uri.split("/").pop() || "", 161 + }); 162 + await loadMultistreamTargets(); 163 + } catch (error) { 164 + console.error("Failed to toggle multistream target:", error); 165 + Alert.alert("Error", t("failed-toggle-multistream-target")); 166 + } finally { 167 + setTogglingTargets((prev) => { 168 + const newSet = new Set(prev); 169 + newSet.delete(target.uri); 170 + return newSet; 171 + }); 172 + } 173 + }; 174 + 175 + const deleteMultistreamTarget = async (uri: string) => { 176 + if (!agent) return; 177 + try { 178 + setFormError(""); 179 + setDeletingTargets((prev) => new Set(prev).add(uri)); 180 + await agent.place.stream.multistream.deleteTarget({ 181 + rkey: uri.split("/").pop() || "", 182 + }); 183 + setShowForm(false); 184 + await loadMultistreamTargets(); 185 + setDeleteDialog({ isVisible: false, target: null, isLoading: false }); 186 + } catch (error) { 187 + console.error("Failed to delete multistream target:", error); 188 + setFormError(error.message); 189 + } finally { 190 + setDeletingTargets((prev) => { 191 + const newSet = new Set(prev); 192 + newSet.delete(uri); 193 + return newSet; 194 + }); 195 + } 196 + }; 197 + 198 + useEffect(() => { 199 + loadMultistreamTargets(); 200 + }, [agent]); 201 + 202 + const handleEdit = (target: MultistreamTargetViewHydrated) => { 203 + setEditingTarget(target); 204 + setShowForm(true); 205 + }; 206 + 207 + const handleCreate = () => { 208 + setEditingTarget(undefined); 209 + setShowForm(true); 210 + }; 211 + 212 + return ( 213 + <> 214 + <ScrollView> 215 + <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 216 + <View style={{ maxWidth: 800, width: "100%" }}> 217 + {/* Header */} 218 + <View style={[mb[6]]}> 219 + <Text style={[mb[2], { fontSize: 24, fontWeight: "700" }]}> 220 + {t("multistream-targets")} 221 + </Text> 222 + <Text style={[text.gray[400], mb[4], { fontSize: 14 }]}> 223 + {t("multistream-description")} 224 + </Text> 225 + 226 + <View 227 + style={[ 228 + layout.flex.row, 229 + layout.flex.justify.start, 230 + gap.all[3], 231 + w.percent[100], 232 + mt[2], 233 + ]} 234 + > 235 + <Button 236 + onPress={handleCreate} 237 + size="pill" 238 + width="min" 239 + leftIcon={<Plus color={theme.colors.text} />} 240 + > 241 + <Text>{t("create-multistream-target")}</Text> 242 + </Button> 243 + 244 + <Button 245 + onPress={loadMultistreamTargets} 246 + disabled={loading} 247 + leftIcon={<RefreshCw color={theme.colors.text} />} 248 + size="pill" 249 + width="min" 250 + variant="secondary" 251 + > 252 + <Text>{t("refresh")}</Text> 253 + </Button> 254 + </View> 255 + </View> 256 + </View> 257 + 258 + {/* Content */} 259 + {loading && !targets ? ( 260 + <Loading /> 261 + ) : targets === null ? ( 262 + <View style={[layout.flex.center, mt[8]]}> 263 + <Text style={[text.gray[600]]}> 264 + {t("failed-load-multistream-targets")} 265 + </Text> 266 + </View> 267 + ) : targets.length === 0 ? ( 268 + <View style={[layout.flex.center, mt[8]]}> 269 + <Text style={[text.gray[600], mb[4], { fontSize: 16 }]}> 270 + {t("no-multistream-targets-yet")} 271 + </Text> 272 + </View> 273 + ) : ( 274 + <> 275 + <View style={[mb[4]]}> 276 + <Text style={[text.gray[600], { fontSize: 14 }]}> 277 + {t("multistream-targets-count", { count: targets.length })} 278 + </Text> 279 + </View> 280 + {targets.map((target) => ( 281 + <MultistreamRow 282 + key={target.uri} 283 + target={target} 284 + onEdit={handleEdit} 285 + onDelete={() => 286 + setDeleteDialog({ 287 + isVisible: true, 288 + target, 289 + isLoading: false, 290 + }) 291 + } 292 + onToggle={toggleMultistreamTarget} 293 + isDeleting={deletingTargets.has(target.uri)} 294 + isToggling={togglingTargets.has(target.uri)} 295 + /> 296 + ))} 297 + </> 298 + )} 299 + </View> 300 + </ScrollView> 301 + <MultistreamTargetForm 302 + target={editingTarget} 303 + isVisible={showForm} 304 + onClose={() => { 305 + setShowForm(false); 306 + }} 307 + onSubmit={(record: PlaceStreamMultistreamTarget.Record) => { 308 + if (editingTarget) { 309 + editMultistreamTarget(editingTarget.uri, record); 310 + } else { 311 + createMultistreamTarget(record); 312 + } 313 + }} 314 + isLoading={formLoading} 315 + formError={formError} 316 + /> 317 + 318 + <MultistreamTargetDeleteDialog 319 + target={deleteDialog.target || undefined} 320 + isVisible={deleteDialog.isVisible} 321 + onClose={() => 322 + setDeleteDialog({ 323 + isVisible: false, 324 + target: null, 325 + isLoading: false, 326 + }) 327 + } 328 + onSubmit={() => 329 + deleteDialog.target && 330 + deleteMultistreamTarget(deleteDialog.target.uri) 331 + } 332 + isLoading={deleteDialog.isLoading} 333 + formError={formError} 334 + /> 335 + </> 336 + ); 337 + } 338 + 339 + export function MultistreamRow({ 340 + target, 341 + onEdit, 342 + onDelete, 343 + onToggle, 344 + isDeleting, 345 + isToggling, 346 + }: { 347 + target: MultistreamTargetViewHydrated; 348 + onEdit: (target: MultistreamTargetViewHydrated) => void; 349 + onDelete: (uri: string) => void; 350 + onToggle: (target: MultistreamTargetViewHydrated, active: boolean) => void; 351 + isDeleting: boolean; 352 + isToggling: boolean; 353 + }) { 354 + const { t } = useTranslation("settings"); 355 + // Determine latest event status for footer 356 + const getStatusInfo = () => { 357 + if (target.latestEvent) { 358 + return ( 359 + <View style={[layout.flex.row, gap.all[4]]}> 360 + <Text style={[text.gray[400], { fontSize: 11 }]}> 361 + {t("status")}: {target.latestEvent.status} 362 + </Text> 363 + <Text style={[text.gray[400], { fontSize: 11 }]}> 364 + {timeAgo(new Date(target.latestEvent.createdAt))} 365 + </Text> 366 + </View> 367 + ); 368 + } 369 + return null; 370 + }; 371 + 372 + return ( 373 + <SettingsListItem 374 + title={multistreamTitle(target, t)} 375 + url={redactMultistreamTargetURL(target.record.url)} 376 + active={target.record.active} 377 + isDeleting={isDeleting} 378 + isToggling={isToggling} 379 + footer={{ 380 + left: `${t("created")} ${timeAgo(new Date(target.record.createdAt))}`, 381 + right: getStatusInfo(), 382 + }} 383 + onEdit={() => onEdit(target)} 384 + onDelete={() => onDelete(target.uri)} 385 + onToggle={(active) => onToggle(target, active)} 386 + /> 387 + ); 388 + } 389 + 390 + function MultistreamTargetForm({ 391 + target, 392 + isVisible, 393 + onClose, 394 + onSubmit, 395 + isLoading, 396 + formError, 397 + }: { 398 + target?: MultistreamTargetViewHydrated; 399 + isVisible: boolean; 400 + onClose: () => void; 401 + onSubmit: (record: PlaceStreamMultistreamTarget.Record) => void; 402 + isLoading: boolean; 403 + formError: string; 404 + }) { 405 + const { t } = useTranslation("settings"); 406 + const [formData, setFormData] = useState<PlaceStreamMultistreamTarget.Record>( 407 + { 408 + $type: "place.stream.multistream.target", 409 + name: target?.record.name || "", 410 + url: target?.record.url || "", 411 + active: target?.record.active ?? true, 412 + createdAt: target?.record.createdAt || "", 413 + }, 414 + ); 415 + 416 + const [errors, setErrors] = useState<Record<string, string>>({}); 417 + const [changedTargetUrl, setChangedTargetUrl] = useState(false); 418 + 419 + // Update form data when webhook prop changes (for editing) 420 + useEffect(() => { 421 + setErrors({}); 422 + setChangedTargetUrl(false); 423 + if (target) { 424 + setFormData({ 425 + $type: "place.stream.multistream.target", 426 + name: target.record.name || "", 427 + url: target.record.url || "", 428 + active: target.record.active ?? true, 429 + createdAt: target.record.createdAt || "", 430 + }); 431 + } else { 432 + // Reset form for new webhook 433 + setFormData({ 434 + $type: "place.stream.multistream.target", 435 + name: "", 436 + url: "", 437 + active: true, 438 + createdAt: "", 439 + }); 440 + } 441 + }, [target, isVisible]); 442 + 443 + const validateForm = () => { 444 + const newErrors: Record<string, string> = {}; 445 + 446 + if (!formData.url.trim()) { 447 + newErrors.url = "URL is required"; 448 + } else if (!formData.url.match(/^rtmps?:\/\/.+/)) { 449 + newErrors.url = "URL must start with rtmp:// or rtmps://"; 450 + } 451 + 452 + setErrors(newErrors); 453 + return Object.keys(newErrors).length === 0; 454 + }; 455 + 456 + const handleSubmit = () => { 457 + if (validateForm()) { 458 + onSubmit(formData); 459 + } 460 + }; 461 + 462 + let displayUrl = formData.url; 463 + if (target && !changedTargetUrl) { 464 + displayUrl = ""; 465 + } 466 + 467 + return ( 468 + <Dialog 469 + open={isVisible} 470 + onOpenChange={(open) => !open && onClose()} 471 + title={ 472 + target ? t("multistream-edit-target") : t("multistream-create-target") 473 + } 474 + size="lg" 475 + dismissible={false} 476 + > 477 + <View style={[w.percent[100]]}> 478 + {/* Name */} 479 + <View style={[mb[4]]}> 480 + <Text 481 + style={[text.gray[300], mb[2], { fontSize: 14, fontWeight: "500" }]} 482 + > 483 + {t("rtmp-target-name")} ({t("optional")}) 484 + </Text> 485 + <Input 486 + value={formData.name} 487 + onChangeText={(text) => 488 + setFormData((prev) => ({ ...prev, name: text })) 489 + } 490 + placeholder={t("rtmp-target-name-placeholder")} 491 + /> 492 + </View> 493 + 494 + {/* URL */} 495 + <View style={[mb[4]]}> 496 + <Text 497 + style={[text.gray[300], mb[2], { fontSize: 14, fontWeight: "500" }]} 498 + > 499 + {t("rtmp-target-url")} * 500 + </Text> 501 + <Input 502 + value={displayUrl} 503 + onChangeText={(text) => { 504 + setChangedTargetUrl(true); 505 + setFormData((prev) => ({ 506 + ...prev, 507 + url: text.trim().replaceAll(/\n/g, ""), 508 + })); 509 + }} 510 + placeholder={ 511 + target 512 + ? redactMultistreamTargetURL(target.record.url) 513 + : "rtmps://example.com:443/live/foo" 514 + } 515 + multiline 516 + /> 517 + <Text style={[text.red[600], mt[1], { fontSize: 12 }]}> 518 + &nbsp;{errors.url} 519 + </Text> 520 + </View> 521 + 522 + {/* Active toggle */} 523 + <View 524 + style={[ 525 + layout.flex.row, 526 + layout.flex.alignCenter, 527 + layout.flex.spaceBetween, 528 + mb[6], 529 + ]} 530 + > 531 + <Text style={[text.gray[300], { fontSize: 14, fontWeight: "500" }]}> 532 + {t("active")} 533 + </Text> 534 + <Switch 535 + value={formData.active} 536 + onValueChange={(active) => 537 + setFormData((prev) => ({ ...prev, active })) 538 + } 539 + /> 540 + </View> 541 + <Text style={[text.red[600], mt[1], { fontSize: 12 }]}> 542 + &nbsp;{formError} 543 + </Text> 544 + </View> 545 + 546 + <DialogFooter> 547 + <Button 548 + variant="secondary" 549 + onPress={onClose} 550 + disabled={isLoading} 551 + width="min" 552 + > 553 + <Text>Cancel</Text> 554 + </Button> 555 + <Button onPress={handleSubmit} disabled={isLoading} width="min"> 556 + <Text>{isLoading ? "Saving..." : target ? "Update" : "Create"}</Text> 557 + </Button> 558 + </DialogFooter> 559 + </Dialog> 560 + ); 561 + } 562 + 563 + const MultistreamTargetDeleteDialog = ({ 564 + target, 565 + isVisible, 566 + onClose, 567 + onSubmit, 568 + isLoading, 569 + formError, 570 + }: { 571 + target?: MultistreamTargetViewHydrated; 572 + isVisible: boolean; 573 + onClose: () => void; 574 + onSubmit: () => void; 575 + isLoading: boolean; 576 + formError: string; 577 + }) => { 578 + const { t } = useTranslation("settings"); 579 + return ( 580 + <Dialog 581 + open={isVisible} 582 + onOpenChange={(open) => !open && onClose()} 583 + title="Delete Webhook" 584 + dismissible={false} 585 + > 586 + <View style={[w.percent[100], mb[8], mt[2]]}> 587 + <Text style={[{ fontSize: 24 }]}> 588 + {t("multistream-delete-target-confirmation", { 589 + target: multistreamTitle(target, t), 590 + })} 591 + </Text> 592 + <Text 593 + style={[text.gray[400], mt[4], { fontSize: 18, fontWeight: "700" }]} 594 + > 595 + {t("this-action-cannot-be-undone")} 596 + </Text> 597 + </View> 598 + 599 + <Text style={[text.red[600], mt[1], { fontSize: 12 }]}> 600 + &nbsp;{formError} 601 + </Text> 602 + 603 + <View style={[layout.flex.row, layout.flex.justify.end, gap.all[3]]}> 604 + <Button 605 + variant="secondary" 606 + onPress={() => onClose()} 607 + disabled={isLoading} 608 + > 609 + <Text>Cancel</Text> 610 + </Button> 611 + <Button variant="destructive" onPress={onSubmit} disabled={isLoading}> 612 + <Text style={[text.white, { fontSize: 14, fontWeight: "500" }]}> 613 + {isLoading ? t("deleting") : t("delete")} 614 + </Text> 615 + </Button> 616 + </View> 617 + </Dialog> 618 + ); 619 + };
+191
js/app/components/settings/settings-list-item.tsx
··· 1 + import { Text, zero } from "@streamplace/components"; 2 + import { Edit3, Trash2 } from "lucide-react-native"; 3 + import { ReactNode } from "react"; 4 + import { Pressable, Switch, View } from "react-native"; 5 + 6 + const { bg, text, p, mb, w, h, r, layout, borders, flex, gap, pt } = zero; 7 + 8 + interface SettingsListItemProps { 9 + title: string; 10 + subtitle?: string; 11 + url?: string; 12 + active: boolean; 13 + isDeleting?: boolean; 14 + badges?: Array<{ text: string; color: string; bgColor: string }>; 15 + metadata?: Array<{ label: string; value: string | string[] }>; 16 + footer?: { 17 + left?: string; 18 + right?: ReactNode; 19 + }; 20 + onEdit: () => void; 21 + onDelete: () => void; 22 + onToggle?: (active: boolean) => void; 23 + isToggling?: boolean; 24 + } 25 + 26 + export function SettingsListItem({ 27 + title, 28 + subtitle, 29 + url, 30 + active, 31 + isDeleting = false, 32 + badges = [], 33 + metadata = [], 34 + footer, 35 + onEdit, 36 + onDelete, 37 + onToggle, 38 + isToggling = false, 39 + }: SettingsListItemProps) { 40 + const { theme } = zero.useTheme(); 41 + 42 + return ( 43 + <View 44 + style={[ 45 + flex.shrink[1], 46 + borders.width.thin, 47 + borders.color.gray[200], 48 + bg.neutral[800], 49 + r.xl, 50 + p[4], 51 + mb[3], 52 + layout.flex.column, 53 + gap.all[3], 54 + { opacity: isDeleting ? 0.5 : active ? 1 : 0.7 }, 55 + ]} 56 + > 57 + {/* Header */} 58 + <View 59 + style={[ 60 + layout.flex.row, 61 + layout.flex.spaceBetween, 62 + layout.flex.alignCenter, 63 + ]} 64 + > 65 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 66 + <View 67 + style={[ 68 + w[3], 69 + h[3], 70 + r.full, 71 + { backgroundColor: active ? "#22c55e" : "#6b7280" }, 72 + ]} 73 + /> 74 + <Text style={[{ fontSize: 16, fontWeight: "600" }]}>{title}</Text> 75 + {badges.map((badge, index) => ( 76 + <View 77 + key={index} 78 + style={[{ backgroundColor: badge.bgColor }, p[1], r.full]} 79 + > 80 + <Text style={[{ color: badge.color, fontSize: 12 }]}> 81 + {badge.text} 82 + </Text> 83 + </View> 84 + ))} 85 + </View> 86 + 87 + <View style={[layout.flex.row, gap.all[2]]}> 88 + {onToggle && ( 89 + <Switch 90 + value={active} 91 + onValueChange={onToggle} 92 + disabled={isDeleting || isToggling} 93 + /> 94 + )} 95 + <Pressable 96 + style={[ 97 + bg.gray[100], 98 + p[2], 99 + r.md, 100 + layout.flex.center, 101 + { minWidth: 32, minHeight: 32 }, 102 + ]} 103 + onPress={onEdit} 104 + disabled={isDeleting} 105 + > 106 + <Edit3 size={16} color="#374151" /> 107 + </Pressable> 108 + 109 + <Pressable 110 + style={[ 111 + bg.red[800], 112 + p[2], 113 + r.md, 114 + layout.flex.center, 115 + { minWidth: 32, minHeight: 32 }, 116 + ]} 117 + onPress={onDelete} 118 + disabled={isDeleting} 119 + > 120 + <Trash2 size={16} /> 121 + </Pressable> 122 + </View> 123 + </View> 124 + 125 + {/* Subtitle/Description */} 126 + {subtitle && ( 127 + <Text style={[text.gray[300], { fontSize: 14 }]}>{subtitle}</Text> 128 + )} 129 + 130 + {/* URL */} 131 + {url && ( 132 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 133 + <Text style={[text.gray[300], { fontSize: 12 }]}>URL:</Text> 134 + <Text 135 + style={[text.gray[400], { fontSize: 12, fontFamily: "monospace" }]} 136 + numberOfLines={1} 137 + > 138 + {url.length > 50 139 + ? url.slice(0, 45) + "..." + url.slice(url.length - 5) 140 + : url} 141 + </Text> 142 + </View> 143 + )} 144 + 145 + {/* Metadata */} 146 + {metadata.map((meta, index) => ( 147 + <View 148 + key={index} 149 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]} 150 + > 151 + <Text style={[text.gray[300], { fontSize: 12 }]}>{meta.label}:</Text> 152 + <View style={[layout.flex.row, gap.all[1], { flexWrap: "wrap" }]}> 153 + {Array.isArray(meta.value) ? ( 154 + meta.value.map((item: any, idx: number) => ( 155 + <View key={idx} style={[bg.blue[700], p[1], r.full]}> 156 + <Text style={[text.blue[300], { fontSize: 11 }]}>{item}</Text> 157 + </View> 158 + )) 159 + ) : ( 160 + <Text style={[text.gray[400], { fontSize: 12 }]}> 161 + {meta.value} 162 + </Text> 163 + )} 164 + </View> 165 + </View> 166 + ))} 167 + 168 + {/* Footer */} 169 + {footer && ( 170 + <View 171 + style={[ 172 + layout.flex.row, 173 + layout.flex.spaceBetween, 174 + pt[2], 175 + borders.top.width.thin, 176 + borders.top.color.gray[100], 177 + ]} 178 + > 179 + {footer.left && ( 180 + <Text style={[text.gray[400], { fontSize: 11 }]}> 181 + {footer.left} 182 + </Text> 183 + )} 184 + {footer.right && ( 185 + <View style={[layout.flex.row, gap.all[4]]}>{footer.right}</View> 186 + )} 187 + </View> 188 + )} 189 + </View> 190 + ); 191 + }
+7 -1
js/app/components/settings/streaming-category-settings.tsx
··· 5 5 View, 6 6 zero, 7 7 } from "@streamplace/components"; 8 - import { Heart, Key, Webhook } from "lucide-react-native"; 8 + import { Globe, Heart, Key, Webhook } from "lucide-react-native"; 9 9 import { useTranslation } from "react-i18next"; 10 10 import { ScrollView } from "react-native"; 11 11 import { SettingsNavigationItem } from "./components/settings-navigation-item"; ··· 34 34 title={t("webhooks")} 35 35 screen="WebhooksSettings" 36 36 icon={Webhook} 37 + /> 38 + <MenuSeparator /> 39 + <SettingsNavigationItem 40 + title={t("multistream")} 41 + screen="MultistreamCategory" 42 + icon={Globe} 37 43 /> 38 44 </MenuGroup> 39 45 </MenuContainer>
+7 -2
js/app/components/settings/webhook-manager.tsx
··· 594 594 </View> 595 595 596 596 <DialogFooter> 597 - <Button variant="secondary" onPress={onClose} disabled={isLoading}> 597 + <Button 598 + width="min" 599 + variant="secondary" 600 + onPress={onClose} 601 + disabled={isLoading} 602 + > 598 603 <Text>{t("cancel")}</Text> 599 604 </Button> 600 - <Button onPress={handleSubmit} disabled={isLoading}> 605 + <Button width="min" onPress={handleSubmit} disabled={isLoading}> 601 606 <Text> 602 607 {isLoading ? t("saving") : webhook ? t("update") : t("create")} 603 608 </Text>
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 2 import { storage } from "@streamplace/components"; 3 3 import { useURL } from "expo-linking"; 4 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 5 6 import { useStore } from "store"; 6 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 8 import { navigateToRoute } from "utils/navigation"; ··· 23 24 loadOAuthClient(); 24 25 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 26 30 storage.getItem("returnRoute").then((stored) => { 27 31 if (stored) { 28 32 try { ··· 82 86 if ( 83 87 lastAuthStatus !== "loggedIn" && 84 88 authStatus === "loggedIn" && 85 - returnRoute 89 + returnRoute && 90 + Platform.OS === "web" 86 91 ) { 87 92 console.log( 88 93 "Login successful, navigating back to returnRoute:",
+15 -11
js/app/features/bluesky/oauthClient.tsx
··· 23 23 let customResolver: StreamplaceOAuthResolver | null = null; 24 24 25 25 let meta: ClientMetadata; 26 + 27 + const redirectURI = 28 + Platform.OS === "web" 29 + ? `${streamplaceUrl}/login` 30 + : `${streamplaceUrl}/api/app-return`; 31 + const res = await fetch( 32 + `${streamplaceUrl}/oauth/downstream/client-metadata.json?redirect_uri=${encodeURIComponent(redirectURI)}`, 33 + ); 34 + meta = await res.json(); 35 + 26 36 if ( 27 37 streamplaceUrl.startsWith("http://localhost") || 28 38 streamplaceUrl.startsWith("http://127.0.0.1") 29 39 ) { 40 + if (!meta.scope) { 41 + throw new Error("meta.scope is required"); 42 + } 30 43 const isWeb = Platform.OS === "web"; 31 44 const u = new URL(streamplaceUrl); 32 45 let hostname = u.hostname; ··· 47 60 redirect = `${redirect}/app-return/${scheme}`; 48 61 } 49 62 const queryParams = new URLSearchParams(); 50 - queryParams.set("scope", "atproto transition:generic"); 63 + queryParams.set("scope", meta.scope); 51 64 queryParams.set("redirect_uri", redirect); 52 65 meta = { 53 66 client_id: `http://localhost?${queryParams.toString()}`, 54 67 redirect_uris: [redirect as any], 55 - scope: "atproto transition:generic", 68 + scope: meta.scope, 56 69 token_endpoint_auth_method: "none", 57 70 client_name: "Loopback client", 58 71 response_types: ["code"], ··· 64 77 subject_type: "public", 65 78 authorization_signed_response_alg: "ES256", 66 79 }; 67 - } else { 68 - const redirectURI = 69 - Platform.OS === "web" 70 - ? `${streamplaceUrl}/login` 71 - : `${streamplaceUrl}/api/app-return`; 72 - const res = await fetch( 73 - `${streamplaceUrl}/oauth/downstream/client-metadata.json?redirect_uri=${encodeURIComponent(redirectURI)}`, 74 - ); 75 - meta = await res.json(); 76 80 } 77 81 try { 78 82 clientMetadataSchema.parse(meta);
+8 -3
js/app/hooks/useBlueskyNotifications.tsx
··· 1 1 import { useToast } from "@streamplace/components"; 2 2 import { CircleX } from "lucide-react-native"; 3 3 import { useEffect } from "react"; 4 + import { Platform } from "react-native"; 5 + import clearQueryParams from "utils/clear-query-params"; 4 6 import { useStore } from "../store"; 5 7 6 8 function titleCase(str: string) { ··· 18 20 let toast = useToast(); 19 21 const notification = useStore((state) => state.notification); 20 22 const clearNotification = useStore((state) => state.clearNotification); 23 + 24 + // we've already saved the notif to the store 25 + clearQueryParams(["error", "error_description"]); 21 26 22 27 useEffect(() => { 23 28 if (notification) { ··· 41 46 { 42 47 duration: 100, 43 48 variant: notification.type, 44 - actionLabel: "Copy message", 49 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 45 50 iconLeft: CircleX, 46 51 onAction: () => { 47 52 navigator.clipboard.writeText( ··· 59 64 notification.message, 60 65 { 61 66 variant: notification.type, 62 - actionLabel: "Copy message", 67 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 63 68 onAction: () => { 64 69 navigator.clipboard.writeText(notification.message); 65 70 }, ··· 74 79 notification.message, 75 80 { 76 81 variant: notification.type, 77 - actionLabel: "Copy message", 82 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 78 83 onAction: () => { 79 84 navigator.clipboard.writeText(notification.message); 80 85 },
+1 -1
js/app/package.json
··· 1 1 { 2 2 "name": "@streamplace/app", 3 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.8.18", 4 + "version": "0.9.9", 5 5 "runtimeVersion": "0.7.2", 6 6 "scripts": { 7 7 "start": "npx expo start -c --port 38081",
+114 -26
js/app/src/router.tsx
··· 16 16 } from "@react-navigation/native"; 17 17 import { createNativeStackNavigator } from "@react-navigation/native-stack"; 18 18 import { 19 + Button, 19 20 Text, 20 21 useDefaultStreamer, 21 22 useSiteTitle, 22 23 useTheme, 23 24 useToast, 25 + zero, 24 26 } from "@streamplace/components"; 25 27 import { Provider, Settings } from "components"; 26 28 import AQLink from "components/aqlink"; ··· 61 63 Platform, 62 64 Pressable, 63 65 StatusBar, 66 + useWindowDimensions, 64 67 View, 65 68 } from "react-native"; 66 69 import AboutScreen from "./screens/about"; ··· 78 81 import HomeScreen from "./screens/home"; 79 82 80 83 import { useUrl } from "@streamplace/components"; 84 + import PdsHostSelectorModal from "components/login/pds-host-selector-modal"; 81 85 import { BrandingAdmin } from "components/settings/branding-admin"; 82 86 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 87 + import MultistreamManager from "components/settings/multistream-manager"; 83 88 import RecommendationsManager from "components/settings/recommendations-manager"; 84 89 import Constants from "expo-constants"; 85 90 import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; ··· 130 135 LanguagesCategory: undefined; 131 136 DeveloperSettings: undefined; 132 137 KeyManagement: undefined; 138 + MultistreamCategory: undefined; 133 139 BrandingAdmin: undefined; 134 140 }; 135 141 ··· 186 192 DanmuCategory: "settings/danmu", 187 193 AdvancedCategory: "settings/advanced", 188 194 DeveloperSettings: "settings/developer", 195 + MultistreamCategory: "settings/streaming/multistream", 196 + KeyManagement: "settings/streaming/key-management", 197 + LanguagesCategory: "settings/languages", 189 198 BrandingAdmin: "settings/branding", 190 199 }, 191 200 }, ··· 288 297 289 298 const AvatarButton = () => { 290 299 const userProfile = useUserProfile(); 300 + const openLoginModal = useStore((state) => state.openLoginModal); 301 + const openPDSModal = useStore((state) => state.openPdsModal); 302 + const loginAction = useStore((state) => state.login); 303 + const openLoginLink = useStore((state) => state.openLoginLink); 304 + const { theme } = useTheme(); 291 305 let source: ImageSourcePropType | undefined = undefined; 292 - let opacity = 1; 293 - const targetScreen: any = userProfile 294 - ? { screen: "Settings", params: { screen: "AccountCategory" } } 295 - : { screen: "Login", params: {} }; 306 + 307 + const windowWidth = useWindowDimensions().width; 308 + 309 + const isCompact = windowWidth <= 800; 296 310 297 311 if (userProfile) { 298 312 source = { uri: userProfile.avatar }; 299 - opacity = 0; 313 + return ( 314 + <AQLink 315 + to={{ screen: "Settings", params: { screen: "AccountCategory" } }} 316 + > 317 + <ImageBackground 318 + key={source?.uri ?? "default"} 319 + source={source} 320 + style={{ 321 + width: 40, 322 + height: 40, 323 + borderRadius: 24, 324 + overflow: "hidden", 325 + marginRight: 10, 326 + backgroundColor: "black", 327 + justifyContent: "center", 328 + alignItems: "center", 329 + }} 330 + > 331 + <User size={24} color="white" style={{ zIndex: -2 }} /> 332 + </ImageBackground> 333 + </AQLink> 334 + ); 300 335 } 336 + 337 + if (isCompact) { 338 + return ( 339 + <Button 340 + onPress={() => openLoginModal()} 341 + variant="ghost" 342 + size="icon" 343 + width="min" 344 + style={{ marginRight: 10, marginLeft: "auto" }} 345 + > 346 + <LogIn size={20} color={theme.colors.text} /> 347 + </Button> 348 + ); 349 + } 350 + 301 351 return ( 302 - <AQLink to={targetScreen}> 303 - <ImageBackground 304 - // defeat cursed-ass caching on ios; image sticks around when source is undefined 305 - key={source?.uri ?? "default"} 306 - source={source} 307 - style={{ 308 - width: 40, 309 - height: 40, 310 - borderRadius: 24, 311 - overflow: "hidden", 312 - marginRight: 10, 313 - backgroundColor: "black", 314 - justifyContent: "center", 315 - alignItems: "center", 316 - }} 352 + <View 353 + style={{ 354 + flexDirection: "row", 355 + alignItems: "center", 356 + gap: 8, 357 + marginRight: 10, 358 + }} 359 + > 360 + <Button 361 + onPress={() => openLoginModal()} 362 + variant="secondary" 363 + width="min" 364 + style={[zero.r.full]} 365 + > 366 + <Text style={{ color: theme.colors.text }}>Log In</Text> 367 + </Button> 368 + <Button 369 + onPress={() => openPDSModal()} 370 + variant="primary" 371 + width="min" 372 + style={[zero.r.full]} 317 373 > 318 - <User size={24} color="white" style={{ zIndex: -2 }} /> 319 - </ImageBackground> 320 - </AQLink> 374 + <Text style={{ color: theme.colors.text }}>Sign Up</Text> 375 + </Button> 376 + <Button 377 + width="min" 378 + size="icon" 379 + variant="secondary" 380 + style={[zero.r.full]} 381 + onPress={() => openLoginModal()} 382 + > 383 + <User size={24} color="white" /> 384 + </Button> 385 + </View> 321 386 ); 322 387 }; 323 388 ··· 409 474 const pollMySegments = useStore((state) => state.pollMySegments); 410 475 const showLoginModal = useStore((state) => state.showLoginModal); 411 476 const closeLoginModal = useStore((state) => state.closeLoginModal); 477 + const showPdsModal = useStore((state) => state.showPdsModal); 478 + const openPdsModal = useStore((state) => state.openPdsModal); 479 + const closePdsModal = useStore((state) => state.closePdsModal); 412 480 const [livePopup, setLivePopup] = useState(false); 481 + const loginAction = useStore((state) => state.login); 482 + const openLoginLink = useStore((state) => state.openLoginLink); 413 483 const siteTitle = useSiteTitle(); 414 484 const defaultStreamer = useDefaultStreamer(); 415 485 ··· 658 728 name="Login" 659 729 component={Login} 660 730 options={{ 661 - drawerIcon: () => <LogIn color={foregroundColor} size={24} />, 662 - drawerLabel: () => <Text variant="h5">Login</Text>, 731 + drawerLabel: () => null, 732 + drawerItemStyle: { display: "none" }, 733 + headerShown: false, 663 734 }} 664 735 /> 665 736 <Drawer.Screen ··· 715 786 }} 716 787 /> 717 788 </Drawer.Navigator> 718 - <LoginModal visible={showLoginModal} onClose={closeLoginModal} /> 789 + <LoginModal 790 + visible={showLoginModal} 791 + onClose={closeLoginModal} 792 + onOpenPdsModal={openPdsModal} 793 + /> 794 + <PdsHostSelectorModal 795 + open={showPdsModal} 796 + onOpenChange={closePdsModal} 797 + onSubmit={(pdsHost) => { 798 + closePdsModal(); 799 + loginAction(pdsHost, openLoginLink); 800 + }} 801 + /> 719 802 </> 720 803 ); 721 804 } ··· 841 924 name="KeyManagement" 842 925 component={KeyManager} 843 926 options={{ headerTitle: "Key Manager", title: "Key Manager" }} 927 + /> 928 + <Stack.Screen 929 + name="MultistreamCategory" 930 + component={MultistreamManager} 931 + options={{ headerTitle: "Multistream", title: "Multistream" }} 844 932 /> 845 933 <Drawer.Screen 846 934 name="BrandingAdmin"
+23 -3
js/app/src/screens/mobile-stream.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 1 2 import { 2 3 KeepAwake, 3 4 LivestreamProvider, ··· 33 34 user, 34 35 src, 35 36 extraProps, 37 + onTeleport, 36 38 }: { 37 39 user: string; 38 40 src: string; 39 41 extraProps: Partial<PlayerProps>; 42 + onTeleport?: (targetHandle: string, targetDID: string) => void; 40 43 }) { 41 44 const problems = useLivestreamStore((x) => x.problems); 42 45 ··· 52 55 <> 53 56 <KeepAwake /> 54 57 <FullscreenProvider> 55 - <Player src={src} {...extraProps} /> 58 + <Player key={src} src={src} {...extraProps} onTeleport={onTeleport} /> 56 59 </FullscreenProvider> 57 60 </> 58 61 ); ··· 60 63 61 64 export default function MobileStream({ route }) { 62 65 const { user, protocol, url } = route?.params ?? {}; 66 + let navi = useNavigation(); 63 67 let extraProps: Partial<PlayerProps> = {}; 64 68 if (isWeb) { 65 69 extraProps = queryToProps(new URLSearchParams(window.location.search)); ··· 69 73 src = url; 70 74 } 71 75 76 + const handleTeleport = (targetHandle: string, targetDID?: string) => { 77 + if (!navi || (!targetHandle && !targetDID)) { 78 + console.error("Navigation or target info missing for teleport"); 79 + return; 80 + } 81 + navi.navigate("Home", { 82 + screen: "Stream", 83 + params: { user: targetHandle }, 84 + }); 85 + }; 86 + 72 87 return ( 73 - <LivestreamProvider src={src}> 88 + <LivestreamProvider key={src} src={src} onTeleport={handleTeleport}> 74 89 <PlayerProvider> 75 - <MobileStreamInner user={user} src={src} extraProps={extraProps} /> 90 + <MobileStreamInner 91 + user={user} 92 + src={src} 93 + extraProps={extraProps} 94 + onTeleport={handleTeleport} 95 + /> 76 96 </PlayerProvider> 77 97 </LivestreamProvider> 78 98 );
+14 -16
js/app/store/slices/blueskySlice.ts
··· 19 19 PlaceStreamServerSettings, 20 20 StreamplaceAgent, 21 21 } from "streamplace"; 22 + import clearQueryParams from "utils/clear-query-params"; 22 23 import { privateKeyToAccount } from "viem/accounts"; 23 24 import { StateCreator } from "zustand"; 24 25 import createOAuthClient, { ··· 86 87 showLoginModal: boolean; 87 88 openLoginModal: (returnRoute?: { name: string; params?: any }) => void; 88 89 closeLoginModal: () => void; 90 + showPdsModal: boolean; 91 + openPdsModal: () => void; 92 + closePdsModal: () => void; 89 93 golivePost: ( 90 94 text: string, 91 95 now: Date, ··· 114 118 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 115 119 } 116 120 117 - const clearQueryParams = () => { 118 - if (Platform.OS !== "web") { 119 - return; 120 - } 121 - const u = new URL(document.location.href); 122 - const params = new URLSearchParams(u.search); 123 - if (u.search === "") { 124 - return; 125 - } 126 - params.delete("iss"); 127 - params.delete("state"); 128 - params.delete("code"); 129 - u.search = params.toString(); 130 - window.history.replaceState(null, "", u.toString()); 131 - }; 132 - 133 121 const uploadThumbnail = async ( 134 122 handle: string, 135 123 u: URL, ··· 210 198 serverSettings: null, 211 199 returnRoute: null, 212 200 showLoginModal: false, 201 + showPdsModal: false, 213 202 notification: null, 214 203 215 204 clearNotification: () => { 205 + clearQueryParams(); 216 206 set({ notification: null }); 217 207 }, 218 208 ··· 237 227 closeLoginModal: () => { 238 228 console.log("closeLoginModal"); 239 229 set({ showLoginModal: false }); 230 + }, 231 + 232 + openPdsModal: () => { 233 + set({ showPdsModal: true }); 234 + }, 235 + 236 + closePdsModal: () => { 237 + set({ showPdsModal: false }); 240 238 }, 241 239 242 240 loadOAuthClient: async () => {
+11 -15
js/app/store/slices/contentMetadataSlice.ts
··· 1 + import { 2 + getPDSServiceEndpoint, 3 + resolveDIDDocument, 4 + } from "@streamplace/components"; 1 5 import { AppStore } from "store"; 2 6 import { StateCreator } from "zustand"; 3 7 import { BlueskySlice } from "./blueskySlice"; ··· 201 205 }); 202 206 203 207 try { 204 - let targetPDS = null; 208 + let targetPDS: string | null = null; 205 209 try { 206 - const didResponse = await fetch(`https://plc.directory/${targetDid}`); 207 - if (didResponse.ok) { 208 - const didDoc = await didResponse.json(); 209 - const pdsService = didDoc.service?.find( 210 - (s: any) => s.id === "#atproto_pds", 211 - ); 212 - if (pdsService) { 213 - targetPDS = pdsService.serviceEndpoint; 214 - console.log( 215 - `[getContentMetadata] Resolved PDS for ${targetDid}:`, 216 - targetPDS, 217 - ); 218 - } 219 - } 210 + const didDoc = await resolveDIDDocument(targetDid); 211 + targetPDS = getPDSServiceEndpoint(didDoc); 212 + console.log( 213 + `[getContentMetadata] Resolved PDS for ${targetDid}:`, 214 + targetPDS, 215 + ); 220 216 } catch (pdsResolveError) { 221 217 console.log( 222 218 `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`,
+15
js/app/utils/clear-query-params.ts
··· 1 + import { Platform } from "react-native"; 2 + 3 + export default function clearQueryParams(par = ["iss", "state", "code"]) { 4 + if (Platform.OS !== "web") { 5 + return; 6 + } 7 + const u = new URL(document.location.href); 8 + const params = new URLSearchParams(u.search); 9 + if (u.search === "") { 10 + return; 11 + } 12 + par.forEach((p) => params.delete(p)); 13 + u.search = params.toString(); 14 + window.history.replaceState(null, "", u.toString()); 15 + }
+3 -6
js/atproto-oauth-client-react-native/README.md
··· 87 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 88 instead need to set up TLS. 89 89 90 - [react-native-quick-crypto]: 91 - https://github.com/margelo/react-native-quick-crypto 90 + [react-native-quick-crypto]: https://github.com/margelo/react-native-quick-crypto 92 91 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 93 - [README]: 94 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 95 - [example]: 96 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example 92 + [README]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 93 + [example]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
+1 -1
js/atproto-oauth-client-react-native/package.json
··· 1 1 { 2 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 - "version": "0.8.0", 3 + "version": "0.9.9", 4 4 "license": "MIT", 5 5 "description": "ATProto OAuth client for React Native", 6 6 "keywords": [
+13 -1
js/components/locales/en-US/common.ftl
··· 51 51 [streamer] Looks like <1>@{ $handle } is offline</1>, but they recommend checking out: 52 52 *[default] Looks like <1>@{ $handle } is offline</1>, but we recommend checking out: 53 53 } 54 - user-offline-no-recommendations = 54 + user-offline-no-recommendations = 55 55 Looks like <1>@{ $handle } is offline</1> right now. 56 56 Check back later. 57 57 streaming-title = streaming { $title } ··· 60 60 [1] 1 viewer 61 61 *[other] { $count } viewers 62 62 } 63 + 64 + ## PDS Host Selector 65 + pds-selector-title = New to the Atmosphere? 66 + pds-selector-description = You'll need to select a PDS (Personal Data Server) to access apps on the Atmosphere, such as Bluesky, Tangled, and Spark. 67 + pds-selector-custom-label = Another PDS 68 + pds-selector-custom-description = Enter your own PDS host URL 69 + pds-selector-custom-url-label = Custom PDS URL 70 + pds-selector-custom-url-placeholder = https://pds.example.com 71 + pds-selector-learn-more = Learn more about self-hosting 72 + pds-selector-info = Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose. 73 + pds-selector-read-policies = Read { $label }'s <tosLink>Terms of Service</tosLink> and <privacyLink>Privacy Policy</privacyLink> before continuing. 74 + pds-selector-handle-policy-checkbox = I have read and agree to the <policyLink>handle policy</policyLink>
+27
js/components/locales/en-US/settings.ftl
··· 62 62 sign-in = Sign In 63 63 update = Update 64 64 log-out = Log out 65 + optional = optional 65 66 66 67 ## Account Settings 67 68 account-greeting = Hey, @{ $handle }. ··· 117 118 events-chat = Chat Events 118 119 untitled-webhook = Untitled Webhook 119 120 inactive = Inactive 121 + active = Active 122 + 123 + ## Multistreaming 124 + multistream = Multistreaming 125 + multistream-targets = Multistream Targets 126 + multistream-description = Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube. 127 + create-multistream-target = Create Multistream Target 128 + untitled-multistream-target = Untitled Target 129 + failed-load-multistream-targets = Failed to load multistream targets. Please try again. 130 + failed-toggle-multistream-target = Failed to toggle multistream target. Please try again. 131 + failed-delete-multistream-target = Failed to delete multistream target. Please try again. 132 + no-multistream-targets-yet = No targets yet! 133 + multistream-targets-count = { $count -> 134 + [one] { $count } target 135 + *[other] { $count } targets 136 + } 137 + multistream-delete-target-confirmation = Are you sure you want to delete "{ $target }"? 138 + this-action-cannot-be-undone = This action cannot be undone. 139 + rtmp-target-name = RTMP Target 140 + rtmp-target-url = RTMP URL 141 + rtmp-target-name-placeholder = My Multistream Target 142 + multistream-create-target = Create Target 143 + multistream-edit-target = Edit Target 144 + created = created 145 + status = status 120 146 121 147 ## Debug Recording 122 148 debug-recording = Debug Recording ··· 142 168 no-languages-found = No languages found 143 169 144 170 ## Branding Administration 171 + branding = Branding 145 172 branding-admin = Branding Administration 146 173 branding-admin-description = Customize your Streamplace instance. Note that settings may take a few hours to propagate. 147 174 branding-login-required = Please log in to manage branding
+4 -1
js/components/package.json
··· 1 1 { 2 2 "name": "@streamplace/components", 3 - "version": "0.8.18", 3 + "version": "0.9.9", 4 4 "description": "Streamplace React (Native) Components", 5 5 "main": "dist/index.js", 6 6 "types": "src/index.tsx", ··· 18 18 "license": "MIT", 19 19 "devDependencies": { 20 20 "@fluent/syntax": "^0.19.0", 21 + "@types/react-dom": "^19.2.3", 21 22 "@types/sdp-transform": "^2.15.0", 22 23 "i18next-cli": "^1.32.0", 23 24 "nodemon": "^3.1.10", ··· 31 32 "@fluent/langneg": "^0.7.0", 32 33 "@fluent/react": "^0.15.2", 33 34 "@gorhom/bottom-sheet": "^5.1.6", 35 + "@radix-ui/react-dropdown-menu": "^2.1.16", 34 36 "@rn-primitives/dropdown-menu": "^1.2.0", 35 37 "@rn-primitives/portal": "^1.3.0", 36 38 "@rn-primitives/slider": "^1.2.0", ··· 40 42 "expo-sensors": "^15.0.7", 41 43 "expo-sqlite": "~15.2.12", 42 44 "expo-video": "^2.0.0", 45 + "graphemer": "^1.4.0", 43 46 "hls.js": "^1.5.17", 44 47 "i18next": "^25.4.2", 45 48 "i18next-browser-languagedetector": "^8.2.0",
+149 -54
js/components/src/components/chat/chat-box.tsx
··· 1 1 import Picker from "@emoji-mart/react"; 2 + import Graphemer from "graphemer"; 2 3 import { AtSignIcon, ExternalLink, X } from "lucide-react-native"; 4 + import { env } from "process"; 3 5 import { useEffect, useMemo, useRef, useState } from "react"; 4 6 import { Platform, Pressable, TextInput } from "react-native"; 5 7 import { ChatMessageViewHydrated } from "streamplace"; 6 - import { 7 - Button, 8 - Loader, 9 - Text, 10 - useChat, 11 - useCreateChatMessage, 12 - useLivestream, 13 - useReplyToMessage, 14 - useSetReplyToMessage, 15 - useTheme, 16 - View, 17 - } from "../../"; 8 + import { Button, Loader, Text, toast, useTheme, View } from "../../"; 9 + import { handleSlashCommand } from "../../lib/slash-commands"; 10 + import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 11 + import { StreamNotifications } from "../../lib/stream-notifications"; 12 + import { SystemMessages } from "../../lib/system-messages"; 18 13 import { 19 - bg, 14 + borders, 20 15 flex, 21 16 gap, 22 17 h, 23 18 layout, 24 19 mb, 25 - mr, 26 20 pl, 27 21 pr, 28 22 py, 23 + r, 29 24 w, 30 25 } from "../../lib/theme/atoms"; 26 + import { 27 + useChat, 28 + useCreateChatMessage, 29 + useLivestream, 30 + useLivestreamStore, 31 + useReplyToMessage, 32 + useSetReplyToMessage, 33 + } from "../../livestream-store"; 34 + import { useDID, usePDSAgent } from "../../streamplace-store"; 31 35 import { Textarea } from "../ui/textarea"; 32 36 import { RenderChatMessage } from "./chat-message"; 33 37 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 38 42 ..."๐Ÿ˜€๐Ÿฅธ๐Ÿ˜๐Ÿ˜˜๐Ÿ˜๐Ÿฅธ๐Ÿ˜†๐Ÿฅธ๐Ÿ˜œ๐Ÿฅธ๐Ÿ˜‚๐Ÿ˜…๐Ÿฅธ๐Ÿ™‚๐Ÿคซ๐Ÿ˜ฑ๐Ÿฅธ๐Ÿคฃ๐Ÿ˜—๐Ÿ˜„๐Ÿฅธ๐Ÿ˜Ž๐Ÿค“๐Ÿ˜ฒ๐Ÿ˜ฏ๐Ÿ˜ฐ๐Ÿฅธ๐Ÿ˜ฅ๐Ÿฅธ๐Ÿ˜ฃ๐Ÿฅธ๐Ÿ˜ž๐Ÿ˜“๐Ÿฅธ๐Ÿ˜ฉ๐Ÿ˜ฉ๐Ÿฅธ๐Ÿ˜ค๐Ÿฅฑ", 39 43 ]; 40 44 45 + const graphemer = new Graphemer(); 46 + 41 47 export function ChatBox({ 42 48 isPopout, 43 49 chatBoxStyle, ··· 62 68 new Map(), 63 69 ); 64 70 const [filteredEmojis, setFilteredEmojis] = useState<any[]>([]); 71 + const isOverLimit = graphemer.countGraphemes(message) > 300; 65 72 66 73 let linfo = useLivestream(); 67 74 68 - const { theme } = useTheme(); 75 + const { theme, zero: zt } = useTheme(); 69 76 70 77 const chat = useChat(); 71 78 const createChatMessage = useCreateChatMessage(); ··· 73 80 const setReplyToMessage = useSetReplyToMessage(); 74 81 const textAreaRef = useRef<TextInput>(null); 75 82 83 + const pdsAgent = usePDSAgent(); 84 + const userDID = useDID(); 85 + const setActiveTeleportUri = useLivestreamStore( 86 + (state) => state.setActiveTeleportUri, 87 + ); 88 + 89 + useEffect(() => { 90 + if (pdsAgent && userDID) { 91 + registerTeleportCommand(pdsAgent, userDID, setActiveTeleportUri); 92 + } 93 + }, [pdsAgent, userDID, setActiveTeleportUri]); 94 + 76 95 const authors = useMemo(() => { 77 96 if (!chat) return null; 78 97 return chat.reduce((acc, msg) => { ··· 84 103 }, new Map<string, ChatMessageViewHydrated["chatProfile"]>()); 85 104 }, [chat]); 86 105 106 + useEffect(() => { 107 + if (pdsAgent && linfo?.author?.did && pdsAgent.did === linfo.author.did) { 108 + registerTeleportCommand(pdsAgent, pdsAgent.did, setActiveTeleportUri); 109 + } 110 + }, [pdsAgent, linfo?.author?.did, setActiveTeleportUri]); 111 + 87 112 const handleMentionSelect = (handle: string) => { 88 113 const beforeAt = message.slice(0, message.lastIndexOf("@")); 89 114 setMessage(`${beforeAt}@${handle} `); ··· 117 142 const colonIndex = text.lastIndexOf(":"); 118 143 if (colonIndex !== -1) { 119 144 const searchText = text.slice(colonIndex + 1).toLowerCase(); 120 - if (searchText.length > 0) { 145 + if (searchText.length >= 3) { 121 146 if (!emojiData) return; 122 147 const aliasMatches = Object.entries(emojiData.aliases) 123 148 .map(([alias, emojiId]) => { ··· 232 257 } 233 258 }; 234 259 235 - const submit = () => { 260 + const submit = async () => { 236 261 if (!message.trim()) return; 262 + if (graphemer.countGraphemes(message) > 300) { 263 + toast.show( 264 + "Message too long", 265 + "Please limit your message to 300 characters.", 266 + { 267 + variant: "error", 268 + duration: 3, 269 + }, 270 + ); 271 + return; 272 + } 273 + 274 + const messageText = message; 237 275 setMessage(""); 238 276 setReplyToMessage(null); 239 277 278 + if (messageText.startsWith("/")) { 279 + const result = await handleSlashCommand(messageText); 280 + if (result.handled) { 281 + if (result.error) { 282 + console.error("Slash command error:", result.error); 283 + SystemMessages.commandError(result.error); 284 + } 285 + return; 286 + } 287 + } 240 288 setSubmitting(true); 241 - createChatMessage({ 242 - text: message, 243 - reply: replyTo || undefined, 244 - }); 245 - setSubmitting(false); 289 + 290 + try { 291 + const result = await handleSlashCommand(messageText); 292 + 293 + if (result.handled) { 294 + if (result.error) { 295 + console.error("Slash command error:", result.error); 296 + } 297 + } else { 298 + createChatMessage({ 299 + text: messageText, 300 + reply: replyTo || undefined, 301 + }); 302 + } 303 + } catch (err) { 304 + console.error("Error submitting message:", err); 305 + } finally { 306 + setSubmitting(false); 307 + } 246 308 247 - // if we press "send" button, we want the same action as pressing "Enter" 248 - // if we're already focused no need to do extra work 249 309 if (textAreaRef.current && !textAreaRef.current.isFocused()) { 250 310 textAreaRef.current.focus(); 251 311 requestAnimationFrame(() => { ··· 268 328 layout.flex.alignCenter, 269 329 layout.flex.spaceBetween, 270 330 pl[2], 271 - pr[6], 272 - mr[6], 331 + pr[1], 273 332 mb[2], 274 333 py[1], 275 - bg.gray[800], 276 - { borderRadius: 16 }, 334 + r["2xl"], 335 + zt.bg.card, 277 336 ]} 278 337 > 279 - <RenderChatMessage 280 - item={replyTo} 281 - showReply={false} 282 - userCache={authors || new Map()} 283 - /> 284 - <Pressable onPress={() => setReplyToMessage(null)}> 285 - <View 286 - style={[ 287 - layout.flex.row, 288 - layout.flex.alignCenter, 289 - layout.flex.justifyCenter, 290 - h[12], 291 - w[12], 292 - bg.gray[600], 293 - { borderRadius: 999 }, 294 - ]} 295 - > 296 - <X size={24} /> 297 - </View> 338 + <View style={{ flex: 1, minWidth: 0, marginRight: 8 }}> 339 + <RenderChatMessage 340 + item={replyTo} 341 + showReply={false} 342 + userCache={authors || new Map()} 343 + /> 344 + </View> 345 + <Pressable 346 + onPress={() => setReplyToMessage(null)} 347 + style={[ 348 + layout.flex.row, 349 + layout.flex.alignCenter, 350 + layout.flex.justifyCenter, 351 + h[8], 352 + w[8], 353 + zt.bg.muted, 354 + zt.border.border, 355 + borders.width.thin, 356 + { borderRadius: 999 }, 357 + ]} 358 + > 359 + <X size={24} style={[zt.text.primaryForeground]} /> 298 360 </Pressable> 299 361 </View> 300 362 )} 301 363 {showEmojiSelector && ( 302 - <> 364 + <View 365 + style={{ 366 + position: "absolute", 367 + top: 0, 368 + left: 0, 369 + right: 0, 370 + bottom: 0, 371 + zIndex: 200, 372 + }} 373 + pointerEvents="box-none" 374 + > 303 375 {/* Overlay to catch outside clicks */} 304 376 <Pressable 305 377 style={{ ··· 308 380 left: 0, 309 381 right: 0, 310 382 bottom: 0, 311 - zIndex: 200, 312 383 }} 313 384 onPress={() => setShowEmojiSelector(false)} 314 385 /> ··· 319 390 left: 0, 320 391 zIndex: 2001, 321 392 }} 393 + pointerEvents="auto" 322 394 > 323 395 <Picker 324 396 data={emojiData} 325 397 onEmojiSelect={(e) => setMessage(message + e.native)} 326 398 /> 327 399 </View> 328 - </> 400 + </View> 329 401 )} 330 402 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 331 403 <Textarea ··· 400 472 } 401 473 } 402 474 }} 403 - style={[chatBoxStyle]} 475 + style={[ 476 + chatBoxStyle, 477 + isOverLimit && { 478 + borderColor: "#ef4444", 479 + borderWidth: 2, 480 + outline: "none", 481 + }, 482 + ]} 404 483 // "submit" won't blur on enter 405 484 submitBehavior="submit" 406 485 placeholder="Type a message..." ··· 440 519 { justifyContent: "flex-end" }, 441 520 ]} 442 521 > 522 + {env.NODE_ENV === "development" && ( 523 + <Button 524 + variant="secondary" 525 + style={{ borderRadius: 16 }} 526 + width="min" 527 + onPress={() => { 528 + StreamNotifications.teleport({ 529 + targetHandle: "test.bsky.social", 530 + targetDID: "did:plc:test", 531 + countdown: 30, 532 + canCancel: true, 533 + onDismiss: (reason) => 534 + console.log("teleport dismissed:", reason), 535 + }); 536 + }} 537 + > 538 + Test Notification 539 + </Button> 540 + )} 443 541 <Button 444 542 variant="secondary" 445 543 style={{ borderRadius: 16, maxWidth: 44, aspectRatio: 1 }} 446 544 aria-label="Insert Mention" 447 545 onPress={() => { 448 - // if the last character is not @, add it 449 546 !message.endsWith("@") && setMessage(message + "@"); 450 - // get all the text after the last @ 451 547 const atIndex = message.lastIndexOf("@"); 452 548 const searchText = message.slice(atIndex + 1).toLowerCase(); 453 549 updateSuggestions(searchText); 454 550 setShowSuggestions(true); 455 - // focus the textarea 456 551 textAreaRef.current?.focus(); 457 552 }} 458 553 >
+1 -1
js/components/src/components/chat/chat-message.tsx
··· 76 76 } 77 77 }; 78 78 79 - const RichTextMessage = ({ 79 + export const RichTextMessage = ({ 80 80 text, 81 81 facets, 82 82 }: {
+79 -5
js/components/src/components/chat/chat.tsx
··· 1 - import { Ellipsis, Reply } from "lucide-react-native"; 1 + import { ChevronDown, Ellipsis, Reply } from "lucide-react-native"; 2 2 import { ComponentProps, memo, useEffect, useRef, useState } from "react"; 3 3 import { Keyboard, Platform, Pressable } from "react-native"; 4 4 import { FlatList } from "react-native-gesture-handler"; ··· 8 8 import Reanimated, { 9 9 SharedValue, 10 10 useAnimatedStyle, 11 + useSharedValue, 12 + withTiming, 11 13 } from "react-native-reanimated"; 12 14 import { ChatMessageViewHydrated } from "streamplace"; 13 15 import { 16 + getSystemMessageType, 14 17 SystemMessage, 18 + SystemMessageType, 15 19 Text, 16 20 useChat, 17 21 usePlayerStore, 18 22 useSetReplyToMessage, 23 + useTheme, 19 24 View, 20 25 } from "../../"; 21 - import { bg, flex, px, py } from "../../lib/theme/atoms"; 26 + import { bg, flex, layout, mr, px, py } from "../../lib/theme/atoms"; 22 27 import { RenderChatMessage } from "./chat-message"; 23 28 import { ModView } from "./mod-view"; 24 29 ··· 168 173 if (item.author.did === "did:sys:system") { 169 174 return ( 170 175 <SystemMessage 176 + variant={getSystemMessageType(item) || SystemMessageType.notification} 171 177 timestamp={new Date(item.record.createdAt)} 172 178 title={item.record.text} 179 + facets={item.record.facets} 173 180 /> 174 181 ); 175 182 } ··· 243 250 shownMessages?: number; 244 251 style?: ComponentProps<typeof View>["style"]; 245 252 }) { 253 + const { theme } = useTheme(); 246 254 const chat = useChat(); 247 255 const [isScrolledUp, setIsScrolledUp] = useState(false); 256 + const flatListRef = useRef<FlatList>(null); 257 + 258 + // Animation for scroll-to-bottom button 259 + const buttonOpacity = useSharedValue(0); 260 + const buttonTranslateY = useSharedValue(20); 261 + 262 + useEffect(() => { 263 + buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 + buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 50, { 265 + duration: 200, 266 + }); 267 + }, [isScrolledUp]); 268 + 269 + const buttonAnimatedStyle = useAnimatedStyle(() => ({ 270 + opacity: buttonOpacity.value, 271 + transform: [{ translateY: buttonTranslateY.value }], 272 + })); 273 + 274 + const scrollToBottom = () => { 275 + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); 276 + }; 248 277 249 278 const handleScroll = (event: any) => { 250 279 const { contentOffset } = event.nativeEvent; ··· 270 299 271 300 return ( 272 301 <View 273 - style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }].concat( 274 - propsStyle || [], 275 - )} 302 + style={[ 303 + flex.shrink[1], 304 + { 305 + minWidth: 0, 306 + maxWidth: "100%", 307 + position: "relative", 308 + overflow: "visible", 309 + }, 310 + ].concat(propsStyle || [])} 276 311 > 277 312 <FlatList 313 + ref={flatListRef} 278 314 style={[ 279 315 flex.grow[1], 280 316 flex.shrink[1], ··· 292 328 scrollEventThrottle={16} 293 329 nestedScrollEnabled={true} 294 330 /> 331 + <Reanimated.View 332 + style={[ 333 + { 334 + position: "absolute", 335 + bottom: 16, 336 + left: 0, 337 + right: 0, 338 + alignItems: "center", 339 + pointerEvents: isScrolledUp ? "box-none" : "none", 340 + }, 341 + buttonAnimatedStyle, 342 + ]} 343 + > 344 + <Pressable 345 + onPress={scrollToBottom} 346 + style={[ 347 + { 348 + pointerEvents: isScrolledUp ? "auto" : "none", 349 + backgroundColor: theme.colors.primary, 350 + opacity: 0.9, 351 + borderRadius: 20, 352 + shadowColor: "#000", 353 + shadowOffset: { width: 0, height: 2 }, 354 + shadowOpacity: 0.25, 355 + shadowRadius: 4, 356 + elevation: 5, 357 + }, 358 + layout.flex.row, 359 + layout.flex.center, 360 + px[2], 361 + py[1], 362 + { gap: 6 }, 363 + ]} 364 + > 365 + <ChevronDown size={24} style={{ marginTop: 2 }} color="white" /> 366 + <Text style={[mr[1]]}>Scroll to bottom</Text> 367 + </Pressable> 368 + </Reanimated.View> 295 369 <ModView /> 296 370 </View> 297 371 );
+27 -25
js/components/src/components/chat/emoji-suggestions.tsx
··· 1 1 import { Pressable } from "react-native"; 2 + import { ScrollView } from "react-native-gesture-handler"; 2 3 import { Code, Text, View } from "../.."; 3 4 import { bg, layout, left, right, zIndex } from "../../lib/theme/atoms"; 4 5 ··· 61 62 borderRadius: 8, 62 63 boxShadow: "0px 4px 6px rgba(0, 0, 0, 0.1)", 63 64 maxHeight: 200, 64 - overflow: "auto", 65 65 }, 66 66 ]} 67 67 > 68 - {emojis.map((emoji, index) => ( 69 - <Pressable 70 - key={emoji.id} 71 - onPress={() => onSelect(emoji)} 72 - style={[ 73 - { 74 - padding: 8, 75 - flexDirection: "row", 76 - alignItems: "center", 77 - backgroundColor: 78 - index === highlightedIndex 79 - ? "rgba(255, 255, 255, 0.1)" 80 - : "transparent", 81 - }, 82 - ]} 83 - > 84 - <Text style={{ fontSize: 16, marginRight: 8 }}> 85 - {emoji.skins[0]?.native} 86 - </Text> 87 - <Text style={{ color: "white", fontSize: 14 }}> 88 - <Code style={[bg.gray[950]]}>:{emoji.id}:</Code> {emoji.name} 89 - </Text> 90 - </Pressable> 91 - ))} 68 + <ScrollView> 69 + {emojis.map((emoji, index) => ( 70 + <Pressable 71 + key={emoji.id} 72 + onPress={() => onSelect(emoji)} 73 + style={[ 74 + { 75 + padding: 8, 76 + flexDirection: "row", 77 + alignItems: "center", 78 + backgroundColor: 79 + index === highlightedIndex 80 + ? "rgba(255, 255, 255, 0.1)" 81 + : "transparent", 82 + }, 83 + ]} 84 + > 85 + <Text style={{ fontSize: 16, marginRight: 8 }}> 86 + {emoji.skins[0]?.native} 87 + </Text> 88 + <Text style={{ color: "white", fontSize: 14 }}> 89 + <Code style={[bg.gray[950]]}>:{emoji.id}:</Code> {emoji.name} 90 + </Text> 91 + </Pressable> 92 + ))} 93 + </ScrollView> 92 94 </View> 93 95 ); 94 96 }
+36 -33
js/components/src/components/chat/mention-suggestions.tsx
··· 1 - import { Pressable } from "react-native"; 1 + import { Pressable, ScrollView } from "react-native"; 2 2 import { ChatMessageViewHydrated } from "streamplace"; 3 3 import { Text, View } from "../.."; 4 - import { bg, layout, left, right, zIndex } from "../../lib/theme/atoms"; 4 + import { bg, layout, left, right } from "../../lib/theme/atoms"; 5 5 6 6 interface MentionSuggestionsProps { 7 7 authors: Map<string, ChatMessageViewHydrated["chatProfile"]>; ··· 27 27 28 28 left[0], 29 29 right[0], 30 - zIndex[50], 31 30 { 32 31 bottom: "100%", 33 32 borderRadius: 8, 34 33 boxShadow: "0px 4px 6px rgba(0, 0, 0, 0.1)", 34 + maxHeight: 200, 35 + zIndex: 999999, 35 36 }, 36 37 ]} 37 38 > 38 - {authorHandles.map((handle, index) => { 39 - let profile = authors.get(handle); 40 - return ( 41 - <Pressable 42 - key={handle} 43 - onPress={() => onSelect(handle)} 44 - style={[ 45 - { 46 - padding: 8, 47 - flexDirection: "row", 48 - alignItems: "center", 49 - backgroundColor: 50 - index === highlightedIndex 51 - ? "rgba(0, 0, 0, 0.1)" 52 - : "rgba(0, 0, 0, 0.5)", 53 - }, 54 - ]} 55 - > 56 - <Text 57 - style={{ 58 - color: profile?.color 59 - ? `rgb(${profile.color.red}, ${profile.color.green}, ${profile.color.blue})` 60 - : "black", 61 - fontWeight: "bold", 62 - }} 39 + <ScrollView> 40 + {authorHandles.map((handle, index) => { 41 + let profile = authors.get(handle); 42 + return ( 43 + <Pressable 44 + key={handle} 45 + onPress={() => onSelect(handle)} 46 + style={[ 47 + { 48 + padding: 8, 49 + flexDirection: "row", 50 + alignItems: "center", 51 + backgroundColor: 52 + index === highlightedIndex 53 + ? "rgba(0, 0, 0, 0.1)" 54 + : "rgba(0, 0, 0, 0.5)", 55 + }, 56 + ]} 63 57 > 64 - @{handle} 65 - </Text> 66 - </Pressable> 67 - ); 68 - })} 58 + <Text 59 + style={{ 60 + color: profile?.color 61 + ? `rgb(${profile.color.red}, ${profile.color.green}, ${profile.color.blue})` 62 + : "black", 63 + fontWeight: "bold", 64 + }} 65 + > 66 + @{handle} 67 + </Text> 68 + </Pressable> 69 + ); 70 + })} 71 + </ScrollView> 69 72 </View> 70 73 ); 71 74 }
+2 -222
js/components/src/components/chat/mod-view.tsx
··· 5 5 import { 6 6 useCreateBlockRecord, 7 7 useCreateHideChatRecord, 8 - useUpdateLivestreamRecord, 9 8 } from "../../streamplace-store/block"; 10 9 import { 11 10 ModerationPermissions, ··· 17 16 import { ChatMessageViewHydrated } from "streamplace"; 18 17 import { 19 18 useDeleteChatMessage, 20 - useLivestream, 21 19 useLivestreamStore, 22 20 } from "../../livestream-store"; 23 21 import { useStreamplaceStore } from "../../streamplace-store"; 24 22 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; 25 23 import { 26 24 atoms, 27 - Button, 28 - DialogFooter, 29 25 DropdownMenu, 30 26 DropdownMenuGroup, 31 27 DropdownMenuItem, 32 28 DropdownMenuTrigger, 33 29 layout, 34 - ResponsiveDialog, 35 30 ResponsiveDropdownMenuContent, 36 31 Text, 37 - Textarea, 38 32 useToast, 39 33 View, 40 34 } from "../ui"; ··· 61 55 let [messageRemoved, setMessageRemoved] = useState(false); 62 56 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 63 57 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 64 - let { updateLivestream, isLoading: isUpdateTitleLoading } = 65 - useUpdateLivestreamRecord(); 66 - const livestream = useLivestream(); 67 - const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 68 58 69 59 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 70 60 const setReportSubject = usePlayerStore((x) => x.setReportSubject); ··· 95 85 } 96 86 }, [message]); 97 87 98 - // Early return AFTER all hooks have been called 99 - if (!agent?.did) { 100 - return ( 101 - <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 102 - <Text>Log in to submit mod actions</Text> 103 - </View> 104 - ); 105 - } 106 - 107 - // Can show moderation actions if user can hide, ban, or manage livestream 108 - const canModerate = 109 - modPermissions.canHide || 110 - modPermissions.canBan || 111 - modPermissions.canManageLivestream; 112 - 113 88 // Check if any moderation actions are actually available for this message 114 89 // This must match the individual action checks inside the DropdownMenuGroup 115 90 const hasAvailableActions = !!( ··· 150 125 createHideChat={createHideChat} 151 126 createBlock={createBlock} 152 127 toast={toast} 153 - setShowUpdateTitleDialog={setShowUpdateTitleDialog} 154 - isUpdateTitleLoading={isUpdateTitleLoading} 155 - livestream={livestream} 156 128 setReportModalOpen={setReportModalOpen} 157 129 setReportSubject={setReportSubject} 158 130 deleteChatMessage={deleteChatMessage} ··· 160 132 )} 161 133 </ResponsiveDropdownMenuContent> 162 134 </DropdownMenu> 163 - 164 - {/* Update Stream Title Dialog - rendered outside dropdown */} 165 - {showUpdateTitleDialog && ( 166 - <UpdateStreamTitleDialog 167 - livestream={livestream} 168 - streamerDID={streamerDID} 169 - updateLivestream={updateLivestream} 170 - isLoading={isUpdateTitleLoading} 171 - onClose={() => setShowUpdateTitleDialog(false)} 172 - /> 173 - )} 174 135 </> 175 136 ); 176 137 }); ··· 188 149 createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 189 150 createBlock: (did: string, streamerDID?: string) => Promise<any>; 190 151 toast: ReturnType<typeof useToast>; 191 - setShowUpdateTitleDialog: (show: boolean) => void; 192 - isUpdateTitleLoading: boolean; 193 - livestream: any; 194 152 setReportModalOpen: (open: boolean) => void; 195 153 setReportSubject: (subject: any) => void; 196 154 deleteChatMessage: (uri: string) => Promise<any>; ··· 209 167 createHideChat, 210 168 createBlock, 211 169 toast, 212 - setShowUpdateTitleDialog, 213 - isUpdateTitleLoading, 214 - livestream, 215 170 setReportModalOpen, 216 171 setReportSubject, 217 172 deleteChatMessage, ··· 305 260 </DropdownMenuGroup> 306 261 )} 307 262 308 - {modPermissions.canManageLivestream && ( 309 - <DropdownMenuGroup key="stream-actions" title={`Stream actions`}> 310 - <DropdownMenuItem 311 - onPress={() => { 312 - setShowUpdateTitleDialog(true); 313 - }} 314 - disabled={isUpdateTitleLoading || !livestream} 315 - > 316 - <Text 317 - color={isUpdateTitleLoading || !livestream ? "muted" : "primary"} 318 - > 319 - {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 320 - </Text> 321 - </DropdownMenuItem> 322 - </DropdownMenuGroup> 323 - )} 324 - 325 263 <DropdownMenuGroup key="user-actions" title={`User actions`}> 326 264 <DropdownMenuItem 327 265 onPress={() => { ··· 332 270 > 333 271 <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 334 272 </DropdownMenuItem> 335 - {message.author.did === agent?.did && ( 273 + {agent?.did && message.author.did === agent.did && ( 336 274 <DeleteButton 337 275 message={message} 338 276 deleteChatMessage={deleteChatMessage} 339 277 onOpenChange={onOpenChange} 340 278 /> 341 279 )} 342 - {message.author.did !== agent?.did && ( 280 + {(!agent?.did || message.author.did !== agent.did) && ( 343 281 <ReportButton 344 282 message={message} 345 283 setReportModalOpen={setReportModalOpen} ··· 433 371 </DropdownMenuItem> 434 372 ); 435 373 } 436 - 437 - interface UpdateStreamTitleDialogProps { 438 - livestream: any; 439 - streamerDID?: string; 440 - updateLivestream: ( 441 - livestreamUri: string, 442 - title: string, 443 - streamerDID?: string, 444 - ) => Promise<any>; 445 - isLoading: boolean; 446 - onClose: () => void; 447 - } 448 - 449 - function UpdateStreamTitleDialog({ 450 - livestream, 451 - streamerDID, 452 - updateLivestream, 453 - isLoading, 454 - onClose, 455 - }: UpdateStreamTitleDialogProps) { 456 - const [title, setTitle] = useState(livestream?.record?.title || ""); 457 - const [error, setError] = useState<string | null>(null); 458 - const toast = useToast(); 459 - 460 - useEffect(() => { 461 - if (livestream?.record?.title) { 462 - setTitle(livestream.record.title); 463 - } 464 - }, [livestream?.record?.title]); 465 - 466 - const handleUpdate = async () => { 467 - setError(null); 468 - 469 - if (!title.trim()) { 470 - setError("Please enter a stream title"); 471 - return; 472 - } 473 - 474 - if (!livestream?.uri) { 475 - setError("No livestream found"); 476 - return; 477 - } 478 - 479 - try { 480 - await updateLivestream(livestream.uri, title.trim(), streamerDID); 481 - toast.show( 482 - "Stream title updated", 483 - "The stream title has been successfully updated.", 484 - { duration: 3 }, 485 - ); 486 - onClose(); 487 - } catch (err) { 488 - setError( 489 - err instanceof Error ? err.message : "Failed to update stream title", 490 - ); 491 - } 492 - }; 493 - 494 - return ( 495 - <ResponsiveDialog 496 - open={true} 497 - onOpenChange={(open) => { 498 - if (!open) { 499 - onClose(); 500 - setError(null); 501 - setTitle(livestream?.record?.title || ""); 502 - } 503 - }} 504 - title="Update Stream Title" 505 - description="Update the title of the livestream." 506 - size="md" 507 - dismissible={false} 508 - > 509 - <View style={[{ padding: 16, paddingBottom: 0 }]}> 510 - <View style={[{ marginBottom: 16 }]}> 511 - <Text 512 - style={[ 513 - { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 514 - ]} 515 - > 516 - Stream Title 517 - </Text> 518 - <Textarea 519 - value={title} 520 - onChangeText={(text) => { 521 - setTitle(text); 522 - setError(null); 523 - }} 524 - placeholder="Enter stream title..." 525 - maxLength={140} 526 - multiline 527 - style={[ 528 - { 529 - padding: 12, 530 - borderRadius: 8, 531 - backgroundColor: atoms.colors.neutral[800], 532 - color: atoms.colors.white, 533 - borderWidth: 1, 534 - borderColor: atoms.colors.neutral[600], 535 - minHeight: 100, 536 - fontSize: 16, 537 - }, 538 - ]} 539 - /> 540 - <Text 541 - style={[ 542 - { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 543 - ]} 544 - > 545 - {title.length}/140 characters 546 - </Text> 547 - </View> 548 - 549 - {error && ( 550 - <View 551 - style={[ 552 - { 553 - backgroundColor: atoms.colors.red[900], 554 - padding: 12, 555 - borderRadius: 8, 556 - borderWidth: 1, 557 - borderColor: atoms.colors.red[700], 558 - marginBottom: 16, 559 - }, 560 - ]} 561 - > 562 - <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 563 - {error} 564 - </Text> 565 - </View> 566 - )} 567 - </View> 568 - 569 - <DialogFooter> 570 - <Button 571 - width="min" 572 - variant="secondary" 573 - onPress={() => { 574 - onClose(); 575 - setError(null); 576 - setTitle(livestream?.record?.title || ""); 577 - }} 578 - disabled={isLoading} 579 - > 580 - <Text>Cancel</Text> 581 - </Button> 582 - <Button 583 - variant="primary" 584 - width="min" 585 - onPress={handleUpdate} 586 - disabled={isLoading || !title.trim()} 587 - > 588 - <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 589 - </Button> 590 - </DialogFooter> 591 - </ResponsiveDialog> 592 - ); 593 - }
+14 -5
js/components/src/components/chat/system-message.tsx
··· 1 1 import { View } from "react-native"; 2 - import { flex, gap, layout, ml, pb, pl, px, w } from "../../ui"; 3 - import { atoms } from "../ui"; 2 + import { Main } from "streamplace/src/lexicons/types/place/stream/richtext/facet"; 3 + import { SystemMessageType } from "../../lib/system-messages"; 4 + import { colors, flex, gap, layout, ml, pb, pl, px, w } from "../../ui"; 4 5 import { Code, Text } from "../ui/text"; 6 + import { RichTextMessage } from "./chat-message"; 5 7 6 8 interface SystemMessageProps { 9 + variant: SystemMessageType; 7 10 title: string; 8 11 timestamp: Date; 12 + facets?: Main[]; 9 13 } 10 14 11 - export function SystemMessage({ title, timestamp }: SystemMessageProps) { 15 + export function SystemMessage({ 16 + variant, 17 + title, 18 + timestamp, 19 + facets, 20 + }: SystemMessageProps) { 12 21 return ( 13 22 <View style={[w.percent[100], px[2], pb[2]]}> 14 23 <Code color="muted" tracking="widest" style={[pl[12], ml[1]]}> ··· 18 27 <Text 19 28 style={{ 20 29 fontVariant: ["tabular-nums"], 21 - color: atoms.colors.gray[300], 30 + color: colors.gray[400], 22 31 }} 23 32 > 24 33 {timestamp.toLocaleTimeString([], { ··· 28 37 })} 29 38 </Text> 30 39 <Text weight="bold" color="default" style={[flex.shrink[1]]}> 31 - {title} 40 + <RichTextMessage facets={facets} text={title} /> 32 41 </Text> 33 42 </View> 34 43 </View>
+169
js/components/src/components/chat/update-stream-title-dialog.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { 3 + atoms, 4 + Button, 5 + DialogFooter, 6 + ResponsiveDialog, 7 + Text, 8 + Textarea, 9 + useToast, 10 + View, 11 + } from "../ui"; 12 + 13 + export interface UpdateStreamTitleDialogProps { 14 + livestream: any; 15 + streamerDID?: string; 16 + updateLivestream: ( 17 + livestreamUri: string, 18 + title: string, 19 + streamerDID?: string, 20 + ) => Promise<any>; 21 + isLoading: boolean; 22 + onClose: () => void; 23 + } 24 + 25 + export function UpdateStreamTitleDialog({ 26 + livestream, 27 + streamerDID, 28 + updateLivestream, 29 + isLoading, 30 + onClose, 31 + }: UpdateStreamTitleDialogProps) { 32 + const [title, setTitle] = useState(livestream?.record?.title || ""); 33 + const [error, setError] = useState<string | null>(null); 34 + const toast = useToast(); 35 + 36 + useEffect(() => { 37 + if (livestream?.record?.title) { 38 + setTitle(livestream.record.title); 39 + } 40 + }, [livestream?.record?.title]); 41 + 42 + const handleUpdate = async () => { 43 + setError(null); 44 + 45 + if (!title.trim()) { 46 + setError("Please enter a stream title"); 47 + return; 48 + } 49 + 50 + if (!livestream?.uri) { 51 + setError("No livestream found"); 52 + return; 53 + } 54 + 55 + try { 56 + await updateLivestream(livestream.uri, title.trim(), streamerDID); 57 + toast.show( 58 + "Stream title updated", 59 + "The stream title has been successfully updated.", 60 + { duration: 3 }, 61 + ); 62 + onClose(); 63 + } catch (err) { 64 + setError( 65 + err instanceof Error ? err.message : "Failed to update stream title", 66 + ); 67 + } 68 + }; 69 + 70 + return ( 71 + <ResponsiveDialog 72 + open={true} 73 + onOpenChange={(open) => { 74 + if (!open) { 75 + onClose(); 76 + setError(null); 77 + setTitle(livestream?.record?.title || ""); 78 + } 79 + }} 80 + title="Update Stream Title" 81 + description="Update the title of the livestream." 82 + size="md" 83 + dismissible={false} 84 + > 85 + <View style={[{ padding: 16, paddingBottom: 0 }]}> 86 + <View style={[{ marginBottom: 16 }]}> 87 + <Text 88 + style={[ 89 + { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 90 + ]} 91 + > 92 + Stream Title 93 + </Text> 94 + <Textarea 95 + value={title} 96 + onChangeText={(text) => { 97 + setTitle(text); 98 + setError(null); 99 + }} 100 + placeholder="Enter stream title..." 101 + maxLength={140} 102 + multiline 103 + style={[ 104 + { 105 + padding: 12, 106 + borderRadius: 8, 107 + backgroundColor: atoms.colors.neutral[800], 108 + color: atoms.colors.white, 109 + borderWidth: 1, 110 + borderColor: atoms.colors.neutral[600], 111 + minHeight: 100, 112 + fontSize: 16, 113 + }, 114 + ]} 115 + /> 116 + <Text 117 + style={[ 118 + { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 119 + ]} 120 + > 121 + {title.length}/140 characters 122 + </Text> 123 + </View> 124 + 125 + {error && ( 126 + <View 127 + style={[ 128 + { 129 + backgroundColor: atoms.colors.red[900], 130 + padding: 12, 131 + borderRadius: 8, 132 + borderWidth: 1, 133 + borderColor: atoms.colors.red[700], 134 + marginBottom: 16, 135 + }, 136 + ]} 137 + > 138 + <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 139 + {error} 140 + </Text> 141 + </View> 142 + )} 143 + </View> 144 + 145 + <DialogFooter> 146 + <Button 147 + width="min" 148 + variant="secondary" 149 + onPress={() => { 150 + onClose(); 151 + setError(null); 152 + setTitle(livestream?.record?.title || ""); 153 + }} 154 + disabled={isLoading} 155 + > 156 + <Text>Cancel</Text> 157 + </Button> 158 + <Button 159 + variant="primary" 160 + width="min" 161 + onPress={handleUpdate} 162 + disabled={isLoading || !title.trim()} 163 + > 164 + <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 165 + </Button> 166 + </DialogFooter> 167 + </ResponsiveDialog> 168 + ); 169 + }
+37 -10
js/components/src/components/content-metadata/content-metadata-form.tsx
··· 1 1 import { forwardRef, useCallback, useEffect, useState } from "react"; 2 - import { ScrollView, View } from "react-native"; 2 + import { Linking, Pressable, ScrollView, View } from "react-native"; 3 3 import { 4 4 CONTENT_WARNINGS, 5 5 LICENSE_OPTIONS, 6 6 } from "../../lib/metadata-constants"; 7 7 8 + import { ExternalLink } from "lucide-react-native"; 8 9 import { 9 10 PlaceStreamMetadataConfiguration, 10 11 PlaceStreamMetadataContentRights, ··· 21 22 } from "../../streamplace-store/streamplace-store"; 22 23 import { usePDSAgent } from "../../streamplace-store/xrpc"; 23 24 import * as zero from "../../ui"; 25 + import { Admonition } from "../ui"; 24 26 import { Button } from "../ui/button"; 25 27 import { Checkbox } from "../ui/checkbox"; 26 28 import { Input } from "../ui/input"; ··· 41 43 style?: any; 42 44 } 43 45 44 - // ButtonSelector component (same as in livestream-panel) 45 46 const ButtonSelector = ({ 46 47 values, 47 48 selectedValue, ··· 55 56 disabledValues?: string[]; 56 57 style?: any[]; 57 58 }) => ( 58 - <View style={[layout.flex.row, gap.all[1], ...style]}> 59 + <View style={[layout.flex.row, gap.all[1], layout.flex.wrap.wrap, ...style]}> 59 60 {values.map(({ label, value }) => ( 60 61 <Button 61 62 key={value} 62 63 variant={selectedValue === value ? "primary" : "secondary"} 63 64 size="pill" 65 + width="min" 64 66 disabled={disabledValues.includes(value)} 65 67 onPress={() => setSelectedValue(value)} 66 68 style={[ ··· 71 73 ]} 72 74 > 73 75 <Text 74 - style={[ 75 - selectedValue === value ? text.white : text.gray[300], 76 - { fontSize: 14, fontWeight: "600" }, 77 - ]} 76 + size="sm" 77 + style={[selectedValue === value ? text.white : text.gray[300]]} 78 78 > 79 79 {label} 80 80 </Text> ··· 93 93 const getContentMetadata = useGetContentMetadata(); 94 94 const saveContentMetadata = useSaveContentMetadata(); 95 95 const toast = useToast(); 96 + const th = zero.useTheme(); 96 97 97 98 // Local state for metadata 98 99 const [contentWarnings, setContentWarnings] = useState<string[]>([]); ··· 364 365 ]} 365 366 selectedValue={activeSection} 366 367 setSelectedValue={setActiveSection} 367 - style={[{ marginVertical: -2, flexDirection: "column" }]} 368 + style={[{ marginVertical: -2 }]} 368 369 /> 369 370 </View> 370 371 ··· 379 380 gap.all[2], 380 381 ]} 381 382 > 382 - <Text>Content Warnings</Text> 383 - <Text muted>(optional)</Text> 383 + <Text size="lg">Content Warnings</Text> 384 384 </View> 385 385 <View style={[gap.all[2], w.percent[100]]}> 386 386 {CONTENT_WARNINGS.map((warning) => ( ··· 398 398 </View> 399 399 ))} 400 400 </View> 401 + <Admonition variant="info" size="sm"> 402 + <Text size="sm"> 403 + You are required to disclose if your content is not suitable 404 + for certain viewers. 405 + </Text> 406 + </Admonition> 407 + <Admonition variant="warning" size="sm"> 408 + <Text size="sm"> 409 + Your node may prohibit some of this content. Read the 410 + community guidelines to make sure.{" "} 411 + <Pressable 412 + onPress={() => 413 + Linking.openURL( 414 + "https://blog.stream.place/3mcqwibo4ks2w", 415 + ) 416 + } 417 + > 418 + <Text size="sm" color={zero.colors.blue[400]}> 419 + Learn more{" "} 420 + <ExternalLink 421 + size="14" 422 + style={{ marginVertical: -2 }} 423 + /> 424 + </Text> 425 + </Pressable> 426 + </Text> 427 + </Admonition> 401 428 </View> 402 429 )} 403 430
+1 -14
js/components/src/components/dashboard/header.tsx
··· 1 - import { AlertCircle, Car, Radio, Users } from "lucide-react-native"; 1 + import { AlertCircle, Radio } from "lucide-react-native"; 2 2 import { Pressable, Text, View } from "react-native"; 3 3 import * as zero from "../../ui"; 4 4 ··· 98 98 interface HeaderProps { 99 99 isLive: boolean; 100 100 streamTitle?: string; 101 - viewers?: number; 102 101 uptime?: string; 103 102 bitrate?: string; 104 103 timeBetweenSegments?: number; ··· 110 109 export default function Header({ 111 110 isLive, 112 111 streamTitle = "Live Stream", 113 - viewers = 0, 114 112 uptime = "00:00:00", 115 113 bitrate = "0 mbps", 116 114 timeBetweenSegments = 0, ··· 179 177 180 178 {/* Right side - Stream metrics */} 181 179 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[6]]}> 182 - {isLive && ( 183 - <> 184 - <MetricItem 185 - icon={Users} 186 - label="Viewers" 187 - value={viewers.toLocaleString()} 188 - /> 189 - <MetricItem icon={Car} label="Bitrate" value={bitrate} /> 190 - </> 191 - )} 192 - 193 180 {!isLive && ( 194 181 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 195 182 <Radio size={16} color="#6b7280" />
+2 -1
js/components/src/components/dashboard/information-widget.tsx
··· 12 12 import React, { useCallback, useEffect, useMemo, useState } from "react"; 13 13 import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; 14 14 import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; 15 + import { useAQState } from "../../hooks"; 15 16 import { 16 17 useLivestreamStore, 17 18 useSegment, ··· 38 39 const [bitrateHistory, setBitrateHistory] = useState<number[]>( 39 40 Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0), 40 41 ); 41 - const [showViewers, setShowViewers] = useState(false); 42 + const [showViewers, setShowViewers] = useAQState("showViewers", true); 42 43 const [componentWidth, setComponentWidth] = useState<number>(220); 43 44 const [componentHeight, setComponentHeight] = useState<number>(400); 44 45 const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
+2
js/components/src/components/dashboard/moderator-panel.tsx
··· 334 334 > 335 335 <DialogFooter> 336 336 <Button 337 + width="min" 337 338 variant="secondary" 338 339 onPress={() => setShowConfirm(false)} 339 340 disabled={isLoading} ··· 341 342 <Text>Cancel</Text> 342 343 </Button> 343 344 <Button 345 + width="min" 344 346 variant="destructive" 345 347 onPress={handleRemove} 346 348 disabled={isLoading}
+22 -1
js/components/src/components/mobile-player/player.tsx
··· 5 5 PlayerStatusTracker, 6 6 usePlayerStore, 7 7 } from "../../player-store"; 8 - import { useStreamplaceStore } from "../../streamplace-store"; 8 + import { 9 + useMuted, 10 + useSetMuted, 11 + useStreamplaceStore, 12 + } from "../../streamplace-store"; 9 13 import { Text, View } from "../ui"; 10 14 import { Fullscreen } from "./fullscreen"; 11 15 import { PlayerProps } from "./props"; ··· 28 32 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 29 33 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 30 34 const reportSubject = usePlayerStore((x) => x.reportSubject); 35 + 36 + const setMuted = useSetMuted(); 37 + const muted = useMuted(); 38 + 39 + // if we set muted, set it and restore after 40 + useEffect(() => { 41 + let wasMuted: null | boolean = null; 42 + setTimeout(() => { 43 + if (props.muted != undefined) { 44 + wasMuted = muted; 45 + setMuted(props.muted); 46 + } 47 + }, 200); 48 + return () => { 49 + wasMuted !== null && setMuted(wasMuted); 50 + }; 51 + }, [props.muted]); 31 52 32 53 useEffect(() => { 33 54 setReportingURL(props.reportingURL ?? null);
+2 -1
js/components/src/components/mobile-player/shared.tsx
··· 1 1 import { useMemo } from "react"; 2 - import { PlayerProtocol, useStreamplaceStore } from "../.."; 2 + import { PlayerProtocol } from "../../player-store/player-state"; 3 + import { useStreamplaceStore } from "../../streamplace-store"; 3 4 4 5 const protocolSuffixes = { 5 6 m3u8: PlayerProtocol.HLS,
+42 -8
js/components/src/components/mobile-player/ui/input.tsx
··· 9 9 setTitle: (title: string) => void; 10 10 ingestStarting: boolean; 11 11 toggleGoLive: () => void; 12 + isLive: boolean; 13 + toggleStopStream?: () => void; 12 14 }; 13 15 14 16 export function InputPanel({ ··· 16 18 setTitle, 17 19 ingestStarting, 18 20 toggleGoLive, 21 + isLive, 22 + toggleStopStream, 19 23 }: InputPanelProps) { 20 24 const { slideKeyboard } = useKeyboardSlide(); 21 25 return ( ··· 37 41 { padding: 10 }, 38 42 ]} 39 43 > 40 - <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 - <Input 42 - value={title} 43 - onChange={setTitle} 44 - placeholder="Enter stream title" 45 - onEndEditing={Keyboard.dismiss} 46 - /> 47 - </View> 44 + {!isLive && ( 45 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 46 + <Input 47 + value={title} 48 + onChange={setTitle} 49 + placeholder="Enter stream title" 50 + onEndEditing={Keyboard.dismiss} 51 + /> 52 + </View> 53 + )} 48 54 {ingestStarting ? ( 49 55 <Text>Starting your stream...</Text> 56 + ) : isLive ? ( 57 + <View style={[layout.flex.center]}> 58 + <Pressable 59 + onPress={toggleStopStream} 60 + style={[ 61 + px[4], 62 + py[2], 63 + layout.flex.row, 64 + layout.flex.center, 65 + gap.all[1], 66 + { 67 + backgroundColor: "rgba(64,64,64, 0.8)", 68 + borderRadius: 12, 69 + }, 70 + ]} 71 + > 72 + <View 73 + style={[ 74 + p[2], 75 + { 76 + backgroundColor: "rgba(256,0,0, 0.8)", 77 + borderRadius: 12, 78 + }, 79 + ]} 80 + /> 81 + <Text center>Stop Stream</Text> 82 + </Pressable> 83 + </View> 50 84 ) : ( 51 85 <View style={[layout.flex.center]}> 52 86 <Pressable
+2
js/components/src/components/mobile-player/ui/report-modal.tsx
··· 173 173 </View> 174 174 <DialogFooter> 175 175 <Button 176 + width="min" 176 177 variant="secondary" 177 178 onPress={handleCancel} 178 179 disabled={isSubmitting} ··· 180 181 <Text>Cancel</Text> 181 182 </Button> 182 183 <Button 184 + width="min" 183 185 variant="primary" 184 186 onPress={handleSubmit} 185 187 disabled={!selectedReason || isSubmitting}
+138 -2
js/components/src/components/mobile-player/ui/streamer-context-menu.tsx
··· 1 - export function StreamContextMenu() { 2 - return <></>; 1 + import { ChevronRight, Cog } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withDelay, 8 + withSequence, 9 + withTiming, 10 + } from "react-native-reanimated"; 11 + import { useLivestreamInfo, zero } from "../../.."; 12 + import { usePlayerStore } from "../../../player-store"; 13 + import { 14 + DropdownMenu, 15 + DropdownMenuCheckboxItem, 16 + DropdownMenuGroup, 17 + DropdownMenuItem, 18 + DropdownMenuTrigger, 19 + ResponsiveDropdownMenuContent, 20 + Text, 21 + useTheme, 22 + } from "../../ui"; 23 + 24 + export function StreamContextMenu({ 25 + dropdownPortalContainer, 26 + }: { 27 + dropdownPortalContainer?: string; 28 + }) { 29 + const th = useTheme(); 30 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 31 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 32 + const { toggleStopStream } = useLivestreamInfo(); 33 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 34 + const isLive = ingest !== null && ingest !== "new"; 35 + 36 + const [isOpen, setIsOpen] = useState(false); 37 + const [hasShownTooltip, setHasShownTooltip] = useState(false); 38 + 39 + const tooltipOpacity = useSharedValue(0); 40 + const tooltipTranslateX = useSharedValue(20); 41 + 42 + useEffect(() => { 43 + if (isLive && !hasShownTooltip) { 44 + tooltipOpacity.value = withDelay( 45 + 500, 46 + withSequence( 47 + withTiming(1, { duration: 300 }), 48 + withDelay(10000, withTiming(0, { duration: 300 })), 49 + ), 50 + ); 51 + tooltipTranslateX.value = withDelay( 52 + 500, 53 + withSequence( 54 + withTiming(0, { duration: 300 }), 55 + withDelay(10000, withTiming(20, { duration: 300 })), 56 + ), 57 + ); 58 + setHasShownTooltip(true); 59 + } 60 + }, [isLive, hasShownTooltip]); 61 + 62 + const iconRotate = useAnimatedStyle(() => { 63 + return { 64 + transform: [ 65 + { 66 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 67 + duration: 650, 68 + easing: Easing.out(Easing.ease), 69 + }), 70 + }, 71 + ], 72 + }; 73 + }); 74 + 75 + const tooltipStyle = useAnimatedStyle(() => { 76 + return { 77 + opacity: tooltipOpacity.value, 78 + transform: [{ translateX: tooltipTranslateX.value }], 79 + }; 80 + }); 81 + 82 + return ( 83 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 84 + <DropdownMenuTrigger> 85 + <Animated.View style={[iconRotate]}> 86 + <Cog color={th.theme.colors.foreground} /> 87 + </Animated.View> 88 + <Animated.View 89 + style={[ 90 + tooltipStyle, 91 + { 92 + position: "absolute", 93 + right: 30, 94 + top: 0, 95 + backgroundColor: "rgba(64,64,64,0.95)", 96 + borderRadius: 8, 97 + paddingHorizontal: 8, 98 + paddingRight: 12, 99 + paddingVertical: 4, 100 + flexDirection: "row", 101 + alignItems: "center", 102 + gap: 6, 103 + zIndex: 9999999, 104 + pointerEvents: "box-none", 105 + width: 120, 106 + }, 107 + ]} 108 + > 109 + <Text size="sm" color="white"> 110 + End stream here 111 + </Text> 112 + <ChevronRight color="white" size={16} style={[zero.mr[4]]} /> 113 + </Animated.View> 114 + </DropdownMenuTrigger> 115 + <ResponsiveDropdownMenuContent side="top" align="end"> 116 + {isLive && ( 117 + <DropdownMenuGroup title="Stream"> 118 + <DropdownMenuItem 119 + closeOnPress={true} 120 + onPress={() => { 121 + toggleStopStream(); 122 + }} 123 + > 124 + <Text color="destructive">Stop Stream</Text> 125 + </DropdownMenuItem> 126 + </DropdownMenuGroup> 127 + )} 128 + <DropdownMenuGroup title="Advanced"> 129 + <DropdownMenuCheckboxItem 130 + checked={debugInfo} 131 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 132 + > 133 + <Text>Show Debug Info</Text> 134 + </DropdownMenuCheckboxItem> 135 + </DropdownMenuGroup> 136 + </ResponsiveDropdownMenuContent> 137 + </DropdownMenu> 138 + ); 3 139 }
+220 -166
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 1 1 import { useRootContext } from "@rn-primitives/dropdown-menu"; 2 - import { Menu } from "lucide-react-native"; 2 + import { Cog } from "lucide-react-native"; 3 + import { useState } from "react"; 3 4 import { Image, Linking, Platform, Pressable, View } from "react-native"; 5 + import Animated, { 6 + Easing, 7 + useAnimatedStyle, 8 + withTiming, 9 + } from "react-native-reanimated"; 4 10 import { 5 11 ContentRights, 6 12 ContentWarnings, ··· 10 16 useLivestreamInfo, 11 17 zero, 12 18 } from "../../.."; 13 - import { colors } from "../../../lib/theme"; 14 19 import { useLivestreamStore } from "../../../livestream-store"; 15 20 import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 16 21 import { useGraphManager } from "../../../streamplace-store/graph"; ··· 21 26 DropdownMenuGroup, 22 27 DropdownMenuInfo, 23 28 DropdownMenuItem, 24 - DropdownMenuPortal, 25 29 DropdownMenuRadioGroup, 26 30 DropdownMenuRadioItem, 27 31 DropdownMenuSeparator, ··· 31 35 DropdownMenuTrigger, 32 36 ResponsiveDropdownMenuContent, 33 37 Text, 38 + useTheme, 34 39 } from "../../ui"; 35 40 36 41 export function ContextMenu({ 37 42 dropdownPortalContainer, 38 43 }: { 39 - dropdownPortalContainer?: any; 44 + dropdownPortalContainer?: string; 40 45 }) { 46 + const th = useTheme(); 41 47 const quality = usePlayerStore((x) => x.selectedRendition); 42 48 const setQuality = usePlayerStore((x) => x.setSelectedRendition); 43 49 const qualities = useLivestreamStore((x) => x.renditions); ··· 52 58 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 53 59 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 54 60 61 + const latestSegment = useLivestreamStore((x) => x.segment); 62 + // get highest height x width rendition for video 63 + const videoRendition = latestSegment?.video?.reduce((prev, current) => { 64 + const prevPixels = prev.width * prev.height; 65 + const currentPixels = current.width * current.height; 66 + return currentPixels > prevPixels ? current : prev; 67 + }, latestSegment?.video?.[0]); 68 + const highestLength = videoRendition 69 + ? videoRendition.height < videoRendition.width 70 + ? videoRendition.height 71 + : videoRendition?.width 72 + : 0; 73 + 74 + // ugh i hate this 75 + const frames = videoRendition?.framerate as 76 + | { num: number; den: number } 77 + | undefined; 78 + const fps = 79 + frames?.num && frames?.den 80 + ? Math.round((frames.num / frames.den) * 100) / 100 81 + : 0; 82 + 83 + const resolutionDisplay = highestLength 84 + ? `(${highestLength}p${fps > 0 ? fps : ""})` 85 + : "(Original Quality)"; 86 + 55 87 const { profile } = useLivestreamInfo(); 56 88 57 89 const avatars = useAvatars(profile?.did ? [profile?.did] : []); 58 90 const ls = useLivestreamStore((x) => x.livestream); 59 91 const segment = useLivestreamStore((x) => x.segment); 92 + 93 + const [isOpen, setIsOpen] = useState(false); 60 94 61 95 // Get content rights from the latest segment 62 96 const contentRights = segment?.contentRights; ··· 73 107 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 74 108 75 109 // dummy portal for mobile 76 - const Portal = isMobile ? View : DropdownMenuPortal; 110 + //const Portal: typeof DropdownMenuPortal = DropdownMenu; 77 111 78 112 const DropdownMenuContent = ResponsiveDropdownMenuContent; 79 113 114 + const iconRotate = useAnimatedStyle(() => { 115 + return { 116 + transform: [ 117 + { 118 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 119 + duration: 650, 120 + easing: Easing.out(Easing.ease), 121 + }), 122 + }, 123 + ], 124 + }; 125 + }); 126 + 127 + // rerender when dropdown portal container changes so we swap portals 'seamlessly' 80 128 return ( 81 - <DropdownMenu> 129 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 82 130 <DropdownMenuTrigger> 83 - <Menu color={colors.gray[200]} /> 131 + <Animated.View style={[iconRotate]}> 132 + <Cog color={th.theme.colors.foreground} /> 133 + </Animated.View> 84 134 </DropdownMenuTrigger> 85 - <Portal container={dropdownPortalContainer}> 86 - <DropdownMenuContent side="top" align="end"> 87 - {Platform.OS !== "web" && ( 88 - <DropdownMenuGroup title="Streamer"> 89 - <View 90 - style={[ 91 - zero.layout.flex.row, 92 - zero.layout.flex.center, 93 - zero.gap.all[3], 94 - { flex: 1, minWidth: 0 }, 95 - ]} 96 - > 97 - {profile?.did && avatars[profile?.did]?.avatar && ( 98 - <Image 99 - key="avatar" 100 - source={{ 101 - uri: avatars[profile?.did]?.avatar, 135 + <DropdownMenuContent 136 + side="top" 137 + align="end" 138 + portalHost={dropdownPortalContainer} 139 + > 140 + {Platform.OS !== "web" && ( 141 + <DropdownMenuGroup title="Streamer"> 142 + <View 143 + style={[ 144 + zero.layout.flex.row, 145 + zero.layout.flex.center, 146 + zero.gap.all[3], 147 + { flex: 1, minWidth: 0 }, 148 + ]} 149 + > 150 + {profile?.did && avatars[profile?.did]?.avatar && ( 151 + <Image 152 + key="avatar" 153 + source={{ 154 + uri: avatars[profile?.did]?.avatar, 155 + }} 156 + style={{ width: 42, height: 42, borderRadius: 999 }} 157 + resizeMode="cover" 158 + /> 159 + )} 160 + <View style={{ flex: 1, minWidth: 0 }}> 161 + <View 162 + style={[ 163 + zero.layout.flex.row, 164 + zero.layout.flex.alignCenter, 165 + zero.gap.all[2], 166 + ]} 167 + > 168 + <Pressable 169 + onPress={() => { 170 + if (profile?.handle) { 171 + const url = `https://bsky.app/profile/${formatHandle(profile)}`; 172 + Linking.openURL(url); 173 + } 102 174 }} 103 - style={{ width: 42, height: 42, borderRadius: 999 }} 104 - resizeMode="cover" 105 - /> 106 - )} 107 - <View style={{ flex: 1, minWidth: 0 }}> 108 - <View 109 - style={[ 110 - zero.layout.flex.row, 111 - zero.layout.flex.alignCenter, 112 - zero.gap.all[2], 113 - ]} 114 175 > 115 - <Pressable 116 - onPress={() => { 117 - if (profile?.handle) { 118 - const url = `https://bsky.app/profile/${formatHandle(profile)}`; 119 - Linking.openURL(url); 120 - } 121 - }} 122 - > 123 - <Text>{profile && formatHandleWithAt(profile)}</Text> 124 - </Pressable> 125 - {/*{did && profile && ( 176 + <Text>{profile && formatHandleWithAt(profile)}</Text> 177 + </Pressable> 178 + {/*{did && profile && ( 126 179 <FollowButton streamerDID={profile?.did} currentUserDID={did} /> 127 180 )}*/} 128 - </View> 129 - <Text 130 - color="muted" 131 - size="sm" 132 - numberOfLines={2} 133 - ellipsizeMode="tail" 134 - > 135 - {ls?.record.title || "Stream Title"} 136 - </Text> 137 181 </View> 138 - </View> 139 - <DropdownMenuSeparator /> 140 - <DropdownMenuItem 141 - disabled={graphManager.isLoading || !profile?.did} 142 - onPress={async () => { 143 - try { 144 - if (graphManager.isFollowing) { 145 - await graphManager.unfollow(); 146 - } else { 147 - await graphManager.follow(); 148 - } 149 - } catch (err) { 150 - console.error("Follow/unfollow error:", err); 151 - } 152 - }} 153 - > 154 182 <Text 155 - color={graphManager.isFollowing ? "destructive" : "default"} 183 + color="muted" 184 + size="sm" 185 + numberOfLines={2} 186 + ellipsizeMode="tail" 156 187 > 157 - {graphManager.isLoading 158 - ? "Loading..." 159 - : graphManager.isFollowing 160 - ? "Unfollow" 161 - : "Follow"} 188 + {ls?.record.title || "Stream Title"} 162 189 </Text> 163 - </DropdownMenuItem> 164 - <DropdownMenuSeparator /> 165 - <DropdownMenuItem 166 - onPress={() => { 167 - if (profile?.handle) { 168 - const url = `https://bsky.app/profile/${formatHandle(profile)}`; 169 - Linking.openURL(url); 190 + </View> 191 + </View> 192 + <DropdownMenuSeparator /> 193 + <DropdownMenuItem 194 + disabled={graphManager.isLoading || !profile?.did} 195 + onPress={async () => { 196 + try { 197 + if (graphManager.isFollowing) { 198 + await graphManager.unfollow(); 199 + } else { 200 + await graphManager.follow(); 170 201 } 171 - }} 202 + } catch (err) { 203 + console.error("Follow/unfollow error:", err); 204 + } 205 + }} 206 + > 207 + <Text 208 + color={graphManager.isFollowing ? "destructive" : "default"} 172 209 > 173 - <Text>View Profile on Bluesky</Text> 174 - </DropdownMenuItem> 175 - </DropdownMenuGroup> 176 - )} 210 + {graphManager.isLoading 211 + ? "Loading..." 212 + : graphManager.isFollowing 213 + ? "Unfollow" 214 + : "Follow"} 215 + </Text> 216 + </DropdownMenuItem> 217 + <DropdownMenuSeparator /> 218 + <DropdownMenuItem 219 + onPress={() => { 220 + if (profile?.handle) { 221 + const url = `https://bsky.app/profile/${formatHandle(profile)}`; 222 + Linking.openURL(url); 223 + } 224 + }} 225 + > 226 + <Text>View Profile on Bluesky</Text> 227 + </DropdownMenuItem> 228 + </DropdownMenuGroup> 229 + )} 177 230 178 - <DropdownMenuGroup> 179 - <DropdownMenuSub> 180 - <DropdownMenuSubTrigger subMenuTitle="Quality"> 181 - <View 182 - style={[ 183 - zero.flex.values[1], 184 - isMobile ? zero.layout.flex.row : zero.layout.flex.column, 185 - zero.layout.flex.spaceBetween, 186 - zero.pr[4], 187 - ]} 231 + <DropdownMenuGroup> 232 + <DropdownMenuSub> 233 + <DropdownMenuSubTrigger subMenuTitle="Quality"> 234 + <View 235 + style={[ 236 + zero.flex.values[1], 237 + isMobile ? zero.layout.flex.row : zero.layout.flex.column, 238 + zero.layout.flex.spaceBetween, 239 + zero.pr[4], 240 + ]} 241 + > 242 + <Text>Quality</Text> 243 + <Text muted size={isMobile ? "base" : "sm"}> 244 + {quality === "source" 245 + ? `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}` 246 + : quality} 247 + {lowLatency ? "Low Latency" : ""} 248 + </Text> 249 + </View> 250 + </DropdownMenuSubTrigger> 251 + <DropdownMenuSubContent portalHost={dropdownPortalContainer}> 252 + <DropdownMenuGroup title="Resolution"> 253 + <DropdownMenuRadioGroup 254 + value={quality} 255 + onValueChange={setQuality} 188 256 > 189 - <Text>Quality</Text> 190 - <Text muted size={isMobile ? "base" : "sm"}> 191 - {quality === "source" ? "Source" : quality},{" "} 192 - {lowLatency ? "Low Latency" : ""} 193 - </Text> 194 - </View> 195 - </DropdownMenuSubTrigger> 196 - <DropdownMenuSubContent> 197 - <DropdownMenuGroup title="Resolution"> 198 - <DropdownMenuRadioGroup 199 - value={quality} 200 - onValueChange={setQuality} 201 - > 202 - <DropdownMenuRadioItem value="source"> 203 - <Text>Source (Original Quality)</Text> 257 + <DropdownMenuRadioItem value="source"> 258 + <Text>Source {resolutionDisplay}</Text> 259 + </DropdownMenuRadioItem> 260 + {qualities.map((r) => ( 261 + <DropdownMenuRadioItem key={r.name} value={r.name}> 262 + <Text>{r.name}</Text> 204 263 </DropdownMenuRadioItem> 205 - {qualities.map((r) => ( 206 - <DropdownMenuRadioItem key={r.name} value={r.name}> 207 - <Text>{r.name}</Text> 208 - </DropdownMenuRadioItem> 209 - ))} 210 - </DropdownMenuRadioGroup> 211 - </DropdownMenuGroup> 212 - <DropdownMenuGroup> 213 - <DropdownMenuCheckboxItem 214 - checked={lowLatency} 215 - onCheckedChange={() => setLowLatency(!lowLatency)} 216 - > 217 - <Text>Low Latency</Text> 218 - </DropdownMenuCheckboxItem> 219 - </DropdownMenuGroup> 220 - <DropdownMenuInfo description="Reduces the delay between video and chat for a more real-time experience." /> 221 - </DropdownMenuSubContent> 222 - </DropdownMenuSub> 223 - </DropdownMenuGroup> 224 - <DropdownMenuGroup title="Advanced"> 225 - <DropdownMenuCheckboxItem 226 - checked={debugInfo} 227 - onCheckedChange={() => setShowDebugInfo(!debugInfo)} 228 - > 229 - <Text>Show Debug Info</Text> 230 - </DropdownMenuCheckboxItem> 231 - </DropdownMenuGroup> 232 - <DropdownMenuGroup title="Report"> 233 - <ReportButton 234 - livestream={livestream} 235 - setReportModalOpen={setReportModalOpen} 236 - setReportSubject={setReportSubject} 264 + ))} 265 + </DropdownMenuRadioGroup> 266 + </DropdownMenuGroup> 267 + <DropdownMenuGroup> 268 + <DropdownMenuCheckboxItem 269 + checked={lowLatency} 270 + onCheckedChange={() => setLowLatency(!lowLatency)} 271 + > 272 + <Text>Low Latency</Text> 273 + </DropdownMenuCheckboxItem> 274 + </DropdownMenuGroup> 275 + <DropdownMenuInfo description="Reduces the delay between video and chat for a more real-time experience." /> 276 + </DropdownMenuSubContent> 277 + </DropdownMenuSub> 278 + </DropdownMenuGroup> 279 + <DropdownMenuGroup title="Advanced"> 280 + <DropdownMenuCheckboxItem 281 + checked={debugInfo} 282 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 283 + > 284 + <Text>Show Debug Info</Text> 285 + </DropdownMenuCheckboxItem> 286 + </DropdownMenuGroup> 287 + <DropdownMenuGroup title="Report"> 288 + <ReportButton 289 + livestream={livestream} 290 + setReportModalOpen={setReportModalOpen} 291 + setReportSubject={setReportSubject} 292 + /> 293 + </DropdownMenuGroup> 294 + <View style={[pt[3], px[2], gap.all[2]]}> 295 + {contentWarnings && contentWarnings.length > 0 && ( 296 + <View style={[gap.all[1]]}> 297 + <Text size="base" color="muted"> 298 + Stream may contain 299 + </Text> 300 + <ContentWarnings warnings={contentWarnings} compact={true} /> 301 + </View> 302 + )} 303 + {contentRights && Object.keys(contentRights).length > 0 && ( 304 + <ContentRights 305 + contentRights={contentRights} 306 + size="xs" 307 + color="muted" 237 308 /> 238 - </DropdownMenuGroup> 239 - <View style={[pt[3], px[2], gap.all[2]]}> 240 - {contentWarnings && contentWarnings.length > 0 && ( 241 - <View style={[gap.all[1]]}> 242 - <Text size="base" color="muted"> 243 - Stream may contain 244 - </Text> 245 - <ContentWarnings warnings={contentWarnings} compact={true} /> 246 - </View> 247 - )} 248 - {contentRights && Object.keys(contentRights).length > 0 && ( 249 - <ContentRights 250 - contentRights={contentRights} 251 - size="xs" 252 - color="muted" 253 - /> 254 - )} 255 - </View> 256 - </DropdownMenuContent> 257 - </Portal> 309 + )} 310 + </View> 311 + </DropdownMenuContent> 258 312 </DropdownMenu> 259 313 ); 260 314 }
-1
js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx
··· 52 52 position: "absolute", 53 53 width: "100%", 54 54 height: "100%", 55 - zIndex: 998, 56 55 alignItems: "center", 57 56 justifyContent: "center", 58 57 backgroundColor: "rgba(0,0,0,0.3)",
+5
js/components/src/components/stream-notification/index.ts
··· 1 + export { StreamNotificationProvider } from "./stream-notification"; 2 + export { 3 + streamNotification, 4 + streamNotificationManager, 5 + } from "./stream-notification-manager";
+140
js/components/src/components/stream-notification/stream-notification-manager.ts
··· 1 + export type NotificationConfig = { 2 + id?: string; 3 + message?: string; 4 + render?: ( 5 + isExiting: boolean, 6 + onDismiss: (reason?: "user" | "auto") => void, 7 + startTime?: number, 8 + ) => React.ReactNode; 9 + duration?: number; // seconds, 0 = manual dismiss only 10 + actionLabel?: string; 11 + onAction?: () => void; 12 + onDismiss?: (reason?: "user" | "auto") => void; 13 + variant?: "default" | "info" | "warning"; 14 + }; 15 + 16 + export type StreamNotification = NotificationConfig & { 17 + id: string; 18 + visible: boolean; 19 + shouldDismiss?: boolean; 20 + dismissReason?: "user" | "auto"; 21 + startTime?: number; 22 + }; 23 + 24 + type Listener = (notifications: StreamNotification[]) => void; 25 + 26 + class StreamNotificationManager { 27 + private notifications: StreamNotification[] = []; 28 + private listeners: Set<Listener> = new Set(); 29 + private dismissTimers: Map<string, NodeJS.Timeout> = new Map(); 30 + 31 + show(config: NotificationConfig) { 32 + const notification: StreamNotification = { 33 + id: config.id || `notification-${Date.now()}`, 34 + message: config.message, 35 + render: config.render, 36 + duration: config.duration ?? 5, 37 + actionLabel: config.actionLabel, 38 + onAction: config.onAction, 39 + onDismiss: config.onDismiss, 40 + variant: config.variant ?? "default", 41 + visible: true, 42 + startTime: Date.now(), 43 + }; 44 + 45 + // if notification with same ID exists, dismiss it first 46 + const existingIndex = this.notifications.findIndex( 47 + (n) => n.id === notification.id, 48 + ); 49 + if (existingIndex !== -1) { 50 + const existingTimer = this.dismissTimers.get(notification.id); 51 + if (existingTimer) { 52 + clearTimeout(existingTimer); 53 + this.dismissTimers.delete(notification.id); 54 + } 55 + this.notifications = this.notifications.filter( 56 + (n) => n.id !== notification.id, 57 + ); 58 + } 59 + 60 + this.notifications = [...this.notifications, notification]; 61 + this.notifyListeners(); 62 + 63 + // auto-dismiss if duration > 0 64 + if (notification.duration && notification.duration > 0) { 65 + const timer = setTimeout(() => { 66 + this.requestDismiss(notification.id, "auto"); 67 + }, notification.duration * 1000); 68 + this.dismissTimers.set(notification.id, timer); 69 + } 70 + } 71 + 72 + requestDismiss(id: string, reason: "user" | "auto" = "user") { 73 + const notification = this.notifications.find((n) => n.id === id); 74 + if (!notification) { 75 + console.log("Notification not found!"); 76 + return; 77 + } 78 + 79 + // mark the notification for dismissal 80 + notification.shouldDismiss = true; 81 + notification.dismissReason = reason; 82 + this.notifyListeners(); 83 + // after 500ms, just hide it for real 84 + setTimeout(() => { 85 + this.hide(id, reason); 86 + }, 500); 87 + } 88 + 89 + hide(id: string, reason: "user" | "auto" = "user") { 90 + console.log("Hide called with id:", id, "reason:", reason); 91 + console.log( 92 + "Current notifications:", 93 + this.notifications.map((n) => n.id), 94 + ); 95 + const notification = this.notifications.find((n) => n.id === id); 96 + if (!notification) { 97 + console.log("Notification not found!"); 98 + return; 99 + } 100 + 101 + const timer = this.dismissTimers.get(id); 102 + if (timer) { 103 + clearTimeout(timer); 104 + this.dismissTimers.delete(id); 105 + } 106 + 107 + this.notifications = this.notifications.filter((n) => n.id !== id); 108 + console.log( 109 + "Remaining notifications:", 110 + this.notifications.map((n) => n.id), 111 + ); 112 + this.notifyListeners(); 113 + 114 + notification.onDismiss?.(reason); 115 + } 116 + 117 + getAll(): StreamNotification[] { 118 + return this.notifications; 119 + } 120 + 121 + subscribe(listener: Listener) { 122 + this.listeners.add(listener); 123 + return () => { 124 + this.listeners.delete(listener); 125 + }; 126 + } 127 + 128 + private notifyListeners() { 129 + this.listeners.forEach((listener) => { 130 + listener(this.notifications); 131 + }); 132 + } 133 + } 134 + 135 + export const streamNotificationManager = new StreamNotificationManager(); 136 + 137 + export const streamNotification = { 138 + show: (config: NotificationConfig) => streamNotificationManager.show(config), 139 + hide: (id: string) => streamNotificationManager.hide(id), 140 + };
+227
js/components/src/components/stream-notification/stream-notification.tsx
··· 1 + import { X } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import { Pressable, StyleSheet, View } from "react-native"; 4 + import Animated, { 5 + Easing, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withTiming, 9 + } from "react-native-reanimated"; 10 + import { Text, useTheme } from "../../"; 11 + import { 12 + StreamNotification, 13 + streamNotificationManager, 14 + } from "./stream-notification-manager"; 15 + 16 + export function StreamNotificationProvider({ 17 + children = <></>, 18 + position = "top", 19 + }: { 20 + children?: React.ReactNode; 21 + position?: "top" | "bottom"; 22 + }) { 23 + const [notifications, setNotifications] = useState( 24 + streamNotificationManager.getAll(), 25 + ); 26 + 27 + useEffect(() => { 28 + return streamNotificationManager.subscribe(setNotifications); 29 + }, []); 30 + 31 + return ( 32 + <View style={styles.container}> 33 + {children} 34 + {notifications.map((notification, index) => ( 35 + <NotificationItem 36 + key={notification.id} 37 + notification={notification} 38 + index={index} 39 + position={position} 40 + /> 41 + ))} 42 + </View> 43 + ); 44 + } 45 + 46 + function NotificationItem({ 47 + notification, 48 + index, 49 + position, 50 + }: { 51 + notification: StreamNotification; 52 + index: number; 53 + position: "top" | "bottom"; 54 + }) { 55 + const { theme } = useTheme(); 56 + const translateY = useSharedValue(position === "top" ? -100 : 100); 57 + const opacity = useSharedValue(0); 58 + const [isExiting, setIsExiting] = useState(false); 59 + 60 + const NOTIFICATION_HEIGHT = 60; 61 + const NOTIFICATION_GAP = 8; 62 + const offset = 16 + index * (NOTIFICATION_HEIGHT + NOTIFICATION_GAP); 63 + 64 + useEffect(() => { 65 + translateY.value = withTiming(position === "top" ? offset : -offset, { 66 + duration: 300, 67 + easing: Easing.out(Easing.cubic), 68 + }); 69 + opacity.value = withTiming(1, { 70 + duration: 200, 71 + }); 72 + }, [offset, position]); 73 + 74 + useEffect(() => { 75 + if (notification.shouldDismiss && !isExiting) { 76 + setIsExiting(true); 77 + setTimeout(() => { 78 + streamNotificationManager.hide( 79 + notification.id, 80 + notification.dismissReason || "auto", 81 + ); 82 + }, 200); 83 + } 84 + }, [ 85 + notification.shouldDismiss, 86 + isExiting, 87 + notification.id, 88 + notification.dismissReason, 89 + ]); 90 + 91 + useEffect(() => { 92 + if (isExiting) { 93 + translateY.value = withTiming(position === "top" ? -100 : 100, { 94 + duration: 200, 95 + easing: Easing.in(Easing.cubic), 96 + }); 97 + opacity.value = withTiming(0, { 98 + duration: 200, 99 + }); 100 + } 101 + }, [isExiting, position]); 102 + 103 + const animatedStyle = useAnimatedStyle(() => ({ 104 + transform: [{ translateY: translateY.value }], 105 + opacity: opacity.value, 106 + })); 107 + 108 + const variantStyles = { 109 + default: { 110 + backgroundColor: theme.colors.card, 111 + borderColor: theme.colors.border, 112 + }, 113 + info: { 114 + backgroundColor: theme.colors.info, 115 + borderColor: theme.colors.info, 116 + }, 117 + warning: { 118 + backgroundColor: theme.colors.warning, 119 + borderColor: theme.colors.warning, 120 + }, 121 + }; 122 + 123 + const handleDismiss = (reason: "user" | "auto" = "user") => { 124 + console.log("Dismissing notification:", notification.id); 125 + setIsExiting(true); 126 + setTimeout(() => { 127 + console.log("Requesting dismiss for notification:", notification.id); 128 + streamNotificationManager.hide(notification.id, reason); 129 + }, 200); 130 + console.log(streamNotificationManager.getAll()); 131 + }; 132 + 133 + const handleAction = () => { 134 + notification.onAction?.(); 135 + streamNotificationManager.hide(notification.id, "user"); 136 + }; 137 + 138 + const positionStyle = position === "top" ? { top: 0 } : { bottom: 0 }; 139 + 140 + return ( 141 + <Animated.View 142 + style={[ 143 + styles.notification, 144 + positionStyle, 145 + notification.render 146 + ? {} 147 + : variantStyles[notification.variant || "default"], 148 + { margin: 0, padding: 0 }, 149 + animatedStyle, 150 + ]} 151 + > 152 + {notification.render ? ( 153 + notification.render(isExiting, handleDismiss, notification.startTime) 154 + ) : ( 155 + <View style={styles.content}> 156 + <Text style={[styles.message, { color: theme.colors.foreground }]}> 157 + {notification.message} 158 + </Text> 159 + 160 + <View style={styles.actions}> 161 + {notification.actionLabel && ( 162 + <Pressable onPress={handleAction}> 163 + <Text 164 + style={[styles.actionButton, { color: theme.colors.primary }]} 165 + > 166 + {notification.actionLabel} 167 + </Text> 168 + </Pressable> 169 + )} 170 + 171 + <Pressable 172 + onPress={() => handleDismiss("user")} 173 + style={styles.closeButton} 174 + > 175 + <X size={16} color={theme.colors.mutedForeground} /> 176 + </Pressable> 177 + </View> 178 + </View> 179 + )} 180 + </Animated.View> 181 + ); 182 + } 183 + 184 + const styles = StyleSheet.create({ 185 + container: { 186 + flex: 1, 187 + pointerEvents: "box-none", 188 + }, 189 + notification: { 190 + position: "absolute", 191 + top: 0, 192 + left: 16, 193 + right: 16, 194 + zIndex: 9999, 195 + borderRadius: 8, 196 + borderWidth: 1, 197 + padding: 12, 198 + shadowColor: "#000", 199 + shadowOffset: { width: 0, height: 2 }, 200 + shadowOpacity: 0.25, 201 + shadowRadius: 8, 202 + elevation: 5, 203 + }, 204 + content: { 205 + flexDirection: "row", 206 + alignItems: "center", 207 + justifyContent: "space-between", 208 + gap: 12, 209 + }, 210 + message: { 211 + flex: 1, 212 + fontSize: 14, 213 + fontWeight: "500", 214 + }, 215 + actions: { 216 + flexDirection: "row", 217 + alignItems: "center", 218 + gap: 12, 219 + }, 220 + actionButton: { 221 + fontSize: 14, 222 + fontWeight: "600", 223 + }, 224 + closeButton: { 225 + padding: 4, 226 + }, 227 + });
+187
js/components/src/components/stream-notification/teleport-notification.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { useWindowDimensions, View } from "react-native"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withRepeat, 8 + withTiming, 9 + } from "react-native-reanimated"; 10 + import { Button, Text, useTheme, zero } from "../../"; 11 + 12 + export function TeleportNotification({ 13 + targetHandle, 14 + countdown, 15 + canCancel, 16 + startTime, 17 + onDismiss, 18 + }: { 19 + targetHandle: string; 20 + countdown: number; 21 + canCancel: boolean; 22 + startTime?: number; 23 + onDismiss: (reason?: "user" | "auto") => void; 24 + }) { 25 + const { zero: z } = useTheme(); 26 + const w = useWindowDimensions().width; 27 + 28 + const [start, setStart] = useState(Date.now()); 29 + const [now, setNow] = useState(Date.now()); 30 + const [dismissed, setDismissed] = useState(false); 31 + 32 + useEffect(() => { 33 + const interval = setInterval(() => { 34 + setNow(Date.now()); 35 + }, 100); 36 + return () => clearInterval(interval); 37 + }, []); 38 + 39 + const timeLeft = Math.max(0, countdown - Math.floor((now - start) / 1000)); 40 + 41 + useEffect(() => { 42 + if (dismissed) { 43 + return; 44 + } 45 + if (timeLeft <= 0) { 46 + setDismissed(true); 47 + onDismiss("auto"); 48 + } 49 + }, [dismissed, onDismiss, timeLeft]); 50 + 51 + // if we're past 5 seconds from start, stripes should already be hidden 52 + const elapsedTime = startTime ? (Date.now() - startTime) / 1000 : 0; 53 + const [showStripes, setShowStripes] = useState(elapsedTime < 5); 54 + 55 + const stripeX = useSharedValue(0); 56 + const stripeOpacity = useSharedValue(1); 57 + const progressWidth = useSharedValue(100); 58 + 59 + useEffect(() => { 60 + // if stripes are already hidden, fade out asap and return 61 + if (!showStripes) { 62 + stripeOpacity.value = withTiming(0, { duration: 0 }); 63 + return; 64 + } 65 + // warning stripes animation 66 + stripeX.value = withRepeat( 67 + withTiming(30 * 2, { 68 + duration: 1000, 69 + easing: Easing.linear, 70 + }), 71 + 3, 72 + false, 73 + ); 74 + 75 + // hide stripes after 500ms 76 + const stripesTimer = setTimeout(() => { 77 + // woosh the stripes off to the right before hiding 78 + stripeX.value = withTiming(30 * 80, { 79 + duration: 1500, 80 + easing: Easing.cubic, 81 + }); 82 + // after animation, set stripes as hidden 83 + setTimeout(() => { 84 + setShowStripes(false); 85 + }, 350); 86 + }, 1500); 87 + 88 + return () => clearTimeout(stripesTimer); 89 + }, []); 90 + 91 + useEffect(() => { 92 + if (showStripes) return; 93 + 94 + // animate progress bar 95 + const percentage = (timeLeft / countdown) * 100; 96 + progressWidth.value = withTiming(percentage, { 97 + duration: 1000, 98 + easing: Easing.linear, 99 + }); 100 + }, [timeLeft, countdown, showStripes]); 101 + 102 + const stripesStyle = useAnimatedStyle(() => ({ 103 + opacity: stripeOpacity.value, 104 + transform: [{ translateX: stripeX.value }], 105 + })); 106 + 107 + const progressStyle = useAnimatedStyle(() => ({ 108 + width: `${progressWidth.value}%`, 109 + })); 110 + 111 + return ( 112 + <View style={[{ overflow: "hidden" }, zero.r.lg, zero.bg.neutral[900]]}> 113 + <View 114 + style={[ 115 + zero.layout.flex.row, 116 + zero.layout.flex.alignCenter, 117 + zero.layout.flex.spaceBetween, 118 + zero.px[3], 119 + w > 650 ? zero.py[4] : zero.py[2], 120 + ]} 121 + > 122 + <Text size={w > 650 ? "xl" : "base"}> 123 + Teleporting to @{targetHandle} 124 + </Text> 125 + <View 126 + style={[ 127 + zero.layout.flex.row, 128 + zero.layout.flex.alignCenter, 129 + zero.gap.all[3], 130 + ]} 131 + > 132 + <Text color="muted">{timeLeft}s</Text> 133 + {canCancel && ( 134 + <Button 135 + onPress={() => onDismiss("user")} 136 + width="min" 137 + variant="destructive" 138 + > 139 + Cancel 140 + </Button> 141 + )} 142 + </View> 143 + </View> 144 + <View 145 + style={{ 146 + height: 4, 147 + width: "100%", 148 + borderRadius: 2, 149 + overflow: "hidden", 150 + backgroundColor: "#0f0f1e", 151 + }} 152 + > 153 + <Animated.View 154 + style={[ 155 + { height: "100%", borderRadius: 2, backgroundColor: "#16f4d0" }, 156 + progressStyle, 157 + ]} 158 + /> 159 + </View> 160 + <Animated.View 161 + style={[ 162 + { 163 + position: "absolute", 164 + flexDirection: "row", 165 + height: 180, 166 + width: "200%", 167 + //clickthrough 168 + pointerEvents: "none", 169 + }, 170 + stripesStyle, 171 + ]} 172 + > 173 + {[...Array(80)].map((_, i) => ( 174 + <View 175 + key={i} 176 + style={{ 177 + width: 30, 178 + height: "100%", 179 + backgroundColor: i % 2 === 0 ? "#FFA500" : "#000000", 180 + transform: [{ skewX: "-45deg" }, { translateX: -30 * 8 }], 181 + }} 182 + /> 183 + ))} 184 + </Animated.View> 185 + </View> 186 + ); 187 + }
+177
js/components/src/components/ui/admonition.tsx
··· 1 + import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react-native"; 2 + import { View, ViewStyle } from "react-native"; 3 + import { useTheme } from "../../ui"; 4 + import { Text } from "./text"; 5 + 6 + type AdmonitionVariant = "default" | "success" | "error" | "info" | "warning"; 7 + type AdmonitionSize = "sm" | "md" | "lg"; 8 + 9 + type AdmonitionProps = { 10 + variant?: AdmonitionVariant; 11 + size?: AdmonitionSize; 12 + title?: string; 13 + children?: React.ReactNode; 14 + iconLeft?: React.ComponentType<any>; 15 + style?: ViewStyle; 16 + }; 17 + 18 + export function Admonition({ 19 + variant = "default", 20 + size = "md", 21 + title, 22 + children, 23 + iconLeft, 24 + style, 25 + }: AdmonitionProps) { 26 + const { theme, icons } = useTheme(); 27 + 28 + const defaultIconLeft = (() => { 29 + if (iconLeft) return iconLeft; 30 + switch (variant) { 31 + case "success": 32 + return CheckCircle; 33 + case "error": 34 + return XCircle; 35 + case "info": 36 + return Info; 37 + case "warning": 38 + return AlertCircle; 39 + default: 40 + return Info; 41 + } 42 + })(); 43 + 44 + const FinalIconLeft = defaultIconLeft; 45 + 46 + const variantStyles: Record<AdmonitionVariant, ViewStyle> = { 47 + default: { 48 + backgroundColor: theme.colors.secondary, 49 + borderColor: theme.colors.border, 50 + }, 51 + success: { 52 + backgroundColor: theme.colors.success + "15", 53 + borderColor: theme.colors.success, 54 + }, 55 + error: { 56 + backgroundColor: theme.colors.destructive + "15", 57 + borderColor: theme.colors.destructive, 58 + }, 59 + info: { 60 + backgroundColor: theme.colors.info + "15", 61 + borderColor: theme.colors.info, 62 + }, 63 + warning: { 64 + backgroundColor: theme.colors.warning + "15", 65 + borderColor: theme.colors.warning, 66 + }, 67 + }; 68 + 69 + const iconColor = (() => { 70 + switch (variant) { 71 + case "success": 72 + return theme.colors.success; 73 + case "error": 74 + return theme.colors.destructive; 75 + case "info": 76 + return theme.colors.info; 77 + case "warning": 78 + return theme.colors.warning; 79 + default: 80 + return theme.colors.foreground; 81 + } 82 + })(); 83 + 84 + const sizeConfig = (() => { 85 + switch (size) { 86 + case "sm": 87 + return { 88 + borderRadius: 8, 89 + padding: 12, 90 + gap: 6, 91 + iconSize: icons.size.md, 92 + titleSize: "base" as const, 93 + contentSize: "sm" as const, 94 + innerGap: 8, 95 + }; 96 + case "lg": 97 + return { 98 + borderRadius: 16, 99 + padding: 20, 100 + gap: 12, 101 + iconSize: icons.size.xl, 102 + titleSize: "xl" as const, 103 + contentSize: "lg" as const, 104 + innerGap: 16, 105 + }; 106 + case "md": 107 + default: 108 + return { 109 + borderRadius: 12, 110 + padding: 16, 111 + gap: 8, 112 + iconSize: icons.size.lg, 113 + titleSize: "lg" as const, 114 + contentSize: "base" as const, 115 + innerGap: 12, 116 + }; 117 + } 118 + })(); 119 + 120 + let childrenIn = ( 121 + <View 122 + style={{ 123 + paddingLeft: title ? sizeConfig.iconSize + sizeConfig.innerGap : 0, 124 + }} 125 + > 126 + {typeof children === "string" ? ( 127 + <Text 128 + size={sizeConfig.contentSize} 129 + style={{ color: theme.colors.cardForeground, flexWrap: "wrap" }} 130 + > 131 + {children} 132 + </Text> 133 + ) : ( 134 + children 135 + )} 136 + </View> 137 + ); 138 + 139 + return ( 140 + <View 141 + style={[ 142 + { 143 + borderRadius: sizeConfig.borderRadius, 144 + borderWidth: 1, 145 + padding: sizeConfig.padding, 146 + gap: sizeConfig.gap, 147 + }, 148 + variantStyles[variant], 149 + style, 150 + ]} 151 + > 152 + <View 153 + style={{ 154 + flexDirection: "row", 155 + alignItems: "flex-start", 156 + gap: sizeConfig.innerGap, 157 + }} 158 + > 159 + {FinalIconLeft && ( 160 + <FinalIconLeft size={sizeConfig.iconSize} color={iconColor} /> 161 + )} 162 + {title ? ( 163 + <Text 164 + size={sizeConfig.titleSize} 165 + weight="semibold" 166 + style={{ flex: 1 }} 167 + > 168 + {title} 169 + </Text> 170 + ) : ( 171 + children && <View style={{ flex: 1 }}>{childrenIn}</View> 172 + )} 173 + </View> 174 + {children && title && childrenIn} 175 + </View> 176 + ); 177 + }
+10
js/components/src/components/ui/button.tsx
··· 23 23 lg: "lg", 24 24 xl: "xl", 25 25 pill: "pill", 26 + icon: "icon", 26 27 }, 27 28 }, 28 29 defaultVariants: { ··· 40 41 loading?: boolean; 41 42 loadingText?: string; 42 43 width?: "full" | "min" | number; 44 + hoverStyle?: ButtonPrimitiveProps["hoverStyle"]; 43 45 } 44 46 45 47 export const Button = forwardRef<any, ButtonProps>( ··· 55 57 disabled, 56 58 style, 57 59 width = "full", 60 + hoverStyle, 58 61 ...props 59 62 }, 60 63 ref, ··· 143 146 inner: { gap: 4 }, 144 147 text: zero.typography.universal.xs, 145 148 }; 149 + case "icon": 150 + return { 151 + button: [zero.p[2], { borderRadius: zero.borderRadius.md }], 152 + inner: { gap: 0 }, 153 + text: zero.typography.universal.sm, 154 + }; 146 155 case "md": 147 156 default: 148 157 return { ··· 215 224 ref={ref} 216 225 disabled={disabled || loading} 217 226 style={[buttonStyle, sizeStyles.button, widthStyle, style]} 227 + hoverStyle={hoverStyle} 218 228 {...props} 219 229 > 220 230 <ButtonPrimitive.Content style={sizeStyles.inner}>
+96 -26
js/components/src/components/ui/dropdown.tsx
··· 1 + import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu"; 1 2 import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu"; 2 3 import { 3 4 Check, ··· 109 110 110 111 export const DropdownMenuSubContent = forwardRef< 111 112 any, 112 - DropdownMenuPrimitive.SubContentProps & { children?: ReactNode } 113 - >(({ children, ...props }, ref) => { 114 - const { zero: zt } = useTheme(); 113 + DropdownMenuPrimitive.SubContentProps & { 114 + children?: ReactNode; 115 + portalHost?: string; 116 + sideOffset?: number; 117 + alignOffset?: number; 118 + avoidCollisions?: boolean; 119 + } 120 + >( 121 + ( 122 + { 123 + children, 124 + portalHost, 125 + sideOffset, 126 + alignOffset, 127 + avoidCollisions = true, 128 + ...props 129 + }, 130 + ref, 131 + ) => { 132 + const { zero: zt } = useTheme(); 133 + 134 + const [portalContainer, setPortalContainer] = 135 + React.useState<HTMLElement | null>(null); 136 + 137 + React.useEffect(() => { 138 + if (Platform.OS === "web" && portalHost) { 139 + const element = document.querySelector<HTMLElement>( 140 + `[data-portal-host="${portalHost}"]`, 141 + ); 142 + setPortalContainer(element); 143 + } 144 + }, [portalHost]); 145 + 146 + const styles = [ 147 + a.sizes.minWidth[64], 148 + a.sizes.maxWidth[64], 149 + a.overflow.hidden, 150 + a.radius.all.md, 151 + a.borders.width.thin, 152 + zt.border.default, 153 + mt[1], 154 + zt.bg.popover, 155 + p[1], 156 + a.shadows.md, 157 + ]; 158 + 159 + // On web, use Radix directly to support custom portal container 160 + if (Platform.OS === "web") { 161 + const { forceMount } = props; 162 + // Flatten RN style array into a plain CSS object for DOM 163 + const flattenedStyles = StyleSheet.flatten(styles); 164 + return ( 165 + <RadixDropdownMenu.Portal 166 + {...(portalContainer ? { container: portalContainer } : {})} 167 + > 168 + <RadixDropdownMenu.SubContent 169 + ref={ref} 170 + style={flattenedStyles as React.CSSProperties} 171 + forceMount={forceMount} 172 + sideOffset={sideOffset} 173 + alignOffset={alignOffset} 174 + avoidCollisions={avoidCollisions} 175 + > 176 + {children} 177 + </RadixDropdownMenu.SubContent> 178 + </RadixDropdownMenu.Portal> 179 + ); 180 + } 115 181 116 - return ( 117 - <DropdownMenuPrimitive.SubContent 118 - ref={ref} 119 - style={[ 120 - a.zIndex[50], 121 - a.sizes.minWidth[64], 122 - a.sizes.maxWidth[64], 123 - a.overflow.hidden, 124 - a.radius.all.md, 125 - a.borders.width.thin, 126 - zt.border.default, 127 - mt[1], 128 - zt.bg.popover, 129 - p[1], 130 - a.shadows.md, 131 - ]} 132 - {...props} 133 - > 134 - {children} 135 - </DropdownMenuPrimitive.SubContent> 136 - ); 137 - }); 182 + // On native, use rn-primitives 183 + return ( 184 + <DropdownMenuPrimitive.SubContent ref={ref} style={styles} {...props}> 185 + {children} 186 + </DropdownMenuPrimitive.SubContent> 187 + ); 188 + }, 189 + ); 138 190 139 191 export const DropdownMenuContent = forwardRef< 140 192 any, ··· 147 199 const { height } = useWindowDimensions(); 148 200 const maxHeight = height * 0.9; 149 201 202 + const [portalContainer, setPortalContainer] = 203 + React.useState<HTMLElement | null>(null); 204 + 205 + React.useEffect(() => { 206 + if (Platform.OS === "web" && portalHost) { 207 + const element = document.querySelector<HTMLElement>( 208 + `[data-portal-host="${portalHost}"]`, 209 + ); 210 + setPortalContainer(element); 211 + console.log("set portal container to", element); 212 + } 213 + }, [portalHost]); 214 + 150 215 return ( 151 - <DropdownMenuPrimitive.Portal hostName={portalHost}> 216 + <DropdownMenuPrimitive.Portal 217 + hostName={portalHost} 218 + {...(Platform.OS === "web" && portalContainer 219 + ? { container: portalContainer } 220 + : {})} 221 + > 152 222 <DropdownMenuPrimitive.Overlay 153 223 style={[ 154 224 Platform.OS !== "web" ? StyleSheet.absoluteFill : undefined,
+2
js/components/src/components/ui/index.ts
··· 5 5 export * from "./primitives/text"; 6 6 7 7 // Export styled components 8 + export * from "./admonition"; 8 9 export * from "./button"; 9 10 export * from "./checkbox"; 10 11 export * from "./dialog"; ··· 15 16 export * from "./input"; 16 17 export * from "./loader"; 17 18 export * from "./menu"; 19 + export * from "./portal"; 18 20 export * from "./resizeable"; 19 21 export * from "./slider"; 20 22 export * from "./text";
+1
js/components/src/components/ui/portal.tsx
··· 1 + export * from "@rn-primitives/portal";
+37
js/components/src/components/ui/portal.web.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { createPortal } from "react-dom"; 3 + 4 + function Portal({ 5 + children, 6 + hostName = "INTERNAL_PRIMITIVE_DEFAULT_HOST_NAME", 7 + }: { 8 + children: React.ReactNode; 9 + hostName?: string; 10 + }) { 11 + const [hostElement, setHostElement] = useState<HTMLElement | null>(null); 12 + 13 + useEffect(() => { 14 + const element = document.querySelector<HTMLElement>( 15 + `[data-portal-host="${hostName}"]`, 16 + ); 17 + setHostElement(element); 18 + }, [hostName]); 19 + 20 + if (!hostElement) { 21 + return null; 22 + } 23 + 24 + return createPortal(children, hostElement); 25 + } 26 + 27 + interface PortalHostProps { 28 + name?: string; 29 + } 30 + 31 + function PortalHost({ 32 + name = "INTERNAL_PRIMITIVE_DEFAULT_HOST_NAME", 33 + }: PortalHostProps) { 34 + return <div data-portal-host={name} />; 35 + } 36 + 37 + export { Portal, PortalHost };
+37 -11
js/components/src/components/ui/primitives/button.tsx
··· 1 - import React, { forwardRef } from "react"; 1 + import React, { forwardRef, useState } from "react"; 2 2 import { 3 3 AccessibilityRole, 4 4 GestureResponderEvent, 5 + Platform, 6 + Pressable, 7 + PressableProps, 8 + StyleProp, 5 9 StyleSheet, 6 10 Text, 7 11 TextProps, 8 - TouchableOpacity, 9 - TouchableOpacityProps, 10 12 View, 11 13 ViewProps, 14 + ViewStyle, 12 15 } from "react-native"; 13 16 14 17 // Base button primitive interface 15 - export interface ButtonPrimitiveProps 16 - extends Omit<TouchableOpacityProps, "onPress"> { 18 + export interface ButtonPrimitiveProps extends Omit<PressableProps, "onPress"> { 17 19 onPress?: (event: GestureResponderEvent) => void; 18 20 disabled?: boolean; 19 21 loading?: boolean; ··· 21 23 accessibilityLabel?: string; 22 24 accessibilityHint?: string; 23 25 testID?: string; 26 + hoverStyle?: StyleProp<ViewStyle>; 24 27 } 25 28 26 29 // Button root primitive - handles all touch interactions 27 30 export const ButtonRoot = forwardRef< 28 - React.ComponentRef<typeof TouchableOpacity>, 31 + React.ComponentRef<typeof Pressable>, 29 32 ButtonPrimitiveProps 30 33 >( 31 34 ( ··· 43 46 accessibilityState, 44 47 testID, 45 48 style, 46 - activeOpacity = 0.7, 49 + hoverStyle, 47 50 ...props 48 51 }, 49 52 ref, 50 53 ) => { 54 + const [isHovered, setIsHovered] = useState(false); 55 + 51 56 const handlePress = React.useCallback( 52 57 (event: GestureResponderEvent) => { 53 58 if (!disabled && !loading && onPress) { ··· 84 89 [disabled, loading, onLongPress], 85 90 ); 86 91 92 + const handleHoverIn = React.useCallback(() => { 93 + if (!disabled && !loading) { 94 + setIsHovered(true); 95 + } 96 + }, [disabled, loading]); 97 + 98 + const handleHoverOut = React.useCallback(() => { 99 + setIsHovered(false); 100 + }, []); 101 + 87 102 return ( 88 - <TouchableOpacity 103 + <Pressable 89 104 ref={ref} 90 105 onPress={handlePress} 91 106 onPressIn={handlePressIn} 92 107 onPressOut={handlePressOut} 93 108 onLongPress={handleLongPress} 109 + onHoverIn={handleHoverIn} 110 + onHoverOut={handleHoverOut} 94 111 disabled={disabled || loading} 95 - activeOpacity={disabled || loading ? 1 : activeOpacity} 96 112 accessibilityRole={accessibilityRole} 97 113 accessibilityLabel={accessibilityLabel} 98 114 accessibilityHint={accessibilityHint} ··· 104 120 testID={testID} 105 121 style={[ 106 122 primitiveStyles.button, 123 + primitiveStyles.transition, 107 124 (disabled || loading) && primitiveStyles.disabled, 108 - style, 125 + style as any, 126 + isHovered && hoverStyle, 109 127 ]} 110 128 {...props} 111 129 > 112 130 {children} 113 - </TouchableOpacity> 131 + </Pressable> 114 132 ); 115 133 }, 116 134 ); ··· 245 263 alignItems: "center", 246 264 justifyContent: "center", 247 265 }, 266 + transition: 267 + Platform.OS === "web" 268 + ? // probably fine if web-only 269 + ({ 270 + transitionDuration: "150ms", 271 + transitionProperty: "background-color, border-color, color", 272 + } as any) 273 + : undefined, 248 274 disabled: { 249 275 opacity: 0.5, 250 276 },
+90 -35
js/components/src/components/ui/resizeable.tsx
··· 1 1 import { ChevronUp } from "lucide-react-native"; 2 - import { ComponentProps, useEffect } from "react"; 2 + import { ComponentProps, useEffect, useState } from "react"; 3 3 import { Dimensions } from "react-native"; 4 4 import { 5 5 Gesture, ··· 9 9 import Animated, { 10 10 Extrapolation, 11 11 interpolate, 12 + runOnJS, 12 13 useAnimatedStyle, 13 14 useSharedValue, 14 15 withSpring, ··· 27 28 isPlayerRatioGreater: boolean; 28 29 style?: ComponentProps<typeof AnimatedView>["style"]; 29 30 children?: React.ReactNode; 31 + renderAbove?: (isCollapsed: boolean) => React.ReactNode; 30 32 }; 31 33 32 34 const SPRING_CONFIG = { damping: 20, stiffness: 100 }; ··· 36 38 isPlayerRatioGreater, 37 39 style = {}, 38 40 children, 41 + renderAbove, 39 42 }: ResizableChatSheetProps) { 40 43 const { slideKeyboard } = useKeyboardSlide(); 41 44 const { bottom: safeBottom } = useSafeAreaInsets(); ··· 45 48 46 49 const sheetHeight = useSharedValue(MIN_HEIGHT); 47 50 const startHeight = useSharedValue(MIN_HEIGHT); 51 + const [isCollapsed, setIsCollapsed] = useState(true); 52 + const wasCollapsed = useSharedValue(true); 48 53 49 54 useEffect(() => { 50 55 setTimeout(() => { 51 - sheetHeight.value = withSpring( 52 - startingPercentage ? startingPercentage * SCREEN_HEIGHT : MIN_HEIGHT, 53 - SPRING_CONFIG, 54 - ); 56 + const targetHeight = startingPercentage 57 + ? startingPercentage * SCREEN_HEIGHT 58 + : MIN_HEIGHT; 59 + sheetHeight.value = withSpring(targetHeight, SPRING_CONFIG); 60 + setIsCollapsed(targetHeight < COLLAPSE_HEIGHT); 55 61 }, 1000); 56 62 }, []); 57 63 ··· 65 71 if (newHeight < MIN_HEIGHT) newHeight = MIN_HEIGHT; 66 72 sheetHeight.value = newHeight; 67 73 68 - if (newHeight < COLLAPSE_HEIGHT) { 74 + const nowCollapsed = newHeight < COLLAPSE_HEIGHT; 75 + if (nowCollapsed && !wasCollapsed.value) { 69 76 sheetHeight.value = withSpring(MIN_HEIGHT, SPRING_CONFIG); 77 + wasCollapsed.value = true; 78 + runOnJS(setIsCollapsed)(true); 79 + } else if (!nowCollapsed && wasCollapsed.value) { 80 + wasCollapsed.value = false; 81 + runOnJS(setIsCollapsed)(false); 70 82 } 71 83 }); 72 84 ··· 83 95 translateY: 84 96 slideKeyboard + 85 97 Math.max(0, -sheetHeight.value) + 86 - (slideKeyboard < 0 ? 0 : -safeBottom), 98 + (slideKeyboard < 0 ? 0 : -safeBottom) - 99 + (Math.abs(slideKeyboard) > 1 ? 32 : 16), 87 100 }, 88 101 ], 89 102 })); ··· 97 110 ], 98 111 })); 99 112 113 + const aboveElementStyle = useAnimatedStyle(() => ({ 114 + // show inside area when not collapsed, and show outside area when collapsed 115 + height: sheetHeight.value < COLLAPSE_HEIGHT ? 0 : sheetHeight.value, 116 + transform: [ 117 + { 118 + translateY: 119 + sheetHeight.value < COLLAPSE_HEIGHT 120 + ? withSpring(-120) 121 + : withSpring(20), 122 + }, 123 + ], 124 + })); 125 + 100 126 return ( 101 127 <> 102 128 <Animated.View ··· 111 137 > 112 138 <Pressable 113 139 onPress={() => { 114 - sheetHeight.value = 115 - sheetHeight.value === MIN_HEIGHT 116 - ? withSpring(MAX_HEIGHT, SPRING_CONFIG) 117 - : withSpring(MIN_HEIGHT, SPRING_CONFIG); 140 + const isCurrentlyCollapsed = sheetHeight.value === MIN_HEIGHT; 141 + sheetHeight.value = isCurrentlyCollapsed 142 + ? withSpring(MAX_HEIGHT, SPRING_CONFIG) 143 + : withSpring(MIN_HEIGHT, SPRING_CONFIG); 144 + setIsCollapsed(!isCurrentlyCollapsed); 118 145 }} 119 146 > 120 147 <View ··· 155 182 ]} 156 183 > 157 184 <View style={[layout.flex.row, layout.flex.justifyCenter, h[2]]}> 158 - <GestureDetector gesture={panGesture}> 159 - <View 160 - // Make the touch area much larger, but keep the visible handle small 161 - style={{ 162 - height: 30, // Large touch area 163 - width: 120, // Wide enough for thumbs 164 - alignItems: "center", 165 - justifyContent: "center", 166 - //backgroundColor: "rgba(0,255,255,0.1)", 167 - transform: [{ translateY: -30 }], 168 - }} 169 - > 185 + <View style={{ alignItems: "center", width: "100%" }}> 186 + <GestureDetector gesture={panGesture}> 170 187 <View 171 - style={[ 172 - w[32], 173 - { 174 - height: 6, 175 - backgroundColor: "#eeeeee66", 176 - borderRadius: 999, 188 + // Make the touch area much larger, but keep the visible handle small 189 + style={{ 190 + height: 30, // Large touch area 191 + width: 120, // Wide enough for thumbs 192 + alignItems: "center", 193 + justifyContent: "center", 194 + //backgroundColor: "rgba(0,255,255,0.1)", 195 + transform: [{ translateY: -30 }], 196 + }} 197 + > 198 + <View 199 + style={[ 200 + w[32], 201 + { 202 + height: 6, 203 + backgroundColor: "#eeeeee66", 204 + borderRadius: 999, 177 205 178 - transform: [{ translateY: 5 }], 179 - }, 180 - ]} 181 - /> 182 - </View> 183 - </GestureDetector> 206 + transform: [{ translateY: 5 }], 207 + }, 208 + ]} 209 + /> 210 + </View> 211 + </GestureDetector> 212 + </View> 184 213 </View> 185 214 186 215 {children} 187 216 </AnimatedView> 217 + <Animated.View 218 + style={[ 219 + aboveElementStyle, 220 + { 221 + width: "100%", 222 + pointerEvents: "none", 223 + position: "absolute", 224 + bottom: 0, 225 + }, 226 + ]} 227 + > 228 + <View 229 + style={{ 230 + pointerEvents: "auto", 231 + width: "100%", 232 + // hate doing it this way, but can't figure out 233 + // how to make it size to content otherwise 234 + minHeight: 50, 235 + height: "100%", 236 + maxHeight: 75, 237 + flex: 0, 238 + }} 239 + > 240 + {renderAbove?.(isCollapsed)} 241 + </View> 242 + </Animated.View> 188 243 </> 189 244 ); 190 245 }
+11 -1
js/components/src/components/ui/text.tsx
··· 62 62 63 63 export interface TextProps 64 64 extends Omit<TextPrimitiveProps, "variant" | "size" | "weight" | "color">, 65 - VariantProps<typeof textVariants> { 65 + Omit<VariantProps<typeof textVariants>, "color"> { 66 + // Override color to accept hex values and custom strings 67 + color?: 68 + | "default" 69 + | "muted" 70 + | "primary" 71 + | "secondary" 72 + | "destructive" 73 + | "success" 74 + | "warning" 75 + | (string & {}); 66 76 // Additional convenience props 67 77 muted?: boolean; 68 78 bold?: boolean;
+5
js/components/src/components/ui/textarea.tsx
··· 5 5 import * as React from "react"; 6 6 import { Platform, TextInput, type TextInputProps } from "react-native"; 7 7 import { bg, borders, flex, p, text } from "../../lib/theme/atoms"; 8 + import { useTheme } from "../../ui"; 8 9 9 10 const Textarea = React.forwardRef<TextInput, TextInputProps>( 10 11 ({ style, multiline = true, numberOfLines = 4, ...props }, ref) => { 12 + let th = useTheme(); 11 13 // Detect if we're inside a bottom sheet 12 14 let isInBottomSheet = false; 13 15 try { ··· 38 40 { borderRadius: 10 }, 39 41 style, 40 42 ]} 43 + autoComplete={props.autoComplete || "off"} 44 + textContentType={props.textContentType || "none"} 41 45 multiline={multiline} 42 46 numberOfLines={numberOfLines} 43 47 textAlignVertical="top" 48 + placeholderTextColor={th.theme.colors.textMuted} 44 49 {...props} 45 50 /> 46 51 );
+1
js/components/src/hooks/index.ts
··· 1 1 // barrel file :) 2 + export * from "./useAQState"; 2 3 export * from "./useAvatars"; 3 4 export * from "./useCameraToggle"; 4 5 export * from "./useDocumentTitle";
+37
js/components/src/hooks/useAQState.ts
··· 1 + import { useEffect, useState } from "react"; 2 + import storage from "../storage"; 3 + 4 + export function useAQState<T>( 5 + key: string, 6 + defaultValue: T, 7 + ): [T, (value: T) => void] { 8 + const [state, setState] = useState<T>(defaultValue); 9 + const [isLoaded, setIsLoaded] = useState(false); 10 + 11 + useEffect(() => { 12 + const loadFromStorage = async () => { 13 + try { 14 + const stored = await storage.getItem(key); 15 + if (stored !== null) { 16 + setState(JSON.parse(stored)); 17 + } 18 + } catch (error) { 19 + console.error(`Failed to load ${key} from storage:`, error); 20 + } finally { 21 + setIsLoaded(true); 22 + } 23 + }; 24 + loadFromStorage(); 25 + }, [key]); 26 + 27 + const setStoredState = (value: T) => { 28 + setState(value); 29 + if (isLoaded) { 30 + storage.setItem(key, JSON.stringify(value)).catch((error) => { 31 + console.error(`Failed to save ${key} to storage:`, error); 32 + }); 33 + } 34 + }; 35 + 36 + return [state, setStoredState]; 37 + }
+8
js/components/src/hooks/useLivestreamInfo.ts
··· 9 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 + const stopIngest = usePlayerStore((x) => x.stopIngest); 12 13 13 14 const createStreamRecord = useCreateStreamRecord(); 14 15 ··· 54 55 } 55 56 }; 56 57 58 + // Stop the current broadcast 59 + const toggleStopStream = () => { 60 + console.log("Stopping stream..."); 61 + stopIngest(); 62 + }; 63 + 57 64 return { 58 65 ingest, 59 66 profile, ··· 67 74 setIngestStarting, 68 75 handleSubmit, 69 76 toggleGoLive, 77 + toggleStopStream, 70 78 }; 71 79 }
+5
js/components/src/index.tsx
··· 34 34 export * from "./components/chat/chat"; 35 35 export * from "./components/chat/chat-box"; 36 36 export * from "./components/chat/system-message"; 37 + export * from "./components/chat/update-stream-title-dialog"; 37 38 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 38 39 export * from "./lib/system-messages"; 39 40 41 + export * from "./components/stream-notification"; 42 + export * from "./lib/stream-notifications"; 43 + 44 + export * from "./utils/did"; 40 45 export * from "./utils/format-handle"; 41 46 42 47 export { DanmuOverlay } from "./components/danmu/danmu-overlay";
+136
js/components/src/lib/slash-commands/teleport.ts
··· 1 + import { PlaceStreamLiveTeleport, StreamplaceAgent } from "streamplace"; 2 + import { 3 + registerSlashCommand, 4 + SlashCommandHandler, 5 + SlashCommandResult, 6 + } from "../slash-commands"; 7 + 8 + export async function deleteTeleport( 9 + pdsAgent: StreamplaceAgent, 10 + userDID: string, 11 + uri: string, 12 + ) { 13 + const rkey = uri.split("/").pop(); 14 + if (!rkey) { 15 + throw new Error("No rkey found in teleport URI"); 16 + } 17 + return await pdsAgent.com.atproto.repo.deleteRecord({ 18 + repo: userDID, 19 + collection: "place.stream.live.teleport", 20 + rkey: rkey, 21 + }); 22 + } 23 + 24 + export function registerTeleportCommand( 25 + pdsAgent: StreamplaceAgent, 26 + userDID: string, 27 + setActiveTeleportUri?: (uri: string | null) => void, 28 + ) { 29 + const teleportHandler: SlashCommandHandler = async ( 30 + args, 31 + rawInput, 32 + ): Promise<SlashCommandResult> => { 33 + if (args.length === 0) { 34 + return { 35 + handled: true, 36 + error: "Usage: /teleport @handle.bsky.social [duration_seconds]", 37 + }; 38 + } 39 + 40 + let targetHandle = args[0]; 41 + 42 + if (targetHandle.startsWith("@")) { 43 + targetHandle = targetHandle.slice(1); 44 + } 45 + 46 + if (!targetHandle.includes(".")) { 47 + return { 48 + handled: true, 49 + error: "Invalid handle format. Expected: handle.bsky.social", 50 + }; 51 + } 52 + 53 + let countdownSeconds = 10; 54 + if (args.length > 1) { 55 + const parsedDuration = parseInt(args[1], 10); 56 + if (isNaN(parsedDuration)) { 57 + return { 58 + handled: true, 59 + error: "Countdown must be a number (seconds)", 60 + }; 61 + } 62 + if (parsedDuration < 5 || parsedDuration > 300) { 63 + return { 64 + handled: true, 65 + error: "Countdown must be between 5 seconds and 5 minutes", 66 + }; 67 + } 68 + countdownSeconds = parsedDuration; 69 + } 70 + 71 + let targetDID: string; 72 + try { 73 + const resolution = await pdsAgent.resolveHandle({ 74 + handle: targetHandle, 75 + }); 76 + targetDID = resolution.data.did; 77 + } catch (err) { 78 + return { 79 + handled: true, 80 + error: `Could not resolve handle: ${targetHandle}`, 81 + }; 82 + } 83 + 84 + if (targetDID === userDID) { 85 + return { 86 + handled: true, 87 + error: "You cannot teleport to yourself", 88 + }; 89 + } 90 + 91 + const startsAt = new Date( 92 + Date.now() + countdownSeconds * 1000, 93 + ).toISOString(); 94 + 95 + const record: PlaceStreamLiveTeleport.Record = { 96 + $type: "place.stream.live.teleport", 97 + streamer: targetDID, 98 + startsAt, 99 + countdownSeconds, 100 + }; 101 + 102 + try { 103 + const result = await pdsAgent.com.atproto.repo.createRecord({ 104 + repo: userDID, 105 + collection: "place.stream.live.teleport", 106 + record, 107 + }); 108 + 109 + // store the URI in the livestream store 110 + if (setActiveTeleportUri) { 111 + setActiveTeleportUri(result.data.uri); 112 + } 113 + 114 + return { handled: true }; 115 + } catch (err) { 116 + return { 117 + handled: true, 118 + error: err instanceof Error ? err.message : "Failed to create teleport", 119 + }; 120 + } 121 + }; 122 + 123 + registerSlashCommand({ 124 + name: "teleport", 125 + description: "Start a teleport to another streamer", 126 + usage: "/teleport @handle.bsky.social [duration_seconds]", 127 + handler: teleportHandler, 128 + }); 129 + 130 + registerSlashCommand({ 131 + name: "tp", 132 + description: "Start a teleport to another streamer (alias for /teleport)", 133 + usage: "/tp @handle.bsky.social [duration_seconds]", 134 + handler: teleportHandler, 135 + }); 136 + }
+65
js/components/src/lib/slash-commands.ts
··· 1 + export interface SlashCommandResult { 2 + handled: boolean; 3 + error?: string; 4 + } 5 + 6 + export type SlashCommandHandler = ( 7 + args: string[], 8 + rawInput: string, 9 + ) => Promise<SlashCommandResult>; 10 + 11 + export interface SlashCommand { 12 + name: string; 13 + description: string; 14 + usage: string; 15 + handler: SlashCommandHandler; 16 + } 17 + 18 + const commands = new Map<string, SlashCommand>(); 19 + 20 + export function registerSlashCommand(command: SlashCommand) { 21 + commands.set(command.name, command); 22 + } 23 + 24 + export function unregisterSlashCommand(name: string) { 25 + commands.delete(name); 26 + } 27 + 28 + export async function handleSlashCommand( 29 + input: string, 30 + ): Promise<SlashCommandResult> { 31 + const trimmed = input.trim(); 32 + if (!trimmed.startsWith("/")) { 33 + return { handled: false }; 34 + } 35 + 36 + const parts = trimmed.slice(1).split(/\s+/); 37 + const commandName = parts[0]?.toLowerCase(); 38 + const args = parts.slice(1); 39 + 40 + if (!commandName) { 41 + return { handled: false }; 42 + } 43 + 44 + const command = commands.get(commandName); 45 + if (!command) { 46 + return { 47 + // for now - return false 48 + handled: false, 49 + error: `Unknown command: /${commandName}`, 50 + }; 51 + } 52 + 53 + try { 54 + return await command.handler(args, trimmed); 55 + } catch (err) { 56 + return { 57 + handled: true, 58 + error: err instanceof Error ? err.message : "Command failed", 59 + }; 60 + } 61 + } 62 + 63 + export function getRegisteredCommands(): SlashCommand[] { 64 + return Array.from(commands.values()); 65 + }
+51
js/components/src/lib/stream-notifications.ts
··· 1 + import React from "react"; 2 + import { streamNotification } from "../components/stream-notification"; 3 + import { TeleportNotification } from "../components/stream-notification/teleport-notification"; 4 + 5 + export const StreamNotifications = { 6 + teleport: (params: { 7 + targetHandle: string; 8 + targetDID: string; 9 + countdown: number; 10 + canCancel: boolean; 11 + onDismiss?: (reason?: "user" | "auto") => void; 12 + }) => { 13 + streamNotification.show({ 14 + id: "teleport", 15 + render: (isExiting, onDismiss, startTime) => { 16 + return React.createElement(TeleportNotification, { 17 + targetHandle: params.targetHandle, 18 + countdown: params.countdown, 19 + canCancel: params.canCancel, 20 + startTime: startTime, 21 + onDismiss: onDismiss, 22 + }); 23 + }, 24 + duration: 0, // manually dismissed by countdown or user cancel 25 + variant: "warning", 26 + onDismiss: params.onDismiss, 27 + }); 28 + }, 29 + 30 + teleportCancelled: () => { 31 + streamNotification.hide("teleport"); 32 + }, 33 + 34 + teleportNow: (targetHandle: string) => { 35 + streamNotification.show({ 36 + id: "teleport-now", 37 + message: `Teleporting to @${targetHandle}...`, 38 + duration: 2, 39 + variant: "info", 40 + }); 41 + }, 42 + 43 + activate: (message: string) => { 44 + streamNotification.show({ 45 + id: "stream-activate", 46 + message: message, 47 + duration: 3, 48 + variant: "info", 49 + }); 50 + }, 51 + };
+52 -2
js/components/src/lib/system-messages.ts
··· 4 4 stream_start = "stream_start", 5 5 stream_end = "stream_end", 6 6 notification = "notification", 7 + command_error = "command_error", 7 8 } 8 9 9 10 export interface SystemMessageMetadata { ··· 22 23 * @param metadata Optional metadata for the message 23 24 * @returns A properly formatted ChatMessageViewHydrated object 24 25 */ 26 + let systemMessageCounter = 0; 27 + 25 28 export const createSystemMessage = ( 26 29 type: SystemMessageType, 27 30 text: string, ··· 29 32 date: Date = new Date(), 30 33 ): ChatMessageViewHydrated => { 31 34 const now = date; 35 + const uniqueId = `${now.getTime()}-${systemMessageCounter++}`; 32 36 33 37 return { 34 - uri: `at://did:sys:system/place.stream.chat.message/${now.getTime()}`, 35 - cid: `system-${now.getTime()}`, 38 + uri: `at://did:sys:system/place.stream.chat.message/${uniqueId}`, 39 + cid: `system-${uniqueId}`, 36 40 author: { 37 41 did: "did:sys:system", 38 42 handle: type, // Use handle to specify the type of system message ··· 73 77 { duration }, 74 78 ), 75 79 80 + teleportArrival: ( 81 + streamerName: string, 82 + streamerDid: string, 83 + count: number, 84 + chatProfile?: any, 85 + ): ChatMessageViewHydrated => { 86 + const text = 87 + count > 0 88 + ? `${count} viewer${count !== 1 ? "s" : ""} teleported from ${streamerName}'s stream! Say hi!` 89 + : `Someone teleported from ${streamerName}'s stream! Say hi!`; 90 + const message = createSystemMessage(SystemMessageType.notification, text, { 91 + streamerName, 92 + count, 93 + }); 94 + 95 + // create a mention facet for the streamer name so it gets colored using existing mention rendering 96 + if (chatProfile && streamerDid) { 97 + const nameStart = text.indexOf(streamerName); 98 + 99 + // encode byte positions 100 + const encoder = new TextEncoder(); 101 + const byteStart = encoder.encode(text.substring(0, nameStart)).length; 102 + const byteEnd = byteStart + encoder.encode(streamerName).length; 103 + 104 + message.record.facets = [ 105 + { 106 + index: { 107 + byteStart, 108 + byteEnd, 109 + }, 110 + features: [ 111 + { 112 + $type: "app.bsky.richtext.facet#mention", 113 + did: streamerDid, 114 + }, 115 + ], 116 + }, 117 + ]; 118 + } 119 + 120 + return message; 121 + }, 122 + 76 123 notification: (message: string): ChatMessageViewHydrated => 77 124 createSystemMessage(SystemMessageType.notification, message), 125 + 126 + commandError: (message: string): ChatMessageViewHydrated => 127 + createSystemMessage(SystemMessageType.command_error, message), 78 128 }; 79 129 80 130 /**
+11 -11
js/components/src/lib/theme/tokens.ts
··· 337 337 }, 338 338 339 339 warning: { 340 - 50: "#fffbeb", 341 - 100: "#fef3c7", 342 - 200: "#fde68a", 343 - 300: "#fcd34d", 344 - 400: "#fbbf24", 345 - 500: "#f59e0b", 346 - 600: "#d97706", 347 - 700: "#b45309", 348 - 800: "#92400e", 349 - 900: "#78350f", 350 - 950: "#451a03", 340 + 50: "#fffaf0", 341 + 100: "#ffe6c7", 342 + 200: "#ffd99e", 343 + 300: "#ffcc75", 344 + 400: "#ffb94e", 345 + 500: "#ff9e1f", 346 + 600: "#e67e00", 347 + 700: "#cc6600", 348 + 800: "#998c00", 349 + 900: "#664200", 350 + 950: "#332900", 351 351 }, 352 352 353 353 // iOS system colors (adaptive)
+106 -3
js/components/src/livestream-provider/index.tsx
··· 1 - import React, { useContext, useRef } from "react"; 2 - import { LivestreamContext, makeLivestreamStore } from "../livestream-store"; 1 + import React, { useContext, useEffect, useRef } from "react"; 2 + import { useAvatars } from "../hooks"; 3 + import { deleteTeleport } from "../lib/slash-commands/teleport"; 4 + import { StreamNotifications } from "../lib/stream-notifications"; 5 + import { 6 + LivestreamContext, 7 + makeLivestreamStore, 8 + useLivestreamStore, 9 + } from "../livestream-store"; 10 + import { useDID, usePDSAgent } from "../streamplace-store"; 3 11 import { useLivestreamWebsocket } from "./websocket"; 4 12 5 13 export function LivestreamProvider({ 6 14 children, 7 15 src, 16 + onTeleport, 8 17 ignoreOuterContext = false, 9 18 }: { 10 19 children: React.ReactNode; 11 20 src: string; 21 + onTeleport?: (targetHandle: string, targetDID: string) => void; 12 22 ignoreOuterContext?: boolean; 13 23 }) { 14 24 const context = useContext(LivestreamContext); ··· 24 34 (window as any).livestreamStore = store; 25 35 return ( 26 36 <LivestreamContext.Provider value={{ store: store }}> 27 - <LivestreamPoller src={src}>{children}</LivestreamPoller> 37 + <LivestreamPoller src={src} onTeleport={onTeleport}> 38 + {children} 39 + </LivestreamPoller> 28 40 </LivestreamContext.Provider> 29 41 ); 30 42 } ··· 34 46 return <></>; 35 47 } 36 48 49 + export function TeleportWatcher({ 50 + onTeleport, 51 + }: { 52 + onTeleport?: (targetHandle: string, targetDID: string) => void; 53 + }) { 54 + const activeTeleport = useLivestreamStore((state) => state.activeTeleport); 55 + const activeTeleportUri = useLivestreamStore( 56 + (state) => state.activeTeleportUri, 57 + ); 58 + const profile = useAvatars(activeTeleport ? [activeTeleport.streamer] : []); 59 + const livestreamProfile = useLivestreamStore((state) => state.profile); 60 + const pdsAgent = usePDSAgent(); 61 + const userDID = useDID(); 62 + const prevActiveTeleportRef = useRef(activeTeleport); 63 + 64 + useEffect(() => { 65 + if (!activeTeleport || !profile[activeTeleport.streamer]) return; 66 + 67 + const startsAt = new Date(activeTeleport.startsAt); 68 + const now = new Date(); 69 + const countdown = Math.max( 70 + 0, 71 + Math.floor((startsAt.getTime() - now.getTime()) / 1000), 72 + ); 73 + 74 + // resolve the DID to a handle for display 75 + const targetHandle = 76 + profile[activeTeleport.streamer]?.handle || activeTeleport.streamer; 77 + 78 + // check if the current user is the streamer of the current livestream 79 + const canCancel = livestreamProfile?.did === userDID; 80 + 81 + StreamNotifications.teleport({ 82 + targetHandle: targetHandle, 83 + targetDID: activeTeleport.streamer, 84 + countdown: countdown, 85 + canCancel: canCancel, 86 + onDismiss: async (reason) => { 87 + console.log( 88 + "๐Ÿ” StreamNotifications.onDismiss called with reason:", 89 + reason, 90 + ); 91 + if (reason === "user" && activeTeleportUri && pdsAgent && userDID) { 92 + try { 93 + await deleteTeleport(pdsAgent, userDID, activeTeleportUri); 94 + } catch (err) { 95 + console.error("Failed to delete teleport:", err); 96 + } 97 + } 98 + if (reason === "auto" && onTeleport) { 99 + console.log( 100 + "๐Ÿ” Calling onTeleport with:", 101 + targetHandle, 102 + activeTeleport.streamer, 103 + ); 104 + onTeleport(targetHandle, activeTeleport.streamer); 105 + } else if (reason === "auto" && !onTeleport) { 106 + console.log("๐Ÿ” onTeleport is not defined!"); 107 + } else if (reason === "auto") { 108 + console.log( 109 + "๐Ÿ” Reason is auto but teleport function not called for unknown reason", 110 + ); 111 + } 112 + }, 113 + }); 114 + }, [ 115 + activeTeleport, 116 + activeTeleportUri, 117 + profile, 118 + onTeleport, 119 + pdsAgent, 120 + userDID, 121 + ]); 122 + 123 + useEffect(() => { 124 + if ( 125 + prevActiveTeleportRef.current && 126 + !activeTeleport && 127 + !activeTeleportUri 128 + ) { 129 + StreamNotifications.teleportCancelled(); 130 + } 131 + prevActiveTeleportRef.current = activeTeleport; 132 + }, [activeTeleport, activeTeleportUri]); 133 + 134 + return <></>; 135 + } 136 + 37 137 export function LivestreamPoller({ 38 138 children, 39 139 src, 140 + onTeleport, 40 141 }: { 41 142 children: React.ReactNode; 42 143 src: string; 144 + onTeleport?: (targetHandle: string, targetDID: string) => void; 43 145 }) { 44 146 // Websocket watcher is a sibling instead of a parent to avoid 45 147 // re-rendering when the websocket does stuff 46 148 return ( 47 149 <> 48 150 <WebsocketWatcher src={src} /> 151 + <TeleportWatcher onTeleport={onTeleport} /> 49 152 {children} 50 153 </> 51 154 );
+4
js/components/src/livestream-store/livestream-state.tsx
··· 3 3 ChatMessageViewHydrated, 4 4 LivestreamViewHydrated, 5 5 PlaceStreamDefs, 6 + PlaceStreamLiveTeleport, 6 7 PlaceStreamModerationPermission, 7 8 PlaceStreamSegment, 8 9 } from "streamplace"; ··· 22 23 replyToMessage: ChatMessageViewHydrated | null; 23 24 streamKey: string | null; 24 25 setStreamKey: (key: string | null) => void; 26 + activeTeleport: PlaceStreamLiveTeleport.Record | null; 27 + activeTeleportUri: string | null; 28 + setActiveTeleportUri: (uri: string | null) => void; 25 29 websocketConnected: boolean; 26 30 hasReceivedSegment: boolean; 27 31 moderationPermissions: PlaceStreamModerationPermission.Record[];
+3
js/components/src/livestream-store/livestream-store.tsx
··· 22 22 authors: {}, 23 23 recentSegments: [], 24 24 problems: [], 25 + activeTeleport: null, 26 + activeTeleportUri: null, 27 + setActiveTeleportUri: (uri) => set({ activeTeleportUri: uri }), 25 28 websocketConnected: false, 26 29 hasReceivedSegment: false, 27 30 moderationPermissions: [],
+35 -54
js/components/src/livestream-store/websocket-consumer.tsx
··· 7 7 PlaceStreamChatMessage, 8 8 PlaceStreamDefs, 9 9 PlaceStreamLivestream, 10 - PlaceStreamModerationPermission, 10 + PlaceStreamLiveTeleport, 11 11 PlaceStreamSegment, 12 12 } from "streamplace"; 13 13 import { SystemMessages } from "../lib/system-messages"; 14 + import { formatHandleWithAt } from "../utils/format-handle"; 14 15 import { reduceChat } from "./chat"; 15 16 import { LivestreamState } from "./livestream-state"; 16 17 import { findProblems } from "./problems"; ··· 121 122 pendingHides: newPendingHides, 122 123 }; 123 124 state = reduceChat(state, [], [], [hiddenMessageUri]); 124 - } else if ( 125 - PlaceStreamModerationPermission.isRecord(message) || 126 - (message && 127 - typeof message === "object" && 128 - "$type" in message && 129 - (message as { $type?: string }).$type === 130 - "place.stream.moderation.permission") 131 - ) { 132 - // Handle moderation permission record updates 133 - // This can be a new permission or a deletion marker 134 - const permRecord = message as 135 - | PlaceStreamModerationPermission.Record 136 - | { deleted?: boolean; rkey?: string; streamer?: string }; 125 + } else if (PlaceStreamLiveTeleport.isRecord(message)) { 126 + const teleportRecord = message as PlaceStreamLiveTeleport.Record; 127 + state = { 128 + ...state, 129 + activeTeleport: teleportRecord, 130 + }; 131 + } else if (PlaceStreamLivestream.isTeleportArrival(message)) { 132 + // teleport has succeeded, we are now at the target stream 133 + const arrival = message as PlaceStreamLivestream.TeleportArrival; 137 134 138 - if ((permRecord as any).deleted) { 139 - // Handle deletion: clear permissions to trigger refetch 140 - // The useCanModerate hook will refetch and repopulate 135 + // add the teleporter's chat profile to the authors cache FIRST so mention rendering works 136 + if (arrival.chatProfile && arrival.source.did) { 141 137 state = { 142 138 ...state, 143 - moderationPermissions: [], 139 + authors: { 140 + ...state.authors, 141 + [arrival.source.did]: arrival.chatProfile, 142 + }, 144 143 }; 145 - } else { 146 - // Handle new/updated permission: add or update in the list 147 - // Use createdAt as a unique identifier since multiple records can exist for the same moderator 148 - // (e.g., one record with "ban" permission, another with "hide" permission) 149 - // Note: rkey would be ideal but isn't always present in the WebSocket message 150 - const newPerm = 151 - permRecord as PlaceStreamModerationPermission.Record & { 152 - rkey?: string; 153 - }; 154 - const existingIndex = state.moderationPermissions.findIndex((p) => { 155 - const pWithRkey = p as PlaceStreamModerationPermission.Record & { 156 - rkey?: string; 157 - }; 158 - // Prefer matching by rkey if available, fall back to createdAt 159 - if (newPerm.rkey && pWithRkey.rkey) { 160 - return pWithRkey.rkey === newPerm.rkey; 161 - } 162 - return ( 163 - p.moderator === newPerm.moderator && 164 - p.createdAt === newPerm.createdAt 165 - ); 166 - }); 144 + } 167 145 168 - let newPermissions: PlaceStreamModerationPermission.Record[]; 169 - if (existingIndex >= 0) { 170 - // Update existing record with same moderator AND createdAt 171 - newPermissions = [...state.moderationPermissions]; 172 - newPermissions[existingIndex] = newPerm; 173 - } else { 174 - // Add new record (could be a new record for an existing moderator with different permissions) 175 - newPermissions = [...state.moderationPermissions, newPerm]; 176 - } 146 + const systemMessage = SystemMessages.teleportArrival( 147 + formatHandleWithAt(arrival.source), 148 + arrival.source.did, 149 + arrival.viewerCount, 150 + arrival.chatProfile, 151 + ); 152 + // set proper times 153 + systemMessage.indexedAt = arrival.startsAt; 154 + systemMessage.record.createdAt = arrival.startsAt; 177 155 178 - state = { 179 - ...state, 180 - moderationPermissions: newPermissions, 181 - }; 182 - } 156 + state = reduceChat(state, [systemMessage], []); 157 + } else if (PlaceStreamLivestream.isTeleportCanceled(message)) { 158 + // teleport was canceled (deleted or denied) 159 + state = { 160 + ...state, 161 + activeTeleport: null, 162 + activeTeleportUri: null, 163 + }; 183 164 } 184 165 } 185 166 }
+3
js/components/src/player-store/player-state.tsx
··· 63 63 ingestAutoStart?: boolean; 64 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 65 66 + /** stop ingest process, again with a slight delay to allow UI to update */ 67 + stopIngest: () => void; 68 + 66 69 /** Timestamp (number) when ingest started, or null if not started */ 67 70 ingestStarted: number | null; 68 71
+17
js/components/src/player-store/player-store.tsx
··· 53 53 setIngestStarted: (timestamp: number | null) => 54 54 set(() => ({ ingestStarted: timestamp })), 55 55 56 + stopIngest: () => { 57 + set(() => ({ 58 + ingestLive: false, 59 + ingestConnectionState: "new", 60 + ingestStarted: null, 61 + })), 62 + setTimeout( 63 + () => 64 + set(() => ({ 65 + ingestLive: false, 66 + ingestConnectionState: "new", 67 + ingestStarted: null, 68 + })), 69 + 200, 70 + ); 71 + }, 72 + 56 73 fullscreen: false, 57 74 setFullscreen: (isFullscreen: boolean) => 58 75 set(() => ({ fullscreen: isFullscreen })),
+60 -1
js/components/src/streamplace-store/branding.tsx
··· 25 25 }); 26 26 }; 27 27 28 + const PropsInHeader = [ 29 + "siteTitle", 30 + "siteDescription", 31 + "primaryColor", 32 + "accentColor", 33 + "defaultStreamer", 34 + "mainLogo", 35 + "favicon", 36 + "sidebarBg", 37 + "legalLinks", 38 + ]; 39 + 40 + function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 + const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 + if (meta && meta.getAttribute("content")) { 44 + let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 + } 47 + 48 + return null; 49 + } 50 + 28 51 // hook to fetch broadcaster DID (unauthenticated) 29 52 export function useFetchBroadcasterDID() { 30 53 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 31 54 const store = getStreamplaceStoreFromContext(); 55 + 56 + // prefetch from meta records, if on web 57 + useEffect(() => { 58 + if (typeof window !== "undefined" && window.document) { 59 + try { 60 + const metaRecords = PropsInHeader.reduce( 61 + (acc, key) => { 62 + const meta = document.querySelector( 63 + `meta[name="internal-brand:${key}`, 64 + ); 65 + // hrmmmmmmmmmmmm 66 + if (meta && meta.getAttribute("content")) { 67 + let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 + } 70 + return acc; 71 + }, 72 + {} as Record<string, BrandingAsset>, 73 + ); 74 + 75 + console.log("Found meta records for broadcaster DID:", metaRecords); 76 + // filter out all non-text values, can get on second fetch? 77 + for (const key of Object.keys(metaRecords)) { 78 + if (metaRecords[key].mimeType != "text/plain") { 79 + delete metaRecords[key]; 80 + } 81 + } 82 + } catch (e) { 83 + console.warn("Failed to parse broadcaster DID from meta tags", e); 84 + } 85 + } 86 + }, []); 32 87 33 88 return useCallback(async () => { 34 89 try { ··· 140 195 141 196 // hook to get a specific branding asset by key 142 197 export function useBrandingAsset(key: string): BrandingAsset | undefined { 143 - return useStreamplaceStore((state) => state.branding?.[key]); 198 + return ( 199 + useStreamplaceStore((state) => state.branding?.[key]) || 200 + getMetaContent(key) || 201 + undefined 202 + ); 144 203 } 145 204 146 205 // convenience hook for main logo
+1
js/components/src/streamplace-store/index.tsx
··· 5 5 export * from "./stream"; 6 6 export * from "./streamplace-store"; 7 7 export * from "./user"; 8 + export * from "./xrpc";
+61
js/components/src/utils/did.ts
··· 1 + export interface DIDDocument { 2 + id: string; 3 + service?: Array<{ 4 + id: string; 5 + type?: string; 6 + serviceEndpoint?: string; 7 + }>; 8 + [key: string]: any; 9 + } 10 + 11 + export async function resolveDIDDocument(did: string): Promise<DIDDocument> { 12 + let didDocUrl: string; 13 + 14 + if (did.startsWith("did:web:")) { 15 + // For did:web, construct the URL directly 16 + const domain = did.replace("did:web:", "").replace(/:/g, "/"); 17 + didDocUrl = `https://${domain}/.well-known/did.json`; 18 + } else if (did.startsWith("did:plc:")) { 19 + // For did:plc, use plc.directory 20 + didDocUrl = `https://plc.directory/${did}`; 21 + } else { 22 + throw new Error(`Unsupported DID method: ${did}`); 23 + } 24 + 25 + const response = await fetch(didDocUrl); 26 + if (!response.ok) { 27 + throw new Error( 28 + `Failed to resolve DID document for ${did}: ${response.status}`, 29 + ); 30 + } 31 + 32 + return response.json(); 33 + } 34 + 35 + export function getPDSServiceEndpoint(didDoc: DIDDocument): string { 36 + const pdsService = didDoc.service?.find((s) => s.id === "#atproto_pds"); 37 + 38 + if (!pdsService?.serviceEndpoint) { 39 + throw new Error("No PDS service endpoint found in DID document"); 40 + } 41 + 42 + return pdsService.serviceEndpoint; 43 + } 44 + 45 + export async function getBlob( 46 + did: string, 47 + cid: string, 48 + didDoc?: DIDDocument, 49 + ): Promise<Blob> { 50 + const doc = didDoc || (await resolveDIDDocument(did)); 51 + const pdsEndpoint = getPDSServiceEndpoint(doc); 52 + 53 + const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 54 + 55 + const response = await fetch(blobUrl); 56 + if (!response.ok) { 57 + throw new Error(`Failed to fetch blob: ${response.status}`); 58 + } 59 + 60 + return response.blob(); 61 + }
+1 -1
js/config-react-native-webrtc/package.json
··· 1 1 { 2 2 "name": "@streamplace/config-react-native-webrtc", 3 - "version": "0.8.0", 3 + "version": "0.9.0", 4 4 "description": "react-native-webrtc config mod with some changes to make it useful for streamplace", 5 5 "scripts": { 6 6 "prepare": "tsc -p tsconfig.json",
+1 -1
js/desktop/package.json
··· 1 1 { 2 2 "name": "streamplace-desktop", 3 3 "productName": "streamplace-desktop", 4 - "version": "0.8.17", 4 + "version": "0.9.0", 5 5 "description": "Streamplace Desktop Application", 6 6 "main": ".webpack/main", 7 7 "scripts": {
+1 -1
js/dev-env/package.json
··· 2 2 "private": true, 3 3 "type": "module", 4 4 "name": "@atcute/internal-dev-env", 5 - "version": "0.8.0", 5 + "version": "0.9.0", 6 6 "dependencies": { 7 7 "@atproto/crypto": "^0.4.4", 8 8 "@atproto/identity": "^0.4.8",
-59
js/docs/README.md
··· 1 - # Starlight Starter Kit: Basics 2 - 3 - [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 - 5 - ``` 6 - pnpm create astro@latest -- --template starlight 7 - ``` 8 - 9 - [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 - [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 - [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) 12 - [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) 13 - 14 - > ๐Ÿง‘โ€๐Ÿš€ **Seasoned astronaut?** Delete this file. Have fun! 15 - 16 - ## ๐Ÿš€ Project Structure 17 - 18 - Inside of your Astro + Starlight project, you'll see the following folders and 19 - files: 20 - 21 - ``` 22 - . 23 - โ”œโ”€โ”€ public/ 24 - โ”œโ”€โ”€ src/ 25 - โ”‚ โ”œโ”€โ”€ assets/ 26 - โ”‚ โ”œโ”€โ”€ content/ 27 - โ”‚ โ”‚ โ”œโ”€โ”€ docs/ 28 - โ”‚ โ””โ”€โ”€ content.config.ts 29 - โ”œโ”€โ”€ astro.config.mjs 30 - โ”œโ”€โ”€ package.json 31 - โ””โ”€โ”€ tsconfig.json 32 - ``` 33 - 34 - Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. 35 - Each file is exposed as a route based on its file name. 36 - 37 - Images can be added to `src/assets/` and embedded in Markdown with a relative 38 - link. 39 - 40 - Static assets, like favicons, can be placed in the `public/` directory. 41 - 42 - ## ๐Ÿงž Commands 43 - 44 - All commands are run from the root of the project, from a terminal: 45 - 46 - | Command | Action | 47 - | :--------------------- | :----------------------------------------------- | 48 - | `pnpm install` | Installs dependencies | 49 - | `pnpm dev` | Starts local dev server at `localhost:4321` | 50 - | `pnpm build` | Build your production site to `./dist/` | 51 - | `pnpm preview` | Preview your build locally, before deploying | 52 - | `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` | 53 - | `pnpm astro -- --help` | Get help using the Astro CLI | 54 - 55 - ## ๐Ÿ‘€ Want to learn more? 56 - 57 - Check out [Starlightโ€™s docs](https://starlight.astro.build/), read 58 - [the Astro documentation](https://docs.astro.build), or jump into the 59 - [Astro Discord server](https://astro.build/chat).
+2
js/docs/_redirects
··· 1 + / /docs 301 2 + /docs/favicon.ico https://stream.place/favicon.ico 301
+68 -37
js/docs/astro.config.mjs
··· 1 1 // @ts-check 2 2 import starlight from "@astrojs/starlight"; 3 - import { defineConfig } from "astro/config"; 3 + import { defineConfig, passthroughImageService } from "astro/config"; 4 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 + import starlightSidebarSwipe from "starlight-sidebar-swipe"; 6 + import starlightSidebarTopics from "starlight-sidebar-topics"; 5 7 6 8 // https://astro.build/config 7 9 export default defineConfig({ 8 10 base: "/docs", 11 + image: { 12 + service: passthroughImageService(), 13 + }, 9 14 integrations: [ 10 15 starlight({ 11 16 title: "Streamplace Docs", ··· 27 32 src: "/src/assets/cube.png", 28 33 alt: "Streamplace Logo", 29 34 }, 35 + favicon: "/favicon.ico", 30 36 plugins: [ 37 + //starlightLinksValidator(), 38 + starlightSidebarSwipe(), 31 39 starlightOpenAPI([ 32 40 { 33 - base: "api", 41 + base: "/api", 34 42 label: "Related XRPC API endpoints", 35 43 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 36 44 sidebar: { ··· 41 49 }, 42 50 }, 43 51 ]), 44 - ], 45 - sidebar: [ 46 - { label: "โ† Back to Streamplace", link: "/../" }, 47 - { 48 - label: "How Streamplace Works (Blog)", 49 - link: "https://blog.stream.place/", 50 - attrs: { target: "_blank" }, 51 - }, 52 - { 53 - label: "Guides", 54 - items: [ 52 + starlightSidebarTopics( 53 + [ 55 54 { 56 - label: "Start Streaming", 57 - autogenerate: { directory: "guides/start-streaming" }, 55 + label: "For Streamers & Viewers", 56 + link: "/", 57 + icon: "open-book", 58 + items: [ 59 + { 60 + label: "Start Streaming", 61 + autogenerate: { directory: "guides/start-streaming" }, 62 + }, 63 + { 64 + label: "Features", 65 + autogenerate: { directory: "features" }, 66 + }, 67 + ], 58 68 }, 59 69 { 60 - label: "Installing Streamplace", 61 - autogenerate: { directory: "guides/installing" }, 70 + label: "For Developers", 71 + link: "/developers/", 72 + icon: "seti:config", 73 + id: "developers", 74 + items: [ 75 + { 76 + label: "Start Contributing", 77 + autogenerate: { directory: "guides/start-contributing" }, 78 + }, 79 + { 80 + label: "Installing Streamplace", 81 + autogenerate: { directory: "guides/installing" }, 82 + }, 83 + { 84 + label: "Video Metadata", 85 + autogenerate: { directory: "video-metadata" }, 86 + }, 87 + { 88 + label: "Components", 89 + autogenerate: { directory: "components" }, 90 + }, 91 + { 92 + label: "Localize Streamplace", 93 + autogenerate: { directory: "guides/localizing" }, 94 + }, 95 + ], 62 96 }, 63 97 { 64 - label: "Start Contributing", 65 - autogenerate: { directory: "guides/start-contributing" }, 98 + label: "API Reference", 99 + link: "/reference/", 100 + icon: "seti:json", 101 + id: "ref", 102 + items: [ 103 + { 104 + label: "Lexicon Reference", 105 + autogenerate: { directory: "lex-reference" }, 106 + }, 107 + ...openAPISidebarGroups, 108 + ], 66 109 }, 67 110 ], 68 - }, 69 - { 70 - label: "Features", 71 - autogenerate: { directory: "features" }, 72 - }, 73 - { 74 - label: "Video Metadata", 75 - autogenerate: { directory: "video-metadata" }, 76 - }, 77 - { 78 - label: "Components", 79 - autogenerate: { directory: "components" }, 80 - }, 81 - { 82 - label: "Lexicon Reference", 83 - autogenerate: { directory: "lex-reference" }, 84 - }, 85 - ...openAPISidebarGroups, 111 + { 112 + topics: { 113 + ref: ["/api", "/api/**/*"], 114 + }, 115 + }, 116 + ), 86 117 ], 87 118 }), 88 119 ],
+8 -2
js/docs/package.json
··· 1 1 { 2 2 "name": "streamplace-docs", 3 3 "type": "module", 4 - "version": "0.8.18", 4 + "version": "0.9.9", 5 5 "scripts": { 6 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 15 15 "@streamplace/app": "workspace:*", 16 16 "astro": "^5.6.1", 17 17 "sharp": "^0.32.5", 18 + "starlight-links-validator": "^0.19.2", 18 19 "starlight-openapi": "^0.17.0", 19 20 "starlight-openapi-rapidoc": "^0.8.1-beta", 21 + "starlight-sidebar-swipe": "^0.1.1", 20 22 "streamplace": "workspace:*" 21 - } 23 + }, 24 + "devDependencies": { 25 + "starlight-sidebar-topics": "^0.6.2" 26 + }, 27 + "private": true 22 28 }
-1
js/docs/public/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
+60
js/docs/src/components/HelpDesk.astro
··· 1 + --- 2 + import { Card, CardGrid } from "@astrojs/starlight/components"; 3 + 4 + interface Props { 5 + searchPlaceholder?: string; 6 + } 7 + --- 8 + 9 + <div class="helpdesk"> 10 + 11 + <h2>How can we help?</h2> 12 + <p>Search the knowledge base, or check out topics below.</p> 13 + 14 + <CardGrid> 15 + <Card title="Getting Started" icon="rocket"> 16 + <p>New to Streamplace? Start here to set up your first stream.</p> 17 + <ul> 18 + <li><a href="/docs/guides/start-streaming/quick-start">Quick start guide</a></li> 19 + <li><a href="/docs/guides/start-streaming/obs">Stream with OBS</a></li> 20 + </ul> 21 + </Card> 22 + 23 + <Card title="Developers & Self-Hosters" icon="laptop"> 24 + <p>Building with Streamplace or running your own node?</p> 25 + <ul> 26 + <li><a href="/docs/developers">Developer documentation</a></li> 27 + </ul> 28 + </Card> 29 + </CardGrid> 30 + </div> 31 + 32 + <style> 33 + .helpdesk { 34 + margin: 0 auto; 35 + } 36 + 37 + .helpdesk-search { 38 + margin-bottom: 2rem; 39 + } 40 + 41 + .search-input { 42 + width: 100%; 43 + padding: 1rem 1.5rem; 44 + font-size: 1.125rem; 45 + border: 2px solid var(--sl-color-gray-5); 46 + border-radius: 0.5rem; 47 + background: var(--sl-color-bg); 48 + color: var(--sl-color-text); 49 + transition: border-color 0.2s; 50 + } 51 + 52 + .search-input:focus { 53 + outline: none; 54 + border-color: var(--sl-color-accent); 55 + } 56 + 57 + .helpdesk h2 { 58 + margin-bottom: 1.5rem; 59 + } 60 + </style>
+1 -2
js/docs/src/content/docs/components/custom_ui.md
··· 1 1 --- 2 2 title: Creating your own player UI 3 - description: 4 - How to set up your player UI with components from @streamplace/components. 3 + description: How to set up your player UI with components from @streamplace/components. 5 4 --- 6 5 7 6 # Building a Custom Player UI
+40
js/docs/src/content/docs/developers.mdx
··· 1 + --- 2 + title: Developers & Self-Hosters 3 + description: Build with Streamplace or run your own infrastructure. 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + ## Learn how to deploy, or contribute to Streamplace. 10 + 11 + <br /> 12 + 13 + <CardGrid stagger> 14 + <Card title="Building an Application" icon="laptop"> 15 + Integrate live video into your project. - [API 16 + reference](/docs/lex-reference/place-stream-defs) - [Our component 17 + library](/docs/components/custom_ui/) 18 + </Card> 19 + 20 + {" "} 21 + 22 + <Card title="Self-Hosting" icon="seti:config"> 23 + Run your own Streamplace infrastructure. - [Installation 24 + guide](/docs/guides/installing/installing-streamplace) 25 + </Card> 26 + 27 + {" "} 28 + 29 + <Card title="Contributing" icon="github"> 30 + Help improve Streamplace. - [Development 31 + setup](/docs/guides/streamplace-dev-setup) - [Video 32 + signing](/docs/video-metadata/intro/) 33 + </Card> 34 + 35 + <Card title="Support & Community" icon="information"> 36 + Get help and connect with other developers. - [GitHub 37 + issues](https://github.com/streamplace/streamplace/issues) - [Discord 38 + community](https://discord.stream.place) 39 + </Card> 40 + </CardGrid>
+3 -1
js/docs/src/content/docs/features/danmu.md
··· 3 3 description: Add flying bullet-style chat comments to the player, or your stream 4 4 --- 5 5 6 - :::note This feature is experimental and may change in future releases. ::: 6 + :::note 7 + This feature is experimental and may change in future releases. 8 + ::: 7 9 8 10 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (ๅผนๅน•, 9 11 "bullet curtain") is a comment style where messages fly across the video
+27
js/docs/src/content/docs/features/embed.md
··· 1 + --- 2 + title: Embedding your livestream 3 + description: How to embed your livestream on your website, blog, etc. 4 + --- 5 + 6 + Streamplace provides an easy way to embed your livestream on any website or 7 + blog. 8 + 9 + You can access the embedded livestream page by putting `/embed` in the URL of 10 + your livestream. For example, if your livestream URL is 11 + `https://stream.place/iame.li`, the embed URL will be 12 + `https://stream.place/embed/iame.li`. 13 + 14 + You can use the following HTML snippet to embed your livestream: 15 + 16 + ```html 17 + <iframe 18 + src="https://stream.place/embed/your-handle" 19 + width="560" 20 + height="315" 21 + frameborder="0" 22 + allowfullscreen 23 + ></iframe> 24 + ``` 25 + 26 + Alternatively, you can use the share sheet located on your livestream page. 27 + Click the "Share" button, and you'll find the embed code ready to copy.
+52
js/docs/src/content/docs/features/multistreaming.md
··· 1 + --- 2 + title: Multistreaming 3 + description: Forward your Streamplace stream to other providers. 4 + --- 5 + 6 + :::note 7 + This guide isn't about setting up Streamplace as an OBS destination. See [OBS Multistreaming to Streamplace](/docs/guides/start-streaming/obs-multistreaming/) for information on that. 8 + ::: 9 + 10 + Multistreaming lets you forward your Streamplace stream to multiple platforms at the same time. Instead of streaming only to Streamplace, you can forward your stream to any platform that accepts RTMP input. 11 + 12 + ## Setting up multistream targets 13 + 14 + 1. Go to **Settings** > **Streaming** > **Multistream Targets** 15 + 2. Click **Create Multistream Target** 16 + 3. Enter the RTMP or RTMPS URL from your destination platform 17 + 4. Optionally give it a name to identify it later 18 + 5. Click **Create** 19 + 20 + ### Finding your multistream URL 21 + 22 + Different platforms will provide their own RTMP URLs. Some common examples: 23 + 24 + - **YouTube Live**: Format `rtmp://a.rtmp.youtube.com/live2/your-stream-key` 25 + - Find your stream key at https://studio.youtube.com/channel/UC/livestreaming (click the copy icon in the top right corner of the 'connect your encoder to go live' box) 26 + - **Twitch**: Format `rtmp://usw20.contribute.live-video.net/app/your-stream-key` 27 + - You can get a valid RTMPS url at https://help.twitch.tv/s/twitch-ingest-recommendation 28 + - Find your stream key at https://dashboard.twitch.tv/settings/stream (your 'primary stream key') 29 + 30 + :::note 31 + Your stream key should automatically be hidden once you confirm. Make sure you've entered it correctly! 32 + ::: 33 + 34 + ## Managing targets during a stream 35 + 36 + When you're live, you can see all your multistream targets on the Live Dashboard with their current status: 37 + 38 + - **Green (Active)**: Successfully streaming to this target 39 + - **Yellow (Pending)**: Connecting to this target 40 + - **Red (Error)**: Connection failed; check your URL and credentials 41 + - **Gray (Inactive)**: This target is disabled 42 + 43 + You can toggle any target on or off with the switch next to its name. Changes take effect immediately. 44 + 45 + ## Limits 46 + 47 + - **Maximum targets**: 100 total per account 48 + - **Maximum active targets**: 5 simultaneous streams 49 + 50 + ### Credits 51 + 52 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+83
js/docs/src/content/docs/features/webhooks.md
··· 1 + --- 2 + title: Discord Webhooks 3 + description: Configure Discord webhooks for livestream announcements and chat 4 + sidebar: 5 + order: 30 6 + --- 7 + 8 + Streamplace supports Discord webhooks for receiving livestream 9 + notifications and chat messages. You can create, manage, and configure webhooks 10 + to customize how events are delivered to your Discord channels. 11 + 12 + ## Webhook Events 13 + 14 + You can configure webhooks to listen for specific events. For right now, the 15 + following events are supported: 16 + 17 + - `Chat`: Triggered when a chat message is sent. 18 + - `Livestream`: Triggered when a livestream starts. 19 + 20 + ## Creating a Webhook 21 + 22 + To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 + navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 + fields are required: 25 + 26 + - Name: Webhook URL. For example, 27 + `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 + - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 + `Livestream Started`). `Livestream Started` is pre-checked by default. 30 + 31 + We'd recommend also filling out these optional fields: 32 + 33 + - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 + you can remember. 35 + - Description: A description of what this webhook is for (e.g., "Sends 36 + livestream start notifications to Discord channel"). 37 + - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 + "[Streamplace] "). Will apply to both Chat and Livestream events! 39 + - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 + live!"). Will apply to both Chat and Livestream events! 41 + - Text replacements: A list of text replacements to apply to chat messages sent 42 + by this webhook. Each replacement consists of a "from" string and a "to" 43 + string. For example, you could replace all instances of "foo" with "bar". 44 + 45 + After filling out the form, click "Create" to save your webhook. You should see 46 + it listed in the "Webhooks" section. 47 + 48 + ## Updating a Webhook 49 + 50 + To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 + navigate to the "Webhooks" section. Find the webhook you want to update and 52 + click on the "pen" icon next to it. This will open the webhook edit form, where 53 + you can modify the fields as needed. After making your changes, click "Update" 54 + to save your changes. 55 + 56 + ## Deleting a Webhook 57 + 58 + To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 + navigate to the "Webhooks" section. Find the webhook you want to delete and 60 + click on the "trash" icon next to it. A confirmation dialog will appear; click 61 + "Delete" to confirm. The webhook will be removed from the list. 62 + 63 + ## Recommendations 64 + 65 + We'd recommend: 66 + 67 + - Creating separate Discord channels for livestream notifications and chat 68 + messages to keep them organized. 69 + - If you want to have one webhook for both chat and livestream events, you can 70 + create multiple webhooks with the same URL but different event subscriptions 71 + and prefixes/suffixes/replacements. 72 + - Testing your webhook by starting a livestream or sending a chat message to 73 + ensure that notifications are being sent correctly. 74 + 75 + ## API Documentation 76 + 77 + See these endpoint pages: 78 + 79 + - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 + - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 + - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 + - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 + - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
+1 -2
js/docs/src/content/docs/guides/start-contributing/styling-quick-reference.md
··· 1 1 --- 2 2 title: ZeroCSS Quick Reference 3 - description: 4 - Quick reference for Streamplace ZeroCSS - common patterns and utilities. 3 + description: Quick reference for Streamplace ZeroCSS - common patterns and utilities. 5 4 sidebar: 6 5 order: 31 7 6 ---
-83
js/docs/src/content/docs/guides/start-streaming/discord-hooks.md
··· 1 - --- 2 - title: Discord Webhooks 3 - description: Configure Discord webhooks for livestream announcements and chat 4 - sidebar: 5 - order: 30 6 - --- 7 - 8 - Streamplace supports Discord webhook integration for receiving livestream 9 - notifications and chat messages. You can create, manage, and configure webhooks 10 - to customize how events are delivered to your Discord channels. 11 - 12 - ## Webhook Events 13 - 14 - You can configure webhooks to listen for specific events. For right now, the 15 - following events are supported: 16 - 17 - - `Chat`: Triggered when a chat message is sent. 18 - - `Livestream`: Triggered when a livestream starts. 19 - 20 - ## Creating a Webhook 21 - 22 - To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 - navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 - fields are required: 25 - 26 - - Name: Webhook URL. For example, 27 - `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 - - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 - `Livestream Started`). `Livestream Started` is pre-checked by default. 30 - 31 - We'd recommend also filling out these optional fields: 32 - 33 - - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 - you can remember. 35 - - Description: A description of what this webhook is for (e.g., "Sends 36 - livestream start notifications to Discord channel"). 37 - - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 - "[Streamplace] "). Will apply to both Chat and Livestream events! 39 - - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 - live!"). Will apply to both Chat and Livestream events! 41 - - Text replacements: A list of text replacements to apply to chat messages sent 42 - by this webhook. Each replacement consists of a "from" string and a "to" 43 - string. For example, you could replace all instances of "foo" with "bar". 44 - 45 - After filling out the form, click "Create" to save your webhook. You should see 46 - it listed in the "Webhooks" section. 47 - 48 - ## Updating a Webhook 49 - 50 - To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 - navigate to the "Webhooks" section. Find the webhook you want to update and 52 - click on the "pen" icon next to it. This will open the webhook edit form, where 53 - you can modify the fields as needed. After making your changes, click "Update" 54 - to save your changes. 55 - 56 - ## Deleting a Webhook 57 - 58 - To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 - navigate to the "Webhooks" section. Find the webhook you want to delete and 60 - click on the "trash" icon next to it. A confirmation dialog will appear; click 61 - "Delete" to confirm. The webhook will be removed from the list. 62 - 63 - ## Recommendations 64 - 65 - We'd recommend: 66 - 67 - - Creating separate Discord channels for livestream notifications and chat 68 - messages to keep them organized. 69 - - If you want to have one webhook for both chat and livestream events, you can 70 - create multiple webhooks with the same URL but different event subscriptions 71 - and prefixes/suffixes/replacements. 72 - - Testing your webhook by starting a livestream or sending a chat message to 73 - ensure that notifications are being sent correctly. 74 - 75 - ## API Documentation 76 - 77 - See these endpoint pages: 78 - 79 - - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 - - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 - - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 - - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 - - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
-27
js/docs/src/content/docs/guides/start-streaming/embed.md
··· 1 - --- 2 - title: Embedding your livestream 3 - description: How to embed your livestream on your website, blog, etc. 4 - --- 5 - 6 - Streamplace provides an easy way to embed your livestream on any website or 7 - blog. 8 - 9 - You can access the embedded livestream page by putting `/embed` in the URL of 10 - your livestream. For example, if your livestream URL is 11 - `https://stream.place/iame.li`, the embed URL will be 12 - `https://stream.place/embed/iame.li`. 13 - 14 - You can use the following HTML snippet to embed your livestream: 15 - 16 - ```html 17 - <iframe 18 - src="https://stream.place/embed/your-handle" 19 - width="560" 20 - height="315" 21 - frameborder="0" 22 - allowfullscreen 23 - ></iframe> 24 - ``` 25 - 26 - Alternatively, you can use the share sheet located on your livestream page. 27 - Click the "Share" button, and you'll find the embed code ready to copy.
+7 -1
js/docs/src/content/docs/guides/start-streaming/obs-multistreaming.md
··· 1 1 --- 2 - title: OBS Multistreaming with Streamplace 2 + title: OBS Multistreaming to Streamplace 3 3 description: 4 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 5 obs-multi-rtmp plugin. 6 6 sidebar: 7 7 order: 20 8 8 --- 9 + 10 + :::note 11 + This guide is not about the multistreaming feature. Check 12 + [the multistreaming guide](/docs/features/multistreaming) out for more 13 + information. 14 + ::: 9 15 10 16 This guide explains how to configure Open Broadcaster Software (OBS) for 11 17 simultaneous streaming to Streamplace and other platforms using the
+21 -2
js/docs/src/content/docs/guides/start-streaming/obs.md
··· 58 58 - Audio Encoder: 59 59 - For `RTMP`, choose an appropriate AAC encoder. 60 60 - For `WHIP`, use `ffmpeg_opus`. 61 + - If you are using a server that supports the SRT protocol (e.g. 62 + multistreaming via NGINX) please check below for an example config. 61 63 - Video Encoder: _(Select appropriate encoder, e.g. libx264/nvenc_h264)_ 62 64 63 65 #### 2e. Suggested Video Encoder Settings 64 66 67 + - Video Encoder: x264/h264 (**must** be an x/h.264 encoder) 65 68 - Rate Control: `CBR` 66 - - Keyframe Interval: `1s` 69 + - Keyframe Interval: `1s` (or anything less than once every ~7s) 70 + - This is _one keyframe per second_ 71 + - In some situations (e.g. 'keyframe interval (**frames**)'), this should be 72 + set to your FPS. 67 73 - x264 Options: `bframes=0` 68 - - If available, there also may be a 'no bframes' checkbox which should be 74 + - If available, there also may be a 'bframes' checkbox which should **NOT** be 69 75 checked 70 76 77 + :::caution 78 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 79 + ::: 80 + 71 81 ### 3. Announce your stream 72 82 73 83 1. Once you're live, go back to the live dashboard. ··· 85 95 86 96 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 87 97 98 + Alternatively, you can 99 + [multistream through Streamplace itself.](/docs/features/multistreaming) 100 + 88 101 ## Best Practices 89 102 90 103 - Test your stream settings before going live ··· 96 109 ## Additional Resources 97 110 98 111 - [OBS Official Documentation](https://obsproject.com/docs/) 112 + 113 + ### Example Settings 114 + 115 + ![SRT settings in OBS.](srt.png "OBS SRT Settings") 116 + 117 + > Multistreaming via a server that supports the SRT protocol
+73
js/docs/src/content/docs/guides/start-streaming/quick-start.md
··· 1 + --- 2 + title: Quick Start 3 + description: Get up and streaming on Streamplace quickly. 4 + sidebar: 5 + order: 1 6 + --- 7 + 8 + This guide gets you from zero to streaming. If you get stuck, check out the full [OBS setup guide](/docs/guides/start-streaming/obs). 9 + 10 + :::tip 11 + You will want to check out our [community guidelines](https://blog.stream.place/3mcqwibo4ks2w) first for guidance on what you can and cannot do on Streamplace. 12 + ::: 13 + 14 + ## So, what is Streamplace? 15 + 16 + Streamplace is a video streaming service built on top of the AT Protocol (Authenticated Transfer Protocol), the same protocol Bluesky is built on. 17 + 18 + ## Step 1: Create your account 19 + 20 + 1. Go to [stream.place](https://stream.place) 21 + 2. Click "Sign in" in the top right. 22 + 3. Use your Atmosphere credentials to log in (ex. your Bluesky handle) 23 + - You'll need to use your actual password here - we're using OAuth so you enter your password on your PDS. We do not receive your password at all. 24 + 4. You're done! Your stream profile is live at `stream.place/your-handle` 25 + 26 + ## Step 2: Get your stream key 27 + 28 + 1. Click **Live Dashboard** (or go to [stream.place/dashboard](https://stream.place/dashboard)) 29 + 2. Click **Stream from OBS** 30 + 3. Click **Generate Stream Key** 31 + 4. Your key is copied to clipboard automatically 32 + 33 + Keep this key private. It's like a password, but for your stream. 34 + 35 + ## Step 3: Configure OBS 36 + 37 + Open OBS and go to **Settings โ†’ Stream**: 38 + 39 + - **Service**: `Custom...` 40 + - **Server**: `rtmps://stream.place:1935/live` 41 + - **Stream Key**: Paste what you copied in Step 2 42 + 43 + Then go to **Settings โ†’ Output โ†’ Streaming**: 44 + 45 + - **Video Encoder**: `libx264` (or `NVIDIA NVENC H.264` if you have an NVIDIA GPU) 46 + - **Rate Control**: `CBR` 47 + - **Bitrate**: `6000` Kbps (adjust down if you drop frames) 48 + - **Keyframe Interval**: `1` 49 + - **x264 Options**: `bframes=0`. If there's a 'bframes' option, you'll want to have that at '0' or unchecked. 50 + 51 + :::caution 52 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 53 + ::: 54 + 55 + ## Step 4: Go live 56 + 57 + 1. In OBS, click **Start Streaming** 58 + 2. Go back to the Live Dashboard at stream.place 59 + 3. Fill in your stream title and optionally pick a thumbnail8 60 + 4. If needed, turn on content warnings. ("Metadata" tab in Stream Settings) 61 + 5. Click **Announce Livestream** 62 + 6. Your stream is now live and visible to the world! 63 + 64 + ## Next steps 65 + 66 + - **Customize your chat**: Change your name color in Settings > Account 67 + - **Stream to other platforms too**: Set your Twitch/YouTube URLs in Settings > Multistream Targets to push your stream there automatically. See the [Multistreaming guide](/docs/features/multistreaming) for more information 68 + - **Improve stream quality**: See the [OBS guide](/docs/guides/start-streaming/obs) for encoder settings and troubleshooting 69 + - **Join the Discord!**: If you need any help, or just want to chat, check out our discord at https://discord.stream.place. 70 + 71 + ### Credits 72 + 73 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s excellent [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
js/docs/src/content/docs/guides/start-streaming/srt.png

This is a binary file and will not be displayed.

+2 -32
js/docs/src/content/docs/index.mdx
··· 2 2 title: Welcome to Streamplace! 3 3 description: Begin your development journey with the Streamplace documentation. 4 4 template: doc 5 - hero: 6 - tagline: Solve live video for your project with Streamplace. 7 - image: 8 - file: ../../assets/cube.png 9 - alt: Streamplace logo. A pink 3d box viewed from a top corner. 10 - actions: 11 - - text: Get Started 12 - link: /docs/guides/start-streaming/obs 13 - icon: right-arrow 14 - - text: Visit Streamplace 15 - link: / 16 - icon: external 17 - variant: minimal 18 5 --- 19 6 20 - import { Card, CardGrid } from "@astrojs/starlight/components"; 21 - 22 - ## Next Steps 7 + import HelpDesk from "../../components/HelpDesk.astro"; 23 8 24 - <CardGrid> 25 - <Card title="Read the Docs" icon="open-book"> 26 - Learn how to start streaming with 27 - [Streamplace](/docs/guides/start-streaming/obs). 28 - </Card> 29 - <Card title="Install Streamplace" icon="download"> 30 - [Run your own Streamplace 31 - node](/docs/guides/installing/installing-streamplace). 32 - </Card> 33 - <Card title="API Reference" icon="document"> 34 - Explore the [Lexicon API reference](/docs/lex-reference/place-stream-defs). 35 - </Card> 36 - <Card title="Developer Setup" icon="setting"> 37 - Set up your [development environment](/docs/guides/streamplace-dev-setup). 38 - </Card> 39 - </CardGrid> 9 + <HelpDesk />
+2 -1
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getblob.md
··· 28 28 - **Description:** Raw blob data with appropriate content-type 29 29 - **Schema:** 30 30 31 - _Schema not defined._ **Possible Errors:** 31 + _Schema not defined._ 32 + **Possible Errors:** 32 33 33 34 - `BrandingNotFound`: The requested branding asset does not exist 34 35
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-origin.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Record indicating a livestream is published and available for replication at a 17 - given address. By convention, the record key is streamer::server 16 + Record indicating a livestream is published and available for replication at a given address. By convention, the record key is streamer::server 18 17 19 18 **Record Key:** `any` 20 19
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-syndication.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Record created by a Streamplace broadcaster to indicate that they will be 17 - replicating a livestream. NYI 16 + Record created by a Streamplace broadcaster to indicate that they will be replicating a livestream. NYI 18 17 19 18 **Record Key:** `tid` 20 19
+99
js/docs/src/content/docs/lex-reference/live/place-stream-live-denyteleport.md
··· 1 + --- 2 + title: place.stream.live.denyTeleport 3 + description: Reference for the place.stream.live.denyTeleport 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 + Deny an incoming teleport request. 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 + | `uri` | `string` | โœ… | The URI of the teleport record to deny. | Format: `at-uri` | 30 + 31 + **Output:** 32 + 33 + - **Encoding:** `application/json` 34 + - **Schema:** 35 + 36 + **Schema Type:** `object` 37 + 38 + | Name | Type | Req'd | Description | Constraints | 39 + | --------- | --------- | ----- | --------------------------------------------- | ----------- | 40 + | `success` | `boolean` | โœ… | Whether the teleport was successfully denied. | | 41 + 42 + **Possible Errors:** 43 + 44 + - `TeleportNotFound`: The specified teleport was not found. 45 + - `Unauthorized`: The authenticated user is not the target of this teleport. 46 + 47 + --- 48 + 49 + ## Lexicon Source 50 + 51 + ```json 52 + { 53 + "lexicon": 1, 54 + "id": "place.stream.live.denyTeleport", 55 + "defs": { 56 + "main": { 57 + "type": "procedure", 58 + "description": "Deny an incoming teleport request.", 59 + "input": { 60 + "encoding": "application/json", 61 + "schema": { 62 + "type": "object", 63 + "required": ["uri"], 64 + "properties": { 65 + "uri": { 66 + "type": "string", 67 + "format": "at-uri", 68 + "description": "The URI of the teleport record to deny." 69 + } 70 + } 71 + } 72 + }, 73 + "output": { 74 + "encoding": "application/json", 75 + "schema": { 76 + "type": "object", 77 + "required": ["success"], 78 + "properties": { 79 + "success": { 80 + "type": "boolean", 81 + "description": "Whether the teleport was successfully denied." 82 + } 83 + } 84 + } 85 + }, 86 + "errors": [ 87 + { 88 + "name": "TeleportNotFound", 89 + "description": "The specified teleport was not found." 90 + }, 91 + { 92 + "name": "Unauthorized", 93 + "description": "The authenticated user is not the target of this teleport." 94 + } 95 + ] 96 + } 97 + } 98 + } 99 + ```
+2 -1
js/docs/src/content/docs/lex-reference/live/place-stream-live-getprofilecard.md
··· 26 26 - **Encoding:** `*/*` 27 27 - **Schema:** 28 28 29 - _Schema not defined._ **Possible Errors:** 29 + _Schema not defined._ 30 + **Possible Errors:** 30 31 31 32 - `RepoNotFound` 32 33
+1 -2
js/docs/src/content/docs/lex-reference/live/place-stream-live-searchactorstypeahead.md
··· 13 13 14 14 **Type:** `query` 15 15 16 - Find actor suggestions for a prefix search term. Expected use is for 17 - auto-completion during text field entry. 16 + Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. 18 17 19 18 **Parameters:** 20 19
+66
js/docs/src/content/docs/lex-reference/live/place-stream-live-teleport.md
··· 1 + --- 2 + title: place.stream.live.teleport 3 + description: Reference for the place.stream.live.teleport lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record defining a 'teleport', that is active during a certain time. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------------- | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | 24 + | `streamer` | `string` | โœ… | The DID of the streamer to teleport to. | Format: `did` | 25 + | `startsAt` | `string` | โœ… | The time the teleport becomes active. | Format: `datetime` | 26 + | `durationSeconds` | `integer` | โŒ | The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours). | Min: 60<br/>Max: 32400 | 27 + 28 + --- 29 + 30 + ## Lexicon Source 31 + 32 + ```json 33 + { 34 + "lexicon": 1, 35 + "id": "place.stream.live.teleport", 36 + "defs": { 37 + "main": { 38 + "type": "record", 39 + "key": "tid", 40 + "description": "Record defining a 'teleport', that is active during a certain time.", 41 + "record": { 42 + "type": "object", 43 + "required": ["streamer", "startsAt"], 44 + "properties": { 45 + "streamer": { 46 + "type": "string", 47 + "format": "did", 48 + "description": "The DID of the streamer to teleport to." 49 + }, 50 + "startsAt": { 51 + "type": "string", 52 + "format": "datetime", 53 + "description": "The time the teleport becomes active." 54 + }, 55 + "durationSeconds": { 56 + "type": "integer", 57 + "description": "The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours).", 58 + "minimum": 60, 59 + "maximum": 32400 60 + } 61 + } 62 + } 63 + } 64 + } 65 + } 66 + ```
-133
js/docs/src/content/docs/lex-reference/livestream/place-stream-livestream-update.md
··· 1 - --- 2 - title: place.stream.livestream.update 3 - description: Reference for the place.stream.livestream.update 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 livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 19 - 20 - **Parameters:** _(None defined)_ 21 - 22 - **Input:** 23 - 24 - - **Encoding:** `application/json` 25 - - **Schema:** 26 - 27 - **Schema Type:** `object` 28 - 29 - | Name | Type | Req'd | Description | Constraints | 30 - | --------------- | -------- | ----- | ---------------------------------------------- | --------------------------------------- | 31 - | `streamer` | `string` | โœ… | The DID of the streamer. | Format: `did` | 32 - | `livestreamUri` | `string` | โœ… | The AT-URI of the livestream record to update. | Format: `at-uri` | 33 - | `title` | `string` | โŒ | New title for the livestream. | Max Length: 1400<br/>Max Graphemes: 140 | 34 - 35 - **Output:** 36 - 37 - - **Encoding:** `application/json` 38 - - **Schema:** 39 - 40 - **Schema Type:** `object` 41 - 42 - | Name | Type | Req'd | Description | Constraints | 43 - | ----- | -------- | ----- | -------------------------------------------- | ---------------- | 44 - | `uri` | `string` | โœ… | The AT-URI of the updated livestream record. | Format: `at-uri` | 45 - | `cid` | `string` | โœ… | The CID of the updated livestream record. | Format: `cid` | 46 - 47 - **Possible Errors:** 48 - 49 - - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 54 - - `RecordNotFound`: The specified livestream record does not exist. 55 - 56 - --- 57 - 58 - ## Lexicon Source 59 - 60 - ```json 61 - { 62 - "lexicon": 1, 63 - "id": "place.stream.livestream.update", 64 - "defs": { 65 - "main": { 66 - "type": "procedure", 67 - "description": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.", 68 - "input": { 69 - "encoding": "application/json", 70 - "schema": { 71 - "type": "object", 72 - "required": ["streamer", "livestreamUri"], 73 - "properties": { 74 - "streamer": { 75 - "type": "string", 76 - "format": "did", 77 - "description": "The DID of the streamer." 78 - }, 79 - "livestreamUri": { 80 - "type": "string", 81 - "format": "at-uri", 82 - "description": "The AT-URI of the livestream record to update." 83 - }, 84 - "title": { 85 - "type": "string", 86 - "maxLength": 1400, 87 - "maxGraphemes": 140, 88 - "description": "New title for the livestream." 89 - } 90 - } 91 - } 92 - }, 93 - "output": { 94 - "encoding": "application/json", 95 - "schema": { 96 - "type": "object", 97 - "required": ["uri", "cid"], 98 - "properties": { 99 - "uri": { 100 - "type": "string", 101 - "format": "at-uri", 102 - "description": "The AT-URI of the updated livestream record." 103 - }, 104 - "cid": { 105 - "type": "string", 106 - "format": "cid", 107 - "description": "The CID of the updated livestream record." 108 - } 109 - } 110 - } 111 - }, 112 - "errors": [ 113 - { 114 - "name": "Unauthorized", 115 - "description": "The request lacks valid authentication credentials." 116 - }, 117 - { 118 - "name": "Forbidden", 119 - "description": "The caller does not have permission to update livestream metadata for this streamer." 120 - }, 121 - { 122 - "name": "SessionNotFound", 123 - "description": "The streamer's OAuth session could not be found or is invalid." 124 - }, 125 - { 126 - "name": "RecordNotFound", 127 - "description": "The specified livestream record does not exist." 128 - } 129 - ] 130 - } 131 - } 132 - } 133 - ```
+1 -2
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Default metadata record for livestream including content warnings, rights, and 17 - distribution policy 16 + Default metadata record for livestream including content warnings, rights, and distribution policy 18 17 19 18 **Record Key:** `literal:self` 20 19
+8 -19
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
··· 33 33 34 34 **Type:** `token` 35 35 36 - All rights reserved to the creator โ€” others cannot use, modify, or share without 37 - explicit authorization. 36 + All rights reserved to the creator โ€” others cannot use, modify, or share without explicit authorization. 38 37 39 38 --- 40 39 ··· 44 43 45 44 **Type:** `token` 46 45 47 - Public domain dedication. You waive all copyright and related rights where 48 - possible. Others may copy, modify, distribute, or perform your work for any 49 - purpose without attribution. 46 + Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution. 50 47 51 48 --- 52 49 ··· 56 53 57 54 **Type:** `token` 58 55 59 - Attribution required. Others may copy, distribute, remix, and build upon your 60 - work, even commercially, if they credit you. 56 + Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you. 61 57 62 58 --- 63 59 ··· 67 63 68 64 **Type:** `token` 69 65 70 - Attribution + share-alike. Others may adapt and build upon your work, even 71 - commercially, if they credit you and license their new creations under identical 72 - terms. 66 + Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms. 73 67 74 68 --- 75 69 ··· 79 73 80 74 **Type:** `token` 81 75 82 - Attribution + non-commercial. Others may adapt and build upon your work for 83 - non-commercial purposes only, and must credit you. 76 + Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you. 84 77 85 78 --- 86 79 ··· 90 83 91 84 **Type:** `token` 92 85 93 - Attribution + non-commercial + share-alike. Others may adapt and build upon your 94 - work for non-commercial purposes only, must credit you, and must license their 95 - new creations under identical terms. 86 + Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms. 96 87 97 88 --- 98 89 ··· 102 93 103 94 **Type:** `token` 104 95 105 - Attribution + no derivatives. Others may reuse your work, even commercially, but 106 - it must remain unchanged and you must be credited. 96 + Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited. 107 97 108 98 --- 109 99 ··· 113 103 114 104 **Type:** `token` 115 105 116 - Attribution + non-commercial + no derivatives. Others may download and share 117 - your work with credit, but cannot change it or use it commercially. 106 + Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially. 118 107 119 108 --- 120 109
+8 -18
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
··· 29 29 30 30 **Type:** `token` 31 31 32 - The content could be perceived as offensive due to the discussion or display of 33 - death. 32 + The content could be perceived as offensive due to the discussion or display of death. 34 33 35 34 --- 36 35 ··· 40 39 41 40 **Type:** `token` 42 41 43 - The content contains a portrayal of the use or abuse of mind altering 44 - substances. 42 + The content contains a portrayal of the use or abuse of mind altering substances. 45 43 46 44 --- 47 45 ··· 51 49 52 50 **Type:** `token` 53 51 54 - The content contains violent actions of a fantasy nature, involving human or 55 - non-human characters in situations easily distinguishable from real life. 52 + The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life. 56 53 57 54 --- 58 55 ··· 62 59 63 60 **Type:** `token` 64 61 65 - The content contains flashing lights that could be harmful to viewers with 66 - seizure disorders such as photosensitive epilepsy. 62 + The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy. 67 63 68 64 --- 69 65 ··· 93 89 94 90 **Type:** `token` 95 91 96 - The content contains information that can be used to identify a particular 97 - individual, such as a name, phone number, email address, physical address, or IP 98 - address. 92 + The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address. 99 93 100 94 --- 101 95 ··· 105 99 106 100 **Type:** `token` 107 101 108 - The content could be perceived as offensive due to the discussion or display of 109 - sexuality. 102 + The content could be perceived as offensive due to the discussion or display of sexuality. 110 103 111 104 --- 112 105 ··· 116 109 117 110 **Type:** `token` 118 111 119 - The content could be perceived as distressing due to the discussion or display 120 - of suffering or triggering topics, including suicide, eating disorders or self 121 - harm. 112 + The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm. 122 113 123 114 --- 124 115 ··· 128 119 129 120 **Type:** `token` 130 121 131 - The content could be perceived as offensive due to the discussion or display of 132 - violence. 122 + The content could be perceived as offensive due to the discussion or display of violence. 133 123 134 124 --- 135 125
+3 -6
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createblock.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates 17 - an app.bsky.graph.block record in the streamer's repository. 16 + Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository. 18 17 19 18 **Parameters:** _(None defined)_ 20 19 ··· 46 45 **Possible Errors:** 47 46 48 47 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to create blocks for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 48 + - `Forbidden`: The caller does not have permission to create blocks for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 53 50 54 51 --- 55 52
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-creategate.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Create a gate (hide message) on behalf of a streamer. Requires 'hide' 17 - permission. Creates a place.stream.chat.gate record in the streamer's 18 - repository. 16 + Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 46 44 **Possible Errors:** 47 45 48 46 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to hide messages for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 47 + - `Forbidden`: The caller does not have permission to hide messages for this streamer. 48 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 53 49 54 50 --- 55 51
+5 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deleteblock.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. 17 - Deletes an app.bsky.graph.block record from the streamer's repository. 16 + Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository. 18 17 19 18 **Parameters:** _(None defined)_ 20 19 ··· 37 36 38 37 **Schema Type:** `object` 39 38 40 - _(No properties defined)_ **Possible Errors:** 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 41 42 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 - - `Forbidden`: The caller does not have permission to delete blocks for this 44 - streamer. 45 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 46 - invalid. 43 + - `Forbidden`: The caller does not have permission to delete blocks for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 47 45 48 46 --- 49 47
+5 -8
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletegate.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' 17 - permission. Deletes a place.stream.chat.gate record from the streamer's 18 - repository. 16 + Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 38 36 39 37 **Schema Type:** `object` 40 38 41 - _(No properties defined)_ **Possible Errors:** 39 + _(No properties defined)_ 40 + **Possible Errors:** 42 41 43 42 - `Unauthorized`: The request lacks valid authentication credentials. 44 - - `Forbidden`: The caller does not have permission to unhide messages for this 45 - streamer. 46 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 47 - invalid. 43 + - `Forbidden`: The caller does not have permission to unhide messages for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 48 45 49 46 --- 50 47
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-updatelivestream.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 47 45 **Possible Errors:** 48 46 49 47 - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 48 + - `Forbidden`: The caller does not have permission to update livestream metadata for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 54 50 - `RecordNotFound`: The specified livestream record does not exist. 55 51 56 52 ---
+83
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-createtarget.md
··· 1 + --- 2 + title: place.stream.multistream.createTarget 3 + description: Reference for the place.stream.multistream.createTarget 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 + Create a new target for rebroadcasting a Streamplace stream. 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 + | `multistreamTarget` | [`place.stream.multistream.target`](/lex-reference/place-stream-multistream-target) | โœ… | | | 30 + 31 + **Output:** 32 + 33 + - **Encoding:** `application/json` 34 + - **Schema:** 35 + 36 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 37 + 38 + **Possible Errors:** 39 + 40 + - `InvalidTargetUrl`: The provided target URL is invalid or unreachable. 41 + 42 + --- 43 + 44 + ## Lexicon Source 45 + 46 + ```json 47 + { 48 + "lexicon": 1, 49 + "id": "place.stream.multistream.createTarget", 50 + "defs": { 51 + "main": { 52 + "type": "procedure", 53 + "description": "Create a new target for rebroadcasting a Streamplace stream.", 54 + "input": { 55 + "encoding": "application/json", 56 + "schema": { 57 + "type": "object", 58 + "required": ["multistreamTarget"], 59 + "properties": { 60 + "multistreamTarget": { 61 + "type": "ref", 62 + "ref": "place.stream.multistream.target" 63 + } 64 + } 65 + } 66 + }, 67 + "output": { 68 + "encoding": "application/json", 69 + "schema": { 70 + "type": "ref", 71 + "ref": "place.stream.multistream.defs#targetView" 72 + } 73 + }, 74 + "errors": [ 75 + { 76 + "name": "InvalidTargetUrl", 77 + "description": "The provided target URL is invalid or unreachable." 78 + } 79 + ] 80 + } 81 + } 82 + } 83 + ```
+90
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-defs.md
··· 1 + --- 2 + title: place.stream.multistream.defs 3 + description: Reference for the place.stream.multistream.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="targetview"></a> 11 + 12 + ### `targetView` 13 + 14 + **Type:** `object` 15 + 16 + **Properties:** 17 + 18 + | Name | Type | Req'd | Description | Constraints | 19 + | ------------- | ------------------------------------------------------------------------------------------- | ----- | ----------- | ---------------- | 20 + | `uri` | `string` | โœ… | | Format: `at-uri` | 21 + | `cid` | `string` | โœ… | | Format: `cid` | 22 + | `record` | `unknown` | โœ… | | | 23 + | `latestEvent` | [`place.stream.multistream.defs#event`](/lex-reference/place-stream-multistream-defs#event) | โŒ | | | 24 + 25 + --- 26 + 27 + <a name="event"></a> 28 + 29 + ### `event` 30 + 31 + **Type:** `object` 32 + 33 + **Properties:** 34 + 35 + | Name | Type | Req'd | Description | Constraints | 36 + | ----------- | -------- | ----- | ----------- | ---------------------------------------------- | 37 + | `message` | `string` | โœ… | | | 38 + | `status` | `string` | โœ… | | Enum: `inactive`, `pending`, `active`, `error` | 39 + | `createdAt` | `string` | โœ… | | Format: `datetime` | 40 + 41 + --- 42 + 43 + ## Lexicon Source 44 + 45 + ```json 46 + { 47 + "lexicon": 1, 48 + "id": "place.stream.multistream.defs", 49 + "defs": { 50 + "targetView": { 51 + "type": "object", 52 + "required": ["uri", "cid", "record"], 53 + "properties": { 54 + "uri": { 55 + "type": "string", 56 + "format": "at-uri" 57 + }, 58 + "cid": { 59 + "type": "string", 60 + "format": "cid" 61 + }, 62 + "record": { 63 + "type": "unknown" 64 + }, 65 + "latestEvent": { 66 + "type": "ref", 67 + "ref": "place.stream.multistream.defs#event" 68 + } 69 + } 70 + }, 71 + "event": { 72 + "type": "object", 73 + "required": ["message", "status", "createdAt"], 74 + "properties": { 75 + "message": { 76 + "type": "string" 77 + }, 78 + "status": { 79 + "type": "string", 80 + "enum": ["inactive", "pending", "active", "error"] 81 + }, 82 + "createdAt": { 83 + "type": "string", 84 + "format": "datetime" 85 + } 86 + } 87 + } 88 + } 89 + } 90 + ```
+77
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-deletetarget.md
··· 1 + --- 2 + title: place.stream.multistream.deleteTarget 3 + description: Reference for the place.stream.multistream.deleteTarget 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 target for rebroadcasting a Streamplace stream. 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 + | `rkey` | `string` | โœ… | The Record Key of the target to delete. | Format: `record-key` | 30 + 31 + **Output:** 32 + 33 + - **Encoding:** `application/json` 34 + - **Schema:** 35 + 36 + **Schema Type:** `object` 37 + 38 + _(No properties defined)_ 39 + 40 + --- 41 + 42 + ## Lexicon Source 43 + 44 + ```json 45 + { 46 + "lexicon": 1, 47 + "id": "place.stream.multistream.deleteTarget", 48 + "defs": { 49 + "main": { 50 + "type": "procedure", 51 + "description": "Delete a target for rebroadcasting a Streamplace stream.", 52 + "input": { 53 + "encoding": "application/json", 54 + "schema": { 55 + "type": "object", 56 + "required": ["rkey"], 57 + "properties": { 58 + "rkey": { 59 + "type": "string", 60 + "format": "record-key", 61 + "description": "The Record Key of the target to delete." 62 + } 63 + } 64 + } 65 + }, 66 + "output": { 67 + "encoding": "application/json", 68 + "schema": { 69 + "type": "object", 70 + "properties": {} 71 + } 72 + }, 73 + "errors": [] 74 + } 75 + } 76 + } 77 + ```
+120
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-listtargets.md
··· 1 + --- 2 + title: place.stream.multistream.listTargets 3 + description: Reference for the place.stream.multistream.listTargets 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 + List a range of targets for rebroadcasting a Streamplace stream. 17 + 18 + **Parameters:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | -------- | --------- | ----- | -------------------------------- | ------------------------------------- | 22 + | `limit` | `integer` | โŒ | The number of targets to return. | Min: 1<br/>Max: 100<br/>Default: `50` | 23 + | `cursor` | `string` | โŒ | | | 24 + 25 + **Output:** 26 + 27 + - **Encoding:** `application/json` 28 + - **Schema:** 29 + 30 + **Schema Type:** `object` 31 + 32 + | Name | Type | Req'd | Description | Constraints | 33 + | --------- | -------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 34 + | `targets` | Array of [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) | โœ… | | | 35 + | `cursor` | `string` | โŒ | | | 36 + 37 + --- 38 + 39 + <a name="record"></a> 40 + 41 + ### `record` 42 + 43 + **Type:** `object` 44 + 45 + **Properties:** 46 + 47 + | Name | Type | Req'd | Description | Constraints | 48 + | ------- | --------- | ----- | ----------- | ---------------- | 49 + | `uri` | `string` | โœ… | | Format: `at-uri` | 50 + | `cid` | `string` | โœ… | | Format: `cid` | 51 + | `value` | `unknown` | โœ… | | | 52 + 53 + --- 54 + 55 + ## Lexicon Source 56 + 57 + ```json 58 + { 59 + "lexicon": 1, 60 + "id": "place.stream.multistream.listTargets", 61 + "defs": { 62 + "main": { 63 + "type": "query", 64 + "description": "List a range of targets for rebroadcasting a Streamplace stream.", 65 + "parameters": { 66 + "type": "params", 67 + "required": [], 68 + "properties": { 69 + "limit": { 70 + "type": "integer", 71 + "minimum": 1, 72 + "maximum": 100, 73 + "default": 50, 74 + "description": "The number of targets to return." 75 + }, 76 + "cursor": { 77 + "type": "string" 78 + } 79 + } 80 + }, 81 + "output": { 82 + "encoding": "application/json", 83 + "schema": { 84 + "type": "object", 85 + "required": ["targets"], 86 + "properties": { 87 + "targets": { 88 + "type": "array", 89 + "items": { 90 + "type": "ref", 91 + "ref": "place.stream.multistream.defs#targetView" 92 + } 93 + }, 94 + "cursor": { 95 + "type": "string" 96 + } 97 + } 98 + } 99 + } 100 + }, 101 + "record": { 102 + "type": "object", 103 + "required": ["uri", "cid", "value"], 104 + "properties": { 105 + "uri": { 106 + "type": "string", 107 + "format": "at-uri" 108 + }, 109 + "cid": { 110 + "type": "string", 111 + "format": "cid" 112 + }, 113 + "value": { 114 + "type": "unknown" 115 + } 116 + } 117 + } 118 + } 119 + } 120 + ```
+90
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-puttarget.md
··· 1 + --- 2 + title: place.stream.multistream.putTarget 3 + description: Reference for the place.stream.multistream.putTarget 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 an existing target for rebroadcasting a Streamplace stream. 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 + | `multistreamTarget` | [`place.stream.multistream.target`](/lex-reference/place-stream-multistream-target) | โœ… | | | 30 + | `rkey` | `string` | โŒ | The Record Key. | Format: `record-key`<br/>Max Length: 512 | 31 + 32 + **Output:** 33 + 34 + - **Encoding:** `application/json` 35 + - **Schema:** 36 + 37 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 + 39 + **Possible Errors:** 40 + 41 + - `InvalidTargetUrl`: The provided target URL is invalid or unreachable. 42 + 43 + --- 44 + 45 + ## Lexicon Source 46 + 47 + ```json 48 + { 49 + "lexicon": 1, 50 + "id": "place.stream.multistream.putTarget", 51 + "defs": { 52 + "main": { 53 + "type": "procedure", 54 + "description": "Update an existing target for rebroadcasting a Streamplace stream.", 55 + "input": { 56 + "encoding": "application/json", 57 + "schema": { 58 + "type": "object", 59 + "required": ["multistreamTarget"], 60 + "properties": { 61 + "multistreamTarget": { 62 + "type": "ref", 63 + "ref": "place.stream.multistream.target" 64 + }, 65 + "rkey": { 66 + "type": "string", 67 + "format": "record-key", 68 + "description": "The Record Key.", 69 + "maxLength": 512 70 + } 71 + } 72 + } 73 + }, 74 + "output": { 75 + "encoding": "application/json", 76 + "schema": { 77 + "type": "ref", 78 + "ref": "place.stream.multistream.defs#targetView" 79 + } 80 + }, 81 + "errors": [ 82 + { 83 + "name": "InvalidTargetUrl", 84 + "description": "The provided target URL is invalid or unreachable." 85 + } 86 + ] 87 + } 88 + } 89 + } 90 + ```
+70
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-target.md
··· 1 + --- 2 + title: place.stream.multistream.target 3 + description: Reference for the place.stream.multistream.target lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + An external server for rebroadcasting a Streamplace stream 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------- | --------- | ----- | ------------------------------------------------- | ------------------ | 24 + | `url` | `string` | โœ… | The rtmp:// or rtmps:// url of the target server. | Format: `uri` | 25 + | `active` | `boolean` | โœ… | Whether this target is currently active. | | 26 + | `createdAt` | `string` | โœ… | When this target was created. | Format: `datetime` | 27 + | `name` | `string` | โŒ | A user-friendly name for this target. | Max Length: 100 | 28 + 29 + --- 30 + 31 + ## Lexicon Source 32 + 33 + ```json 34 + { 35 + "lexicon": 1, 36 + "id": "place.stream.multistream.target", 37 + "defs": { 38 + "main": { 39 + "type": "record", 40 + "key": "tid", 41 + "description": "An external server for rebroadcasting a Streamplace stream", 42 + "record": { 43 + "required": ["url", "active", "createdAt"], 44 + "type": "object", 45 + "properties": { 46 + "url": { 47 + "type": "string", 48 + "format": "uri", 49 + "description": "The rtmp:// or rtmps:// url of the target server." 50 + }, 51 + "active": { 52 + "type": "boolean", 53 + "description": "Whether this target is currently active." 54 + }, 55 + "createdAt": { 56 + "type": "string", 57 + "format": "datetime", 58 + "description": "When this target was created." 59 + }, 60 + "name": { 61 + "type": "string", 62 + "maxLength": 100, 63 + "description": "A user-friendly name for this target." 64 + } 65 + } 66 + } 67 + } 68 + } 69 + } 70 + ```
+399
js/docs/src/content/docs/lex-reference/openapi.json
··· 517 517 } 518 518 } 519 519 }, 520 + "/xrpc/place.stream.multistream.createTarget": { 521 + "post": { 522 + "summary": "Create a new target for rebroadcasting a Streamplace stream.", 523 + "operationId": "place.stream.multistream.createTarget", 524 + "tags": ["place.stream.multistream"], 525 + "responses": { 526 + "200": { 527 + "description": "Success", 528 + "content": { 529 + "application/json": { 530 + "schema": { 531 + "$ref": "#/components/schemas/place.stream.multistream.defs_targetView" 532 + } 533 + } 534 + } 535 + }, 536 + "400": { 537 + "description": "Bad Request", 538 + "content": { 539 + "application/json": { 540 + "schema": { 541 + "type": "object", 542 + "required": ["error", "message"], 543 + "properties": { 544 + "error": { 545 + "type": "string", 546 + "oneOf": [ 547 + { 548 + "const": "InvalidTargetUrl" 549 + } 550 + ] 551 + }, 552 + "message": { 553 + "type": "string" 554 + } 555 + } 556 + } 557 + } 558 + } 559 + } 560 + }, 561 + "requestBody": { 562 + "required": true, 563 + "content": { 564 + "application/json": { 565 + "schema": { 566 + "type": "object", 567 + "properties": { 568 + "multistreamTarget": { 569 + "$ref": "#/components/schemas/place.stream.multistream.target" 570 + } 571 + }, 572 + "required": ["multistreamTarget"] 573 + } 574 + } 575 + } 576 + } 577 + } 578 + }, 579 + "/xrpc/place.stream.multistream.deleteTarget": { 580 + "post": { 581 + "summary": "Delete a target for rebroadcasting a Streamplace stream.", 582 + "operationId": "place.stream.multistream.deleteTarget", 583 + "tags": ["place.stream.multistream"], 584 + "responses": { 585 + "200": { 586 + "description": "Success", 587 + "content": { 588 + "application/json": { 589 + "schema": { 590 + "type": "object", 591 + "properties": {} 592 + } 593 + } 594 + } 595 + } 596 + }, 597 + "requestBody": { 598 + "required": true, 599 + "content": { 600 + "application/json": { 601 + "schema": { 602 + "type": "object", 603 + "properties": { 604 + "rkey": { 605 + "type": "string", 606 + "description": "The Record Key of the target to delete.", 607 + "format": "record-key" 608 + } 609 + }, 610 + "required": ["rkey"] 611 + } 612 + } 613 + } 614 + } 615 + } 616 + }, 617 + "/xrpc/place.stream.multistream.listTargets": { 618 + "get": { 619 + "summary": "List a range of targets for rebroadcasting a Streamplace stream.", 620 + "operationId": "place.stream.multistream.listTargets", 621 + "tags": ["place.stream.multistream"], 622 + "responses": { 623 + "200": { 624 + "description": "Success", 625 + "content": { 626 + "application/json": { 627 + "schema": { 628 + "type": "object", 629 + "properties": { 630 + "targets": { 631 + "type": "array", 632 + "items": { 633 + "$ref": "#/components/schemas/place.stream.multistream.defs_targetView" 634 + } 635 + }, 636 + "cursor": { 637 + "type": "string" 638 + } 639 + }, 640 + "required": ["targets"] 641 + } 642 + } 643 + } 644 + } 645 + }, 646 + "parameters": [ 647 + { 648 + "name": "limit", 649 + "in": "query", 650 + "required": false, 651 + "description": "The number of targets to return.", 652 + "schema": { 653 + "type": "integer", 654 + "description": "The number of targets to return.", 655 + "default": 50, 656 + "minimum": 1, 657 + "maximum": 100 658 + } 659 + }, 660 + { 661 + "name": "cursor", 662 + "in": "query", 663 + "required": false, 664 + "schema": { 665 + "type": "string" 666 + } 667 + } 668 + ] 669 + } 670 + }, 671 + "/xrpc/place.stream.multistream.putTarget": { 672 + "post": { 673 + "summary": "Update an existing target for rebroadcasting a Streamplace stream.", 674 + "operationId": "place.stream.multistream.putTarget", 675 + "tags": ["place.stream.multistream"], 676 + "responses": { 677 + "200": { 678 + "description": "Success", 679 + "content": { 680 + "application/json": { 681 + "schema": { 682 + "$ref": "#/components/schemas/place.stream.multistream.defs_targetView" 683 + } 684 + } 685 + } 686 + }, 687 + "400": { 688 + "description": "Bad Request", 689 + "content": { 690 + "application/json": { 691 + "schema": { 692 + "type": "object", 693 + "required": ["error", "message"], 694 + "properties": { 695 + "error": { 696 + "type": "string", 697 + "oneOf": [ 698 + { 699 + "const": "InvalidTargetUrl" 700 + } 701 + ] 702 + }, 703 + "message": { 704 + "type": "string" 705 + } 706 + } 707 + } 708 + } 709 + } 710 + } 711 + }, 712 + "requestBody": { 713 + "required": true, 714 + "content": { 715 + "application/json": { 716 + "schema": { 717 + "type": "object", 718 + "properties": { 719 + "multistreamTarget": { 720 + "$ref": "#/components/schemas/place.stream.multistream.target" 721 + }, 722 + "rkey": { 723 + "type": "string", 724 + "description": "The Record Key.", 725 + "maxLength": 512, 726 + "format": "record-key" 727 + } 728 + }, 729 + "required": ["multistreamTarget"] 730 + } 731 + } 732 + } 733 + } 734 + } 735 + }, 520 736 "/xrpc/place.stream.moderation.createBlock": { 521 737 "post": { 522 738 "summary": "Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository.", ··· 925 1141 } 926 1142 }, 927 1143 "required": ["streamer", "livestreamUri"] 1144 + } 1145 + } 1146 + } 1147 + } 1148 + } 1149 + }, 1150 + "/xrpc/place.stream.live.denyTeleport": { 1151 + "post": { 1152 + "summary": "Deny an incoming teleport request.", 1153 + "operationId": "place.stream.live.denyTeleport", 1154 + "tags": ["place.stream.live"], 1155 + "responses": { 1156 + "200": { 1157 + "description": "Success", 1158 + "content": { 1159 + "application/json": { 1160 + "schema": { 1161 + "type": "object", 1162 + "properties": { 1163 + "success": { 1164 + "type": "boolean", 1165 + "description": "Whether the teleport was successfully denied." 1166 + } 1167 + }, 1168 + "required": ["success"] 1169 + } 1170 + } 1171 + } 1172 + }, 1173 + "400": { 1174 + "description": "Bad Request", 1175 + "content": { 1176 + "application/json": { 1177 + "schema": { 1178 + "type": "object", 1179 + "required": ["error", "message"], 1180 + "properties": { 1181 + "error": { 1182 + "type": "string", 1183 + "oneOf": [ 1184 + { 1185 + "const": "TeleportNotFound" 1186 + }, 1187 + { 1188 + "const": "Unauthorized" 1189 + } 1190 + ] 1191 + }, 1192 + "message": { 1193 + "type": "string" 1194 + } 1195 + } 1196 + } 1197 + } 1198 + } 1199 + } 1200 + }, 1201 + "requestBody": { 1202 + "required": true, 1203 + "content": { 1204 + "application/json": { 1205 + "schema": { 1206 + "type": "object", 1207 + "properties": { 1208 + "uri": { 1209 + "type": "string", 1210 + "description": "The URI of the teleport record to deny.", 1211 + "format": "uri" 1212 + } 1213 + }, 1214 + "required": ["uri"] 928 1215 } 929 1216 } 930 1217 } ··· 1669 1956 ] 1670 1957 } 1671 1958 }, 1959 + "/xrpc/com.atproto.sync.getRepo": { 1960 + "get": { 1961 + "summary": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 1962 + "operationId": "com.atproto.sync.getRepo", 1963 + "tags": ["com.atproto.sync"], 1964 + "responses": { 1965 + "200": { 1966 + "description": "Success", 1967 + "content": { 1968 + "application/vnd.ipld.car": { 1969 + "schema": {} 1970 + } 1971 + } 1972 + }, 1973 + "400": { 1974 + "description": "Bad Request", 1975 + "content": { 1976 + "application/json": { 1977 + "schema": { 1978 + "type": "object", 1979 + "required": ["error", "message"], 1980 + "properties": { 1981 + "error": { 1982 + "type": "string", 1983 + "oneOf": [ 1984 + { 1985 + "const": "RepoNotFound" 1986 + }, 1987 + { 1988 + "const": "RepoTakendown" 1989 + }, 1990 + { 1991 + "const": "RepoSuspended" 1992 + }, 1993 + { 1994 + "const": "RepoDeactivated" 1995 + } 1996 + ] 1997 + }, 1998 + "message": { 1999 + "type": "string" 2000 + } 2001 + } 2002 + } 2003 + } 2004 + } 2005 + } 2006 + }, 2007 + "parameters": [ 2008 + { 2009 + "name": "did", 2010 + "in": "query", 2011 + "required": true, 2012 + "description": "The DID of the repo.", 2013 + "schema": { 2014 + "type": "string", 2015 + "description": "The DID of the repo.", 2016 + "format": "did" 2017 + } 2018 + }, 2019 + { 2020 + "name": "since", 2021 + "in": "query", 2022 + "required": false, 2023 + "description": "The revision ('rev') of the repo to create a diff from.", 2024 + "schema": { 2025 + "type": "string", 2026 + "description": "The revision ('rev') of the repo to create a diff from.", 2027 + "format": "tid" 2028 + } 2029 + } 2030 + ] 2031 + } 2032 + }, 1672 2033 "/xrpc/com.atproto.sync.listRepos": { 1673 2034 "get": { 1674 2035 "summary": "Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.", ··· 2565 2926 } 2566 2927 }, 2567 2928 "required": ["id", "url", "events", "active", "createdAt"] 2929 + }, 2930 + "place.stream.multistream.target": { 2931 + "description": "Unknown type" 2932 + }, 2933 + "place.stream.multistream.defs_targetView": { 2934 + "type": "object", 2935 + "properties": { 2936 + "uri": { 2937 + "type": "string", 2938 + "format": "uri" 2939 + }, 2940 + "cid": { 2941 + "type": "string", 2942 + "format": "cid" 2943 + }, 2944 + "record": {}, 2945 + "latestEvent": { 2946 + "$ref": "#/components/schemas/place.stream.multistream.defs_event" 2947 + } 2948 + }, 2949 + "required": ["uri", "cid", "record"] 2950 + }, 2951 + "place.stream.multistream.defs_event": { 2952 + "type": "object", 2953 + "properties": { 2954 + "message": { 2955 + "type": "string" 2956 + }, 2957 + "status": { 2958 + "type": "string", 2959 + "enum": ["inactive", "pending", "active", "error"] 2960 + }, 2961 + "createdAt": { 2962 + "type": "string", 2963 + "format": "date-time" 2964 + } 2965 + }, 2966 + "required": ["message", "status", "createdAt"] 2568 2967 }, 2569 2968 "place.stream.livestream_livestreamView": { 2570 2969 "type": "object",
+84 -3
js/docs/src/content/docs/lex-reference/place-stream-livestream.md
··· 79 79 80 80 --- 81 81 82 + <a name="teleportarrival"></a> 83 + 84 + ### `teleportArrival` 85 + 86 + **Type:** `object` 87 + 88 + **Properties:** 89 + 90 + | Name | Type | Req'd | Description | Constraints | 91 + | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------- | ------------------ | 92 + | `teleportUri` | `string` | โœ… | The URI of the teleport record | Format: `at-uri` | 93 + | `source` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | The streamer who is teleporting their viewers here | | 94 + | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | The chat profile of the source streamer | | 95 + | `viewerCount` | `integer` | โœ… | How many viewers are arriving from this teleport | | 96 + | `startsAt` | `string` | โœ… | When this teleport started | Format: `datetime` | 97 + 98 + --- 99 + 100 + <a name="teleportcanceled"></a> 101 + 102 + ### `teleportCanceled` 103 + 104 + **Type:** `object` 105 + 106 + **Properties:** 107 + 108 + | Name | Type | Req'd | Description | Constraints | 109 + | ------------- | -------- | ----- | ------------------------------------------------ | ------------------------------------ | 110 + | `teleportUri` | `string` | โœ… | The URI of the teleport record that was canceled | Format: `at-uri` | 111 + | `reason` | `string` | โœ… | Why this teleport was canceled | Enum: `deleted`, `denied`, `expired` | 112 + 113 + --- 114 + 82 115 <a name="streamplaceanything"></a> 83 116 84 117 ### `streamplaceAnything` ··· 87 120 88 121 **Properties:** 89 122 90 - | Name | Type | Req'd | Description | Constraints | 91 - | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 92 - | `livestream` | Union of:<br/>&nbsp;&nbsp;[`#livestreamView`](#livestreamview)<br/>&nbsp;&nbsp;[`#viewerCount`](#viewercount)<br/>&nbsp;&nbsp;[`place.stream.defs#blockView`](/lex-reference/place-stream-defs#blockview)<br/>&nbsp;&nbsp;[`place.stream.defs#renditions`](/lex-reference/place-stream-defs#renditions)<br/>&nbsp;&nbsp;[`place.stream.defs#rendition`](/lex-reference/place-stream-defs#rendition)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#messageView`](/lex-reference/place-stream-chat-defs#messageview) | โœ… | | | 123 + | Name | Type | Req'd | Description | Constraints | 124 + | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 125 + | `livestream` | Union of:<br/>&nbsp;&nbsp;[`#livestreamView`](#livestreamview)<br/>&nbsp;&nbsp;[`#viewerCount`](#viewercount)<br/>&nbsp;&nbsp;[`#teleportArrival`](#teleportarrival)<br/>&nbsp;&nbsp;[`#teleportCanceled`](#teleportcanceled)<br/>&nbsp;&nbsp;[`place.stream.defs#blockView`](/lex-reference/place-stream-defs#blockview)<br/>&nbsp;&nbsp;[`place.stream.defs#renditions`](/lex-reference/place-stream-defs#renditions)<br/>&nbsp;&nbsp;[`place.stream.defs#rendition`](/lex-reference/place-stream-defs#rendition)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#messageView`](/lex-reference/place-stream-chat-defs#messageview) | โœ… | | | 93 126 94 127 --- 95 128 ··· 199 232 } 200 233 } 201 234 }, 235 + "teleportArrival": { 236 + "type": "object", 237 + "required": ["teleportUri", "source", "viewerCount", "startsAt"], 238 + "properties": { 239 + "teleportUri": { 240 + "type": "string", 241 + "format": "at-uri", 242 + "description": "The URI of the teleport record" 243 + }, 244 + "source": { 245 + "type": "ref", 246 + "ref": "app.bsky.actor.defs#profileViewBasic", 247 + "description": "The streamer who is teleporting their viewers here" 248 + }, 249 + "chatProfile": { 250 + "type": "ref", 251 + "ref": "place.stream.chat.profile", 252 + "description": "The chat profile of the source streamer" 253 + }, 254 + "viewerCount": { 255 + "type": "integer", 256 + "description": "How many viewers are arriving from this teleport" 257 + }, 258 + "startsAt": { 259 + "type": "string", 260 + "format": "datetime", 261 + "description": "When this teleport started" 262 + } 263 + } 264 + }, 265 + "teleportCanceled": { 266 + "type": "object", 267 + "required": ["teleportUri", "reason"], 268 + "properties": { 269 + "teleportUri": { 270 + "type": "string", 271 + "format": "at-uri", 272 + "description": "The URI of the teleport record that was canceled" 273 + }, 274 + "reason": { 275 + "type": "string", 276 + "enum": ["deleted", "denied", "expired"], 277 + "description": "Why this teleport was canceled" 278 + } 279 + } 280 + }, 202 281 "streamplaceAnything": { 203 282 "type": "object", 204 283 "required": ["livestream"], ··· 208 287 "refs": [ 209 288 "#livestreamView", 210 289 "#viewerCount", 290 + "#teleportArrival", 291 + "#teleportCanceled", 211 292 "place.stream.defs#blockView", 212 293 "place.stream.defs#renditions", 213 294 "place.stream.defs#rendition",
+9
js/docs/src/content/docs/reference.mdx
··· 1 + --- 2 + title: API Reference 3 + description: Our XRPC and OpenAPI Reference documentation 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + Here contains our XRPC and OpenAPI Reference documentation.
+22
js/docs/src/pages/back-to-home.astro
··· 1 + --- 2 + import "../styles/custom-font-face.css" 3 + import "@fontsource/atkinson-hyperlegible-next/400.css" 4 + import "@fontsource/atkinson-hyperlegible-next/600.css" 5 + --- 6 + <style> 7 + * { 8 + font-family: "Atkinson Hyperlegible Next"; 9 + } 10 + </style> 11 + 12 + <script is:inline> 13 + let hostname = window.location.host 14 + const redirectUrl = hostname.endsWith('.stream.place') 15 + ? 'https://stream.place' 16 + : '/'; 17 + window.location.href = redirectUrl; 18 + </script> 19 + <body style="display: flex; color: white; justify-content: center; align-items: center; height: 100vh; background-color: #181818; flex: 1; flex-direction: column;"> 20 + <h1>Redirecting to Streamplace...</h1> 21 + <a href="https://stream.place" style="color: lightskyblue">Click here if you are not redirected automatically</a> 22 + </body>
+1 -1
js/streamplace/package.json
··· 1 1 { 2 2 "name": "streamplace", 3 - "version": "0.8.0", 3 + "version": "0.9.0", 4 4 "description": "Live video on the AT Protocol", 5 5 "main": "dist/index.js", 6 6 "types": "src/index.ts",
+1 -1
lerna.json
··· 1 1 { 2 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.8.18", 3 + "version": "0.9.9", 4 4 "npmClient": "pnpm" 5 5 }
+35
lexicons/com/atproto/sync/getRepo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.sync.getRepo", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["did"], 11 + "properties": { 12 + "did": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "The DID of the repo." 16 + }, 17 + "since": { 18 + "type": "string", 19 + "format": "tid", 20 + "description": "The revision ('rev') of the repo to create a diff from." 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/vnd.ipld.car" 26 + }, 27 + "errors": [ 28 + { "name": "RepoNotFound" }, 29 + { "name": "RepoTakendown" }, 30 + { "name": "RepoSuspended" }, 31 + { "name": "RepoDeactivated" } 32 + ] 33 + } 34 + } 35 + }
+47
lexicons/place/stream/live/denyTeleport.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.denyTeleport", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Deny an incoming teleport request.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["uri"], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "The URI of the teleport record to deny." 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["success"], 27 + "properties": { 28 + "success": { 29 + "type": "boolean", 30 + "description": "Whether the teleport was successfully denied." 31 + } 32 + } 33 + } 34 + }, 35 + "errors": [ 36 + { 37 + "name": "TeleportNotFound", 38 + "description": "The specified teleport was not found." 39 + }, 40 + { 41 + "name": "Unauthorized", 42 + "description": "The authenticated user is not the target of this teleport." 43 + } 44 + ] 45 + } 46 + } 47 + }
+33
lexicons/place/stream/live/teleport.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.teleport", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record defining a 'teleport', that is active during a certain time.", 9 + "record": { 10 + "type": "object", 11 + "required": ["streamer", "startsAt"], 12 + "properties": { 13 + "streamer": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "The DID of the streamer to teleport to." 17 + }, 18 + "startsAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "The time the teleport becomes active." 22 + }, 23 + "durationSeconds": { 24 + "type": "integer", 25 + "description": "The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours).", 26 + "minimum": 60, 27 + "maximum": 32400 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+48
lexicons/place/stream/livestream.json
··· 88 88 "count": { "type": "integer" } 89 89 } 90 90 }, 91 + "teleportArrival": { 92 + "type": "object", 93 + "required": ["teleportUri", "source", "viewerCount", "startsAt"], 94 + "properties": { 95 + "teleportUri": { 96 + "type": "string", 97 + "format": "at-uri", 98 + "description": "The URI of the teleport record" 99 + }, 100 + "source": { 101 + "type": "ref", 102 + "ref": "app.bsky.actor.defs#profileViewBasic", 103 + "description": "The streamer who is teleporting their viewers here" 104 + }, 105 + "chatProfile": { 106 + "type": "ref", 107 + "ref": "place.stream.chat.profile", 108 + "description": "The chat profile of the source streamer" 109 + }, 110 + "viewerCount": { 111 + "type": "integer", 112 + "description": "How many viewers are arriving from this teleport" 113 + }, 114 + "startsAt": { 115 + "type": "string", 116 + "format": "datetime", 117 + "description": "When this teleport started" 118 + } 119 + } 120 + }, 121 + "teleportCanceled": { 122 + "type": "object", 123 + "required": ["teleportUri", "reason"], 124 + "properties": { 125 + "teleportUri": { 126 + "type": "string", 127 + "format": "at-uri", 128 + "description": "The URI of the teleport record that was canceled" 129 + }, 130 + "reason": { 131 + "type": "string", 132 + "enum": ["deleted", "denied", "expired"], 133 + "description": "Why this teleport was canceled" 134 + } 135 + } 136 + }, 91 137 "streamplaceAnything": { 92 138 "type": "object", 93 139 "required": ["livestream"], ··· 97 143 "refs": [ 98 144 "#livestreamView", 99 145 "#viewerCount", 146 + "#teleportArrival", 147 + "#teleportCanceled", 100 148 "place.stream.defs#blockView", 101 149 "place.stream.defs#renditions", 102 150 "place.stream.defs#rendition",
+36
lexicons/place/stream/multistream/createTarget.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.multistream.createTarget", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new target for rebroadcasting a Streamplace stream.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["multistreamTarget"], 13 + "properties": { 14 + "multistreamTarget": { 15 + "type": "ref", 16 + "ref": "place.stream.multistream.target" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "ref", 25 + "ref": "place.stream.multistream.defs#targetView" 26 + } 27 + }, 28 + "errors": [ 29 + { 30 + "name": "InvalidTargetUrl", 31 + "description": "The provided target URL is invalid or unreachable." 32 + } 33 + ] 34 + } 35 + } 36 + }
+31
lexicons/place/stream/multistream/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.multistream.defs", 4 + "defs": { 5 + "targetView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "record"], 8 + "properties": { 9 + "uri": { "type": "string", "format": "at-uri" }, 10 + "cid": { "type": "string", "format": "cid" }, 11 + "record": { "type": "unknown" }, 12 + "latestEvent": { 13 + "type": "ref", 14 + "ref": "place.stream.multistream.defs#event" 15 + } 16 + } 17 + }, 18 + "event": { 19 + "type": "object", 20 + "required": ["message", "status", "createdAt"], 21 + "properties": { 22 + "message": { "type": "string" }, 23 + "status": { 24 + "type": "string", 25 + "enum": ["inactive", "pending", "active", "error"] 26 + }, 27 + "createdAt": { "type": "string", "format": "datetime" } 28 + } 29 + } 30 + } 31 + }
+32
lexicons/place/stream/multistream/deleteTarget.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.multistream.deleteTarget", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a target for rebroadcasting a Streamplace stream.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["rkey"], 13 + "properties": { 14 + "rkey": { 15 + "type": "string", 16 + "format": "record-key", 17 + "description": "The Record Key of the target to delete." 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "properties": {} 27 + } 28 + }, 29 + "errors": [] 30 + } 31 + } 32 + }
+50
lexicons/place/stream/multistream/listTargets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.multistream.listTargets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List a range of targets for rebroadcasting a Streamplace stream.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [], 11 + "properties": { 12 + "limit": { 13 + "type": "integer", 14 + "minimum": 1, 15 + "maximum": 100, 16 + "default": 50, 17 + "description": "The number of targets to return." 18 + }, 19 + "cursor": { "type": "string" } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["targets"], 27 + "properties": { 28 + "targets": { 29 + "type": "array", 30 + "items": { 31 + "type": "ref", 32 + "ref": "place.stream.multistream.defs#targetView" 33 + } 34 + }, 35 + "cursor": { "type": "string" } 36 + } 37 + } 38 + } 39 + }, 40 + "record": { 41 + "type": "object", 42 + "required": ["uri", "cid", "value"], 43 + "properties": { 44 + "uri": { "type": "string", "format": "at-uri" }, 45 + "cid": { "type": "string", "format": "cid" }, 46 + "value": { "type": "unknown" } 47 + } 48 + } 49 + } 50 + }
+42
lexicons/place/stream/multistream/putTarget.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.multistream.putTarget", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update an existing target for rebroadcasting a Streamplace stream.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["multistreamTarget"], 13 + "properties": { 14 + "multistreamTarget": { 15 + "type": "ref", 16 + "ref": "place.stream.multistream.target" 17 + }, 18 + "rkey": { 19 + "type": "string", 20 + "format": "record-key", 21 + "description": "The Record Key.", 22 + "maxLength": 512 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "ref", 31 + "ref": "place.stream.multistream.defs#targetView" 32 + } 33 + }, 34 + "errors": [ 35 + { 36 + "name": "InvalidTargetUrl", 37 + "description": "The provided target URL is invalid or unreachable." 38 + } 39 + ] 40 + } 41 + } 42 + }
+36
lexicons/place/stream/multistream/target.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.multistream.target", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "An external server for rebroadcasting a Streamplace stream", 9 + "record": { 10 + "required": ["url", "active", "createdAt"], 11 + "type": "object", 12 + "properties": { 13 + "url": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "The rtmp:// or rtmps:// url of the target server." 17 + }, 18 + "active": { 19 + "type": "boolean", 20 + "description": "Whether this target is currently active." 21 + }, 22 + "createdAt": { 23 + "type": "string", 24 + "format": "datetime", 25 + "description": "When this target was created." 26 + }, 27 + "name": { 28 + "type": "string", 29 + "maxLength": 100, 30 + "description": "A user-friendly name for this target." 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+10 -58
pkg/api/api.go
··· 35 35 "stream.place/streamplace/pkg/director" 36 36 apierrors "stream.place/streamplace/pkg/errors" 37 37 "stream.place/streamplace/pkg/linking" 38 + "stream.place/streamplace/pkg/localdb" 38 39 "stream.place/streamplace/pkg/log" 39 40 "stream.place/streamplace/pkg/media" 40 41 "stream.place/streamplace/pkg/mist/mistconfig" ··· 56 57 CLI *config.CLI 57 58 Model model.Model 58 59 StatefulDB *statedb.StatefulDB 60 + LocalDB localdb.LocalDB 59 61 Updater *Updater 60 62 Signer *eip712.EIP712Signer 61 63 Mimes map[string]string ··· 93 95 mu sync.RWMutex 94 96 } 95 97 96 - func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, statefulDB *statedb.StatefulDB, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director, op *oatproxy.OATProxy) (*StreamplaceAPI, error) { 98 + func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, statefulDB *statedb.StatefulDB, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director, op *oatproxy.OATProxy, ldb localdb.LocalDB) (*StreamplaceAPI, error) { 97 99 updater, err := PrepareUpdater(cli) 98 100 if err != nil { 99 101 return nil, err ··· 117 119 sessionsLock: sync.RWMutex{}, 118 120 rtmpSessions: make(map[string]*media.RTMPSession), 119 121 rtmpSessionsLock: sync.Mutex{}, 122 + LocalDB: ldb, 120 123 } 121 124 a.Mimes, err = updater.GetMimes() 122 125 if err != nil { ··· 152 155 Recorder: metrics.NewRecorder(metrics.Config{}), 153 156 }) 154 157 var xrpc http.Handler 155 - xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus) 158 + xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB) 156 159 if err != nil { 157 160 return nil, err 158 161 } ··· 203 206 addHandle(apiRouter, "GET", "/api/chat/:repoDID", a.HandleChat(ctx)) 204 207 addHandle(apiRouter, "GET", "/api/websocket/:repoDID", a.HandleWebsocket(ctx)) 205 208 addHandle(apiRouter, "GET", "/api/livestream/:repoDID", a.HandleLivestream(ctx)) 206 - addHandle(apiRouter, "GET", "/api/segment/recent", a.HandleRecentSegments(ctx)) 207 - addHandle(apiRouter, "GET", "/api/segment/recent/:repoDID", a.HandleUserRecentSegments(ctx)) 208 209 addHandle(apiRouter, "GET", "/api/bluesky/resolve/:handle", a.HandleBlueskyResolve(ctx)) 209 210 addHandle(apiRouter, "GET", "/api/view-count/:user", a.HandleViewCount(ctx)) 210 211 addHandle(apiRouter, "GET", "/api/clip/:user/:file", a.HandleClip(ctx)) ··· 271 272 if err != nil { 272 273 return nil, err 273 274 } 274 - linker, err := linking.NewLinker(ctx, bs) 275 + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) 275 276 if err != nil { 276 277 return nil, err 277 278 } ··· 543 544 544 545 func (a *StreamplaceAPI) HandlePlayerEvent(ctx context.Context) httprouter.Handle { 545 546 return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) { 547 + if !a.CLI.PlayerTelemetry { 548 + w.WriteHeader(200) 549 + return 550 + } 546 551 var event model.PlayerEventAPI 547 552 if err := json.NewDecoder(req.Body).Decode(&event); err != nil { 548 553 apierrors.WriteHTTPBadRequest(w, "could not decode JSON body", err) ··· 554 559 return 555 560 } 556 561 w.WriteHeader(201) 557 - } 558 - } 559 - 560 - func (a *StreamplaceAPI) HandleRecentSegments(ctx context.Context) httprouter.Handle { 561 - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 562 - segs, err := a.Model.MostRecentSegments() 563 - if err != nil { 564 - apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 565 - return 566 - } 567 - bs, err := json.Marshal(segs) 568 - if err != nil { 569 - apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 570 - return 571 - } 572 - w.Header().Add("Content-Type", "application/json") 573 - if _, err := w.Write(bs); err != nil { 574 - log.Error(ctx, "error writing response", "error", err) 575 - } 576 - } 577 - } 578 - 579 - func (a *StreamplaceAPI) HandleUserRecentSegments(ctx context.Context) httprouter.Handle { 580 - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 581 - user := params.ByName("repoDID") 582 - if user == "" { 583 - apierrors.WriteHTTPBadRequest(w, "user required", nil) 584 - return 585 - } 586 - user, err := a.NormalizeUser(ctx, user) 587 - if err != nil { 588 - apierrors.WriteHTTPNotFound(w, "user not found", err) 589 - return 590 - } 591 - seg, err := a.Model.LatestSegmentForUser(user) 592 - if err != nil { 593 - apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 594 - return 595 - } 596 - streamplaceSeg, err := seg.ToStreamplaceSegment() 597 - if err != nil { 598 - apierrors.WriteHTTPInternalServerError(w, "could not convert segment to streamplace segment", err) 599 - return 600 - } 601 - bs, err := json.Marshal(streamplaceSeg) 602 - if err != nil { 603 - apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 604 - return 605 - } 606 - w.Header().Add("Content-Type", "application/json") 607 - if _, err := w.Write(bs); err != nil { 608 - log.Error(ctx, "error writing response", "error", err) 609 - } 610 562 } 611 563 } 612 564
+2 -2
pkg/api/api_internal.go
··· 298 298 errors.WriteHTTPBadRequest(w, "id required", nil) 299 299 return 300 300 } 301 - segment, err := a.Model.GetSegment(id) 301 + segment, err := a.LocalDB.GetSegment(id) 302 302 if err != nil { 303 303 errors.WriteHTTPBadRequest(w, err.Error(), err) 304 304 return ··· 553 553 } 554 554 after := time.Now().Add(-time.Duration(secs) * time.Second) 555 555 w.Header().Set("Content-Type", "video/mp4") 556 - err = media.ClipUser(ctx, a.Model, a.CLI, user, w, nil, &after) 556 + err = media.ClipUser(ctx, a.LocalDB, a.CLI, user, w, nil, &after) 557 557 if err != nil { 558 558 errors.WriteHTTPInternalServerError(w, "unable to clip user", err) 559 559 return
+1 -1
pkg/api/playback.go
··· 272 272 errors.WriteHTTPNotFound(w, "user not found", err) 273 273 return 274 274 } 275 - thumb, err := a.Model.LatestThumbnailForUser(user) 275 + thumb, err := a.LocalDB.LatestThumbnailForUser(user) 276 276 if err != nil { 277 277 errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) 278 278 return
+39 -1
pkg/api/websocket.go
··· 7 7 "net/http" 8 8 "time" 9 9 10 + bsky "github.com/bluesky-social/indigo/api/bsky" 10 11 "github.com/google/uuid" 11 12 "github.com/gorilla/websocket" 12 13 "github.com/julienschmidt/httprouter" ··· 180 181 }() 181 182 182 183 go func() { 183 - seg, err := a.Model.LatestSegmentForUser(repoDID) 184 + seg, err := a.LocalDB.LatestSegmentForUser(repoDID) 184 185 if err != nil { 185 186 log.Error(ctx, "could not get replies", "error", err) 186 187 return ··· 238 239 } 239 240 for _, message := range messages { 240 241 initialBurst <- message 242 + } 243 + }() 244 + 245 + go func() { 246 + teleports, err := a.Model.GetActiveTeleportsToRepo(repoDID) 247 + if err != nil { 248 + log.Error(ctx, "could not get active teleports", "error", err) 249 + return 250 + } 251 + // just send the latest one if it started <3m ago 252 + if len(teleports) > 0 && teleports[0].StartsAt.After(time.Now().Add(-3*time.Minute)) { 253 + tp := teleports[0] 254 + if tp.Repo == nil { 255 + log.Error(ctx, "teleportee repo is nil", "uri", tp.URI) 256 + } 257 + viewerCount := a.Bus.GetViewerCount(tp.RepoDID) 258 + arrivalMsg := streamplace.Livestream_TeleportArrival{ 259 + LexiconTypeID: "place.stream.livestream#teleportArrival", 260 + TeleportUri: tp.URI, 261 + Source: &bsky.ActorDefs_ProfileViewBasic{ 262 + Did: tp.RepoDID, 263 + Handle: tp.Repo.Handle, 264 + }, 265 + ViewerCount: int64(viewerCount), 266 + StartsAt: tp.StartsAt.Format(time.RFC3339), 267 + } 268 + 269 + // get the source chat profile 270 + chatProfile, err := a.Model.GetChatProfile(ctx, tp.RepoDID) 271 + if err == nil && chatProfile != nil { 272 + spcp, err := chatProfile.ToStreamplaceChatProfile() 273 + if err == nil { 274 + arrivalMsg.ChatProfile = spcp 275 + } 276 + } 277 + 278 + initialBurst <- arrivalMsg 241 279 } 242 280 }() 243 281
+30 -7
pkg/aqtime/aqtime.go
··· 7 7 "time" 8 8 ) 9 9 10 + // RE matches the canonical internal format: 2006-01-02T15:04:05.000Z 11 + // It also accepts the file-safe variant with dashes/dots swapped, for backward compat. 10 12 var RE *regexp.Regexp 11 - var Pattern string = `^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z$` 12 - 13 - type AQTime string 13 + var Pattern string = `(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z` 14 14 15 15 func init() { 16 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 17 } 18 18 19 19 var fstr = "2006-01-02T15:04:05.000Z" 20 + 21 + type AQTime string 20 22 21 23 // return a consistently formatted timestamp 22 24 func FromMillis(ms int64) AQTime { ··· 29 31 } 30 32 31 33 func FromString(str string) (AQTime, error) { 32 - bits := RE.FindStringSubmatch(str) 33 - if bits == nil { 34 - return "", fmt.Errorf("bad time format, expected=%s got=%s", fstr, str) 34 + // Reject -00:00 (valid RFC 3339 but disallowed by ATProto) 35 + if strings.HasSuffix(str, "-00:00") { 36 + return "", fmt.Errorf("bad time format, -00:00 timezone offset is not allowed, got=%s", str) 37 + } 38 + 39 + t, err := time.Parse(time.RFC3339Nano, str) 40 + if err != nil { 41 + // Fall back to file-safe variant (e.g. 2024-09-13T18-10-17-090Z) 42 + if bits := RE.FindStringSubmatch(str); bits != nil { 43 + if bits[2] < "01" || bits[2] > "12" || bits[3] < "01" || bits[3] > "31" || 44 + bits[4] > "23" || bits[5] > "59" || bits[6] > "60" { 45 + return "", fmt.Errorf("bad time format, invalid date/time values in %s", str) 46 + } 47 + return AQTime(str), nil 48 + } 49 + return "", fmt.Errorf("bad time format: %w", err) 35 50 } 36 - return AQTime(str), nil 51 + 52 + // Reject if UTC normalization results in a negative year 53 + utc := t.UTC() 54 + if utc.Year() < 0 { 55 + return "", fmt.Errorf("bad time format, datetime normalizes to negative year: %s", str) 56 + } 57 + 58 + // Normalize to canonical UTC millisecond format 59 + return AQTime(utc.Format(fstr)), nil 37 60 } 38 61 39 62 func FromTime(t time.Time) AQTime {
+63 -2
pkg/aqtime/aqtime_test.go
··· 35 35 } 36 36 } 37 37 38 + // Valid ATProto datetime examples from the spec 39 + // https://atproto.com/specs/lexicon#datetime 40 + func TestATProtoValidCases(t *testing.T) { 41 + tests := []struct { 42 + input string 43 + wantMs string // expected millisecond portion after normalization 44 + wantHr string // expected hour after UTC normalization 45 + wantMin string 46 + }{ 47 + {"1985-04-12T23:20:50.123Z", "123", "23", "20"}, 48 + {"1985-04-12T23:20:50.123456Z", "123", "23", "20"}, 49 + {"1985-04-12T23:20:50.120Z", "120", "23", "20"}, 50 + {"1985-04-12T23:20:50.120000Z", "120", "23", "20"}, 51 + {"0001-01-01T00:00:00.000Z", "000", "00", "00"}, 52 + {"0000-01-01T00:00:00.000Z", "000", "00", "00"}, 53 + {"1985-04-12T23:20:50.12345678912345Z", "123", "23", "20"}, 54 + {"1985-04-12T23:20:50Z", "000", "23", "20"}, 55 + {"1985-04-12T23:20:50.0Z", "000", "23", "20"}, 56 + {"1985-04-12T23:20:50.123+00:00", "123", "23", "20"}, 57 + {"1985-04-12T23:20:50.123-07:00", "123", "06", "20"}, // 23+7=30 -> next day 06:20 58 + } 59 + for _, tt := range tests { 60 + t.Run(tt.input, func(t *testing.T) { 61 + aqt, err := FromString(tt.input) 62 + require.NoError(t, err, "input: %s", tt.input) 63 + _, _, _, hr, min, _, ms := aqt.Parts() 64 + require.Equal(t, tt.wantMs, ms, "millis mismatch for %s", tt.input) 65 + require.Equal(t, tt.wantHr, hr, "hour mismatch for %s", tt.input) 66 + require.Equal(t, tt.wantMin, min, "minute mismatch for %s", tt.input) 67 + }) 68 + } 69 + } 70 + 38 71 func TestBadCases(t *testing.T) { 39 72 for _, str := range []string{ 73 + // existing cases 40 74 "prefix2024-09-13T18:10:17.090Z", 41 75 "2024-09-13T18-10-17-090Zsuffix", 42 76 "2024-09-13T18-10-17-090ZZZZ", 43 77 "2024-09-13T18-10-17*090ZZZZ", 78 + // ATProto spec invalid examples 79 + "1985-04-12", 80 + "1985-04-12T23:20Z", 81 + "1985-04-12T23:20:5Z", 82 + "1985-04-12T23:20:50.123", 83 + "+001985-04-12T23:20:50.123Z", 84 + "23:20:50.123Z", 85 + "-1985-04-12T23:20:50.123Z", 86 + "1985-4-12T23:20:50.123Z", 87 + "01985-04-12T23:20:50.123Z", 88 + "1985-04-12T23:20:50.123+00", 89 + "1985-04-12T23:20:50.123+0000", 90 + // ISO-8601 strict capitalization 91 + "1985-04-12t23:20:50.123Z", 92 + "1985-04-12T23:20:50.123z", 93 + // RFC-3339, but not ISO-8601 94 + "1985-04-12T23:20:50.123-00:00", 95 + "1985-04-12 23:20:50.123Z", 96 + // timezone is required 97 + "1985-04-12T23:20:50.123", 98 + // syntax looks ok, but datetime is not valid 99 + "1985-04-12T23:99:50.123Z", 100 + "1985-00-12T23:20:50.123Z", 101 + // ISO-8601, but normalizes to a negative time 102 + "0000-01-01T00:00:00+01:00", 44 103 } { 45 - _, err := FromString(str) 46 - require.Error(t, err) 104 + t.Run(str, func(t *testing.T) { 105 + _, err := FromString(str) 106 + require.Error(t, err, "expected error for: %s", str) 107 + }) 47 108 } 48 109 }
+2 -4
pkg/atproto/atproto.go
··· 231 231 "https://w3id.org/security/multikey/v1", 232 232 "https://w3id.org/security/suites/secp256k1-2019/v1", 233 233 }, 234 - "id": fmt.Sprintf("did:web:%s", host), 235 - "alsoKnownAs": []string{ 236 - fmt.Sprintf("at://%s", host), 237 - }, 234 + "id": fmt.Sprintf("did:web:%s", host), 235 + "alsoKnownAs": []string{}, 238 236 "service": []map[string]any{ 239 237 { 240 238 "id": "#bsky_fg",
+7
pkg/atproto/firehose.go
··· 305 305 atsync.Bus.Publish(msg.StreamerRepoDID, mv) 306 306 } 307 307 308 + if collection.String() == constants.PLACE_STREAM_LIVE_TELEPORT { 309 + err := atsync.Model.DeleteTeleport(ctx, uri) 310 + if err != nil { 311 + log.Error(ctx, "failed to delete teleport", "err", err) 312 + } 313 + } 314 + 308 315 if collection.String() == constants.PLACE_STREAM_MODERATION_PERMISSION { 309 316 log.Debug(ctx, "deleting moderation delegation", "userDID", evt.Repo, "rkey", rkey.String()) 310 317 err := atsync.Model.DeleteModerationDelegation(ctx, rkey.String())
+74
pkg/atproto/lexicon_permission_sets.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/lexicon" 9 + ) 10 + 11 + func generatePermissionSets(ctx context.Context, lexs []*lexicon.SchemaFile) ([]*lexicon.SchemaFile, error) { 12 + recordLexicons := []*lexicon.SchemaFile{} 13 + for _, lex := range lexs { 14 + main, ok := lex.Defs["main"] 15 + if !ok { 16 + continue 17 + } 18 + switch main.Inner.(type) { 19 + case lexicon.SchemaRecord: 20 + recordLexicons = append(recordLexicons, lex) 21 + case lexicon.SchemaPermissionSet: 22 + return nil, fmt.Errorf("unexpected permission set in `lexicons` directory: %s", lex.ID) 23 + } 24 + } 25 + 26 + allRecords := []string{} 27 + allCollectionStrings := []string{ 28 + "atproto", 29 + "blob:*/*", 30 + "repo?collection=app.bsky.feed.post&action=create", 31 + "repo?collection=app.bsky.actor.status", 32 + "repo?collection=app.bsky.graph.block", 33 + "repo?collection=app.bsky.graph.follow", 34 + "rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 35 + "rpc:app.bsky.actor.getProfiles?aud=did:web:api.bsky.app%23bsky_appview", 36 + "include:place.stream.authFull", 37 + "rpc:com.atproto.moderation.createReport?aud=*", 38 + } 39 + for _, record := range recordLexicons { 40 + allRecords = append(allRecords, record.ID) 41 + allCollectionStrings = append(allCollectionStrings, fmt.Sprintf("repo?collection=%s", record.ID)) 42 + } 43 + 44 + OAuthString = strings.Join(allCollectionStrings, " ") 45 + permissionSets := []*lexicon.SchemaFile{} 46 + 47 + // place.stream.authFull 48 + authFullTitle := "Full Streamplace Access" 49 + authFullDetail := "Full access to all Streamplace features and data." 50 + authFullSet := &lexicon.SchemaPermissionSet{ 51 + Type: "permission-set", 52 + Title: &authFullTitle, 53 + Detail: &authFullDetail, 54 + Permissions: []lexicon.SchemaPermission{ 55 + { 56 + Type: "permission", 57 + Resource: "repo", 58 + Collection: allRecords, 59 + }, 60 + }, 61 + } 62 + authFull := &lexicon.SchemaFile{ 63 + Lexicon: 1, 64 + ID: "place.stream.authFull", 65 + Defs: map[string]lexicon.SchemaDef{ 66 + "main": { 67 + Inner: authFullSet, 68 + }, 69 + }, 70 + } 71 + permissionSets = append(permissionSets, authFull) 72 + 73 + return permissionSets, nil 74 + }
+15 -1
pkg/atproto/lexicon_repo.go
··· 35 35 36 36 var LexiconRepo *atrepo.Repo 37 37 var LexiconPubMultibase string 38 + var OAuthString string 38 39 var RepoUser models.Uid = models.Uid(1) 39 40 var CarStore carstore.CarStore 40 41 var ActionCreate = "create" ··· 237 238 238 239 ops := []*comatproto.SyncSubscribeRepos_RepoOp{} 239 240 241 + lexSchemas := []*lexicon.SchemaFile{} 242 + 240 243 for _, lex := range lexs { 241 244 lexFile := lexicon.SchemaFile{} 242 245 err := json.Unmarshal(lex, &lexFile) ··· 246 249 if !strings.HasPrefix(lexFile.ID, "place.stream") { 247 250 continue 248 251 } 249 - sfw := &SchemaFileWrapper{SchemaFile: lexFile} 252 + lexSchemas = append(lexSchemas, &lexFile) 253 + } 254 + 255 + permissionSchemas, err := generatePermissionSets(ctx, lexSchemas) 256 + if err != nil { 257 + return nil, fmt.Errorf("failed to generate permission sets: %w", err) 258 + } 259 + 260 + lexSchemas = append(lexSchemas, permissionSchemas...) 261 + 262 + for _, lexFile := range lexSchemas { 263 + sfw := &SchemaFileWrapper{SchemaFile: *lexFile} 250 264 rpath := fmt.Sprintf("com.atproto.lexicon.schema/%s", lexFile.ID) 251 265 newCid, err := spid.GetCID(sfw) 252 266 if err != nil {
+12
pkg/atproto/lexicon_repo_queries.go
··· 126 126 Value: &lexutil.LexiconTypeDecoder{Val: rec}, 127 127 }, nil 128 128 } 129 + 130 + func LexiconRepoGetRepo(ctx context.Context, since string) ([]byte, error) { 131 + buf := bytes.Buffer{} 132 + 133 + repoLock.Lock() 134 + defer repoLock.Unlock() 135 + err := CarStore.ReadUserCar(ctx, RepoUser, since, true, &buf) 136 + if err != nil { 137 + return nil, fmt.Errorf("LexiconRepoGetRepo: failed to read user car: %w", err) 138 + } 139 + return buf.Bytes(), nil 140 + }
+92 -1
pkg/atproto/sync.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "reflect" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/bluesky-social/indigo/api/bsky" ··· 73 74 return fmt.Errorf("failed to create block: %w", err) 74 75 } 75 76 block, err = atsync.Model.GetBlock(ctx, rkey.String()) 76 - if err != nil { 77 + if err != nil || block == nil { 77 78 return fmt.Errorf("failed to get block after we just saved it?!: %w", err) 78 79 } 79 80 streamplaceBlock, err := block.ToStreamplaceBlock() ··· 117 118 if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil { 118 119 mcm.ReplyToCID = &rec.Reply.Parent.Cid 119 120 } 121 + 122 + // check if we have any link facets with 'javascript:' links 123 + for _, facet := range rec.Facets { 124 + for _, feature := range facet.Features { 125 + if link := feature.RichtextFacet_Link; link != nil { 126 + if link.Uri != "" && strings.HasPrefix(strings.ToLower(link.Uri), "javascript:") { 127 + log.Warn(ctx, "excluding message with javascript: link", "uri", aturi.String(), "link", link.Uri) 128 + return nil 129 + } 130 + } 131 + } 132 + } 133 + 120 134 err = atsync.Model.CreateChatMessage(ctx, mcm) 121 135 if err != nil { 122 136 log.Error(ctx, "failed to create chat message", "err", err) ··· 365 379 } 366 380 task.ChatProfile = spcp 367 381 } 382 + 383 + case *streamplace.LiveTeleport: 384 + if r == nil { 385 + return nil 386 + } 387 + startsAt, err := time.Parse(time.RFC3339, rec.StartsAt) 388 + if err != nil { 389 + log.Error(ctx, "failed to parse startsAt", "err", err) 390 + return nil 391 + } 392 + viewerCount := atsync.Bus.GetViewerCount(userDID) 393 + tp := &model.Teleport{ 394 + CID: cid, 395 + URI: aturi.String(), 396 + StartsAt: startsAt, 397 + DurationSeconds: rec.DurationSeconds, 398 + ViewerCount: int64(viewerCount), 399 + Teleport: recCBOR, 400 + RepoDID: userDID, 401 + TargetDID: rec.Streamer, 402 + } 403 + err = atsync.Model.CreateTeleport(ctx, tp) 404 + if err != nil { 405 + return fmt.Errorf("failed to create teleport: %w", err) 406 + } 407 + go atsync.Bus.Publish(userDID, rec) 408 + 409 + // schedule arrival notification 10 seconds after startsAt 410 + arrivalTime := startsAt.Add(10 * time.Second) 411 + waitDuration := time.Until(arrivalTime) 412 + if waitDuration < 0 { 413 + waitDuration = 0 414 + } 415 + 416 + time.AfterFunc(waitDuration, func() { 417 + // verify teleport still exists 418 + existingTp, err := atsync.Model.GetTeleportByURI(aturi.String()) 419 + if err != nil { 420 + log.Error(ctx, "failed to get teleport by uri", "err", err) 421 + return 422 + } 423 + if existingTp == nil || existingTp.Denied { 424 + log.Debug(ctx, "teleport no longer active, skipping arrival notification", "uri", aturi.String()) 425 + return 426 + } 427 + 428 + // get the source profile 429 + sourceRepo, err := atsync.Model.GetRepo(userDID) 430 + if err != nil { 431 + log.Error(ctx, "failed to get source repo", "err", err) 432 + return 433 + } 434 + 435 + viewerCount := existingTp.ViewerCount 436 + 437 + arrivalMsg := &streamplace.Livestream_TeleportArrival{ 438 + LexiconTypeID: "place.stream.livestream#teleportArrival", 439 + TeleportUri: aturi.String(), 440 + Source: &bsky.ActorDefs_ProfileViewBasic{ 441 + Did: userDID, 442 + Handle: sourceRepo.Handle, 443 + }, 444 + ViewerCount: int64(viewerCount), 445 + StartsAt: rec.StartsAt, 446 + } 447 + 448 + // get the source chat profile 449 + chatProfile, err := atsync.Model.GetChatProfile(ctx, userDID) 450 + if err == nil && chatProfile != nil { 451 + spcp, err := chatProfile.ToStreamplaceChatProfile() 452 + if err == nil { 453 + arrivalMsg.ChatProfile = spcp 454 + } 455 + } 456 + 457 + atsync.Bus.Publish(rec.Streamer, arrivalMsg) 458 + }) 368 459 369 460 case *streamplace.Key: 370 461 log.Debug(ctx, "creating key", "key", rec)
+18 -12
pkg/cmd/streamplace.go
··· 29 29 "stream.place/streamplace/pkg/director" 30 30 "stream.place/streamplace/pkg/gstinit" 31 31 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 32 + "stream.place/streamplace/pkg/localdb" 32 33 "stream.place/streamplace/pkg/log" 33 34 "stream.place/streamplace/pkg/media" 34 35 "stream.place/streamplace/pkg/notifications" ··· 237 238 return fmt.Errorf("error creating streamplace dir at %s:%w", cli.DataDir, err) 238 239 } 239 240 241 + ldb, err := localdb.MakeDB(cli.LocalDBURL) 242 + if err != nil { 243 + return err 244 + } 245 + 240 246 mod, err := model.MakeDB(cli.DataFilePath([]string{"index"})) 241 247 if err != nil { 242 248 return err ··· 291 297 return fmt.Errorf("failed to migrate: %w", err) 292 298 } 293 299 294 - mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync) 300 + mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync, ldb) 295 301 if err != nil { 296 302 return err 297 303 } ··· 310 316 } 311 317 host = u.Host 312 318 clientMetadata = &oatproxy.OAuthClientMetadata{ 313 - Scope: "atproto transition:generic", 319 + Scope: atproto.OAuthString, 314 320 ClientName: "Streamplace", 315 321 RedirectURIs: []string{ 316 322 fmt.Sprintf("%s/login", cli.OwnPublicURL()), ··· 320 326 } else { 321 327 host = cli.BroadcasterHost 322 328 clientMetadata = &oatproxy.OAuthClientMetadata{ 323 - Scope: "atproto transition:generic", 329 + Scope: atproto.OAuthString, 324 330 ClientName: "Streamplace", 325 331 RedirectURIs: []string{ 326 332 fmt.Sprintf("https://%s/login", cli.BroadcasterHost), ··· 374 380 UpdateOAuthSession: state.UpdateOAuthSession, 375 381 GetOAuthSession: state.LoadOAuthSession, 376 382 Lock: state.GetNamedLock, 377 - Scope: "atproto transition:generic", 383 + Scope: atproto.OAuthString, 378 384 UpstreamJWK: cli.JWK, 379 385 DownstreamJWK: cli.AccessJWK, 380 386 ClientMetadata: clientMetadata, 381 387 Public: cli.PublicOAuth, 388 + HTTPClient: &aqhttp.Client, 382 389 }) 383 - d := director.NewDirector(mm, mod, &cli, b, op, state, replicator) 384 - a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op) 390 + d := director.NewDirector(mm, mod, &cli, b, op, state, replicator, ldb) 391 + a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) 385 392 if err != nil { 386 393 return err 387 394 } ··· 412 419 if cli.RTMPServerAddon != "" { 413 420 group.Go(func() error { 414 421 return rtmps.ServeRTMPSAddon(ctx, &cli) 415 - }) 416 - } else { 417 - group.Go(func() error { 418 - return a.ServeRTMPS(ctx, &cli) 419 422 }) 420 423 } 424 + group.Go(func() error { 425 + return a.ServeRTMPS(ctx, &cli) 426 + }) 421 427 } else { 422 428 group.Go(func() error { 423 429 return a.ServeHTTP(ctx) ··· 447 453 }) 448 454 449 455 group.Go(func() error { 450 - return storage.StartSegmentCleaner(ctx, mod, &cli) 456 + return storage.StartSegmentCleaner(ctx, ldb, &cli) 451 457 }) 452 458 453 459 group.Go(func() error { 454 - return mod.StartSegmentCleaner(ctx) 460 + return ldb.StartSegmentCleaner(ctx) 455 461 }) 456 462 457 463 group.Go(func() error {
+7
pkg/config/config.go
··· 56 56 Build *BuildFlags 57 57 DataDir string 58 58 DBURL string 59 + LocalDBURL string 59 60 EthAccountAddr string 60 61 EthKeystorePath string 61 62 EthPassword string ··· 67 68 HTTPSAddr string 68 69 RTMPAddr string 69 70 RTMPSAddr string 71 + RTMPSAddonAddr string 70 72 Secure bool 71 73 NoMist bool 72 74 MistAdminPort int ··· 139 141 SegmentDebugDir string 140 142 AdminDIDs []string 141 143 Syndicate []string 144 + PlayerTelemetry bool 142 145 } 143 146 144 147 // ContentFilters represents the content filtering configuration ··· 212 215 fs.IntVar(&cli.RateLimitBurst, "rate-limit-burst", 0, "rate limit burst for requests per ip") 213 216 fs.IntVar(&cli.RateLimitWebsocket, "rate-limit-websocket", 10, "number of concurrent websocket connections allowed per ip") 214 217 fs.StringVar(&cli.RTMPServerAddon, "rtmp-server-addon", "", "address of external RTMP server to forward streams to") 218 + fs.StringVar(&cli.RTMPSAddonAddr, "rtmps-addon-addr", ":1936", "address to listen for RTMPS on the addon server") 215 219 fs.StringVar(&cli.RTMPSAddr, "rtmps-addr", ":1935", "address to listen for RTMPS connections (when --secure=true)") 216 220 fs.StringVar(&cli.RTMPAddr, "rtmp-addr", ":1935", "address to listen for RTMP connections (when --secure=false)") 217 221 cli.JSONFlag(fs, &cli.DiscordWebhooks, "discord-webhooks", "[]", "JSON array of Discord webhooks to send notifications to") ··· 238 242 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 243 cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations") 240 244 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") 245 + fs.BoolVar(&cli.PlayerTelemetry, "player-telemetry", true, "enable player telemetry") 246 + fs.StringVar(&cli.LocalDBURL, "local-db-url", "sqlite://$SP_DATA_DIR/localdb.sqlite", "URL of the local database to use for storing local data") 247 + cli.dataDirFlags = append(cli.dataDirFlags, &cli.LocalDBURL) 241 248 242 249 fs.Bool("external-signing", true, "DEPRECATED, does nothing.") 243 250 fs.Bool("insecure", false, "DEPRECATED, does nothing.")
+13
pkg/config/git/git.go
··· 82 82 homebrew := flag.Bool("homebrew", false, "print homebrew formula") 83 83 84 84 flag.Parse() 85 + 86 + // handle CF_PAGES environment fallback 87 + if os.Getenv("CF_PAGES") != "" && *javascript { 88 + out := `export const version = "unknown"; export const buildTime = 0; export const uuid = "00000000-0000-0000-0000-000000000000";` 89 + if *output != "" { 90 + if err := os.WriteFile(*output, []byte(out), 0644); err != nil { 91 + return err 92 + } 93 + } else { 94 + fmt.Print(out) 95 + } 96 + return nil 97 + } 85 98 r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) 86 99 if err != nil { 87 100 return err
+1
pkg/constants/constants.go
··· 5 5 var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all 6 6 var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all 7 7 var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all 8 + var PLACE_STREAM_LIVE_TELEPORT = "place.stream.live.teleport" //nolint:all 8 9 var PLACE_STREAM_MODERATION_PERMISSION = "place.stream.moderation.permission" //nolint:all 9 10 var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all 10 11 var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all
+8 -1
pkg/director/director.go
··· 9 9 "golang.org/x/sync/errgroup" 10 10 "stream.place/streamplace/pkg/bus" 11 11 "stream.place/streamplace/pkg/config" 12 + "stream.place/streamplace/pkg/localdb" 12 13 "stream.place/streamplace/pkg/log" 13 14 "stream.place/streamplace/pkg/media" 14 15 "stream.place/streamplace/pkg/model" ··· 32 33 op *oatproxy.OATProxy 33 34 statefulDB *statedb.StatefulDB 34 35 replicator replication.Replicator 36 + localDB localdb.LocalDB 35 37 } 36 38 37 - func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator) *Director { 39 + func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator, ldb localdb.LocalDB) *Director { 38 40 return &Director{ 39 41 mm: mm, 40 42 mod: mod, ··· 45 47 op: op, 46 48 statefulDB: statefulDB, 47 49 replicator: replicator, 50 + localDB: ldb, 48 51 } 49 52 } 50 53 ··· 76 79 started: make(chan struct{}), 77 80 statefulDB: d.statefulDB, 78 81 replicator: d.replicator, 82 + // Initialize notification channels (buffered size 1 for coalescing) 83 + statusUpdateChan: make(chan struct{}, 1), 84 + originUpdateChan: make(chan struct{}, 1), 85 + localDB: d.localDB, 79 86 } 80 87 d.streamSessions[not.Segment.RepoDID] = ss 81 88 g.Go(func() error {
+198 -38
pkg/director/stream_session.go
··· 4 4 "bytes" 5 5 "context" 6 6 "fmt" 7 - "sync" 7 + "net/url" 8 8 "time" 9 9 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 12 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 13 "github.com/bluesky-social/indigo/util" 14 14 "github.com/bluesky-social/indigo/xrpc" 15 + "github.com/google/uuid" 15 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 16 17 "golang.org/x/sync/errgroup" 17 18 "stream.place/streamplace/pkg/aqhttp" ··· 19 20 "stream.place/streamplace/pkg/bus" 20 21 "stream.place/streamplace/pkg/config" 21 22 "stream.place/streamplace/pkg/livepeer" 23 + "stream.place/streamplace/pkg/localdb" 22 24 "stream.place/streamplace/pkg/log" 23 25 "stream.place/streamplace/pkg/media" 24 26 "stream.place/streamplace/pkg/model" ··· 42 44 segmentChan chan struct{} 43 45 lastStatus time.Time 44 46 lastStatusCID *string 45 - lastStatusLock sync.Mutex 46 47 lastOriginTime time.Time 47 - lastOriginLock sync.Mutex 48 - g *errgroup.Group 49 - started chan struct{} 50 - ctx context.Context 51 - packets []bus.PacketizedSegment 52 - statefulDB *statedb.StatefulDB 53 - replicator replication.Replicator 48 + localDB localdb.LocalDB 49 + 50 + // Channels for background workers 51 + statusUpdateChan chan struct{} // Signal to update status 52 + originUpdateChan chan struct{} // Signal to update broadcast origin 53 + 54 + g *errgroup.Group 55 + started chan struct{} 56 + ctx context.Context 57 + packets []bus.PacketizedSegment 58 + statefulDB *statedb.StatefulDB 59 + replicator replication.Replicator 54 60 } 55 61 56 62 func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotification) error { ··· 109 115 110 116 close(ss.started) 111 117 118 + // Start background workers for status and origin updates 119 + ss.g.Go(func() error { 120 + return ss.statusUpdateLoop(ctx, spseg.Creator) 121 + }) 122 + ss.g.Go(func() error { 123 + return ss.originUpdateLoop(ctx) 124 + }) 125 + 126 + if notif.Local { 127 + ss.Go(ctx, func() error { 128 + return ss.HandleMultistreamTargets(ctx) 129 + }) 130 + } 131 + 112 132 for { 113 133 select { 114 134 case <-ss.segmentChan: 115 135 // reset timer 116 136 case <-ctx.Done(): 137 + // Signal all background workers to stop 117 138 return ss.g.Wait() 118 139 // case <-time.After(time.Minute * 1): 119 140 case <-time.After(ss.cli.StreamSessionTimeout): ··· 122 143 for _, r := range allRenditions { 123 144 ss.bus.EndSession(ctx, spseg.Creator, r.Name) 124 145 } 146 + // Signal background workers to stop 125 147 if notif.Local { 126 148 ss.Go(ctx, func() error { 127 149 return ss.DeleteStatus(spseg.Creator) ··· 158 180 aqt := aqtime.FromTime(notif.Segment.StartTime) 159 181 ctx = log.WithLogValues(ctx, "segID", notif.Segment.ID, "repoDID", notif.Segment.RepoDID, "timestamp", aqt.FileSafeString()) 160 182 notif.Segment.MediaData.Size = len(notif.Data) 161 - err := ss.mod.CreateSegment(notif.Segment) 183 + err := ss.localDB.CreateSegment(notif.Segment) 162 184 if err != nil { 163 185 return fmt.Errorf("could not add segment to database: %w", err) 164 186 } ··· 182 204 } 183 205 184 206 if notif.Local { 185 - ss.Go(ctx, func() error { 186 - return ss.UpdateStatus(ctx, spseg.Creator) 187 - }) 188 - 189 - ss.Go(ctx, func() error { 190 - return ss.UpdateBroadcastOrigin(ctx) 191 - }) 207 + ss.UpdateStatus(ctx, spseg.Creator) 208 + ss.UpdateBroadcastOrigin(ctx) 192 209 } 193 210 194 211 if ss.cli.LivepeerGatewayURL != "" { ··· 244 261 if err != nil { 245 262 log.Error(ctx, "failed to enqueue notification task", "err", err) 246 263 } 247 - return ss.UpdateStatus(ctx, spseg.Creator) 264 + ss.UpdateStatus(ctx, spseg.Creator) 265 + return nil 248 266 }) 249 267 } else { 250 268 log.Warn(ctx, "no livestream detected in stream, skipping notification blast", "repoDID", spseg.Creator) ··· 276 294 return nil 277 295 } 278 296 defer lock.Unlock() 279 - oldThumb, err := ss.mod.LatestThumbnailForUser(not.Segment.RepoDID) 297 + oldThumb, err := ss.localDB.LatestThumbnailForUser(not.Segment.RepoDID) 280 298 if err != nil { 281 299 return err 282 300 } ··· 295 313 if err != nil { 296 314 return err 297 315 } 298 - thumb := &model.Thumbnail{ 316 + thumb := &localdb.Thumbnail{ 299 317 Format: "jpeg", 300 318 SegmentID: not.Segment.ID, 301 319 } 302 - err = ss.mod.CreateThumbnail(thumb) 320 + err = ss.localDB.CreateThumbnail(thumb) 303 321 if err != nil { 304 322 return err 305 323 } 306 324 return nil 307 325 } 308 326 309 - func (ss *StreamSession) UpdateStatus(ctx context.Context, repoDID string) error { 310 - ctx = log.WithLogValues(ctx, "func", "UpdateStatus") 311 - ss.lastStatusLock.Lock() 312 - defer ss.lastStatusLock.Unlock() 313 - if time.Since(ss.lastStatus) < time.Minute { 314 - log.Debug(ctx, "not updating status, last status was less than 1 minute ago") 315 - return nil 327 + // UpdateStatus signals the background worker to update status (non-blocking) 328 + func (ss *StreamSession) UpdateStatus(ctx context.Context, repoDID string) { 329 + select { 330 + case ss.statusUpdateChan <- struct{}{}: 331 + default: 332 + // Channel full, signal already pending 333 + } 334 + } 335 + 336 + // statusUpdateLoop runs as a background goroutine for the session lifetime 337 + func (ss *StreamSession) statusUpdateLoop(ctx context.Context, repoDID string) error { 338 + ctx = log.WithLogValues(ctx, "func", "statusUpdateLoop") 339 + for { 340 + select { 341 + case <-ctx.Done(): 342 + return nil 343 + case <-ss.statusUpdateChan: 344 + if time.Since(ss.lastStatus) < time.Minute { 345 + log.Debug(ctx, "not updating status, last status was less than 1 minute ago") 346 + continue 347 + } 348 + if err := ss.doUpdateStatus(ctx, repoDID); err != nil { 349 + log.Error(ctx, "failed to update status", "error", err) 350 + } 351 + } 316 352 } 353 + } 354 + 355 + // doUpdateStatus performs the actual status update work 356 + func (ss *StreamSession) doUpdateStatus(ctx context.Context, repoDID string) error { 357 + ctx = log.WithLogValues(ctx, "func", "doUpdateStatus") 317 358 318 359 client, err := ss.GetClientByDID(repoDID) 319 360 if err != nil { ··· 362 403 }, 363 404 } 364 405 365 - duration := int64(120) 406 + duration := int64(10) 366 407 status := bsky.ActorStatus{ 367 408 Status: "app.bsky.actor.status#live", 368 409 DurationMinutes: &duration, ··· 415 456 416 457 func (ss *StreamSession) DeleteStatus(repoDID string) error { 417 458 // need a special extra context because the stream session context is already cancelled 459 + // No lock needed - this runs during teardown after the background worker has exited 418 460 ctx := log.WithLogValues(context.Background(), "func", "DeleteStatus", "repoDID", repoDID) 419 - ss.lastStatusLock.Lock() 420 - defer ss.lastStatusLock.Unlock() 421 461 if ss.lastStatusCID == nil { 422 462 log.Debug(ctx, "no status cid to delete") 423 463 return nil ··· 446 486 447 487 var originUpdateInterval = time.Second * 30 448 488 449 - func (ss *StreamSession) UpdateBroadcastOrigin(ctx context.Context) error { 450 - ctx = log.WithLogValues(ctx, "func", "UpdateStatus") 451 - ss.lastOriginLock.Lock() 452 - defer ss.lastOriginLock.Unlock() 453 - if time.Since(ss.lastOriginTime) < originUpdateInterval { 454 - log.Debug(ctx, "not updating origin, last origin was less than 30 seconds ago") 455 - return nil 489 + // UpdateBroadcastOrigin signals the background worker to update origin (non-blocking) 490 + func (ss *StreamSession) UpdateBroadcastOrigin(ctx context.Context) { 491 + select { 492 + case ss.originUpdateChan <- struct{}{}: 493 + default: 494 + // Channel full, signal already pending 456 495 } 496 + } 497 + 498 + // originUpdateLoop runs as a background goroutine for the session lifetime 499 + func (ss *StreamSession) originUpdateLoop(ctx context.Context) error { 500 + ctx = log.WithLogValues(ctx, "func", "originUpdateLoop") 501 + for { 502 + select { 503 + case <-ctx.Done(): 504 + return nil 505 + case <-ss.originUpdateChan: 506 + if time.Since(ss.lastOriginTime) < originUpdateInterval { 507 + log.Debug(ctx, "not updating origin, last origin was less than 30 seconds ago") 508 + continue 509 + } 510 + if err := ss.doUpdateBroadcastOrigin(ctx); err != nil { 511 + log.Error(ctx, "failed to update broadcast origin", "error", err) 512 + } 513 + } 514 + } 515 + } 516 + 517 + // doUpdateBroadcastOrigin performs the actual broadcast origin update work 518 + func (ss *StreamSession) doUpdateBroadcastOrigin(ctx context.Context) error { 519 + ctx = log.WithLogValues(ctx, "func", "doUpdateBroadcastOrigin") 520 + 457 521 broadcaster := fmt.Sprintf("did:web:%s", ss.cli.BroadcasterHost) 458 522 origin := streamplace.BroadcastOrigin{ 459 523 Streamer: ss.repoDID, ··· 679 743 680 744 return client, nil 681 745 } 746 + 747 + type runningMultistream struct { 748 + cancel func() 749 + key string 750 + pushID string 751 + url string 752 + } 753 + 754 + func sanitizeMultistreamTargetURL(uri string) string { 755 + u, err := url.Parse(uri) 756 + if err != nil { 757 + return uri 758 + } 759 + u.Path = "/redacted" 760 + return u.String() 761 + } 762 + 763 + // we're making an attempt here not to log (sensitive) stream keys, so we're 764 + // referencing by atproto URI 765 + func (ss *StreamSession) HandleMultistreamTargets(ctx context.Context) error { 766 + ctx = log.WithLogValues(ctx, "system", "multistreaming") 767 + isTrue := true 768 + // {target.Uri}:{rec.Url} -> runningMultistream 769 + // no concurrency issues, it's only used from this one loop 770 + running := map[string]*runningMultistream{} 771 + for { 772 + targets, err := ss.statefulDB.ListMultistreamTargets(ss.repoDID, 100, 0, &isTrue) 773 + if err != nil { 774 + return fmt.Errorf("failed to list multistream targets: %w", err) 775 + } 776 + currentRunning := map[string]bool{} 777 + for _, targetView := range targets { 778 + rec, ok := targetView.Record.Val.(*streamplace.MultistreamTarget) 779 + if !ok { 780 + log.Error(ctx, "failed to convert multistream target to streamplace multistream target", "uri", targetView.Uri) 781 + continue 782 + } 783 + uu, err := uuid.NewV7() 784 + if err != nil { 785 + return err 786 + } 787 + ctx := log.WithLogValues(ctx, "url", sanitizeMultistreamTargetURL(rec.Url), "pushID", uu.String()) 788 + key := fmt.Sprintf("%s:%s", targetView.Uri, rec.Url) 789 + if running[key] == nil { 790 + childCtx, childCancel := context.WithCancel(ctx) 791 + ss.Go(ctx, func() error { 792 + log.Log(ctx, "starting multistream target", "uri", targetView.Uri) 793 + err := ss.statefulDB.CreateMultistreamEvent(targetView.Uri, "starting multistream target", "pending") 794 + if err != nil { 795 + log.Error(ctx, "failed to create multistream event", "error", err) 796 + } 797 + return ss.StartMultistreamTarget(childCtx, targetView) 798 + }) 799 + running[key] = &runningMultistream{ 800 + cancel: childCancel, 801 + key: key, 802 + pushID: uu.String(), 803 + url: sanitizeMultistreamTargetURL(rec.Url), 804 + } 805 + } 806 + currentRunning[key] = true 807 + } 808 + for key := range running { 809 + if !currentRunning[key] { 810 + log.Log(ctx, "stopping multistream target", "url", sanitizeMultistreamTargetURL(running[key].url), "pushID", running[key].pushID) 811 + running[key].cancel() 812 + delete(running, key) 813 + } 814 + } 815 + select { 816 + case <-ctx.Done(): 817 + return nil 818 + case <-time.After(time.Second * 5): 819 + continue 820 + } 821 + } 822 + } 823 + 824 + func (ss *StreamSession) StartMultistreamTarget(ctx context.Context, targetView *streamplace.MultistreamDefs_TargetView) error { 825 + for { 826 + err := ss.mm.RTMPPush(ctx, ss.repoDID, "source", targetView) 827 + if err != nil { 828 + log.Error(ctx, "failed to push to RTMP server", "error", err) 829 + err := ss.statefulDB.CreateMultistreamEvent(targetView.Uri, err.Error(), "error") 830 + if err != nil { 831 + log.Error(ctx, "failed to create multistream event", "error", err) 832 + } 833 + } 834 + select { 835 + case <-ctx.Done(): 836 + return nil 837 + case <-time.After(time.Second * 5): 838 + continue 839 + } 840 + } 841 + }
+2
pkg/gen/gen.go
··· 26 26 streamplace.ChatMessage_ReplyRef{}, 27 27 streamplace.ServerSettings{}, 28 28 streamplace.ChatGate{}, 29 + streamplace.MultistreamTarget{}, 29 30 streamplace.BroadcastOrigin{}, 30 31 streamplace.BroadcastSyndication{}, 31 32 streamplace.MetadataConfiguration{}, ··· 33 34 streamplace.MetadataContentRights{}, 34 35 streamplace.MetadataContentWarnings{}, 35 36 streamplace.ModerationPermission{}, 37 + streamplace.LiveTeleport{}, 36 38 streamplace.LiveRecommendations{}, 37 39 ); err != nil { 38 40 panic(err)
+1 -1
pkg/integrations/discord/send-livestream.go
··· 67 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 68 } else { 69 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 - payload.Embeds[0].URL = fmt.Sprintf("%s/%s", *ls.Url, lsv.Author.Handle) 70 + payload.Embeds[0].URL = *ls.Url 71 71 } 72 72 } 73 73
+139 -11
pkg/linking/linking.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/json" 6 7 "errors" 7 8 "fmt" 9 + "log" 8 10 "net/url" 9 11 10 12 "golang.org/x/net/html" 13 + "stream.place/streamplace/pkg/config" 14 + "stream.place/streamplace/pkg/statedb" 11 15 "stream.place/streamplace/pkg/streamplace" 12 16 ) 13 17 14 18 type Linker struct { 15 19 BaseHTML []byte 20 + sdb *statedb.StatefulDB 21 + cli *config.CLI 16 22 } 17 23 18 - func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { 24 + func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { 19 25 _, err := html.Parse(bytes.NewReader(baseHTML)) 20 26 if err != nil { 21 27 return nil, err 22 28 } 23 29 24 - return &Linker{BaseHTML: baseHTML}, nil 30 + return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil 25 31 } 26 32 27 33 type PageConfig struct { 28 34 Title string 29 35 Metas []MetaTag 30 36 SentryDSN string 37 + Branding []string 31 38 } 32 39 33 40 // Define all meta tags in a structured way ··· 37 44 Content string 38 45 } 39 46 47 + var BrandingAssetList = [...]string{ 48 + "siteTitle", 49 + "siteDescription", 50 + "primaryColor", 51 + "accentColor", 52 + "defaultStreamer", 53 + "mainLogo", 54 + "favicon", 55 + "sidebarBg", 56 + "legalLinks", 57 + } 58 + 59 + // fetch branding assets for a given broadcaster DID 60 + func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { 61 + ret := make([]streamplace.BrandingGetBranding_BrandingAsset, 0) 62 + for _, asset := range BrandingAssetList { 63 + blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) 64 + if err != nil { 65 + // this can probably include a 'record not found' error, in which case we skip 66 + log.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) 67 + continue 68 + } 69 + asset := streamplace.BrandingGetBranding_BrandingAsset{ 70 + Key: blob.Key, 71 + MimeType: blob.MimeType, 72 + } 73 + 74 + if blob.Width != nil { 75 + w := int64(*blob.Width) 76 + asset.Width = &w 77 + } 78 + if blob.Height != nil { 79 + h := int64(*blob.Height) 80 + asset.Height = &h 81 + } 82 + 83 + // process based on mime type 84 + if blob.MimeType == "text/plain" { 85 + str := string(blob.Data) 86 + asset.Data = &str 87 + } else { 88 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) 89 + asset.Url = &url 90 + } 91 + ret = append(ret, asset) 92 + } 93 + 94 + return ret, nil 95 + } 96 + 40 97 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 41 98 if u == nil { 42 99 return nil, errors.New("url is nil") ··· 49 106 return nil, errors.New("livestream view is not a livestream") 50 107 } 51 108 52 - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) 109 + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) 53 110 outURL := u.String() 54 - 55 - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) 56 111 57 112 thumbURL, _ := url.Parse(u.String()) 58 113 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 66 121 // Facebook Meta Tags 67 122 {Type: "property", Key: "og:url", Content: u.String()}, 68 123 {Type: "property", Key: "og:type", Content: "website"}, 69 - {Type: "property", Key: "og:title", Content: titleStr}, 70 124 {Type: "property", Key: "og:description", Content: ls.Title}, 71 125 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 72 126 ··· 74 128 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 75 129 {Type: "property", Key: "twitter:domain", Content: u.Host}, 76 130 {Type: "property", Key: "twitter:url", Content: outURL}, 77 - {Type: "name", Key: "twitter:title", Content: titleStr}, 78 131 {Type: "name", Key: "twitter:description", Content: ls.Title}, 79 132 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 80 133 } 134 + brandingTitle := "streamplace node" 135 + if l.sdb != nil && l.cli != nil { 136 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 137 + if err == nil { 138 + for i := range branding { 139 + val := branding[i] 140 + if val.Key == "siteTitle" && val.Data != nil { 141 + brandingTitle = *val.Data 142 + } 143 + marshalledJson, err := json.Marshal(val) 144 + if err != nil { 145 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 146 + continue 147 + } 148 + metaTags = append(metaTags, MetaTag{ 149 + Type: "name", 150 + Key: "internal-brand:" + val.Key, 151 + Content: string(marshalledJson), 152 + }) 153 + } 154 + } else { 155 + // log but we should not block rendering 156 + fmt.Printf("error fetching branding assets: %v\n", err) 157 + } 158 + } 159 + 160 + // do twitter/og title after 161 + metaTags = append(metaTags, MetaTag{ 162 + Type: "property", 163 + Key: "og:title", 164 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 165 + }) 166 + metaTags = append(metaTags, MetaTag{ 167 + Type: "name", 168 + Key: "twitter:title", 169 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 170 + }) 81 171 82 172 return l.GenerateHTML(ctx, &PageConfig{ 83 - Title: pageTitle, 173 + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), 84 174 Metas: metaTags, 85 175 SentryDSN: sentryDSN, 86 176 }) ··· 103 193 {Type: "property", Key: "og:url", Content: u.String()}, 104 194 {Type: "property", Key: "og:type", Content: "website"}, 105 195 {Type: "property", Key: "og:title", Content: "Stream.place"}, 106 - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 196 + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, 107 197 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 108 198 109 199 // Twitter Meta Tags ··· 111 201 {Type: "property", Key: "twitter:domain", Content: u.Host}, 112 202 {Type: "property", Key: "twitter:url", Content: u.String()}, 113 203 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 114 - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 204 + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, 115 205 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 116 206 } 117 207 208 + brandingTitle := "streamplace node" 209 + if l.sdb != nil && l.cli != nil { 210 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 211 + if err == nil { 212 + for i := range branding { 213 + val := branding[i] 214 + if val.Key == "siteTitle" && val.Data != nil { 215 + brandingTitle = *val.Data 216 + } 217 + marshalledJson, err := json.Marshal(val) 218 + if err != nil { 219 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 220 + continue 221 + } 222 + metaTags = append(metaTags, MetaTag{ 223 + Type: "name", 224 + Key: "internal-brand:" + val.Key, 225 + Content: string(marshalledJson), 226 + }) 227 + } 228 + } else { 229 + // log but we should not block rendering 230 + fmt.Printf("error fetching branding assets: %v\n", err) 231 + } 232 + } 233 + 234 + // do twitter/og title after 235 + metaTags = append(metaTags, MetaTag{ 236 + Type: "property", 237 + Key: "og:title", 238 + Content: brandingTitle, 239 + }) 240 + metaTags = append(metaTags, MetaTag{ 241 + Type: "name", 242 + Key: "twitter:title", 243 + Content: brandingTitle, 244 + }) 245 + 118 246 return l.GenerateHTML(ctx, &PageConfig{ 119 - Title: "Stream.place", 247 + Title: brandingTitle, 120 248 Metas: metaTags, 121 249 SentryDSN: sentryDSN, 122 250 })
+2 -2
pkg/linking/linking_test.go
··· 29 29 30 30 func TestNewLinker(t *testing.T) { 31 31 index := IndexHTML(t) 32 - linker, err := NewLinker(context.Background(), index) 32 + linker, err := NewLinker(context.Background(), index, nil, nil) 33 33 require.NoError(t, err) 34 34 require.NotNil(t, linker) 35 35 } 36 36 37 37 func TestGenerateLinkCard(t *testing.T) { 38 38 index := IndexHTML(t) 39 - linker, err := NewLinker(context.Background(), index) 39 + linker, err := NewLinker(context.Background(), index, nil, nil) 40 40 require.NoError(t, err) 41 41 require.NotNil(t, linker) 42 42
+81
pkg/localdb/localdb.go
··· 1 + package localdb 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "gorm.io/driver/sqlite" 10 + "gorm.io/gorm" 11 + "gorm.io/plugin/prometheus" 12 + "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/log" 14 + ) 15 + 16 + type LocalDB interface { 17 + CreateSegment(segment *Segment) error 18 + MostRecentSegments() ([]Segment, error) 19 + LatestSegmentForUser(user string) (*Segment, error) 20 + LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 21 + FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 22 + CreateThumbnail(thumb *Thumbnail) error 23 + LatestThumbnailForUser(user string) (*Thumbnail, error) 24 + GetSegment(id string) (*Segment, error) 25 + GetExpiredSegments(ctx context.Context) ([]Segment, error) 26 + DeleteSegment(ctx context.Context, id string) error 27 + StartSegmentCleaner(ctx context.Context) error 28 + SegmentCleaner(ctx context.Context) error 29 + } 30 + 31 + type LocalDatabase struct { 32 + DB *gorm.DB 33 + } 34 + 35 + func MakeDB(dbURL string) (LocalDB, error) { 36 + log.Log(context.Background(), "starting database", "dbURL", dbURL) 37 + if strings.HasPrefix(dbURL, "sqlite://") { 38 + dbURL = dbURL[len("sqlite://"):] 39 + } else if dbURL != ":memory:" { 40 + return nil, fmt.Errorf("unsupported database URL (most start with sqlite://): %s", dbURL) 41 + } 42 + dial := sqlite.Open(dbURL) 43 + 44 + db, err := gorm.Open(dial, &gorm.Config{ 45 + SkipDefaultTransaction: true, 46 + TranslateError: true, 47 + Logger: config.GormLogger, 48 + }) 49 + if err != nil { 50 + return nil, fmt.Errorf("error starting database: %w", err) 51 + } 52 + err = db.Exec("PRAGMA journal_mode=WAL;").Error 53 + if err != nil { 54 + return nil, fmt.Errorf("error setting journal mode: %w", err) 55 + } 56 + 57 + err = db.Use(prometheus.New(prometheus.Config{ 58 + DBName: "localdb", 59 + RefreshInterval: 10, 60 + StartServer: false, 61 + })) 62 + if err != nil { 63 + return nil, fmt.Errorf("error using prometheus plugin: %w", err) 64 + } 65 + 66 + sqlDB, err := db.DB() 67 + if err != nil { 68 + return nil, fmt.Errorf("error getting database: %w", err) 69 + } 70 + sqlDB.SetMaxOpenConns(1) 71 + for _, model := range []any{ 72 + Segment{}, 73 + Thumbnail{}, 74 + } { 75 + err = db.AutoMigrate(model) 76 + if err != nil { 77 + return nil, err 78 + } 79 + } 80 + return &LocalDatabase{DB: db}, nil 81 + }
+410
pkg/localdb/segment.go
··· 1 + package localdb 2 + 3 + import ( 4 + "context" 5 + "database/sql/driver" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "time" 10 + 11 + "gorm.io/gorm" 12 + "stream.place/streamplace/pkg/aqtime" 13 + "stream.place/streamplace/pkg/log" 14 + "stream.place/streamplace/pkg/streamplace" 15 + ) 16 + 17 + type SegmentMediadataVideo struct { 18 + Width int `json:"width"` 19 + Height int `json:"height"` 20 + FPSNum int `json:"fpsNum"` 21 + FPSDen int `json:"fpsDen"` 22 + BFrames bool `json:"bframes"` 23 + } 24 + 25 + type SegmentMediadataAudio struct { 26 + Rate int `json:"rate"` 27 + Channels int `json:"channels"` 28 + } 29 + 30 + type SegmentMediaData struct { 31 + Video []*SegmentMediadataVideo `json:"video"` 32 + Audio []*SegmentMediadataAudio `json:"audio"` 33 + Duration int64 `json:"duration"` 34 + Size int `json:"size"` 35 + } 36 + 37 + // Scan scan value into Jsonb, implements sql.Scanner interface 38 + func (j *SegmentMediaData) Scan(value any) error { 39 + bytes, ok := value.([]byte) 40 + if !ok { 41 + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) 42 + } 43 + 44 + result := SegmentMediaData{} 45 + err := json.Unmarshal(bytes, &result) 46 + *j = SegmentMediaData(result) 47 + return err 48 + } 49 + 50 + // Value return json value, implement driver.Valuer interface 51 + func (j SegmentMediaData) Value() (driver.Value, error) { 52 + return json.Marshal(j) 53 + } 54 + 55 + // ContentRights represents content rights and attribution information 56 + type ContentRights struct { 57 + CopyrightNotice *string `json:"copyrightNotice,omitempty"` 58 + CopyrightYear *int64 `json:"copyrightYear,omitempty"` 59 + Creator *string `json:"creator,omitempty"` 60 + CreditLine *string `json:"creditLine,omitempty"` 61 + License *string `json:"license,omitempty"` 62 + } 63 + 64 + // Scan scan value into ContentRights, implements sql.Scanner interface 65 + func (c *ContentRights) Scan(value any) error { 66 + if value == nil { 67 + *c = ContentRights{} 68 + return nil 69 + } 70 + bytes, ok := value.([]byte) 71 + if !ok { 72 + return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value)) 73 + } 74 + 75 + result := ContentRights{} 76 + err := json.Unmarshal(bytes, &result) 77 + *c = ContentRights(result) 78 + return err 79 + } 80 + 81 + // Value return json value, implement driver.Valuer interface 82 + func (c ContentRights) Value() (driver.Value, error) { 83 + return json.Marshal(c) 84 + } 85 + 86 + // DistributionPolicy represents distribution policy information 87 + type DistributionPolicy struct { 88 + DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 + } 90 + 91 + // Scan scan value into DistributionPolicy, implements sql.Scanner interface 92 + func (d *DistributionPolicy) Scan(value any) error { 93 + if value == nil { 94 + *d = DistributionPolicy{} 95 + return nil 96 + } 97 + bytes, ok := value.([]byte) 98 + if !ok { 99 + return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value)) 100 + } 101 + 102 + result := DistributionPolicy{} 103 + err := json.Unmarshal(bytes, &result) 104 + *d = DistributionPolicy(result) 105 + return err 106 + } 107 + 108 + // Value return json value, implement driver.Valuer interface 109 + func (d DistributionPolicy) Value() (driver.Value, error) { 110 + return json.Marshal(d) 111 + } 112 + 113 + // ContentWarningsSlice is a custom type for storing content warnings as JSON in the database 114 + type ContentWarningsSlice []string 115 + 116 + // Scan scan value into ContentWarningsSlice, implements sql.Scanner interface 117 + func (c *ContentWarningsSlice) Scan(value any) error { 118 + if value == nil { 119 + *c = ContentWarningsSlice{} 120 + return nil 121 + } 122 + bytes, ok := value.([]byte) 123 + if !ok { 124 + return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value)) 125 + } 126 + 127 + result := ContentWarningsSlice{} 128 + err := json.Unmarshal(bytes, &result) 129 + *c = ContentWarningsSlice(result) 130 + return err 131 + } 132 + 133 + // Value return json value, implement driver.Valuer interface 134 + func (c ContentWarningsSlice) Value() (driver.Value, error) { 135 + return json.Marshal(c) 136 + } 137 + 138 + type Segment struct { 139 + ID string `json:"id" gorm:"primaryKey"` 140 + SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` 141 + StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` 142 + RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` 143 + Title string `json:"title"` 144 + Size int `json:"size" gorm:"column:size"` 145 + MediaData *SegmentMediaData `json:"mediaData,omitempty"` 146 + ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"` 147 + ContentRights *ContentRights `json:"contentRights,omitempty"` 148 + DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 149 + DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 150 + } 151 + 152 + func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { 153 + aqt := aqtime.FromTime(s.StartTime) 154 + if s.MediaData == nil { 155 + return nil, fmt.Errorf("media data is nil") 156 + } 157 + if len(s.MediaData.Video) == 0 || s.MediaData.Video[0] == nil { 158 + return nil, fmt.Errorf("video data is nil") 159 + } 160 + if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 161 + return nil, fmt.Errorf("audio data is nil") 162 + } 163 + duration := s.MediaData.Duration 164 + sizei64 := int64(s.Size) 165 + 166 + // Convert model metadata to streamplace metadata 167 + var contentRights *streamplace.MetadataContentRights 168 + if s.ContentRights != nil { 169 + contentRights = &streamplace.MetadataContentRights{ 170 + CopyrightNotice: s.ContentRights.CopyrightNotice, 171 + CopyrightYear: s.ContentRights.CopyrightYear, 172 + Creator: s.ContentRights.Creator, 173 + CreditLine: s.ContentRights.CreditLine, 174 + License: s.ContentRights.License, 175 + } 176 + } 177 + 178 + var contentWarnings *streamplace.MetadataContentWarnings 179 + if len(s.ContentWarnings) > 0 { 180 + contentWarnings = &streamplace.MetadataContentWarnings{ 181 + Warnings: []string(s.ContentWarnings), 182 + } 183 + } 184 + 185 + var distributionPolicy *streamplace.MetadataDistributionPolicy 186 + if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 187 + distributionPolicy = &streamplace.MetadataDistributionPolicy{ 188 + DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 189 + } 190 + } 191 + 192 + return &streamplace.Segment{ 193 + LexiconTypeID: "place.stream.segment", 194 + Creator: s.RepoDID, 195 + Id: s.ID, 196 + SigningKey: s.SigningKeyDID, 197 + StartTime: string(aqt), 198 + Duration: &duration, 199 + Size: &sizei64, 200 + ContentRights: contentRights, 201 + ContentWarnings: contentWarnings, 202 + DistributionPolicy: distributionPolicy, 203 + Video: []*streamplace.Segment_Video{ 204 + { 205 + Codec: "h264", 206 + Width: int64(s.MediaData.Video[0].Width), 207 + Height: int64(s.MediaData.Video[0].Height), 208 + Framerate: &streamplace.Segment_Framerate{ 209 + Num: int64(s.MediaData.Video[0].FPSNum), 210 + Den: int64(s.MediaData.Video[0].FPSDen), 211 + }, 212 + Bframes: &s.MediaData.Video[0].BFrames, 213 + }, 214 + }, 215 + Audio: []*streamplace.Segment_Audio{ 216 + { 217 + Codec: "opus", 218 + Rate: int64(s.MediaData.Audio[0].Rate), 219 + Channels: int64(s.MediaData.Audio[0].Channels), 220 + }, 221 + }, 222 + }, nil 223 + } 224 + 225 + func (m *LocalDatabase) CreateSegment(seg *Segment) error { 226 + err := m.DB.Model(Segment{}).Create(seg).Error 227 + if err != nil { 228 + return err 229 + } 230 + return nil 231 + } 232 + 233 + // should return the most recent segment for each user, ordered by most recent first 234 + // only includes segments from the last 30 seconds 235 + func (m *LocalDatabase) MostRecentSegments() ([]Segment, error) { 236 + var segments []Segment 237 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 238 + 239 + err := m.DB.Table("segments"). 240 + Select("segments.*"). 241 + Where("start_time > ?", thirtySecondsAgo.UTC()). 242 + Order("start_time DESC"). 243 + Find(&segments).Error 244 + if err != nil { 245 + return nil, err 246 + } 247 + if segments == nil { 248 + return []Segment{}, nil 249 + } 250 + 251 + segmentMap := make(map[string]Segment) 252 + for _, seg := range segments { 253 + prev, ok := segmentMap[seg.RepoDID] 254 + if !ok { 255 + segmentMap[seg.RepoDID] = seg 256 + } else { 257 + if seg.StartTime.After(prev.StartTime) { 258 + segmentMap[seg.RepoDID] = seg 259 + } 260 + } 261 + } 262 + 263 + filteredSegments := []Segment{} 264 + for _, seg := range segmentMap { 265 + filteredSegments = append(filteredSegments, seg) 266 + } 267 + 268 + return filteredSegments, nil 269 + } 270 + 271 + func (m *LocalDatabase) LatestSegmentForUser(user string) (*Segment, error) { 272 + var seg Segment 273 + err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 274 + if err != nil { 275 + return nil, err 276 + } 277 + return &seg, nil 278 + } 279 + 280 + func (m *LocalDatabase) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 281 + if len(repoDIDs) == 0 { 282 + return []string{}, nil 283 + } 284 + 285 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 286 + 287 + var liveDIDs []string 288 + 289 + err := m.DB.Table("segments"). 290 + Select("DISTINCT repo_did"). 291 + Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 292 + Pluck("repo_did", &liveDIDs).Error 293 + 294 + if err != nil { 295 + return nil, err 296 + } 297 + 298 + return liveDIDs, nil 299 + } 300 + 301 + func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 302 + var segs []Segment 303 + if before == nil { 304 + later := time.Now().Add(1000 * time.Hour) 305 + before = &later 306 + } 307 + if after == nil { 308 + earlier := time.Time{} 309 + after = &earlier 310 + } 311 + err := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()).Order("start_time DESC").Limit(limit).Find(&segs).Error 312 + if err != nil { 313 + return nil, err 314 + } 315 + return segs, nil 316 + } 317 + 318 + func (m *LocalDatabase) GetSegment(id string) (*Segment, error) { 319 + var seg Segment 320 + 321 + err := m.DB.Model(&Segment{}). 322 + Preload("Repo"). 323 + Where("id = ?", id). 324 + First(&seg).Error 325 + 326 + if errors.Is(err, gorm.ErrRecordNotFound) { 327 + return nil, nil 328 + } 329 + if err != nil { 330 + return nil, err 331 + } 332 + 333 + return &seg, nil 334 + } 335 + 336 + func (m *LocalDatabase) GetExpiredSegments(ctx context.Context) ([]Segment, error) { 337 + 338 + var expiredSegments []Segment 339 + now := time.Now() 340 + err := m.DB. 341 + Where("delete_after IS NOT NULL AND delete_after < ?", now.UTC()). 342 + Find(&expiredSegments).Error 343 + if err != nil { 344 + return nil, err 345 + } 346 + 347 + return expiredSegments, nil 348 + } 349 + 350 + func (m *LocalDatabase) DeleteSegment(ctx context.Context, id string) error { 351 + return m.DB.Delete(&Segment{}, "id = ?", id).Error 352 + } 353 + 354 + func (m *LocalDatabase) StartSegmentCleaner(ctx context.Context) error { 355 + err := m.SegmentCleaner(ctx) 356 + if err != nil { 357 + return err 358 + } 359 + ticker := time.NewTicker(1 * time.Minute) 360 + defer ticker.Stop() 361 + 362 + for { 363 + select { 364 + case <-ctx.Done(): 365 + return nil 366 + case <-ticker.C: 367 + err := m.SegmentCleaner(ctx) 368 + if err != nil { 369 + log.Error(ctx, "Failed to clean segments", "error", err) 370 + } 371 + } 372 + } 373 + } 374 + 375 + func (m *LocalDatabase) SegmentCleaner(ctx context.Context) error { 376 + // Calculate the cutoff time (10 minutes ago) 377 + cutoffTime := aqtime.FromTime(time.Now().Add(-10 * time.Minute)).Time() 378 + 379 + // Find all unique repo_did values 380 + var repoDIDs []string 381 + if err := m.DB.Model(&Segment{}).Distinct("repo_did").Pluck("repo_did", &repoDIDs).Error; err != nil { 382 + log.Error(ctx, "Failed to get unique repo_dids for segment cleaning", "error", err) 383 + return err 384 + } 385 + 386 + // For each user, keep their last 10 segments and delete older ones 387 + for _, repoDID := range repoDIDs { 388 + // Get IDs of the last 10 segments for this user 389 + var keepSegmentIDs []string 390 + if err := m.DB.Model(&Segment{}). 391 + Where("repo_did = ?", repoDID). 392 + Order("start_time DESC"). 393 + Limit(10). 394 + Pluck("id", &keepSegmentIDs).Error; err != nil { 395 + log.Error(ctx, "Failed to get segment IDs to keep", "repo_did", repoDID, "error", err) 396 + return err 397 + } 398 + 399 + // Delete old segments except the ones we want to keep 400 + result := m.DB.Where("repo_did = ? AND start_time < ? AND id NOT IN ?", 401 + repoDID, cutoffTime, keepSegmentIDs).Delete(&Segment{}) 402 + 403 + if result.Error != nil { 404 + log.Error(ctx, "Failed to clean old segments", "repo_did", repoDID, "error", result.Error) 405 + } else if result.RowsAffected > 0 { 406 + log.Log(ctx, "Cleaned old segments", "repo_did", repoDID, "count", result.RowsAffected) 407 + } 408 + } 409 + return nil 410 + }
+59
pkg/localdb/segment_test.go
··· 1 + package localdb 2 + 3 + import ( 4 + "fmt" 5 + "sync" 6 + "testing" 7 + "time" 8 + 9 + "github.com/stretchr/testify/require" 10 + "stream.place/streamplace/pkg/config" 11 + ) 12 + 13 + func TestSegmentPerf(t *testing.T) { 14 + config.DisableSQLLogging() 15 + // dburl := filepath.Join(t.TempDir(), "test.db") 16 + db, err := MakeDB(":memory:") 17 + require.NoError(t, err) 18 + // Create a ldb instance 19 + ldb := db.(*LocalDatabase) 20 + t.Cleanup(func() { 21 + // os.Remove(dburl) 22 + }) 23 + 24 + defer config.EnableSQLLogging() 25 + // Create 250000 segments with timestamps 1 hour ago, each one second apart 26 + wg := sync.WaitGroup{} 27 + segCount := 250000 28 + wg.Add(segCount) 29 + baseTime := time.Now() 30 + for i := 0; i < segCount; i++ { 31 + segment := &Segment{ 32 + ID: fmt.Sprintf("segment-%d", i), 33 + RepoDID: "did:plc:test123", 34 + StartTime: baseTime.Add(-time.Duration(i) * time.Second).UTC(), 35 + } 36 + go func() { 37 + defer wg.Done() 38 + err = ldb.DB.Create(segment).Error 39 + require.NoError(t, err) 40 + }() 41 + } 42 + wg.Wait() 43 + 44 + startTime := time.Now() 45 + wg = sync.WaitGroup{} 46 + runs := 1000 47 + wg.Add(runs) 48 + for i := 0; i < runs; i++ { 49 + go func() { 50 + defer wg.Done() 51 + _, err := ldb.MostRecentSegments() 52 + require.NoError(t, err) 53 + // require.Len(t, segments, 1) 54 + }() 55 + } 56 + wg.Wait() 57 + fmt.Printf("Time taken: %s\n", time.Since(startTime)) 58 + require.Less(t, time.Since(startTime), 10*time.Second) 59 + }
+60
pkg/localdb/thumbnail.go
··· 1 + package localdb 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/google/uuid" 7 + ) 8 + 9 + type Thumbnail struct { 10 + ID string `json:"id" gorm:"primaryKey"` 11 + Format string `json:"format"` 12 + SegmentID string `json:"segmentId" gorm:"index"` 13 + Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 + } 15 + 16 + func (m *LocalDatabase) CreateThumbnail(thumb *Thumbnail) error { 17 + uu, err := uuid.NewV7() 18 + if err != nil { 19 + return err 20 + } 21 + if thumb.SegmentID == "" { 22 + return fmt.Errorf("segmentID is required") 23 + } 24 + thumb.ID = uu.String() 25 + err = m.DB.Model(Thumbnail{}).Create(thumb).Error 26 + if err != nil { 27 + return err 28 + } 29 + return nil 30 + } 31 + 32 + // return the most recent thumbnail for a user 33 + func (m *LocalDatabase) LatestThumbnailForUser(user string) (*Thumbnail, error) { 34 + var thumbnail Thumbnail 35 + 36 + res := m.DB.Table("thumbnails AS t"). 37 + Select("t.*"). 38 + Joins("JOIN segments AS s ON t.segment_id = s.id"). 39 + Where("s.repo_did = ?", user). 40 + Order("s.start_time DESC"). 41 + Limit(1). 42 + Scan(&thumbnail) 43 + 44 + if res.RowsAffected == 0 { 45 + return nil, nil 46 + } 47 + if res.Error != nil { 48 + return nil, res.Error 49 + } 50 + 51 + var seg Segment 52 + err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 53 + if err != nil { 54 + return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 55 + } 56 + 57 + thumbnail.Segment = seg 58 + 59 + return &thumbnail, nil 60 + }
+3 -3
pkg/media/clip_user.go
··· 10 10 11 11 "stream.place/streamplace/pkg/aqtime" 12 12 "stream.place/streamplace/pkg/config" 13 - "stream.place/streamplace/pkg/model" 13 + "stream.place/streamplace/pkg/localdb" 14 14 ) 15 15 16 - func ClipUser(ctx context.Context, mod model.Model, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { 17 - segments, err := mod.LatestSegmentsForUser(user, -1, before, after) 16 + func ClipUser(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { 17 + segments, err := localDB.LatestSegmentsForUser(user, -1, before, after) 18 18 if err != nil { 19 19 return fmt.Errorf("unable to get segments: %w", err) 20 20 }
+11 -8
pkg/media/media.go
··· 21 21 c2patypes "stream.place/streamplace/pkg/c2patypes" 22 22 "stream.place/streamplace/pkg/config" 23 23 "stream.place/streamplace/pkg/gstinit" 24 + "stream.place/streamplace/pkg/localdb" 24 25 "stream.place/streamplace/pkg/model" 25 26 "stream.place/streamplace/pkg/streamplace" 26 27 ··· 51 52 atsync *atproto.ATProtoSynchronizer 52 53 webrtcAPI *webrtc.API 53 54 webrtcConfig webrtc.Configuration 55 + localDB localdb.LocalDB 54 56 } 55 57 56 58 type NewSegmentNotification struct { 57 - Segment *model.Segment 59 + Segment *localdb.Segment 58 60 Data []byte 59 61 Metadata *SegmentMetadata 60 62 Local bool ··· 65 67 return SelfTest(ctx) 66 68 } 67 69 68 - func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, mod model.Model, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer) (*MediaManager, error) { 70 + func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, mod model.Model, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, ldb localdb.LocalDB) (*MediaManager, error) { 69 71 gstinit.InitGST() 70 72 err := SelfTest(ctx) 71 73 if err != nil { ··· 127 129 atsync: atsync, 128 130 webrtcAPI: api, 129 131 webrtcConfig: config, 132 + localDB: ldb, 130 133 }, nil 131 134 } 132 135 ··· 190 193 Title string 191 194 Creator string 192 195 ContentWarnings []string 193 - ContentRights *model.ContentRights 194 - DistributionPolicy *model.DistributionPolicy 196 + ContentRights *localdb.ContentRights 197 + DistributionPolicy *localdb.DistributionPolicy 195 198 MetadataConfiguration *streamplace.MetadataConfiguration 196 199 Livestream *streamplace.Livestream 197 200 } ··· 312 315 } 313 316 314 317 // extractContentRights extracts content rights from the C2PA manifest 315 - func extractContentRights(mani *c2patypes.Manifest) *model.ContentRights { 318 + func extractContentRights(mani *c2patypes.Manifest) *localdb.ContentRights { 316 319 ass := findAssertion(mani, StreamplaceMetadata) 317 320 if ass == nil { 318 321 return nil ··· 323 326 return nil 324 327 } 325 328 326 - rights := &model.ContentRights{} 329 + rights := &localdb.ContentRights{} 327 330 328 331 // Extract copyright notice 329 332 if notice, ok := data["dc:rights"]; ok { ··· 375 378 } 376 379 377 380 // extractDistributionPolicy extracts distribution policy from the C2PA manifest 378 - func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *model.DistributionPolicy { 381 + func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *localdb.DistributionPolicy { 379 382 metadataConfig := extractMetadataConfiguration(mani) 380 383 if metadataConfig == nil { 381 384 return nil ··· 392 395 // deleteAfter contains an offset in seconds from creation time 393 396 deleteAfterSeconds := *metadataConfig.DistributionPolicy.DeleteAfter 394 397 395 - return &model.DistributionPolicy{ 398 + return &localdb.DistributionPolicy{ 396 399 DeleteAfterSeconds: &deleteAfterSeconds, 397 400 } 398 401 }
+9 -9
pkg/media/media_data_parser.go
··· 13 13 "github.com/go-gst/go-gst/gst" 14 14 "github.com/go-gst/go-gst/gst/app" 15 15 "go.opentelemetry.io/otel" 16 + "stream.place/streamplace/pkg/localdb" 16 17 "stream.place/streamplace/pkg/log" 17 - "stream.place/streamplace/pkg/model" 18 18 ) 19 19 20 20 func padProbeEmpty(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn { 21 21 return gst.PadProbeOK 22 22 } 23 23 24 - func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*model.SegmentMediaData, error) { 24 + func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentMediaData, error) { 25 25 ctx, span := otel.Tracer("signer").Start(ctx, "ParseSegmentMediaData") 26 26 defer span.End() 27 27 ctx = log.WithLogValues(ctx, "GStreamerFunc", "ParseSegmentMediaData") ··· 40 40 return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 41 41 } 42 42 43 - var videoMetadata *model.SegmentMediadataVideo 44 - var audioMetadata *model.SegmentMediadataAudio 43 + var videoMetadata *localdb.SegmentMediadataVideo 44 + var audioMetadata *localdb.SegmentMediadataAudio 45 45 46 46 appsrc, err := pipeline.GetElementByName("appsrc") 47 47 if err != nil { ··· 118 118 name := structure.Name() 119 119 120 120 if name[:5] == "video" { 121 - videoMetadata = &model.SegmentMediadataVideo{} 121 + videoMetadata = &localdb.SegmentMediadataVideo{} 122 122 // Get some common video properties 123 123 widthVal, _ := structure.GetValue("width") 124 124 heightVal, _ := structure.GetValue("height") ··· 147 147 } 148 148 149 149 if name[:5] == "audio" { 150 - audioMetadata = &model.SegmentMediadataAudio{} 150 + audioMetadata = &localdb.SegmentMediadataAudio{} 151 151 // Get some common audio properties 152 152 rateVal, _ := structure.GetValue("rate") 153 153 channelsVal, _ := structure.GetValue("channels") ··· 275 275 276 276 videoMetadata.BFrames = hasBFrames 277 277 278 - meta := &model.SegmentMediaData{ 279 - Video: []*model.SegmentMediadataVideo{videoMetadata}, 280 - Audio: []*model.SegmentMediadataAudio{audioMetadata}, 278 + meta := &localdb.SegmentMediaData{ 279 + Video: []*localdb.SegmentMediadataVideo{videoMetadata}, 280 + Audio: []*localdb.SegmentMediadataAudio{audioMetadata}, 281 281 } 282 282 283 283 ok, dur := pipeline.QueryDuration(gst.FormatTime)
+4 -1
pkg/media/media_test.go
··· 11 11 "stream.place/streamplace/pkg/bus" 12 12 "stream.place/streamplace/pkg/config" 13 13 ct "stream.place/streamplace/pkg/config/configtesting" 14 + "stream.place/streamplace/pkg/localdb" 14 15 "stream.place/streamplace/pkg/model" 15 16 "stream.place/streamplace/pkg/statedb" 16 17 ) ··· 23 24 24 25 func getStaticTestMediaManager(t *testing.T) (*MediaManager, MediaSigner) { 25 26 mod, err := model.MakeDB(":memory:") 27 + require.NoError(t, err) 28 + ldb, err := localdb.MakeDB(":memory:") 26 29 require.NoError(t, err) 27 30 // signer, err := c2pa.MakeStaticSigner(eip712test.KeyBytes) 28 31 require.NoError(t, err) ··· 42 45 StatefulDB: statedb, 43 46 Bus: bus.NewBus(), 44 47 } 45 - mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync) 48 + mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync, ldb) 46 49 require.NoError(t, err) 47 50 // ms, err := MakeMediaSigner(context.Background(), cli, "test-person", signer) 48 51 // require.NoError(t, err)
+96 -111
pkg/media/rtcrec_test.go
··· 1 1 package media 2 2 3 - import ( 4 - "context" 5 - "os" 6 - "testing" 3 + // var RTCRecTestCases = []struct { 4 + // name string 5 + // fatalErrors bool 6 + // fixture string 7 + // expectedSegmentsMin int 8 + // expectedSegmentsMax int 9 + // }{ 10 + // { 11 + // name: "IntermittentTracks", 12 + // fatalErrors: false, 13 + // fixture: getFixture("intermittent-tracks.cbor"), 14 + // expectedSegmentsMin: 10, 15 + // expectedSegmentsMax: 15, 16 + // }, 17 + // { 18 + // name: "SegmentConvergenceIssues", 19 + // fatalErrors: true, 20 + // fixture: remote.RemoteFixture("6a1fb84e3c23405fc53161f59d5b837839c4889fc1a96533c82fb44fafc51d27/2025-11-14T22-41-20-399Z.cbor"), 21 + // expectedSegmentsMin: 1, 22 + // expectedSegmentsMax: 10, 23 + // }, 24 + // { 25 + // name: "NekomimiPet", 26 + // fixture: remote.RemoteFixture("91176de4b92fb4c8e84116bd2be0070e96f964fcb8e127da4bfa7020317f4195/nekomimi.pet.rtcrec.cbor"), 27 + // fatalErrors: true, 28 + // expectedSegmentsMin: 29, 29 + // expectedSegmentsMax: 29, 30 + // }, 31 + // } 7 32 8 - "github.com/cenkalti/backoff/v5" 9 - "github.com/pion/webrtc/v4" 10 - "github.com/stretchr/testify/require" 11 - "go.uber.org/goleak" 12 - "stream.place/streamplace/pkg/config" 13 - "stream.place/streamplace/pkg/crypto/spkey" 14 - "stream.place/streamplace/pkg/rtcrec" 15 - "stream.place/streamplace/test/remote" 16 - ) 33 + // func TestRTCRecording(t *testing.T) { 17 34 18 - var RTCRecTestCases = []struct { 19 - name string 20 - fatalErrors bool 21 - fixture string 22 - expectedSegmentsMin int 23 - expectedSegmentsMax int 24 - }{ 25 - { 26 - name: "IntermittentTracks", 27 - fatalErrors: false, 28 - fixture: getFixture("intermittent-tracks.cbor"), 29 - expectedSegmentsMin: 10, 30 - expectedSegmentsMax: 15, 31 - }, 32 - { 33 - name: "SegmentConvergenceIssues", 34 - fatalErrors: true, 35 - fixture: remote.RemoteFixture("6a1fb84e3c23405fc53161f59d5b837839c4889fc1a96533c82fb44fafc51d27/2025-11-14T22-41-20-399Z.cbor"), 36 - expectedSegmentsMin: 1, 37 - expectedSegmentsMax: 10, 38 - }, 39 - { 40 - name: "NekomimiPet", 41 - fixture: remote.RemoteFixture("91176de4b92fb4c8e84116bd2be0070e96f964fcb8e127da4bfa7020317f4195/nekomimi.pet.rtcrec.cbor"), 42 - fatalErrors: true, 43 - expectedSegmentsMin: 29, 44 - expectedSegmentsMax: 29, 45 - }, 46 - } 35 + // previous := FatalSegmentationErrors 36 + // defer func() { 37 + // FatalSegmentationErrors = previous 38 + // }() 39 + // // ctx := context.Background() 40 + // // mm, ms := getStaticTestMediaManager(t) 41 + // for _, testCase := range RTCRecTestCases { 42 + // t.Run(testCase.name, func(t *testing.T) { 43 + // withNoGSTLeaks(t, func() { 44 + // ctx := context.Background() 45 + // dir, err := os.MkdirTemp("", "rtcrec-test-*") 46 + // require.NoError(t, err) 47 + // defer os.RemoveAll(dir) 48 + // cli := &config.CLI{} 49 + // fs := cli.NewFlagSet("rtcrec-test") 50 + // err = cli.Parse(fs, []string{ 51 + // "--data-dir", dir, 52 + // "-wide-open=true", 53 + // "--segment-debug-dir", "/home/iameli/testvids/nekomimi.pet", 54 + // }) 55 + // require.NoError(t, err) 56 + // mm, err := MakeMediaManager(context.Background(), cli, nil, nil, nil, nil) 57 + // require.NoError(t, err) 58 + // priv, pub, err := spkey.GenerateStreamKey() 59 + // require.NoError(t, err) 60 + // signer, err := spkey.KeyToSigner(priv) 61 + // require.NoError(t, err) 62 + // mediaSigner, err := MakeMediaSigner(ctx, cli, pub.DIDKey(), signer, nil) 63 + // require.NoError(t, err) 47 64 48 - func TestRTCRecording(t *testing.T) { 65 + // segsub := mm.NewSegment() 66 + // segCount := 0 67 + // go func() { 68 + // for range segsub { 69 + // segCount++ 70 + // } 71 + // }() 49 72 50 - previous := FatalSegmentationErrors 51 - defer func() { 52 - FatalSegmentationErrors = previous 53 - }() 54 - // ctx := context.Background() 55 - // mm, ms := getStaticTestMediaManager(t) 56 - for _, testCase := range RTCRecTestCases { 57 - t.Run(testCase.name, func(t *testing.T) { 58 - withNoGSTLeaks(t, func() { 59 - ctx := context.Background() 60 - dir, err := os.MkdirTemp("", "rtcrec-test-*") 61 - require.NoError(t, err) 62 - defer os.RemoveAll(dir) 63 - cli := &config.CLI{} 64 - fs := cli.NewFlagSet("rtcrec-test") 65 - err = cli.Parse(fs, []string{ 66 - "--data-dir", dir, 67 - "-wide-open=true", 68 - "--segment-debug-dir", "/home/iameli/testvids/nekomimi.pet", 69 - }) 70 - require.NoError(t, err) 71 - mm, err := MakeMediaManager(context.Background(), cli, nil, nil, nil, nil) 72 - require.NoError(t, err) 73 - priv, pub, err := spkey.GenerateStreamKey() 74 - require.NoError(t, err) 75 - signer, err := spkey.KeyToSigner(priv) 76 - require.NoError(t, err) 77 - mediaSigner, err := MakeMediaSigner(ctx, cli, pub.DIDKey(), signer, nil) 78 - require.NoError(t, err) 73 + // cur := goleak.IgnoreCurrent() 74 + // defer goleak.VerifyNone(t, cur) 79 75 80 - segsub := mm.NewSegment() 81 - segCount := 0 82 - go func() { 83 - for range segsub { 84 - segCount++ 85 - } 86 - }() 76 + // FatalSegmentationErrors = testCase.fatalErrors 77 + // fd, err := os.Open(testCase.fixture) 78 + // require.NoError(t, err) 79 + // defer fd.Close() 80 + // pc, err := rtcrec.NewReplayPeerConnection(ctx, fd) 81 + // require.NoError(t, err) 82 + // done := make(chan error) 83 + // _, err = mm.WebRTCIngest(ctx, &webrtc.SessionDescription{SDP: "placeholder"}, mediaSigner, pc, done) 84 + // require.NoError(t, err) 85 + // // fmt.Println(answer.SDP) 86 + // <-done 87 87 88 - cur := goleak.IgnoreCurrent() 89 - defer goleak.VerifyNone(t, cur) 88 + // // the segment getting ingested is ever so slightly after the done, which doesn't matter except in tests, just do a backoff for checking 89 + // ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 90 + // defer ticker.Stop() 91 + // for i := 0; i < 10; i++ { 92 + // if segCount >= testCase.expectedSegmentsMin { 93 + // break 94 + // } 95 + // if i < 9 { 96 + // <-ticker.C 97 + // } 98 + // } 99 + // require.GreaterOrEqual(t, segCount, testCase.expectedSegmentsMin) 100 + // require.LessOrEqual(t, segCount, testCase.expectedSegmentsMax) 101 + // }) 102 + // }) 103 + // } 90 104 91 - FatalSegmentationErrors = testCase.fatalErrors 92 - fd, err := os.Open(testCase.fixture) 93 - require.NoError(t, err) 94 - defer fd.Close() 95 - pc, err := rtcrec.NewReplayPeerConnection(ctx, fd) 96 - require.NoError(t, err) 97 - done := make(chan error) 98 - _, err = mm.WebRTCIngest(ctx, &webrtc.SessionDescription{SDP: "placeholder"}, mediaSigner, pc, done) 99 - require.NoError(t, err) 100 - // fmt.Println(answer.SDP) 101 - <-done 102 - 103 - // the segment getting ingested is ever so slightly after the done, which doesn't matter except in tests, just do a backoff for checking 104 - ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 105 - defer ticker.Stop() 106 - for i := 0; i < 10; i++ { 107 - if segCount >= testCase.expectedSegmentsMin { 108 - break 109 - } 110 - if i < 9 { 111 - <-ticker.C 112 - } 113 - } 114 - require.GreaterOrEqual(t, segCount, testCase.expectedSegmentsMin) 115 - require.LessOrEqual(t, segCount, testCase.expectedSegmentsMax) 116 - }) 117 - }) 118 - } 119 - 120 - } 105 + // }
+325
pkg/media/rtmp_push.go
··· 1 + package media 2 + 3 + import ( 4 + "context" 5 + "crypto/tls" 6 + "fmt" 7 + "io" 8 + "net" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-gst/go-gst/gst" 14 + "stream.place/streamplace/pkg/bus" 15 + "stream.place/streamplace/pkg/log" 16 + "stream.place/streamplace/pkg/streamplace" 17 + ) 18 + 19 + func (mm *MediaManager) RTMPPush(ctx context.Context, user string, rendition string, targetView *streamplace.MultistreamDefs_TargetView) error { 20 + ctx, cancel := context.WithCancel(ctx) 21 + defer cancel() 22 + ctx = log.WithLogValues(ctx, "mediafunc", "RTMPPush") 23 + rec, ok := targetView.Record.Val.(*streamplace.MultistreamTarget) 24 + if !ok { 25 + return fmt.Errorf("failed to convert target view to multistream target") 26 + } 27 + targetURL := rec.Url 28 + 29 + pipelineSlice := []string{ 30 + "flvmux name=muxer ! rtmp2sink name=rtmp2sink", 31 + "h264parse name=videoparse ! muxer.video", 32 + "opusparse name=audioparse ! opusdec ! audioresample ! fdkaacenc ! muxer.audio", 33 + } 34 + 35 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 36 + if err != nil { 37 + return fmt.Errorf("failed to create GStreamer pipeline: %w", err) //nolint:all 38 + } 39 + 40 + rtmp2sink, err := pipeline.GetElementByName("rtmp2sink") 41 + if err != nil { 42 + return fmt.Errorf("failed to get rtmp2sink element from pipeline: %w", err) 43 + } 44 + 45 + u, err := url.Parse(targetURL) 46 + if err != nil { 47 + return fmt.Errorf("failed to parse target URL: %w", err) 48 + } 49 + if u.Scheme == "rtmps" { 50 + localAddr, err := mm.RunTLSFForwarder(ctx, targetURL) 51 + if err != nil { 52 + return fmt.Errorf("failed to run TLS forwarder: %w", err) 53 + } 54 + local := fmt.Sprintf("rtmp://%s%s", localAddr, u.Path) 55 + log.Debug(ctx, "running TLS forwarder", "localAddr", local, "destination", targetURL) 56 + err = rtmp2sink.SetProperty("location", local) 57 + if err != nil { 58 + return fmt.Errorf("failed to set rtmp2sink location: %w", err) 59 + } 60 + } else if u.Scheme == "rtmp" { 61 + localAddr, err := mm.RunTCPForwarder(ctx, targetURL) 62 + if err != nil { 63 + return fmt.Errorf("failed to run TCP forwarder: %w", err) 64 + } 65 + local := fmt.Sprintf("rtmp://%s%s", localAddr, u.Path) 66 + log.Debug(ctx, "running TCP forwarder", "localAddr", local, "destination", targetURL) 67 + err = rtmp2sink.SetProperty("location", local) 68 + if err != nil { 69 + return fmt.Errorf("failed to set rtmp2sink location: %w", err) 70 + } 71 + } else { 72 + return fmt.Errorf("invalid target URL scheme: %s", u.Scheme) 73 + } 74 + 75 + go func() { 76 + pollFreq := time.Second * 1 77 + for { 78 + select { 79 + case <-ctx.Done(): 80 + return 81 + case <-time.After(pollFreq): 82 + prop, err := rtmp2sink.GetProperty("stats") 83 + if err != nil { 84 + log.Error(ctx, "error getting rtmp2sink peak-kbps", "error", err) 85 + continue 86 + } 87 + if prop == nil { 88 + log.Error(ctx, "failed to get rtmp2sink peak-kbps", "prop", prop) 89 + continue 90 + } 91 + propVal, ok := prop.(*gst.Structure) 92 + if !ok { 93 + log.Error(ctx, "failed to convert rtmp2sink peak-kbps", "prop", prop) 94 + continue 95 + } 96 + outBytesAcked, err := propVal.GetValue("out-bytes-acked") 97 + if err != nil { 98 + log.Error(ctx, "failed to get rtmp2sink out-bytes-acked", "error", err) 99 + continue 100 + } 101 + outBytesAckedVal, ok := outBytesAcked.(uint64) 102 + if !ok { 103 + log.Error(ctx, "failed to convert rtmp2sink out-bytes-acked", "prop", prop) 104 + continue 105 + } 106 + if outBytesAckedVal > 0 { 107 + err = mm.atsync.StatefulDB.CreateMultistreamEvent(targetView.Uri, fmt.Sprintf("wrote %d bytes", outBytesAckedVal), "active") 108 + if err != nil { 109 + log.Error(ctx, "failed to create multistream event", "error", err) 110 + } 111 + // increase pollFreq, once it's working we don't need to spam the database 112 + pollFreq = time.Second * 15 113 + } 114 + log.Debug(ctx, "rtmp2sink out-bytes-acked", "outBytesAckedVal", outBytesAckedVal) 115 + } 116 + 117 + } 118 + }() 119 + 120 + segBuffer := make(chan *bus.Seg, 1024) 121 + go func() { 122 + segChan := mm.bus.SubscribeSegment(ctx, user, rendition) 123 + defer mm.bus.UnsubscribeSegment(ctx, user, rendition, segChan) 124 + for { 125 + select { 126 + case <-ctx.Done(): 127 + log.Debug(ctx, "exiting segment reader") 128 + return 129 + case file := <-segChan.C: 130 + log.Debug(ctx, "got segment", "file", file.Filepath) 131 + segBuffer <- file 132 + } 133 + } 134 + }() 135 + 136 + segCh := make(chan *bus.Seg) 137 + go func() { 138 + for { 139 + select { 140 + case <-ctx.Done(): 141 + log.Debug(ctx, "exiting segment reader") 142 + return 143 + case seg := <-segBuffer: 144 + select { 145 + case <-ctx.Done(): 146 + return 147 + case segCh <- seg: 148 + } 149 + } 150 + } 151 + }() 152 + 153 + concatBin, err := ConcatBin(ctx, segCh, true) 154 + if err != nil { 155 + return fmt.Errorf("failed to create concat bin: %w", err) 156 + } 157 + 158 + err = pipeline.Add(concatBin.Element) 159 + if err != nil { 160 + return fmt.Errorf("failed to add concat bin to pipeline: %w", err) 161 + } 162 + 163 + videoPad := concatBin.GetStaticPad("video_0") 164 + if videoPad == nil { 165 + return fmt.Errorf("video pad not found") 166 + } 167 + 168 + audioPad := concatBin.GetStaticPad("audio_0") 169 + if audioPad == nil { 170 + return fmt.Errorf("audio pad not found") 171 + } 172 + 173 + videoParse, err := pipeline.GetElementByName("videoparse") 174 + if err != nil { 175 + return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 176 + } 177 + videoParsePad := videoParse.GetStaticPad("sink") 178 + if videoParsePad == nil { 179 + return fmt.Errorf("video parse pad not found") 180 + } 181 + linked := videoPad.Link(videoParsePad) 182 + if linked != gst.PadLinkOK { 183 + return fmt.Errorf("failed to link video pad to video parse pad: %v", linked) 184 + } 185 + 186 + audioParse, err := pipeline.GetElementByName("audioparse") 187 + if err != nil { 188 + return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) 189 + } 190 + audioParsePad := audioParse.GetStaticPad("sink") 191 + if audioParsePad == nil { 192 + return fmt.Errorf("audio parse pad not found") 193 + } 194 + linked = audioPad.Link(audioParsePad) 195 + if linked != gst.PadLinkOK { 196 + return fmt.Errorf("failed to link audio pad to audio parse pad: %v", linked) 197 + } 198 + 199 + errCh := make(chan error) 200 + go func() { 201 + err := HandleBusMessages(ctx, pipeline) 202 + log.Log(ctx, "RTMP push pipeline error", "error", err) 203 + errCh <- err 204 + }() 205 + 206 + err = pipeline.SetState(gst.StatePlaying) 207 + if err != nil { 208 + return fmt.Errorf("failed to set pipeline state to playing: %w", err) 209 + } 210 + 211 + defer func() { 212 + log.Log(ctx, "shutting down RTMP push pipeline") 213 + err = pipeline.SetState(gst.StateNull) 214 + if err != nil { 215 + log.Error(ctx, "failed to set pipeline state to null", "error", err) 216 + } 217 + }() 218 + 219 + return <-errCh 220 + } 221 + func (mm *MediaManager) RunTLSFForwarder(ctx context.Context, dest string) (string, error) { 222 + destURL, err := url.Parse(dest) 223 + if err != nil { 224 + return "", fmt.Errorf("failed to parse destination URL: %w", err) 225 + } 226 + return mm.runForwarder(ctx, dest, func(destHost string) (net.Conn, error) { 227 + return tls.Dial("tcp", destHost, &tls.Config{ 228 + ServerName: destURL.Hostname(), 229 + }) 230 + }) 231 + } 232 + 233 + func (mm *MediaManager) RunTCPForwarder(ctx context.Context, dest string) (string, error) { 234 + return mm.runForwarder(ctx, dest, func(destHost string) (net.Conn, error) { 235 + return net.Dial("tcp", destHost) 236 + }) 237 + } 238 + 239 + func (mm *MediaManager) runForwarder(ctx context.Context, dest string, dial func(destHost string) (net.Conn, error)) (string, error) { 240 + ctx = log.WithLogValues(ctx, "mediafunc", "runForwarder") 241 + // Parse the destination URL to extract host and port 242 + destURL, err := url.Parse(dest) 243 + if err != nil { 244 + return "", fmt.Errorf("failed to parse destination URL: %w", err) 245 + } 246 + 247 + // Default to port 1935 if not specified 248 + destHost := destURL.Host 249 + if !strings.Contains(destHost, ":") { 250 + destHost = destHost + ":1935" 251 + } 252 + 253 + // Listen on a random port 254 + listener, err := net.Listen("tcp", "127.0.0.1:0") 255 + if err != nil { 256 + return "", fmt.Errorf("failed to listen on random port: %w", err) 257 + } 258 + 259 + log.Debug(ctx, "RTMP forwarder listening", "localAddr", listener.Addr().String(), "destination", dest) 260 + 261 + go func() { 262 + <-ctx.Done() 263 + listener.Close() 264 + }() 265 + 266 + go func() { 267 + defer listener.Close() 268 + if ctx.Err() != nil { 269 + return 270 + } 271 + // Accept incoming RTMP connection 272 + clientConn, err := listener.Accept() 273 + if err != nil { 274 + log.Error(ctx, "failed to accept connection", "error", err) 275 + return 276 + } 277 + 278 + closed := false 279 + go func() { 280 + <-ctx.Done() 281 + if !closed { 282 + closed = true 283 + clientConn.Close() 284 + } 285 + }() 286 + 287 + defer func() { 288 + if !closed { 289 + closed = true 290 + clientConn.Close() 291 + } 292 + }() 293 + 294 + // Establish connection to destination 295 + serverConn, err := dial(destHost) 296 + if err != nil { 297 + log.Error(ctx, "failed to establish connection to destination", "error", err) 298 + return 299 + } 300 + defer serverConn.Close() 301 + 302 + // Proxy data bidirectionally 303 + done := make(chan error, 2) 304 + 305 + // Copy from client to server 306 + go func() { 307 + _, err := io.Copy(serverConn, clientConn) 308 + done <- err 309 + }() 310 + 311 + // Copy from server to client 312 + go func() { 313 + _, err := io.Copy(clientConn, serverConn) 314 + done <- err 315 + }() 316 + 317 + // Wait for either direction to complete or error 318 + err = <-done 319 + if err != nil { 320 + log.Error(ctx, "proxy connection error", "error", err) 321 + } 322 + }() 323 + 324 + return listener.Addr().String(), nil 325 + }
+5 -5
pkg/media/validate.go
··· 18 18 "stream.place/streamplace/pkg/constants" 19 19 "stream.place/streamplace/pkg/crypto/signers" 20 20 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 21 + "stream.place/streamplace/pkg/localdb" 21 22 "stream.place/streamplace/pkg/log" 22 - "stream.place/streamplace/pkg/model" 23 23 ) 24 24 25 25 type ManifestAndCert struct { ··· 47 47 48 48 label := manifest.Label 49 49 if label != nil && mm.model != nil { 50 - oldSeg, err := mm.model.GetSegment(*label) 50 + oldSeg, err := mm.localDB.GetSegment(*label) 51 51 if err != nil { 52 52 return fmt.Errorf("failed to get old segment: %w", err) 53 53 } ··· 117 117 expiryTime := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DeleteAfterSeconds) * time.Second) 118 118 deleteAfter = &expiryTime 119 119 } 120 - seg := &model.Segment{ 120 + seg := &localdb.Segment{ 121 121 ID: *label, 122 122 SigningKeyDID: signingKeyDID, 123 123 RepoDID: repoDID, ··· 125 125 Title: meta.Title, 126 126 Size: len(buf), 127 127 MediaData: mediaData, 128 - ContentWarnings: model.ContentWarningsSlice(meta.ContentWarnings), 128 + ContentWarnings: localdb.ContentWarningsSlice(meta.ContentWarnings), 129 129 ContentRights: meta.ContentRights, 130 130 DistributionPolicy: meta.DistributionPolicy, 131 131 DeleteAfter: deleteAfter, ··· 205 205 type ValidationResult struct { 206 206 Pub *atcrypto.PublicKeyK256 207 207 Meta *SegmentMetadata 208 - MediaData *model.SegmentMediaData 208 + MediaData *localdb.SegmentMediaData 209 209 Manifest *c2patypes.Manifest 210 210 Cert string 211 211 }
+9
pkg/model/block.go
··· 23 23 } 24 24 25 25 func (b *Block) ToStreamplaceBlock() (*streamplace.Defs_BlockView, error) { 26 + if b == nil { 27 + return nil, fmt.Errorf("block is nil") 28 + } 29 + if b.Repo == nil { 30 + return nil, fmt.Errorf("block repo is nil") 31 + } 32 + if b.Record == nil { 33 + return nil, fmt.Errorf("block record is nil") 34 + } 26 35 rec, err := lexutil.CborDecodeValue(b.Record) 27 36 if err != nil { 28 37 return nil, fmt.Errorf("error decoding feed post: %w", err)
+15
pkg/model/chat_message.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "hash/fnv" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/api/bsky" 11 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 + "github.com/rivo/uniseg" 12 14 "gorm.io/gorm" 13 15 "stream.place/streamplace/pkg/streamplace" 14 16 ) ··· 41 43 if err != nil { 42 44 return nil, fmt.Errorf("error decoding feed post: %w", err) 43 45 } 46 + // Truncate message text if it is a ChatMessage 47 + if msg, ok := rec.(*streamplace.ChatMessage); ok { 48 + graphemeCount := uniseg.GraphemeClusterCount(msg.Text) 49 + if graphemeCount > 300 { 50 + gr := uniseg.NewGraphemes(msg.Text) 51 + var result strings.Builder 52 + for count := 0; count < 300 && gr.Next(); count++ { 53 + result.WriteString(gr.Str()) 54 + } 55 + msg.Text = result.String() 56 + } 57 + } 58 + 44 59 message := &streamplace.ChatDefs_MessageView{ 45 60 LexiconTypeID: "place.stream.chat.defs#messageView", 46 61 }
+37 -21
pkg/model/livestream.go
··· 83 83 return &livestream, nil 84 84 } 85 85 86 - // GetLatestLivestreams returns the most recent livestreams, given a limit and a cursor 87 - // Only gets livestreams with a valid segment no less than 30 seconds old 88 - func (m *DBModel) GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) { 86 + // Get the latest livestreams for a given list of repo DIDs 87 + func (m *DBModel) GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) { 89 88 var recentLivestreams []Livestream 90 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 89 + now := time.Now().UTC() 90 + 91 + if len(dids) == 0 { 92 + return []Livestream{}, nil 93 + } 91 94 92 - // get latest segment for the repo DID 93 - latestRecentSegmentsSubQuery := m.DB.Table("segments"). 94 - Select("repo_did, MAX(start_time) as latest_segment_start_time"). 95 - Where("(repo_did, start_time) IN (?)", 96 - m.DB.Table("segments"). 97 - Select("repo_did, MAX(start_time)"). 98 - Group("repo_did")). 99 - Where("start_time > ?", thirtySecondsAgo.UTC()). 95 + // Subquery to get the most recent livestream for each repo_did 96 + subQuery := m.DB. 97 + Table("livestreams"). 98 + Select("MAX(created_at) as max_created_at, repo_did"). 99 + Where("repo_did IN ?", dids). 100 100 Group("repo_did") 101 101 102 - rankedLivestreamsSubQuery := m.DB.Table("livestreams"). 103 - Select("livestreams.*, ROW_NUMBER() OVER(PARTITION BY livestreams.repo_did ORDER BY livestreams.created_at DESC) as rn"). 104 - Joins("JOIN repos ON livestreams.repo_did = repos.did") 105 - 106 - mainQuery := m.DB.Table("(?) as ranked_livestreams", rankedLivestreamsSubQuery). 107 - Joins("JOIN (?) as latest_segments ON ranked_livestreams.repo_did = latest_segments.repo_did", latestRecentSegmentsSubQuery). 108 - Select("ranked_livestreams.*, latest_segments.latest_segment_start_time"). 109 - Where("ranked_livestreams.rn = 1") 102 + mainQuery := m.DB. 103 + Table("livestreams"). 104 + Select("livestreams.*"). 105 + Joins("JOIN (?) as sq ON livestreams.repo_did = sq.repo_did AND livestreams.created_at = sq.max_created_at", subQuery). 106 + Where("livestreams.repo_did IN ?", dids). 107 + // exclude livestreams with !hide label on the record 108 + Where("NOT EXISTS (?)", 109 + m.DB.Table("labels"). 110 + Select("1"). 111 + Where("labels.uri = livestreams.uri"). 112 + Where("labels.val = ?", "!hide"). 113 + Where("labels.neg = ?", false). 114 + Where("(labels.exp IS NULL OR labels.exp > ?)", now), 115 + ). 116 + // exclude livestreams with !hide label on the user 117 + Where("NOT EXISTS (?)", 118 + m.DB.Table("labels"). 119 + Select("1"). 120 + Where("labels.uri = livestreams.repo_did"). 121 + Where("labels.val = ?", "!hide"). 122 + Where("labels.neg = ?", false). 123 + Where("(labels.exp IS NULL OR labels.exp > ?)", now), 124 + ) 110 125 111 126 if before != nil { 112 127 mainQuery = mainQuery.Where("livestreams.created_at < ?", *before) 113 128 } 114 129 115 - mainQuery = mainQuery.Order("ranked_livestreams.created_at DESC"). 130 + mainQuery = mainQuery. 131 + Order("livestreams.created_at DESC"). 116 132 Limit(limit). 117 133 Preload("Repo") 118 134
+10 -16
pkg/model/model.go
··· 28 28 PlayerReport(playerID string) (map[string]any, error) 29 29 ClearPlayerEvents() error 30 30 31 - CreateSegment(segment *Segment) error 32 - MostRecentSegments() ([]Segment, error) 33 - LatestSegmentForUser(user string) (*Segment, error) 34 - LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 35 - FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 36 - CreateThumbnail(thumb *Thumbnail) error 37 - LatestThumbnailForUser(user string) (*Thumbnail, error) 38 - GetSegment(id string) (*Segment, error) 39 - GetExpiredSegments(ctx context.Context) ([]Segment, error) 40 - DeleteSegment(ctx context.Context, id string) error 41 - StartSegmentCleaner(ctx context.Context) error 42 - SegmentCleaner(ctx context.Context) error 43 - 44 31 GetIdentity(id string) (*Identity, error) 45 32 UpdateIdentity(ident *Identity) error 46 33 ··· 72 59 CreateLivestream(ctx context.Context, ls *Livestream) error 73 60 GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) 74 61 GetLivestreamByPostURI(postURI string) (*Livestream, error) 75 - GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) 62 + GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) 63 + 64 + CreateTeleport(ctx context.Context, tp *Teleport) error 65 + GetLatestTeleportForRepo(repoDID string) (*Teleport, error) 66 + GetActiveTeleportsForRepo(repoDID string) ([]Teleport, error) 67 + GetActiveTeleportsToRepo(targetDID string) ([]Teleport, error) 68 + GetTeleportByURI(uri string) (*Teleport, error) 69 + DeleteTeleport(ctx context.Context, uri string) error 70 + DenyTeleport(ctx context.Context, uri string) error 76 71 77 72 CreateBlock(ctx context.Context, block *Block) error 78 73 GetBlock(ctx context.Context, rkey string) (*Block, error) ··· 170 165 sqlDB.SetMaxOpenConns(1) 171 166 for _, model := range []any{ 172 167 PlayerEvent{}, 173 - Segment{}, 174 - Thumbnail{}, 175 168 Identity{}, 176 169 Repo{}, 177 170 SigningKey{}, ··· 187 180 Label{}, 188 181 BroadcastOrigin{}, 189 182 MetadataConfiguration{}, 183 + Teleport{}, 190 184 ModerationDelegation{}, 191 185 Recommendation{}, 192 186 } {
-411
pkg/model/segment.go
··· 1 1 package model 2 - 3 - import ( 4 - "context" 5 - "database/sql/driver" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "time" 10 - 11 - "gorm.io/gorm" 12 - "stream.place/streamplace/pkg/aqtime" 13 - "stream.place/streamplace/pkg/log" 14 - "stream.place/streamplace/pkg/streamplace" 15 - ) 16 - 17 - type SegmentMediadataVideo struct { 18 - Width int `json:"width"` 19 - Height int `json:"height"` 20 - FPSNum int `json:"fpsNum"` 21 - FPSDen int `json:"fpsDen"` 22 - BFrames bool `json:"bframes"` 23 - } 24 - 25 - type SegmentMediadataAudio struct { 26 - Rate int `json:"rate"` 27 - Channels int `json:"channels"` 28 - } 29 - 30 - type SegmentMediaData struct { 31 - Video []*SegmentMediadataVideo `json:"video"` 32 - Audio []*SegmentMediadataAudio `json:"audio"` 33 - Duration int64 `json:"duration"` 34 - Size int `json:"size"` 35 - } 36 - 37 - // Scan scan value into Jsonb, implements sql.Scanner interface 38 - func (j *SegmentMediaData) Scan(value any) error { 39 - bytes, ok := value.([]byte) 40 - if !ok { 41 - return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) 42 - } 43 - 44 - result := SegmentMediaData{} 45 - err := json.Unmarshal(bytes, &result) 46 - *j = SegmentMediaData(result) 47 - return err 48 - } 49 - 50 - // Value return json value, implement driver.Valuer interface 51 - func (j SegmentMediaData) Value() (driver.Value, error) { 52 - return json.Marshal(j) 53 - } 54 - 55 - // ContentRights represents content rights and attribution information 56 - type ContentRights struct { 57 - CopyrightNotice *string `json:"copyrightNotice,omitempty"` 58 - CopyrightYear *int64 `json:"copyrightYear,omitempty"` 59 - Creator *string `json:"creator,omitempty"` 60 - CreditLine *string `json:"creditLine,omitempty"` 61 - License *string `json:"license,omitempty"` 62 - } 63 - 64 - // Scan scan value into ContentRights, implements sql.Scanner interface 65 - func (c *ContentRights) Scan(value any) error { 66 - if value == nil { 67 - *c = ContentRights{} 68 - return nil 69 - } 70 - bytes, ok := value.([]byte) 71 - if !ok { 72 - return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value)) 73 - } 74 - 75 - result := ContentRights{} 76 - err := json.Unmarshal(bytes, &result) 77 - *c = ContentRights(result) 78 - return err 79 - } 80 - 81 - // Value return json value, implement driver.Valuer interface 82 - func (c ContentRights) Value() (driver.Value, error) { 83 - return json.Marshal(c) 84 - } 85 - 86 - // DistributionPolicy represents distribution policy information 87 - type DistributionPolicy struct { 88 - DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 - } 90 - 91 - // Scan scan value into DistributionPolicy, implements sql.Scanner interface 92 - func (d *DistributionPolicy) Scan(value any) error { 93 - if value == nil { 94 - *d = DistributionPolicy{} 95 - return nil 96 - } 97 - bytes, ok := value.([]byte) 98 - if !ok { 99 - return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value)) 100 - } 101 - 102 - result := DistributionPolicy{} 103 - err := json.Unmarshal(bytes, &result) 104 - *d = DistributionPolicy(result) 105 - return err 106 - } 107 - 108 - // Value return json value, implement driver.Valuer interface 109 - func (d DistributionPolicy) Value() (driver.Value, error) { 110 - return json.Marshal(d) 111 - } 112 - 113 - // ContentWarningsSlice is a custom type for storing content warnings as JSON in the database 114 - type ContentWarningsSlice []string 115 - 116 - // Scan scan value into ContentWarningsSlice, implements sql.Scanner interface 117 - func (c *ContentWarningsSlice) Scan(value any) error { 118 - if value == nil { 119 - *c = ContentWarningsSlice{} 120 - return nil 121 - } 122 - bytes, ok := value.([]byte) 123 - if !ok { 124 - return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value)) 125 - } 126 - 127 - result := ContentWarningsSlice{} 128 - err := json.Unmarshal(bytes, &result) 129 - *c = ContentWarningsSlice(result) 130 - return err 131 - } 132 - 133 - // Value return json value, implement driver.Valuer interface 134 - func (c ContentWarningsSlice) Value() (driver.Value, error) { 135 - return json.Marshal(c) 136 - } 137 - 138 - type Segment struct { 139 - ID string `json:"id" gorm:"primaryKey"` 140 - SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` 141 - SigningKey *SigningKey `json:"signingKey,omitempty" gorm:"foreignKey:DID;references:SigningKeyDID"` 142 - StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` 143 - RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` 144 - Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 145 - Title string `json:"title"` 146 - Size int `json:"size" gorm:"column:size"` 147 - MediaData *SegmentMediaData `json:"mediaData,omitempty"` 148 - ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"` 149 - ContentRights *ContentRights `json:"contentRights,omitempty"` 150 - DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 151 - DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 152 - } 153 - 154 - func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { 155 - aqt := aqtime.FromTime(s.StartTime) 156 - if s.MediaData == nil { 157 - return nil, fmt.Errorf("media data is nil") 158 - } 159 - if len(s.MediaData.Video) == 0 || s.MediaData.Video[0] == nil { 160 - return nil, fmt.Errorf("video data is nil") 161 - } 162 - if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 163 - return nil, fmt.Errorf("audio data is nil") 164 - } 165 - duration := s.MediaData.Duration 166 - sizei64 := int64(s.Size) 167 - 168 - // Convert model metadata to streamplace metadata 169 - var contentRights *streamplace.MetadataContentRights 170 - if s.ContentRights != nil { 171 - contentRights = &streamplace.MetadataContentRights{ 172 - CopyrightNotice: s.ContentRights.CopyrightNotice, 173 - CopyrightYear: s.ContentRights.CopyrightYear, 174 - Creator: s.ContentRights.Creator, 175 - CreditLine: s.ContentRights.CreditLine, 176 - License: s.ContentRights.License, 177 - } 178 - } 179 - 180 - var contentWarnings *streamplace.MetadataContentWarnings 181 - if len(s.ContentWarnings) > 0 { 182 - contentWarnings = &streamplace.MetadataContentWarnings{ 183 - Warnings: []string(s.ContentWarnings), 184 - } 185 - } 186 - 187 - var distributionPolicy *streamplace.MetadataDistributionPolicy 188 - if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 189 - distributionPolicy = &streamplace.MetadataDistributionPolicy{ 190 - DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 191 - } 192 - } 193 - 194 - return &streamplace.Segment{ 195 - LexiconTypeID: "place.stream.segment", 196 - Creator: s.RepoDID, 197 - Id: s.ID, 198 - SigningKey: s.SigningKeyDID, 199 - StartTime: string(aqt), 200 - Duration: &duration, 201 - Size: &sizei64, 202 - ContentRights: contentRights, 203 - ContentWarnings: contentWarnings, 204 - DistributionPolicy: distributionPolicy, 205 - Video: []*streamplace.Segment_Video{ 206 - { 207 - Codec: "h264", 208 - Width: int64(s.MediaData.Video[0].Width), 209 - Height: int64(s.MediaData.Video[0].Height), 210 - Framerate: &streamplace.Segment_Framerate{ 211 - Num: int64(s.MediaData.Video[0].FPSNum), 212 - Den: int64(s.MediaData.Video[0].FPSDen), 213 - }, 214 - Bframes: &s.MediaData.Video[0].BFrames, 215 - }, 216 - }, 217 - Audio: []*streamplace.Segment_Audio{ 218 - { 219 - Codec: "opus", 220 - Rate: int64(s.MediaData.Audio[0].Rate), 221 - Channels: int64(s.MediaData.Audio[0].Channels), 222 - }, 223 - }, 224 - }, nil 225 - } 226 - 227 - func (m *DBModel) CreateSegment(seg *Segment) error { 228 - err := m.DB.Model(Segment{}).Create(seg).Error 229 - if err != nil { 230 - return err 231 - } 232 - return nil 233 - } 234 - 235 - // should return the most recent segment for each user, ordered by most recent first 236 - // only includes segments from the last 30 seconds 237 - func (m *DBModel) MostRecentSegments() ([]Segment, error) { 238 - var segments []Segment 239 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 240 - 241 - err := m.DB.Table("segments"). 242 - Select("segments.*"). 243 - Where("start_time > ?", thirtySecondsAgo.UTC()). 244 - Order("start_time DESC"). 245 - Find(&segments).Error 246 - if err != nil { 247 - return nil, err 248 - } 249 - if segments == nil { 250 - return []Segment{}, nil 251 - } 252 - 253 - segmentMap := make(map[string]Segment) 254 - for _, seg := range segments { 255 - prev, ok := segmentMap[seg.RepoDID] 256 - if !ok { 257 - segmentMap[seg.RepoDID] = seg 258 - } else { 259 - if seg.StartTime.After(prev.StartTime) { 260 - segmentMap[seg.RepoDID] = seg 261 - } 262 - } 263 - } 264 - 265 - filteredSegments := []Segment{} 266 - for _, seg := range segmentMap { 267 - filteredSegments = append(filteredSegments, seg) 268 - } 269 - 270 - return filteredSegments, nil 271 - } 272 - 273 - func (m *DBModel) LatestSegmentForUser(user string) (*Segment, error) { 274 - var seg Segment 275 - err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 276 - if err != nil { 277 - return nil, err 278 - } 279 - return &seg, nil 280 - } 281 - 282 - func (m *DBModel) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 283 - if len(repoDIDs) == 0 { 284 - return []string{}, nil 285 - } 286 - 287 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 288 - 289 - var liveDIDs []string 290 - 291 - err := m.DB.Table("segments"). 292 - Select("DISTINCT repo_did"). 293 - Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 294 - Pluck("repo_did", &liveDIDs).Error 295 - 296 - if err != nil { 297 - return nil, err 298 - } 299 - 300 - return liveDIDs, nil 301 - } 302 - 303 - func (m *DBModel) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 304 - var segs []Segment 305 - if before == nil { 306 - later := time.Now().Add(1000 * time.Hour) 307 - before = &later 308 - } 309 - if after == nil { 310 - earlier := time.Time{} 311 - after = &earlier 312 - } 313 - err := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()).Order("start_time DESC").Limit(limit).Find(&segs).Error 314 - if err != nil { 315 - return nil, err 316 - } 317 - return segs, nil 318 - } 319 - 320 - func (m *DBModel) GetSegment(id string) (*Segment, error) { 321 - var seg Segment 322 - 323 - err := m.DB.Model(&Segment{}). 324 - Preload("Repo"). 325 - Where("id = ?", id). 326 - First(&seg).Error 327 - 328 - if errors.Is(err, gorm.ErrRecordNotFound) { 329 - return nil, nil 330 - } 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return &seg, nil 336 - } 337 - 338 - func (m *DBModel) GetExpiredSegments(ctx context.Context) ([]Segment, error) { 339 - 340 - var expiredSegments []Segment 341 - now := time.Now() 342 - err := m.DB. 343 - Where("delete_after IS NOT NULL AND delete_after < ?", now.UTC()). 344 - Find(&expiredSegments).Error 345 - if err != nil { 346 - return nil, err 347 - } 348 - 349 - return expiredSegments, nil 350 - } 351 - 352 - func (m *DBModel) DeleteSegment(ctx context.Context, id string) error { 353 - return m.DB.Delete(&Segment{}, "id = ?", id).Error 354 - } 355 - 356 - func (m *DBModel) StartSegmentCleaner(ctx context.Context) error { 357 - err := m.SegmentCleaner(ctx) 358 - if err != nil { 359 - return err 360 - } 361 - ticker := time.NewTicker(1 * time.Minute) 362 - defer ticker.Stop() 363 - 364 - for { 365 - select { 366 - case <-ctx.Done(): 367 - return nil 368 - case <-ticker.C: 369 - err := m.SegmentCleaner(ctx) 370 - if err != nil { 371 - log.Error(ctx, "Failed to clean segments", "error", err) 372 - } 373 - } 374 - } 375 - } 376 - 377 - func (m *DBModel) SegmentCleaner(ctx context.Context) error { 378 - // Calculate the cutoff time (10 minutes ago) 379 - cutoffTime := aqtime.FromTime(time.Now().Add(-10 * time.Minute)).Time() 380 - 381 - // Find all unique repo_did values 382 - var repoDIDs []string 383 - if err := m.DB.Model(&Segment{}).Distinct("repo_did").Pluck("repo_did", &repoDIDs).Error; err != nil { 384 - log.Error(ctx, "Failed to get unique repo_dids for segment cleaning", "error", err) 385 - return err 386 - } 387 - 388 - // For each user, keep their last 10 segments and delete older ones 389 - for _, repoDID := range repoDIDs { 390 - // Get IDs of the last 10 segments for this user 391 - var keepSegmentIDs []string 392 - if err := m.DB.Model(&Segment{}). 393 - Where("repo_did = ?", repoDID). 394 - Order("start_time DESC"). 395 - Limit(10). 396 - Pluck("id", &keepSegmentIDs).Error; err != nil { 397 - log.Error(ctx, "Failed to get segment IDs to keep", "repo_did", repoDID, "error", err) 398 - return err 399 - } 400 - 401 - // Delete old segments except the ones we want to keep 402 - result := m.DB.Where("repo_did = ? AND start_time < ? AND id NOT IN ?", 403 - repoDID, cutoffTime, keepSegmentIDs).Delete(&Segment{}) 404 - 405 - if result.Error != nil { 406 - log.Error(ctx, "Failed to clean old segments", "repo_did", repoDID, "error", result.Error) 407 - } else if result.RowsAffected > 0 { 408 - log.Log(ctx, "Cleaned old segments", "repo_did", repoDID, "count", result.RowsAffected) 409 - } 410 - } 411 - return nil 412 - }
-65
pkg/model/segment_test.go
··· 1 1 package model 2 - 3 - import ( 4 - "fmt" 5 - "sync" 6 - "testing" 7 - "time" 8 - 9 - "github.com/stretchr/testify/require" 10 - "stream.place/streamplace/pkg/config" 11 - ) 12 - 13 - func TestSegmentPerf(t *testing.T) { 14 - config.DisableSQLLogging() 15 - // dburl := filepath.Join(t.TempDir(), "test.db") 16 - db, err := MakeDB(":memory:") 17 - require.NoError(t, err) 18 - // Create a model instance 19 - model := db.(*DBModel) 20 - t.Cleanup(func() { 21 - // os.Remove(dburl) 22 - }) 23 - 24 - // Create a repo for testing 25 - repo := &Repo{ 26 - DID: "did:plc:test123", 27 - } 28 - err = model.DB.Create(repo).Error 29 - require.NoError(t, err) 30 - 31 - defer config.EnableSQLLogging() 32 - // Create 250000 segments with timestamps 1 hour ago, each one second apart 33 - wg := sync.WaitGroup{} 34 - segCount := 250000 35 - wg.Add(segCount) 36 - baseTime := time.Now() 37 - for i := 0; i < segCount; i++ { 38 - segment := &Segment{ 39 - ID: fmt.Sprintf("segment-%d", i), 40 - RepoDID: repo.DID, 41 - StartTime: baseTime.Add(-time.Duration(i) * time.Second).UTC(), 42 - } 43 - go func() { 44 - defer wg.Done() 45 - err = model.DB.Create(segment).Error 46 - require.NoError(t, err) 47 - }() 48 - } 49 - wg.Wait() 50 - 51 - startTime := time.Now() 52 - wg = sync.WaitGroup{} 53 - runs := 1000 54 - wg.Add(runs) 55 - for i := 0; i < runs; i++ { 56 - go func() { 57 - defer wg.Done() 58 - _, err := model.MostRecentSegments() 59 - require.NoError(t, err) 60 - // require.Len(t, segments, 1) 61 - }() 62 - } 63 - wg.Wait() 64 - fmt.Printf("Time taken: %s\n", time.Since(startTime)) 65 - require.Less(t, time.Since(startTime), 10*time.Second) 66 - }
+115
pkg/model/teleport.go
··· 1 + package model 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "time" 8 + 9 + "gorm.io/gorm" 10 + "gorm.io/gorm/clause" 11 + ) 12 + 13 + type Teleport struct { 14 + URI string `json:"uri" gorm:"primaryKey;column:uri"` 15 + CID string `json:"cid" gorm:"column:cid"` 16 + StartsAt time.Time `json:"startsAt" gorm:"column:starts_at;index:idx_repo_starts,priority:2"` 17 + DurationSeconds *int64 `json:"durationSeconds" gorm:"column:duration_seconds"` 18 + ViewerCount int64 `json:"viewerCount" gorm:"column:viewer_count;default:0"` 19 + Teleport *[]byte `json:"teleport"` 20 + RepoDID string `json:"repoDID" gorm:"column:repo_did;index:idx_repo_starts,priority:1"` 21 + TargetDID string `json:"targetDID" gorm:"column:target_did;index:idx_target_did"` 22 + Denied bool `json:"denied" gorm:"column:denied;default:false"` 23 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 24 + Target *Repo `json:"target,omitempty" gorm:"foreignKey:DID;references:TargetDID"` 25 + } 26 + 27 + func (m *DBModel) CreateTeleport(ctx context.Context, tp *Teleport) error { 28 + return m.DB.Clauses(clause.OnConflict{ 29 + Columns: []clause.Column{{Name: "uri"}}, 30 + DoUpdates: clause.AssignmentColumns([]string{"cid", "starts_at", "duration_seconds", "viewer_count", "teleport", "repo_did", "target_did"}), 31 + }).Create(tp).Error 32 + } 33 + 34 + func (m *DBModel) GetLatestTeleportForRepo(repoDID string) (*Teleport, error) { 35 + var teleport Teleport 36 + err := m.DB. 37 + Preload("Repo"). 38 + Preload("Target"). 39 + Where("repo_did = ?", repoDID). 40 + Order("starts_at DESC"). 41 + First(&teleport).Error 42 + if errors.Is(err, gorm.ErrRecordNotFound) { 43 + return nil, nil 44 + } 45 + if err != nil { 46 + return nil, fmt.Errorf("error retrieving latest teleport: %w", err) 47 + } 48 + return &teleport, nil 49 + } 50 + 51 + func (m *DBModel) GetActiveTeleportsForRepo(repoDID string) ([]Teleport, error) { 52 + now := time.Now() 53 + var teleports []Teleport 54 + err := m.DB. 55 + Preload("Repo"). 56 + Preload("Target"). 57 + Where("repo_did = ?", repoDID). 58 + Where("denied = ?", false). 59 + Where("starts_at <= ?", now). 60 + Where("(duration_seconds IS NULL OR DATE_ADD(starts_at, INTERVAL duration_seconds SECOND) > ?)", now). 61 + Order("starts_at DESC"). 62 + Find(&teleports).Error 63 + if errors.Is(err, gorm.ErrRecordNotFound) { 64 + return nil, nil 65 + } 66 + if err != nil { 67 + return nil, fmt.Errorf("error retrieving active teleports: %w", err) 68 + } 69 + return teleports, nil 70 + } 71 + 72 + func (m *DBModel) GetActiveTeleportsToRepo(targetDID string) ([]Teleport, error) { 73 + now := time.Now() 74 + var teleports []Teleport 75 + err := m.DB. 76 + Preload("Repo"). 77 + Preload("Target"). 78 + Where("target_did = ?", targetDID). 79 + Where("denied = ?", false). 80 + Where("starts_at <= ?", now). 81 + Where("(duration_seconds IS NULL OR datetime(starts_at, '+' || duration_seconds || ' seconds') > ?)", now). 82 + Order("starts_at DESC"). 83 + Find(&teleports).Error 84 + if errors.Is(err, gorm.ErrRecordNotFound) { 85 + return nil, nil 86 + } 87 + if err != nil { 88 + return nil, fmt.Errorf("error retrieving active teleports to repo: %w", err) 89 + } 90 + return teleports, nil 91 + } 92 + 93 + func (m *DBModel) GetTeleportByURI(uri string) (*Teleport, error) { 94 + var teleport Teleport 95 + err := m.DB. 96 + Preload("Repo"). 97 + Preload("Target"). 98 + Where("uri = ?", uri). 99 + First(&teleport).Error 100 + if errors.Is(err, gorm.ErrRecordNotFound) { 101 + return nil, nil 102 + } 103 + if err != nil { 104 + return nil, fmt.Errorf("error retrieving teleport by uri: %w", err) 105 + } 106 + return &teleport, nil 107 + } 108 + 109 + func (m *DBModel) DeleteTeleport(ctx context.Context, uri string) error { 110 + return m.DB.Where("uri = ?", uri).Delete(&Teleport{}).Error 111 + } 112 + 113 + func (m *DBModel) DenyTeleport(ctx context.Context, uri string) error { 114 + return m.DB.Model(&Teleport{}).Where("uri = ?", uri).Update("denied", true).Error 115 + }
-59
pkg/model/thumbnail.go
··· 1 1 package model 2 - 3 - import ( 4 - "fmt" 5 - 6 - "github.com/google/uuid" 7 - ) 8 - 9 - type Thumbnail struct { 10 - ID string `json:"id" gorm:"primaryKey"` 11 - Format string `json:"format"` 12 - SegmentID string `json:"segmentId" gorm:"index"` 13 - Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 - } 15 - 16 - func (m *DBModel) CreateThumbnail(thumb *Thumbnail) error { 17 - uu, err := uuid.NewV7() 18 - if err != nil { 19 - return err 20 - } 21 - if thumb.SegmentID == "" { 22 - return fmt.Errorf("segmentID is required") 23 - } 24 - thumb.ID = uu.String() 25 - err = m.DB.Model(Thumbnail{}).Create(thumb).Error 26 - if err != nil { 27 - return err 28 - } 29 - return nil 30 - } 31 - 32 - // return the most recent thumbnail for a user 33 - func (m *DBModel) LatestThumbnailForUser(user string) (*Thumbnail, error) { 34 - var thumbnail Thumbnail 35 - 36 - res := m.DB.Table("thumbnails AS t"). 37 - Select("t.*"). 38 - Joins("JOIN segments AS s ON t.segment_id = s.id"). 39 - Where("s.repo_did = ?", user). 40 - Order("s.start_time DESC"). 41 - Limit(1). 42 - Scan(&thumbnail) 43 - 44 - if res.RowsAffected == 0 { 45 - return nil, nil 46 - } 47 - if res.Error != nil { 48 - return nil, res.Error 49 - } 50 - 51 - var seg Segment 52 - err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 53 - if err != nil { 54 - return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 55 - } 56 - 57 - thumbnail.Segment = seg 58 - 59 - return &thumbnail, nil 60 - }
+2 -2
pkg/rtmps/rtmps.go
··· 29 29 MinVersion: tls.VersionTLS12, 30 30 } 31 31 32 - listener, err := tls.Listen("tcp", cli.RTMPAddr, tlsConfig) 32 + listener, err := tls.Listen("tcp", cli.RTMPSAddonAddr, tlsConfig) 33 33 if err != nil { 34 34 return fmt.Errorf("failed to create RTMPS listener: %w", err) 35 35 } 36 36 37 37 log.Log(ctx, "rtmps server starting", 38 - "addr", cli.RTMPAddr, 38 + "addr", cli.RTMPSAddonAddr, 39 39 "forwarding_to", cli.RTMPServerAddon) 40 40 41 41 go func() {
+19
pkg/spid/tid.go
··· 1 + package spid 2 + 3 + import ( 4 + "crypto/rand" 5 + "math/big" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + var TIDClock *syntax.TIDClock 11 + 12 + func init() { 13 + id, err := rand.Int(rand.Reader, big.NewInt(1024)) 14 + if err != nil { 15 + panic(err) 16 + } 17 + clock := syntax.NewTIDClock(uint(id.Uint64())) 18 + TIDClock = &clock 19 + }
+1 -1
pkg/spxrpc/app_bsky_feed.go
··· 56 56 outCursor = fmt.Sprintf("%d::%s", ts, last.CID) 57 57 } 58 58 } else if name == FeedLiveStreams { 59 - segs, err := s.model.MostRecentSegments() 59 + segs, err := s.localDB.MostRecentSegments() 60 60 if err != nil { 61 61 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get recent segments: %v", err)) 62 62 }
+2 -1
pkg/spxrpc/com_atproto_identity.go
··· 5 5 6 6 comatprototypes "github.com/bluesky-social/indigo/api/atproto" 7 7 "github.com/streamplace/oatproxy/pkg/oatproxy" 8 + "stream.place/streamplace/pkg/aqhttp" 8 9 ) 9 10 10 11 func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 11 - did, err := oatproxy.ResolveHandle(ctx, handle) 12 + did, err := oatproxy.ResolveHandleWithClient(ctx, handle, &aqhttp.Client) 12 13 if err != nil { 13 14 return nil, err 14 15 }
+4 -4
pkg/spxrpc/com_atproto_moderation.go
··· 13 13 "github.com/labstack/echo/v4" 14 14 "github.com/streamplace/oatproxy/pkg/oatproxy" 15 15 "stream.place/streamplace/pkg/config" 16 + "stream.place/streamplace/pkg/localdb" 16 17 "stream.place/streamplace/pkg/log" 17 18 "stream.place/streamplace/pkg/media" 18 - "stream.place/streamplace/pkg/model" 19 19 ) 20 20 21 21 func (s *Server) handleComAtprotoModerationCreateReport(ctx context.Context, body *comatprototypes.ModerationCreateReport_Input) (*comatprototypes.ModerationCreateReport_Output, error) { ··· 76 76 return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid subject") 77 77 } 78 78 79 - clipID, err := makeClip(ctx, s.cli, s.model, did) 79 + clipID, err := makeClip(ctx, s.cli, s.localDB, did) 80 80 if err != nil { 81 81 // we still want the report to go through! 82 82 log.Error(ctx, "failed to make clip for report", "error", err) ··· 99 99 return &output, nil 100 100 } 101 101 102 - func makeClip(ctx context.Context, cli *config.CLI, mod model.Model, did string) (string, error) { 102 + func makeClip(ctx context.Context, cli *config.CLI, localDB localdb.LocalDB, did string) (string, error) { 103 103 after := time.Now().Add(-time.Duration(60) * time.Second) 104 104 105 105 uu, err := uuid.NewV7() ··· 113 113 } 114 114 defer fd.Close() 115 115 116 - err = media.ClipUser(ctx, mod, cli, did, fd, nil, &after) 116 + err = media.ClipUser(ctx, localDB, cli, did, fd, nil, &after) 117 117 if err != nil { 118 118 return "", echo.NewHTTPError(http.StatusInternalServerError, "failed to clip user") 119 119 }
+3 -2
pkg/spxrpc/com_atproto_repo.go
··· 15 15 "github.com/labstack/echo/v4" 16 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 17 17 "go.opentelemetry.io/otel" 18 + "stream.place/streamplace/pkg/aqhttp" 18 19 "stream.place/streamplace/pkg/atproto" 19 20 "stream.place/streamplace/pkg/log" 20 21 ) ··· 23 24 did := repo 24 25 var err error 25 26 if !strings.HasPrefix(repo, "did:") { 26 - did, err = oatproxy.ResolveHandle(ctx, repo) 27 + did, err = oatproxy.ResolveHandleWithClient(ctx, repo, &aqhttp.Client) 27 28 if err != nil { 28 29 return "", "", "", fmt.Errorf("failed to resolve handle %q: %w", repo, err) 29 30 } 30 31 } 31 32 32 - service, handle, err := oatproxy.ResolveService(ctx, did) 33 + service, handle, err := oatproxy.ResolveServiceWithClient(ctx, did, &aqhttp.Client) 33 34 if err != nil { 34 35 return "", "", "", fmt.Errorf("failed to resolve service for did %q: %w", did, err) 35 36 }
+11
pkg/spxrpc/com_atproto_sync.go
··· 46 46 }, 47 47 } 48 48 49 + func (s *Server) handleComAtprotoSyncGetRepo(ctx context.Context, did string, since string) (io.Reader, error) { 50 + if did != atproto.LexiconRepo.RepoDid() { 51 + return nil, echo.NewHTTPError(http.StatusNotFound, "RepoNotFound") 52 + } 53 + bs, err := atproto.LexiconRepoGetRepo(ctx, since) 54 + if err != nil { 55 + return nil, err 56 + } 57 + return bytes.NewReader(bs), nil 58 + } 59 + 49 60 func (s *Server) handleComAtprotoSyncSubscribeRepos(c echo.Context) error { 50 61 ctx := log.WithLogValues(c.Request().Context(), "client_ip", c.RealIP(), "user_agent", c.Request().UserAgent()) 51 62 cursor := c.QueryParam("cursor")
+4 -4
pkg/spxrpc/place_stream_branding.go
··· 38 38 return s.cli.BroadcasterHost 39 39 } 40 40 41 - func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 41 + func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 42 // cache miss - fetch from db 43 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 44 if err == gorm.ErrRecordNotFound { ··· 61 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 64 + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) 65 65 if err != nil { 66 66 return nil, err 67 67 } ··· 94 94 // build output 95 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 96 for key := range allKeys { 97 - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 97 + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) 98 98 if err != nil { 99 99 continue // skip if error 100 100 } ··· 238 238 239 239 broadcasterID := s.cli.BroadcasterHost 240 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 241 + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 242 243 243 if err != nil || data == nil { 244 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
+67 -5
pkg/spxrpc/place_stream_live.go
··· 9 9 "github.com/bluesky-social/indigo/lex/util" 10 10 "github.com/gorilla/websocket" 11 11 "github.com/labstack/echo/v4" 12 + "github.com/streamplace/oatproxy/pkg/oatproxy" 12 13 "stream.place/streamplace/pkg/log" 13 14 "stream.place/streamplace/pkg/spid" 14 15 "stream.place/streamplace/pkg/spmetrics" ··· 16 17 placestreamtypes "stream.place/streamplace/pkg/streamplace" 17 18 ) 18 19 20 + func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *placestreamtypes.LiveDenyTeleport_Input) (*placestreamtypes.LiveDenyTeleport_Output, error) { 21 + session, _ := oatproxy.GetOAuthSession(ctx) 22 + if session == nil { 23 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 24 + } 25 + 26 + if input.Uri == "" { 27 + return nil, echo.NewHTTPError(http.StatusBadRequest, "URI is required") 28 + } 29 + 30 + teleport, err := s.model.GetTeleportByURI(input.Uri) 31 + if err != nil { 32 + log.Error(ctx, "failed to get teleport", "err", err) 33 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve teleport") 34 + } 35 + 36 + if teleport == nil { 37 + return nil, echo.NewHTTPError(http.StatusNotFound, "Teleport not found") 38 + } 39 + 40 + if teleport.TargetDID != session.DID { 41 + return nil, echo.NewHTTPError(http.StatusForbidden, "You are not the target of this teleport") 42 + } 43 + 44 + err = s.model.DenyTeleport(ctx, input.Uri) 45 + if err != nil { 46 + log.Error(ctx, "failed to deny teleport", "err", err) 47 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to deny teleport") 48 + } 49 + 50 + cancelMsg := &placestreamtypes.Livestream_TeleportCanceled{ 51 + LexiconTypeID: "place.stream.livestream#teleportCanceled", 52 + TeleportUri: input.Uri, 53 + Reason: "denied", 54 + } 55 + 56 + s.bus.Publish(teleport.RepoDID, cancelMsg) 57 + s.bus.Publish(teleport.TargetDID, cancelMsg) 58 + 59 + return &placestreamtypes.LiveDenyTeleport_Output{ 60 + Success: true, 61 + }, nil 62 + } 63 + 19 64 var replicationUpgrader = websocket.Upgrader{ 20 65 ReadBufferSize: 1024, 21 66 WriteBufferSize: 1024 * 1024 * 10, // 10MB ··· 37 82 beforeTime = &parsedTime 38 83 } 39 84 40 - segments, err := s.model.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 85 + segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 41 86 if err != nil { 42 87 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch segments") 43 88 } ··· 68 113 } 69 114 70 115 func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before string, limit int) (*placestreamtypes.LiveGetLiveUsers_Output, error) { 116 + // Check cache first 117 + cacheKey := fmt.Sprintf("live_users_%s_%d", before, limit) 118 + if cached, found := s.LiveUsersCache.Get(cacheKey); found { 119 + return cached.(*placestreamtypes.LiveGetLiveUsers_Output), nil 120 + } 121 + 71 122 var beforeTime *time.Time 72 123 if before != "" { 73 124 parsedTime, err := time.Parse(time.RFC3339, before) ··· 76 127 } 77 128 beforeTime = &parsedTime 78 129 } 79 - ls, err := s.model.GetLatestLivestreams(limit, beforeTime) 130 + segs, err := s.localDB.MostRecentSegments() 131 + if err != nil { 132 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch recent segments") 133 + } 134 + dids := make([]string, len(segs)) 135 + for i, seg := range segs { 136 + dids[i] = seg.RepoDID 137 + } 138 + ls, err := s.model.GetLatestLivestreams(limit, beforeTime, dids) 80 139 if err != nil { 81 140 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch livestreams") 82 141 } ··· 100 159 Streams: streams, 101 160 } 102 161 162 + // Cache the result 163 + s.LiveUsersCache.SetDefault(cacheKey, liveUsers) 164 + 103 165 return liveUsers, nil 104 166 } 105 167 ··· 169 231 } 170 232 171 233 // Filter for only live streamers 172 - liveStreamers, err := s.model.FilterLiveRepoDIDs(streamers) 234 + liveStreamers, err := s.localDB.FilterLiveRepoDIDs(streamers) 173 235 if err != nil { 174 236 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live streamers") 175 237 } ··· 202 264 followDIDs[i] = follow.SubjectDID 203 265 } 204 266 205 - liveFollows, err := s.model.FilterLiveRepoDIDs(followDIDs) 267 + liveFollows, err := s.localDB.FilterLiveRepoDIDs(followDIDs) 206 268 if err != nil { 207 269 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live follows") 208 270 } ··· 227 289 // Final fallback: use host's default recommendations 228 290 defaultStreamers := s.cli.DefaultRecommendedStreamers 229 291 if len(defaultStreamers) > 0 { 230 - liveDefaults, err := s.model.FilterLiveRepoDIDs(defaultStreamers) 292 + liveDefaults, err := s.localDB.FilterLiveRepoDIDs(defaultStreamers) 231 293 if err != nil { 232 294 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") 233 295 }
+136
pkg/spxrpc/place_stream_multistream.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "slices" 9 + "strconv" 10 + 11 + "github.com/labstack/echo/v4" 12 + "github.com/streamplace/oatproxy/pkg/oatproxy" 13 + "go.opentelemetry.io/otel" 14 + placestreamtypes "stream.place/streamplace/pkg/streamplace" 15 + ) 16 + 17 + var allowedSchemes = []string{"rtmp", "rtmps"} 18 + 19 + func validateMultistreamTargetURL(urlStr string) error { 20 + u, err := url.Parse(urlStr) 21 + if err != nil { 22 + return fmt.Errorf("invalid multistream target URL: %w", err) 23 + } 24 + if !slices.Contains(allowedSchemes, u.Scheme) { 25 + return fmt.Errorf("invalid multistream target scheme (must be rtmp or rtmps)") 26 + } 27 + if u.Scheme == "rtmps" && u.Port() == "" { 28 + return fmt.Errorf("rtmps URLs must include a port") 29 + } 30 + return nil 31 + } 32 + 33 + func (s *Server) handlePlaceStreamMultistreamCreateTarget(ctx context.Context, body *placestreamtypes.MultistreamCreateTarget_Input) (*placestreamtypes.MultistreamDefs_TargetView, error) { 34 + ctx, span := otel.Tracer("server").Start(ctx, "handleComAtprotoRepoUploadBlob") 35 + defer span.End() 36 + 37 + session, _ := oatproxy.GetOAuthSession(ctx) 38 + if session == nil { 39 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 40 + } 41 + 42 + err := validateMultistreamTargetURL(body.MultistreamTarget.Url) 43 + if err != nil { 44 + return nil, echo.NewHTTPError(http.StatusBadRequest, err.Error()) 45 + } 46 + 47 + return s.statefulDB.CreateMultistreamTarget(body, session.DID) 48 + } 49 + 50 + func (s *Server) handlePlaceStreamMultistreamListTargets(ctx context.Context, cursor string, limit int) (*placestreamtypes.MultistreamListTargets_Output, error) { 51 + ctx, span := otel.Tracer("server").Start(ctx, "handlePlaceStreamMultistreamListTargets") 52 + defer span.End() 53 + 54 + session, _ := oatproxy.GetOAuthSession(ctx) 55 + if session == nil { 56 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 57 + } 58 + 59 + // Set default limit and validate bounds 60 + if limit <= 0 || limit > 100 { 61 + limit = 50 62 + } 63 + 64 + // Parse cursor for offset 65 + offset := 0 66 + if cursor != "" { 67 + var err error 68 + offset, err = strconv.Atoi(cursor) 69 + if err != nil { 70 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid cursor") 71 + } 72 + } 73 + 74 + targets, err := s.statefulDB.ListMultistreamTargets(session.DID, limit+1, offset, nil) 75 + if err != nil { 76 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to list multistream targets") 77 + } 78 + 79 + // Check if there are more results 80 + var nextCursor *string 81 + if len(targets) > limit { 82 + targets = targets[:limit] 83 + next := strconv.Itoa(offset + limit) 84 + nextCursor = &next 85 + } 86 + 87 + return &placestreamtypes.MultistreamListTargets_Output{ 88 + Targets: targets, 89 + Cursor: nextCursor, 90 + }, nil 91 + } 92 + func (s *Server) handlePlaceStreamMultistreamPutTarget(ctx context.Context, body *placestreamtypes.MultistreamPutTarget_Input) (*placestreamtypes.MultistreamDefs_TargetView, error) { 93 + ctx, span := otel.Tracer("server").Start(ctx, "handlePlaceStreamMultistreamPutTarget") 94 + defer span.End() 95 + 96 + session, _ := oatproxy.GetOAuthSession(ctx) 97 + if session == nil { 98 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 99 + } 100 + 101 + err := validateMultistreamTargetURL(body.MultistreamTarget.Url) 102 + if err != nil { 103 + return nil, echo.NewHTTPError(http.StatusBadRequest, err.Error()) 104 + } 105 + 106 + // Build URI from rkey 107 + rkey := "" 108 + if body.Rkey != nil { 109 + rkey = *body.Rkey 110 + } 111 + if rkey == "" { 112 + return nil, echo.NewHTTPError(http.StatusBadRequest, "rkey is required") 113 + } 114 + uri := fmt.Sprintf("at://%s/place.stream.multistream.target/%s", session.DID, rkey) 115 + 116 + return s.statefulDB.UpdateMultistreamTarget(uri, body) 117 + } 118 + func (s *Server) handlePlaceStreamMultistreamDeleteTarget(ctx context.Context, body *placestreamtypes.MultistreamDeleteTarget_Input) (*placestreamtypes.MultistreamDeleteTarget_Output, error) { 119 + ctx, span := otel.Tracer("server").Start(ctx, "handlePlaceStreamMultistreamDeleteTarget") 120 + defer span.End() 121 + 122 + session, _ := oatproxy.GetOAuthSession(ctx) 123 + if session == nil { 124 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 125 + } 126 + 127 + // Build URI from rkey 128 + uri := fmt.Sprintf("at://%s/place.stream.multistream.target/%s", session.DID, body.Rkey) 129 + 130 + err := s.statefulDB.DeleteMultistreamTarget(uri) 131 + if err != nil { 132 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to delete multistream target") 133 + } 134 + 135 + return &placestreamtypes.MultistreamDeleteTarget_Output{}, nil 136 + }
+22 -17
pkg/spxrpc/spxrpc.go
··· 18 18 "stream.place/streamplace/pkg/atproto" 19 19 "stream.place/streamplace/pkg/bus" 20 20 "stream.place/streamplace/pkg/config" 21 + "stream.place/streamplace/pkg/localdb" 21 22 "stream.place/streamplace/pkg/log" 22 23 "stream.place/streamplace/pkg/model" 23 24 "stream.place/streamplace/pkg/statedb" 24 25 ) 25 26 26 27 type Server struct { 27 - e *echo.Echo 28 - cli *config.CLI 29 - model model.Model 30 - OGImageCache *cache.Cache 31 - ATSync *atproto.ATProtoSynchronizer 32 - statefulDB *statedb.StatefulDB 33 - bus *bus.Bus 34 - op *oatproxy.OATProxy 28 + e *echo.Echo 29 + cli *config.CLI 30 + model model.Model 31 + OGImageCache *cache.Cache 32 + LiveUsersCache *cache.Cache 33 + ATSync *atproto.ATProtoSynchronizer 34 + statefulDB *statedb.StatefulDB 35 + bus *bus.Bus 36 + op *oatproxy.OATProxy 37 + localDB localdb.LocalDB 35 38 } 36 39 37 - func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus) (*Server, error) { 40 + func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB) (*Server, error) { 38 41 e := echo.New() 39 42 s := &Server{ 40 - e: e, 41 - cli: cli, 42 - model: model, 43 - OGImageCache: cache.New(5*time.Minute, 10*time.Minute), // 5min TTL, 10min cleanup 44 - ATSync: atsync, 45 - statefulDB: statefulDB, 46 - bus: bus, 47 - op: op, 43 + e: e, 44 + cli: cli, 45 + model: model, 46 + OGImageCache: cache.New(5*time.Minute, 10*time.Minute), // 5min TTL, 10min cleanup 47 + LiveUsersCache: cache.New(5*time.Second, 10*time.Second), // 5sec TTL, 10sec cleanup 48 + ATSync: atsync, 49 + statefulDB: statefulDB, 50 + bus: bus, 51 + op: op, 52 + localDB: ldb, 48 53 } 49 54 e.Use(s.ErrorHandlingMiddleware()) 50 55 e.Use(s.ContextPreservingMiddleware())
+118
pkg/spxrpc/stubs.go
··· 71 71 e.POST("/xrpc/com.atproto.repo.uploadBlob", s.HandleComAtprotoRepoUploadBlob) 72 72 e.GET("/xrpc/com.atproto.server.describeServer", s.HandleComAtprotoServerDescribeServer) 73 73 e.GET("/xrpc/com.atproto.sync.getRecord", s.HandleComAtprotoSyncGetRecord) 74 + e.GET("/xrpc/com.atproto.sync.getRepo", s.HandleComAtprotoSyncGetRepo) 74 75 e.GET("/xrpc/com.atproto.sync.listRepos", s.HandleComAtprotoSyncListRepos) 75 76 return nil 76 77 } ··· 236 237 return c.Stream(200, "application/vnd.ipld.car", out) 237 238 } 238 239 240 + func (s *Server) HandleComAtprotoSyncGetRepo(c echo.Context) error { 241 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetRepo") 242 + defer span.End() 243 + did := c.QueryParam("did") 244 + since := c.QueryParam("since") 245 + var out io.Reader 246 + var handleErr error 247 + // func (s *Server) handleComAtprotoSyncGetRepo(ctx context.Context,did string,since string) (io.Reader, error) 248 + out, handleErr = s.handleComAtprotoSyncGetRepo(ctx, did, since) 249 + if handleErr != nil { 250 + return handleErr 251 + } 252 + return c.Stream(200, "application/vnd.ipld.car", out) 253 + } 254 + 239 255 func (s *Server) HandleComAtprotoSyncListRepos(c echo.Context) error { 240 256 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncListRepos") 241 257 defer span.End() ··· 268 284 e.POST("/xrpc/place.stream.branding.updateBlob", s.HandlePlaceStreamBrandingUpdateBlob) 269 285 e.GET("/xrpc/place.stream.broadcast.getBroadcaster", s.HandlePlaceStreamBroadcastGetBroadcaster) 270 286 e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) 287 + e.POST("/xrpc/place.stream.live.denyTeleport", s.HandlePlaceStreamLiveDenyTeleport) 271 288 e.GET("/xrpc/place.stream.live.getLiveUsers", s.HandlePlaceStreamLiveGetLiveUsers) 272 289 e.GET("/xrpc/place.stream.live.getProfileCard", s.HandlePlaceStreamLiveGetProfileCard) 273 290 e.GET("/xrpc/place.stream.live.getRecommendations", s.HandlePlaceStreamLiveGetRecommendations) ··· 278 295 e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock) 279 296 e.POST("/xrpc/place.stream.moderation.deleteGate", s.HandlePlaceStreamModerationDeleteGate) 280 297 e.POST("/xrpc/place.stream.moderation.updateLivestream", s.HandlePlaceStreamModerationUpdateLivestream) 298 + e.POST("/xrpc/place.stream.multistream.createTarget", s.HandlePlaceStreamMultistreamCreateTarget) 299 + e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget) 300 + e.GET("/xrpc/place.stream.multistream.listTargets", s.HandlePlaceStreamMultistreamListTargets) 301 + e.POST("/xrpc/place.stream.multistream.putTarget", s.HandlePlaceStreamMultistreamPutTarget) 281 302 e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) 282 303 e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) 283 304 e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) ··· 380 401 return c.JSON(200, out) 381 402 } 382 403 404 + func (s *Server) HandlePlaceStreamLiveDenyTeleport(c echo.Context) error { 405 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveDenyTeleport") 406 + defer span.End() 407 + 408 + var body placestream.LiveDenyTeleport_Input 409 + if err := c.Bind(&body); err != nil { 410 + return err 411 + } 412 + var out *placestream.LiveDenyTeleport_Output 413 + var handleErr error 414 + // func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context,body *placestream.LiveDenyTeleport_Input) (*placestream.LiveDenyTeleport_Output, error) 415 + out, handleErr = s.handlePlaceStreamLiveDenyTeleport(ctx, &body) 416 + if handleErr != nil { 417 + return handleErr 418 + } 419 + return c.JSON(200, out) 420 + } 421 + 383 422 func (s *Server) HandlePlaceStreamLiveGetLiveUsers(c echo.Context) error { 384 423 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveGetLiveUsers") 385 424 defer span.End() ··· 568 607 var handleErr error 569 608 // func (s *Server) handlePlaceStreamModerationUpdateLivestream(ctx context.Context,body *placestream.ModerationUpdateLivestream_Input) (*placestream.ModerationUpdateLivestream_Output, error) 570 609 out, handleErr = s.handlePlaceStreamModerationUpdateLivestream(ctx, &body) 610 + if handleErr != nil { 611 + return handleErr 612 + } 613 + return c.JSON(200, out) 614 + } 615 + 616 + func (s *Server) HandlePlaceStreamMultistreamCreateTarget(c echo.Context) error { 617 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamMultistreamCreateTarget") 618 + defer span.End() 619 + 620 + var body placestream.MultistreamCreateTarget_Input 621 + if err := c.Bind(&body); err != nil { 622 + return err 623 + } 624 + var out *placestream.MultistreamDefs_TargetView 625 + var handleErr error 626 + // func (s *Server) handlePlaceStreamMultistreamCreateTarget(ctx context.Context,body *placestream.MultistreamCreateTarget_Input) (*placestream.MultistreamDefs_TargetView, error) 627 + out, handleErr = s.handlePlaceStreamMultistreamCreateTarget(ctx, &body) 628 + if handleErr != nil { 629 + return handleErr 630 + } 631 + return c.JSON(200, out) 632 + } 633 + 634 + func (s *Server) HandlePlaceStreamMultistreamDeleteTarget(c echo.Context) error { 635 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamMultistreamDeleteTarget") 636 + defer span.End() 637 + 638 + var body placestream.MultistreamDeleteTarget_Input 639 + if err := c.Bind(&body); err != nil { 640 + return err 641 + } 642 + var out *placestream.MultistreamDeleteTarget_Output 643 + var handleErr error 644 + // func (s *Server) handlePlaceStreamMultistreamDeleteTarget(ctx context.Context,body *placestream.MultistreamDeleteTarget_Input) (*placestream.MultistreamDeleteTarget_Output, error) 645 + out, handleErr = s.handlePlaceStreamMultistreamDeleteTarget(ctx, &body) 646 + if handleErr != nil { 647 + return handleErr 648 + } 649 + return c.JSON(200, out) 650 + } 651 + 652 + func (s *Server) HandlePlaceStreamMultistreamListTargets(c echo.Context) error { 653 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamMultistreamListTargets") 654 + defer span.End() 655 + cursor := c.QueryParam("cursor") 656 + 657 + var limit int 658 + if p := c.QueryParam("limit"); p != "" { 659 + var err error 660 + limit, err = strconv.Atoi(p) 661 + if err != nil { 662 + return err 663 + } 664 + } else { 665 + limit = 50 666 + } 667 + var out *placestream.MultistreamListTargets_Output 668 + var handleErr error 669 + // func (s *Server) handlePlaceStreamMultistreamListTargets(ctx context.Context,cursor string,limit int) (*placestream.MultistreamListTargets_Output, error) 670 + out, handleErr = s.handlePlaceStreamMultistreamListTargets(ctx, cursor, limit) 671 + if handleErr != nil { 672 + return handleErr 673 + } 674 + return c.JSON(200, out) 675 + } 676 + 677 + func (s *Server) HandlePlaceStreamMultistreamPutTarget(c echo.Context) error { 678 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamMultistreamPutTarget") 679 + defer span.End() 680 + 681 + var body placestream.MultistreamPutTarget_Input 682 + if err := c.Bind(&body); err != nil { 683 + return err 684 + } 685 + var out *placestream.MultistreamDefs_TargetView 686 + var handleErr error 687 + // func (s *Server) handlePlaceStreamMultistreamPutTarget(ctx context.Context,body *placestream.MultistreamPutTarget_Input) (*placestream.MultistreamDefs_TargetView, error) 688 + out, handleErr = s.handlePlaceStreamMultistreamPutTarget(ctx, &body) 571 689 if handleErr != nil { 572 690 return handleErr 573 691 }
+34
pkg/statedb/multistream_event.go
··· 1 + package statedb 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/google/uuid" 7 + ) 8 + 9 + type MultistreamEvent struct { 10 + ID string `gorm:"column:id;primarykey"` 11 + TargetURI string `gorm:"column:target_uri;primarykey;index:idx_target_created,priority:1"` 12 + Message string `gorm:"column:message"` 13 + Status string `gorm:"column:status"` 14 + CreatedAt time.Time `gorm:"column:created_at;index:idx_target_created,priority:2"` 15 + } 16 + 17 + func (m *MultistreamEvent) TableName() string { 18 + return "multistream_events" 19 + } 20 + 21 + func (state *StatefulDB) CreateMultistreamEvent(targetURI, message, status string) error { 22 + uu, err := uuid.NewV7() 23 + if err != nil { 24 + return err 25 + } 26 + event := &MultistreamEvent{ 27 + ID: uu.String(), 28 + TargetURI: targetURI, 29 + Message: message, 30 + Status: status, 31 + CreatedAt: time.Now().UTC(), 32 + } 33 + return state.DB.Create(event).Error 34 + }
+220
pkg/statedb/multistream_target.go
··· 1 + package statedb 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "time" 7 + 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + "github.com/bluesky-social/indigo/util" 10 + "stream.place/streamplace/pkg/spid" 11 + "stream.place/streamplace/pkg/streamplace" 12 + ) 13 + 14 + const MAX_MULTISTREAM_TARGETS = 100 15 + const MAX_ACTIVE_MULTISTREAM_TARGETS = 5 16 + 17 + type MultistreamTarget struct { 18 + URI string `gorm:"column:uri;primarykey"` 19 + CID string `gorm:"column:cid;not null"` 20 + Active bool `gorm:"column:active"` 21 + RepoDID string `gorm:"column:repo_did;not null;index"` 22 + MultistreamTarget []byte `gorm:"column:record"` 23 + } 24 + 25 + func (m *MultistreamTarget) TableName() string { 26 + return "multistream_targets" 27 + } 28 + 29 + func (state *StatefulDB) CreateMultistreamTarget(input *streamplace.MultistreamCreateTarget_Input, repoDID string) (*streamplace.MultistreamDefs_TargetView, error) { 30 + // Check total targets limit 31 + var totalCount int64 32 + err := state.DB.Model(&MultistreamTarget{}).Where("repo_did = ?", repoDID).Count(&totalCount).Error 33 + if err != nil { 34 + return nil, fmt.Errorf("failed to count existing targets: %w", err) 35 + } 36 + if totalCount >= MAX_MULTISTREAM_TARGETS { 37 + return nil, fmt.Errorf("maximum number of multistream targets (%d) reached", MAX_MULTISTREAM_TARGETS) 38 + } 39 + 40 + // Check active targets limit if this target is active 41 + if input.MultistreamTarget.Active { 42 + var activeCount int64 43 + err := state.DB.Model(&MultistreamTarget{}).Where("repo_did = ? AND active = ?", repoDID, true).Count(&activeCount).Error 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to count active targets: %w", err) 46 + } 47 + if activeCount >= MAX_ACTIVE_MULTISTREAM_TARGETS { 48 + return nil, fmt.Errorf("maximum number of active multistream targets (%d) reached", MAX_ACTIVE_MULTISTREAM_TARGETS) 49 + } 50 + } 51 + 52 + // this URI is, of course, a LIE 53 + tid := spid.TIDClock.Next() 54 + uri := fmt.Sprintf("at://%s/place.stream.multistream.target/%s", repoDID, tid.String()) 55 + 56 + cid, err := spid.GetCID(input.MultistreamTarget) 57 + if err != nil { 58 + return nil, fmt.Errorf("failed to get CID: %w", err) 59 + } 60 + 61 + buf := bytes.Buffer{} 62 + err = input.MultistreamTarget.MarshalCBOR(&buf) 63 + if err != nil { 64 + return nil, fmt.Errorf("failed to marshal multistream target: %w", err) 65 + } 66 + 67 + dbTarget := &MultistreamTarget{ 68 + URI: uri, 69 + CID: cid.String(), 70 + RepoDID: repoDID, 71 + MultistreamTarget: buf.Bytes(), 72 + Active: input.MultistreamTarget.Active, 73 + } 74 + err = state.DB.Create(dbTarget).Error 75 + if err != nil { 76 + return nil, err 77 + } 78 + return &streamplace.MultistreamDefs_TargetView{ 79 + Uri: uri, 80 + Cid: cid.String(), 81 + Record: &lexutil.LexiconTypeDecoder{Val: input.MultistreamTarget}, 82 + }, nil 83 + } 84 + 85 + func (state *StatefulDB) GetMultistreamTarget(uri string) (*streamplace.MultistreamDefs_TargetView, error) { 86 + return nil, nil 87 + } 88 + 89 + type TargetWithEvent struct { 90 + MultistreamTarget 91 + LatestEventID *string `gorm:"column:latest_event_id"` 92 + LatestEventStatus *string `gorm:"column:latest_event_status"` 93 + LatestEventMessage *string `gorm:"column:latest_event_message"` 94 + LatestEventCreatedAt *time.Time `gorm:"column:latest_event_created_at"` 95 + } 96 + 97 + func (state *StatefulDB) ListMultistreamTargets(repoDID string, limit int, offset int, active *bool) ([]*streamplace.MultistreamDefs_TargetView, error) { 98 + 99 + var targets []TargetWithEvent 100 + query := state.DB.Table("multistream_targets"). 101 + Select("multistream_targets.*, me.id as latest_event_id, me.status as latest_event_status, me.message as latest_event_message, me.created_at as latest_event_created_at"). 102 + Joins(`LEFT JOIN multistream_events me ON multistream_targets.uri = me.target_uri 103 + AND me.created_at = (SELECT MAX(created_at) FROM multistream_events WHERE target_uri = multistream_targets.uri)`). 104 + Where("repo_did = ?", repoDID) 105 + 106 + if active != nil { 107 + query = query.Where("active = ?", *active) 108 + } 109 + 110 + err := query.Limit(limit). 111 + Offset(offset). 112 + Order("uri ASC"). 113 + Find(&targets).Error 114 + if err != nil { 115 + return nil, fmt.Errorf("failed to list multistream targets: %w", err) 116 + } 117 + 118 + result := make([]*streamplace.MultistreamDefs_TargetView, len(targets)) 119 + for i, target := range targets { 120 + var multistreamTarget streamplace.MultistreamTarget 121 + err = multistreamTarget.UnmarshalCBOR(bytes.NewReader(target.MultistreamTarget.MultistreamTarget)) 122 + if err != nil { 123 + return nil, fmt.Errorf("failed to unmarshal multistream target: %w", err) 124 + } 125 + cid, err := spid.GetCID(&multistreamTarget) 126 + if err != nil { 127 + return nil, fmt.Errorf("failed to get CID: %w", err) 128 + } 129 + 130 + targetView := &streamplace.MultistreamDefs_TargetView{ 131 + Uri: target.URI, 132 + Cid: cid.String(), 133 + Record: &lexutil.LexiconTypeDecoder{Val: &multistreamTarget}, 134 + } 135 + 136 + // Add the latest event if it exists 137 + if target.LatestEventID != nil { 138 + event := &streamplace.MultistreamDefs_Event{ 139 + Status: *target.LatestEventStatus, 140 + Message: *target.LatestEventMessage, 141 + CreatedAt: target.LatestEventCreatedAt.Format(util.ISO8601), 142 + } 143 + targetView.LatestEvent = event 144 + } 145 + 146 + result[i] = targetView 147 + } 148 + 149 + return result, nil 150 + } 151 + 152 + func (state *StatefulDB) UpdateMultistreamTarget(uri string, input *streamplace.MultistreamPutTarget_Input) (*streamplace.MultistreamDefs_TargetView, error) { 153 + if input.MultistreamTarget == nil { 154 + return nil, fmt.Errorf("multistream target is required") 155 + } 156 + 157 + // Get the current target to check repo ownership and current active status 158 + var currentTarget MultistreamTarget 159 + err := state.DB.Where("uri = ?", uri).First(&currentTarget).Error 160 + if err != nil { 161 + return nil, fmt.Errorf("multistream target not found") 162 + } 163 + 164 + // If updating to active and wasn't previously active, check active targets limit 165 + if input.MultistreamTarget.Active && !currentTarget.Active { 166 + var activeCount int64 167 + err := state.DB.Model(&MultistreamTarget{}).Where("repo_did = ? AND active = ?", currentTarget.RepoDID, true).Count(&activeCount).Error 168 + if err != nil { 169 + return nil, fmt.Errorf("failed to count active targets: %w", err) 170 + } 171 + if activeCount >= MAX_ACTIVE_MULTISTREAM_TARGETS { 172 + return nil, fmt.Errorf("maximum number of active multistream targets (%d) reached", MAX_ACTIVE_MULTISTREAM_TARGETS) 173 + } 174 + } 175 + 176 + // Get CID for the updated target 177 + cid, err := spid.GetCID(input.MultistreamTarget) 178 + if err != nil { 179 + return nil, fmt.Errorf("failed to get CID: %w", err) 180 + } 181 + 182 + // Marshal the target data 183 + buf := bytes.Buffer{} 184 + err = input.MultistreamTarget.MarshalCBOR(&buf) 185 + if err != nil { 186 + return nil, fmt.Errorf("failed to marshal multistream target: %w", err) 187 + } 188 + 189 + // Update the database record 190 + updates := map[string]interface{}{ 191 + "cid": cid.String(), 192 + "record": buf.Bytes(), 193 + "active": input.MultistreamTarget.Active, 194 + } 195 + 196 + result := state.DB.Model(&MultistreamTarget{}).Where("uri = ?", uri).Updates(updates) 197 + if result.Error != nil { 198 + return nil, fmt.Errorf("failed to update multistream target: %w", result.Error) 199 + } 200 + if result.RowsAffected == 0 { 201 + return nil, fmt.Errorf("multistream target not found") 202 + } 203 + 204 + return &streamplace.MultistreamDefs_TargetView{ 205 + Uri: uri, 206 + Cid: cid.String(), 207 + Record: &lexutil.LexiconTypeDecoder{Val: input.MultistreamTarget}, 208 + }, nil 209 + } 210 + 211 + func (state *StatefulDB) DeleteMultistreamTarget(uri string) error { 212 + result := state.DB.Where("uri = ?", uri).Delete(&MultistreamTarget{}) 213 + if result.Error != nil { 214 + return fmt.Errorf("failed to delete multistream target: %w", result.Error) 215 + } 216 + if result.RowsAffected == 0 { 217 + return fmt.Errorf("multistream target not found") 218 + } 219 + return nil 220 + }
+2
pkg/statedb/statedb.go
··· 49 49 AppTask{}, 50 50 Repo{}, 51 51 Webhook{}, 52 + MultistreamTarget{}, 53 + MultistreamEvent{}, 52 54 BrandingBlob{}, 53 55 ModerationAuditLog{}, 54 56 }
+6 -6
pkg/storage/storage.go
··· 10 10 "golang.org/x/sync/errgroup" 11 11 "stream.place/streamplace/pkg/aqtime" 12 12 "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/localdb" 13 14 "stream.place/streamplace/pkg/log" 14 - "stream.place/streamplace/pkg/model" 15 15 ) 16 16 17 17 const moderationRetention = 120 * time.Second 18 18 19 - func StartSegmentCleaner(ctx context.Context, mod model.Model, cli *config.CLI) error { 19 + func StartSegmentCleaner(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI) error { 20 20 ctx = log.WithLogValues(ctx, "func", "StartSegmentCleaner") 21 21 g, ctx := errgroup.WithContext(ctx) 22 22 g.Go(func() error { ··· 25 25 case <-ctx.Done(): 26 26 return nil 27 27 case <-time.After(60 * time.Second): 28 - expiredSegments, err := mod.GetExpiredSegments(ctx) 28 + expiredSegments, err := localDB.GetExpiredSegments(ctx) 29 29 if err != nil { 30 30 return err 31 31 } 32 32 log.Log(ctx, "Cleaning expired segments", "count", len(expiredSegments)) 33 33 for _, seg := range expiredSegments { 34 34 g.Go(func() error { 35 - err := deleteSegment(ctx, mod, cli, seg) 35 + err := deleteSegment(ctx, localDB, cli, seg) 36 36 if err != nil { 37 37 log.Error(ctx, "Failed to delete segment", "error", err) 38 38 } ··· 47 47 return g.Wait() 48 48 } 49 49 50 - func deleteSegment(ctx context.Context, mod model.Model, cli *config.CLI, seg model.Segment) error { 50 + func deleteSegment(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, seg localdb.Segment) error { 51 51 if time.Since(seg.StartTime) < moderationRetention { 52 52 log.Debug(ctx, "Skipping deletion of segment", "id", seg.ID, "time since start", time.Since(seg.StartTime)) 53 53 return nil ··· 61 61 if err != nil && !errors.Is(err, os.ErrNotExist) { 62 62 return err 63 63 } 64 - err = mod.DeleteSegment(ctx, seg.ID) 64 + err = localDB.DeleteSegment(ctx, seg.ID) 65 65 if err != nil { 66 66 return err 67 67 }
+493
pkg/streamplace/cbor_gen.go
··· 3448 3448 3449 3449 return nil 3450 3450 } 3451 + func (t *MultistreamTarget) MarshalCBOR(w io.Writer) error { 3452 + if t == nil { 3453 + _, err := w.Write(cbg.CborNull) 3454 + return err 3455 + } 3456 + 3457 + cw := cbg.NewCborWriter(w) 3458 + fieldCount := 5 3459 + 3460 + if t.Name == nil { 3461 + fieldCount-- 3462 + } 3463 + 3464 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3465 + return err 3466 + } 3467 + 3468 + // t.Url (string) (string) 3469 + if len("url") > 1000000 { 3470 + return xerrors.Errorf("Value in field \"url\" was too long") 3471 + } 3472 + 3473 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("url"))); err != nil { 3474 + return err 3475 + } 3476 + if _, err := cw.WriteString(string("url")); err != nil { 3477 + return err 3478 + } 3479 + 3480 + if len(t.Url) > 1000000 { 3481 + return xerrors.Errorf("Value in field t.Url was too long") 3482 + } 3483 + 3484 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Url))); err != nil { 3485 + return err 3486 + } 3487 + if _, err := cw.WriteString(string(t.Url)); err != nil { 3488 + return err 3489 + } 3490 + 3491 + // t.Name (string) (string) 3492 + if t.Name != nil { 3493 + 3494 + if len("name") > 1000000 { 3495 + return xerrors.Errorf("Value in field \"name\" was too long") 3496 + } 3497 + 3498 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 3499 + return err 3500 + } 3501 + if _, err := cw.WriteString(string("name")); err != nil { 3502 + return err 3503 + } 3504 + 3505 + if t.Name == nil { 3506 + if _, err := cw.Write(cbg.CborNull); err != nil { 3507 + return err 3508 + } 3509 + } else { 3510 + if len(*t.Name) > 1000000 { 3511 + return xerrors.Errorf("Value in field t.Name was too long") 3512 + } 3513 + 3514 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Name))); err != nil { 3515 + return err 3516 + } 3517 + if _, err := cw.WriteString(string(*t.Name)); err != nil { 3518 + return err 3519 + } 3520 + } 3521 + } 3522 + 3523 + // t.LexiconTypeID (string) (string) 3524 + if len("$type") > 1000000 { 3525 + return xerrors.Errorf("Value in field \"$type\" was too long") 3526 + } 3527 + 3528 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3529 + return err 3530 + } 3531 + if _, err := cw.WriteString(string("$type")); err != nil { 3532 + return err 3533 + } 3534 + 3535 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.multistream.target"))); err != nil { 3536 + return err 3537 + } 3538 + if _, err := cw.WriteString(string("place.stream.multistream.target")); err != nil { 3539 + return err 3540 + } 3541 + 3542 + // t.Active (bool) (bool) 3543 + if len("active") > 1000000 { 3544 + return xerrors.Errorf("Value in field \"active\" was too long") 3545 + } 3546 + 3547 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("active"))); err != nil { 3548 + return err 3549 + } 3550 + if _, err := cw.WriteString(string("active")); err != nil { 3551 + return err 3552 + } 3553 + 3554 + if err := cbg.WriteBool(w, t.Active); err != nil { 3555 + return err 3556 + } 3557 + 3558 + // t.CreatedAt (string) (string) 3559 + if len("createdAt") > 1000000 { 3560 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 3561 + } 3562 + 3563 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 3564 + return err 3565 + } 3566 + if _, err := cw.WriteString(string("createdAt")); err != nil { 3567 + return err 3568 + } 3569 + 3570 + if len(t.CreatedAt) > 1000000 { 3571 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 3572 + } 3573 + 3574 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 3575 + return err 3576 + } 3577 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 3578 + return err 3579 + } 3580 + return nil 3581 + } 3582 + 3583 + func (t *MultistreamTarget) UnmarshalCBOR(r io.Reader) (err error) { 3584 + *t = MultistreamTarget{} 3585 + 3586 + cr := cbg.NewCborReader(r) 3587 + 3588 + maj, extra, err := cr.ReadHeader() 3589 + if err != nil { 3590 + return err 3591 + } 3592 + defer func() { 3593 + if err == io.EOF { 3594 + err = io.ErrUnexpectedEOF 3595 + } 3596 + }() 3597 + 3598 + if maj != cbg.MajMap { 3599 + return fmt.Errorf("cbor input should be of type map") 3600 + } 3601 + 3602 + if extra > cbg.MaxLength { 3603 + return fmt.Errorf("MultistreamTarget: map struct too large (%d)", extra) 3604 + } 3605 + 3606 + n := extra 3607 + 3608 + nameBuf := make([]byte, 9) 3609 + for i := uint64(0); i < n; i++ { 3610 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3611 + if err != nil { 3612 + return err 3613 + } 3614 + 3615 + if !ok { 3616 + // Field doesn't exist on this type, so ignore it 3617 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3618 + return err 3619 + } 3620 + continue 3621 + } 3622 + 3623 + switch string(nameBuf[:nameLen]) { 3624 + // t.Url (string) (string) 3625 + case "url": 3626 + 3627 + { 3628 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3629 + if err != nil { 3630 + return err 3631 + } 3632 + 3633 + t.Url = string(sval) 3634 + } 3635 + // t.Name (string) (string) 3636 + case "name": 3637 + 3638 + { 3639 + b, err := cr.ReadByte() 3640 + if err != nil { 3641 + return err 3642 + } 3643 + if b != cbg.CborNull[0] { 3644 + if err := cr.UnreadByte(); err != nil { 3645 + return err 3646 + } 3647 + 3648 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3649 + if err != nil { 3650 + return err 3651 + } 3652 + 3653 + t.Name = (*string)(&sval) 3654 + } 3655 + } 3656 + // t.LexiconTypeID (string) (string) 3657 + case "$type": 3658 + 3659 + { 3660 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3661 + if err != nil { 3662 + return err 3663 + } 3664 + 3665 + t.LexiconTypeID = string(sval) 3666 + } 3667 + // t.Active (bool) (bool) 3668 + case "active": 3669 + 3670 + maj, extra, err = cr.ReadHeader() 3671 + if err != nil { 3672 + return err 3673 + } 3674 + if maj != cbg.MajOther { 3675 + return fmt.Errorf("booleans must be major type 7") 3676 + } 3677 + switch extra { 3678 + case 20: 3679 + t.Active = false 3680 + case 21: 3681 + t.Active = true 3682 + default: 3683 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 3684 + } 3685 + // t.CreatedAt (string) (string) 3686 + case "createdAt": 3687 + 3688 + { 3689 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3690 + if err != nil { 3691 + return err 3692 + } 3693 + 3694 + t.CreatedAt = string(sval) 3695 + } 3696 + 3697 + default: 3698 + // Field doesn't exist on this type, so ignore it 3699 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3700 + return err 3701 + } 3702 + } 3703 + } 3704 + 3705 + return nil 3706 + } 3451 3707 func (t *BroadcastOrigin) MarshalCBOR(w io.Writer) error { 3452 3708 if t == nil { 3453 3709 _, err := w.Write(cbg.CborNull) ··· 5257 5513 } 5258 5514 5259 5515 t.ExpirationTime = (*string)(&sval) 5516 + } 5517 + } 5518 + 5519 + default: 5520 + // Field doesn't exist on this type, so ignore it 5521 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5522 + return err 5523 + } 5524 + } 5525 + } 5526 + 5527 + return nil 5528 + } 5529 + func (t *LiveTeleport) MarshalCBOR(w io.Writer) error { 5530 + if t == nil { 5531 + _, err := w.Write(cbg.CborNull) 5532 + return err 5533 + } 5534 + 5535 + cw := cbg.NewCborWriter(w) 5536 + fieldCount := 4 5537 + 5538 + if t.DurationSeconds == nil { 5539 + fieldCount-- 5540 + } 5541 + 5542 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 5543 + return err 5544 + } 5545 + 5546 + // t.LexiconTypeID (string) (string) 5547 + if len("$type") > 1000000 { 5548 + return xerrors.Errorf("Value in field \"$type\" was too long") 5549 + } 5550 + 5551 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5552 + return err 5553 + } 5554 + if _, err := cw.WriteString(string("$type")); err != nil { 5555 + return err 5556 + } 5557 + 5558 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.live.teleport"))); err != nil { 5559 + return err 5560 + } 5561 + if _, err := cw.WriteString(string("place.stream.live.teleport")); err != nil { 5562 + return err 5563 + } 5564 + 5565 + // t.StartsAt (string) (string) 5566 + if len("startsAt") > 1000000 { 5567 + return xerrors.Errorf("Value in field \"startsAt\" was too long") 5568 + } 5569 + 5570 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("startsAt"))); err != nil { 5571 + return err 5572 + } 5573 + if _, err := cw.WriteString(string("startsAt")); err != nil { 5574 + return err 5575 + } 5576 + 5577 + if len(t.StartsAt) > 1000000 { 5578 + return xerrors.Errorf("Value in field t.StartsAt was too long") 5579 + } 5580 + 5581 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.StartsAt))); err != nil { 5582 + return err 5583 + } 5584 + if _, err := cw.WriteString(string(t.StartsAt)); err != nil { 5585 + return err 5586 + } 5587 + 5588 + // t.Streamer (string) (string) 5589 + if len("streamer") > 1000000 { 5590 + return xerrors.Errorf("Value in field \"streamer\" was too long") 5591 + } 5592 + 5593 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("streamer"))); err != nil { 5594 + return err 5595 + } 5596 + if _, err := cw.WriteString(string("streamer")); err != nil { 5597 + return err 5598 + } 5599 + 5600 + if len(t.Streamer) > 1000000 { 5601 + return xerrors.Errorf("Value in field t.Streamer was too long") 5602 + } 5603 + 5604 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Streamer))); err != nil { 5605 + return err 5606 + } 5607 + if _, err := cw.WriteString(string(t.Streamer)); err != nil { 5608 + return err 5609 + } 5610 + 5611 + // t.DurationSeconds (int64) (int64) 5612 + if t.DurationSeconds != nil { 5613 + 5614 + if len("durationSeconds") > 1000000 { 5615 + return xerrors.Errorf("Value in field \"durationSeconds\" was too long") 5616 + } 5617 + 5618 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("durationSeconds"))); err != nil { 5619 + return err 5620 + } 5621 + if _, err := cw.WriteString(string("durationSeconds")); err != nil { 5622 + return err 5623 + } 5624 + 5625 + if t.DurationSeconds == nil { 5626 + if _, err := cw.Write(cbg.CborNull); err != nil { 5627 + return err 5628 + } 5629 + } else { 5630 + if *t.DurationSeconds >= 0 { 5631 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.DurationSeconds)); err != nil { 5632 + return err 5633 + } 5634 + } else { 5635 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.DurationSeconds-1)); err != nil { 5636 + return err 5637 + } 5638 + } 5639 + } 5640 + 5641 + } 5642 + return nil 5643 + } 5644 + 5645 + func (t *LiveTeleport) UnmarshalCBOR(r io.Reader) (err error) { 5646 + *t = LiveTeleport{} 5647 + 5648 + cr := cbg.NewCborReader(r) 5649 + 5650 + maj, extra, err := cr.ReadHeader() 5651 + if err != nil { 5652 + return err 5653 + } 5654 + defer func() { 5655 + if err == io.EOF { 5656 + err = io.ErrUnexpectedEOF 5657 + } 5658 + }() 5659 + 5660 + if maj != cbg.MajMap { 5661 + return fmt.Errorf("cbor input should be of type map") 5662 + } 5663 + 5664 + if extra > cbg.MaxLength { 5665 + return fmt.Errorf("LiveTeleport: map struct too large (%d)", extra) 5666 + } 5667 + 5668 + n := extra 5669 + 5670 + nameBuf := make([]byte, 15) 5671 + for i := uint64(0); i < n; i++ { 5672 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5673 + if err != nil { 5674 + return err 5675 + } 5676 + 5677 + if !ok { 5678 + // Field doesn't exist on this type, so ignore it 5679 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5680 + return err 5681 + } 5682 + continue 5683 + } 5684 + 5685 + switch string(nameBuf[:nameLen]) { 5686 + // t.LexiconTypeID (string) (string) 5687 + case "$type": 5688 + 5689 + { 5690 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5691 + if err != nil { 5692 + return err 5693 + } 5694 + 5695 + t.LexiconTypeID = string(sval) 5696 + } 5697 + // t.StartsAt (string) (string) 5698 + case "startsAt": 5699 + 5700 + { 5701 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5702 + if err != nil { 5703 + return err 5704 + } 5705 + 5706 + t.StartsAt = string(sval) 5707 + } 5708 + // t.Streamer (string) (string) 5709 + case "streamer": 5710 + 5711 + { 5712 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5713 + if err != nil { 5714 + return err 5715 + } 5716 + 5717 + t.Streamer = string(sval) 5718 + } 5719 + // t.DurationSeconds (int64) (int64) 5720 + case "durationSeconds": 5721 + { 5722 + 5723 + b, err := cr.ReadByte() 5724 + if err != nil { 5725 + return err 5726 + } 5727 + if b != cbg.CborNull[0] { 5728 + if err := cr.UnreadByte(); err != nil { 5729 + return err 5730 + } 5731 + maj, extra, err := cr.ReadHeader() 5732 + if err != nil { 5733 + return err 5734 + } 5735 + var extraI int64 5736 + switch maj { 5737 + case cbg.MajUnsignedInt: 5738 + extraI = int64(extra) 5739 + if extraI < 0 { 5740 + return fmt.Errorf("int64 positive overflow") 5741 + } 5742 + case cbg.MajNegativeInt: 5743 + extraI = int64(extra) 5744 + if extraI < 0 { 5745 + return fmt.Errorf("int64 negative overflow") 5746 + } 5747 + extraI = -1 - extraI 5748 + default: 5749 + return fmt.Errorf("wrong type for int64 field: %d", maj) 5750 + } 5751 + 5752 + t.DurationSeconds = (*int64)(&extraI) 5260 5753 } 5261 5754 } 5262 5755
+33
pkg/streamplace/livedenyTeleport.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.denyTeleport 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // LiveDenyTeleport_Input is the input argument to a place.stream.live.denyTeleport call. 14 + type LiveDenyTeleport_Input struct { 15 + // uri: The URI of the teleport record to deny. 16 + Uri string `json:"uri" cborgen:"uri"` 17 + } 18 + 19 + // LiveDenyTeleport_Output is the output of a place.stream.live.denyTeleport call. 20 + type LiveDenyTeleport_Output struct { 21 + // success: Whether the teleport was successfully denied. 22 + Success bool `json:"success" cborgen:"success"` 23 + } 24 + 25 + // LiveDenyTeleport calls the XRPC method "place.stream.live.denyTeleport". 26 + func LiveDenyTeleport(ctx context.Context, c lexutil.LexClient, input *LiveDenyTeleport_Input) (*LiveDenyTeleport_Output, error) { 27 + var out LiveDenyTeleport_Output 28 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.live.denyTeleport", nil, input, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+23
pkg/streamplace/liveteleport.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.teleport 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.live.teleport", &LiveTeleport{}) 13 + } 14 + 15 + type LiveTeleport struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.live.teleport"` 17 + // durationSeconds: The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours). 18 + DurationSeconds *int64 `json:"durationSeconds,omitempty" cborgen:"durationSeconds,omitempty"` 19 + // startsAt: The time the teleport becomes active. 20 + StartsAt string `json:"startsAt" cborgen:"startsAt"` 21 + // streamer: The DID of the streamer to teleport to. 22 + Streamer string `json:"streamer" cborgen:"streamer"` 23 + }
+26
pkg/streamplace/multistreamcreateTarget.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.multistream.createTarget 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // MultistreamCreateTarget_Input is the input argument to a place.stream.multistream.createTarget call. 14 + type MultistreamCreateTarget_Input struct { 15 + MultistreamTarget *MultistreamTarget `json:"multistreamTarget" cborgen:"multistreamTarget"` 16 + } 17 + 18 + // MultistreamCreateTarget calls the XRPC method "place.stream.multistream.createTarget". 19 + func MultistreamCreateTarget(ctx context.Context, c lexutil.LexClient, input *MultistreamCreateTarget_Input) (*MultistreamDefs_TargetView, error) { 20 + var out MultistreamDefs_TargetView 21 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.multistream.createTarget", nil, input, &out); err != nil { 22 + return nil, err 23 + } 24 + 25 + return &out, nil 26 + }
+24
pkg/streamplace/multistreamdefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.multistream.defs 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + // MultistreamDefs_Event is a "event" in the place.stream.multistream.defs schema. 12 + type MultistreamDefs_Event struct { 13 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 14 + Message string `json:"message" cborgen:"message"` 15 + Status string `json:"status" cborgen:"status"` 16 + } 17 + 18 + // MultistreamDefs_TargetView is a "targetView" in the place.stream.multistream.defs schema. 19 + type MultistreamDefs_TargetView struct { 20 + Cid string `json:"cid" cborgen:"cid"` 21 + LatestEvent *MultistreamDefs_Event `json:"latestEvent,omitempty" cborgen:"latestEvent,omitempty"` 22 + Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` 23 + Uri string `json:"uri" cborgen:"uri"` 24 + }
+31
pkg/streamplace/multistreamdeleteTarget.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.multistream.deleteTarget 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // MultistreamDeleteTarget_Input is the input argument to a place.stream.multistream.deleteTarget call. 14 + type MultistreamDeleteTarget_Input struct { 15 + // rkey: The Record Key of the target to delete. 16 + Rkey string `json:"rkey" cborgen:"rkey"` 17 + } 18 + 19 + // MultistreamDeleteTarget_Output is the output of a place.stream.multistream.deleteTarget call. 20 + type MultistreamDeleteTarget_Output struct { 21 + } 22 + 23 + // MultistreamDeleteTarget calls the XRPC method "place.stream.multistream.deleteTarget". 24 + func MultistreamDeleteTarget(ctx context.Context, c lexutil.LexClient, input *MultistreamDeleteTarget_Input) (*MultistreamDeleteTarget_Output, error) { 25 + var out MultistreamDeleteTarget_Output 26 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.multistream.deleteTarget", nil, input, &out); err != nil { 27 + return nil, err 28 + } 29 + 30 + return &out, nil 31 + }
+44
pkg/streamplace/multistreamlistTargets.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.multistream.listTargets 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // MultistreamListTargets_Output is the output of a place.stream.multistream.listTargets call. 14 + type MultistreamListTargets_Output struct { 15 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 16 + Targets []*MultistreamDefs_TargetView `json:"targets" cborgen:"targets"` 17 + } 18 + 19 + // MultistreamListTargets_Record is a "record" in the place.stream.multistream.listTargets schema. 20 + type MultistreamListTargets_Record struct { 21 + Cid string `json:"cid" cborgen:"cid"` 22 + Uri string `json:"uri" cborgen:"uri"` 23 + Value *lexutil.LexiconTypeDecoder `json:"value" cborgen:"value"` 24 + } 25 + 26 + // MultistreamListTargets calls the XRPC method "place.stream.multistream.listTargets". 27 + // 28 + // limit: The number of targets to return. 29 + func MultistreamListTargets(ctx context.Context, c lexutil.LexClient, cursor string, limit int64) (*MultistreamListTargets_Output, error) { 30 + var out MultistreamListTargets_Output 31 + 32 + params := map[string]interface{}{} 33 + if cursor != "" { 34 + params["cursor"] = cursor 35 + } 36 + if limit != 0 { 37 + params["limit"] = limit 38 + } 39 + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.multistream.listTargets", params, nil, &out); err != nil { 40 + return nil, err 41 + } 42 + 43 + return &out, nil 44 + }
+28
pkg/streamplace/multistreamputTarget.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.multistream.putTarget 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // MultistreamPutTarget_Input is the input argument to a place.stream.multistream.putTarget call. 14 + type MultistreamPutTarget_Input struct { 15 + MultistreamTarget *MultistreamTarget `json:"multistreamTarget" cborgen:"multistreamTarget"` 16 + // rkey: The Record Key. 17 + Rkey *string `json:"rkey,omitempty" cborgen:"rkey,omitempty"` 18 + } 19 + 20 + // MultistreamPutTarget calls the XRPC method "place.stream.multistream.putTarget". 21 + func MultistreamPutTarget(ctx context.Context, c lexutil.LexClient, input *MultistreamPutTarget_Input) (*MultistreamDefs_TargetView, error) { 22 + var out MultistreamDefs_TargetView 23 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.multistream.putTarget", nil, input, &out); err != nil { 24 + return nil, err 25 + } 26 + 27 + return &out, nil 28 + }
+25
pkg/streamplace/multistreamtarget.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.multistream.target 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.multistream.target", &MultistreamTarget{}) 13 + } 14 + 15 + type MultistreamTarget struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.multistream.target"` 17 + // active: Whether this target is currently active. 18 + Active bool `json:"active" cborgen:"active"` 19 + // createdAt: When this target was created. 20 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 21 + // name: A user-friendly name for this target. 22 + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` 23 + // url: The rtmp:// or rtmps:// url of the target server. 24 + Url string `json:"url" cborgen:"url"` 25 + }
+46 -6
pkg/streamplace/streamlivestream.go
··· 59 59 } 60 60 61 61 type Livestream_StreamplaceAnything_Livestream struct { 62 - Livestream_LivestreamView *Livestream_LivestreamView 63 - Livestream_ViewerCount *Livestream_ViewerCount 64 - Defs_BlockView *Defs_BlockView 65 - Defs_Renditions *Defs_Renditions 66 - Defs_Rendition *Defs_Rendition 67 - ChatDefs_MessageView *ChatDefs_MessageView 62 + Livestream_LivestreamView *Livestream_LivestreamView 63 + Livestream_ViewerCount *Livestream_ViewerCount 64 + Livestream_TeleportArrival *Livestream_TeleportArrival 65 + Livestream_TeleportCanceled *Livestream_TeleportCanceled 66 + Defs_BlockView *Defs_BlockView 67 + Defs_Renditions *Defs_Renditions 68 + Defs_Rendition *Defs_Rendition 69 + ChatDefs_MessageView *ChatDefs_MessageView 68 70 } 69 71 70 72 func (t *Livestream_StreamplaceAnything_Livestream) MarshalJSON() ([]byte, error) { ··· 75 77 if t.Livestream_ViewerCount != nil { 76 78 t.Livestream_ViewerCount.LexiconTypeID = "place.stream.livestream#viewerCount" 77 79 return json.Marshal(t.Livestream_ViewerCount) 80 + } 81 + if t.Livestream_TeleportArrival != nil { 82 + t.Livestream_TeleportArrival.LexiconTypeID = "place.stream.livestream#teleportArrival" 83 + return json.Marshal(t.Livestream_TeleportArrival) 84 + } 85 + if t.Livestream_TeleportCanceled != nil { 86 + t.Livestream_TeleportCanceled.LexiconTypeID = "place.stream.livestream#teleportCanceled" 87 + return json.Marshal(t.Livestream_TeleportCanceled) 78 88 } 79 89 if t.Defs_BlockView != nil { 80 90 t.Defs_BlockView.LexiconTypeID = "place.stream.defs#blockView" ··· 108 118 case "place.stream.livestream#viewerCount": 109 119 t.Livestream_ViewerCount = new(Livestream_ViewerCount) 110 120 return json.Unmarshal(b, t.Livestream_ViewerCount) 121 + case "place.stream.livestream#teleportArrival": 122 + t.Livestream_TeleportArrival = new(Livestream_TeleportArrival) 123 + return json.Unmarshal(b, t.Livestream_TeleportArrival) 124 + case "place.stream.livestream#teleportCanceled": 125 + t.Livestream_TeleportCanceled = new(Livestream_TeleportCanceled) 126 + return json.Unmarshal(b, t.Livestream_TeleportCanceled) 111 127 case "place.stream.defs#blockView": 112 128 t.Defs_BlockView = new(Defs_BlockView) 113 129 return json.Unmarshal(b, t.Defs_BlockView) ··· 123 139 default: 124 140 return nil 125 141 } 142 + } 143 + 144 + // Livestream_TeleportArrival is a "teleportArrival" in the place.stream.livestream schema. 145 + type Livestream_TeleportArrival struct { 146 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#teleportArrival"` 147 + // chatProfile: The chat profile of the source streamer 148 + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 149 + // source: The streamer who is teleporting their viewers here 150 + Source *appbsky.ActorDefs_ProfileViewBasic `json:"source" cborgen:"source"` 151 + // startsAt: When this teleport started 152 + StartsAt string `json:"startsAt" cborgen:"startsAt"` 153 + // teleportUri: The URI of the teleport record 154 + TeleportUri string `json:"teleportUri" cborgen:"teleportUri"` 155 + // viewerCount: How many viewers are arriving from this teleport 156 + ViewerCount int64 `json:"viewerCount" cborgen:"viewerCount"` 157 + } 158 + 159 + // Livestream_TeleportCanceled is a "teleportCanceled" in the place.stream.livestream schema. 160 + type Livestream_TeleportCanceled struct { 161 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#teleportCanceled"` 162 + // reason: Why this teleport was canceled 163 + Reason string `json:"reason" cborgen:"reason"` 164 + // teleportUri: The URI of the teleport record that was canceled 165 + TeleportUri string `json:"teleportUri" cborgen:"teleportUri"` 126 166 } 127 167 128 168 // Livestream_ViewerCount is a "viewerCount" in the place.stream.livestream schema.
+377 -254
pnpm-lock.yaml
··· 445 445 version: 0.15.2(@fluent/bundle@0.19.1)(react@19.0.0) 446 446 '@gorhom/bottom-sheet': 447 447 specifier: ^5.1.6 448 - version: 5.1.6(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 448 + version: 5.1.6(@types/react@18.3.12)(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 449 + '@radix-ui/react-dropdown-menu': 450 + specifier: ^2.1.16 451 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 449 452 '@rn-primitives/dropdown-menu': 450 453 specifier: ^1.2.0 451 - version: 1.2.0(@rn-primitives/portal@1.3.0(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)))(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 454 + version: 1.2.0(@rn-primitives/portal@1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)))(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 452 455 '@rn-primitives/portal': 453 456 specifier: ^1.3.0 454 - version: 1.3.0(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 457 + version: 1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 455 458 '@rn-primitives/slider': 456 459 specifier: ^1.2.0 457 - version: 1.2.0(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 460 + version: 1.2.0(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 458 461 class-variance-authority: 459 462 specifier: ^0.6.1 460 463 version: 0.6.1 461 464 expo-keep-awake: 462 465 specifier: ^14.0.0 463 - version: 14.1.4(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 466 + version: 14.1.4(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 464 467 expo-localization: 465 468 specifier: '*' 466 - version: 17.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 469 + version: 17.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 467 470 expo-screen-orientation: 468 471 specifier: ^9.0.7 469 - version: 9.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 472 + version: 9.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 470 473 expo-sensors: 471 474 specifier: ^15.0.7 472 - version: 15.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 475 + version: 15.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 473 476 expo-sqlite: 474 477 specifier: ~15.2.12 475 - version: 15.2.12(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 478 + version: 15.2.12(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 476 479 expo-video: 477 480 specifier: ^2.0.0 478 - version: 2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 481 + version: 2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 482 + graphemer: 483 + specifier: ^1.4.0 484 + version: 1.4.0 479 485 hls.js: 480 486 specifier: ^1.5.17 481 487 version: 1.5.17 ··· 499 505 version: 2.0.1 500 506 lucide-react-native: 501 507 specifier: ^0.514.0 502 - version: 0.514.0(react-native-svg@15.12.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 508 + version: 0.514.0(react-native-svg@15.12.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 503 509 react: 504 510 specifier: '*' 505 511 version: 19.0.0 506 512 react-i18next: 507 513 specifier: ^15.7.3 508 - version: 15.7.4(i18next@25.5.2(typescript@5.8.3))(react-dom@19.0.0(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(typescript@5.8.3) 514 + version: 15.7.4(i18next@25.5.2(typescript@5.8.3))(react-dom@19.0.0(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(typescript@5.8.3) 509 515 react-native: 510 516 specifier: ^0.79.0 511 - version: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 517 + version: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 512 518 react-native-edge-to-edge: 513 519 specifier: ^1.6.2 514 - version: 1.6.2(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 520 + version: 1.6.2(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 515 521 react-native-gesture-handler: 516 522 specifier: ^2.20.0 517 - version: 2.26.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 523 + version: 2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 518 524 react-native-reanimated: 519 525 specifier: ^3.0.0 520 - version: 3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 526 + version: 3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 521 527 react-native-safe-area-context: 522 528 specifier: ^5.0.0 523 - version: 5.4.1(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 529 + version: 5.4.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 524 530 react-native-svg: 525 531 specifier: ^15.0.0 526 - version: 15.12.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 532 + version: 15.12.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 527 533 react-native-webrtc: 528 534 specifier: git+https://github.com/streamplace/react-native-webrtc.git#6b8472a771ac47f89217d327058a8a4124a6ae56 529 - version: https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/6b8472a771ac47f89217d327058a8a4124a6ae56(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 535 + version: https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/6b8472a771ac47f89217d327058a8a4124a6ae56(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 530 536 react-use-websocket: 531 537 specifier: ^4.13.0 532 538 version: 4.13.0 ··· 543 549 '@fluent/syntax': 544 550 specifier: ^0.19.0 545 551 version: 0.19.0 552 + '@types/react-dom': 553 + specifier: ^19.2.3 554 + version: 19.2.3(@types/react@18.3.12) 546 555 '@types/sdp-transform': 547 556 specifier: ^2.15.0 548 557 version: 2.15.0 ··· 719 728 sharp: 720 729 specifier: ^0.32.5 721 730 version: 0.32.6 731 + starlight-links-validator: 732 + specifier: ^0.19.2 733 + version: 0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 722 734 starlight-openapi: 723 735 specifier: ^0.17.0 724 736 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 725 737 starlight-openapi-rapidoc: 726 738 specifier: ^0.8.1-beta 727 739 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 740 + starlight-sidebar-swipe: 741 + specifier: ^0.1.1 742 + version: 0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 728 743 streamplace: 729 744 specifier: workspace:* 730 745 version: link:../streamplace 746 + devDependencies: 747 + starlight-sidebar-topics: 748 + specifier: ^0.6.2 749 + version: 0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 731 750 732 751 js/streamplace: 733 752 dependencies: ··· 3398 3417 '@radix-ui/primitive@1.1.2': 3399 3418 resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} 3400 3419 3420 + '@radix-ui/primitive@1.1.3': 3421 + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} 3422 + 3401 3423 '@radix-ui/react-arrow@1.1.7': 3402 3424 resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} 3403 3425 peerDependencies: ··· 3451 3473 '@types/react': 3452 3474 optional: true 3453 3475 3454 - '@radix-ui/react-dismissable-layer@1.1.10': 3455 - resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} 3476 + '@radix-ui/react-dismissable-layer@1.1.11': 3477 + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} 3456 3478 peerDependencies: 3457 3479 '@types/react': '*' 3458 3480 '@types/react-dom': '*' ··· 3464 3486 '@types/react-dom': 3465 3487 optional: true 3466 3488 3467 - '@radix-ui/react-dropdown-menu@2.1.15': 3468 - resolution: {integrity: sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==} 3489 + '@radix-ui/react-dropdown-menu@2.1.16': 3490 + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} 3469 3491 peerDependencies: 3470 3492 '@types/react': '*' 3471 3493 '@types/react-dom': '*' ··· 3477 3499 '@types/react-dom': 3478 3500 optional: true 3479 3501 3480 - '@radix-ui/react-focus-guards@1.1.2': 3481 - resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} 3502 + '@radix-ui/react-focus-guards@1.1.3': 3503 + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} 3482 3504 peerDependencies: 3483 3505 '@types/react': '*' 3484 3506 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc ··· 3508 3530 '@types/react': 3509 3531 optional: true 3510 3532 3511 - '@radix-ui/react-menu@2.1.15': 3512 - resolution: {integrity: sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==} 3533 + '@radix-ui/react-menu@2.1.16': 3534 + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} 3513 3535 peerDependencies: 3514 3536 '@types/react': '*' 3515 3537 '@types/react-dom': '*' ··· 3521 3543 '@types/react-dom': 3522 3544 optional: true 3523 3545 3524 - '@radix-ui/react-popper@1.2.7': 3525 - resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==} 3546 + '@radix-ui/react-popper@1.2.8': 3547 + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} 3526 3548 peerDependencies: 3527 3549 '@types/react': '*' 3528 3550 '@types/react-dom': '*' ··· 3547 3569 '@types/react-dom': 3548 3570 optional: true 3549 3571 3550 - '@radix-ui/react-presence@1.1.4': 3551 - resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} 3572 + '@radix-ui/react-presence@1.1.5': 3573 + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} 3552 3574 peerDependencies: 3553 3575 '@types/react': '*' 3554 3576 '@types/react-dom': '*' ··· 3573 3595 '@types/react-dom': 3574 3596 optional: true 3575 3597 3576 - '@radix-ui/react-roving-focus@1.1.10': 3577 - resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} 3598 + '@radix-ui/react-roving-focus@1.1.11': 3599 + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} 3578 3600 peerDependencies: 3579 3601 '@types/react': '*' 3580 3602 '@types/react-dom': '*' ··· 4894 4916 '@types/normalize-package-data@2.4.4': 4895 4917 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4896 4918 4919 + '@types/picomatch@3.0.2': 4920 + resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} 4921 + 4897 4922 '@types/prop-types@15.7.12': 4898 4923 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} 4899 4924 ··· 4905 4930 4906 4931 '@types/range-parser@1.2.7': 4907 4932 resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} 4933 + 4934 + '@types/react-dom@19.2.3': 4935 + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} 4936 + peerDependencies: 4937 + '@types/react': ^19.2.0 4908 4938 4909 4939 '@types/react@18.3.12': 4910 4940 resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} ··· 7888 7918 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7889 7919 engines: {node: '>=8'} 7890 7920 7921 + has-flag@5.0.1: 7922 + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} 7923 + engines: {node: '>=12'} 7924 + 7891 7925 has-property-descriptors@1.0.2: 7892 7926 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7893 7927 ··· 8337 8371 8338 8372 iron-webcrypto@1.2.1: 8339 8373 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8374 + 8375 + is-absolute-url@4.0.1: 8376 + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} 8377 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 8340 8378 8341 8379 is-alphabetical@2.0.1: 8342 8380 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11740 11778 standard-as-callback@2.1.0: 11741 11779 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11742 11780 11781 + starlight-links-validator@0.19.2: 11782 + resolution: {integrity: sha512-IHeK3R78fsmv53VfRkGbXkwK1CQEUBHM9QPzBEyoAxjZ/ssi5gjV+F4oNNUppTR48iPp+lEY0MTAmvkX7yNnkw==} 11783 + engines: {node: '>=18.17.1'} 11784 + peerDependencies: 11785 + '@astrojs/starlight': '>=0.32.0' 11786 + astro: '>=5.1.5' 11787 + 11743 11788 starlight-openapi-rapidoc@0.8.1-beta: 11744 11789 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11745 11790 engines: {node: '>=18.14.1'} ··· 11755 11800 '@astrojs/starlight': '>=0.34.0' 11756 11801 astro: '>=5.5.0' 11757 11802 11803 + starlight-sidebar-swipe@0.1.1: 11804 + resolution: {integrity: sha512-Q+xv7LSpSLCG3yQaEmZX4Qpks9dcIEc+FBA0Ql+LbLMO9IMBXt8S2zK5wJDhjJn5lbI0i0ipyP375T1GrVS8ig==} 11805 + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} 11806 + peerDependencies: 11807 + '@astrojs/starlight': '>=0.30' 11808 + 11809 + starlight-sidebar-topics@0.6.2: 11810 + resolution: {integrity: sha512-SNCTUZS/hcVor0ZcaXbaSVU37+V+qtvzNirkvnOg3Mqu/awuGpthkH5+uKpiZqWxLffp6TrOlsv5E5QsxrndNg==} 11811 + engines: {node: '>=18'} 11812 + peerDependencies: 11813 + '@astrojs/starlight': '>=0.32.0' 11814 + 11758 11815 statuses@1.5.0: 11759 11816 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} 11760 11817 engines: {node: '>= 0.6'} ··· 11962 12019 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 11963 12020 engines: {node: '>= 8.0'} 11964 12021 12022 + supports-color@10.2.2: 12023 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} 12024 + engines: {node: '>=18'} 12025 + 11965 12026 supports-color@5.5.0: 11966 12027 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 11967 12028 engines: {node: '>=4'} ··· 11977 12038 supports-hyperlinks@2.3.0: 11978 12039 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 11979 12040 engines: {node: '>=8'} 12041 + 12042 + supports-hyperlinks@4.4.0: 12043 + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} 12044 + engines: {node: '>=20'} 11980 12045 11981 12046 supports-preserve-symlinks-flag@1.0.0: 11982 12047 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} ··· 12040 12105 terminal-link@2.1.1: 12041 12106 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12042 12107 engines: {node: '>=8'} 12108 + 12109 + terminal-link@5.0.0: 12110 + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} 12111 + engines: {node: '>=20'} 12043 12112 12044 12113 terser-webpack-plugin@5.3.10: 12045 12114 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} ··· 12891 12960 whatwg-encoding@3.1.1: 12892 12961 resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} 12893 12962 engines: {node: '>=18'} 12963 + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation 12894 12964 12895 12965 whatwg-fetch@3.6.20: 12896 12966 resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} ··· 16706 16776 - supports-color 16707 16777 optional: true 16708 16778 16709 - '@gorhom/bottom-sheet@5.1.6(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 16779 + '@gorhom/bottom-sheet@5.1.6(@types/react@18.3.12)(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 16710 16780 dependencies: 16711 - '@gorhom/portal': 1.0.14(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 16781 + '@gorhom/portal': 1.0.14(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 16712 16782 invariant: 2.2.4 16713 16783 react: 19.0.0 16714 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 16715 - react-native-gesture-handler: 2.26.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 16716 - react-native-reanimated: 3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 16784 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 16785 + react-native-gesture-handler: 2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 16786 + react-native-reanimated: 3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 16787 + optionalDependencies: 16788 + '@types/react': 18.3.12 16717 16789 16718 - '@gorhom/portal@1.0.14(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 16790 + '@gorhom/portal@1.0.14(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 16719 16791 dependencies: 16720 16792 nanoid: 3.3.11 16721 16793 react: 19.0.0 16722 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 16794 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 16723 16795 16724 16796 '@grpc/grpc-js@1.10.10': 16725 16797 dependencies: ··· 17615 17687 17616 17688 '@radix-ui/primitive@1.1.2': {} 17617 17689 17618 - '@radix-ui/react-arrow@1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17690 + '@radix-ui/primitive@1.1.3': {} 17691 + 17692 + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17619 17693 dependencies: 17620 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17694 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17621 17695 react: 19.0.0 17622 17696 react-dom: 19.0.0(react@19.0.0) 17697 + optionalDependencies: 17698 + '@types/react': 18.3.12 17699 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17623 17700 17624 - '@radix-ui/react-collection@1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17701 + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17625 17702 dependencies: 17626 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17627 - '@radix-ui/react-context': 1.1.2(react@19.0.0) 17628 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17629 - '@radix-ui/react-slot': 1.2.3(react@19.0.0) 17703 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17704 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17705 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17706 + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@19.0.0) 17630 17707 react: 19.0.0 17631 17708 react-dom: 19.0.0(react@19.0.0) 17709 + optionalDependencies: 17710 + '@types/react': 18.3.12 17711 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17632 17712 17633 - '@radix-ui/react-compose-refs@1.1.2(react@19.0.0)': 17713 + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.12)(react@19.0.0)': 17634 17714 dependencies: 17635 17715 react: 19.0.0 17716 + optionalDependencies: 17717 + '@types/react': 18.3.12 17636 17718 17637 - '@radix-ui/react-context@1.1.2(react@19.0.0)': 17719 + '@radix-ui/react-context@1.1.2(@types/react@18.3.12)(react@19.0.0)': 17638 17720 dependencies: 17639 17721 react: 19.0.0 17722 + optionalDependencies: 17723 + '@types/react': 18.3.12 17640 17724 17641 - '@radix-ui/react-direction@1.1.1(react@19.0.0)': 17725 + '@radix-ui/react-direction@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17642 17726 dependencies: 17643 17727 react: 19.0.0 17728 + optionalDependencies: 17729 + '@types/react': 18.3.12 17644 17730 17645 - '@radix-ui/react-dismissable-layer@1.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17731 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17646 17732 dependencies: 17647 - '@radix-ui/primitive': 1.1.2 17648 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17649 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17650 - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0) 17651 - '@radix-ui/react-use-escape-keydown': 1.1.1(react@19.0.0) 17733 + '@radix-ui/primitive': 1.1.3 17734 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17735 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17736 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17737 + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17652 17738 react: 19.0.0 17653 17739 react-dom: 19.0.0(react@19.0.0) 17740 + optionalDependencies: 17741 + '@types/react': 18.3.12 17742 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17654 17743 17655 - '@radix-ui/react-dropdown-menu@2.1.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17744 + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17656 17745 dependencies: 17657 - '@radix-ui/primitive': 1.1.2 17658 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17659 - '@radix-ui/react-context': 1.1.2(react@19.0.0) 17660 - '@radix-ui/react-id': 1.1.1(react@19.0.0) 17661 - '@radix-ui/react-menu': 2.1.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17662 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17663 - '@radix-ui/react-use-controllable-state': 1.2.2(react@19.0.0) 17746 + '@radix-ui/primitive': 1.1.3 17747 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17748 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17749 + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17750 + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17751 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17752 + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@19.0.0) 17664 17753 react: 19.0.0 17665 17754 react-dom: 19.0.0(react@19.0.0) 17755 + optionalDependencies: 17756 + '@types/react': 18.3.12 17757 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17666 17758 17667 - '@radix-ui/react-focus-guards@1.1.2(react@19.0.0)': 17759 + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.12)(react@19.0.0)': 17668 17760 dependencies: 17669 17761 react: 19.0.0 17762 + optionalDependencies: 17763 + '@types/react': 18.3.12 17670 17764 17671 - '@radix-ui/react-focus-scope@1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17765 + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17672 17766 dependencies: 17673 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17674 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17675 - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0) 17767 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17768 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17769 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17676 17770 react: 19.0.0 17677 17771 react-dom: 19.0.0(react@19.0.0) 17772 + optionalDependencies: 17773 + '@types/react': 18.3.12 17774 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17678 17775 17679 - '@radix-ui/react-id@1.1.1(react@19.0.0)': 17776 + '@radix-ui/react-id@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17680 17777 dependencies: 17681 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17778 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17682 17779 react: 19.0.0 17780 + optionalDependencies: 17781 + '@types/react': 18.3.12 17683 17782 17684 - '@radix-ui/react-menu@2.1.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17783 + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17685 17784 dependencies: 17686 - '@radix-ui/primitive': 1.1.2 17687 - '@radix-ui/react-collection': 1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17688 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17689 - '@radix-ui/react-context': 1.1.2(react@19.0.0) 17690 - '@radix-ui/react-direction': 1.1.1(react@19.0.0) 17691 - '@radix-ui/react-dismissable-layer': 1.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17692 - '@radix-ui/react-focus-guards': 1.1.2(react@19.0.0) 17693 - '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17694 - '@radix-ui/react-id': 1.1.1(react@19.0.0) 17695 - '@radix-ui/react-popper': 1.2.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17696 - '@radix-ui/react-portal': 1.1.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17697 - '@radix-ui/react-presence': 1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17698 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17699 - '@radix-ui/react-roving-focus': 1.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17700 - '@radix-ui/react-slot': 1.2.3(react@19.0.0) 17701 - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0) 17785 + '@radix-ui/primitive': 1.1.3 17786 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17787 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17788 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17789 + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17790 + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17791 + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.12)(react@19.0.0) 17792 + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17793 + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17794 + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17795 + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17796 + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17797 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17798 + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17799 + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@19.0.0) 17800 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17702 17801 aria-hidden: 1.2.4 17703 17802 react: 19.0.0 17704 17803 react-dom: 19.0.0(react@19.0.0) 17705 - react-remove-scroll: 2.7.1(react@19.0.0) 17804 + react-remove-scroll: 2.7.1(@types/react@18.3.12)(react@19.0.0) 17805 + optionalDependencies: 17806 + '@types/react': 18.3.12 17807 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17706 17808 17707 - '@radix-ui/react-popper@1.2.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17809 + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17708 17810 dependencies: 17709 17811 '@floating-ui/react-dom': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17710 - '@radix-ui/react-arrow': 1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17711 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17712 - '@radix-ui/react-context': 1.1.2(react@19.0.0) 17713 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17714 - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0) 17715 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17716 - '@radix-ui/react-use-rect': 1.1.1(react@19.0.0) 17717 - '@radix-ui/react-use-size': 1.1.1(react@19.0.0) 17812 + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17813 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17814 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17815 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17816 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17817 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17818 + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17819 + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17718 17820 '@radix-ui/rect': 1.1.1 17719 17821 react: 19.0.0 17720 17822 react-dom: 19.0.0(react@19.0.0) 17823 + optionalDependencies: 17824 + '@types/react': 18.3.12 17825 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17721 17826 17722 - '@radix-ui/react-portal@1.1.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17827 + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17723 17828 dependencies: 17724 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17725 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17829 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17830 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17726 17831 react: 19.0.0 17727 17832 react-dom: 19.0.0(react@19.0.0) 17833 + optionalDependencies: 17834 + '@types/react': 18.3.12 17835 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17728 17836 17729 - '@radix-ui/react-presence@1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17837 + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17730 17838 dependencies: 17731 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17732 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17839 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17840 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17733 17841 react: 19.0.0 17734 17842 react-dom: 19.0.0(react@19.0.0) 17843 + optionalDependencies: 17844 + '@types/react': 18.3.12 17845 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17735 17846 17736 - '@radix-ui/react-primitive@2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17847 + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17737 17848 dependencies: 17738 - '@radix-ui/react-slot': 1.2.3(react@19.0.0) 17849 + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@19.0.0) 17739 17850 react: 19.0.0 17740 17851 react-dom: 19.0.0(react@19.0.0) 17852 + optionalDependencies: 17853 + '@types/react': 18.3.12 17854 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17741 17855 17742 - '@radix-ui/react-roving-focus@1.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17856 + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17743 17857 dependencies: 17744 - '@radix-ui/primitive': 1.1.2 17745 - '@radix-ui/react-collection': 1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17746 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17747 - '@radix-ui/react-context': 1.1.2(react@19.0.0) 17748 - '@radix-ui/react-direction': 1.1.1(react@19.0.0) 17749 - '@radix-ui/react-id': 1.1.1(react@19.0.0) 17750 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17751 - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0) 17752 - '@radix-ui/react-use-controllable-state': 1.2.2(react@19.0.0) 17858 + '@radix-ui/primitive': 1.1.3 17859 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17860 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17861 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17862 + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17863 + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17864 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17865 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17866 + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@19.0.0) 17753 17867 react: 19.0.0 17754 17868 react-dom: 19.0.0(react@19.0.0) 17869 + optionalDependencies: 17870 + '@types/react': 18.3.12 17871 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17755 17872 17756 - '@radix-ui/react-slider@1.3.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17873 + '@radix-ui/react-slider@1.3.5(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 17757 17874 dependencies: 17758 17875 '@radix-ui/number': 1.1.1 17759 17876 '@radix-ui/primitive': 1.1.2 17760 - '@radix-ui/react-collection': 1.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17761 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17762 - '@radix-ui/react-context': 1.1.2(react@19.0.0) 17763 - '@radix-ui/react-direction': 1.1.1(react@19.0.0) 17764 - '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17765 - '@radix-ui/react-use-controllable-state': 1.2.2(react@19.0.0) 17766 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17767 - '@radix-ui/react-use-previous': 1.1.1(react@19.0.0) 17768 - '@radix-ui/react-use-size': 1.1.1(react@19.0.0) 17877 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17878 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17879 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17880 + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17881 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17882 + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@19.0.0) 17883 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17884 + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17885 + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17769 17886 react: 19.0.0 17770 17887 react-dom: 19.0.0(react@19.0.0) 17888 + optionalDependencies: 17889 + '@types/react': 18.3.12 17890 + '@types/react-dom': 19.2.3(@types/react@18.3.12) 17771 17891 17772 - '@radix-ui/react-slot@1.2.3(react@19.0.0)': 17892 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.12)(react@19.0.0)': 17773 17893 dependencies: 17774 - '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0) 17894 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 17775 17895 react: 19.0.0 17896 + optionalDependencies: 17897 + '@types/react': 18.3.12 17776 17898 17777 - '@radix-ui/react-use-callback-ref@1.1.1(react@19.0.0)': 17899 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17778 17900 dependencies: 17779 17901 react: 19.0.0 17902 + optionalDependencies: 17903 + '@types/react': 18.3.12 17780 17904 17781 - '@radix-ui/react-use-controllable-state@1.2.2(react@19.0.0)': 17905 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.12)(react@19.0.0)': 17782 17906 dependencies: 17783 - '@radix-ui/react-use-effect-event': 0.0.2(react@19.0.0) 17784 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17907 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.12)(react@19.0.0) 17908 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17785 17909 react: 19.0.0 17910 + optionalDependencies: 17911 + '@types/react': 18.3.12 17786 17912 17787 - '@radix-ui/react-use-effect-event@0.0.2(react@19.0.0)': 17913 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.12)(react@19.0.0)': 17788 17914 dependencies: 17789 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17915 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17790 17916 react: 19.0.0 17917 + optionalDependencies: 17918 + '@types/react': 18.3.12 17791 17919 17792 - '@radix-ui/react-use-escape-keydown@1.1.1(react@19.0.0)': 17920 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17793 17921 dependencies: 17794 - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0) 17922 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17795 17923 react: 19.0.0 17924 + optionalDependencies: 17925 + '@types/react': 18.3.12 17796 17926 17797 - '@radix-ui/react-use-layout-effect@1.1.1(react@19.0.0)': 17927 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17798 17928 dependencies: 17799 17929 react: 19.0.0 17930 + optionalDependencies: 17931 + '@types/react': 18.3.12 17800 17932 17801 - '@radix-ui/react-use-previous@1.1.1(react@19.0.0)': 17933 + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17802 17934 dependencies: 17803 17935 react: 19.0.0 17936 + optionalDependencies: 17937 + '@types/react': 18.3.12 17804 17938 17805 - '@radix-ui/react-use-rect@1.1.1(react@19.0.0)': 17939 + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17806 17940 dependencies: 17807 17941 '@radix-ui/rect': 1.1.1 17808 17942 react: 19.0.0 17943 + optionalDependencies: 17944 + '@types/react': 18.3.12 17809 17945 17810 - '@radix-ui/react-use-size@1.1.1(react@19.0.0)': 17946 + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.12)(react@19.0.0)': 17811 17947 dependencies: 17812 - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0) 17948 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 17813 17949 react: 19.0.0 17950 + optionalDependencies: 17951 + '@types/react': 18.3.12 17814 17952 17815 17953 '@radix-ui/rect@1.1.1': {} 17816 17954 ··· 18157 18295 18158 18296 '@reforged/maker-types@1.0.1': {} 18159 18297 18160 - '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)))(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18298 + '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)))(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18161 18299 dependencies: 18162 - '@radix-ui/react-dropdown-menu': 2.1.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18163 - '@rn-primitives/hooks': 1.3.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18164 - '@rn-primitives/portal': 1.3.0(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 18165 - '@rn-primitives/slot': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18166 - '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18167 - '@rn-primitives/utils': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18300 + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18301 + '@rn-primitives/hooks': 1.3.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18302 + '@rn-primitives/portal': 1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 18303 + '@rn-primitives/slot': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18304 + '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18305 + '@rn-primitives/utils': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18168 18306 react: 19.0.0 18169 18307 optionalDependencies: 18170 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18308 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18171 18309 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18172 18310 transitivePeerDependencies: 18173 18311 - '@types/react' 18174 18312 - '@types/react-dom' 18175 18313 - react-dom 18176 18314 18177 - '@rn-primitives/hooks@1.3.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18315 + '@rn-primitives/hooks@1.3.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18178 18316 dependencies: 18179 - '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18317 + '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18180 18318 react: 19.0.0 18181 18319 optionalDependencies: 18182 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18320 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18183 18321 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18184 18322 18185 - '@rn-primitives/portal@1.3.0(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0))': 18323 + '@rn-primitives/portal@1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0))': 18186 18324 dependencies: 18187 18325 react: 19.0.0 18188 18326 zustand: 5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 18189 18327 optionalDependencies: 18190 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18328 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18191 18329 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18192 18330 transitivePeerDependencies: 18193 18331 - '@types/react' 18194 18332 - immer 18195 18333 - use-sync-external-store 18196 18334 18197 - '@rn-primitives/slider@1.2.0(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18335 + '@rn-primitives/slider@1.2.0(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18198 18336 dependencies: 18199 - '@radix-ui/react-slider': 1.3.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18200 - '@rn-primitives/slot': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18201 - '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18337 + '@radix-ui/react-slider': 1.3.5(@types/react-dom@19.2.3(@types/react@18.3.12))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18338 + '@rn-primitives/slot': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18339 + '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 18202 18340 react: 19.0.0 18203 18341 optionalDependencies: 18204 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18342 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18205 18343 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18206 18344 transitivePeerDependencies: 18207 18345 - '@types/react' 18208 18346 - '@types/react-dom' 18209 18347 - react-dom 18210 18348 18211 - '@rn-primitives/slot@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18349 + '@rn-primitives/slot@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18212 18350 dependencies: 18213 18351 react: 19.0.0 18214 18352 optionalDependencies: 18215 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18353 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18216 18354 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18217 18355 18218 - '@rn-primitives/types@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18356 + '@rn-primitives/types@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18219 18357 dependencies: 18220 18358 react: 19.0.0 18221 18359 optionalDependencies: 18222 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18360 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18223 18361 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18224 18362 18225 - '@rn-primitives/utils@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18363 + '@rn-primitives/utils@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 18226 18364 dependencies: 18227 18365 react: 19.0.0 18228 18366 optionalDependencies: 18229 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18367 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 18230 18368 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18231 18369 18232 18370 '@rollup/pluginutils@5.1.4(rollup@4.40.1)': ··· 19384 19522 19385 19523 '@types/normalize-package-data@2.4.4': {} 19386 19524 19525 + '@types/picomatch@3.0.2': {} 19526 + 19387 19527 '@types/prop-types@15.7.12': {} 19388 19528 19389 19529 '@types/qrcode@1.5.5': ··· 19393 19533 '@types/qs@6.9.15': {} 19394 19534 19395 19535 '@types/range-parser@1.2.7': {} 19536 + 19537 + '@types/react-dom@19.2.3(@types/react@18.3.12)': 19538 + dependencies: 19539 + '@types/react': 18.3.12 19396 19540 19397 19541 '@types/react@18.3.12': 19398 19542 dependencies: ··· 22167 22311 react: 19.0.0 22168 22312 rtl-detect: 1.1.2 22169 22313 22170 - expo-localization@17.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 22171 - dependencies: 22172 - expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) 22173 - react: 19.0.0 22174 - rtl-detect: 1.1.2 22175 - 22176 22314 expo-manifests@0.16.5(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10)): 22177 22315 dependencies: 22178 22316 '@expo/config': 11.0.10 ··· 22215 22353 expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) 22216 22354 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 22217 22355 22218 - expo-screen-orientation@9.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 22356 + expo-sensors@15.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 22219 22357 dependencies: 22220 - expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) 22221 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 22222 - 22223 - expo-sensors@15.0.7(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 22224 - dependencies: 22225 - expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) 22358 + expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) 22226 22359 invariant: 2.2.4 22227 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 22360 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 22228 22361 22229 22362 expo-splash-screen@0.30.9(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10)): 22230 22363 dependencies: ··· 22290 22423 react: 19.0.0 22291 22424 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 22292 22425 22293 - expo-video@2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 22294 - dependencies: 22295 - expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) 22296 - react: 19.0.0 22297 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 22298 - 22299 22426 expo-web-browser@14.1.6(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 22300 22427 dependencies: 22301 22428 expo: 53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10) ··· 23247 23374 has-flag@3.0.0: {} 23248 23375 23249 23376 has-flag@4.0.0: {} 23377 + 23378 + has-flag@5.0.1: {} 23250 23379 23251 23380 has-property-descriptors@1.0.2: 23252 23381 dependencies: ··· 23922 24051 23923 24052 iron-webcrypto@1.2.1: {} 23924 24053 24054 + is-absolute-url@4.0.1: {} 24055 + 23925 24056 is-alphabetical@2.0.1: {} 23926 24057 23927 24058 is-alphanumerical@2.0.1: ··· 24849 24980 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 24850 24981 react-native-svg: 15.12.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 24851 24982 24852 - lucide-react-native@0.514.0(react-native-svg@15.12.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 24853 - dependencies: 24854 - react: 19.0.0 24855 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 24856 - react-native-svg: 15.12.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 24857 - 24858 24983 macos-alias@0.2.11: 24859 24984 dependencies: 24860 24985 nan: 2.20.0 ··· 27087 27212 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27088 27213 typescript: 5.3.3 27089 27214 27090 - react-i18next@15.7.4(i18next@25.5.2(typescript@5.8.3))(react-dom@19.0.0(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(typescript@5.8.3): 27215 + react-i18next@15.7.4(i18next@25.5.2(typescript@5.8.3))(react-dom@19.0.0(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(typescript@5.8.3): 27091 27216 dependencies: 27092 27217 '@babel/runtime': 7.28.4 27093 27218 html-parse-stringify: 3.0.1 ··· 27095 27220 react: 19.0.0 27096 27221 optionalDependencies: 27097 27222 react-dom: 19.0.0(react@19.0.0) 27098 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27223 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27099 27224 typescript: 5.8.3 27100 27225 27101 27226 react-is@16.13.1: {} ··· 27127 27252 react: 19.0.0 27128 27253 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27129 27254 27130 - react-native-edge-to-edge@1.6.2(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27131 - dependencies: 27132 - react: 19.0.0 27133 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27134 - 27135 27255 react-native-fit-image@1.5.5: 27136 27256 dependencies: 27137 27257 prop-types: 15.8.1 ··· 27144 27264 react: 19.0.0 27145 27265 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27146 27266 27147 - react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27148 - dependencies: 27149 - '@egjs/hammerjs': 2.0.17 27150 - hoist-non-react-statics: 3.3.2 27151 - invariant: 2.2.4 27152 - react: 19.0.0 27153 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27154 - 27155 27267 react-native-haptic-feedback@2.3.3(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 27156 27268 dependencies: 27157 27269 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) ··· 27161 27273 dependencies: 27162 27274 react: 19.0.0 27163 27275 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27164 - 27165 - react-native-is-edge-to-edge@1.1.7(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27166 - dependencies: 27167 - react: 19.0.0 27168 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27169 27276 27170 27277 react-native-localize@3.5.2(@expo/config-plugins@10.0.2)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27171 27278 dependencies: ··· 27237 27344 transitivePeerDependencies: 27238 27345 - supports-color 27239 27346 27240 - react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27241 - dependencies: 27242 - '@babel/core': 7.26.0 27243 - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.26.0) 27244 - '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.26.0) 27245 - '@babel/plugin-transform-classes': 7.27.1(@babel/core@7.26.0) 27246 - '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.26.0) 27247 - '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.26.0) 27248 - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.26.0) 27249 - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.26.0) 27250 - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.26.0) 27251 - '@babel/preset-typescript': 7.24.7(@babel/core@7.26.0) 27252 - convert-source-map: 2.0.0 27253 - invariant: 2.2.4 27254 - react: 19.0.0 27255 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27256 - react-native-is-edge-to-edge: 1.1.7(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 27257 - transitivePeerDependencies: 27258 - - supports-color 27259 - 27260 27347 react-native-safe-area-context@5.4.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27261 27348 dependencies: 27262 27349 react: 19.0.0 27263 27350 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27264 27351 27265 - react-native-safe-area-context@5.4.1(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27266 - dependencies: 27267 - react: 19.0.0 27268 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27269 - 27270 27352 react-native-screens@4.11.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27271 27353 dependencies: 27272 27354 react: 19.0.0 ··· 27290 27372 css-tree: 1.1.3 27291 27373 react: 19.0.0 27292 27374 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27293 - warn-once: 0.1.1 27294 - 27295 - react-native-svg@15.12.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27296 - dependencies: 27297 - css-select: 5.1.0 27298 - css-tree: 1.1.3 27299 - react: 19.0.0 27300 - react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27301 27375 warn-once: 0.1.1 27302 27376 27303 27377 react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): ··· 27444 27518 27445 27519 react-refresh@0.14.2: {} 27446 27520 27447 - react-remove-scroll-bar@2.3.8(react@19.0.0): 27521 + react-remove-scroll-bar@2.3.8(@types/react@18.3.12)(react@19.0.0): 27448 27522 dependencies: 27449 27523 react: 19.0.0 27450 - react-style-singleton: 2.2.3(react@19.0.0) 27524 + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@19.0.0) 27451 27525 tslib: 2.8.1 27526 + optionalDependencies: 27527 + '@types/react': 18.3.12 27452 27528 27453 - react-remove-scroll@2.7.1(react@19.0.0): 27529 + react-remove-scroll@2.7.1(@types/react@18.3.12)(react@19.0.0): 27454 27530 dependencies: 27455 27531 react: 19.0.0 27456 - react-remove-scroll-bar: 2.3.8(react@19.0.0) 27457 - react-style-singleton: 2.2.3(react@19.0.0) 27532 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.12)(react@19.0.0) 27533 + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@19.0.0) 27458 27534 tslib: 2.8.1 27459 - use-callback-ref: 1.3.3(react@19.0.0) 27460 - use-sidecar: 1.1.3(react@19.0.0) 27535 + use-callback-ref: 1.3.3(@types/react@18.3.12)(react@19.0.0) 27536 + use-sidecar: 1.1.3(@types/react@18.3.12)(react@19.0.0) 27537 + optionalDependencies: 27538 + '@types/react': 18.3.12 27461 27539 27462 - react-style-singleton@2.2.3(react@19.0.0): 27540 + react-style-singleton@2.2.3(@types/react@18.3.12)(react@19.0.0): 27463 27541 dependencies: 27464 27542 get-nonce: 1.0.1 27465 27543 react: 19.0.0 27466 27544 tslib: 2.8.1 27545 + optionalDependencies: 27546 + '@types/react': 18.3.12 27467 27547 27468 27548 react-use-websocket@4.13.0: {} 27469 27549 ··· 28405 28485 28406 28486 standard-as-callback@2.1.0: {} 28407 28487 28488 + starlight-links-validator@0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)): 28489 + dependencies: 28490 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28491 + '@types/picomatch': 3.0.2 28492 + astro: 5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2) 28493 + github-slugger: 2.0.0 28494 + hast-util-from-html: 2.0.3 28495 + hast-util-has-property: 3.0.0 28496 + is-absolute-url: 4.0.1 28497 + kleur: 4.1.5 28498 + mdast-util-mdx-jsx: 3.2.0 28499 + mdast-util-to-string: 4.0.0 28500 + picomatch: 4.0.2 28501 + terminal-link: 5.0.0 28502 + unist-util-visit: 5.0.0 28503 + transitivePeerDependencies: 28504 + - supports-color 28505 + 28408 28506 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28409 28507 dependencies: 28410 28508 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28425 28523 url-template: 3.1.1 28426 28524 transitivePeerDependencies: 28427 28525 - openapi-types 28526 + 28527 + starlight-sidebar-swipe@0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28528 + dependencies: 28529 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28530 + 28531 + starlight-sidebar-topics@0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28532 + dependencies: 28533 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28534 + picomatch: 4.0.2 28428 28535 28429 28536 statuses@1.5.0: {} 28430 28537 ··· 28639 28746 transitivePeerDependencies: 28640 28747 - supports-color 28641 28748 28749 + supports-color@10.2.2: {} 28750 + 28642 28751 supports-color@5.5.0: 28643 28752 dependencies: 28644 28753 has-flag: 3.0.0 ··· 28655 28764 dependencies: 28656 28765 has-flag: 4.0.0 28657 28766 supports-color: 7.2.0 28767 + 28768 + supports-hyperlinks@4.4.0: 28769 + dependencies: 28770 + has-flag: 5.0.1 28771 + supports-color: 10.2.2 28658 28772 28659 28773 supports-preserve-symlinks-flag@1.0.0: {} 28660 28774 ··· 28750 28864 dependencies: 28751 28865 ansi-escapes: 4.3.2 28752 28866 supports-hyperlinks: 2.3.0 28867 + 28868 + terminal-link@5.0.0: 28869 + dependencies: 28870 + ansi-escapes: 7.0.0 28871 + supports-hyperlinks: 4.4.0 28753 28872 28754 28873 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28755 28874 dependencies: ··· 29288 29407 29289 29408 url-template@3.1.1: {} 29290 29409 29291 - use-callback-ref@1.3.3(react@19.0.0): 29410 + use-callback-ref@1.3.3(@types/react@18.3.12)(react@19.0.0): 29292 29411 dependencies: 29293 29412 react: 19.0.0 29294 29413 tslib: 2.8.1 29414 + optionalDependencies: 29415 + '@types/react': 18.3.12 29295 29416 29296 29417 use-latest-callback@0.2.1(react@19.0.0): 29297 29418 dependencies: 29298 29419 react: 19.0.0 29299 29420 29300 - use-sidecar@1.1.3(react@19.0.0): 29421 + use-sidecar@1.1.3(@types/react@18.3.12)(react@19.0.0): 29301 29422 dependencies: 29302 29423 detect-node-es: 1.1.0 29303 29424 react: 19.0.0 29304 29425 tslib: 2.8.1 29426 + optionalDependencies: 29427 + '@types/react': 18.3.12 29305 29428 29306 29429 use-sync-external-store@1.2.2(react@19.0.0): 29307 29430 dependencies:
+9
wrangler.toml
··· 1 + name = "streamplace-docs" 2 + compatibility_date = "2025-04-01" 3 + 4 + [assets] 5 + directory = "./js/docs/dist/" 6 + not_found_handling = "404-page" 7 + 8 + [build] 9 + command = "pnpm install --filter=streamplace-docs... && cd js/docs && astro build --outDir ./dist/docs && cp ./_redirects ./dist/_redirects && cp ./src/assets/cube.png ./dist/favicon.ico"