1use crate::engine::capture::{audio_devices, ScreenCaptureOptions, VoiceoverOptions};
15use crate::settings::Settings;
16use crate::theme::Palette;
17use crate::ui::combo;
18use crate::ui::tools::{glyph_text_button, Glyph};
19use eframe::egui;
20use std::path::PathBuf;
21
22#[derive(Default)]
23pub struct CaptureUi {
24 pub screen_open: bool,
25 pub voice_open: bool,
26 pub elapsed: f64,
27 pub region: Option<(i32, i32, u32, u32)>,
29 pub area: String,
31 pub level: f32,
33 pub from_playhead: bool,
35 pub voice_start: Option<f64>,
37}
38
39#[derive(Default)]
41pub struct CaptureResponse {
42 pub start_screen: bool,
43 pub stop_screen: bool,
44 pub start_voice: bool,
45 pub stop_voice: bool,
46 pub screen: Option<ScreenCaptureOptions>,
48 pub voice: Option<VoiceoverOptions>,
49 pub auto_on_blur: bool,
51 pub retake: bool,
53}
54
55const AREAS: [(&str, &str); 2] = [("desktop", "Whole desktop"), ("region", "Region")];
56
57pub fn capture_dir(settings: &Settings) -> PathBuf {
59 if !settings.capture_dir.trim().is_empty() {
60 return PathBuf::from(settings.capture_dir.trim());
61 }
62 let videos = std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("Videos"));
63 match videos {
64 Some(v) if v.is_dir() => v,
65 _ => std::env::temp_dir(),
66 }
67}
68
69fn out_path(settings: &Settings, prefix: &str, ext: &str) -> PathBuf {
70 capture_dir(settings).join(format!("{prefix}-{}.{ext}", Settings::now()))
71}
72
73pub fn show(
74 ctx: &egui::Context,
75 state: &mut CaptureUi,
76 settings: &mut Settings,
77 recording_screen: bool,
78 recording_voice: bool,
79 palette: &Palette,
80) -> CaptureResponse {
81 let mut r = CaptureResponse { auto_on_blur: settings.capture_on_blur, ..Default::default() };
83 if state.area.is_empty() {
84 state.area = "desktop".into();
85 }
86 if state.screen_open {
87 screen_window(ctx, state, settings, recording_screen, &mut r);
88 }
89 if state.voice_open {
90 voice_window(ctx, state, settings, recording_voice, palette, &mut r);
91 }
92 r
93}
94
95fn screen_window(
96 ctx: &egui::Context,
97 state: &mut CaptureUi,
98 settings: &mut Settings,
99 recording: bool,
100 r: &mut CaptureResponse,
101) {
102 let devices = audio_devices();
103 let has_loopback = devices.iter().any(|(_, lb)| *lb);
104 let mut open = state.screen_open;
105 egui::Window::new("Screen Recording").open(&mut open).resizable(false).default_width(320.0).show(ctx, |ui| {
106 ui.add_enabled_ui(!recording, |ui| {
107 egui::Grid::new("cap_opts").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
108 ui.label("Folder");
109 ui.horizontal(|ui| {
110 let mut dir = capture_dir(settings).to_string_lossy().into_owned();
111 if ui.add(egui::TextEdit::singleline(&mut dir).desired_width(160.0)).changed() {
112 settings.capture_dir = dir.clone();
113 }
114 if ui.small_button("…").clicked() {
115 if let Some(p) = rfd::FileDialog::new().set_directory(&dir).pick_folder() {
116 settings.capture_dir = p.to_string_lossy().into_owned();
117 }
118 }
119 });
120 ui.end_row();
121
122 ui.label("Frame rate");
123 ui.add(egui::DragValue::new(&mut settings.capture_fps).range(1..=120).suffix(" fps"));
124 ui.end_row();
125
126 ui.label("Bitrate");
127 ui.horizontal(|ui| {
128 ui.add(
129 egui::DragValue::new(&mut settings.capture_bitrate_kbps).range(0..=200_000).suffix(" kbit/s"),
130 );
131 if settings.capture_bitrate_kbps == 0 {
132 ui.weak(format!("quality CRF {}", settings.crf));
133 }
134 });
135 ui.end_row();
136
137 ui.label("Capture");
138 combo(ui, "cap_area", &mut state.area, &AREAS, Some(110.0));
139 ui.end_row();
140
141 if state.area == "region" {
142 let (mut x, mut y, mut w, mut h) = state.region.unwrap_or((0, 0, 1280, 720));
145 ui.label("Region");
146 ui.horizontal(|ui| {
147 ui.add(egui::DragValue::new(&mut x).prefix("x ").speed(2.0));
148 ui.add(egui::DragValue::new(&mut y).prefix("y ").speed(2.0));
149 ui.add(egui::DragValue::new(&mut w).prefix("w ").range(16..=16384).speed(2.0));
150 ui.add(egui::DragValue::new(&mut h).prefix("h ").range(16..=16384).speed(2.0));
151 });
152 state.region = Some((x, y, w, h));
153 ui.end_row();
154 }
155
156 ui.label("Microphone");
157 let mut opts: Vec<(&str, &str)> = vec![("", "None")];
158 opts.extend(devices.iter().map(|(n, _)| (n.as_str(), n.as_str())));
159 combo(ui, "cap_mic", &mut settings.capture_mic, &opts, Some(180.0));
160 ui.end_row();
161 });
162 ui.add_enabled_ui(has_loopback, |ui| {
163 ui.checkbox(&mut settings.capture_desktop_audio, "Desktop audio").on_disabled_hover_text(
164 "No loopback input found — enable \"Stereo Mix\" in Windows sound settings.",
165 );
166 });
167 ui.checkbox(&mut settings.capture_cursor, "Record the mouse cursor");
168 if ui.checkbox(&mut settings.capture_on_blur, "Record when the editor loses focus").changed() {
169 settings.save();
170 }
171 r.auto_on_blur = settings.capture_on_blur;
172 });
173 ui.separator();
174 ui.horizontal(|ui| {
175 if recording {
176 crate::ui::tools::glyph_label(ui, Glyph::Record, ui.visuals().error_fg_color);
177 ui.colored_label(ui.visuals().error_fg_color, "REC");
178 ui.label(clock(state.elapsed));
179 if ui.button("Stop").clicked() {
180 r.stop_screen = true;
181 }
182 } else if glyph_text_button(ui, Glyph::Record, "Record").clicked() {
183 r.screen = Some(screen_options(settings, state, out_path(settings, "screen", "mp4"), has_loopback));
184 r.start_screen = true;
185 settings.save();
186 }
187 });
188 ui.weak("The file is added to the library when the recording stops.");
189 });
190 state.screen_open = open;
191}
192
193pub(crate) fn screen_options(
195 settings: &Settings,
196 state: &CaptureUi,
197 out: PathBuf,
198 has_loopback: bool,
199) -> ScreenCaptureOptions {
200 ScreenCaptureOptions {
201 out,
202 fps: settings.capture_fps.clamp(1, 120),
203 bitrate_kbps: settings.capture_bitrate_kbps,
204 crf: settings.crf,
205 region: if state.area == "region" { state.region } else { None },
206 mic: settings.capture_mic.clone(),
207 desktop_audio: settings.capture_desktop_audio && has_loopback,
208 cursor: settings.capture_cursor,
209 }
210}
211
212pub(crate) fn voice_options(settings: &Settings) -> VoiceoverOptions {
214 VoiceoverOptions {
215 out: out_path(settings, "voice", "wav"),
216 device: settings.voice_device.clone(),
217 sample_rate: 48_000,
218 channels: settings.voice_channels.clamp(1, 2),
219 }
220}
221
222fn voice_window(
223 ctx: &egui::Context,
224 state: &mut CaptureUi,
225 settings: &mut Settings,
226 recording: bool,
227 palette: &Palette,
228 r: &mut CaptureResponse,
229) {
230 let devices = audio_devices();
231 let mut open = state.voice_open;
232 egui::Window::new("Voiceover").open(&mut open).resizable(false).default_width(280.0).show(ctx, |ui| {
233 ui.add_enabled_ui(!recording, |ui| {
234 egui::Grid::new("vo_opts").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
235 ui.label("Input");
236 let opts: Vec<(&str, &str)> = devices.iter().map(|(n, _)| (n.as_str(), n.as_str())).collect();
237 combo(ui, "vo_dev", &mut settings.voice_device, &opts, Some(170.0));
238 ui.end_row();
239 ui.label("Channels");
240 ui.horizontal(|ui| {
241 ui.selectable_value(&mut settings.voice_channels, 1, "Mono");
242 ui.selectable_value(&mut settings.voice_channels, 2, "Stereo");
243 });
244 ui.end_row();
245 });
246 ui.checkbox(&mut state.from_playhead, "Record from the playhead");
247 });
248 meter(ui, state.level, palette);
249 ui.horizontal(|ui| {
250 if recording {
251 crate::ui::tools::glyph_label(ui, Glyph::Record, ui.visuals().error_fg_color);
252 ui.colored_label(ui.visuals().error_fg_color, "REC");
253 ui.label(clock(state.elapsed));
254 if ui.button("Stop").clicked() {
255 r.stop_voice = true;
256 }
257 } else {
258 let can = !settings.voice_device.is_empty();
259 if ui
260 .add_enabled_ui(can, |ui| glyph_text_button(ui, Glyph::Mic, "Record"))
261 .inner
262 .on_disabled_hover_text("Pick an input.")
263 .clicked()
264 {
265 r.voice = Some(voice_options(settings));
266 r.start_voice = true;
267 settings.save();
268 }
269 if state.voice_start.is_some() && ui.button("Retake").clicked() {
270 r.retake = true;
271 r.start_voice = true;
272 r.voice = Some(voice_options(settings));
273 }
274 }
275 });
276 if state.from_playhead {
277 ui.weak("Playback rolls while recording; the take lands at the playhead.");
278 }
279 });
280 state.voice_open = open;
281}
282
283fn meter(ui: &mut egui::Ui, level: f32, palette: &Palette) {
285 let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width().min(240.0), 8.0), egui::Sense::hover());
286 let p = ui.painter();
287 p.rect_filled(rect, 0, palette.panel);
288 let l = level.clamp(0.0, 1.0);
289 if l > 0.0 {
290 let mut filled = rect;
291 filled.set_width(rect.width() * l);
292 p.rect_filled(filled, 0, if l > 0.95 { palette.playhead } else { palette.waveform });
293 }
294 p.rect_stroke(rect, 0, egui::Stroke::new(1.0, palette.border), egui::StrokeKind::Inside);
295}
296
297fn clock(secs: f64) -> String {
299 let s = secs.max(0.0);
300 format!("{}:{:04.1}", (s / 60.0).floor() as u64, s % 60.0)
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn state() -> CaptureUi {
308 CaptureUi { screen_open: true, voice_open: true, area: "desktop".into(), ..Default::default() }
309 }
310
311 #[test]
312 fn options_follow_the_settings() {
313 let mut s = Settings::default();
314 s.capture_fps = 240; s.capture_mic = "Mic".into();
316 let mut st = state();
317 st.region = Some((10, 20, 640, 480));
318 let o = screen_options(&s, &st, PathBuf::from("a.mp4"), false);
319 assert_eq!(o.fps, 120);
320 assert_eq!(o.region, None, "the region only applies in region mode");
321 assert_eq!(o.mic, "Mic");
322 assert!(!o.desktop_audio, "no loopback device → no desktop audio, whatever the setting says");
323 st.area = "region".into();
324 let o = screen_options(&s, &st, PathBuf::from("a.mp4"), true);
325 assert_eq!(o.region, Some((10, 20, 640, 480)));
326 assert!(o.desktop_audio);
327 }
328
329 #[test]
331 fn show_headless() {
332 let ctx = egui::Context::default();
333 let palette = Palette::new(false, egui::Color32::BLUE);
334 let mut s = Settings::default();
335 for area in ["desktop", "region"] {
336 for rec in [false, true] {
337 let mut st = CaptureUi { area: area.into(), elapsed: 3.5, ..state() };
338 let mut r = CaptureResponse::default();
339 let _ = ctx.run(egui::RawInput::default(), |ctx| r = show(ctx, &mut st, &mut s, rec, rec, &palette));
340 assert!(!r.start_screen && !r.stop_screen && !r.start_voice && !r.stop_voice);
341 assert!(r.screen.is_none() && r.voice.is_none() && !r.retake);
342 assert_eq!(r.auto_on_blur, s.capture_on_blur);
343 assert!(st.screen_open && st.voice_open);
344 }
345 }
346 let mut st = CaptureUi { area: "region".into(), ..state() };
348 let _ = ctx.run(egui::RawInput::default(), |ctx| {
349 show(ctx, &mut st, &mut s, false, false, &palette);
350 });
351 assert!(st.region.is_some(), "region mode always has a concrete region");
352 }
353
354 #[test]
355 fn clock_text() {
356 assert_eq!(clock(0.0), "0:00.0");
357 assert_eq!(clock(65.25), "1:05.2");
358 assert_eq!(clock(-1.0), "0:00.0");
359 }
360
361 #[test]
362 fn capture_dir_falls_back() {
363 let mut s = Settings::default();
364 s.capture_dir = r"C:\shots ".into();
365 assert_eq!(capture_dir(&s), PathBuf::from(r"C:\shots"));
366 s.capture_dir = " ".into();
367 assert!(capture_dir(&s).is_dir(), "the fallback is always a real folder");
368 }
369}