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