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;