Skip to content

Commit e90083a

Browse files
feat: recording UI - record, play, list, delete (#38)
Part 3/4 of audio recording feature stack. ## Changes - Zustand store for recording state management - useAudioRecorder hook with MediaRecorder API - useRecordings hook for data fetching - AudioPlayer with playback speed control - RecordingsList with keyboard navigation - RecordingDetail view - Keyboard shortcuts: R (record), S (stop), Del (delete), Esc (close), ↑/↓ (navigate) - Split view UI layout ## Stack - PR 1/4: Foundation - PR 2/4: Recording service - **PR 3/4: Recording UI** ← You are here - PR 4/4: Transcription & AI ## Review notes Full end-to-end recording flow works: record → save → play → delete. No transcription UI yet.
1 parent e32cffd commit e90083a

File tree

13 files changed

+1448
-2
lines changed

13 files changed

+1448
-2
lines changed

src/renderer/components/command/CommandMenu.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MicrophoneIcon } from "@phosphor-icons/react";
12
import { FileTextIcon, ListBulletIcon } from "@radix-ui/react-icons";
23
import { Flex, Text } from "@radix-ui/themes";
34
import { useCallback, useEffect, useRef } from "react";
@@ -81,6 +82,19 @@ export function CommandMenu({
8182
onOpenChange(false);
8283
};
8384

85+
const handleNavigateToRecordings = () => {
86+
const recordingsTab = tabs.find((tab) => tab.type === "recordings");
87+
if (recordingsTab) {
88+
setActiveTab(recordingsTab.id);
89+
} else {
90+
createTab({
91+
type: "recordings",
92+
title: "Recordings",
93+
});
94+
}
95+
onOpenChange(false);
96+
};
97+
8498
const handleCreateTask = () => {
8599
onOpenChange(false);
86100
onCreateTask?.();
@@ -148,6 +162,14 @@ export function CommandMenu({
148162
<ListBulletIcon className="mr-3 h-4 w-4 text-gray-11" />
149163
<Text size="2">Go to tasks</Text>
150164
</Command.Item>
165+
166+
<Command.Item
167+
value="Go to recordings"
168+
onSelect={handleNavigateToRecordings}
169+
>
170+
<MicrophoneIcon className="mr-3 h-4 w-4 text-gray-11" />
171+
<Text size="2">Go to recordings</Text>
172+
</Command.Item>
151173
</Command.Group>
152174

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