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