creates video voice memos from audio clips; with bluesky integration.
trill.ptr.pet
1import {
2 CaptionsIcon,
3 CircleAlertIcon,
4 DownloadIcon,
5 EllipsisVerticalIcon,
6 SendIcon,
7} from "lucide-solid";
8import { Stack } from "styled-system/jsx";
9import { IconButton } from "~/components/ui/icon-button";
10import { Spinner } from "~/components/ui/spinner";
11import { Popover } from "~/components/ui/popover";
12
13import { css } from "styled-system/css";
14import { Account } from "~/lib/accounts";
15
16import { TaskState } from "~/lib/task";
17import PostDialog from "./PostDialog";
18import { Button, ButtonProps } from "./ui/button";
19import { Menu } from "./ui/menu";
20import { createSignal } from "solid-js";
21import { toaster } from "./Toaster";
22
23const downloadFile = (blob: Blob, fileName: string) => {
24 const url = URL.createObjectURL(blob);
25 const a = document.createElement("a");
26 a.href = url;
27 // handle file names with periods in them
28 a.download = fileName;
29 document.body.appendChild(a);
30 a.click();
31 document.body.removeChild(a);
32 URL.revokeObjectURL(url);
33};
34
35const Task = (process: TaskState, selectedAccount: Account | undefined) => {
36 const [dialogOpen, setDialogOpen] = createSignal(false);
37 const statusError = (error: string) => (
38 <Popover.Root>
39 <Popover.Trigger
40 asChild={(triggerProps) => (
41 <IconButton
42 {...triggerProps()}
43 color={{
44 base: "red",
45 _hover: "red.emphasized",
46 }}
47 variant="ghost"
48 >
49 <CircleAlertIcon />
50 </IconButton>
51 )}
52 />
53 <Popover.Positioner>
54 <Popover.Content>error processing file: {error}</Popover.Content>
55 </Popover.Positioner>
56 </Popover.Root>
57 );
58 const statusSuccess = (result: Blob, altText?: string) => {
59 const [menuOpen, setMenuOpen] = createSignal(false);
60 const MenuButton = (props: ButtonProps) => (
61 <Button
62 color={{ _hover: "colorPalette.emphasized" }}
63 variant="ghost"
64 display="flex"
65 justifyContent="space-between"
66 alignItems="center"
67 {...props}
68 onClick={(e) => {
69 if (typeof props.onClick === "function") props.onClick(e);
70 setMenuOpen(false);
71 }}
72 />
73 );
74 return (
75 <>
76 <PostDialog
77 openSignal={[dialogOpen, setDialogOpen]}
78 account={selectedAccount}
79 result={result}
80 initialAltText={altText}
81 />
82 <Menu.Root
83 open={menuOpen()}
84 onOpenChange={(e) => setMenuOpen(e.open)}
85 positioning={{ placement: "bottom-start", strategy: "fixed" }}
86 >
87 <Menu.Trigger
88 asChild={(triggerProps) => (
89 <IconButton {...triggerProps()} variant="ghost">
90 <EllipsisVerticalIcon />
91 </IconButton>
92 )}
93 />
94 <Menu.Positioner>
95 <Menu.Content py="0">
96 <Menu.ItemGroup>
97 <MenuButton
98 onClick={() => {
99 downloadFile(
100 result,
101 process.file.name
102 .split(".")
103 .slice(0, -1)
104 .join(".")
105 .concat(".mp4"),
106 );
107 toaster.create({
108 title: "downloaded result file",
109 type: "success",
110 duration: 1000,
111 });
112 }}
113 >
114 download <DownloadIcon />
115 </MenuButton>
116 <MenuButton
117 disabled={altText === undefined}
118 onClick={() => {
119 navigator.clipboard.writeText(altText!);
120 toaster.create({
121 title: "copied transcribed text to clipboard",
122 type: "success",
123 duration: 1000,
124 });
125 }}
126 >
127 copy transcription <CaptionsIcon />
128 </MenuButton>
129 <MenuButton
130 disabled={selectedAccount === undefined}
131 onClick={() => setDialogOpen(!dialogOpen())}
132 >
133 post to bsky <SendIcon />
134 </MenuButton>
135 </Menu.ItemGroup>
136 </Menu.Content>
137 </Menu.Positioner>
138 </Menu.Root>
139 </>
140 );
141 };
142 const statusProcessing = () => (
143 <Spinner
144 borderLeftColor="bg.emphasized"
145 borderBottomColor="bg.emphasized"
146 borderWidth="4px"
147 m="2"
148 />
149 );
150
151 const status = () => {
152 switch (process.status) {
153 case "success":
154 return statusSuccess(process.result, process.altText);
155 case "processing":
156 return statusProcessing();
157 default:
158 return statusError(process.error);
159 }
160 };
161
162 return (
163 <Stack
164 direction="row"
165 border="1px solid var(--colors-border-muted)"
166 borderBottomWidth="2px"
167 gap="2"
168 align="center"
169 rounded="sm"
170 >
171 <span
172 class={css({
173 overflow: "hidden",
174 textOverflow: "ellipsis",
175 whiteSpace: "nowrap",
176 pl: 2,
177 })}
178 >
179 {process.file.name}
180 </span>
181 <div class={css({ flexGrow: 1 })}></div>
182 <Stack direction="row" gap="0" flexShrink="0" align="center">
183 {status()}
184 </Stack>
185 </Stack>
186 );
187};
188
189export default Task;