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