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