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