feat: add real-time audio VU meters, application sound isolation, and video clip trimmer

This commit is contained in:
katR 2026-09-05 16:15:22 +05:30
parent 6af07e7c7d
commit f8c9212420
6 changed files with 626 additions and 23 deletions

View file

@ -89,6 +89,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn FrameSource> = if std::env::args().any(|a| a == "--mock") {
@ -444,12 +450,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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);

View file

@ -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<std::process::Child>,
_streams: Vec<cpal::Stream>,
pub levels: Arc<AudioLevels>,
}
impl Drop for AudioCapture {
@ -54,6 +106,31 @@ pub fn list_input_devices() -> Vec<String> {
names
}
pub fn list_application_audio() -> Vec<String> {
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<Vec<f32>>,
gain: f32,
levels: Option<Arc<AudioLevels>>,
is_mic: bool,
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
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<Vec<f32>>) -> Result<(Self, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
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<Vec<f32>>,
device_name: Option<&str>,
) -> Result<(Self, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<Vec<f32>>,
device_name: Option<&str>,
audio_mode: &str,
mic_volume: f32,
system_volume: f32,
levels: Option<Arc<AudioLevels>>,
) -> Result<(Self, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
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::<Vec<f32>>(100);
let (mic_tx, mic_rx) = crossbeam_channel::bounded::<Vec<f32>>(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<Vec<f32>>, gain: f32| -> Result<(cpal::Stream, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
let levels_for_cpal = Arc::clone(&levels);
let build_stream = move |dev: &cpal::Device, tx: Sender<Vec<f32>>, gain: f32, is_mic: bool| -> Result<(cpal::Stream, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
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<f32> = 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<f32> = 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::<Vec<f32>>(100);
let (mic_tx, mic_rx) = crossbeam_channel::bounded::<Vec<f32>>(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))
}
}

View file

@ -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") {

View file

@ -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 {

View file

@ -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
});

View file

@ -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::<f32>() {
return val;
}
}
0.0
}
fn trim_clip(
input_path: &std::path::Path,
start_sec: f32,
end_sec: f32,
) -> Result<PathBuf, String> {
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<usize>,
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<DaemonStatus>,
@ -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),
});
}
}