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

feat: nah make it toggle to record on tap, hold otherwise

ptr.pet cb193787 cf4b246c

verified
Changed files
+56 -74
src
+3 -16
src/App.tsx
··· 1 - import { createSignal, For } from "solid-js"; 2 3 - import { 4 - CheckIcon, 5 - ChevronsUpDownIcon, 6 - ClipboardIcon, 7 - HeartIcon, 8 - MicIcon, 9 - Trash2Icon, 10 - } from "lucide-solid"; 11 import { Button } from "./components/ui/button"; 12 import { Card } from "./components/ui/card"; 13 import { Stack, Box, StackProps, HStack, VStack } from "styled-system/jsx"; 14 import { FileUpload } from "./components/ui/file-upload"; 15 - import { IconButton } from "./components/ui/icon-button"; 16 import { Text } from "./components/ui/text"; 17 18 import { AtprotoDid } from "@atcute/lexicons/syntax"; ··· 30 import Settings from "./components/Settings"; 31 import MicRecorder from "./components/MicRecorder"; 32 import { Link } from "./components/ui/link"; 33 - import { css } from "styled-system/css"; 34 - import { toggleToRecord } from "./lib/settings"; 35 36 const App = () => { 37 const collection = () => ··· 274 </Button> 275 )} 276 /> 277 - <MicRecorder 278 - selectedAccount={selectedAccount} 279 - holdToRecord={!toggleToRecord()} 280 - /> 281 {/*<IconButton 282 size="sm" 283 onClick={() =>
··· 1 + import { For } from "solid-js"; 2 3 + import { CheckIcon, ChevronsUpDownIcon } from "lucide-solid"; 4 import { Button } from "./components/ui/button"; 5 import { Card } from "./components/ui/card"; 6 import { Stack, Box, StackProps, HStack, VStack } from "styled-system/jsx"; 7 import { FileUpload } from "./components/ui/file-upload"; 8 import { Text } from "./components/ui/text"; 9 10 import { AtprotoDid } from "@atcute/lexicons/syntax"; ··· 22 import Settings from "./components/Settings"; 23 import MicRecorder from "./components/MicRecorder"; 24 import { Link } from "./components/ui/link"; 25 26 const App = () => { 27 const collection = () => ··· 264 </Button> 265 )} 266 /> 267 + <MicRecorder selectedAccount={selectedAccount} /> 268 {/*<IconButton 269 size="sm" 270 onClick={() =>
+53 -26
src/components/MicRecorder.tsx
··· 9 10 type MicRecorderProps = { 11 selectedAccount: () => AtprotoDid | undefined; 12 - holdToRecord?: boolean; 13 }; 14 15 const MicRecorder = (props: MicRecorderProps) => { ··· 23 let mediaStream: MediaStream | null = null; 24 let audioChunks: Blob[] = []; 25 26 const isSafari = 27 typeof navigator !== "undefined" && 28 navigator.vendor && ··· 35 36 const startRecording = async () => { 37 if (isRecording()) return; 38 39 try { 40 audioChunks = []; ··· 55 echoCancellation: { ideal: true }, 56 }, 57 }); 58 const audioTrack = mediaStream.getAudioTracks()[0] ?? null; 59 if (!audioTrack) throw "no audio track found"; 60 ··· 129 130 setIsRecording(true); 131 setRecordingStart(Date.now()); 132 } catch (error) { 133 console.error("error accessing microphone:", error); 134 toaster.create({ ··· 145 }; 146 147 const stopRecording = () => { 148 - if (!isRecording() || !mediaRecorder) return; 149 if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); 150 setIsRecording(false); 151 }; ··· 162 return `${mins}:${secs.toString().padStart(2, "0")}`; 163 }; 164 165 return ( 166 <Popover.Root positioning={{ placement: "top" }} open={isRecording()}> 167 <Popover.Anchor ··· 171 size="md" 172 variant={isRecording() ? "solid" : "subtle"} 173 colorPalette={isRecording() ? "red" : undefined} 174 - onClick={ 175 - !props.holdToRecord 176 - ? () => (isRecording() ? stopRecording() : startRecording()) 177 - : undefined 178 - } 179 - onMouseDown={props.holdToRecord ? startRecording : undefined} 180 - onMouseUp={props.holdToRecord ? stopRecording : undefined} 181 - onMouseLeave={props.holdToRecord ? stopRecording : undefined} 182 - onTouchStart={ 183 - props.holdToRecord 184 - ? (e) => { 185 - e.preventDefault(); // Prevent mouse emulation 186 - startRecording(); 187 - } 188 - : undefined 189 - } 190 - onTouchEnd={ 191 - props.holdToRecord 192 - ? (e) => { 193 - e.preventDefault(); 194 - stopRecording(); 195 - } 196 - : undefined 197 - } 198 > 199 {isRecording() ? <CircleStopIcon /> : <MicIcon />} 200 </IconButton>
··· 9 10 type MicRecorderProps = { 11 selectedAccount: () => AtprotoDid | undefined; 12 }; 13 14 const MicRecorder = (props: MicRecorderProps) => { ··· 22 let mediaStream: MediaStream | null = null; 23 let audioChunks: Blob[] = []; 24 25 + // Flag to handle case where user releases hold before recording actually starts 26 + let stopRequestPending = false; 27 + 28 const isSafari = 29 typeof navigator !== "undefined" && 30 navigator.vendor && ··· 37 38 const startRecording = async () => { 39 if (isRecording()) return; 40 + stopRequestPending = false; 41 42 try { 43 audioChunks = []; ··· 58 echoCancellation: { ideal: true }, 59 }, 60 }); 61 + 62 + // check if holding stopped while waiting for permission/stream 63 + if (stopRequestPending) { 64 + mediaStream.getTracks().forEach((track) => track.stop()); 65 + mediaStream = null; 66 + return; 67 + } 68 + 69 const audioTrack = mediaStream.getAudioTracks()[0] ?? null; 70 if (!audioTrack) throw "no audio track found"; 71 ··· 140 141 setIsRecording(true); 142 setRecordingStart(Date.now()); 143 + 144 + // delayed hold release 145 + if (stopRequestPending) stopRecording(); 146 } catch (error) { 147 console.error("error accessing microphone:", error); 148 toaster.create({ ··· 159 }; 160 161 const stopRecording = () => { 162 + if (!isRecording() || !mediaRecorder) { 163 + stopRequestPending = true; 164 + return; 165 + } 166 if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); 167 setIsRecording(false); 168 }; ··· 179 return `${mins}:${secs.toString().padStart(2, "0")}`; 180 }; 181 182 + let pressStartTime = 0; 183 + let startedSession = false; 184 + 185 + const handlePointerDown = (e: PointerEvent) => { 186 + if (isRecording()) { 187 + stopRecording(); 188 + startedSession = false; 189 + } else { 190 + startRecording(); 191 + pressStartTime = Date.now(); 192 + startedSession = true; 193 + } 194 + }; 195 + 196 + const handlePointerUp = (e: PointerEvent) => { 197 + if (startedSession) { 198 + const duration = Date.now() - pressStartTime; 199 + if (duration >= 500) stopRecording(); 200 + 201 + startedSession = false; 202 + } 203 + }; 204 + 205 + const handlePointerLeave = (e: PointerEvent) => { 206 + if (startedSession && isRecording()) { 207 + stopRecording(); 208 + startedSession = false; 209 + } 210 + }; 211 + 212 return ( 213 <Popover.Root positioning={{ placement: "top" }} open={isRecording()}> 214 <Popover.Anchor ··· 218 size="md" 219 variant={isRecording() ? "solid" : "subtle"} 220 colorPalette={isRecording() ? "red" : undefined} 221 + onPointerDown={handlePointerDown} 222 + onPointerUp={handlePointerUp} 223 + onPointerLeave={handlePointerLeave} 224 + onContextMenu={(e) => e.preventDefault()} 225 > 226 {isRecording() ? <CircleStopIcon /> : <MicIcon />} 227 </IconButton>
-18
src/components/Settings.tsx
··· 34 backgroundColor as backgroundColorSetting, 35 frameRate as frameRateSetting, 36 useDominantColorAsBg as useDominantColorAsBgSetting, 37 - toggleToRecordSetting, 38 Setting, 39 - toggleToRecord, 40 - setToggleToRecord, 41 } from "~/lib/settings"; 42 import { handleResolver } from "~/lib/at"; 43 import { toaster } from "~/components/Toaster"; ··· 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
··· 34 backgroundColor as backgroundColorSetting, 35 frameRate as frameRateSetting, 36 useDominantColorAsBg as useDominantColorAsBgSetting, 37 Setting, 38 } from "~/lib/settings"; 39 import { handleResolver } from "~/lib/at"; 40 import { toaster } from "~/components/Toaster"; ··· 351 <Drawer.Body> 352 <Stack gap="4"> 353 <Accounts /> 354 <Stack> 355 <FormLabel>processing</FormLabel> 356 <Stack
-14
src/lib/settings.ts
··· 1 - import { createSignal } from "solid-js"; 2 - 3 export const setting = <T>(key: string) => { 4 return { 5 get: () => { ··· 19 export const useDominantColorAsBg = setting<boolean>("useDominantColorAsBg"); 20 export const backgroundColor = setting<string>("backgroundColor"); 21 export const frameRate = setting<number>("frameRate"); 22 - 23 - export const toggleToRecordSetting = setting<boolean>("toggleToRecord"); 24 - const [_toggleToRecord, _setToggleToRecord] = createSignal<boolean>( 25 - toggleToRecordSetting.get() ?? false, 26 - ); 27 - export const toggleToRecord = _toggleToRecord; 28 - export const setToggleToRecord = ( 29 - value: boolean | ((prev: boolean) => boolean), 30 - ) => { 31 - const newAccounts = _setToggleToRecord(value); 32 - toggleToRecordSetting.set(newAccounts); 33 - };
··· 1 export const setting = <T>(key: string) => { 2 return { 3 get: () => { ··· 17 export const useDominantColorAsBg = setting<boolean>("useDominantColorAsBg"); 18 export const backgroundColor = setting<string>("backgroundColor"); 19 export const frameRate = setting<number>("frameRate");