feat(ui): Redesign HUD to GPU Screen Recorder style, numeric inputs for duration/bitrate/fps, audio mode toggling, and auto-daemon recovery

This commit is contained in:
katR 2026-09-03 21:49:33 +05:30
parent e0096b0f86
commit 7a15b7f8ec
6 changed files with 753 additions and 349 deletions

View file

@ -12,8 +12,42 @@ use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
fn ensure_wayland_env() {
#[cfg(target_os = "linux")]
{
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
unsafe {
if env::var("WAYLAND_DISPLAY").is_err() {
if let Ok(entries) = std::fs::read_dir(&runtime_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("wayland-") && !name.ends_with(".lock") {
env::set_var("WAYLAND_DISPLAY", &name);
println!("Auto-detected Wayland display: {}", name);
break;
}
}
}
}
if env::var("DBUS_SESSION_BUS_ADDRESS").is_err() {
let bus_path = format!("{}/bus", runtime_dir);
if std::path::Path::new(&bus_path).exists() {
env::set_var("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}", bus_path));
}
}
if env::var("XDG_CURRENT_DESKTOP").is_err() {
env::set_var("XDG_CURRENT_DESKTOP", "Hyprland");
}
if env::var("XDG_SESSION_TYPE").map(|s| s == "tty" || s.is_empty()).unwrap_or(true) && env::var("WAYLAND_DISPLAY").is_ok() {
env::set_var("XDG_SESSION_TYPE", "wayland");
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
ensure_wayland_env();
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string());
println!("Detected session type: {}", session_type);
@ -65,7 +99,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (mux_tx, mux_rx) = bounded::<Vec<Packet>>(1);
let (audio_tx, audio_rx) = bounded::<Vec<f32>>(100);
let initial_config = vrec::config::VrecConfig::load();
let audio_info = vrec::capture::audio::AudioCapture::new_with_device(audio_tx, Some(&initial_config.audio_device)).ok();
let audio_info = vrec::capture::audio::AudioCapture::new_with_device_and_mode(
audio_tx,
Some(&initial_config.audio_device),
&initial_config.audio_mode,
).ok();
let is_recording_state = Arc::new(AtomicBool::new(false));
let record_start_state = Arc::new(AtomicU64::new(0));
@ -306,11 +344,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
} else {
0
};
let cfg = vrec::config::VrecConfig::load();
let status = DaemonStatus {
is_recording: rec,
recording_duration_sec: duration,
is_replay_active: replay_enabled_state.load(Ordering::SeqCst),
audio_muted: audio_muted_state.load(Ordering::SeqCst),
audio_mode: cfg.audio_mode,
};
if let Ok(resp) = serde_json::to_vec(&status) {
let len_resp = (resp.len() as u32).to_le_bytes();
@ -318,6 +358,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = stream.write_all(&resp);
}
},
Command::CycleAudioMode => {
let mut cfg = vrec::config::VrecConfig::load();
cfg.audio_mode = match cfg.audio_mode.as_str() {
"system" => "mic",
"mic" => "both",
"both" => "muted",
_ => "system",
}.to_string();
let _ = cfg.save();
println!("Audio mode cycled to: {}", cfg.audio_mode);
},
Command::StopDaemon => {
println!("StopDaemon requested: Finalizing active recordings...");
let _ = cmd_tx.send(Command::StopRecording);

View file

@ -3,20 +3,72 @@ use vrec::overlay::{show_menu_overlay, show_notification};
use std::env;
use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent};
fn ensure_wayland_env() {
#[cfg(target_os = "linux")]
{
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
unsafe {
if env::var("WAYLAND_DISPLAY").is_err() {
if let Ok(entries) = std::fs::read_dir(&runtime_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("wayland-") && !name.ends_with(".lock") {
env::set_var("WAYLAND_DISPLAY", &name);
break;
}
}
}
}
if env::var("DBUS_SESSION_BUS_ADDRESS").is_err() {
let bus_path = format!("{}/bus", runtime_dir);
if std::path::Path::new(&bus_path).exists() {
env::set_var("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}", bus_path));
}
}
if env::var("XDG_CURRENT_DESKTOP").is_err() {
env::set_var("XDG_CURRENT_DESKTOP", "Hyprland");
}
if env::var("XDG_SESSION_TYPE").map(|s| s == "tty" || s.is_empty()).unwrap_or(true) && env::var("WAYLAND_DISPLAY").is_ok() {
env::set_var("XDG_SESSION_TYPE", "wayland");
}
}
}
}
fn ensure_daemon_running() {
if query_status().is_err() {
println!("vrec-daemon not running. Auto-launching vrec-daemon...");
let _ = std::process::Command::new("vrec-daemon")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
for _ in 0..15 {
std::thread::sleep(std::time::Duration::from_millis(100));
if query_status().is_ok() {
println!("vrec-daemon successfully connected.");
break;
}
}
}
}
fn send_with_notification(cmd: Command, success_msg: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
ensure_daemon_running();
match send_command(cmd) {
Ok(()) => {
show_notification(success_msg);
Ok(())
}
Err(e) => {
show_notification("Error: vrec-daemon is not running");
show_notification("Error: failed to connect to vrec-daemon");
Err(e)
}
}
}
fn handle_toggle_recording() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
ensure_daemon_running();
let status = query_status().ok();
let is_rec = status.as_ref().map(|s| s.is_recording).unwrap_or(false);
match send_command(Command::ToggleRecording) {
@ -29,13 +81,15 @@ fn handle_toggle_recording() -> Result<(), Box<dyn std::error::Error + Send + Sy
Ok(())
}
Err(e) => {
show_notification("Error: vrec-daemon is not running");
show_notification("Error: failed to connect to vrec-daemon");
Err(e)
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
ensure_wayland_env();
let args: Vec<String> = env::args().collect();
if args.len() > 1 {
match args[1].as_str() {
@ -43,6 +97,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
return send_with_notification(Command::SaveReplay, "Replay saved");
}
"--menu" => {
ensure_daemon_running();
show_menu_overlay();
return Ok(());
}
@ -98,55 +153,29 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("X11 session detected.");
}
let manager = GlobalHotKeyManager::new().map_err(|e| format!("Failed to init GlobalHotKeyManager: {:?}", e))?;
let config = vrec::config::VrecConfig::load();
let save_hotkey = vrec::hotkey::parse_hotkey(&config.save_hotkey)
.unwrap_or_else(|| HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyR));
let menu_hotkey = vrec::hotkey::parse_hotkey(&config.menu_hotkey)
.unwrap_or_else(|| HotKey::new(Some(Modifiers::ALT), Code::KeyZ));
let record_hotkey = vrec::hotkey::parse_hotkey(&config.record_hotkey)
.unwrap_or_else(|| HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F9));
let mut registered_count = 0;
if let Err(e) = manager.register(save_hotkey) {
eprintln!("Warning: Failed to register save hotkey ({}): {}", config.save_hotkey, e);
} else {
println!("Registered save replay hotkey: {}", config.save_hotkey);
registered_count += 1;
}
let manager = GlobalHotKeyManager::new()?;
let hotkey_menu = HotKey::new(Some(Modifiers::ALT), Code::KeyZ);
let hotkey_save = HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyR);
let hotkey_record = HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F9);
if let Err(e) = manager.register(menu_hotkey) {
eprintln!("Warning: Failed to register menu hotkey ({}): {}", config.menu_hotkey, e);
} else {
println!("Registered menu overlay hotkey: {}", config.menu_hotkey);
registered_count += 1;
}
let _ = manager.register(hotkey_menu);
let _ = manager.register(hotkey_save);
let _ = manager.register(hotkey_record);
if let Err(e) = manager.register(record_hotkey) {
eprintln!("Warning: Failed to register record hotkey ({}): {}", config.record_hotkey, e);
} else {
println!("Registered toggle recording hotkey: {}", config.record_hotkey);
registered_count += 1;
}
if registered_count == 0 {
eprintln!("Note: Running without global hotkeys. You can bind `vrec-ui --menu`, `vrec-ui --record`, and `vrec-ui --save` to compositor shortcuts.");
}
println!("Listening for global hotkeys (Alt+Z for overlay, Ctrl+Shift+R for replay, Ctrl+Shift+F9 for recording)...");
let receiver = GlobalHotKeyEvent::receiver();
loop {
if let Ok(event) = receiver.recv()
&& event.state == global_hotkey::HotKeyState::Pressed {
if event.id == save_hotkey.id() {
println!("Save replay hotkey pressed. Triggering SaveReplay...");
let _ = send_with_notification(Command::SaveReplay, "Replay saved");
} else if event.id == menu_hotkey.id() {
println!("Menu hotkey pressed. Opening Overlay Menu...");
show_menu_overlay();
} else if event.id == record_hotkey.id() {
println!("Record hotkey pressed. Toggling Recording...");
let _ = handle_toggle_recording();
}
if let Ok(event) = receiver.try_recv() {
if event.id == hotkey_menu.id() {
ensure_daemon_running();
show_menu_overlay();
} else if event.id == hotkey_save.id() {
let _ = send_with_notification(Command::SaveReplay, "Replay saved");
} else if event.id == hotkey_record.id() {
let _ = handle_toggle_recording();
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}

View file

@ -1,8 +1,10 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use crossbeam_channel::Sender;
use std::collections::VecDeque;
use std::thread;
pub struct AudioCapture {
_stream: cpal::Stream,
_streams: Vec<cpal::Stream>,
}
pub fn get_device_name(dev: &cpal::Device) -> String {
@ -27,75 +29,168 @@ pub fn list_input_devices() -> Vec<String> {
impl AudioCapture {
pub fn new(sender: Sender<Vec<f32>>) -> Result<(Self, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
Self::new_with_device(sender, None)
Self::new_with_device_and_mode(sender, None, "system")
}
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_and_mode(sender, device_name, "system")
}
pub fn new_with_device_and_mode(
sender: Sender<Vec<f32>>,
device_name: Option<&str>,
audio_mode: &str,
) -> Result<(Self, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
let host = cpal::default_host();
let device = if let Some(target) = device_name
&& target != "default"
&& !target.trim().is_empty() {
if let Ok(mut devs) = host.input_devices() {
devs.find(|d| get_device_name(d) == target)
.or_else(|| host.default_input_device())
.ok_or("Audio device not found")?
} else {
host.default_input_device().ok_or("No audio input device available")?
if audio_mode == "muted" {
println!("Audio capture mode: MUTED (No audio recorded)");
return Ok((Self { _streams: Vec::new() }, 48000, 2));
}
// Helper to find monitor device (system sound)
let find_system_device = || -> Option<cpal::Device> {
if let Ok(devs) = host.input_devices() {
for d in devs {
let n = get_device_name(&d).to_lowercase();
if n.contains("monitor") {
return Some(d);
}
}
} else {
// When default is selected, prefer desktop audio monitor if available so gameplay sound is recorded
if let Ok(mut devs) = host.input_devices() {
devs.find(|d| {
let n = get_device_name(d).to_lowercase();
n.contains("monitor")
})
.or_else(|| host.default_input_device())
.ok_or("No audio input device available")?
} else {
host.default_input_device().ok_or("No audio input device available")?
}
};
let dev_name = get_device_name(&device);
let config = device.default_input_config()?;
let sample_rate = config.sample_rate();
let channels = config.channels();
println!("Audio capture initialized on [{}]: {} Hz, {} channels", dev_name, sample_rate, channels);
let err_fn = |err| eprintln!("Audio stream error: {}", err);
let stream_config: cpal::StreamConfig = config.into();
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => {
device.build_input_stream(
stream_config,
move |data: &[f32], _: &_| {
let _ = sender.try_send(data.to_vec());
},
err_fn,
None,
)?
}
cpal::SampleFormat::I16 => {
device.build_input_stream(
stream_config,
move |data: &[i16], _: &_| {
let f32_data: Vec<f32> = data.iter().map(|&s| s as f32 / i16::MAX as f32).collect();
let _ = sender.try_send(f32_data);
},
err_fn,
None,
)?
}
_ => return Err("Unsupported audio format".into()),
host.default_input_device()
};
stream.play()?;
Ok((Self { _stream: stream }, sample_rate, channels))
// Helper to find microphone device
let find_mic_device = || -> Option<cpal::Device> {
if let Some(target) = device_name
&& target != "default"
&& !target.trim().is_empty() {
if let Ok(devs) = host.input_devices() {
for d in devs {
if get_device_name(&d) == target {
return Some(d);
}
}
}
}
if let Ok(devs) = host.input_devices() {
for d in devs {
let n = get_device_name(&d).to_lowercase();
if !n.contains("monitor") {
return Some(d);
}
}
}
host.default_input_device()
};
// Helper to build a stream for a device
let build_stream = |dev: &cpal::Device, tx: Sender<Vec<f32>>| -> 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 stream = match format {
cpal::SampleFormat::F32 => {
dev.build_input_stream(
stream_config,
move |data: &[f32], _: &_| {
let _ = tx.try_send(data.to_vec());
},
err_fn,
None,
)?
}
cpal::SampleFormat::I16 => {
dev.build_input_stream(
stream_config,
move |data: &[i16], _: &_| {
let f32_data: Vec<f32> = data.iter().map(|&s| s as f32 / i16::MAX as f32).collect();
let _ = tx.try_send(f32_data);
},
err_fn,
None,
)?
}
_ => return Err("Unsupported audio format".into()),
};
stream.play()?;
Ok((stream, sample_rate, channels))
};
match audio_mode {
"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)?;
println!("Audio capture mode: MICROPHONE ONLY [{}] ({} Hz, {} ch)", name, sr, ch);
Ok((Self { _streams: vec![stream] }, sr, ch))
}
"both" => {
let sys_dev = find_system_device().ok_or("No system audio monitor found")?;
let mic_dev = find_mic_device().ok_or("No microphone found")?;
let sys_name = get_device_name(&sys_dev);
let mic_name = get_device_name(&mic_dev);
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)?;
let (mic_stream, _, _) = build_stream(&mic_dev, mic_tx)?;
let out_tx = sender;
thread::spawn(move || {
let mut sys_q: VecDeque<f32> = VecDeque::with_capacity(16384);
let mut mic_q: VecDeque<f32> = VecDeque::with_capacity(16384);
loop {
while let Ok(chunk) = sys_rx.try_recv() {
sys_q.extend(chunk);
}
while let Ok(chunk) = mic_rx.try_recv() {
mic_q.extend(chunk);
}
let avail = sys_q.len().min(mic_q.len());
if avail >= 480 {
let mut mixed = Vec::with_capacity(avail);
for _ in 0..avail {
let s = sys_q.pop_front().unwrap_or(0.0);
let m = mic_q.pop_front().unwrap_or(0.0);
mixed.push((s + m * 0.9).clamp(-1.0, 1.0));
}
let _ = out_tx.try_send(mixed);
} else if sys_q.len() > 4800 {
let chunk: Vec<f32> = sys_q.drain(..960.min(sys_q.len())).collect();
let _ = out_tx.try_send(chunk);
} else if mic_q.len() > 4800 {
let chunk: Vec<f32> = mic_q.drain(..960.min(mic_q.len())).collect();
let _ = out_tx.try_send(chunk);
}
thread::sleep(std::time::Duration::from_millis(4));
}
});
println!("Audio capture mode: BOTH (System [{}] + Mic [{}]) ({} Hz, {} ch)", sys_name, mic_name, sr, ch);
Ok((Self { _streams: vec![sys_stream, mic_stream] }, sr, ch))
}
_ => {
// "system" is default
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)?;
println!("Audio capture mode: SYSTEM SOUNDS ONLY [{}] ({} Hz, {} ch)", name, sr, ch);
Ok((Self { _streams: vec![stream] }, sr, ch))
}
}
}
}

View file

@ -2,24 +2,55 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn default_true() -> bool { true }
fn default_replay_duration() -> u32 { 60 }
fn default_replay_bitrate() -> u32 { 18000 }
fn default_record_bitrate() -> u32 { 20000 }
fn default_fps() -> u32 { 60 }
fn default_video_codec() -> String { "h264".to_string() }
fn default_audio_device() -> String { "default".to_string() }
fn default_audio_mode() -> String { "system".to_string() }
fn default_lang() -> String { "en".to_string() }
fn default_theme() -> String { "dark".to_string() }
fn default_save_hotkey() -> String { "Ctrl+Shift+R".to_string() }
fn default_menu_hotkey() -> String { "Alt+Z".to_string() }
fn default_record_hotkey() -> String { "Ctrl+Shift+F9".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VrecConfig {
#[serde(default = "default_true")]
pub replay_enabled: bool,
#[serde(default = "default_replay_duration")]
pub replay_duration_sec: u32,
#[serde(default = "default_replay_bitrate")]
pub replay_bitrate_kbps: u32,
#[serde(default)]
pub record_enabled: bool,
#[serde(default = "default_record_bitrate")]
pub record_bitrate_kbps: u32,
#[serde(default = "default_fps")]
pub fps: u32,
#[serde(default = "default_video_codec")]
pub video_codec: String,
#[serde(default = "VrecConfig::default_output_directory")]
pub output_directory: String,
#[serde(default = "default_audio_device")]
pub audio_device: String,
#[serde(default = "default_audio_mode")]
pub audio_mode: String,
#[serde(default)]
pub autostart: bool,
#[serde(default = "default_lang")]
pub language: String,
#[serde(default = "default_theme")]
pub ui_color_theme: String,
#[serde(default = "default_save_hotkey")]
pub save_hotkey: String,
#[serde(default = "default_menu_hotkey")]
pub menu_hotkey: String,
#[serde(default = "default_record_hotkey")]
pub record_hotkey: String,
}
@ -36,6 +67,7 @@ impl Default for VrecConfig {
video_codec: "h264".to_string(),
output_directory: Self::default_output_directory(),
audio_device: "default".to_string(),
audio_mode: "system".to_string(),
autostart: false,
language: "en".to_string(),
@ -61,52 +93,31 @@ impl VrecConfig {
path
}
pub fn resolve_save_path(&self, filename: &str) -> String {
let mut p = PathBuf::from(&self.output_directory);
p.push(filename);
p.to_string_lossy().to_string()
}
pub fn load() -> Self {
let path = Self::config_path();
if path.exists()
&& let Ok(data) = fs::read_to_string(&path)
&& let Ok(config) = serde_json::from_str(&data) {
return config;
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(cfg) = serde_json::from_str(&content) {
return cfg;
}
Self::default()
}
let default_cfg = Self::default();
let _ = default_cfg.save();
default_cfg
}
pub fn resolve_save_path(&self, filename: &str) -> String {
let p = std::path::Path::new(&self.output_directory);
let _ = fs::create_dir_all(p);
p.join(filename).to_string_lossy().to_string()
}
pub fn save(&self) -> Result<(), std::io::Error> {
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let _ = fs::create_dir_all(&self.output_directory);
let data = serde_json::to_string_pretty(self)?;
fs::write(path, data)?;
let _ = self.update_autostart();
Ok(())
}
pub fn update_autostart(&self) -> Result<(), std::io::Error> {
let mut autostart_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
autostart_dir.push("autostart");
let desktop_file = autostart_dir.join("vrec.desktop");
if self.autostart {
fs::create_dir_all(&autostart_dir)?;
let content = "[Desktop Entry]\n\
Type=Application\n\
Name=vrec Screen Recorder\n\
Comment=Hardware-accelerated screen recorder and instant replay\n\
Exec=sh -c \"vrec-daemon & vrec-ui\"\n\
Terminal=false\n\
Categories=AudioVideo;Recorder;\n";
fs::write(desktop_file, content)?;
} else if desktop_file.exists() {
let _ = fs::remove_file(desktop_file);
}
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json)?;
Ok(())
}
@ -122,13 +133,14 @@ mod tests {
#[test]
fn test_config_defaults_and_json() {
let cfg = VrecConfig::default();
assert!(cfg.replay_enabled);
assert_eq!(cfg.replay_duration_sec, 60);
assert_eq!(cfg.replay_bitrate_kbps, 18000);
assert_eq!(cfg.fps, 60);
assert!(cfg.output_directory.contains("vrec"));
assert_eq!(cfg.video_codec, "h264");
assert_eq!(cfg.audio_mode, "system");
let json = serde_json::to_string(&cfg).unwrap();
let parsed: VrecConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.menu_hotkey, "Alt+Z");
assert_eq!(parsed.save_hotkey, "Ctrl+Shift+R");
assert_eq!(parsed.replay_duration_sec, 60);
assert_eq!(parsed.audio_mode, "system");
}
}

View file

@ -7,6 +7,7 @@ pub enum Command {
StopRecording,
ToggleRecording,
ToggleAudio,
CycleAudioMode,
ReloadConfig,
StopDaemon,
ShowOverlay,
@ -19,6 +20,12 @@ pub struct DaemonStatus {
pub recording_duration_sec: u64,
pub is_replay_active: bool,
pub audio_muted: bool,
#[serde(default = "default_audio_mode_str")]
pub audio_mode: String,
}
fn default_audio_mode_str() -> String {
"system".to_string()
}
pub fn send_command(cmd: Command) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {

View file

@ -1,7 +1,7 @@
use gtk::prelude::*;
use gtk::{
Application, ApplicationWindow, Box, Button, ComboBoxText, CssProvider, Entry, Label,
Orientation, Stack, StackTransitionType, StyleContext, Switch,
Orientation, SpinButton, Stack, StackTransitionType, StyleContext, Switch,
};
#[cfg(target_os = "linux")]
use gtk_layer_shell::{Layer, LayerShell};
@ -32,8 +32,8 @@ pub fn show_notification(message: &str) {
app.connect_activate(move |app| {
let window = ApplicationWindow::builder()
.application(app)
.default_width(340)
.default_height(70)
.default_width(320)
.default_height(60)
.build();
#[cfg(target_os = "linux")]
@ -41,7 +41,7 @@ pub fn show_notification(message: &str) {
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_namespace("vrec-notification");
window.set_layer_shell_margin(gtk_layer_shell::Edge::Top, 35);
window.set_layer_shell_margin(gtk_layer_shell::Edge::Top, 30);
window.set_anchor(gtk_layer_shell::Edge::Top, true);
}
@ -56,16 +56,16 @@ pub fn show_notification(message: &str) {
let css_provider = CssProvider::new();
let css = r#"
window {
background-color: rgba(18, 20, 26, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.7);
background-color: #12141a;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.8);
}
label {
color: #ffffff;
font-weight: 800;
font-size: 16px;
padding: 12px 28px;
font-weight: 700;
font-size: 14px;
padding: 10px 24px;
}
"#;
let _ = css_provider.load_from_data(css.as_bytes());
@ -111,8 +111,8 @@ pub fn show_menu_overlay() {
let window = ApplicationWindow::builder()
.application(app)
.default_width(860)
.default_height(470)
.default_width(820)
.default_height(480)
.build();
#[cfg(target_os = "linux")]
@ -121,7 +121,7 @@ pub fn show_menu_overlay() {
window.set_layer(Layer::Overlay);
window.set_namespace("vrec-hud");
window.set_keyboard_interactivity(true);
window.set_layer_shell_margin(gtk_layer_shell::Edge::Top, 70);
window.set_layer_shell_margin(gtk_layer_shell::Edge::Top, 50);
window.set_anchor(gtk_layer_shell::Edge::Top, true);
}
@ -133,25 +133,24 @@ pub fn show_menu_overlay() {
window.set_position(gtk::WindowPosition::Center);
}
// Main Stack (Allows toggling between HUD cards and Inline Settings with NO extra windows!)
let stack = Stack::new();
stack.set_transition_type(StackTransitionType::Crossfade);
stack.set_transition_duration(200);
stack.set_transition_duration(180);
// ==========================================
// PAGE 1: HUD Quick Actions (Xbox / GPU Screen Recorder layout)
// PAGE 1: HUD Quick Actions (GPU Screen Recorder Style)
// ==========================================
let hud_page = Box::new(Orientation::Vertical, 16);
hud_page.set_margin_start(24);
hud_page.set_margin_end(24);
hud_page.set_margin_top(20);
hud_page.set_margin_bottom(20);
hud_page.set_margin_top(18);
hud_page.set_margin_bottom(18);
// Header
// Header Bar
let header_box = Box::new(Orientation::Horizontal, 10);
let title_label = Label::new(Some("vrec"));
let title_label = Label::new(Some("VREC"));
title_label.style_context().add_class("hud-title");
let badge_label = Label::new(Some("GPU ZERO-COPY"));
let badge_label = Label::new(Some("GPU ACCELERATED"));
badge_label.style_context().add_class("badge-tag");
let spacer = Box::new(Orientation::Horizontal, 0);
let close_btn = Button::with_label("✕");
@ -162,61 +161,120 @@ pub fn show_menu_overlay() {
header_box.pack_start(&spacer, true, true, 0);
header_box.pack_start(&close_btn, false, false, 0);
// Cards Row (4 Primary Action Cards)
let cards_box = Box::new(Orientation::Horizontal, 16);
// 4 Action Cards Row
let cards_box = Box::new(Orientation::Horizontal, 14);
cards_box.set_halign(gtk::Align::Center);
// Card 1: Instant Replay
// Card 1: Replay
let replay_card = Box::new(Orientation::Vertical, 8);
replay_card.style_context().add_class("card-box");
let replay_toggle_btn = Button::with_label(if config.replay_enabled {
"Instant Replay\nEnabled"
} else {
"Instant Replay\nDisabled"
});
replay_toggle_btn.set_size_request(170, 75);
let replay_hdr = Box::new(Orientation::Horizontal, 6);
let replay_title = Label::new(Some("REPLAY"));
replay_title.style_context().add_class("card-header-label");
let replay_pill = Label::new(Some(if config.replay_enabled { "ACTIVE" } else { "OFF" }));
replay_pill.style_context().add_class("status-pill");
replay_pill.style_context().add_class(if config.replay_enabled { "pill-active" } else { "pill-idle" });
let replay_sp = Box::new(Orientation::Horizontal, 0);
replay_hdr.pack_start(&replay_title, false, false, 0);
replay_hdr.pack_start(&replay_sp, true, true, 0);
replay_hdr.pack_start(&replay_pill, false, false, 0);
let replay_toggle_btn = Button::with_label(if config.replay_enabled { "Disable Replay" } else { "Enable Replay" });
replay_toggle_btn.set_size_request(165, 45);
replay_toggle_btn.style_context().add_class("action-btn");
let replay_save_btn = Button::with_label("Save Replay");
replay_save_btn.style_context().add_class("sub-btn");
replay_card.pack_start(&replay_toggle_btn, true, true, 0);
replay_card.pack_start(&replay_hdr, false, false, 0);
replay_card.pack_start(&replay_toggle_btn, true, true, 4);
replay_card.pack_start(&replay_save_btn, false, false, 0);
// Card 2: Normal Recording
// Card 2: Record
let record_card = Box::new(Orientation::Vertical, 8);
record_card.style_context().add_class("card-box");
let record_toggle_btn = Button::with_label("Record\nStart");
record_toggle_btn.set_size_request(170, 75);
let record_hdr = Box::new(Orientation::Horizontal, 6);
let record_title = Label::new(Some("RECORD"));
record_title.style_context().add_class("card-header-label");
let record_pill = Label::new(Some("IDLE"));
record_pill.style_context().add_class("status-pill");
record_pill.style_context().add_class("pill-idle");
let record_sp = Box::new(Orientation::Horizontal, 0);
record_hdr.pack_start(&record_title, false, false, 0);
record_hdr.pack_start(&record_sp, true, true, 0);
record_hdr.pack_start(&record_pill, false, false, 0);
let record_toggle_btn = Button::with_label("Start Record");
record_toggle_btn.set_size_request(165, 45);
record_toggle_btn.style_context().add_class("action-btn");
let record_status_sub = Label::new(Some("H.264 • 60 FPS"));
record_status_sub.style_context().add_class("sub-label");
record_card.pack_start(&record_toggle_btn, true, true, 0);
record_card.pack_start(&record_status_sub, false, false, 0);
let record_format_sub = Label::new(Some(&format!("{} • {} FPS", config.video_codec.to_uppercase(), config.fps)));
record_format_sub.style_context().add_class("sub-label");
record_card.pack_start(&record_hdr, false, false, 0);
record_card.pack_start(&record_toggle_btn, true, true, 4);
record_card.pack_start(&record_format_sub, false, false, 0);
// Card 3: Microphone & Audio
// Card 3: Audio Mode
let audio_card = Box::new(Orientation::Vertical, 8);
audio_card.style_context().add_class("card-box");
let mic_toggle_btn = Button::with_label("Microphone\nActive");
mic_toggle_btn.set_size_request(170, 75);
mic_toggle_btn.style_context().add_class("action-btn");
let audio_hdr = Box::new(Orientation::Horizontal, 6);
let audio_title = Label::new(Some("AUDIO"));
audio_title.style_context().add_class("card-header-label");
let audio_pill = Label::new(Some(match config.audio_mode.as_str() {
"mic" => "MIC ONLY",
"both" => "SYSTEM + MIC",
"muted" => "MUTED",
_ => "SYSTEM ONLY",
}));
audio_pill.style_context().add_class("status-pill");
audio_pill.style_context().add_class(match config.audio_mode.as_str() {
"mic" => "pill-cyan",
"both" => "pill-purple",
"muted" => "pill-recording",
_ => "pill-active",
});
let audio_sp = Box::new(Orientation::Horizontal, 0);
audio_hdr.pack_start(&audio_title, false, false, 0);
audio_hdr.pack_start(&audio_sp, true, true, 0);
audio_hdr.pack_start(&audio_pill, false, false, 0);
let mic_status_sub = Label::new(Some("AAC • 48 kHz"));
mic_status_sub.style_context().add_class("sub-label");
audio_card.pack_start(&mic_toggle_btn, true, true, 0);
audio_card.pack_start(&mic_status_sub, false, false, 0);
let audio_cycle_btn = Button::with_label(match config.audio_mode.as_str() {
"mic" => "Microphone Only",
"both" => "System + Mic",
"muted" => "Muted",
_ => "System Audio Only",
});
audio_cycle_btn.set_size_request(165, 45);
audio_cycle_btn.style_context().add_class("action-btn");
let audio_hint_sub = Label::new(Some("Click to Cycle Mode"));
audio_hint_sub.style_context().add_class("sub-label");
audio_card.pack_start(&audio_hdr, false, false, 0);
audio_card.pack_start(&audio_cycle_btn, true, true, 4);
audio_card.pack_start(&audio_hint_sub, false, false, 0);
// Card 4: Settings
let settings_card = Box::new(Orientation::Vertical, 8);
settings_card.style_context().add_class("card-box");
let settings_open_btn = Button::with_label("Settings\nConfigure");
settings_open_btn.set_size_request(170, 75);
let settings_hdr = Box::new(Orientation::Horizontal, 6);
let settings_title_hdr = Label::new(Some("PREFERENCES"));
settings_title_hdr.style_context().add_class("card-header-label");
let settings_pill = Label::new(Some("TUNING"));
settings_pill.style_context().add_class("status-pill");
settings_pill.style_context().add_class("pill-idle");
let settings_sp = Box::new(Orientation::Horizontal, 0);
settings_hdr.pack_start(&settings_title_hdr, false, false, 0);
settings_hdr.pack_start(&settings_sp, true, true, 0);
settings_hdr.pack_start(&settings_pill, false, false, 0);
let settings_open_btn = Button::with_label("Open Settings");
settings_open_btn.set_size_request(165, 45);
settings_open_btn.style_context().add_class("action-btn");
let shortcut_hint = Label::new(Some("Esc to Close"));
shortcut_hint.style_context().add_class("sub-label");
settings_card.pack_start(&settings_open_btn, true, true, 0);
settings_card.pack_start(&shortcut_hint, false, false, 0);
let settings_hint_sub = Label::new(Some("Esc to Close"));
settings_hint_sub.style_context().add_class("sub-label");
settings_card.pack_start(&settings_hdr, false, false, 0);
settings_card.pack_start(&settings_open_btn, true, true, 4);
settings_card.pack_start(&settings_hint_sub, false, false, 0);
cards_box.pack_start(&replay_card, true, true, 0);
cards_box.pack_start(&record_card, true, true, 0);
@ -227,75 +285,90 @@ pub fn show_menu_overlay() {
hud_page.pack_start(&cards_box, true, true, 0);
// ==========================================
// PAGE 2: Inline Settings Panel (Zero separate windows!)
// PAGE 2: Fine-Tuned Settings Panel (Numeric inputs for Time, Quality, FPS)
// ==========================================
let settings_page = Box::new(Orientation::Vertical, 14);
settings_page.set_margin_start(30);
settings_page.set_margin_end(30);
settings_page.set_margin_top(20);
settings_page.set_margin_bottom(20);
let settings_page = Box::new(Orientation::Vertical, 12);
settings_page.set_margin_start(24);
settings_page.set_margin_end(24);
settings_page.set_margin_top(16);
settings_page.set_margin_bottom(16);
let settings_header = Box::new(Orientation::Horizontal, 12);
let back_btn = Button::with_label("Back");
let back_btn = Button::with_label("Back to HUD");
back_btn.style_context().add_class("sub-btn");
let settings_title = Label::new(Some("Recorder Settings"));
let settings_title = Label::new(Some("Recorder Settings & Quality Tuning"));
settings_title.style_context().add_class("hud-title");
settings_header.pack_start(&back_btn, false, false, 0);
settings_header.pack_start(&settings_title, false, false, 0);
let settings_grid = gtk::Grid::new();
settings_grid.set_column_spacing(24);
settings_grid.set_row_spacing(12);
settings_grid.set_column_spacing(20);
settings_grid.set_row_spacing(10);
// Replay Duration
let dur_lbl = Label::new(Some("Replay Duration:"));
// 1. Replay Duration (Seconds) - Direct numeric spin button + preset pills!
let dur_lbl = Label::new(Some("Replay Buffer (Seconds):"));
dur_lbl.set_halign(gtk::Align::Start);
let dur_combo = ComboBoxText::new();
dur_combo.append(Some("15"), "15 Seconds");
dur_combo.append(Some("30"), "30 Seconds");
dur_combo.append(Some("60"), "1 Minute");
dur_combo.append(Some("120"), "2 Minutes");
dur_combo.append(Some("300"), "5 Minutes");
dur_combo.append(Some("600"), "10 Minutes");
dur_combo.set_active_id(Some(&config.replay_duration_sec.to_string()));
let dur_box = Box::new(Orientation::Horizontal, 6);
let dur_spin = SpinButton::with_range(5.0, 1800.0, 5.0);
dur_spin.set_value(config.replay_duration_sec as f64);
dur_box.pack_start(&dur_spin, false, false, 0);
let p_30s = Button::with_label("30s"); p_30s.style_context().add_class("preset-btn");
let p_60s = Button::with_label("60s"); p_60s.style_context().add_class("preset-btn");
let p_2m = Button::with_label("2m"); p_2m.style_context().add_class("preset-btn");
let p_5m = Button::with_label("5m"); p_5m.style_context().add_class("preset-btn");
let s_clone = dur_spin.clone(); p_30s.connect_clicked(move |_| s_clone.set_value(30.0));
let s_clone = dur_spin.clone(); p_60s.connect_clicked(move |_| s_clone.set_value(60.0));
let s_clone = dur_spin.clone(); p_2m.connect_clicked(move |_| s_clone.set_value(120.0));
let s_clone = dur_spin.clone(); p_5m.connect_clicked(move |_| s_clone.set_value(300.0));
dur_box.pack_start(&p_30s, false, false, 0);
dur_box.pack_start(&p_60s, false, false, 0);
dur_box.pack_start(&p_2m, false, false, 0);
dur_box.pack_start(&p_5m, false, false, 0);
// Video Bitrate
let bit_lbl = Label::new(Some("Recording Quality:"));
// 2. Video Quality / Bitrate (Mbps) - Direct numeric spin button + preset pills!
let bit_lbl = Label::new(Some("Video Bitrate (Mbps):"));
bit_lbl.set_halign(gtk::Align::Start);
let bit_combo = ComboBoxText::new();
bit_combo.append(Some("10000"), "Low (10 Mbps)");
bit_combo.append(Some("15000"), "Medium (15 Mbps)");
bit_combo.append(Some("25000"), "High (25 Mbps)");
bit_combo.append(Some("50000"), "Ultra (50 Mbps)");
bit_combo.set_active_id(Some(&config.record_bitrate_kbps.to_string()));
let bit_box = Box::new(Orientation::Horizontal, 6);
let bit_spin = SpinButton::with_range(2.0, 100.0, 1.0);
bit_spin.set_value((config.record_bitrate_kbps / 1000) as f64);
bit_box.pack_start(&bit_spin, false, false, 0);
// Autostart
let auto_lbl = Label::new(Some("Autostart on Boot:"));
auto_lbl.set_halign(gtk::Align::Start);
let auto_switch = Switch::new();
auto_switch.set_active(config.autostart);
let b_10m = Button::with_label("10M"); b_10m.style_context().add_class("preset-btn");
let b_20m = Button::with_label("20M"); b_20m.style_context().add_class("preset-btn");
let b_30m = Button::with_label("30M"); b_30m.style_context().add_class("preset-btn");
let b_50m = Button::with_label("50M"); b_50m.style_context().add_class("preset-btn");
let b_clone = bit_spin.clone(); b_10m.connect_clicked(move |_| b_clone.set_value(10.0));
let b_clone = bit_spin.clone(); b_20m.connect_clicked(move |_| b_clone.set_value(20.0));
let b_clone = bit_spin.clone(); b_30m.connect_clicked(move |_| b_clone.set_value(30.0));
let b_clone = bit_spin.clone(); b_50m.connect_clicked(move |_| b_clone.set_value(50.0));
bit_box.pack_start(&b_10m, false, false, 0);
bit_box.pack_start(&b_20m, false, false, 0);
bit_box.pack_start(&b_30m, false, false, 0);
bit_box.pack_start(&b_50m, false, false, 0);
// Hotkeys
let save_hk_lbl = Label::new(Some("Save Replay Hotkey:"));
save_hk_lbl.set_halign(gtk::Align::Start);
let save_hk_entry = Entry::new();
save_hk_entry.set_text(&config.save_hotkey);
let menu_hk_lbl = Label::new(Some("Menu Overlay Hotkey:"));
menu_hk_lbl.set_halign(gtk::Align::Start);
let menu_hk_entry = Entry::new();
menu_hk_entry.set_text(&config.menu_hotkey);
// Framerate Selector (Crucial for low-end PCs: 30 FPS cuts GPU load by 50%)
let fps_lbl = Label::new(Some("Framerate (FPS):"));
// 3. Framerate (FPS) - Direct numeric spin button + preset pills!
let fps_lbl = Label::new(Some("Target Framerate (FPS):"));
fps_lbl.set_halign(gtk::Align::Start);
let fps_combo = ComboBoxText::new();
fps_combo.append(Some("30"), "30 FPS (Low-End PC)");
fps_combo.append(Some("60"), "60 FPS (Smooth / Gaming)");
fps_combo.append(Some("120"), "120 FPS (High-End)");
fps_combo.set_active_id(Some(&config.fps.to_string()));
let fps_box = Box::new(Orientation::Horizontal, 6);
let fps_spin = SpinButton::with_range(15.0, 240.0, 1.0);
fps_spin.set_value(config.fps as f64);
fps_box.pack_start(&fps_spin, false, false, 0);
// Video Codec Selector
let f_30 = Button::with_label("30"); f_30.style_context().add_class("preset-btn");
let f_60 = Button::with_label("60"); f_60.style_context().add_class("preset-btn");
let f_120 = Button::with_label("120"); f_120.style_context().add_class("preset-btn");
let f_144 = Button::with_label("144"); f_144.style_context().add_class("preset-btn");
let f_clone = fps_spin.clone(); f_30.connect_clicked(move |_| f_clone.set_value(30.0));
let f_clone = fps_spin.clone(); f_60.connect_clicked(move |_| f_clone.set_value(60.0));
let f_clone = fps_spin.clone(); f_120.connect_clicked(move |_| f_clone.set_value(120.0));
let f_clone = fps_spin.clone(); f_144.connect_clicked(move |_| f_clone.set_value(144.0));
fps_box.pack_start(&f_30, false, false, 0);
fps_box.pack_start(&f_60, false, false, 0);
fps_box.pack_start(&f_120, false, false, 0);
fps_box.pack_start(&f_144, false, false, 0);
// 4. Video Codec
let codec_lbl = Label::new(Some("Video Codec:"));
codec_lbl.set_halign(gtk::Align::Start);
let codec_combo = ComboBoxText::new();
@ -304,8 +377,18 @@ pub fn show_menu_overlay() {
codec_combo.append(Some("av1"), "AV1 (Next-Gen High Quality)");
codec_combo.set_active_id(Some(&config.video_codec));
// Audio Input Device Selector
let audio_dev_lbl = Label::new(Some("Audio Device:"));
// 5. Audio Source Mode (System Only vs Mic Only vs Both!)
let audio_mode_lbl = Label::new(Some("Audio Track Mode:"));
audio_mode_lbl.set_halign(gtk::Align::Start);
let audio_mode_combo = ComboBoxText::new();
audio_mode_combo.append(Some("system"), "System Sounds Only (Game / Desktop)");
audio_mode_combo.append(Some("mic"), "Microphone Only");
audio_mode_combo.append(Some("both"), "Both (System Sounds + Microphone Merged)");
audio_mode_combo.append(Some("muted"), "Muted (No Audio)");
audio_mode_combo.set_active_id(Some(&config.audio_mode));
// 6. Audio Device
let audio_dev_lbl = Label::new(Some("Audio Hardware Device:"));
audio_dev_lbl.set_halign(gtk::Align::Start);
let audio_dev_combo = ComboBoxText::new();
audio_dev_combo.append(Some("default"), "Default Device");
@ -314,39 +397,58 @@ pub fn show_menu_overlay() {
}
audio_dev_combo.set_active_id(Some(&config.audio_device));
// Save Folder
// 7. Save Folder
let dir_lbl = Label::new(Some("Save Folder:"));
dir_lbl.set_halign(gtk::Align::Start);
let dir_entry = Entry::new();
dir_entry.set_text(&config.output_directory);
// 8. Hotkeys
let save_hk_lbl = Label::new(Some("Save Replay Hotkey:"));
save_hk_lbl.set_halign(gtk::Align::Start);
let save_hk_entry = Entry::new();
save_hk_entry.set_text(&config.save_hotkey);
let record_hk_lbl = Label::new(Some("Toggle Record Hotkey:"));
record_hk_lbl.set_halign(gtk::Align::Start);
let record_hk_entry = Entry::new();
record_hk_entry.set_text(&config.record_hotkey);
let menu_hk_lbl = Label::new(Some("Menu Overlay Hotkey:"));
menu_hk_lbl.set_halign(gtk::Align::Start);
let menu_hk_entry = Entry::new();
menu_hk_entry.set_text(&config.menu_hotkey);
// 9. Autostart
let auto_lbl = Label::new(Some("Autostart on Login:"));
auto_lbl.set_halign(gtk::Align::Start);
let auto_switch = Switch::new();
auto_switch.set_active(config.autostart);
settings_grid.attach(&dur_lbl, 0, 0, 1, 1);
settings_grid.attach(&dur_combo, 1, 0, 1, 1);
settings_grid.attach(&dur_box, 1, 0, 1, 1);
settings_grid.attach(&bit_lbl, 0, 1, 1, 1);
settings_grid.attach(&bit_combo, 1, 1, 1, 1);
settings_grid.attach(&bit_box, 1, 1, 1, 1);
settings_grid.attach(&fps_lbl, 0, 2, 1, 1);
settings_grid.attach(&fps_combo, 1, 2, 1, 1);
settings_grid.attach(&fps_box, 1, 2, 1, 1);
settings_grid.attach(&codec_lbl, 0, 3, 1, 1);
settings_grid.attach(&codec_combo, 1, 3, 1, 1);
settings_grid.attach(&audio_dev_lbl, 0, 4, 1, 1);
settings_grid.attach(&audio_dev_combo, 1, 4, 1, 1);
settings_grid.attach(&dir_lbl, 0, 5, 1, 1);
settings_grid.attach(&dir_entry, 1, 5, 1, 1);
settings_grid.attach(&auto_lbl, 0, 6, 1, 1);
settings_grid.attach(&auto_switch, 1, 6, 1, 1);
settings_grid.attach(&audio_mode_lbl, 0, 4, 1, 1);
settings_grid.attach(&audio_mode_combo, 1, 4, 1, 1);
settings_grid.attach(&audio_dev_lbl, 0, 5, 1, 1);
settings_grid.attach(&audio_dev_combo, 1, 5, 1, 1);
settings_grid.attach(&dir_lbl, 0, 6, 1, 1);
settings_grid.attach(&dir_entry, 1, 6, 1, 1);
settings_grid.attach(&save_hk_lbl, 0, 7, 1, 1);
settings_grid.attach(&save_hk_entry, 1, 7, 1, 1);
settings_grid.attach(&record_hk_lbl, 0, 8, 1, 1);
settings_grid.attach(&record_hk_entry, 1, 8, 1, 1);
settings_grid.attach(&menu_hk_lbl, 0, 9, 1, 1);
settings_grid.attach(&menu_hk_entry, 1, 9, 1, 1);
settings_grid.attach(&auto_lbl, 0, 10, 1, 1);
settings_grid.attach(&auto_switch, 1, 10, 1, 1);
let save_settings_btn = Button::with_label("Apply & Save");
let save_settings_btn = Button::with_label("Apply & Save Settings");
save_settings_btn.style_context().add_class("apply-btn");
settings_page.pack_start(&settings_header, false, false, 0);
@ -357,75 +459,133 @@ pub fn show_menu_overlay() {
stack.add_named(&settings_page, "settings");
window.add(&stack);
// CSS Styling (Sleek Frosted Glass HUD)
// CSS Styling (Brushed Stealth HUD Theme)
let css_provider = CssProvider::new();
let css = r#"
window {
background-color: rgba(18, 20, 26, 0.95);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0px 12px 40px rgba(0, 0, 0, 0.75);
color: #ffffff;
background-color: #12141a;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.85);
color: #f1f5f9;
}
.hud-title {
font-size: 20px;
font-weight: 900;
font-size: 18px;
font-weight: 800;
color: #ffffff;
letter-spacing: 1px;
letter-spacing: 1.5px;
}
.badge-tag {
background-color: rgba(56, 189, 248, 0.2);
background-color: rgba(56, 189, 248, 0.12);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.3);
font-size: 10px;
font-weight: bold;
font-weight: 700;
border-radius: 6px;
padding: 2px 8px;
}
.card-box {
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 10px;
background-color: #181b23;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
padding: 12px;
min-width: 170px;
}
.card-box:hover {
background-color: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.18);
background-color: #1e222d;
border-color: rgba(255, 255, 255, 0.15);
}
.card-header-label {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
letter-spacing: 0.8px;
}
.status-pill {
font-size: 10px;
font-weight: 800;
padding: 2px 8px;
border-radius: 10px;
}
.pill-active {
background-color: rgba(16, 185, 129, 0.15);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.pill-idle {
background-color: rgba(148, 163, 184, 0.12);
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.2);
}
.pill-recording {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.pill-cyan {
background-color: rgba(6, 182, 212, 0.15);
color: #06b6d4;
border: 1px solid rgba(6, 182, 212, 0.3);
}
.pill-purple {
background-color: rgba(168, 85, 247, 0.15);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.action-btn {
background-color: transparent;
border: none;
color: #f1f5f9;
font-size: 15px;
font-weight: bold;
}
.action-btn:hover {
color: #38bdf8;
}
.recording-active {
color: #ef4444 !important;
animation: pulse 1s infinite alternate;
}
.sub-btn {
background-color: rgba(255, 255, 255, 0.08);
color: #cbd5e1;
background-color: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 12px;
color: #ffffff;
font-size: 13px;
font-weight: 700;
padding: 8px 12px;
}
.action-btn:hover {
background-color: #2563eb;
border-color: #3b82f6;
color: #ffffff;
}
.recording-active {
background-color: rgba(239, 68, 68, 0.25) !important;
border-color: #ef4444 !important;
color: #ef4444 !important;
}
.sub-btn {
background-color: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: #94a3b8;
font-size: 11px;
font-weight: 600;
padding: 6px 12px;
padding: 5px 10px;
}
.sub-btn:hover {
background-color: rgba(255, 255, 255, 0.16);
background-color: rgba(255, 255, 255, 0.08);
color: #ffffff;
}
.preset-btn {
background-color: #1e222d;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: #94a3b8;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
}
.preset-btn:hover {
background-color: rgba(37, 99, 235, 0.2);
border-color: #3b82f6;
color: #ffffff;
}
.apply-btn {
background-color: #2563eb;
color: #ffffff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: bold;
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
padding: 10px 24px;
}
.apply-btn:hover {
background-color: #1d4ed8;
@ -434,7 +594,7 @@ pub fn show_menu_overlay() {
background-color: transparent;
color: #94a3b8;
border: none;
font-size: 18px;
font-size: 16px;
font-weight: bold;
padding: 4px 8px;
}
@ -446,12 +606,26 @@ pub fn show_menu_overlay() {
font-size: 11px;
font-weight: 500;
}
entry, combobox button {
background-color: rgba(30, 32, 40, 0.9);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.12);
entry, spinbutton, combobox button {
background-color: #171a22;
color: #f1f5f9;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
font-weight: 600;
}
entry:focus, spinbutton:focus, combobox button:focus {
border-color: #3b82f6;
background-color: #1c202a;
}
spinbutton button {
background-color: transparent;
color: #94a3b8;
border: none;
}
spinbutton button:hover {
color: #ffffff;
}
"#;
let _ = css_provider.load_from_data(css.as_bytes());
@ -494,29 +668,25 @@ pub fn show_menu_overlay() {
let stack_after_save = stack.clone();
save_settings_btn.connect_clicked(move |_| {
let mut cfg = VrecConfig::load();
if let Some(dur_str) = dur_combo.active_id()
&& let Ok(dur) = dur_str.parse() {
cfg.replay_duration_sec = dur;
}
if let Some(bit_str) = bit_combo.active_id()
&& let Ok(bit) = bit_str.parse() {
cfg.record_bitrate_kbps = bit;
cfg.replay_bitrate_kbps = bit;
}
if let Some(fps_str) = fps_combo.active_id()
&& let Ok(fps) = fps_str.parse() {
cfg.fps = fps;
}
cfg.replay_duration_sec = dur_spin.value() as u32;
let bit_mbps = bit_spin.value() as u32;
cfg.record_bitrate_kbps = bit_mbps * 1000;
cfg.replay_bitrate_kbps = bit_mbps * 1000;
cfg.fps = fps_spin.value() as u32;
if let Some(c) = codec_combo.active_id() {
cfg.video_codec = c.to_string();
}
if let Some(m) = audio_mode_combo.active_id() {
cfg.audio_mode = m.to_string();
}
if let Some(dev) = audio_dev_combo.active_id() {
cfg.audio_device = dev.to_string();
}
let dir_val = dir_entry.text().to_string();
if !dir_val.trim().is_empty() {
cfg.output_directory = dir_val;
}
if let Some(dev) = audio_dev_combo.active_id() {
cfg.audio_device = dev.to_string();
}
cfg.autostart = auto_switch.is_active();
cfg.save_hotkey = save_hk_entry.text().to_string();
cfg.menu_hotkey = menu_hk_entry.text().to_string();
@ -528,15 +698,22 @@ pub fn show_menu_overlay() {
// Action Handlers
let replay_lbl_btn = replay_toggle_btn.clone();
let replay_pill_clone = replay_pill.clone();
replay_toggle_btn.connect_clicked(move |_| {
let mut cfg = VrecConfig::load();
cfg.replay_enabled = !cfg.replay_enabled;
let _ = cfg.save();
VrecConfig::notify_daemon_reload();
if cfg.replay_enabled {
replay_lbl_btn.set_label("Instant Replay\nEnabled");
replay_lbl_btn.set_label("Disable Replay");
replay_pill_clone.set_label("ACTIVE");
replay_pill_clone.style_context().remove_class("pill-idle");
replay_pill_clone.style_context().add_class("pill-active");
} else {
replay_lbl_btn.set_label("Instant Replay\nDisabled");
replay_lbl_btn.set_label("Enable Replay");
replay_pill_clone.set_label("OFF");
replay_pill_clone.style_context().remove_class("pill-active");
replay_pill_clone.style_context().add_class("pill-idle");
}
});
@ -549,14 +726,53 @@ pub fn show_menu_overlay() {
let _ = ipc::send_command(Command::ToggleRecording);
});
mic_toggle_btn.connect_clicked(|_| {
let _ = ipc::send_command(Command::ToggleAudio);
// Audio Source Mode Quick-Cycle
let audio_pill_clone = audio_pill.clone();
let audio_btn_clone = audio_cycle_btn.clone();
audio_cycle_btn.connect_clicked(move |_| {
let mut cfg = VrecConfig::load();
let next_mode = match cfg.audio_mode.as_str() {
"system" => "mic",
"mic" => "both",
"both" => "muted",
_ => "system",
};
cfg.audio_mode = next_mode.to_string();
let _ = cfg.save();
VrecConfig::notify_daemon_reload();
audio_pill_clone.style_context().remove_class("pill-active");
audio_pill_clone.style_context().remove_class("pill-cyan");
audio_pill_clone.style_context().remove_class("pill-purple");
audio_pill_clone.style_context().remove_class("pill-recording");
match next_mode {
"mic" => {
audio_pill_clone.set_label("MIC ONLY");
audio_pill_clone.style_context().add_class("pill-cyan");
audio_btn_clone.set_label("Microphone Only");
}
"both" => {
audio_pill_clone.set_label("SYSTEM + MIC");
audio_pill_clone.style_context().add_class("pill-purple");
audio_btn_clone.set_label("System + Mic");
}
"muted" => {
audio_pill_clone.set_label("MUTED");
audio_pill_clone.style_context().add_class("pill-recording");
audio_btn_clone.set_label("Muted");
}
_ => {
audio_pill_clone.set_label("SYSTEM ONLY");
audio_pill_clone.style_context().add_class("pill-active");
audio_btn_clone.set_label("System Audio Only");
}
}
});
// Periodic Live Status Query from Daemon (Syncs live recording state & timer!)
// Periodic Status Sync (Timer & Live States)
let rec_btn_sync = record_toggle_btn.clone();
let mic_btn_sync = mic_toggle_btn.clone();
let replay_btn_sync = replay_toggle_btn.clone();
let rec_pill_sync = record_pill.clone();
let is_running = Arc::new(AtomicBool::new(true));
let is_running_clone = Arc::clone(&is_running);
@ -569,23 +785,17 @@ pub fn show_menu_overlay() {
if status.is_recording {
let mins = status.recording_duration_sec / 60;
let secs = status.recording_duration_sec % 60;
rec_btn_sync.set_label(&format!("Recording\n{:02}:{:02}", mins, secs));
rec_btn_sync.set_label("Stop Recording");
rec_btn_sync.style_context().add_class("recording-active");
rec_pill_sync.set_label(&format!("REC {:02}:{:02}", mins, secs));
rec_pill_sync.style_context().remove_class("pill-idle");
rec_pill_sync.style_context().add_class("pill-recording");
} else {
rec_btn_sync.set_label("Record\nStart");
rec_btn_sync.set_label("Start Record");
rec_btn_sync.style_context().remove_class("recording-active");
}
if status.audio_muted {
mic_btn_sync.set_label("Microphone\nMuted");
} else {
mic_btn_sync.set_label("Microphone\nActive");
}
if status.is_replay_active {
replay_btn_sync.set_label("Instant Replay\nEnabled");
} else {
replay_btn_sync.set_label("Instant Replay\nDisabled");
rec_pill_sync.set_label("IDLE");
rec_pill_sync.style_context().remove_class("pill-recording");
rec_pill_sync.style_context().add_class("pill-idle");
}
}
gtk::glib::ControlFlow::Continue