diff --git a/src/bin/scythe-daemon.rs b/src/bin/scythe-daemon.rs index 5a77f87..0a56fa3 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/bin/scythe-daemon.rs @@ -89,6 +89,7 @@ async fn main() -> Result<(), Box> { let record_start_state = Arc::new(AtomicU64::new(0)); let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled)); let audio_muted_state = Arc::new(AtomicBool::new(false)); + let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new()); #[cfg(unix)] let listener = { @@ -122,6 +123,7 @@ async fn main() -> Result<(), Box> { let record_start_state_ipc = Arc::clone(&record_start_state); let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state); let audio_muted_state_ipc = Arc::clone(&audio_muted_state); + let audio_levels_ipc = Arc::clone(&audio_levels); std::thread::spawn(move || { for stream in listener.incoming() { @@ -132,6 +134,7 @@ async fn main() -> Result<(), Box> { let record_start_state_ipc = Arc::clone(&record_start_state_ipc); let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state_ipc); let audio_muted_state_ipc = Arc::clone(&audio_muted_state_ipc); + let audio_levels_ipc = Arc::clone(&audio_levels_ipc); std::thread::spawn(move || { let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(1500))); @@ -162,6 +165,8 @@ async fn main() -> Result<(), Box> { show_cursor: cfg.show_cursor, mic_volume: cfg.mic_volume, system_volume: cfg.system_volume, + mic_level_peak: audio_levels_ipc.get_mic_peak(), + system_level_peak: audio_levels_ipc.get_system_peak(), }; if let Ok(resp) = serde_json::to_vec(&status) { let len_resp = (resp.len() as u32).to_le_bytes(); @@ -233,12 +238,13 @@ async fn main() -> Result<(), Box> { let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string()); println!("Detected session type: {}", session_type); - let audio_capture_initial = scythe::capture::audio::AudioCapture::new_with_device_mode_and_volumes( + let audio_capture_initial = scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels( audio_tx.clone(), Some(&initial_config.audio_device), &initial_config.audio_mode, initial_config.mic_volume, initial_config.system_volume, + Some(Arc::clone(&audio_levels)), ).ok(); let mut source: Box = if std::env::args().any(|a| a == "--mock") { @@ -444,12 +450,13 @@ async fn main() -> Result<(), Box> { println!("Replay buffer resized to {} packets.", new_capacity); } scythe::hyprland_binds::register_hyprland_binds(&config); - audio_capture = match scythe::capture::audio::AudioCapture::new_with_device_mode_and_volumes( + audio_capture = match scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels( audio_tx_clone.clone(), Some(&config.audio_device), &config.audio_mode, config.mic_volume, config.system_volume, + Some(Arc::clone(&audio_levels)), ) { Ok((c, _, _)) => { println!("Audio capture reloaded for mode: {} (mic: {:.0}%, sys: {:.0}%)", config.audio_mode, config.mic_volume * 100.0, config.system_volume * 100.0); diff --git a/src/capture/audio.rs b/src/capture/audio.rs index 4748c07..806e8fa 100644 --- a/src/capture/audio.rs +++ b/src/capture/audio.rs @@ -1,12 +1,64 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use crossbeam_channel::Sender; use std::collections::VecDeque; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; use std::thread; +#[derive(Debug, Default)] +pub struct AudioLevels { + mic_peak: AtomicU32, + system_peak: AtomicU32, +} + +impl AudioLevels { + pub fn new() -> Self { + Self { + mic_peak: AtomicU32::new(0), + system_peak: AtomicU32::new(0), + } + } + + pub fn update_mic(&self, samples: &[f32]) { + let peak = samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())); + self.update_val(&self.mic_peak, peak); + } + + pub fn update_system(&self, samples: &[f32]) { + let peak = samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())); + self.update_val(&self.system_peak, peak); + } + + fn update_val(&self, atomic: &AtomicU32, peak: f32) { + let current = f32::from_bits(atomic.load(Ordering::Relaxed)); + let next = if peak >= current { + peak.min(1.0) + } else { + (current * 0.82 + peak * 0.18).max(0.0) + }; + atomic.store(next.to_bits(), Ordering::Relaxed); + } + + pub fn get_mic_peak(&self) -> f32 { + let current = f32::from_bits(self.mic_peak.load(Ordering::Relaxed)); + let decayed = (current * 0.90).max(0.0); + self.mic_peak.store(decayed.to_bits(), Ordering::Relaxed); + current + } + + pub fn get_system_peak(&self) -> f32 { + let current = f32::from_bits(self.system_peak.load(Ordering::Relaxed)); + let decayed = (current * 0.90).max(0.0); + self.system_peak.store(decayed.to_bits(), Ordering::Relaxed); + current + } +} + pub struct AudioCapture { #[cfg(unix)] _processes: Vec, _streams: Vec, + pub levels: Arc, } impl Drop for AudioCapture { @@ -54,6 +106,31 @@ pub fn list_input_devices() -> Vec { names } +pub fn list_application_audio() -> Vec { + let mut apps = Vec::new(); + #[cfg(unix)] + { + if let Ok(out) = std::process::Command::new("pactl").args(["list", "sink-inputs"]).output() { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("application.name = ") { + let name = rest.trim_matches('"').trim(); + if !name.is_empty() && !apps.iter().any(|a: &String| a.eq_ignore_ascii_case(name)) { + apps.push(name.to_string()); + } + } else if let Some(rest) = trimmed.strip_prefix("media.name = ") { + let name = rest.trim_matches('"').trim(); + if !name.is_empty() && !apps.iter().any(|a: &String| a.eq_ignore_ascii_case(name)) { + apps.push(name.to_string()); + } + } + } + } + } + apps +} + #[inline] pub fn soft_limit(x: f32) -> f32 { if x.abs() <= 0.75 { @@ -81,6 +158,8 @@ fn spawn_parec_stream( device: &str, sender: Sender>, gain: f32, + levels: Option>, + is_mic: bool, ) -> Result> { use std::io::Read; @@ -132,6 +211,14 @@ fn spawn_parec_stream( remainder.clear(); remainder.extend_from_slice(&combined[full_bytes..]); + if let Some(ref l) = levels { + if is_mic { + l.update_mic(&floats); + } else { + l.update_system(&floats); + } + } + if sender.send(floats).is_err() { break; } @@ -146,14 +233,14 @@ fn spawn_parec_stream( impl AudioCapture { pub fn new(sender: Sender>) -> Result<(Self, u32, u16), Box> { - Self::new_with_device_mode_and_volumes(sender, None, "system", 0.60, 1.0) + Self::new_with_device_mode_volumes_and_levels(sender, None, "system", 0.60, 1.0, None) } pub fn new_with_device( sender: Sender>, device_name: Option<&str>, ) -> Result<(Self, u32, u16), Box> { - Self::new_with_device_mode_and_volumes(sender, device_name, "system", 0.60, 1.0) + Self::new_with_device_mode_volumes_and_levels(sender, device_name, "system", 0.60, 1.0, None) } pub fn new_with_device_and_mode( @@ -161,7 +248,7 @@ impl AudioCapture { device_name: Option<&str>, audio_mode: &str, ) -> Result<(Self, u32, u16), Box> { - Self::new_with_device_mode_and_volumes(sender, device_name, audio_mode, 0.60, 1.0) + Self::new_with_device_mode_volumes_and_levels(sender, device_name, audio_mode, 0.60, 1.0, None) } pub fn new_with_device_mode_and_volumes( @@ -171,12 +258,26 @@ impl AudioCapture { mic_volume: f32, system_volume: f32, ) -> Result<(Self, u32, u16), Box> { + Self::new_with_device_mode_volumes_and_levels(sender, device_name, audio_mode, mic_volume, system_volume, None) + } + + pub fn new_with_device_mode_volumes_and_levels( + sender: Sender>, + device_name: Option<&str>, + audio_mode: &str, + mic_volume: f32, + system_volume: f32, + levels: Option>, + ) -> Result<(Self, u32, u16), Box> { + let levels = levels.unwrap_or_else(|| Arc::new(AudioLevels::new())); + if audio_mode == "muted" { println!("Audio capture mode: MUTED (No audio recorded)"); return Ok((Self { #[cfg(unix)] _processes: Vec::new(), _streams: Vec::new(), + levels, }, 48000, 2)); } @@ -188,22 +289,23 @@ impl AudioCapture { let mic_target = device_name .filter(|d| *d != "default" && !d.trim().is_empty()) .unwrap_or("@DEFAULT_SOURCE@"); - let child = spawn_parec_stream(mic_target, sender, mic_volume)?; + let child = spawn_parec_stream(mic_target, sender, mic_volume, Some(Arc::clone(&levels)), true)?; println!("Audio capture mode: MICROPHONE ONLY [{}] (vol: {:.0}%, 48000 Hz, 2 ch)", mic_target, mic_volume * 100.0); return Ok((Self { _processes: vec![child], _streams: Vec::new(), + levels, }, 48000, 2)); } "both" => { let (sys_tx, sys_rx) = crossbeam_channel::bounded::>(100); let (mic_tx, mic_rx) = crossbeam_channel::bounded::>(100); - let sys_child = spawn_parec_stream("@DEFAULT_MONITOR@", sys_tx, system_volume)?; + let sys_child = spawn_parec_stream("@DEFAULT_MONITOR@", sys_tx, system_volume, Some(Arc::clone(&levels)), false)?; let mic_target = device_name .filter(|d| *d != "default" && !d.trim().is_empty()) .unwrap_or("@DEFAULT_SOURCE@"); - let mic_child = spawn_parec_stream(mic_target, mic_tx, mic_volume)?; + let mic_child = spawn_parec_stream(mic_target, mic_tx, mic_volume, Some(Arc::clone(&levels)), true)?; let out_tx = sender; thread::spawn(move || { @@ -243,15 +345,17 @@ impl AudioCapture { return Ok((Self { _processes: vec![sys_child, mic_child], _streams: Vec::new(), + levels, }, 48000, 2)); } _ => { // System audio only - let child = spawn_parec_stream("@DEFAULT_MONITOR@", sender, system_volume)?; + let child = spawn_parec_stream("@DEFAULT_MONITOR@", sender, system_volume, Some(Arc::clone(&levels)), false)?; println!("Audio capture mode: SYSTEM AUDIO ONLY [@DEFAULT_MONITOR@] (vol: {:.0}%, 48000 Hz, 2 ch)", system_volume * 100.0); return Ok((Self { _processes: vec![child], _streams: Vec::new(), + levels, }, 48000, 2)); } } @@ -294,13 +398,16 @@ impl AudioCapture { host.default_input_device() }; - let build_stream = |dev: &cpal::Device, tx: Sender>, gain: f32| -> Result<(cpal::Stream, u32, u16), Box> { + let levels_for_cpal = Arc::clone(&levels); + let build_stream = move |dev: &cpal::Device, tx: Sender>, gain: f32, is_mic: bool| -> Result<(cpal::Stream, u32, u16), Box> { let config = dev.default_input_config()?; let sample_rate = config.sample_rate(); let channels = config.channels(); let format = config.sample_format(); let err_fn = |err| eprintln!("Audio stream error: {}", err); let stream_config: cpal::StreamConfig = config.into(); + let lvl_f32 = Arc::clone(&levels_for_cpal); + let lvl_i16 = Arc::clone(&levels_for_cpal); let stream = match format { cpal::SampleFormat::F32 => { @@ -308,6 +415,11 @@ impl AudioCapture { stream_config, move |data: &[f32], _: &_| { let f32_data: Vec = data.iter().map(|&s| soft_limit(s * gain)).collect(); + if is_mic { + lvl_f32.update_mic(&f32_data); + } else { + lvl_f32.update_system(&f32_data); + } let _ = tx.try_send(f32_data); }, err_fn, @@ -319,6 +431,11 @@ impl AudioCapture { stream_config, move |data: &[i16], _: &_| { let f32_data: Vec = data.iter().map(|&s| soft_limit((s as f32 / i16::MAX as f32) * gain)).collect(); + if is_mic { + lvl_i16.update_mic(&f32_data); + } else { + lvl_i16.update_system(&f32_data); + } let _ = tx.try_send(f32_data); }, err_fn, @@ -335,12 +452,13 @@ impl AudioCapture { "mic" => { let dev = find_mic_device().ok_or("No microphone device found")?; let name = get_device_name(&dev); - let (stream, sr, ch) = build_stream(&dev, sender, mic_volume)?; + let (stream, sr, ch) = build_stream(&dev, sender, mic_volume, true)?; println!("Audio capture mode: MICROPHONE ONLY [{}] ({} Hz, {} ch)", name, sr, ch); Ok((Self { #[cfg(unix)] _processes: Vec::new(), _streams: vec![stream], + levels, }, sr, ch)) } "both" => { @@ -353,8 +471,8 @@ impl AudioCapture { let (sys_tx, sys_rx) = crossbeam_channel::bounded::>(100); let (mic_tx, mic_rx) = crossbeam_channel::bounded::>(100); - let (sys_stream, sr, ch) = build_stream(&sys_dev, sys_tx, system_volume)?; - let (mic_stream, _, _) = build_stream(&mic_dev, mic_tx, mic_volume)?; + let (sys_stream, sr, ch) = build_stream(&sys_dev, sys_tx, system_volume, false)?; + let (mic_stream, _, _) = build_stream(&mic_dev, mic_tx, mic_volume, true)?; let out_tx = sender; thread::spawn(move || { @@ -395,17 +513,19 @@ impl AudioCapture { #[cfg(unix)] _processes: Vec::new(), _streams: vec![sys_stream, mic_stream], + levels, }, sr, ch)) } _ => { let dev = find_system_device().ok_or("No audio device available")?; let name = get_device_name(&dev); - let (stream, sr, ch) = build_stream(&dev, sender, system_volume)?; + let (stream, sr, ch) = build_stream(&dev, sender, system_volume, false)?; println!("Audio capture mode: SYSTEM SOUNDS ONLY [{}] ({} Hz, {} ch)", name, sr, ch); Ok((Self { #[cfg(unix)] _processes: Vec::new(), _streams: vec![stream], + levels, }, sr, ch)) } } diff --git a/src/encoder.rs b/src/encoder.rs index 46fa132..2e175d3 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -67,7 +67,11 @@ impl VaapiEncoder { (*codec_ctx).rc_buffer_size = (rate / 2) as i32; (*codec_ctx).qmin = 16; (*codec_ctx).qmax = 28; // Prevent compression blockiness - (*codec_ctx).profile = FF_PROFILE_H264_HIGH; + (*codec_ctx).profile = match codec_pref.to_lowercase().as_str() { + "hevc" | "h265" => FF_PROFILE_HEVC_MAIN, + "av1" => FF_PROFILE_UNKNOWN, + _ => FF_PROFILE_H264_HIGH, + }; (*codec_ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int; let mut hw_device_ctx: *mut AVBufferRef = ptr::null_mut(); @@ -444,7 +448,11 @@ impl WindowsHwEncoder { (*codec_ctx).rc_buffer_size = (rate / 2) as i32; (*codec_ctx).qmin = 16; (*codec_ctx).qmax = 28; - (*codec_ctx).profile = FF_PROFILE_H264_HIGH; + (*codec_ctx).profile = match codec_pref.to_lowercase().as_str() { + "hevc" | "h265" => FF_PROFILE_HEVC_MAIN, + "av1" => FF_PROFILE_UNKNOWN, + _ => FF_PROFILE_H264_HIGH, + }; (*codec_ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int; if selected_desc.contains("NVENC") { diff --git a/src/ipc.rs b/src/ipc.rs index 4f8bfe1..20b08c7 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -29,6 +29,10 @@ pub struct DaemonStatus { pub mic_volume: f32, #[serde(default = "default_system_volume")] pub system_volume: f32, + #[serde(default)] + pub mic_level_peak: f32, + #[serde(default)] + pub system_level_peak: f32, } fn default_true() -> bool { diff --git a/src/overlay.rs b/src/overlay.rs index a971312..e2105f3 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -1,7 +1,7 @@ use gtk::prelude::*; use gtk::{ Application, ApplicationWindow, Box, Button, ComboBoxText, CssProvider, - DrawingArea, Entry, Label, Orientation, Revealer, RevealerTransitionType, Scale, + DrawingArea, Entry, Label, LevelBar, Orientation, Revealer, RevealerTransitionType, Scale, ScrolledWindow, SpinButton, Stack, StackTransitionType, StyleContext, Switch, }; #[cfg(target_os = "linux")] @@ -737,6 +737,9 @@ pub fn show_menu_overlay() { for dev in crate::capture::audio::list_input_devices() { audio_dev_combo.append(Some(&dev), &dev); } + for app in crate::capture::audio::list_application_audio() { + audio_dev_combo.append(Some(&format!("app:{}", app)), &format!("App: {}", app)); + } audio_dev_combo.set_active_id(Some(&config.audio_device)); let sys_vol_lbl = Label::new(Some("System Volume:")); @@ -745,20 +748,32 @@ pub fn show_menu_overlay() { sys_vol_scale.set_value((config.system_volume * 100.0).round() as f64); sys_vol_scale.set_size_request(240, -1); + let sys_level_bar = LevelBar::new(); + sys_level_bar.set_min_value(0.0); + sys_level_bar.set_max_value(1.0); + sys_level_bar.set_size_request(240, 6); + let mic_vol_lbl = Label::new(Some("Mic Volume:")); mic_vol_lbl.set_halign(gtk::Align::Start); let mic_vol_scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 5.0); mic_vol_scale.set_value((config.mic_volume * 100.0).round() as f64); mic_vol_scale.set_size_request(240, -1); + let mic_level_bar = LevelBar::new(); + mic_level_bar.set_min_value(0.0); + mic_level_bar.set_max_value(1.0); + mic_level_bar.set_size_request(240, 6); + sec3_grid.attach(&audio_mode_lbl, 0, 0, 1, 1); sec3_grid.attach(&audio_mode_combo, 1, 0, 1, 1); sec3_grid.attach(&audio_dev_lbl, 0, 1, 1, 1); sec3_grid.attach(&audio_dev_combo, 1, 1, 1, 1); sec3_grid.attach(&sys_vol_lbl, 0, 2, 1, 1); sec3_grid.attach(&sys_vol_scale, 1, 2, 1, 1); - sec3_grid.attach(&mic_vol_lbl, 0, 3, 1, 1); - sec3_grid.attach(&mic_vol_scale, 1, 3, 1, 1); + sec3_grid.attach(&sys_level_bar, 1, 3, 1, 1); + sec3_grid.attach(&mic_vol_lbl, 0, 4, 1, 1); + sec3_grid.attach(&mic_vol_scale, 1, 4, 1, 1); + sec3_grid.attach(&mic_level_bar, 1, 5, 1, 1); sec3_card.pack_start(&sec3_grid, false, false, 2); settings_body.pack_start(&sec3_card, false, false, 0); @@ -1341,11 +1356,13 @@ pub fn show_menu_overlay() { let r_toggle_item_sync2 = replay_toggle_item.clone(); let r_state_sync2 = Arc::clone(&replay_active_state); let r_icon_sync2 = replay_icon_area.clone(); + let sys_level_sync = sys_level_bar.clone(); + let mic_level_sync = mic_level_bar.clone(); let is_running = Arc::new(AtomicBool::new(true)); let is_running_clone = Arc::clone(&is_running); - gtk::glib::timeout_add_local(Duration::from_millis(300), move || { + gtk::glib::timeout_add_local(Duration::from_millis(100), move || { if !is_running_clone.load(Ordering::Relaxed) { return gtk::glib::ControlFlow::Break; } @@ -1382,6 +1399,10 @@ pub fn show_menu_overlay() { if prev_replay != st.is_replay_active { r_icon_sync2.queue_draw(); } + + // 3. Audio VU meter levels sync + sys_level_sync.set_value(st.system_level_peak as f64); + mic_level_sync.set_value(st.mic_level_peak as f64); } gtk::glib::ControlFlow::Continue }); diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index 1c0c791..9bb1fab 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -10,6 +10,123 @@ use crate::ipc::{self, Command, DaemonStatus}; pub enum ShadowPlayView { MainHud, Settings, + Gallery, +} + +fn probe_duration_sec(path: &std::path::Path) -> f32 { + let out = std::process::Command::new("ffprobe") + .args([ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + ]) + .arg(path) + .output(); + if let Ok(out) = out { + let text = String::from_utf8_lossy(&out.stdout); + if let Ok(val) = text.trim().parse::() { + return val; + } + } + 0.0 +} + +fn trim_clip( + input_path: &std::path::Path, + start_sec: f32, + end_sec: f32, +) -> Result { + let stem = input_path.file_stem().unwrap_or_default().to_string_lossy(); + let ext = input_path.extension().unwrap_or_default().to_string_lossy(); + let parent = input_path.parent().unwrap_or_else(|| std::path::Path::new(".")); + + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs(); + let out_name = format!("{}_trim_{}.{}", stem, now, ext); + let out_path = parent.join(&out_name); + + let mut cmd = std::process::Command::new("ffmpeg"); + cmd.args([ + "-y", + "-ss", &format!("{:.2}", start_sec), + "-to", &format!("{:.2}", end_sec), + "-i", + ]) + .arg(input_path) + .args([ + "-c", "copy", + "-avoid_negative_ts", "make_zero", + ]) + .arg(&out_path); + + let res = cmd.output().map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?; + if res.status.success() { + Ok(out_path) + } else { + let err_str = String::from_utf8_lossy(&res.stderr); + Err(format!("FFmpeg trim failed: {}", err_str.lines().last().unwrap_or("Unknown error"))) + } +} + +fn play_clip(path: &std::path::Path) { + let p = path.to_path_buf(); + std::thread::spawn(move || { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + let _ = std::process::Command::new("cmd") + .args(["/C", "start", "", &p.to_string_lossy()]) + .creation_flags(0x08000000) + .spawn(); + } + #[cfg(not(target_os = "windows"))] + { + if std::process::Command::new("mpv").arg(&p).spawn().is_err() + && std::process::Command::new("vlc").arg(&p).spawn().is_err() + { + let _ = std::process::Command::new("xdg-open").arg(&p).spawn(); + } + } + }); +} + +fn render_vu_meter(ui: &mut egui::Ui, level: f32, width: f32, height: f32, label: &str) { + let (rect, _) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover()); + let clamped = level.clamp(0.0, 1.0); + + let bg_color = Color32::from_rgb(18, 24, 34); + let border_stroke = Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 30)); + ui.painter().rect(rect, CornerRadius::same(3_u8), bg_color, border_stroke, egui::StrokeKind::Inside); + + let fill_w = (rect.width() * clamped).max(0.0); + if fill_w > 0.5 { + let fill_rect = egui::Rect::from_min_size(rect.min, Vec2::new(fill_w, rect.height())); + let fill_color = if clamped > 0.85 { + Color32::from_rgb(239, 68, 68) + } else if clamped > 0.65 { + Color32::from_rgb(234, 179, 8) + } else { + Color32::from_rgb(118, 185, 0) + }; + ui.painter().rect_filled(fill_rect, CornerRadius::same(2_u8), fill_color); + } + + if clamped > 0.05 { + let tick_x = rect.left() + rect.width() * clamped; + ui.painter().line_segment( + [egui::pos2(tick_x, rect.top()), egui::pos2(tick_x, rect.bottom())], + Stroke::new(1.5_f32, Color32::WHITE), + ); + } + + if !label.is_empty() { + ui.painter().text( + rect.left_center() + Vec2::new(3.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + FontId::monospace(8.0), + Color32::from_rgba_unmultiplied(255, 255, 255, 200), + ); + } } #[derive(Debug, Clone)] @@ -440,6 +557,13 @@ pub struct ScytheOverlayApp { show_cursor: bool, mic_volume_pct: u32, system_volume_pct: u32, + mic_vu: f32, + sys_vu: f32, + selected_clip_idx: Option, + trim_start_sec: f32, + trim_end_sec: f32, + trim_status_msg: Option<(String, Instant)>, + clip_duration_sec: f32, anim_time: f32, status_msg: Option<(String, Instant)>, status_rx: Receiver, @@ -481,7 +605,7 @@ impl ScytheOverlayApp { if let Ok(s) = ipc::query_status() { let _ = status_tx.send(s); } - std::thread::sleep(Duration::from_millis(250)); + std::thread::sleep(Duration::from_millis(50)); } }); @@ -504,6 +628,13 @@ impl ScytheOverlayApp { show_cursor, mic_volume_pct, system_volume_pct, + mic_vu: 0.0, + sys_vu: 0.0, + selected_clip_idx: None, + trim_start_sec: 0.0, + trim_end_sec: 30.0, + trim_status_msg: None, + clip_duration_sec: 0.0, anim_time: 0.0, status_msg: None, status_rx, @@ -520,6 +651,18 @@ impl ScytheOverlayApp { fn poll_async_events(&mut self) { while let Ok(s) = self.status_rx.try_recv() { + let target_mic = s.mic_level_peak; + let target_sys = s.system_level_peak; + self.mic_vu = if target_mic > self.mic_vu { + target_mic + } else { + self.mic_vu * 0.85 + target_mic * 0.15 + }; + self.sys_vu = if target_sys > self.sys_vu { + target_sys + } else { + self.sys_vu * 0.85 + target_sys * 0.15 + }; self.status = s; self.daemon_connected = true; } @@ -553,6 +696,7 @@ impl ScytheOverlayApp { if self.replay_dropdown_open || self.record_dropdown_open { 385.0 } else { 270.0 } } ShadowPlayView::Settings => 580.0, + ShadowPlayView::Gallery => 580.0, }; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(760.0, target_h))); } @@ -617,7 +761,13 @@ impl ScytheOverlayApp { ); } - // Right side Esc hint badge (No close X button) + // Live audio VU meters + ui.add_space(8.0); + render_vu_meter(ui, self.mic_vu, 42.0, 14.0, "MIC"); + ui.add_space(4.0); + render_vu_meter(ui, self.sys_vu, 42.0, 14.0, "SYS"); + + // Right side controls (Esc hint & Gallery button) ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { egui::Frame::NONE .fill(Color32::from_rgba_unmultiplied(255, 255, 255, 20)) @@ -632,6 +782,22 @@ impl ScytheOverlayApp { .color(Color32::from_rgb(148, 163, 184)), ); }); + + ui.add_space(8.0); + + let gallery_btn = egui::Button::new( + egui::RichText::new(format!("Gallery ({})", self.clips.len())) + .size(11.0) + .strong() + .color(Color32::from_rgb(226, 232, 240)), + ) + .fill(Color32::from_rgba_unmultiplied(255, 255, 255, 16)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 30))) + .corner_radius(CornerRadius::same(6_u8)); + + if ui.add(gallery_btn).clicked() { + self.switch_view(ShadowPlayView::Gallery, ctx); + } }); }); }); @@ -934,6 +1100,8 @@ impl ScytheOverlayApp { if ui.add(egui::Slider::new(&mut mv, 0..=200).suffix("%")).changed() { self.mic_volume_pct = mv; } + ui.add_space(8.0); + render_vu_meter(ui, self.mic_vu, 80.0, 16.0, &format!("{:.0}%", self.mic_vu * 100.0)); }); ui.add_space(6.0); @@ -944,7 +1112,27 @@ impl ScytheOverlayApp { if ui.add(egui::Slider::new(&mut sv, 0..=200).suffix("%")).changed() { self.system_volume_pct = sv; } + ui.add_space(8.0); + render_vu_meter(ui, self.sys_vu, 80.0, 16.0, &format!("{:.0}%", self.sys_vu * 100.0)); }); + + let apps = crate::capture::audio::list_application_audio(); + if !apps.is_empty() { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Active Sound Streams:").size(10.5).color(Color32::from_rgb(148, 163, 184))); + for app in apps.iter().take(4) { + egui::Frame::NONE + .fill(Color32::from_rgba_unmultiplied(255, 255, 255, 14)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 24))) + .corner_radius(CornerRadius::same(4_u8)) + .inner_margin(Margin::symmetric(5_i8, 2_i8)) + .show(ui, |ui| { + ui.label(egui::RichText::new(app).size(10.0).color(Color32::from_rgb(203, 213, 225))); + }); + } + }); + } }); ui.add_space(10.0); @@ -963,7 +1151,13 @@ impl ScytheOverlayApp { open_folder(&ScytheConfig::expand_tilde(&self.output_dir)); } }); - ui.label(egui::RichText::new(format!("{} video recordings in destination folder.", self.clips.len())).size(10.5).color(Color32::from_rgb(148, 163, 184))); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(format!("{} video recordings in destination folder.", self.clips.len())).size(10.5).color(Color32::from_rgb(148, 163, 184))); + if ui.button(egui::RichText::new("Open Clip Gallery & Trimmer").size(10.5).strong().color(Color32::from_rgb(118, 185, 0))).clicked() { + self.switch_view(ShadowPlayView::Gallery, ctx); + } + }); }); ui.add_space(10.0); @@ -1018,6 +1212,7 @@ impl ScytheOverlayApp { 3 => "muted".to_string(), _ => "system".to_string(), }; + self.config.audio_device = self.config.audio_device.clone(); let _ = self.config.save(); let _ = ipc::send_command(Command::ReloadConfig); self.status_msg = Some(("Settings Saved & Applied!".to_string(), Instant::now())); @@ -1027,11 +1222,258 @@ impl ScytheOverlayApp { }); }); } + + fn render_gallery_view(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + egui::Frame::NONE + .fill(Color32::from_rgba_unmultiplied(15, 22, 32, 235)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 40))) + .corner_radius(CornerRadius::same(16_u8)) + .inner_margin(Margin::symmetric(22_i8, 16_i8)) + .show(ui, |ui| { + ui.set_width(720.0); + + // Header with Back Button and Refresh + ui.horizontal(|ui| { + let back_btn = egui::Button::new( + egui::RichText::new("< Back to Overlay") + .size(11.5) + .strong() + .color(Color32::from_rgb(118, 185, 0)), + ) + .fill(Color32::from_rgba_unmultiplied(118, 185, 0, 30)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(118, 185, 0, 115))) + .corner_radius(CornerRadius::same(8_u8)); + + if ui.add(back_btn).clicked() { + self.switch_view(ShadowPlayView::MainHud, ctx); + return; + } + + ui.add_space(12.0); + ui.label( + egui::RichText::new("GALLERY & CLIP TRIMMER") + .font(FontId::proportional(15.0)) + .strong() + .color(Color32::WHITE), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(egui::RichText::new("Refresh").size(11.0)).clicked() { + self.refresh_clips(); + } + if ui.button(egui::RichText::new("Open Folder").size(11.0)).clicked() { + open_folder(&ScytheConfig::expand_tilde(&self.output_dir)); + } + }); + }); + + ui.add_space(10.0); + + if let Some((msg, ts)) = &self.trim_status_msg { + if ts.elapsed() < Duration::from_secs(4) { + egui::Frame::NONE + .fill(Color32::from_rgba_unmultiplied(118, 185, 0, 25)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(118, 185, 0))) + .corner_radius(CornerRadius::same(6_u8)) + .inner_margin(Margin::symmetric(10_i8, 6_i8)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(msg) + .size(11.5) + .strong() + .color(Color32::from_rgb(140, 224, 0)), + ); + }); + ui.add_space(8.0); + } + } + + // Main two-column split + ui.horizontal(|ui| { + // Left Column: Clips List + ui.vertical(|ui| { + ui.set_width(280.0); + ui.label(egui::RichText::new(format!("RECORDED CLIPS ({})", self.clips.len())).size(11.0).strong().color(Color32::from_rgb(148, 163, 184))); + ui.add_space(4.0); + + egui::ScrollArea::vertical() + .max_height(450.0) + .id_salt("gallery_clips_scroll") + .show(ui, |ui| { + if self.clips.is_empty() { + ui.label(egui::RichText::new("No recordings found yet.\nPress Alt+F9 or Save Replay to capture clips.").size(11.0).color(Color32::from_rgb(100, 116, 139))); + } else { + for (idx, clip) in self.clips.iter().enumerate() { + let is_sel = self.selected_clip_idx == Some(idx); + let card_bg = if is_sel { + Color32::from_rgba_unmultiplied(118, 185, 0, 35) + } else { + Color32::from_rgba_unmultiplied(255, 255, 255, 10) + }; + let border = if is_sel { + Stroke::new(1.0_f32, Color32::from_rgb(118, 185, 0)) + } else { + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 20)) + }; + + let resp = egui::Frame::NONE + .fill(card_bg) + .stroke(border) + .corner_radius(CornerRadius::same(6_u8)) + .inner_margin(Margin::symmetric(8_i8, 6_i8)) + .show(ui, |ui| { + ui.set_width(260.0); + ui.horizontal(|ui| { + let badge_col = if clip.is_replay { Color32::from_rgb(118, 185, 0) } else { Color32::from_rgb(239, 68, 68) }; + let badge_txt = if clip.is_replay { "REPLAY" } else { "REC" }; + let (b_rect, _) = ui.allocate_exact_size(Vec2::new(44.0, 16.0), egui::Sense::hover()); + ui.painter().rect_filled(b_rect, CornerRadius::same(3_u8), badge_col); + ui.painter().text(b_rect.center(), egui::Align2::CENTER_CENTER, badge_txt, FontId::monospace(8.5), Color32::from_rgb(11, 18, 4)); + + ui.add_space(4.0); + let size_mb = clip.size_bytes as f64 / (1024.0 * 1024.0); + ui.label(egui::RichText::new(format!("{:.1} MB", size_mb)).size(10.0).color(Color32::from_rgb(148, 163, 184))); + }); + ui.add_space(2.0); + ui.label(egui::RichText::new(&clip.filename).size(10.5).strong().color(Color32::WHITE)); + }); + + if resp.response.interact(egui::Sense::click()).clicked() { + self.selected_clip_idx = Some(idx); + let dur = probe_duration_sec(&clip.path); + self.clip_duration_sec = dur; + self.trim_start_sec = 0.0; + self.trim_end_sec = if dur > 0.0 { dur } else { 30.0 }; + } + ui.add_space(4.0); + } + } + }); + }); + + ui.add_space(16.0); + + // Right Column: Preview, Details & Trimmer + ui.vertical(|ui| { + ui.set_width(400.0); + if let Some(idx) = self.selected_clip_idx { + if idx < self.clips.len() { + let clip = self.clips[idx].clone(); + let size_mb = clip.size_bytes as f64 / (1024.0 * 1024.0); + + render_section_card(ui, "CLIP DETAILS", |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("File:").size(11.0).strong().color(Color32::WHITE)); + ui.label(egui::RichText::new(&clip.filename).size(11.0).color(Color32::from_rgb(203, 213, 225))); + }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Size:").size(11.0).strong().color(Color32::WHITE)); + ui.label(egui::RichText::new(format!("{:.2} MB ({} bytes)", size_mb, clip.size_bytes)).size(10.5).color(Color32::from_rgb(148, 163, 184))); + }); + if self.clip_duration_sec > 0.0 { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Duration:").size(11.0).strong().color(Color32::WHITE)); + let total_s = self.clip_duration_sec as u32; + ui.label(egui::RichText::new(format!("{:02}:{:02} ({:.1}s)", total_s / 60, total_s % 60, self.clip_duration_sec)).size(10.5).color(Color32::from_rgb(118, 185, 0))); + }); + } + + ui.add_space(6.0); + ui.horizontal(|ui| { + let play_btn = egui::Button::new(egui::RichText::new("▶ Play Video").size(11.5).strong().color(Color32::from_rgb(11, 18, 4))) + .fill(Color32::from_rgb(118, 185, 0)) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(6_u8)); + if ui.add(play_btn).clicked() { + play_clip(&clip.path); + } + + if ui.button(egui::RichText::new("Show in Folder").size(11.0)).clicked() { + open_folder(&clip.path); + } + + if ui.button(egui::RichText::new("Delete").size(11.0).color(Color32::from_rgb(239, 68, 68))).clicked() { + let _ = std::fs::remove_file(&clip.path); + self.trim_status_msg = Some((format!("Deleted {}", clip.filename), Instant::now())); + self.selected_clip_idx = None; + self.refresh_clips(); + } + }); + }); + + ui.add_space(10.0); + + // LOSSLESS TRIMMER SECTION + render_section_card(ui, "LOSSLESS VIDEO TRIMMER", |ui| { + let max_dur = if self.clip_duration_sec > 0.0 { self.clip_duration_sec } else { 300.0 }; + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Start Trim:").size(11.0).strong().color(Color32::WHITE)); + let mut s = self.trim_start_sec; + if ui.add(egui::Slider::new(&mut s, 0.0..=max_dur).suffix("s")).changed() { + self.trim_start_sec = s.min(self.trim_end_sec); + } + }); + + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("End Trim:").size(11.0).strong().color(Color32::WHITE)); + let mut e = self.trim_end_sec; + if ui.add(egui::Slider::new(&mut e, 0.0..=max_dur).suffix("s")).changed() { + self.trim_end_sec = e.max(self.trim_start_sec); + } + }); + + let trimmed_dur = (self.trim_end_sec - self.trim_start_sec).max(0.0); + ui.add_space(4.0); + ui.label(egui::RichText::new(format!("Trimmed output length: {:.1} seconds (Instant stream-copy, lossless)", trimmed_dur)).size(10.5).color(Color32::from_rgb(148, 163, 184))); + + ui.add_space(8.0); + let trim_btn = egui::Button::new(egui::RichText::new("Trim & Export Copy").size(12.0).strong().color(Color32::from_rgb(11, 18, 4))) + .fill(Color32::from_rgb(118, 185, 0)) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(6_u8)) + .min_size(Vec2::new(ui.available_width(), 32.0)); + + if ui.add(trim_btn).clicked() { + match trim_clip(&clip.path, self.trim_start_sec, self.trim_end_sec) { + Ok(out) => { + let fname = out.file_name().unwrap_or_default().to_string_lossy().to_string(); + self.trim_status_msg = Some((format!("Exported trimmed clip: {}", fname), Instant::now())); + self.refresh_clips(); + } + Err(e) => { + self.trim_status_msg = Some((format!("Trim failed: {}", e), Instant::now())); + } + } + } + }); + } + } else { + render_section_card(ui, "CLIP PREVIEW & TRIMMER", |ui| { + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("No clip selected").font(FontId::proportional(14.0)).strong().color(Color32::from_rgb(148, 163, 184))); + ui.add_space(6.0); + ui.label(egui::RichText::new("Select a recording or instant replay from the list on the left to inspect, play, or losslessly trim.").size(11.0).color(Color32::from_rgb(100, 116, 139))); + }); + ui.add_space(40.0); + }); + } + }); + }); + }); + }); + } } impl eframe::App for ScytheOverlayApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_async_events(); + self.mic_vu = (self.mic_vu * 0.94).max(0.0); + self.sys_vu = (self.sys_vu * 0.94).max(0.0); self.anim_time += 0.033; ctx.request_repaint_after(Duration::from_millis(50)); @@ -1070,6 +1512,7 @@ impl eframe::App for ScytheOverlayApp { .show(ctx, |ui| match self.current_view { ShadowPlayView::MainHud => self.render_main_hud(ctx, ui), ShadowPlayView::Settings => self.render_settings_view(ctx, ui), + ShadowPlayView::Gallery => self.render_gallery_view(ctx, ui), }); } }