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, isLatest: boolean) => (
228 <Stack
229 direction="row"
230 w="full"
231 px="2"
232 pb="2"
233 borderBottom={
234 !isLatest ? "1px solid var(--colors-border-muted)" : undefined
235 }
236 align="center"
237 >
238 {account.handle ? `@${account.handle}` : account.did}
239 <div class={css({ flexGrow: 1 })} />
240 <IconButton
241 onClick={() => deleteAccount(account)}
242 variant="ghost"
243 size="sm"
244 >
245 <Trash2Icon />
246 </IconButton>
247 </Stack>
248 );
249 const items = (accounts: Account[]) => (
250 <For
251 each={accounts}
252 fallback={
253 <Text color="fg.muted" px="2" pb="2" alignSelf="center">
254 no accounts added
255 </Text>
256 }
257 >
258 {(acc, idx) => item(acc, idx() === accounts.length - 1)}
259 </For>
260 );
261 return (
262 <Stack>
263 <FormLabel>accounts</FormLabel>
264 <Stack
265 border="1px solid var(--colors-border-default)"
266 borderBottomWidth="3px"
267 rounded="xs"
268 >
269 <Stack
270 borderBottom="1px solid var(--colors-border-default)"
271 p="2"
272 direction="row"
273 gap="2"
274 w="full"
275 >
276 <Field.Root w="full">
277 <Field.Input
278 placeholder="example.bsky.social"
279 value={handle()}
280 onInput={(e) => setHandle(e.currentTarget.value)}
281 />
282 </Field.Root>
283 <IconButton onClick={startAccountFlow} disabled={!isHandleValid()}>
284 <PlusIcon />
285 </IconButton>
286 </Stack>
287 {items(accounts())}
288 </Stack>
289 </Stack>
290 );
291 };
292
293 const [showProfilePicture, setShowProfilePicture] = createSignal(
294 showProfilePictureSetting.get() ?? true,
295 );
296 const [showVisualizer, setShowVisualizer] = createSignal(
297 showVisualizerSetting.get() ?? true,
298 );
299 const [useDominantColorAsBg, setUseDominantColorAsBg] = createSignal(
300 useDominantColorAsBgSetting.get() ?? true,
301 );
302
303 const frameRateCollection = createListCollection({
304 items: [24, 30, 60].map((rate) => ({
305 label: `${rate} FPS`,
306 value: rate.toString(),
307 })),
308 });
309 const [frameRate, _setFrameRate] = createSignal(
310 (frameRateSetting.get() ?? 24).toString(),
311 );
312 const setFrameRate = (value: string | ((prev: string) => string)) => {
313 const newFrameRate = _setFrameRate(value);
314 frameRateSetting.set(parseInt(newFrameRate));
315 };
316
317 const [backgroundColor, _setBackgroundColor] = createSignal(
318 parseColor(backgroundColorSetting.get() ?? "#000000"),
319 );
320 const setBackgroundColor = (value: Color | ((prev: Color) => Color)) => {
321 const newColor = _setBackgroundColor(value);
322 backgroundColorSetting.set(newColor.toString("rgb"));
323 };
324
325 return (
326 <Drawer.Root>
327 <Drawer.Trigger
328 asChild={(triggerProps) => (
329 <IconButton variant="outline" {...triggerProps()}>
330 <CogIcon />
331 </IconButton>
332 )}
333 />
334 <Drawer.Backdrop />
335 <Drawer.Positioner>
336 <Drawer.Content>
337 <Drawer.Header p="0" pl="4">
338 <Stack direction="row" alignItems="center">
339 <Drawer.Title>settings</Drawer.Title>
340 <div style="flex-grow: 1;"></div>
341 <Drawer.CloseTrigger
342 placeSelf="end"
343 asChild={(closeProps) => (
344 <IconButton size="lg" {...closeProps()} variant="ghost">
345 <XIcon />
346 </IconButton>
347 )}
348 />
349 </Stack>
350 </Drawer.Header>
351 <Drawer.Body>
352 <Stack gap="4">
353 <Accounts />
354 <Stack>
355 <FormLabel>processing</FormLabel>
356 <Stack
357 gap="0"
358 border="1px solid var(--colors-border-default)"
359 borderBottomWidth="3px"
360 rounded="xs"
361 >
362 <Box borderBottom="1px solid var(--colors-border-subtle)">
363 <SettingCheckbox
364 label="show profile picture"
365 setting={showProfilePictureSetting}
366 signal={[showProfilePicture, setShowProfilePicture]}
367 />
368 </Box>
369 <SettingCheckbox
370 label="show visualizer"
371 setting={showVisualizerSetting}
372 signal={[showVisualizer, setShowVisualizer]}
373 />
374 <Stack gap="0" borderY="1px solid var(--colors-border-muted)">
375 <SettingCheckbox
376 label="use dominant color as bg"
377 setting={useDominantColorAsBgSetting}
378 signal={[useDominantColorAsBg, setUseDominantColorAsBg]}
379 disabled={!showProfilePicture()}
380 />
381 <SettingColorPicker
382 label="background color"
383 signal={[backgroundColor, setBackgroundColor]}
384 />
385 </Stack>
386 <SettingSelect
387 label="frame rate"
388 signal={[frameRate, setFrameRate]}
389 collection={frameRateCollection}
390 />
391 </Stack>
392 </Stack>
393 </Stack>
394 </Drawer.Body>
395 <Drawer.Footer p="2" gap="3">
396 <Drawer.CloseTrigger
397 asChild={(closeProps) => (
398 <Button {...closeProps()} variant="outline">
399 back
400 </Button>
401 )}
402 />
403 </Drawer.Footer>
404 </Drawer.Content>
405 </Drawer.Positioner>
406 </Drawer.Root>
407 );
408};
409export default Settings;