Skip to content

Commit 1093438

Browse files
committed
feat: recording UI - record, play, list, delete
- Zustand store for recording state - useAudioRecorder hook with MediaRecorder - useRecordings hook for data fetching - AudioPlayer with playback speed control - RecordingsList with keyboard navigation - RecordingDetail view - Keyboard shortcuts (R, S, Del, Esc, ↑/↓) - Split view UI layout
1 parent 0c604bc commit 1093438

File tree

10 files changed

+1389
-2
lines changed

10 files changed

+1389
-2
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { FastForward, Pause, Play, Rewind } from "@phosphor-icons/react";
2+
import { Box, Button, Flex, Text } from "@radix-ui/themes";
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { useHotkeys } from "react-hotkeys-hook";
5+
6+
interface AudioPlayerProps {
7+
recordingId: string;
8+
duration: number;
9+
}
10+
11+
function formatTime(seconds: number): string {
12+
const mins = Math.floor(seconds / 60);
13+
const secs = Math.floor(seconds % 60);
14+
return `${mins}:${secs.toString().padStart(2, "0")}`;
15+
}
16+
17+
export function AudioPlayer({ recordingId, duration }: AudioPlayerProps) {
18+
const [isPlaying, setIsPlaying] = useState(false);
19+
const [currentTime, setCurrentTime] = useState(0);
20+
const [playbackRate, setPlaybackRate] = useState(1);
21+
const [isReady, setIsReady] = useState(false);
22+
const audioRef = useRef<HTMLAudioElement | null>(null);
23+
const audioUrlRef = useRef<string | null>(null);
24+
25+
useEffect(() => {
26+
let mounted = true;
27+
setIsReady(false);
28+
29+
const loadAudio = async () => {
30+
try {
31+
const buffer = await window.electronAPI.recordingGetFile(recordingId);
32+
if (!mounted) return;
33+
34+
const blob = new Blob([buffer], { type: "audio/webm" });
35+
const url = URL.createObjectURL(blob);
36+
audioUrlRef.current = url;
37+
38+
const audio = new Audio(url);
39+
40+
// Wait for metadata to load
41+
await new Promise<void>((resolve) => {
42+
audio.addEventListener("loadedmetadata", () => resolve(), {
43+
once: true,
44+
});
45+
});
46+
47+
if (!mounted) return;
48+
49+
audioRef.current = audio;
50+
51+
audio.addEventListener("timeupdate", () => {
52+
if (mounted) setCurrentTime(audio.currentTime);
53+
});
54+
55+
audio.addEventListener("ended", () => {
56+
if (mounted) {
57+
setIsPlaying(false);
58+
setCurrentTime(0);
59+
}
60+
});
61+
62+
audio.addEventListener("pause", () => {
63+
if (mounted) setIsPlaying(false);
64+
});
65+
66+
audio.addEventListener("play", () => {
67+
if (mounted) setIsPlaying(true);
68+
});
69+
70+
setIsReady(true);
71+
} catch (error) {
72+
console.error("Failed to load audio:", error);
73+
}
74+
};
75+
76+
loadAudio();
77+
78+
return () => {
79+
mounted = false;
80+
if (audioRef.current) {
81+
audioRef.current.pause();
82+
audioRef.current.src = "";
83+
}
84+
if (audioUrlRef.current) {
85+
URL.revokeObjectURL(audioUrlRef.current);
86+
}
87+
};
88+
}, [recordingId]);
89+
90+
const togglePlayPause = useCallback(() => {
91+
if (!audioRef.current) return;
92+
93+
if (isPlaying) {
94+
audioRef.current.pause();
95+
} else {
96+
audioRef.current.play();
97+
}
98+
}, [isPlaying]);
99+
100+
const cyclePlaybackRate = useCallback(() => {
101+
if (!audioRef.current) return;
102+
const rates = [1, 1.25, 1.5, 2];
103+
const currentIndex = rates.indexOf(playbackRate);
104+
const nextRate = rates[(currentIndex + 1) % rates.length];
105+
audioRef.current.playbackRate = nextRate;
106+
setPlaybackRate(nextRate);
107+
}, [playbackRate]);
108+
109+
const skipBackward = useCallback(() => {
110+
if (!audioRef.current) return;
111+
audioRef.current.currentTime = Math.max(
112+
0,
113+
audioRef.current.currentTime - 10,
114+
);
115+
}, []);
116+
117+
const skipForward = useCallback(() => {
118+
if (!audioRef.current) return;
119+
audioRef.current.currentTime = Math.min(
120+
duration,
121+
audioRef.current.currentTime + 10,
122+
);
123+
}, [duration]);
124+
125+
useHotkeys(
126+
"space",
127+
(e) => {
128+
e.preventDefault();
129+
togglePlayPause();
130+
},
131+
{ enableOnFormTags: false },
132+
[togglePlayPause],
133+
);
134+
135+
return (
136+
<Flex direction="column" gap="3">
137+
<Flex align="center" gap="2">
138+
<Button
139+
size="1"
140+
variant="ghost"
141+
color="gray"
142+
onClick={skipBackward}
143+
disabled={!isReady}
144+
title="Skip backward 10s"
145+
>
146+
<Rewind weight="fill" size={16} />
147+
</Button>
148+
149+
<Button
150+
size="2"
151+
variant="soft"
152+
color={isPlaying ? "blue" : "gray"}
153+
onClick={togglePlayPause}
154+
disabled={!isReady}
155+
>
156+
{isPlaying ? <Pause weight="fill" /> : <Play weight="fill" />}
157+
</Button>
158+
159+
<Button
160+
size="1"
161+
variant="ghost"
162+
color="gray"
163+
onClick={skipForward}
164+
disabled={!isReady}
165+
title="Skip forward 10s"
166+
>
167+
<FastForward weight="fill" size={16} />
168+
</Button>
169+
170+
<Box style={{ flex: 1 }} />
171+
172+
<Flex gap="2" align="center">
173+
<Text
174+
size="1"
175+
color="gray"
176+
style={{ minWidth: "80px", textAlign: "right" }}
177+
>
178+
{formatTime(currentTime)} / {formatTime(duration)}
179+
</Text>
180+
<Button
181+
size="1"
182+
variant="ghost"
183+
color="gray"
184+
onClick={cyclePlaybackRate}
185+
disabled={!isReady}
186+
style={{ minWidth: "40px" }}
187+
>
188+
<Text size="1">{playbackRate}x</Text>
189+
</Button>
190+
</Flex>
191+
</Flex>
192+
</Flex>
193+
);
194+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { CircleIcon } from "@phosphor-icons/react";
2+
import { Button, Flex, Kbd, Text } from "@radix-ui/themes";
3+
4+
interface RecordingControlsProps {
5+
isRecording: boolean;
6+
recordingDuration: number;
7+
onStartRecording: () => void;
8+
onStopRecording: () => void;
9+
}
10+
11+
function formatDuration(seconds: number): string {
12+
const mins = Math.floor(seconds / 60);
13+
const secs = Math.floor(seconds % 60);
14+
return `${mins}:${secs.toString().padStart(2, "0")}`;
15+
}
16+
17+
export function RecordingControls({
18+
isRecording,
19+
recordingDuration,
20+
onStartRecording,
21+
onStopRecording,
22+
}: RecordingControlsProps) {
23+
return (
24+
<Flex gap="3" align="center">
25+
{isRecording && (
26+
<Flex align="center" gap="2">
27+
<CircleIcon
28+
size={12}
29+
weight="fill"
30+
className="animate-pulse text-red-500"
31+
/>
32+
<Text size="2" color="gray">
33+
{formatDuration(recordingDuration)}
34+
</Text>
35+
</Flex>
36+
)}
37+
<Button
38+
size="2"
39+
variant="soft"
40+
color={isRecording ? "red" : "blue"}
41+
onClick={isRecording ? onStopRecording : onStartRecording}
42+
>
43+
<Flex align="center" gap="2">
44+
<Text size="2">
45+
{isRecording ? "Stop Recording" : "Start Recording"}
46+
</Text>
47+
<Kbd size="1">{isRecording ? "S" : "R"}</Kbd>
48+
</Flex>
49+
</Button>
50+
</Flex>
51+
);
52+
}

0 commit comments

Comments
 (0)