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};