creates video voice memos from audio clips; with bluesky integration.
trill.ptr.pet
1import { For } from "solid-js";
2
3import { CheckIcon, ChevronsUpDownIcon } from "lucide-solid";
4import { Button } from "./components/ui/button";
5import { Card } from "./components/ui/card";
6import { Stack, Box, StackProps, HStack, VStack } from "styled-system/jsx";
7import { FileUpload } from "./components/ui/file-upload";
8import { Text } from "./components/ui/text";
9
10import { AtprotoDid } from "@atcute/lexicons/syntax";
11import {
12 Account,
13 accounts,
14 selectedAccount,
15 setSelectedAccount,
16} from "./lib/accounts";
17import { Toaster } from "~/components/Toaster";
18import { createListCollection, Select } from "./components/ui/select";
19
20import { addTask, tasks, TaskState } from "./lib/task";
21import Task from "./components/FileTask";
22import Settings from "./components/Settings";
23import MicRecorder from "./components/MicRecorder";
24import { Link } from "./components/ui/link";
25
26const App = () => {
27 const collection = () =>
28 createListCollection({
29 items: accounts().map((account) => ({
30 label: account.handle ?? account.did,
31 value: account.did,
32 })),
33 });
34
35 const AccountSelect = () => (
36 <Select.Root
37 positioning={{ sameWidth: true }}
38 value={selectedAccount() ? [selectedAccount()!] : []}
39 onValueChange={(details) =>
40 setSelectedAccount(details.value[0] as AtprotoDid)
41 }
42 collection={collection()}
43 >
44 <Select.Control>
45 <Select.Trigger
46 border="none"
47 p="2"
48 pl="0"
49 minW="8rem"
50 maxW="xs"
51 boxShadow={{ _focus: "none" }}
52 justifyContent="end"
53 disabled={accounts().length === 0}
54 >
55 <Box>
56 {selectedAccount() ? "@" : ""}
57 <Select.ValueText
58 overflow="hidden"
59 textOverflow="ellipsis"
60 whiteSpace="nowrap"
61 placeholder="account"
62 />
63 </Box>
64 <ChevronsUpDownIcon />
65 </Select.Trigger>
66 </Select.Control>
67 <Select.Positioner>
68 <Select.Content>
69 <Select.ItemGroup>
70 <For each={collection().items}>
71 {(item) => (
72 <Select.Item item={item}>
73 <Select.ItemText
74 overflow="hidden"
75 textOverflow="ellipsis"
76 whiteSpace="nowrap"
77 >
78 @{item.label}
79 </Select.ItemText>
80 <Select.ItemIndicator pl="2">
81 <CheckIcon />
82 </Select.ItemIndicator>
83 </Select.Item>
84 )}
85 </For>
86 </Select.ItemGroup>
87 </Select.Content>
88 </Select.Positioner>
89 </Select.Root>
90 );
91
92 return (
93 <>
94 <VStack
95 py="8"
96 minH="100vh"
97 minW="100vw"
98 justifyContent="center"
99 alignItems="center"
100 >
101 <Card.Root maxW="3xl" w="94%" h="max">
102 <Card.Header>
103 <Card.Title w="full">
104 <Stack direction="row" align="center">
105 <Text>trill</Text>
106 <div style="flex-grow: 1;"></div>
107 <AccountSelect />
108 <Settings />
109 </Stack>
110 </Card.Title>
111 <Card.Description>
112 <ol>
113 <li>1. upload a voice memo or record one.</li>
114 <li>2. it will automatically be converted to a video</li>
115 <li>
116 3. (optional) add an account to enable bluesky integration.
117 </li>
118 </ol>
119 </Card.Description>
120 </Card.Header>
121 <Card.Body>
122 <Stack gap="4" direction={{ base: "row", smDown: "column" }}>
123 <Upload
124 flex="4"
125 acceptedFiles={[]}
126 onFileAccept={(e) =>
127 e.files.forEach((file) => addTask(selectedAccount(), file))
128 }
129 />
130 <Tasks
131 flex="3"
132 minH="20rem"
133 maxH="20rem"
134 minW="0"
135 overflowY="scroll"
136 currentTasks={tasks.values().toArray()}
137 selectedAccount={accounts().find(
138 (account) => account.did === selectedAccount(),
139 )}
140 />
141 </Stack>
142 </Card.Body>
143 </Card.Root>
144 <Card.Root maxW="3xl" w="94%">
145 <Card.Header py="2" px="4">
146 <Card.Description>
147 <HStack justifyContent="space-between" alignItems="center">
148 <Text>
149 /made by{" "}
150 <Link
151 target="_blank"
152 rel="noopener noreferrer"
153 href="https://gaze.systems"
154 >
155 {Math.random() < 0.98 ? "dawn" : "90008"}
156 </Link>
157 /
158 </Text>
159 <Link
160 target="_blank"
161 rel="noopener noreferrer"
162 href="https://github.com/sponsors/90-008"
163 transition="all"
164 transitionDuration="250ms"
165 color={{ _hover: "red" }}
166 >
167 <svg
168 xmlns="http://www.w3.org/2000/svg"
169 width="16"
170 height="16"
171 viewBox="0 0 16 16"
172 >
173 <path
174 fill="currentColor"
175 d="M11.8 1c-1.682 0-3.129 1.368-3.799 2.797C7.33 2.368 5.883 1 4.201 1a4.2 4.2 0 0 0-4.2 4.2c0 4.716 4.758 5.953 8 10.616c3.065-4.634 8-6.05 8-10.616c0-2.319-1.882-4.2-4.2-4.2z"
176 />
177 </svg>
178 </Link>
179 <Text>
180 source on{" "}
181 <Link
182 target="_blank"
183 rel="noopener noreferrer"
184 href="https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae/trill"
185 >
186 tangled
187 </Link>
188 </Text>
189 </HStack>
190 </Card.Description>
191 </Card.Header>
192 </Card.Root>
193 </VStack>
194 <Toaster />
195 </>
196 );
197};
198export default App;
199
200type TasksProps = StackProps & {
201 currentTasks: TaskState[];
202 selectedAccount: Account | undefined;
203};
204
205const Tasks = (props: TasksProps) => (
206 <Stack
207 border="1px solid var(--colors-border-subtle)"
208 borderBottomWidth="3px"
209 gap="1.5"
210 p="2"
211 rounded="sm"
212 justifyContent={props.currentTasks.length === 0 ? "center" : undefined}
213 alignItems={props.currentTasks.length === 0 ? "center" : undefined}
214 {...props}
215 >
216 <For
217 each={props.currentTasks}
218 fallback={
219 <Box
220 fontSize="sm"
221 display="flex"
222 justifyContent="center"
223 alignItems="center"
224 h="full"
225 >
226 no files processed (yet!)
227 </Box>
228 }
229 >
230 {(process) => Task(process, props.selectedAccount)}
231 </For>
232 </Stack>
233);
234
235const getAudioClipboard = async () => {
236 try {
237 const clipboardItems = await navigator.clipboard.read();
238 for (const item of clipboardItems) {
239 console.log(item);
240 const type = item.types.find((type) => type.startsWith("audio/"));
241 if (type) {
242 const blob = await item.getType(type);
243 const file = new File([blob], `audio.${type.split("/")[1]}`, { type });
244 return file;
245 }
246 }
247 return;
248 } catch (err) {
249 console.error(err);
250 return;
251 }
252};
253
254const Upload = (props: FileUpload.RootProps) => {
255 return (
256 <FileUpload.Root maxFiles={100} {...props}>
257 <FileUpload.Dropzone borderBottomWidth="3px">
258 <FileUpload.Label>drop your files here</FileUpload.Label>
259 <HStack alignItems="center">
260 <FileUpload.Trigger
261 asChild={(triggerProps) => (
262 <Button size="sm" {...triggerProps()}>
263 or pick file
264 </Button>
265 )}
266 />
267 <MicRecorder selectedAccount={selectedAccount} />
268 {/*<IconButton
269 size="sm"
270 onClick={() =>
271 getAudioClipboard().then((file) => {
272 if (!file) return;
273 addTask(selectedAccount(), file);
274 })
275 }
276 variant="subtle"
277 >
278 <ClipboardIcon />
279 </IconButton>*/}
280 </HStack>
281 </FileUpload.Dropzone>
282 <FileUpload.HiddenInput />
283 </FileUpload.Root>
284 );
285};