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