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 gap="1.5"
166 p="2"
167 rounded="sm"
168 justifyContent={props.currentTasks.length === 0 ? "center" : undefined}
169 alignItems={props.currentTasks.length === 0 ? "center" : undefined}
170 {...props}
171 >
172 <For
173 each={props.currentTasks}
174 fallback={
175 <Box
176 fontSize="sm"
177 display="flex"
178 justifyContent="center"
179 alignItems="center"
180 h="full"
181 >
182 no files processed (yet!)
183 </Box>
184 }
185 >
186 {(process) => Task(process, props.selectedAccount)}
187 </For>
188 </Stack>
189);
190
191const getAudioClipboard = async () => {
192 try {
193 const clipboardItems = await navigator.clipboard.read();
194 for (const item of clipboardItems) {
195 console.log(item);
196 const type = item.types.find((type) => type.startsWith("audio/"));
197 if (type) {
198 const blob = await item.getType(type);
199 const file = new File([blob], `audio.${type.split("/")[1]}`, { type });
200 return file;
201 }
202 }
203 return;
204 } catch (err) {
205 console.error(err);
206 return;
207 }
208};
209
210const Upload = (props: FileUpload.RootProps) => {
211 return (
212 <FileUpload.Root maxFiles={100} {...props}>
213 <FileUpload.Dropzone>
214 <FileUpload.Label>drop your files here</FileUpload.Label>
215 <HStack alignItems="center">
216 <FileUpload.Trigger
217 asChild={(triggerProps) => (
218 <Button size="sm" {...triggerProps()}>
219 or pick file
220 </Button>
221 )}
222 />
223 <MicRecorder selectedAccount={selectedAccount} />
224 {/*<IconButton
225 size="sm"
226 onClick={() =>
227 getAudioClipboard().then((file) => {
228 if (!file) return;
229 addTask(selectedAccount(), file);
230 })
231 }
232 variant="subtle"
233 >
234 <ClipboardIcon />
235 </IconButton>*/}
236 </HStack>
237 </FileUpload.Dropzone>
238 <FileUpload.HiddenInput />
239 </FileUpload.Root>
240 );
241};