Live video on the AT Protocol

Merge pull request #189 from streamplace/natb/update-title

golive: update title option

authored by

natalie and committed by
GitHub
e265e900 44a50b87

+341 -23
+146
js/app/components/edit-livestream.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { Button, H3, Label, Paragraph, Text, TextArea, View } from "tamagui"; 3 + import { useToastController } from "@tamagui/toast"; 4 + import { 5 + selectNewLivestream, 6 + selectUserProfile, 7 + updateLivestreamRecord, 8 + } from "features/bluesky/blueskySlice"; 9 + import { useAppDispatch, useAppSelector } from "store/hooks"; 10 + import { useLiveUser } from "hooks/useLiveUser"; 11 + import { ScrollView } from "react-native"; 12 + 13 + export default function UpdateLivestream({ 14 + playerId, 15 + }: { 16 + playerId: string | null; 17 + }) { 18 + const dispatch = useAppDispatch(); 19 + const toast = useToastController(); 20 + const userIsLive = useLiveUser(); 21 + const [title, setTitle] = useState(""); 22 + const [loading, setLoading] = useState(false); 23 + const profile = useAppSelector(selectUserProfile); 24 + const newLivestream = useAppSelector(selectNewLivestream); 25 + 26 + useEffect(() => { 27 + if (newLivestream?.record) { 28 + toast.show("Livestream title updated", { 29 + message: newLivestream.record.title, 30 + }); 31 + setTitle(""); 32 + } 33 + }, [newLivestream?.record]); 34 + useEffect(() => { 35 + if (newLivestream?.error) { 36 + toast.show("Error updating livestream", { 37 + message: newLivestream.error, 38 + }); 39 + } 40 + }, [newLivestream?.error]); 41 + const disabled = !userIsLive || loading || title === ""; 42 + 43 + if (!playerId) { 44 + return ( 45 + <View justifyContent="center" alignContent="center"> 46 + <Text> 47 + Couldn't get the player ID. You may not have created an initial 48 + livestream record. 49 + </Text> 50 + </View> 51 + ); 52 + } 53 + 54 + const handleSubmit = async () => { 55 + setLoading(true); 56 + try { 57 + await dispatch( 58 + updateLivestreamRecord({ 59 + title, 60 + playerId, 61 + }), 62 + ); 63 + } catch (error) { 64 + console.error("Error updating livestream:", error); 65 + toast.show("Error updating livestream", { 66 + message: String(error), 67 + }); 68 + } finally { 69 + setLoading(false); 70 + } 71 + }; 72 + 73 + const buttonText = loading 74 + ? "Loading..." 75 + : !userIsLive 76 + ? "Waiting for stream to start..." 77 + : "Update Livestream!"; 78 + 79 + return ( 80 + <ScrollView 81 + style={{ width: "60%" }} 82 + contentContainerStyle={{ 83 + flexGrow: 1, 84 + justifyContent: "flex-start", 85 + paddingVertical: 40, 86 + }} 87 + showsVerticalScrollIndicator={false} 88 + > 89 + <H3 pl="$4">Change your Current Livestream Title</H3> 90 + <View w="100%" alignSelf="center" p="$4" justifyContent="center"> 91 + <View f={2} minWidth={0} gap="$3"> 92 + <Label asChild={true} display="flex"> 93 + <View flexDirection="row" alignItems="center" w="100%"> 94 + <Paragraph pb="$2" minWidth={100} textAlign="left"> 95 + Streamer 96 + </Paragraph> 97 + <Paragraph pb="$2" fontWeight="bold"> 98 + @{profile?.handle} 99 + </Paragraph> 100 + </View> 101 + </Label> 102 + <Label asChild={true}> 103 + <View flexDirection="row" alignItems="center" w="100%"> 104 + <Paragraph pb="$2" minWidth={100} textAlign="left"> 105 + Title 106 + </Paragraph> 107 + <View flex={1}> 108 + <TextArea 109 + id="livestream-title" 110 + value={title} 111 + onChangeText={setTitle} 112 + size="$4" 113 + minHeight={100} 114 + maxLength={140} 115 + w="100%" 116 + /> 117 + </View> 118 + </View> 119 + </Label> 120 + <Label asChild={true} mt="$-4"> 121 + <View flexDirection="row" alignItems="center" w="100%"> 122 + <Paragraph minWidth={100} textAlign="left"></Paragraph> 123 + <View flex={1}> 124 + <Text fontSize="$1" color="$gray11"> 125 + Updating will not send out notifications to viewers or create 126 + a new social media post. 127 + </Text> 128 + </View> 129 + </View> 130 + </Label> 131 + <View w="100%" alignItems="center" mt="$-4"> 132 + <Button 133 + disabled={disabled} 134 + opacity={disabled ? 0.5 : 1} 135 + size="$4" 136 + w="100%" 137 + onPress={handleSubmit} 138 + > 139 + {buttonText} 140 + </Button> 141 + </View> 142 + </View> 143 + </View> 144 + </ScrollView> 145 + ); 146 + }
+2
js/app/components/player/props.tsx
··· 45 45 | React.MutableRefObject<HTMLVideoElement | null> 46 46 | ((instance: HTMLVideoElement | null) => void) 47 47 | undefined; 48 + 49 + setPlayerId?: (playerId: string) => void; 48 50 }; 49 51 50 52 export type PlayerEvent = {
+2
js/app/components/player/provider.tsx
··· 42 42 } 43 43 dispatch(newPlayerAction); 44 44 setPlayerId(newPlayerAction.payload.playerId); 45 + // if needed, prop back up 46 + props.setPlayerId?.(newPlayerAction.payload.playerId); 45 47 }, []); 46 48 if (!playerId) { 47 49 return <></>;
+56
js/app/components/ui/button-selector.tsx
··· 1 + import { YStack, XStack, Text, Button, View, YStackProps } from "tamagui"; 2 + 3 + export default function ButtonSelector({ 4 + text, 5 + values, 6 + selectedValue, 7 + setSelectedValue, 8 + disabledValues, 9 + ...props 10 + }: { 11 + text?: string; 12 + values: { label: string; value: string }[]; 13 + selectedValue: string; 14 + setSelectedValue: (value: any) => void; 15 + disabledValues?: string[]; 16 + } & YStackProps) { 17 + return ( 18 + <YStack ai="flex-start" gap="$2" pt="$2" {...props}> 19 + {text && ( 20 + <Text fontSize="$base" fontWeight="semibold"> 21 + {text} 22 + </Text> 23 + )} 24 + <XStack 25 + ai="center" 26 + jc="space-around" 27 + gap="$1" 28 + w="100%" 29 + bg="$background" 30 + borderRadius="$xl" 31 + > 32 + {values.map(({ label, value }) => ( 33 + <Button 34 + key={value} 35 + onPress={() => setSelectedValue(value)} 36 + f={1} 37 + height="$2" 38 + disabled={disabledValues?.includes(value)} 39 + opacity={disabledValues?.includes(value) ? 0.5 : 1} 40 + variant={selectedValue === value ? "outlined" : undefined} 41 + > 42 + <Text 43 + color={ 44 + selectedValue === value 45 + ? "$color.foreground" 46 + : "$color.mutedForeground" 47 + } 48 + > 49 + {label} 50 + </Text> 51 + </Button> 52 + ))} 53 + </XStack> 54 + </YStack> 55 + ); 56 + }
+90
js/app/features/bluesky/blueskySlice.tsx
··· 13 13 import { 14 14 LivestreamViewHydrated, 15 15 MessageViewHydrated, 16 + PlayersState, 16 17 } from "features/player/playerSlice"; 17 18 import { StreamplaceState } from "features/streamplace/streamplaceSlice"; 18 19 import { ··· 915 916 }, 916 917 ), 917 918 919 + updateLivestreamRecord: create.asyncThunk( 920 + async ( 921 + { title, playerId }: { title: string; playerId: string }, 922 + thunkAPI, 923 + ) => { 924 + const now = new Date(); 925 + const { bluesky, streamplace, player } = thunkAPI.getState() as { 926 + bluesky: BlueskyState; 927 + streamplace: StreamplaceState; 928 + player: PlayersState; 929 + }; 930 + 931 + if (!bluesky.pdsAgent) { 932 + throw new Error("No agent"); 933 + } 934 + const did = bluesky.oauthSession?.did; 935 + if (!did) { 936 + throw new Error("No DID"); 937 + } 938 + const profile = bluesky.profiles[did]; 939 + if (!profile) { 940 + throw new Error("No profile"); 941 + } 942 + 943 + let oldRecord = player[playerId].livestream; 944 + if (!oldRecord) { 945 + throw new Error("No latest record"); 946 + } 947 + 948 + let rkey = oldRecord.uri.split("/").pop(); 949 + let oldRecordValue: PlaceStreamLivestream.Record = oldRecord.record; 950 + 951 + if (!rkey) { 952 + throw new Error("No rkey?"); 953 + } 954 + 955 + console.log("Updating rkey", rkey); 956 + 957 + const record: PlaceStreamLivestream.Record = { 958 + title: title, 959 + url: streamplace.url, 960 + createdAt: new Date().toISOString(), 961 + post: oldRecordValue.post, 962 + }; 963 + 964 + await bluesky.pdsAgent.com.atproto.repo.putRecord({ 965 + repo: did, 966 + collection: "place.stream.livestream", 967 + rkey, 968 + record, 969 + }); 970 + return record; 971 + }, 972 + { 973 + pending: (state) => { 974 + return { 975 + ...state, 976 + newLivestream: { 977 + loading: true, 978 + error: null, 979 + record: null, 980 + }, 981 + }; 982 + }, 983 + fulfilled: (state, action) => { 984 + return { 985 + ...state, 986 + newLivestream: { 987 + loading: false, 988 + error: null, 989 + record: action.payload, 990 + }, 991 + }; 992 + }, 993 + rejected: (state, action) => { 994 + console.error("createLivestreamRecord rejected", action.error); 995 + return { 996 + ...state, 997 + newLivestream: { 998 + loading: false, 999 + error: action.error?.message ?? null, 1000 + record: null, 1001 + }, 1002 + }; 1003 + }, 1004 + }, 1005 + ), 1006 + 918 1007 getChatProfileRecordFromPDS: create.asyncThunk( 919 1008 async (_, thunkAPI) => { 920 1009 const { bluesky } = thunkAPI.getState() as { bluesky: BlueskyState }; ··· 1179 1268 createStreamKeyRecord, 1180 1269 clearStreamKeyRecord, 1181 1270 createLivestreamRecord, 1271 + updateLivestreamRecord, 1182 1272 createChatProfileRecord, 1183 1273 getChatProfileRecordFromPDS, 1184 1274 chatPost,
+20 -1
js/app/src/screens/live-dashboard.tsx
··· 16 16 import { H6, Text } from "tamagui"; 17 17 import Waiting from "components/live-dashboard/waiting"; 18 18 import { selectTelemetry } from "features/streamplace/streamplaceSlice"; 19 + import UpdateLivestream from "components/edit-livestream"; 20 + import ButtonSelector from "components/ui/button-selector"; 19 21 20 22 enum StreamSource { 21 23 Start, ··· 32 34 const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( 33 35 null, 34 36 ); 37 + 38 + const [playerId, setPlayerId] = useState<string | null>(null); 39 + 40 + const [page, setPage] = useState<"update" | "create">("create"); 35 41 36 42 const videoRef = useCallback((node: HTMLVideoElement | null) => { 37 43 if (node !== null) { ··· 57 63 src={userProfile.did} 58 64 name={userProfile.handle} 59 65 videoRef={videoRef} 66 + setPlayerId={setPlayerId} 60 67 /> 61 68 ); 62 69 } else if (streamSource === StreamSource.Start) { ··· 97 104 {closeButton} 98 105 </View> 99 106 <View f={1} ai="center" jc="center" fb={0}> 100 - <CreateLivestream /> 107 + <ButtonSelector 108 + values={[ 109 + { label: "Create", value: "create" }, 110 + { label: "Update", value: "update" }, 111 + ]} 112 + disabledValues={playerId ? [] : ["update"]} 113 + selectedValue={page} 114 + setSelectedValue={setPage} 115 + maxWidth={250} 116 + width="100%" 117 + /> 118 + {page === "update" ? <UpdateLivestream playerId={playerId} /> : null} 119 + {page === "create" ? <CreateLivestream /> : null} 101 120 </View> 102 121 </View> 103 122 </VideoElementProvider>
+2 -1
pkg/atproto/atproto.go
··· 152 152 return fmt.Errorf("could not retrieve record bytes for %s (rkey: %s): %w", k, rkey, err) 153 153 } 154 154 log.Debug(ctx, "record type", "key", k, "type", nsid.String()) 155 - err = atsync.handleCreateUpdate(ctx, signerDID.String(), rkey, bs, v.String(), nsid) 155 + 156 + err = atsync.handleCreateUpdate(ctx, signerDID.String(), rkey, bs, v.String(), nsid, false) 156 157 if err != nil { 157 158 log.Warn(ctx, "failed to handle create update", "err", err) 158 159 // invalid CBOR and stuff should get ignored, so
+1 -1
pkg/atproto/firehose.go
··· 219 219 break 220 220 } 221 221 222 - err = atsync.handleCreateUpdate(ctx, evt.Repo, rkey, recCBOR, op.Cid.String(), collection) 222 + err = atsync.handleCreateUpdate(ctx, evt.Repo, rkey, recCBOR, op.Cid.String(), collection, ek == repomgr.EvtKindUpdateRecord) 223 223 if err != nil { 224 224 log.Error(ctx, "failed to handle create update", "err", err) 225 225 continue
+22 -20
pkg/atproto/sync.go
··· 19 19 lexutil "github.com/bluesky-social/indigo/lex/util" 20 20 ) 21 21 22 - func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userDID string, rkey syntax.RecordKey, recCBOR *[]byte, cid string, collection syntax.NSID) error { 22 + func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userDID string, rkey syntax.RecordKey, recCBOR *[]byte, cid string, collection syntax.NSID, isUpdate bool) error { 23 23 ctx = log.WithLogValues(ctx, "func", "handleCreateUpdate", "userDID", userDID, "rkey", rkey.String(), "cid", cid, "collection", collection.String()) 24 24 now := time.Now() 25 25 r, err := atsync.Model.GetRepo(userDID) ··· 275 275 } 276 276 go atsync.Bus.Publish(userDID, lsv) 277 277 278 - log.Warn(ctx, "Livestream detected! Blasting followers!", "title", rec.Title, "url", u, "createdAt", rec.CreatedAt, "repo", userDID) 279 - notifications, err := atsync.Model.GetFollowersNotificationTokens(userDID) 280 - if err != nil { 281 - return err 282 - } 283 - 284 - nb := &notificationpkg.NotificationBlast{ 285 - Title: fmt.Sprintf("🔴 @%s is LIVE!", r.Handle), 286 - Body: rec.Title, 287 - Data: map[string]string{ 288 - "path": fmt.Sprintf("/%s", r.Handle), 289 - }, 290 - } 291 - if atsync.Noter != nil { 292 - err := atsync.Noter.Blast(ctx, notifications, nb) 278 + if !isUpdate { 279 + log.Warn(ctx, "Livestream detected! Blasting followers!", "title", rec.Title, "url", u, "createdAt", rec.CreatedAt, "repo", userDID) 280 + notifications, err := atsync.Model.GetFollowersNotificationTokens(userDID) 293 281 if err != nil { 294 - log.Error(ctx, "failed to blast notifications", "err", err) 282 + return err 283 + } 284 + 285 + nb := &notificationpkg.NotificationBlast{ 286 + Title: fmt.Sprintf("🔴 @%s is LIVE!", r.Handle), 287 + Body: rec.Title, 288 + Data: map[string]string{ 289 + "path": fmt.Sprintf("/%s", r.Handle), 290 + }, 291 + } 292 + if atsync.Noter != nil { 293 + err := atsync.Noter.Blast(ctx, notifications, nb) 294 + if err != nil { 295 + log.Error(ctx, "failed to blast notifications", "err", err) 296 + } else { 297 + log.Log(ctx, "sent notifications", "user", userDID, "count", len(notifications), "content", nb) 298 + } 295 299 } else { 296 - log.Log(ctx, "sent notifications", "user", userDID, "count", len(notifications), "content", nb) 300 + log.Log(ctx, "no notifier configured, skipping notifications", "user", userDID, "count", len(notifications), "content", nb) 297 301 } 298 - } else { 299 - log.Log(ctx, "no notifier configured, skipping notifications", "user", userDID, "count", len(notifications), "content", nb) 300 302 } 301 303 302 304 case *streamplace.Key: