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;