Rebuild UI with egui, fix VAAPI AMDGPU encoding, and add dynamic Hyprland keybinds
This commit is contained in:
parent
8291b267aa
commit
e3624246f8
9 changed files with 3619 additions and 81 deletions
3067
Cargo.lock
generated
3067
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,8 @@ tokio = { version = "1.53.1", features = ["rt", "macros", "rt-multi-thread", "si
|
|||
dirs = "6.0.0"
|
||||
cpal = "0.18.2"
|
||||
rubato = "5.0.0"
|
||||
eframe = "0.31"
|
||||
egui = "0.31"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd = { version = "0.13.13", features = ["screencast"] }
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
&initial_config.audio_mode,
|
||||
).ok();
|
||||
|
||||
// Automatically register dynamic keybindings on Hyprland if active (no config edits needed)
|
||||
vrec::hyprland_binds::register_hyprland_binds(&initial_config);
|
||||
vrec::hyprland_binds::spawn_hyprland_reload_watcher();
|
||||
|
||||
let is_recording_state = Arc::new(AtomicBool::new(false));
|
||||
let record_start_state = Arc::new(AtomicU64::new(0));
|
||||
let replay_enabled_state = Arc::new(AtomicBool::new(true));
|
||||
|
|
@ -197,6 +201,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
ring = HeapRb::<Packet>::new(new_capacity);
|
||||
println!("Replay buffer resized to {} packets.", new_capacity);
|
||||
}
|
||||
vrec::hyprland_binds::register_hyprland_binds(&config);
|
||||
},
|
||||
Command::SaveReplay => {
|
||||
if config.replay_enabled {
|
||||
|
|
@ -382,6 +387,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
},
|
||||
Command::StopDaemon => {
|
||||
println!("StopDaemon requested: Finalizing active recordings...");
|
||||
vrec::hyprland_binds::unregister_hyprland_binds(&vrec::config::VrecConfig::load());
|
||||
let _ = cmd_tx.send(Command::StopRecording);
|
||||
std::thread::sleep(std::time::Duration::from_millis(350));
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use vrec::ipc::{Command, send_command, query_status};
|
||||
use vrec::overlay::{show_menu_overlay, show_notification};
|
||||
use vrec::overlay::show_notification;
|
||||
use std::env;
|
||||
use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent};
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
"--menu" => {
|
||||
ensure_daemon_running();
|
||||
show_menu_overlay();
|
||||
vrec::overlay_egui::run_egui_overlay();
|
||||
return Ok(());
|
||||
}
|
||||
"--record" | "--toggle" => {
|
||||
|
|
@ -148,6 +148,8 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
println!(" You can bind `vrec-ui --menu` or `vrec-ui --save` in your system shortcut settings.");
|
||||
} else {
|
||||
println!("wlroots/Hyprland compositor detected.");
|
||||
vrec::hyprland_binds::register_hyprland_binds(&vrec::config::VrecConfig::load());
|
||||
vrec::hyprland_binds::spawn_hyprland_reload_watcher();
|
||||
}
|
||||
} else {
|
||||
println!("X11 session detected.");
|
||||
|
|
@ -169,7 +171,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
if let Ok(event) = receiver.try_recv() {
|
||||
if event.id == hotkey_menu.id() {
|
||||
ensure_daemon_running();
|
||||
show_menu_overlay();
|
||||
vrec::overlay_egui::run_egui_overlay();
|
||||
} else if event.id == hotkey_save.id() {
|
||||
let _ = send_with_notification(Command::SaveReplay, "Replay saved");
|
||||
} else if event.id == hotkey_record.id() {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ impl WaylandCapture {
|
|||
.set_multiple(false)
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
.set_sources(SourceType::Monitor | SourceType::Monitor)
|
||||
.set_persist_mode(PersistMode::Application);
|
||||
.set_persist_mode(PersistMode::DoNot);
|
||||
|
||||
proxy.select_sources(&session, select_opts).await.map_err(|e| {
|
||||
format!("Failed to select ScreenCast sources: {:?}", e)
|
||||
|
|
|
|||
|
|
@ -232,7 +232,86 @@ impl VaapiEncoder {
|
|||
av_packet_free(&mut pkt);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Failed to map DRM prime to VAAPI: {}", map_ret);
|
||||
let mmap_size = (*stride as usize) * (*dma_height as usize);
|
||||
let ptr = libc::mmap(
|
||||
ptr::null_mut(),
|
||||
mmap_size,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
*fd,
|
||||
*offset as libc::off_t,
|
||||
);
|
||||
if ptr != libc::MAP_FAILED {
|
||||
let mut bgra_frame = av_frame_alloc();
|
||||
(*bgra_frame).format = AVPixelFormat::AV_PIX_FMT_BGRA as i32;
|
||||
(*bgra_frame).width = *dma_width as i32;
|
||||
(*bgra_frame).height = *dma_height as i32;
|
||||
av_frame_get_buffer(bgra_frame, 32);
|
||||
|
||||
let bgra_stride = (*bgra_frame).linesize[0] as usize;
|
||||
for y in 0..(*dma_height as usize) {
|
||||
let src_row = std::slice::from_raw_parts(
|
||||
(ptr as *const u8).add(y * (*stride as usize)),
|
||||
(*dma_width as usize) * 4
|
||||
);
|
||||
let dst_row = std::slice::from_raw_parts_mut(
|
||||
(*bgra_frame).data[0].add(y * bgra_stride),
|
||||
(*dma_width as usize) * 4
|
||||
);
|
||||
dst_row.copy_from_slice(src_row);
|
||||
}
|
||||
libc::munmap(ptr, mmap_size);
|
||||
|
||||
let mut nv12_frame = av_frame_alloc();
|
||||
(*nv12_frame).format = AVPixelFormat::AV_PIX_FMT_NV12 as i32;
|
||||
(*nv12_frame).width = *dma_width as i32;
|
||||
(*nv12_frame).height = *dma_height as i32;
|
||||
av_frame_get_buffer(nv12_frame, 32);
|
||||
|
||||
if self.sws_ctx.is_null() {
|
||||
self.sws_ctx = sws_getContext(
|
||||
*dma_width as i32, *dma_height as i32, AVPixelFormat::AV_PIX_FMT_BGRA,
|
||||
*dma_width as i32, *dma_height as i32, AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
2, ptr::null_mut(), ptr::null_mut(), ptr::null_mut()
|
||||
);
|
||||
}
|
||||
|
||||
sws_scale(
|
||||
self.sws_ctx,
|
||||
(*bgra_frame).data.as_ptr() as *const *const u8,
|
||||
(*bgra_frame).linesize.as_ptr(),
|
||||
0,
|
||||
*dma_height as i32,
|
||||
(*nv12_frame).data.as_ptr(),
|
||||
(*nv12_frame).linesize.as_ptr()
|
||||
);
|
||||
|
||||
let mut fresh_hw_frame = av_frame_alloc();
|
||||
(*fresh_hw_frame).format = AVPixelFormat::AV_PIX_FMT_VAAPI as i32;
|
||||
let get_buf_ret = av_hwframe_get_buffer(self.hw_frames_ctx, fresh_hw_frame, 0);
|
||||
let transfer_ret = av_hwframe_transfer_data(fresh_hw_frame, nv12_frame, 0);
|
||||
(*fresh_hw_frame).pts = self.next_pts;
|
||||
self.next_pts += 1;
|
||||
|
||||
let send_ret = avcodec_send_frame(self.codec_ctx, fresh_hw_frame);
|
||||
if send_ret >= 0 {
|
||||
let mut pkt = av_packet_alloc();
|
||||
while avcodec_receive_packet(self.codec_ctx, pkt) >= 0 {
|
||||
let new_pkt = av_packet_alloc();
|
||||
av_packet_move_ref(new_pkt, pkt);
|
||||
packets.push(crate::ring::Packet::new(new_pkt));
|
||||
}
|
||||
av_packet_free(&mut pkt);
|
||||
} else {
|
||||
eprintln!("get_buf: {}, transfer: {}, send_ret: {}", get_buf_ret, transfer_ret, send_ret);
|
||||
}
|
||||
av_frame_free(&mut fresh_hw_frame);
|
||||
|
||||
av_frame_free(&mut nv12_frame);
|
||||
av_frame_free(&mut bgra_frame);
|
||||
} else {
|
||||
eprintln!("mmap failed on dma-buf fd {}: {}", *fd, std::io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
181
src/hyprland_binds.rs
Normal file
181
src/hyprland_binds.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
use std::process::Command;
|
||||
use std::env;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use crate::config::VrecConfig;
|
||||
|
||||
pub fn is_hyprland() -> bool {
|
||||
env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok()
|
||||
|| env::var("XDG_CURRENT_DESKTOP")
|
||||
.map(|d| d.to_lowercase().contains("hyprland"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Convert standard hotkey format (e.g. "Ctrl+Shift+R") to Hyprland bind format ("CONTROL_SHIFT, R")
|
||||
pub fn hotkey_to_hyprland(hotkey: &str) -> Option<(String, String)> {
|
||||
let parts: Vec<&str> = hotkey.split('+').map(|s| s.trim()).collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut mods = Vec::new();
|
||||
let mut key = "";
|
||||
|
||||
for (i, &part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
key = part;
|
||||
} else {
|
||||
let m = match part.to_lowercase().as_str() {
|
||||
"ctrl" | "control" => "CONTROL",
|
||||
"shift" => "SHIFT",
|
||||
"alt" => "ALT",
|
||||
"super" | "win" | "meta" => "SUPER",
|
||||
_ => continue,
|
||||
};
|
||||
mods.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mod_str = if mods.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
mods.join("_")
|
||||
};
|
||||
|
||||
Some((mod_str, key.to_string()))
|
||||
}
|
||||
|
||||
/// Dynamically inject binds into running Hyprland without touching hyprland.conf
|
||||
pub fn register_hyprland_binds(config: &VrecConfig) {
|
||||
if !is_hyprland() {
|
||||
return;
|
||||
}
|
||||
|
||||
let binds = [
|
||||
(&config.menu_hotkey, "exec, vrec-ui --menu"),
|
||||
(&config.save_hotkey, "exec, vrec-ui --save"),
|
||||
(&config.record_hotkey, "exec, vrec-ui --record"),
|
||||
];
|
||||
|
||||
for (hotkey, action) in binds {
|
||||
if let Some((mods, key)) = hotkey_to_hyprland(hotkey) {
|
||||
let bind_arg = if mods.is_empty() {
|
||||
format!("{}, {}", key, action)
|
||||
} else {
|
||||
format!("{}, {}, {}", mods, key, action)
|
||||
};
|
||||
let res = Command::new("hyprctl")
|
||||
.args(["keyword", "bind", &bind_arg])
|
||||
.output();
|
||||
if let Ok(out) = res {
|
||||
if !out.status.success() {
|
||||
eprintln!("hyprctl keyword bind note: {:?}", String::from_utf8_lossy(&out.stderr));
|
||||
} else {
|
||||
println!("Hyprland dynamic bind active: {}", bind_arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamically remove binds from running Hyprland
|
||||
pub fn unregister_hyprland_binds(config: &VrecConfig) {
|
||||
if !is_hyprland() {
|
||||
return;
|
||||
}
|
||||
|
||||
let hotkeys = [
|
||||
&config.menu_hotkey,
|
||||
&config.save_hotkey,
|
||||
&config.record_hotkey,
|
||||
];
|
||||
|
||||
for hotkey in hotkeys {
|
||||
if let Some((mods, key)) = hotkey_to_hyprland(hotkey) {
|
||||
let unbind_arg = if mods.is_empty() {
|
||||
key
|
||||
} else {
|
||||
format!("{}, {}", mods, key)
|
||||
};
|
||||
let _ = Command::new("hyprctl")
|
||||
.args(["keyword", "unbind", &unbind_arg])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Hyprland socket2 for live event listening
|
||||
fn get_hyprland_socket2() -> Option<PathBuf> {
|
||||
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||
let xdg_runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
|
||||
let path = PathBuf::from(format!("{}/hypr/{}/.socket2.sock", xdg_runtime, sig));
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
let fallback = PathBuf::from(format!("/tmp/hypr/{}/.socket2.sock", sig));
|
||||
if fallback.exists() {
|
||||
return Some(fallback);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Background watcher for Hyprland config reloads so dynamic binds are never lost
|
||||
pub fn spawn_hyprland_reload_watcher() {
|
||||
if !is_hyprland() {
|
||||
return;
|
||||
}
|
||||
|
||||
std::thread::spawn(|| {
|
||||
loop {
|
||||
if let Some(sock_path) = get_hyprland_socket2() {
|
||||
if let Ok(stream) = UnixStream::connect(&sock_path) {
|
||||
let reader = BufReader::new(stream);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if line.starts_with("configreloaded") {
|
||||
println!("Hyprland config reload detected! Re-registering vrec dynamic binds...");
|
||||
let config = VrecConfig::load();
|
||||
register_hyprland_binds(&config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hotkey_to_hyprland_conversion() {
|
||||
assert_eq!(
|
||||
hotkey_to_hyprland("Ctrl+Shift+R"),
|
||||
Some(("CONTROL_SHIFT".to_string(), "R".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
hotkey_to_hyprland("Alt+Z"),
|
||||
Some(("ALT".to_string(), "Z".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
hotkey_to_hyprland("Ctrl+Shift+F9"),
|
||||
Some(("CONTROL_SHIFT".to_string(), "F9".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
hotkey_to_hyprland("F12"),
|
||||
Some(("".to_string(), "F12".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
hotkey_to_hyprland("Super+Alt+S"),
|
||||
Some(("SUPER_ALT".to_string(), "S".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7,3 +7,5 @@ pub mod ipc;
|
|||
pub mod overlay;
|
||||
pub mod config;
|
||||
pub mod settings_ui;
|
||||
pub mod overlay_egui;
|
||||
pub mod hyprland_binds;
|
||||
|
|
|
|||
351
src/overlay_egui.rs
Normal file
351
src/overlay_egui.rs
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
use eframe::egui;
|
||||
use egui::{Color32, CornerRadius, FontId, Margin, Stroke, Vec2};
|
||||
use std::time::{Duration, Instant};
|
||||
use crate::config::VrecConfig;
|
||||
use crate::ipc::{self, Command, DaemonStatus};
|
||||
|
||||
pub struct VrecOverlayApp {
|
||||
config: VrecConfig,
|
||||
status: DaemonStatus,
|
||||
last_status_fetch: Instant,
|
||||
show_settings: bool,
|
||||
replay_sec: u32,
|
||||
bitrate_mbps: u32,
|
||||
target_fps: u32,
|
||||
audio_mode_idx: usize,
|
||||
status_msg: Option<(String, Instant)>,
|
||||
}
|
||||
|
||||
impl VrecOverlayApp {
|
||||
pub fn new() -> Self {
|
||||
let config = VrecConfig::load();
|
||||
let status = ipc::query_status().unwrap_or_default();
|
||||
let replay_sec = config.replay_duration_sec;
|
||||
let bitrate_mbps = (config.record_bitrate_kbps / 1000).max(1);
|
||||
let target_fps = config.fps;
|
||||
let audio_mode_idx = match config.audio_mode.as_str() {
|
||||
"mic" => 1,
|
||||
"both" => 2,
|
||||
"muted" => 3,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
status,
|
||||
last_status_fetch: Instant::now(),
|
||||
show_settings: false,
|
||||
replay_sec,
|
||||
bitrate_mbps,
|
||||
target_fps,
|
||||
audio_mode_idx,
|
||||
status_msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_status(&mut self) {
|
||||
if self.last_status_fetch.elapsed() > Duration::from_millis(400) {
|
||||
if let Ok(s) = ipc::query_status() {
|
||||
self.status = s;
|
||||
}
|
||||
self.last_status_fetch = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_msg(&mut self, text: &str) {
|
||||
self.status_msg = Some((text.to_string(), Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for VrecOverlayApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.refresh_status();
|
||||
ctx.request_repaint_after(Duration::from_millis(200));
|
||||
|
||||
// Handle Escape key to close
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
|
||||
// Custom dark stealth theme matching GPU Screen Recorder
|
||||
let mut visuals = egui::Visuals::dark();
|
||||
visuals.panel_fill = Color32::from_rgb(15, 17, 23);
|
||||
visuals.window_fill = Color32::from_rgb(15, 17, 23);
|
||||
visuals.window_stroke = Stroke::new(1.0_f32, Color32::from_rgb(45, 52, 64));
|
||||
visuals.window_corner_radius = CornerRadius::same(14_u8);
|
||||
ctx.set_visuals(visuals);
|
||||
|
||||
egui::CentralPanel::default()
|
||||
.frame(
|
||||
egui::Frame::NONE
|
||||
.fill(Color32::from_rgba_premultiplied(13, 16, 23, 245))
|
||||
.stroke(Stroke::new(1.5_f32, Color32::from_rgb(38, 44, 58)))
|
||||
.corner_radius(CornerRadius::same(14_u8))
|
||||
.inner_margin(Margin::same(16_i8)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
// Top Header Bar
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("VREC")
|
||||
.font(FontId::proportional(20.0))
|
||||
.strong()
|
||||
.color(Color32::from_rgb(88, 166, 255)),
|
||||
);
|
||||
|
||||
let (dot_color, status_text) = if self.status.is_recording {
|
||||
(Color32::from_rgb(239, 68, 68), "RECORDING")
|
||||
} else if self.status.is_replay_active {
|
||||
(Color32::from_rgb(34, 197, 94), "REPLAY READY")
|
||||
} else {
|
||||
(Color32::from_rgb(156, 163, 175), "STANDBY")
|
||||
};
|
||||
|
||||
ui.add_space(8.0);
|
||||
let (dot_rect, _) = ui.allocate_exact_size(Vec2::new(10.0, 10.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(dot_rect.center(), 5.0, dot_color);
|
||||
ui.label(
|
||||
egui::RichText::new(status_text)
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(dot_color)
|
||||
.strong(),
|
||||
);
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(egui::RichText::new("X").strong())
|
||||
.fill(Color32::from_rgb(30, 34, 45))
|
||||
.corner_radius(CornerRadius::same(6_u8)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
|
||||
let settings_text = if self.show_settings { "Close Settings" } else { "Settings" };
|
||||
let settings_btn = egui::Button::new(egui::RichText::new(settings_text).color(Color32::from_rgb(200, 210, 225)))
|
||||
.fill(if self.show_settings { Color32::from_rgb(38, 45, 60) } else { Color32::from_rgb(25, 28, 38) })
|
||||
.corner_radius(CornerRadius::same(6_u8));
|
||||
if ui.add(settings_btn).clicked() {
|
||||
self.show_settings = !self.show_settings;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some((msg, time)) = &self.status_msg {
|
||||
if time.elapsed() < Duration::from_secs(3) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new(msg)
|
||||
.color(Color32::from_rgb(74, 222, 128))
|
||||
.font(FontId::proportional(12.0)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Main HUD Cards Grid (4 Cards)
|
||||
ui.columns(4, |cols| {
|
||||
// Card 1: Replay
|
||||
cols[0].group(|ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("REPLAY").font(FontId::proportional(13.0)).strong().color(Color32::from_rgb(148, 163, 184)));
|
||||
let buf_label = format!("{}s Buffer", self.config.replay_duration_sec);
|
||||
ui.label(egui::RichText::new(buf_label).font(FontId::proportional(11.0)).color(Color32::from_rgb(100, 116, 139)));
|
||||
ui.add_space(8.0);
|
||||
if ui.add_sized([120.0, 32.0], egui::Button::new("Save Replay").fill(Color32::from_rgb(16, 185, 129)).corner_radius(CornerRadius::same(8_u8))).clicked() {
|
||||
let _ = ipc::send_command(Command::SaveReplay);
|
||||
self.set_msg("Replay save command sent!");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Card 2: Record
|
||||
cols[1].group(|ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("RECORD").font(FontId::proportional(13.0)).strong().color(Color32::from_rgb(148, 163, 184)));
|
||||
let timer_text = if self.status.is_recording {
|
||||
let d = self.status.recording_duration_sec;
|
||||
format!("{:02}:{:02}:{:02}", d / 3600, (d % 3600) / 60, d % 60)
|
||||
} else {
|
||||
"Idle".to_string()
|
||||
};
|
||||
ui.label(egui::RichText::new(timer_text).font(FontId::proportional(11.0)).color(Color32::from_rgb(100, 116, 139)));
|
||||
ui.add_space(8.0);
|
||||
let (rec_label, rec_color) = if self.status.is_recording {
|
||||
("Stop", Color32::from_rgb(239, 68, 68))
|
||||
} else {
|
||||
("Start", Color32::from_rgb(59, 130, 246))
|
||||
};
|
||||
if ui.add_sized([120.0, 32.0], egui::Button::new(rec_label).fill(rec_color).corner_radius(CornerRadius::same(8_u8))).clicked() {
|
||||
let _ = ipc::send_command(Command::ToggleRecording);
|
||||
self.set_msg("Recording toggled!");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Card 3: Audio
|
||||
cols[2].group(|ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("AUDIO").font(FontId::proportional(13.0)).strong().color(Color32::from_rgb(148, 163, 184)));
|
||||
let mode_name = match self.status.audio_mode.as_str() {
|
||||
"mic" => "Microphone",
|
||||
"both" => "System + Mic",
|
||||
"muted" => "Muted",
|
||||
_ => "System Audio",
|
||||
};
|
||||
ui.label(egui::RichText::new(mode_name).font(FontId::proportional(11.0)).color(Color32::from_rgb(100, 116, 139)));
|
||||
ui.add_space(8.0);
|
||||
if ui.add_sized([120.0, 32.0], egui::Button::new("Cycle Mode").fill(Color32::from_rgb(30, 41, 59)).corner_radius(CornerRadius::same(8_u8))).clicked() {
|
||||
let _ = ipc::send_command(Command::CycleAudioMode);
|
||||
self.refresh_status();
|
||||
self.set_msg("Audio mode cycled!");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Card 4: Preferences Toggle
|
||||
cols[3].group(|ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("SETTINGS").font(FontId::proportional(13.0)).strong().color(Color32::from_rgb(148, 163, 184)));
|
||||
let codec_info = format!("{} / {}fps", self.config.video_codec.to_uppercase(), self.config.fps);
|
||||
ui.label(egui::RichText::new(codec_info).font(FontId::proportional(11.0)).color(Color32::from_rgb(100, 116, 139)));
|
||||
ui.add_space(8.0);
|
||||
let gear_label = if self.show_settings { "Hide Panel" } else { "Open Panel" };
|
||||
if ui.add_sized([120.0, 32.0], egui::Button::new(gear_label).fill(Color32::from_rgb(51, 65, 85)).corner_radius(CornerRadius::same(8_u8))).clicked() {
|
||||
self.show_settings = !self.show_settings;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Detailed Settings Panel (Collapsible / Dynamic)
|
||||
if self.show_settings {
|
||||
ui.add_space(14.0);
|
||||
ui.separator();
|
||||
ui.add_space(10.0);
|
||||
|
||||
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Precise Tuning & Configuration").strong().color(Color32::from_rgb(203, 213, 225)));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// 1. Replay Duration with Number Input & Pills
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
ui.label("Replay Duration (sec):");
|
||||
ui.add(egui::DragValue::new(&mut self.replay_sec).range(5..=600).suffix(" s"));
|
||||
ui.add_space(8.0);
|
||||
if ui.small_button("30s").clicked() { self.replay_sec = 30; }
|
||||
if ui.small_button("60s").clicked() { self.replay_sec = 60; }
|
||||
if ui.small_button("120s").clicked() { self.replay_sec = 120; }
|
||||
if ui.small_button("300s").clicked() { self.replay_sec = 300; }
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 2. Video Bitrate with Number Input & Pills
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
ui.label("Bitrate (Mbps):");
|
||||
ui.add(egui::DragValue::new(&mut self.bitrate_mbps).range(2..=120).suffix(" Mbps"));
|
||||
ui.add_space(8.0);
|
||||
if ui.small_button("10M").clicked() { self.bitrate_mbps = 10; }
|
||||
if ui.small_button("20M").clicked() { self.bitrate_mbps = 20; }
|
||||
if ui.small_button("30M").clicked() { self.bitrate_mbps = 30; }
|
||||
if ui.small_button("50M").clicked() { self.bitrate_mbps = 50; }
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 3. Target Framerate with Number Input & Pills
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
ui.label("Framerate (FPS):");
|
||||
ui.add(egui::DragValue::new(&mut self.target_fps).range(15..=240).suffix(" fps"));
|
||||
ui.add_space(8.0);
|
||||
if ui.small_button("30").clicked() { self.target_fps = 30; }
|
||||
if ui.small_button("60").clicked() { self.target_fps = 60; }
|
||||
if ui.small_button("120").clicked() { self.target_fps = 120; }
|
||||
if ui.small_button("144").clicked() { self.target_fps = 144; }
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 4. Audio Routing Mode
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
ui.label("Audio Routing Mode:");
|
||||
egui::ComboBox::from_id_salt("audio_routing_cb")
|
||||
.selected_text(match self.audio_mode_idx {
|
||||
1 => "Microphone Only",
|
||||
2 => "Both (System + Mic)",
|
||||
3 => "Muted",
|
||||
_ => "System Audio Only",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.audio_mode_idx, 0, "System Audio Only");
|
||||
ui.selectable_value(&mut self.audio_mode_idx, 1, "Microphone Only");
|
||||
ui.selectable_value(&mut self.audio_mode_idx, 2, "Both (System + Mic)");
|
||||
ui.selectable_value(&mut self.audio_mode_idx, 3, "Muted");
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Save & Apply Button
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_sized([160.0, 30.0], egui::Button::new("Save & Apply").fill(Color32::from_rgb(37, 99, 235)).corner_radius(CornerRadius::same(6_u8))).clicked() {
|
||||
self.config.replay_duration_sec = self.replay_sec;
|
||||
self.config.record_bitrate_kbps = self.bitrate_mbps * 1000;
|
||||
self.config.replay_bitrate_kbps = self.bitrate_mbps * 1000;
|
||||
self.config.fps = self.target_fps;
|
||||
self.config.audio_mode = match self.audio_mode_idx {
|
||||
1 => "mic",
|
||||
2 => "both",
|
||||
3 => "muted",
|
||||
_ => "system",
|
||||
}.to_string();
|
||||
let _ = self.config.save();
|
||||
let _ = ipc::send_command(Command::ReloadConfig);
|
||||
self.set_msg("Settings applied & saved!");
|
||||
}
|
||||
|
||||
if ui.button("Open Recordings Folder").clicked() {
|
||||
let dir = self.config.output_directory.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = std::process::Command::new("xdg-open").arg(dir).spawn();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_egui_overlay() {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("vrec")
|
||||
.with_inner_size([740.0, 220.0])
|
||||
.with_min_inner_size([640.0, 180.0])
|
||||
.with_max_inner_size([820.0, 520.0])
|
||||
.with_resizable(true)
|
||||
.with_decorations(false)
|
||||
.with_transparent(true)
|
||||
.with_always_on_top(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = eframe::run_native(
|
||||
"vrec-overlay",
|
||||
options,
|
||||
Box::new(|_cc| Ok(Box::new(VrecOverlayApp::new()))),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue