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