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