返回 presentation-ai
RecordingControls.tsx
根目录 / src / components / notebook / presentation / recording / RecordingControls.tsx
1 "use client";
2
3 import {
4 Check,
5 ChevronDown,
6 Loader2,
7 Mic,
8 MicOff,
9 Square,
10 Video,
11 VideoOff,
12 } from "lucide-react";
13 import { m as motion } from "motion/react";
14
15 import { Button } from "@/components/ui/button";
16 import { ButtonGroup } from "@/components/ui/button-group";
17 import {
18 Popover,
19 PopoverContent,
20 PopoverTrigger,
21 } from "@/components/ui/popover";
22 import { TooltipProvider } from "@/components/ui/tooltip";
23 import {
24 useMediaDevices,
25 type MediaDevice,
26 } from "@/hooks/presentation/useMediaDevices";
27 import { useRecording } from "@/hooks/presentation/useRecording";
28 import { cn } from "@/lib/utils";
29 import { usePresentationRecordingState } from "@/states/presentation-recording-state";
30
31 const isDeviceSelected = (
32 deviceId: string,
33 selectedId: string | null,
34 devices: MediaDevice[],
35 ) => {
36 // If a device is explicitly selected, check if it matches
37 if (selectedId) {
38 return selectedId === deviceId;
39 }
40 // If no device is selected, mark the first device as default
41 return devices.length > 0 && devices[0]?.deviceId === deviceId;
42 };
43
44 function RecordingControls() {
45 const {
46 isRecording,
47 isStarting,
48 isStopping,
49 webcamEnabled,
50 micEnabled,
51 selectedVideoDeviceId,
52 selectedAudioDeviceId,
53 setWebcamEnabled,
54 setMicEnabled,
55 setSelectedVideoDevice,
56 setSelectedAudioDevice,
57 } = usePresentationRecordingState();
58
59 const { start, stop } = useRecording();
60 const { videoDevices, audioDevices, isLoading } = useMediaDevices();
61
62 return (
63 <TooltipProvider>
64 <motion.div
65 drag
66 dragMomentum={false}
67 className={cn(
68 "fixed z-1000",
69 "rounded-2xl border bg-background/95 backdrop-blur-md",
70 "px-3 py-2.5 shadow-xl",
71 "cursor-move select-none",
72 "bottom-6 left-1/2 -translate-x-1/2",
73 )}
74 initial={{ y: 100, opacity: 0 }}
75 animate={{ y: 0, opacity: 1 }}
76 transition={{ type: "spring", stiffness: 300, damping: 30 }}
77 >
78 <div className="flex items-center gap-2">
79 {/* Video ButtonGroup */}
80 <ButtonGroup className="rounded-full">
81 <Button
82 size="sm"
83 onClick={() => setWebcamEnabled(!webcamEnabled)}
84 className={cn("size-9 p-0")}
85 >
86 {webcamEnabled ? (
87 <Video className="size-4" />
88 ) : (
89 <VideoOff className="size-4 text-red-500" />
90 )}
91 </Button>
92 <Popover>
93 <PopoverTrigger asChild>
94 <Button
95 size="sm"
96 className="h-9 w-8 p-0"
97 aria-label="Select camera"
98 >
99 <ChevronDown className="size-3.5" />
100 </Button>
101 </PopoverTrigger>
102 <PopoverContent
103 align="center"
104 className="z-9999 w-64 rounded-xl border p-0 shadow-lg"
105 side="top"
106 sideOffset={12}
107 >
108 <div className="border-b bg-muted/50 px-3 py-2.5">
109 <div className="text-xs font-semibold text-foreground">
110 Camera
111 </div>
112 </div>
113 <div className="max-h-60 overflow-y-auto py-1">
114 {isLoading ? (
115 <div className="px-3 py-6 text-center text-sm text-muted-foreground">
116 Loading devices…
117 </div>
118 ) : videoDevices.length === 0 ? (
119 <div className="px-3 py-6 text-center text-sm text-muted-foreground">
120 No cameras found
121 </div>
122 ) : (
123 videoDevices.map((device) => (
124 <button
125 type="button"
126 key={device.deviceId}
127 className={cn(
128 "flex w-full items-center gap-2 px-3 py-2 text-left text-sm",
129 "transition-colors hover:bg-accent",
130 "focus-visible:bg-accent focus-visible:outline-none",
131 )}
132 onClick={() => {
133 setSelectedVideoDevice(device.deviceId);
134 }}
135 >
136 <span className="flex-1 truncate text-foreground">
137 {device.label}
138 </span>
139 {isDeviceSelected(
140 device.deviceId,
141 selectedVideoDeviceId,
142 videoDevices,
143 ) && <Check className="size-4 shrink-0 text-primary" />}
144 </button>
145 ))
146 )}
147 </div>
148 </PopoverContent>
149 </Popover>
150 </ButtonGroup>
151
152 {/* Audio ButtonGroup */}
153 <ButtonGroup>
154 <Button
155 size="sm"
156 onClick={() => setMicEnabled(!micEnabled)}
157 className={cn("size-9 p-0")}
158 >
159 {micEnabled ? (
160 <Mic className="size-4" />
161 ) : (
162 <MicOff className="size-4 text-red-500" />
163 )}
164 </Button>
165 <Popover>
166 <PopoverTrigger asChild>
167 <Button
168 size="sm"
169 className="h-9 w-8 p-0"
170 aria-label="Select microphone"
171 >
172 <ChevronDown className="size-3.5" />
173 </Button>
174 </PopoverTrigger>
175 <PopoverContent
176 align="center"
177 className="z-9999 w-64 rounded-xl border p-0 shadow-lg"
178 side="top"
179 sideOffset={12}
180 >
181 <div className="border-b bg-muted/50 px-3 py-2.5">
182 <div className="text-xs font-semibold text-foreground">
183 Microphone
184 </div>
185 </div>
186 <div className="max-h-60 overflow-y-auto py-1">
187 {isLoading ? (
188 <div className="px-3 py-6 text-center text-sm text-muted-foreground">
189 Loading devices…
190 </div>
191 ) : audioDevices.length === 0 ? (
192 <div className="px-3 py-6 text-center text-sm text-muted-foreground">
193 No microphones found
194 </div>
195 ) : (
196 audioDevices.map((device) => (
197 <button
198 type="button"
199 key={device.deviceId}
200 className={cn(
201 "flex w-full items-center gap-2 px-3 py-2 text-left text-sm",
202 "transition-colors hover:bg-accent",
203 "focus-visible:bg-accent focus-visible:outline-none",
204 )}
205 onClick={() => {
206 setSelectedAudioDevice(device.deviceId);
207 }}
208 >
209 <span className="flex-1 truncate text-foreground">
210 {device.label}
211 </span>
212 {isDeviceSelected(
213 device.deviceId,
214 selectedAudioDeviceId,
215 audioDevices,
216 ) && <Check className="size-4 shrink-0 text-primary" />}
217 </button>
218 ))
219 )}
220 </div>
221 </PopoverContent>
222 </Popover>
223 </ButtonGroup>
224
225 <div className="mx-1 h-5 w-px bg-border" />
226
227 {isRecording ? (
228 <Button
229 size="sm"
230 variant="destructive"
231 onClick={() => stop()}
232 disabled={isStopping}
233 className="h-9 gap-2 rounded-lg px-3"
234 >
235 {isStopping ? (
236 <>
237 <Loader2 className="size-3.5 animate-spin" />
238 <span className="text-xs font-medium">Stopping…</span>
239 </>
240 ) : (
241 <>
242 <Square className="size-3.5 fill-current" />
243 <span className="text-xs font-medium">Stop</span>
244 </>
245 )}
246 </Button>
247 ) : (
248 <Button
249 onClick={() => start()}
250 disabled={isStarting}
251 className="h-9 gap-2 rounded-lg px-4"
252 >
253 {isStarting ? (
254 <>
255 <Loader2 className="size-3.5 animate-spin" />
256 <span className="text-xs font-medium">Starting…</span>
257 </>
258 ) : (
259 <>
260 <Video className="size-3.5" />
261 <span className="text-xs font-medium">Start Recording</span>
262 </>
263 )}
264 </Button>
265 )}
266 </div>
267 </motion.div>
268 </TooltipProvider>
269 );
270 }
271
272 export default RecordingControls;
273
273 lines Plain Text