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 MicIcon,
8 Trash2Icon,
9} from "lucide-solid";
10import { Button } from "./components/ui/button";
11import { Card } from "./components/ui/card";
12import { Stack, Box, StackProps, HStack } from "styled-system/jsx";
13import { FileUpload } from "./components/ui/file-upload";
14import { IconButton } from "./components/ui/icon-button";
15import { Text } from "./components/ui/text";
16
17import { AtprotoDid } from "@atcute/lexicons/syntax";
18import {
19 Account,
20 accounts,
21 selectedAccount,
22 setSelectedAccount,
23} from "./lib/accounts";
24import { Toaster } from "~/components/Toaster";
25import { createListCollection, Select } from "./components/ui/select";
26
27import { addTask, tasks, TaskState } from "./lib/task";
28import Task from "./components/FileTask";
29import Settings from "./components/Settings";
30import MicRecorder from "./components/MicRecorder";
31
32const App = () => {
33 const collection = () =>
34 createListCollection({
35 items: accounts().map((account) => ({
36 label: account.handle ?? account.did,
37 value: account.did,
38 })),
39 });
40
41 const AccountSelect = () => (
42 <Select.Root
43 positioning={{ sameWidth: true }}
44 value={selectedAccount() ? [selectedAccount()!] : []}
45 onValueChange={(details) =>
46 setSelectedAccount(details.value[0] as AtprotoDid)
47 }
48 collection={collection()}
49 >
50 <Select.Control>
51 <Select.Trigger
52 border="none"
53 p="2"
54 pl="0"
55 minW="8rem"
56 maxW="xs"
57 boxShadow={{ _focus: "none" }}
58 justifyContent="end"
59 disabled={accounts().length === 0}
60 >
61 <Box>
62 {selectedAccount() ? "@" : ""}
63 <Select.ValueText
64 overflow="hidden"
65 textOverflow="ellipsis"
66 whiteSpace="nowrap"
67 placeholder="account"
68 />
69 </Box>
70 <ChevronsUpDownIcon />
71 </Select.Trigger>
72 </Select.Control>
73 <Select.Positioner>
74 <Select.Content>
75 <Select.ItemGroup>
76 <For each={collection().items}>
77 {(item) => (
78 <Select.Item item={item}>
79 <Select.ItemText
80 overflow="hidden"
81 textOverflow="ellipsis"
82 whiteSpace="nowrap"
83 >
84 @{item.label}
85 </Select.ItemText>
86 <Select.ItemIndicator pl="2">
87 <CheckIcon />
88 </Select.ItemIndicator>
89 </Select.Item>
90 )}
91 </For>
92 </Select.ItemGroup>
93 </Select.Content>
94 </Select.Positioner>
95 </Select.Root>
96 );
97
98 return (
99 <Box
100 py="8"
101 minH="100vh"
102 minW="100vw"
103 display="flex"
104 justifyContent="center"
105 alignItems="center"
106 >
107 <Card.Root maxW="3xl" w="94%" h="max">
108 <Card.Header>
109 <Card.Title w="full">
110 <Stack direction="row" align="center">
111 <Text>memos</Text>
112 <div style="flex-grow: 1;"></div>
113 <AccountSelect />
114 <Settings />
115 </Stack>
116 </Card.Title>
117 <Card.Description>
118 <ol>
119 <li>1. upload a voice memo</li>
120 <li>2. it will automatically be converted to a video</li>
121 <li>
122 3. (optional) add an account for posting and using pfp in video
123 </li>
124 </ol>
125 </Card.Description>
126 </Card.Header>
127 <Card.Body>
128 <Stack gap="4" direction={{ base: "row", smDown: "column" }}>
129 <Upload
130 flex="4"
131 acceptedFiles={[]}
132 onFileAccept={(e) =>
133 e.files.forEach((file) => addTask(selectedAccount(), file))
134 }
135 />
136 <Tasks
137 flex="3"
138 minH="20rem"
139 maxH="20rem"
140 minW="0"
141 overflowY="scroll"
142 currentTasks={tasks.values().toArray()}
143 selectedAccount={accounts().find(
144 (account) => account.did === selectedAccount(),
145 )}
146 />
147 </Stack>
148 </Card.Body>
149 {/*<Card.Footer gap="3"></Card.Footer>*/}
150 </Card.Root>
151 <Toaster />
152 </Box>
153 );
154};
155export default App;
156
157type TasksProps = StackProps & {
158 currentTasks: TaskState[];
159 selectedAccount: Account | undefined;
160};
161
162const Tasks = (props: TasksProps) => (
163 <Stack
164 border="1px solid var(--colors-border-subtle)"
165 borderBottomWidth="3px"
166 gap="1.5"
167 p="2"
168 rounded="sm"
169 justifyContent={props.currentTasks.length === 0 ? "center" : undefined}
170 alignItems={props.currentTasks.length === 0 ? "center" : undefined}
171 {...props}
172 >
173 <For
174 each={props.currentTasks}
175 fallback={
176 <Box
177 fontSize="sm"
178 display="flex"
179 justifyContent="center"
180 alignItems="center"
181 h="full"
182 >
183 no files processed (yet!)
184 </Box>
185 }
186 >
187 {(process) => Task(process, props.selectedAccount)}
188 </For>
189 </Stack>
190);
191
192const getAudioClipboard = async () => {
193 try {
194 const clipboardItems = await navigator.clipboard.read();
195 for (const item of clipboardItems) {
196 console.log(item);
197 const type = item.types.find((type) => type.startsWith("audio/"));
198 if (type) {
199 const blob = await item.getType(type);
200 const file = new File([blob], `audio.${type.split("/")[1]}`, { type });
201 return file;
202 }
203 }
204 return;
205 } catch (err) {
206 console.error(err);
207 return;
208 }
209};
210
211const Upload = (props: FileUpload.RootProps) => {
212 return (
213 <FileUpload.Root maxFiles={100} {...props}>
214 <FileUpload.Dropzone borderBottomWidth="3px">
215 <FileUpload.Label>drop your files here</FileUpload.Label>
216 <HStack alignItems="center">
217 <FileUpload.Trigger
218 asChild={(triggerProps) => (
219 <Button size="sm" {...triggerProps()}>
220 or pick file
221 </Button>
222 )}
223 />
224 <MicRecorder selectedAccount={selectedAccount} />
225 {/*<IconButton
226 size="sm"
227 onClick={() =>
228 getAudioClipboard().then((file) => {
229 if (!file) return;
230 addTask(selectedAccount(), file);
231 })
232 }
233 variant="subtle"
234 >
235 <ClipboardIcon />
236 </IconButton>*/}
237 </HStack>
238 </FileUpload.Dropzone>
239 <FileUpload.HiddenInput />
240 </FileUpload.Root>
241 );
242};