+3
-16
src/App.tsx
+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
+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
-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
-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
-
};