creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet

feat: add post / upload status

ptr.pet 78885a9a 7dbb8d96

verified
Changed files
+94 -6
src
components
lib
+73 -6
src/components/PostDialog.tsx
··· 1 - import { Component, createSignal, Signal } from "solid-js"; 2 3 import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid"; 4 - import { Stack } from "styled-system/jsx"; 5 import { IconButton } from "~/components/ui/icon-button"; 6 import { Spinner } from "~/components/ui/spinner"; 7 import { Text } from "~/components/ui/text"; ··· 9 10 import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; 11 import { css } from "styled-system/css"; 12 - import { sendPost } from "~/lib/at"; 13 import { toaster } from "~/components/Toaster"; 14 import { Dialog } from "~/components/ui/dialog"; 15 import { Textarea } from "~/components/ui/textarea"; 16 import { Account } from "~/lib/accounts"; 17 import { Popover } from "./ui/popover"; 18 19 const PostDialog = (props: { 20 result: Blob; ··· 27 props.initialAltText ?? "", 28 ); 29 const [posting, setPosting] = createSignal(false); 30 const [open, setOpen] = props.openSignal; 31 32 return ( 33 <Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}> 34 <Dialog.Backdrop /> ··· 51 border="none" 52 borderTop="1px solid var(--colors-border-muted)" 53 boxShadow={{ base: "none", _focus: "none" }} 54 /> 55 </Stack> 56 <Stack ··· 94 {...triggerProps()} 95 variant={altText() ? "solid" : "ghost"} 96 size="sm" 97 > 98 <CaptionsIcon /> 99 </IconButton> ··· 118 disabled={posting()} 119 onClick={() => { 120 setPosting(true); 121 sendPost( 122 props.account?.did!, 123 props.result, 124 postContent(), 125 altText(), 126 ) 127 .then((result) => { 128 const parsedUri = parseCanonicalResourceUri(result.uri); ··· 131 toaster.create({ 132 title: "post sent", 133 description: ( 134 - <> 135 - <Text>view post </Text> 136 <Link 137 href={`https://bsky.app/profile/${repo}/post/${rkey}`} 138 color={{ ··· 143 > 144 here 145 </Link> 146 - </> 147 ), 148 type: "success", 149 }); ··· 158 }) 159 .finally(() => { 160 setPosting(false); 161 }); 162 }} 163 variant="ghost" ··· 166 <SendIcon /> 167 </IconButton> 168 </Stack> 169 </Stack> 170 </Dialog.Content> 171 </Dialog.Positioner>
··· 1 + import { createSignal, Signal } from "solid-js"; 2 3 import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid"; 4 + import { HStack, Stack, VStack } from "styled-system/jsx"; 5 import { IconButton } from "~/components/ui/icon-button"; 6 import { Spinner } from "~/components/ui/spinner"; 7 import { Text } from "~/components/ui/text"; ··· 9 10 import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; 11 import { css } from "styled-system/css"; 12 + import { sendPost, UploadStatus } from "~/lib/at"; 13 import { toaster } from "~/components/Toaster"; 14 import { Dialog } from "~/components/ui/dialog"; 15 import { Textarea } from "~/components/ui/textarea"; 16 import { Account } from "~/lib/accounts"; 17 import { Popover } from "./ui/popover"; 18 + import { Progress } from "./ui/progress"; 19 20 const PostDialog = (props: { 21 result: Blob; ··· 28 props.initialAltText ?? "", 29 ); 30 const [posting, setPosting] = createSignal(false); 31 + const [uploadStatus, setUploadStatus] = createSignal<UploadStatus | null>( 32 + null, 33 + ); 34 const [open, setOpen] = props.openSignal; 35 36 + const getStatusMessage = () => { 37 + const status = uploadStatus(); 38 + if (!status) return ""; 39 + 40 + switch (status.stage) { 41 + case "auth": 42 + return "authenticating..."; 43 + case "uploading": 44 + return "uploading video..."; 45 + case "processing": 46 + return status.progress 47 + ? `processing video... ${Math.round(status.progress)}%` 48 + : "processing video..."; 49 + case "posting": 50 + return "creating post..."; 51 + case "complete": 52 + return "complete!"; 53 + default: 54 + return ""; 55 + } 56 + }; 57 + 58 + const getProgressValue = () => { 59 + const status = uploadStatus(); 60 + if (!status) return 0; 61 + 62 + switch (status.stage) { 63 + case "auth": 64 + return 5; 65 + case "uploading": 66 + return 10; 67 + case "processing": 68 + return status.progress ? 10 + status.progress * 0.6 : 40; 69 + case "posting": 70 + return 90; 71 + case "complete": 72 + return 100; 73 + default: 74 + return 0; 75 + } 76 + }; 77 + 78 return ( 79 <Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}> 80 <Dialog.Backdrop /> ··· 97 border="none" 98 borderTop="1px solid var(--colors-border-muted)" 99 boxShadow={{ base: "none", _focus: "none" }} 100 + disabled={posting()} 101 /> 102 </Stack> 103 <Stack ··· 141 {...triggerProps()} 142 variant={altText() ? "solid" : "ghost"} 143 size="sm" 144 + disabled={posting()} 145 > 146 <CaptionsIcon /> 147 </IconButton> ··· 166 disabled={posting()} 167 onClick={() => { 168 setPosting(true); 169 + setUploadStatus(null); 170 sendPost( 171 props.account?.did!, 172 props.result, 173 postContent(), 174 altText(), 175 + (status) => setUploadStatus(status), 176 ) 177 .then((result) => { 178 const parsedUri = parseCanonicalResourceUri(result.uri); ··· 181 toaster.create({ 182 title: "post sent", 183 description: ( 184 + <HStack gap="1"> 185 + <Text>view post</Text> 186 <Link 187 href={`https://bsky.app/profile/${repo}/post/${rkey}`} 188 color={{ ··· 193 > 194 here 195 </Link> 196 + </HStack> 197 ), 198 type: "success", 199 }); ··· 208 }) 209 .finally(() => { 210 setPosting(false); 211 + setUploadStatus(null); 212 }); 213 }} 214 variant="ghost" ··· 217 <SendIcon /> 218 </IconButton> 219 </Stack> 220 + {posting() && uploadStatus() && ( 221 + <VStack 222 + gap="2" 223 + p="2" 224 + borderTop="1px solid var(--colors-border-muted)" 225 + > 226 + <Text fontSize="sm" color="fg.muted"> 227 + {getStatusMessage()} 228 + </Text> 229 + <Progress 230 + value={getProgressValue()} 231 + max={100} 232 + colorPalette="blue" 233 + /> 234 + </VStack> 235 + )} 236 </Stack> 237 </Dialog.Content> 238 </Dialog.Positioner>
+3
src/index.tsx
··· 14 import { toaster } from "./components/Toaster"; 15 import { autoTranscribe } from "./lib/settings"; 16 import { preloadModel } from "./lib/transcribe"; 17 18 const root = document.getElementById("root"); 19
··· 14 import { toaster } from "./components/Toaster"; 15 import { autoTranscribe } from "./lib/settings"; 16 import { preloadModel } from "./lib/transcribe"; 17 + import { Text } from "~/components/ui/text"; 18 + import { Link } from "~/components/ui/link"; 19 + import { HStack } from "styled-system/jsx"; 20 21 const root = document.getElementById("root"); 22
+18
src/lib/at.ts
··· 32 }; 33 }; 34 35 export const sendPost = async ( 36 did: AtprotoDid, 37 blob: Blob, 38 postContent: string, 39 altText?: string, 40 ) => { 41 const login = await getSessionClient(did); 42 43 const serviceAuthUrl = new URL( 44 `${login.pds}/xrpc/com.atproto.server.getServiceAuth`, 45 ); ··· 68 const serviceAuth = await serviceAuthResponse.json(); 69 const token = serviceAuth.token; 70 71 const uploadUrl = new URL( 72 "https://video.bsky.app/xrpc/app.bsky.video.uploadVideo", 73 ); ··· 91 const jobStatus = await uploadResponse.json(); 92 let videoBlobRef = jobStatus.blob; 93 94 while (!videoBlobRef) { 95 await new Promise((resolve) => setTimeout(resolve, 1000)); 96 ··· 100 101 if (!statusResponse.ok) { 102 const error = await statusResponse.json(); 103 if (error.error === "already_exists" && error.blob) { 104 videoBlobRef = error.blob; 105 break; ··· 112 videoBlobRef = status.jobStatus.blob; 113 } else if (status.jobStatus.state === "JOB_STATE_FAILED") { 114 throw `video processing failed: ${status.jobStatus.error || "unknown error"}`; 115 } 116 } 117 118 const record: AppBskyFeedPost.Main = { 119 $type: "app.bsky.feed.post", 120 text: postContent, ··· 135 }); 136 137 if (!result.ok) throw `failed to upload post: ${result.data.error}`; 138 return result.data; 139 };
··· 32 }; 33 }; 34 35 + export type UploadStatus = { 36 + stage: "auth" | "uploading" | "processing" | "posting" | "complete"; 37 + progress?: number; 38 + }; 39 + 40 export const sendPost = async ( 41 did: AtprotoDid, 42 blob: Blob, 43 postContent: string, 44 altText?: string, 45 + onStatus?: (status: UploadStatus) => void, 46 ) => { 47 const login = await getSessionClient(did); 48 49 + onStatus?.({ stage: "auth" }); 50 const serviceAuthUrl = new URL( 51 `${login.pds}/xrpc/com.atproto.server.getServiceAuth`, 52 ); ··· 75 const serviceAuth = await serviceAuthResponse.json(); 76 const token = serviceAuth.token; 77 78 + onStatus?.({ stage: "uploading" }); 79 const uploadUrl = new URL( 80 "https://video.bsky.app/xrpc/app.bsky.video.uploadVideo", 81 ); ··· 99 const jobStatus = await uploadResponse.json(); 100 let videoBlobRef = jobStatus.blob; 101 102 + onStatus?.({ stage: "processing" }); 103 while (!videoBlobRef) { 104 await new Promise((resolve) => setTimeout(resolve, 1000)); 105 ··· 109 110 if (!statusResponse.ok) { 111 const error = await statusResponse.json(); 112 + // reuse blob 113 if (error.error === "already_exists" && error.blob) { 114 videoBlobRef = error.blob; 115 break; ··· 122 videoBlobRef = status.jobStatus.blob; 123 } else if (status.jobStatus.state === "JOB_STATE_FAILED") { 124 throw `video processing failed: ${status.jobStatus.error || "unknown error"}`; 125 + } else if (status.jobStatus.progress !== undefined) { 126 + onStatus?.({ 127 + stage: "processing", 128 + progress: status.jobStatus.progress, 129 + }); 130 } 131 } 132 133 + onStatus?.({ stage: "posting" }); 134 const record: AppBskyFeedPost.Main = { 135 $type: "app.bsky.feed.post", 136 text: postContent, ··· 151 }); 152 153 if (!result.ok) throw `failed to upload post: ${result.data.error}`; 154 + 155 + onStatus?.({ stage: "complete" }); 156 return result.data; 157 };