Live video on the AT Protocol
1import { useToastController } from "@tamagui/toast";
2import ThumbnailSelector from "components/thumbnail-selector";
3import {
4 createLivestreamRecord,
5 selectNewLivestream,
6 selectUserProfile,
7} from "features/bluesky/blueskySlice";
8import { useCaptureVideoFrame } from "hooks/useCaptureVideoFrame";
9import { useLiveUser } from "hooks/useLiveUser";
10import { useEffect, useState } from "react";
11import { ScrollView, useWindowDimensions } from "react-native";
12import { useAppDispatch, useAppSelector } from "store/hooks";
13import { Button, Label, Paragraph, TextArea, View, isWeb } from "tamagui";
14
15export default function CreateLivestream() {
16 const dispatch = useAppDispatch();
17 const toast = useToastController();
18 const userIsLive = useLiveUser();
19 const [title, setTitle] = useState("");
20 const [loading, setLoading] = useState(false);
21 const [customThumbnail, setCustomThumbnail] = useState<Blob | undefined>(
22 undefined,
23 );
24 const profile = useAppSelector(selectUserProfile);
25 const newLivestream = useAppSelector(selectNewLivestream);
26 const captureFrame = useCaptureVideoFrame();
27 const { width } = useWindowDimensions();
28
29 // Responsive layout logic
30 const isWide = width > 1020;
31 const useTwoColumns = isWide;
32
33 useEffect(() => {
34 if (newLivestream?.record) {
35 toast.show("Livestream announced", {
36 message: newLivestream.record.title,
37 });
38 setTitle("");
39 setCustomThumbnail(undefined);
40 }
41 }, [newLivestream?.record]);
42 useEffect(() => {
43 if (newLivestream?.error) {
44 toast.show("Error creating livestream", {
45 message: newLivestream.error,
46 });
47 }
48 }, [newLivestream?.error]);
49 const disabled = !userIsLive || loading || title === "";
50
51 const handleSubmit = async () => {
52 setLoading(true);
53 try {
54 let thumbnailToUse = customThumbnail;
55 if (!thumbnailToUse && isWeb && captureFrame) {
56 const capturedFrame = await captureFrame(1280, 0.85);
57 if (capturedFrame) {
58 thumbnailToUse = capturedFrame;
59 }
60 }
61
62 await dispatch(
63 createLivestreamRecord({
64 title,
65 customThumbnail: thumbnailToUse,
66 }),
67 );
68 } catch (error) {
69 console.error("Error creating livestream:", error);
70 toast.show("Error creating livestream", {
71 message: String(error),
72 });
73 } finally {
74 setLoading(false);
75 }
76 };
77
78 const buttonText = loading
79 ? "Loading..."
80 : !userIsLive
81 ? "Waiting for stream to start..."
82 : "Announce Livestream!";
83
84 return (
85 <ScrollView
86 style={{ width: "60%" }}
87 contentContainerStyle={{
88 flexGrow: 1,
89 justifyContent: "flex-start",
90 paddingVertical: 40,
91 }}
92 showsVerticalScrollIndicator={false}
93 >
94 <View
95 flexDirection={useTwoColumns ? "row" : "column"}
96 gap={useTwoColumns ? 48 : 16}
97 w="100%"
98 maxWidth={useTwoColumns ? 900 : undefined}
99 alignSelf="center"
100 p="$4"
101 alignItems={useTwoColumns ? "flex-start" : "stretch"}
102 justifyContent="center"
103 >
104 {/* Left column: labels and fields */}
105 <View f={2} minWidth={0} gap="$3" w={useTwoColumns ? 500 : "100%"}>
106 <Label asChild={true} display="flex">
107 <View flexDirection="row" alignItems="center" w="100%">
108 <Paragraph pb="$2" minWidth={100} textAlign="left">
109 Streamer
110 </Paragraph>
111 <Paragraph pb="$2" fontWeight="bold">
112 @{profile?.handle}
113 </Paragraph>
114 </View>
115 </Label>
116 <Label asChild={true}>
117 <View flexDirection="row" alignItems="center" w="100%">
118 <Paragraph pb="$2" minWidth={100} textAlign="left">
119 Title
120 </Paragraph>
121 <View flex={1}>
122 <TextArea
123 id="livestream-title"
124 value={title}
125 onChangeText={setTitle}
126 size="$4"
127 minHeight={100}
128 maxLength={140}
129 w="100%"
130 />
131 </View>
132 </View>
133 </Label>
134 <View w="100%" alignItems="center" mt="$4">
135 <Button
136 disabled={disabled}
137 opacity={disabled ? 0.5 : 1}
138 size="$4"
139 w="100%"
140 onPress={handleSubmit}
141 >
142 {buttonText}
143 </Button>
144 </View>
145 </View>
146 {/* Right column: thumbnail */}
147 <View
148 f={1}
149 minWidth={0}
150 gap="$4"
151 alignItems="center"
152 justifyContent="flex-start"
153 w={useTwoColumns ? 400 : "100%"}
154 style={{
155 marginTop: 12,
156 ...(useTwoColumns ? {} : { marginLeft: 40 }),
157 }}
158 >
159 <Label asChild={true}>
160 <View flexDirection="column" alignItems="center" w="100%">
161 <Paragraph pb={0} lineHeight={18} fontWeight="bold" mb="$2">
162 Custom Thumbnail (Optional)
163 </Paragraph>
164 <View maxWidth={400} w="100%">
165 <ThumbnailSelector onThumbnailSelected={setCustomThumbnail} />
166 </View>
167 </View>
168 </Label>
169 </View>
170 </View>
171 </ScrollView>
172 );
173}