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