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