creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet
1import { Component, createSignal, For, JSXElement, Signal } from "solid-js"; 2 3import { 4 CheckIcon, 5 ChevronsUpDownIcon, 6 CogIcon, 7 PipetteIcon, 8 PlusIcon, 9 Trash2Icon, 10 XIcon, 11} from "lucide-solid"; 12import { Button } from "~/components/ui/button"; 13import { Field } from "~/components/ui/field"; 14import { Stack, Box, HStack } from "styled-system/jsx"; 15import { IconButton } from "~/components/ui/icon-button"; 16import { FormLabel } from "~/components/ui/form-label"; 17import { Checkbox } from "~/components/ui/checkbox"; 18import { Drawer } from "~/components/ui/drawer"; 19import { Text } from "~/components/ui/text"; 20 21import { Handle, isHandle } from "@atcute/lexicons/syntax"; 22import { css } from "styled-system/css"; 23import { flow, sessions } from "~/lib/oauth"; 24import { 25 Account, 26 loggingIn, 27 accounts, 28 setAccounts, 29 setSelectedAccount, 30} from "~/lib/accounts"; 31import { 32 showProfilePicture as showProfilePictureSetting, 33 showVisualizer as showVisualizerSetting, 34 backgroundColor as backgroundColorSetting, 35 frameRate as frameRateSetting, 36 useDominantColorAsBg as useDominantColorAsBgSetting, 37 autoTranscribe as autoTranscribeSetting, 38 whisperModel as whisperModelSetting, 39 Setting, 40 defaultWhisperModel, 41} from "~/lib/settings"; 42import { handleResolver } from "~/lib/at"; 43import { toaster } from "~/components/Toaster"; 44import { createListCollection, Select } from "~/components/ui/select"; 45 46import { type Color, type ListCollection, parseColor } from "@ark-ui/solid"; 47import { ColorPicker } from "~/components/ui/color-picker"; 48import { Input } from "~/components/ui/input"; 49import { preloadModel } from "~/lib/transcribe"; 50 51const SettingCheckbox = (props: { 52 setting: Setting<boolean>; 53 signal: Signal<boolean>; 54 label: string; 55 disabled?: boolean; 56}) => ( 57 <Checkbox 58 p="2" 59 checked={props.signal[0]()} 60 onCheckedChange={(e) => { 61 const val = e.checked === "indeterminate" ? false : e.checked; 62 props.signal[1](val); 63 props.setting.set(val); 64 }} 65 disabled={props.disabled} 66 colorPalette={props.disabled ? "gray" : undefined} 67 cursor={props.disabled ? { _hover: "not-allowed" } : undefined} 68 > 69 <Text color={props.disabled ? "fg.disabled" : undefined}> 70 {props.label} 71 </Text> 72 </Checkbox> 73); 74 75const SettingSelect = (props: { 76 label: string; 77 signal: Signal<string>; 78 collection: ListCollection<{ label: string; value: string }>; 79}) => ( 80 <Select.Root 81 width="2xs" 82 positioning={{ sameWidth: true }} 83 value={[props.signal[0]()]} 84 onValueChange={(details) => props.signal[1](details.value[0])} 85 collection={props.collection} 86 > 87 <Select.Label px="2">{props.label}</Select.Label> 88 <Select.Control> 89 <Select.Trigger border="none" p="2" boxShadow={{ _focus: "none" }}> 90 <Select.ValueText placeholder="account" /> 91 <ChevronsUpDownIcon /> 92 </Select.Trigger> 93 </Select.Control> 94 <Select.Positioner> 95 <Select.Content> 96 <Select.ItemGroup> 97 <For each={props.collection.items}> 98 {(item) => ( 99 <Select.Item item={item}> 100 <Select.ItemText>{item.label}</Select.ItemText> 101 <Select.ItemIndicator pl="2"> 102 <CheckIcon /> 103 </Select.ItemIndicator> 104 </Select.Item> 105 )} 106 </For> 107 </Select.ItemGroup> 108 </Select.Content> 109 </Select.Positioner> 110 </Select.Root> 111); 112 113const SettingColorPicker = (props: { 114 label: string; 115 signal: Signal<Color>; 116}) => { 117 return ( 118 <ColorPicker.Root 119 p="2" 120 value={props.signal[0]()} 121 onValueChange={(e) => props.signal[1](e.value)} 122 onValueChangeEnd={(e) => props.signal[1](e.value)} 123 > 124 <ColorPicker.Context> 125 {(api) => ( 126 <> 127 <ColorPicker.Label>{props.label}</ColorPicker.Label> 128 <ColorPicker.Control> 129 <ColorPicker.ChannelInput 130 channel="hex" 131 asChild={(inputProps) => <Input {...inputProps()} />} 132 /> 133 <ColorPicker.Trigger 134 asChild={(triggerProps) => ( 135 <IconButton variant="outline" {...triggerProps()}> 136 <ColorPicker.Swatch value={api().value} /> 137 </IconButton> 138 )} 139 /> 140 </ColorPicker.Control> 141 <ColorPicker.Positioner> 142 <ColorPicker.Content> 143 <Stack gap="3"> 144 <ColorPicker.Area> 145 <ColorPicker.AreaBackground /> 146 <ColorPicker.AreaThumb /> 147 </ColorPicker.Area> 148 <HStack gap="3"> 149 <ColorPicker.EyeDropperTrigger 150 asChild={(triggerProps) => ( 151 <IconButton 152 size="xs" 153 variant="outline" 154 aria-label="Pick a color" 155 {...triggerProps()} 156 > 157 <PipetteIcon /> 158 </IconButton> 159 )} 160 /> 161 <Stack gap="2" flex="1"> 162 <ColorPicker.ChannelSlider channel="hue"> 163 <ColorPicker.ChannelSliderTrack /> 164 <ColorPicker.ChannelSliderThumb /> 165 </ColorPicker.ChannelSlider> 166 <ColorPicker.ChannelSlider channel="alpha"> 167 <ColorPicker.TransparencyGrid size="8px" /> 168 <ColorPicker.ChannelSliderTrack /> 169 <ColorPicker.ChannelSliderThumb /> 170 </ColorPicker.ChannelSlider> 171 </Stack> 172 </HStack> 173 <HStack> 174 <ColorPicker.ChannelInput 175 channel="hex" 176 asChild={(inputProps) => ( 177 <Input size="2xs" {...inputProps()} /> 178 )} 179 /> 180 <ColorPicker.ChannelInput 181 channel="alpha" 182 asChild={(inputProps) => ( 183 <Input size="2xs" {...inputProps()} /> 184 )} 185 /> 186 </HStack> 187 </Stack> 188 </ColorPicker.Content> 189 </ColorPicker.Positioner> 190 </> 191 )} 192 </ColorPicker.Context> 193 <ColorPicker.HiddenInput /> 194 </ColorPicker.Root> 195 ); 196}; 197 198const Category = ({ 199 title, 200 children, 201}: { 202 title: string; 203 children: JSXElement; 204}) => ( 205 <Stack> 206 <FormLabel>{title}</FormLabel> 207 <Stack 208 gap="0" 209 border="1px solid var(--colors-border-default)" 210 borderBottomWidth="3px" 211 rounded="xs" 212 > 213 {children} 214 </Stack> 215 </Stack> 216); 217 218const Settings = () => { 219 const [handle, setHandle] = createSignal(""); 220 const isHandleValid = () => isHandle(handle()); 221 222 const deleteAccount = (account: Account) => { 223 const newAccounts = accounts().filter((a) => a.did !== account.did); 224 setAccounts(newAccounts); 225 sessions.remove(account.did); 226 setSelectedAccount(newAccounts[0]?.did ?? undefined); 227 }; 228 229 const startAccountFlow = async () => { 230 try { 231 toaster.create({ 232 title: "logging in", 233 description: `logging in to ${handle()}...`, 234 type: "info", 235 }); 236 const did = await handleResolver.resolve(handle() as Handle); 237 loggingIn.set(did); 238 await flow.start(did); 239 } catch (err) { 240 console.error(err); 241 toaster.create({ 242 title: "login error", 243 description: `${err}`, 244 type: "error", 245 }); 246 loggingIn.set(undefined); 247 } 248 }; 249 250 const Accounts = () => { 251 const item = (account: Account, isLatest: boolean) => ( 252 <Stack 253 direction="row" 254 w="full" 255 px="2" 256 pb="2" 257 borderBottom={ 258 !isLatest ? "1px solid var(--colors-border-muted)" : undefined 259 } 260 align="center" 261 > 262 {account.handle ? `@${account.handle}` : account.did} 263 <div class={css({ flexGrow: 1 })} /> 264 <IconButton 265 onClick={() => deleteAccount(account)} 266 variant="ghost" 267 size="sm" 268 > 269 <Trash2Icon /> 270 </IconButton> 271 </Stack> 272 ); 273 const items = (accounts: Account[]) => ( 274 <For 275 each={accounts} 276 fallback={ 277 <Text color="fg.muted" px="2" pb="2" alignSelf="center"> 278 no accounts added 279 </Text> 280 } 281 > 282 {(acc, idx) => item(acc, idx() === accounts.length - 1)} 283 </For> 284 ); 285 return ( 286 <Category title="accounts"> 287 <Stack 288 borderBottom="1px solid var(--colors-border-default)" 289 p="2" 290 marginBottom="2" 291 direction="row" 292 gap="2" 293 w="full" 294 > 295 <Field.Root w="full"> 296 <Field.Input 297 placeholder="example.bsky.social" 298 value={handle()} 299 onInput={(e) => setHandle(e.currentTarget.value)} 300 /> 301 </Field.Root> 302 <IconButton onClick={startAccountFlow} disabled={!isHandleValid()}> 303 <PlusIcon /> 304 </IconButton> 305 </Stack> 306 {items(accounts())} 307 </Category> 308 ); 309 }; 310 311 const [showProfilePicture, setShowProfilePicture] = createSignal( 312 showProfilePictureSetting.get() ?? true, 313 ); 314 const [showVisualizer, setShowVisualizer] = createSignal( 315 showVisualizerSetting.get() ?? true, 316 ); 317 const [useDominantColorAsBg, setUseDominantColorAsBg] = createSignal( 318 useDominantColorAsBgSetting.get() ?? true, 319 ); 320 321 const frameRateCollection = createListCollection({ 322 items: [24, 30, 60].map((rate) => ({ 323 label: `${rate} FPS`, 324 value: rate.toString(), 325 })), 326 }); 327 const [frameRate, _setFrameRate] = createSignal( 328 (frameRateSetting.get() ?? 24).toString(), 329 ); 330 const setFrameRate = (value: string | ((prev: string) => string)) => { 331 const newFrameRate = _setFrameRate(value); 332 frameRateSetting.set(parseInt(newFrameRate)); 333 }; 334 335 const [backgroundColor, _setBackgroundColor] = createSignal( 336 parseColor(backgroundColorSetting.get() ?? "#000000"), 337 ); 338 const setBackgroundColor = (value: Color | ((prev: Color) => Color)) => { 339 const newColor = _setBackgroundColor(value); 340 backgroundColorSetting.set(newColor.toString("rgb")); 341 }; 342 343 const whisperModelCollection = createListCollection({ 344 items: [ 345 { tag: "tiny", size: "40MB" }, 346 { tag: "base", size: "80MB" }, 347 { tag: "small", size: "250MB" }, 348 ].map((model) => ({ 349 label: `${model.tag} (${model.size})`, 350 value: `onnx-community/whisper-${model.tag}`, 351 })), 352 }); 353 const [whisperModel, _setWhisperModel] = createSignal( 354 (whisperModelSetting.get() ?? defaultWhisperModel).toString(), 355 ); 356 const setWhisperModel = (value: string | ((prev: string) => string)) => { 357 const newModel = _setWhisperModel(value); 358 whisperModelSetting.set(newModel); 359 if (autoTranscribe()) setTimeout(() => preloadModel(), 200); 360 }; 361 const [autoTranscribe, setAutoTranscribe] = createSignal( 362 autoTranscribeSetting.get() ?? false, 363 ); 364 365 return ( 366 <Drawer.Root> 367 <Drawer.Trigger 368 asChild={(triggerProps) => ( 369 <IconButton variant="outline" {...triggerProps()}> 370 <CogIcon /> 371 </IconButton> 372 )} 373 /> 374 <Drawer.Backdrop /> 375 <Drawer.Positioner> 376 <Drawer.Content> 377 <Drawer.Header p="0" pl="4"> 378 <Stack direction="row" alignItems="center"> 379 <Drawer.Title>settings</Drawer.Title> 380 <div style="flex-grow: 1;"></div> 381 <Drawer.CloseTrigger 382 placeSelf="end" 383 asChild={(closeProps) => ( 384 <IconButton size="lg" {...closeProps()} variant="ghost"> 385 <XIcon /> 386 </IconButton> 387 )} 388 /> 389 </Stack> 390 </Drawer.Header> 391 <Drawer.Body> 392 <Stack gap="4"> 393 <Accounts /> 394 <Category title="video processing"> 395 <Box borderBottom="1px solid var(--colors-border-subtle)"> 396 <SettingCheckbox 397 label="show profile picture" 398 setting={showProfilePictureSetting} 399 signal={[showProfilePicture, setShowProfilePicture]} 400 /> 401 </Box> 402 <SettingCheckbox 403 label="show visualizer" 404 setting={showVisualizerSetting} 405 signal={[showVisualizer, setShowVisualizer]} 406 /> 407 <Stack gap="0" borderY="1px solid var(--colors-border-muted)"> 408 <SettingCheckbox 409 label="use dominant color as bg" 410 setting={useDominantColorAsBgSetting} 411 signal={[useDominantColorAsBg, setUseDominantColorAsBg]} 412 disabled={!showProfilePicture()} 413 /> 414 <SettingColorPicker 415 label="background color" 416 signal={[backgroundColor, setBackgroundColor]} 417 /> 418 </Stack> 419 <SettingSelect 420 label="frame rate" 421 signal={[frameRate, setFrameRate]} 422 collection={frameRateCollection} 423 /> 424 </Category> 425 <Category title="audio transcription"> 426 <Box borderBottom="1px solid var(--colors-border-subtle)"> 427 <SettingCheckbox 428 label="transcribe audio" 429 setting={autoTranscribeSetting} 430 signal={[ 431 autoTranscribe, 432 (val) => { 433 const newVal = setAutoTranscribe(val); 434 if (newVal) preloadModel(); 435 return val; 436 }, 437 ]} 438 /> 439 </Box> 440 <Box borderBottom="1px solid var(--colors-border-subtle)"> 441 <SettingSelect 442 label="whisper model" 443 signal={[whisperModel, setWhisperModel]} 444 collection={whisperModelCollection} 445 /> 446 </Box> 447 <Text color="fg.subtle" p="2" fontSize="sm" fontWeight="normal"> 448 note: the model will only be downloaded once. 449 </Text> 450 </Category> 451 </Stack> 452 </Drawer.Body> 453 <Drawer.Footer p="2" gap="3"> 454 <Drawer.CloseTrigger 455 asChild={(closeProps) => ( 456 <Button {...closeProps()} variant="outline"> 457 back 458 </Button> 459 )} 460 /> 461 </Drawer.Footer> 462 </Drawer.Content> 463 </Drawer.Positioner> 464 </Drawer.Root> 465 ); 466}; 467export default Settings;