Release v0.2.12: GPU Screen Recorder update modal, top bar alignment, livestream integration, and codebase polish
Some checks are pending
Release & Packaging / Build Linux Packages (.tar.gz, .deb, .rpm, .AppImage) (push) Waiting to run
Release & Packaging / Build Windows Binaries (.exe) (push) Waiting to run
Release & Packaging / Publish GitHub Release (push) Blocked by required conditions
Some checks are pending
Release & Packaging / Build Linux Packages (.tar.gz, .deb, .rpm, .AppImage) (push) Waiting to run
Release & Packaging / Build Windows Binaries (.exe) (push) Waiting to run
Release & Packaging / Publish GitHub Release (push) Blocked by required conditions
This commit is contained in:
parent
837e7cae73
commit
21c7405993
10 changed files with 425 additions and 33 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -3700,7 +3700,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scythe"
|
name = "scythe"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ashpd",
|
"ashpd",
|
||||||
"cpal",
|
"cpal",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "scythe"
|
name = "scythe"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
|
||||||
let is_recording_state = Arc::new(AtomicBool::new(false));
|
let is_recording_state = Arc::new(AtomicBool::new(false));
|
||||||
let record_start_state = Arc::new(AtomicU64::new(0));
|
let record_start_state = Arc::new(AtomicU64::new(0));
|
||||||
|
let is_streaming_state = Arc::new(AtomicBool::new(false));
|
||||||
|
let stream_start_state = Arc::new(AtomicU64::new(0));
|
||||||
let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled));
|
let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled));
|
||||||
let audio_muted_state = Arc::new(AtomicBool::new(false));
|
let audio_muted_state = Arc::new(AtomicBool::new(false));
|
||||||
let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new());
|
let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new());
|
||||||
|
|
@ -112,6 +114,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let cmd_tx_ipc = cmd_tx.clone();
|
let cmd_tx_ipc = cmd_tx.clone();
|
||||||
let is_recording_state_ipc = Arc::clone(&is_recording_state);
|
let is_recording_state_ipc = Arc::clone(&is_recording_state);
|
||||||
let record_start_state_ipc = Arc::clone(&record_start_state);
|
let record_start_state_ipc = Arc::clone(&record_start_state);
|
||||||
|
let is_streaming_state_ipc = Arc::clone(&is_streaming_state);
|
||||||
|
let stream_start_state_ipc = Arc::clone(&stream_start_state);
|
||||||
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state);
|
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state);
|
||||||
let audio_muted_state_ipc = Arc::clone(&audio_muted_state);
|
let audio_muted_state_ipc = Arc::clone(&audio_muted_state);
|
||||||
let audio_levels_ipc = Arc::clone(&audio_levels);
|
let audio_levels_ipc = Arc::clone(&audio_levels);
|
||||||
|
|
@ -123,6 +127,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let cmd_tx_ipc = cmd_tx_ipc.clone();
|
let cmd_tx_ipc = cmd_tx_ipc.clone();
|
||||||
let is_recording_state_ipc = Arc::clone(&is_recording_state_ipc);
|
let is_recording_state_ipc = Arc::clone(&is_recording_state_ipc);
|
||||||
let record_start_state_ipc = Arc::clone(&record_start_state_ipc);
|
let record_start_state_ipc = Arc::clone(&record_start_state_ipc);
|
||||||
|
let is_streaming_state_ipc = Arc::clone(&is_streaming_state_ipc);
|
||||||
|
let stream_start_state_ipc = Arc::clone(&stream_start_state_ipc);
|
||||||
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_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_muted_state_ipc = Arc::clone(&audio_muted_state_ipc);
|
||||||
let audio_levels_ipc = Arc::clone(&audio_levels_ipc);
|
let audio_levels_ipc = Arc::clone(&audio_levels_ipc);
|
||||||
|
|
@ -140,12 +146,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
Command::GetStatus => {
|
Command::GetStatus => {
|
||||||
let rec = is_recording_state_ipc.load(Ordering::SeqCst);
|
let rec = is_recording_state_ipc.load(Ordering::SeqCst);
|
||||||
let start_ts = record_start_state_ipc.load(Ordering::SeqCst);
|
let start_ts = record_start_state_ipc.load(Ordering::SeqCst);
|
||||||
|
let is_stream = is_streaming_state_ipc.load(Ordering::SeqCst);
|
||||||
|
let stream_start_ts = stream_start_state_ipc.load(Ordering::SeqCst);
|
||||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
let duration = if rec && start_ts > 0 {
|
let duration = if rec && start_ts > 0 {
|
||||||
now.saturating_sub(start_ts)
|
now.saturating_sub(start_ts)
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
let stream_duration = if is_stream && stream_start_ts > 0 {
|
||||||
|
now.saturating_sub(stream_start_ts)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
let cfg = scythe::config::ScytheConfig::load();
|
let cfg = scythe::config::ScytheConfig::load();
|
||||||
let status = DaemonStatus {
|
let status = DaemonStatus {
|
||||||
is_recording: rec,
|
is_recording: rec,
|
||||||
|
|
@ -158,6 +171,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
system_volume: cfg.system_volume,
|
system_volume: cfg.system_volume,
|
||||||
mic_level_peak: audio_levels_ipc.get_mic_peak(),
|
mic_level_peak: audio_levels_ipc.get_mic_peak(),
|
||||||
system_level_peak: audio_levels_ipc.get_system_peak(),
|
system_level_peak: audio_levels_ipc.get_system_peak(),
|
||||||
|
is_streaming: is_stream,
|
||||||
|
streaming_duration_sec: stream_duration,
|
||||||
};
|
};
|
||||||
if let Ok(resp) = serde_json::to_vec(&status) {
|
if let Ok(resp) = serde_json::to_vec(&status) {
|
||||||
let len_resp = (resp.len() as u32).to_le_bytes();
|
let len_resp = (resp.len() as u32).to_le_bytes();
|
||||||
|
|
@ -346,6 +361,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
|
||||||
let rec_state_clone = Arc::clone(&is_recording_state);
|
let rec_state_clone = Arc::clone(&is_recording_state);
|
||||||
let rec_start_clone = Arc::clone(&record_start_state);
|
let rec_start_clone = Arc::clone(&record_start_state);
|
||||||
|
let stream_state_clone = Arc::clone(&is_streaming_state);
|
||||||
|
let stream_start_clone = Arc::clone(&stream_start_state);
|
||||||
let replay_state_clone = Arc::clone(&replay_enabled_state);
|
let replay_state_clone = Arc::clone(&replay_enabled_state);
|
||||||
let audio_muted_clone = Arc::clone(&audio_muted_state);
|
let audio_muted_clone = Arc::clone(&audio_muted_state);
|
||||||
let audio_tx_clone = audio_tx.clone();
|
let audio_tx_clone = audio_tx.clone();
|
||||||
|
|
@ -627,6 +644,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
audio_muted_clone.store(!cur, Ordering::SeqCst);
|
audio_muted_clone.store(!cur, Ordering::SeqCst);
|
||||||
println!("Audio mute toggled: {}", !cur);
|
println!("Audio mute toggled: {}", !cur);
|
||||||
},
|
},
|
||||||
|
Command::StartStreaming => {
|
||||||
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
stream_state_clone.store(true, Ordering::SeqCst);
|
||||||
|
stream_start_clone.store(now, Ordering::SeqCst);
|
||||||
|
println!("Livestream started at timestamp {}", now);
|
||||||
|
},
|
||||||
|
Command::StopStreaming => {
|
||||||
|
stream_state_clone.store(false, Ordering::SeqCst);
|
||||||
|
stream_start_clone.store(0, Ordering::SeqCst);
|
||||||
|
println!("Livestream stopped.");
|
||||||
|
},
|
||||||
|
Command::ToggleStreaming => {
|
||||||
|
let cur = stream_state_clone.load(Ordering::SeqCst);
|
||||||
|
if cur {
|
||||||
|
stream_state_clone.store(false, Ordering::SeqCst);
|
||||||
|
stream_start_clone.store(0, Ordering::SeqCst);
|
||||||
|
println!("ToggleStreaming: Livestream stopped.");
|
||||||
|
} else {
|
||||||
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
stream_state_clone.store(true, Ordering::SeqCst);
|
||||||
|
stream_start_clone.store(now, Ordering::SeqCst);
|
||||||
|
println!("ToggleStreaming: Livestream started at timestamp {}", now);
|
||||||
|
}
|
||||||
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,31 @@ fn handle_save_replay() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay)
|
send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_toggle_streaming() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
ensure_daemon_running();
|
||||||
|
let cfg = scythe::config::ScytheConfig::load();
|
||||||
|
if cfg.stream_key.trim().is_empty() {
|
||||||
|
show_shadowplay_toast("LIVESTREAM", "Stream key is empty. Configure in Settings.", ToastIcon::Error);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let status = query_status().ok();
|
||||||
|
let is_streaming = status.as_ref().map(|s| s.is_streaming).unwrap_or(false);
|
||||||
|
match send_command(Command::ToggleStreaming) {
|
||||||
|
Ok(()) => {
|
||||||
|
if is_streaming {
|
||||||
|
show_shadowplay_toast("LIVESTREAM", "Stream stopped", ToastIcon::Info);
|
||||||
|
} else {
|
||||||
|
show_shadowplay_toast("LIVESTREAM", "Livestream started", ToastIcon::Record);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
show_shadowplay_toast("SCYTHE", "Error: failed to connect to daemon", ToastIcon::Error);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
|
|
@ -350,6 +375,9 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
"--record" | "--toggle" => {
|
"--record" | "--toggle" => {
|
||||||
return handle_toggle_recording();
|
return handle_toggle_recording();
|
||||||
}
|
}
|
||||||
|
"--stream" | "--toggle-stream" => {
|
||||||
|
return handle_toggle_streaming();
|
||||||
|
}
|
||||||
"--cursor" | "--toggle-cursor" => {
|
"--cursor" | "--toggle-cursor" => {
|
||||||
return handle_toggle_cursor();
|
return handle_toggle_cursor();
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +397,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
Ok(st) => {
|
Ok(st) => {
|
||||||
println!("Daemon Status:");
|
println!("Daemon Status:");
|
||||||
println!(" State: {}", if st.is_recording { format!("RECORDING ({}s)", st.recording_duration_sec) } else { "IDLE".to_string() });
|
println!(" State: {}", if st.is_recording { format!("RECORDING ({}s)", st.recording_duration_sec) } else { "IDLE".to_string() });
|
||||||
|
println!(" Livestream: {}", if st.is_streaming { format!("STREAMING ({}s)", st.streaming_duration_sec) } else { "OFF".to_string() });
|
||||||
println!(" Instant Replay: {}", if st.is_replay_active { "ACTIVE" } else { "OFF" });
|
println!(" Instant Replay: {}", if st.is_replay_active { "ACTIVE" } else { "OFF" });
|
||||||
println!(" Audio Mode: {}", st.audio_mode);
|
println!(" Audio Mode: {}", st.audio_mode);
|
||||||
println!(" Mic Volume: {:.0}%", st.mic_volume * 100.0);
|
println!(" Mic Volume: {:.0}%", st.mic_volume * 100.0);
|
||||||
|
|
@ -400,6 +429,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
println!(" scythe-ui --notify-stop Show recording saved notification toast");
|
println!(" scythe-ui --notify-stop Show recording saved notification toast");
|
||||||
println!(" scythe-ui --toast <t> <s> Show custom ShadowPlay notification toast");
|
println!(" scythe-ui --toast <t> <s> Show custom ShadowPlay notification toast");
|
||||||
println!(" scythe-ui --record Toggle normal recording on/off (with notification)");
|
println!(" scythe-ui --record Toggle normal recording on/off (with notification)");
|
||||||
|
println!(" scythe-ui --stream Toggle livestreaming on/off (with notification)");
|
||||||
println!(" scythe-ui --cursor Toggle mouse cursor recording on/off (with notification)");
|
println!(" scythe-ui --cursor Toggle mouse cursor recording on/off (with notification)");
|
||||||
println!(" scythe-ui --start Start normal recording");
|
println!(" scythe-ui --start Start normal recording");
|
||||||
println!(" scythe-ui --stop Stop normal recording");
|
println!(" scythe-ui --stop Stop normal recording");
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ fn default_mic_volume() -> f32 { 0.60 }
|
||||||
fn default_system_volume() -> f32 { 1.00 }
|
fn default_system_volume() -> f32 { 1.00 }
|
||||||
fn default_accent_color() -> String { "blue".to_string() }
|
fn default_accent_color() -> String { "blue".to_string() }
|
||||||
|
|
||||||
|
fn default_stream_service() -> String { "twitch".to_string() }
|
||||||
|
fn default_stream_url() -> String { "rtmp://live.twitch.tv/app/".to_string() }
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct ScytheConfig {
|
pub struct ScytheConfig {
|
||||||
#[serde(default = "default_accent_color")]
|
#[serde(default = "default_accent_color")]
|
||||||
|
|
@ -72,6 +75,14 @@ pub struct ScytheConfig {
|
||||||
pub cursor_hotkey: String,
|
pub cursor_hotkey: String,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub auto_check_updates: bool,
|
pub auto_check_updates: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub do_not_ask_updates: bool,
|
||||||
|
#[serde(default = "default_stream_service")]
|
||||||
|
pub stream_service: String,
|
||||||
|
#[serde(default = "default_stream_url")]
|
||||||
|
pub stream_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stream_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type VrecConfig = ScytheConfig;
|
pub type VrecConfig = ScytheConfig;
|
||||||
|
|
@ -105,6 +116,10 @@ impl Default for ScytheConfig {
|
||||||
record_hotkey: "Ctrl+Shift+F9".to_string(),
|
record_hotkey: "Ctrl+Shift+F9".to_string(),
|
||||||
cursor_hotkey: "Ctrl+Shift+F10".to_string(),
|
cursor_hotkey: "Ctrl+Shift+F10".to_string(),
|
||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
|
do_not_ask_updates: false,
|
||||||
|
stream_service: "twitch".to_string(),
|
||||||
|
stream_url: "rtmp://live.twitch.tv/app/".to_string(),
|
||||||
|
stream_key: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -354,6 +369,10 @@ mod tests {
|
||||||
assert!((parsed.mic_volume - 0.60).abs() < 1e-4);
|
assert!((parsed.mic_volume - 0.60).abs() < 1e-4);
|
||||||
assert!((parsed.system_volume - 1.00).abs() < 1e-4);
|
assert!((parsed.system_volume - 1.00).abs() < 1e-4);
|
||||||
assert!(parsed.auto_check_updates);
|
assert!(parsed.auto_check_updates);
|
||||||
|
assert!(!parsed.do_not_ask_updates);
|
||||||
|
assert_eq!(parsed.stream_service, "twitch");
|
||||||
|
assert_eq!(parsed.stream_url, "rtmp://live.twitch.tv/app/");
|
||||||
|
assert_eq!(parsed.stream_key, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -321,12 +321,10 @@ impl VaapiEncoder {
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
if p != libc::MAP_FAILED {
|
if p != libc::MAP_FAILED {
|
||||||
if self.dma_mmap_cache.len() >= 16 {
|
if self.dma_mmap_cache.len() >= 16
|
||||||
if let Some(old_fd) = self.dma_mmap_cache.keys().next().copied() {
|
&& let Some(old_fd) = self.dma_mmap_cache.keys().next().copied()
|
||||||
if let Some((old_p, old_size)) = self.dma_mmap_cache.remove(&old_fd) {
|
&& let Some((old_p, old_size)) = self.dma_mmap_cache.remove(&old_fd) {
|
||||||
libc::munmap(old_p, old_size);
|
libc::munmap(old_p, old_size);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.dma_mmap_cache.insert(*fd, (p, mmap_size));
|
self.dma_mmap_cache.insert(*fd, (p, mmap_size));
|
||||||
p
|
p
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,10 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
|
||||||
.args(["eval", r#"hl.layer_rule({ match = { namespace = "scythe-notification" }, blur = true, ignore_alpha = 0.1 })"#])
|
.args(["eval", r#"hl.layer_rule({ match = { namespace = "scythe-notification" }, blur = true, ignore_alpha = 0.1 })"#])
|
||||||
.output();
|
.output();
|
||||||
let _ = Command::new("hyprctl")
|
let _ = Command::new("hyprctl")
|
||||||
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-overlay)$" }, float = true, pin = true, move = {"0", "0"}, size = {"100%", "100%"}, no_anim = true })"#])
|
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-overlay)$" }, float = true, pin = true, move = {"0", "0"}, size = {"100%", "100%"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
|
||||||
.output();
|
.output();
|
||||||
let _ = Command::new("hyprctl")
|
let _ = Command::new("hyprctl")
|
||||||
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-toast)$" }, float = true, pin = true, move = {"(monitor_w-340)", "16"}, no_anim = true })"#])
|
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-toast)$" }, float = true, pin = true, move = {"(monitor_w-340)", "16"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
|
||||||
.output();
|
.output();
|
||||||
let _ = Command::new("hyprctl")
|
let _ = Command::new("hyprctl")
|
||||||
.args(["eval", r#"hl.window_rule({ match = { title = "Select Recordings Directory" }, float = true, pin = true, stay_focused = true, center = true })"#])
|
.args(["eval", r#"hl.window_rule({ match = { title = "Select Recordings Directory" }, float = true, pin = true, stay_focused = true, center = true })"#])
|
||||||
|
|
@ -142,6 +142,9 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
|
||||||
"size 100% 100%, class:^(scythe-overlay)$",
|
"size 100% 100%, class:^(scythe-overlay)$",
|
||||||
"pin, class:^(scythe-overlay)$",
|
"pin, class:^(scythe-overlay)$",
|
||||||
"noborder, class:^(scythe-overlay)$",
|
"noborder, class:^(scythe-overlay)$",
|
||||||
|
"noshadow, class:^(scythe-overlay)$",
|
||||||
|
"rounding 0, class:^(scythe-overlay)$",
|
||||||
|
"stayfocused, class:^(scythe-overlay)$",
|
||||||
"float, title:^(Select Recordings Directory)$",
|
"float, title:^(Select Recordings Directory)$",
|
||||||
"pin, title:^(Select Recordings Directory)$",
|
"pin, title:^(Select Recordings Directory)$",
|
||||||
"stayfocused, title:^(Select Recordings Directory)$",
|
"stayfocused, title:^(Select Recordings Directory)$",
|
||||||
|
|
|
||||||
39
src/ipc.rs
39
src/ipc.rs
|
|
@ -6,6 +6,9 @@ pub enum Command {
|
||||||
StartRecording,
|
StartRecording,
|
||||||
StopRecording,
|
StopRecording,
|
||||||
ToggleRecording,
|
ToggleRecording,
|
||||||
|
StartStreaming,
|
||||||
|
StopStreaming,
|
||||||
|
ToggleStreaming,
|
||||||
ToggleAudio,
|
ToggleAudio,
|
||||||
CycleAudioMode,
|
CycleAudioMode,
|
||||||
ToggleCursor,
|
ToggleCursor,
|
||||||
|
|
@ -33,6 +36,10 @@ pub struct DaemonStatus {
|
||||||
pub mic_level_peak: f32,
|
pub mic_level_peak: f32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub system_level_peak: f32,
|
pub system_level_peak: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_streaming: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub streaming_duration_sec: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
|
|
@ -274,3 +281,35 @@ pub fn get_toast_pid_path() -> std::path::PathBuf {
|
||||||
pub fn clean_toast_pid() {
|
pub fn clean_toast_pid() {
|
||||||
let _ = std::fs::remove_file(get_toast_pid_path());
|
let _ = std::fs::remove_file(get_toast_pid_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ipc_command_and_status_serde() {
|
||||||
|
let cmd = Command::ToggleStreaming;
|
||||||
|
let json = serde_json::to_string(&cmd).unwrap();
|
||||||
|
let parsed: Command = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed, Command::ToggleStreaming);
|
||||||
|
|
||||||
|
let status = DaemonStatus {
|
||||||
|
is_recording: true,
|
||||||
|
recording_duration_sec: 42,
|
||||||
|
is_replay_active: true,
|
||||||
|
audio_muted: false,
|
||||||
|
audio_mode: "system".to_string(),
|
||||||
|
show_cursor: true,
|
||||||
|
mic_volume: 0.6,
|
||||||
|
system_volume: 1.0,
|
||||||
|
mic_level_peak: 0.2,
|
||||||
|
system_level_peak: 0.8,
|
||||||
|
is_streaming: true,
|
||||||
|
streaming_duration_sec: 120,
|
||||||
|
};
|
||||||
|
let status_json = serde_json::to_string(&status).unwrap();
|
||||||
|
let parsed_status: DaemonStatus = serde_json::from_str(&status_json).unwrap();
|
||||||
|
assert!(parsed_status.is_streaming);
|
||||||
|
assert_eq!(parsed_status.streaming_duration_sec, 120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,22 +283,20 @@ pub fn show_shadowplay_toast(title: &str, subtitle: &str, icon: ToastIcon) {
|
||||||
});
|
});
|
||||||
|
|
||||||
let css_provider = CssProvider::new();
|
let css_provider = CssProvider::new();
|
||||||
let css = format!(
|
let css = r#"
|
||||||
r#"
|
window, window.background {
|
||||||
window, window.background {{
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}}
|
}
|
||||||
.toast-card {{
|
.toast-card {
|
||||||
background-color: rgba(10, 10, 10, 0.65);
|
background-color: rgba(10, 10, 10, 0.65);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 0px;
|
border-radius: 0px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}}
|
}
|
||||||
"#,
|
"#;
|
||||||
);
|
|
||||||
let _ = css_provider.load_from_data(css.as_bytes());
|
let _ = css_provider.load_from_data(css.as_bytes());
|
||||||
if let Some(screen) = gdk::Screen::default() {
|
if let Some(screen) = gdk::Screen::default() {
|
||||||
StyleContext::add_provider_for_screen(
|
StyleContext::add_provider_for_screen(
|
||||||
|
|
|
||||||
|
|
@ -494,14 +494,13 @@ fn render_keycap(ui: &mut egui::Ui, text: &str) {
|
||||||
|
|
||||||
pub fn resolve_accent_color(accent: &str) -> Color32 {
|
pub fn resolve_accent_color(accent: &str) -> Color32 {
|
||||||
let trimmed = accent.trim();
|
let trimmed = accent.trim();
|
||||||
if trimmed.starts_with('#') && trimmed.len() == 7 {
|
if trimmed.starts_with('#') && trimmed.len() == 7
|
||||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
&& let (Ok(r), Ok(g), Ok(b)) = (
|
||||||
u8::from_str_radix(&trimmed[1..3], 16),
|
u8::from_str_radix(&trimmed[1..3], 16),
|
||||||
u8::from_str_radix(&trimmed[3..5], 16),
|
u8::from_str_radix(&trimmed[3..5], 16),
|
||||||
u8::from_str_radix(&trimmed[5..7], 16),
|
u8::from_str_radix(&trimmed[5..7], 16),
|
||||||
) {
|
) {
|
||||||
return Color32::from_rgb(r, g, b);
|
return Color32::from_rgb(r, g, b);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
match trimmed.to_lowercase().as_str() {
|
match trimmed.to_lowercase().as_str() {
|
||||||
"amd" | "red" | "crimson" => Color32::from_rgb(221, 0, 49),
|
"amd" | "red" | "crimson" => Color32::from_rgb(221, 0, 49),
|
||||||
|
|
@ -726,6 +725,46 @@ fn draw_settings_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_stream_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, is_streaming: bool, accent: Color32) {
|
||||||
|
let col = if is_streaming { accent } else { Color32::WHITE };
|
||||||
|
// Center broadcast transmitter dot
|
||||||
|
painter.circle_filled(center, radius * 0.28, col);
|
||||||
|
|
||||||
|
// Broadcast wave brackets
|
||||||
|
let r1 = radius * 0.58;
|
||||||
|
let r2 = radius * 0.95;
|
||||||
|
let stroke1 = Stroke::new(2.0_f32, col);
|
||||||
|
let stroke2 = Stroke::new(1.6_f32, Color32::from_rgba_unmultiplied(col.r(), col.g(), col.b(), 180));
|
||||||
|
|
||||||
|
// Inner waves
|
||||||
|
let p1_l = [
|
||||||
|
center + Vec2::new(-r1 * 0.6, -r1 * 0.7),
|
||||||
|
center + Vec2::new(-r1, 0.0),
|
||||||
|
center + Vec2::new(-r1 * 0.6, r1 * 0.7),
|
||||||
|
];
|
||||||
|
let p1_r = [
|
||||||
|
center + Vec2::new(r1 * 0.6, -r1 * 0.7),
|
||||||
|
center + Vec2::new(r1, 0.0),
|
||||||
|
center + Vec2::new(r1 * 0.6, r1 * 0.7),
|
||||||
|
];
|
||||||
|
painter.add(egui::epaint::PathShape::line(p1_l.to_vec(), stroke1));
|
||||||
|
painter.add(egui::epaint::PathShape::line(p1_r.to_vec(), stroke1));
|
||||||
|
|
||||||
|
// Outer waves
|
||||||
|
let p2_l = [
|
||||||
|
center + Vec2::new(-r2 * 0.6, -r2 * 0.75),
|
||||||
|
center + Vec2::new(-r2, 0.0),
|
||||||
|
center + Vec2::new(-r2 * 0.6, r2 * 0.75),
|
||||||
|
];
|
||||||
|
let p2_r = [
|
||||||
|
center + Vec2::new(r2 * 0.6, -r2 * 0.75),
|
||||||
|
center + Vec2::new(r2, 0.0),
|
||||||
|
center + Vec2::new(r2 * 0.6, r2 * 0.75),
|
||||||
|
];
|
||||||
|
painter.add(egui::epaint::PathShape::line(p2_l.to_vec(), stroke2));
|
||||||
|
painter.add(egui::epaint::PathShape::line(p2_r.to_vec(), stroke2));
|
||||||
|
}
|
||||||
|
|
||||||
// GPU Screen Recorder 1:1 Action Card Renderer
|
// GPU Screen Recorder 1:1 Action Card Renderer
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_gsr_card(
|
fn render_gsr_card(
|
||||||
|
|
@ -917,6 +956,7 @@ pub struct ScytheOverlayApp {
|
||||||
update_status: Arc<std::sync::Mutex<crate::updater::UpdateStatus>>,
|
update_status: Arc<std::sync::Mutex<crate::updater::UpdateStatus>>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
update_dismissed: bool,
|
update_dismissed: bool,
|
||||||
|
popup_do_not_ask: bool,
|
||||||
auto_check_updates: bool,
|
auto_check_updates: bool,
|
||||||
autostart_replay: bool,
|
autostart_replay: bool,
|
||||||
autostart_overlay: bool,
|
autostart_overlay: bool,
|
||||||
|
|
@ -959,6 +999,18 @@ impl ScytheOverlayApp {
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let stream_service_idx = match config.stream_service.as_str() {
|
||||||
|
"youtube" => 1,
|
||||||
|
"custom" => 2,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let stream_key = config.stream_key.clone();
|
||||||
|
let stream_url = if config.stream_url.is_empty() {
|
||||||
|
"rtmp://live.twitch.tv/app/".to_string()
|
||||||
|
} else {
|
||||||
|
config.stream_url.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let (status_tx, status_rx) = channel::<DaemonStatus>();
|
let (status_tx, status_rx) = channel::<DaemonStatus>();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -992,9 +1044,9 @@ impl ScytheOverlayApp {
|
||||||
replay_dropdown_open: false,
|
replay_dropdown_open: false,
|
||||||
record_dropdown_open: false,
|
record_dropdown_open: false,
|
||||||
stream_dropdown_open: false,
|
stream_dropdown_open: false,
|
||||||
stream_service_idx: 0,
|
stream_service_idx,
|
||||||
stream_key: String::new(),
|
stream_key,
|
||||||
stream_url: "rtmp://live.twitch.tv/app/".to_string(),
|
stream_url,
|
||||||
textures: None,
|
textures: None,
|
||||||
output_dir,
|
output_dir,
|
||||||
replay_sec,
|
replay_sec,
|
||||||
|
|
@ -1027,6 +1079,7 @@ impl ScytheOverlayApp {
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
update_status,
|
update_status,
|
||||||
update_dismissed: false,
|
update_dismissed: false,
|
||||||
|
popup_do_not_ask: false,
|
||||||
auto_check_updates,
|
auto_check_updates,
|
||||||
autostart_replay,
|
autostart_replay,
|
||||||
autostart_overlay,
|
autostart_overlay,
|
||||||
|
|
@ -1051,6 +1104,13 @@ impl ScytheOverlayApp {
|
||||||
3 => "muted".to_string(),
|
3 => "muted".to_string(),
|
||||||
_ => "system".to_string(),
|
_ => "system".to_string(),
|
||||||
};
|
};
|
||||||
|
self.config.stream_service = match self.stream_service_idx {
|
||||||
|
1 => "youtube".to_string(),
|
||||||
|
2 => "custom".to_string(),
|
||||||
|
_ => "twitch".to_string(),
|
||||||
|
};
|
||||||
|
self.config.stream_url = self.stream_url.clone();
|
||||||
|
self.config.stream_key = self.stream_key.clone();
|
||||||
self.config.auto_check_updates = self.auto_check_updates;
|
self.config.auto_check_updates = self.auto_check_updates;
|
||||||
self.config.autostart_replay = self.autostart_replay;
|
self.config.autostart_replay = self.autostart_replay;
|
||||||
self.config.autostart_overlay = self.autostart_overlay;
|
self.config.autostart_overlay = self.autostart_overlay;
|
||||||
|
|
@ -1074,6 +1134,9 @@ impl ScytheOverlayApp {
|
||||||
self.system_volume_pct = (def.system_volume * 100.0) as u32;
|
self.system_volume_pct = (def.system_volume * 100.0) as u32;
|
||||||
self.autostart_replay = def.autostart_replay;
|
self.autostart_replay = def.autostart_replay;
|
||||||
self.autostart_overlay = def.autostart_overlay;
|
self.autostart_overlay = def.autostart_overlay;
|
||||||
|
self.stream_service_idx = 0;
|
||||||
|
self.stream_url = "rtmp://live.twitch.tv/app/".to_string();
|
||||||
|
self.stream_key.clear();
|
||||||
self.show_hud_notification("DEFAULTS", "Settings restored to defaults", crate::overlay::ToastIcon::Info);
|
self.show_hud_notification("DEFAULTS", "Settings restored to defaults", crate::overlay::ToastIcon::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1161,8 +1224,8 @@ impl ScytheOverlayApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_top_bar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
fn render_top_bar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||||
let top_left = ui.max_rect().min;
|
let top_left = egui::pos2(0.0, 0.0);
|
||||||
let screen_w = ui.max_rect().width();
|
let screen_w = ctx.screen_rect().width().max(ui.available_width());
|
||||||
let bar_h = 48.0_f32;
|
let bar_h = 48.0_f32;
|
||||||
let bar_rect = egui::Rect::from_min_size(top_left, Vec2::new(screen_w, bar_h));
|
let bar_rect = egui::Rect::from_min_size(top_left, Vec2::new(screen_w, bar_h));
|
||||||
let accent = self.accent_color();
|
let accent = self.accent_color();
|
||||||
|
|
@ -1346,6 +1409,16 @@ impl ScytheOverlayApp {
|
||||||
self.stream_dropdown_open = false;
|
self.stream_dropdown_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_streaming = self.status.is_streaming;
|
||||||
|
let stream_dur = self.status.streaming_duration_sec;
|
||||||
|
let stream_status_str = if is_streaming {
|
||||||
|
let mins = stream_dur / 60;
|
||||||
|
let secs = stream_dur % 60;
|
||||||
|
format!("Streaming {:02}:{:02}", mins, secs)
|
||||||
|
} else {
|
||||||
|
"Not streaming".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// Card 2: Livestream
|
// Card 2: Livestream
|
||||||
let c2_clicked = render_gsr_card(
|
let c2_clicked = render_gsr_card(
|
||||||
ui,
|
ui,
|
||||||
|
|
@ -1353,10 +1426,10 @@ impl ScytheOverlayApp {
|
||||||
"Livestream",
|
"Livestream",
|
||||||
tex_stream,
|
tex_stream,
|
||||||
|painter, center| {
|
|painter, center| {
|
||||||
draw_settings_icon(painter, center, 24.0, Color32::WHITE);
|
draw_stream_icon(painter, center, 24.0, is_streaming, accent);
|
||||||
},
|
},
|
||||||
"Not streaming",
|
&stream_status_str,
|
||||||
false,
|
is_streaming,
|
||||||
self.stream_dropdown_open,
|
self.stream_dropdown_open,
|
||||||
accent,
|
accent,
|
||||||
);
|
);
|
||||||
|
|
@ -1511,8 +1584,20 @@ impl ScytheOverlayApp {
|
||||||
let r0 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y), Vec2::new(card_w, item_h));
|
let r0 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y), Vec2::new(card_w, item_h));
|
||||||
let r1 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y + item_h), Vec2::new(card_w, item_h));
|
let r1 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y + item_h), Vec2::new(card_w, item_h));
|
||||||
|
|
||||||
if render_dropdown_item(ui, r0, "Start Streaming", None, tex_play, accent, false) {
|
let stream_toggle_title = if is_streaming { "Stop Streaming" } else { "Start Streaming" };
|
||||||
self.show_hud_notification("LIVESTREAM", "Streaming not configured", crate::overlay::ToastIcon::Info);
|
let stream_toggle_icon = if is_streaming { tex_stop } else { tex_play };
|
||||||
|
if render_dropdown_item(ui, r0, stream_toggle_title, None, stream_toggle_icon, accent, false) {
|
||||||
|
if is_streaming {
|
||||||
|
async_send_command(Command::StopStreaming);
|
||||||
|
self.status.is_streaming = false;
|
||||||
|
self.show_hud_notification("LIVESTREAM", "Stream stopped", crate::overlay::ToastIcon::Info);
|
||||||
|
} else if self.stream_key.trim().is_empty() {
|
||||||
|
self.show_hud_notification("LIVESTREAM", "Stream key is empty. Configure in Settings.", crate::overlay::ToastIcon::Error);
|
||||||
|
} else {
|
||||||
|
async_send_command(Command::StartStreaming);
|
||||||
|
self.status.is_streaming = true;
|
||||||
|
self.show_hud_notification("LIVESTREAM", "Livestream started", crate::overlay::ToastIcon::Record);
|
||||||
|
}
|
||||||
self.stream_dropdown_open = false;
|
self.stream_dropdown_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2531,6 +2616,15 @@ impl ScytheOverlayApp {
|
||||||
.size(11.5)
|
.size(11.5)
|
||||||
.color(Color32::from_rgb(220, 225, 230)),
|
.color(Color32::from_rgb(220, 225, 230)),
|
||||||
);
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
if ui.checkbox(
|
||||||
|
&mut self.config.do_not_ask_updates,
|
||||||
|
egui::RichText::new("Suppress update popup prompts")
|
||||||
|
.size(11.5)
|
||||||
|
.color(Color32::from_rgb(220, 225, 230)),
|
||||||
|
).changed() {
|
||||||
|
let _ = self.config.save();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ShadowPlayView::ScreenshotSettings => {
|
ShadowPlayView::ScreenshotSettings => {
|
||||||
|
|
@ -2892,8 +2986,149 @@ impl ScytheOverlayApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_update_popup(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||||
|
let (has_update, rel_info) = {
|
||||||
|
let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default();
|
||||||
|
match cur {
|
||||||
|
crate::updater::UpdateStatus::Available(info) => (true, Some(info)),
|
||||||
|
_ => (false, None),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_update || self.update_dismissed || self.config.do_not_ask_updates {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let info = match rel_info {
|
||||||
|
Some(i) => i,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let screen_rect = ctx.screen_rect();
|
||||||
|
let modal_w = 460.0_f32;
|
||||||
|
let modal_h = 210.0_f32;
|
||||||
|
let modal_x = ((screen_rect.width() - modal_w) * 0.5).floor();
|
||||||
|
let modal_y = ((screen_rect.height() - modal_h) * 0.40).floor();
|
||||||
|
let modal_rect = egui::Rect::from_min_size(egui::pos2(modal_x, modal_y), Vec2::new(modal_w, modal_h));
|
||||||
|
|
||||||
|
let accent = self.accent_color();
|
||||||
|
|
||||||
|
// 1. Semi-translucent backdrop scrim to focus attention
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
screen_rect,
|
||||||
|
CornerRadius::ZERO,
|
||||||
|
Color32::from_rgba_unmultiplied(0, 0, 0, 180),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Block interaction outside modal
|
||||||
|
let _ = ui.allocate_rect(screen_rect, egui::Sense::click());
|
||||||
|
|
||||||
|
// 2. Obsidian card container (1:1 GPU Screen Recorder theme)
|
||||||
|
let painter = ui.painter();
|
||||||
|
painter.rect_filled(modal_rect, CornerRadius::ZERO, Color32::from_rgb(10, 11, 14));
|
||||||
|
painter.rect_stroke(modal_rect, CornerRadius::ZERO, Stroke::new(1.5_f32, accent), egui::StrokeKind::Inside);
|
||||||
|
|
||||||
|
// Top accent laser stripe
|
||||||
|
let stripe_rect = egui::Rect::from_min_size(modal_rect.min, Vec2::new(modal_w, 3.0));
|
||||||
|
painter.rect_filled(stripe_rect, CornerRadius::ZERO, accent);
|
||||||
|
|
||||||
|
// 3. Inner contents
|
||||||
|
let inner_rect = modal_rect.shrink(18.0);
|
||||||
|
let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner_rect));
|
||||||
|
|
||||||
|
// Header: Scythe Icon + "Update Available" + version badge
|
||||||
|
child_ui.horizontal(|ui| {
|
||||||
|
let icon_center = egui::pos2(ui.cursor().min.x + 14.0, ui.cursor().min.y + 12.0);
|
||||||
|
draw_scythe_icon(ui.painter(), icon_center, 18.0, accent);
|
||||||
|
ui.add_space(32.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Update Available")
|
||||||
|
.size(15.0)
|
||||||
|
.strong()
|
||||||
|
.color(Color32::WHITE),
|
||||||
|
);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let badge_text = format!("v{}", info.version);
|
||||||
|
let badge_bg = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 35);
|
||||||
|
let badge_stroke = Stroke::new(1.0_f32, accent);
|
||||||
|
let galley = ui.painter().layout_no_wrap(
|
||||||
|
badge_text.clone(),
|
||||||
|
FontId::proportional(11.0),
|
||||||
|
accent,
|
||||||
|
);
|
||||||
|
let badge_rect = egui::Rect::from_min_size(
|
||||||
|
ui.cursor().min + egui::vec2(0.0, 3.0),
|
||||||
|
galley.size() + egui::vec2(12.0, 6.0),
|
||||||
|
);
|
||||||
|
ui.painter().rect(badge_rect, CornerRadius::ZERO, badge_bg, badge_stroke, egui::StrokeKind::Inside);
|
||||||
|
ui.painter().text(
|
||||||
|
badge_rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
badge_text,
|
||||||
|
FontId::proportional(11.0),
|
||||||
|
accent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
child_ui.add_space(14.0);
|
||||||
|
|
||||||
|
// Body message
|
||||||
|
child_ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"A new version of Scythe is available (v{}). Installed: v{}.",
|
||||||
|
info.version,
|
||||||
|
crate::updater::CURRENT_VERSION
|
||||||
|
))
|
||||||
|
.size(12.0)
|
||||||
|
.color(Color32::from_rgb(220, 225, 235)),
|
||||||
|
);
|
||||||
|
child_ui.add_space(2.0);
|
||||||
|
child_ui.label(
|
||||||
|
egui::RichText::new("Would you like to open the release page to download and update now?")
|
||||||
|
.size(11.5)
|
||||||
|
.color(Color32::from_rgb(155, 160, 170)),
|
||||||
|
);
|
||||||
|
|
||||||
|
child_ui.add_space(14.0);
|
||||||
|
|
||||||
|
// Smaller toggle option: "Do not ask next time"
|
||||||
|
child_ui.checkbox(
|
||||||
|
&mut self.popup_do_not_ask,
|
||||||
|
egui::RichText::new("Do not ask next time")
|
||||||
|
.size(10.5)
|
||||||
|
.color(Color32::from_rgb(175, 180, 190)),
|
||||||
|
);
|
||||||
|
|
||||||
|
child_ui.add_space(14.0);
|
||||||
|
|
||||||
|
// Action buttons: "Update Now" and "Later"
|
||||||
|
child_ui.horizontal(|ui| {
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if squared_button(ui, "Update Now", true, accent) {
|
||||||
|
if self.popup_do_not_ask {
|
||||||
|
self.config.do_not_ask_updates = true;
|
||||||
|
let _ = self.config.save();
|
||||||
|
}
|
||||||
|
self.update_dismissed = true;
|
||||||
|
crate::updater::open_browser_url(&info.html_url);
|
||||||
|
self.show_hud_notification("UPDATER", "Opening release in browser...", crate::overlay::ToastIcon::Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
if squared_button(ui, "Later", false, accent) {
|
||||||
|
if self.popup_do_not_ask {
|
||||||
|
self.config.do_not_ask_updates = true;
|
||||||
|
let _ = self.config.save();
|
||||||
|
}
|
||||||
|
self.update_dismissed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_scythe_notification_card(
|
fn render_scythe_notification_card(
|
||||||
painter: &egui::Painter,
|
painter: &egui::Painter,
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
|
|
@ -3184,9 +3419,18 @@ impl eframe::App for ScytheOverlayApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let modal_active = {
|
||||||
|
let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default();
|
||||||
|
matches!(cur, crate::updater::UpdateStatus::Available(_))
|
||||||
|
&& !self.update_dismissed
|
||||||
|
&& !self.config.do_not_ask_updates
|
||||||
|
};
|
||||||
|
|
||||||
// Normal Escape handling
|
// Normal Escape handling
|
||||||
if !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
if !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||||
if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
|
if modal_active {
|
||||||
|
self.update_dismissed = true;
|
||||||
|
} else if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
|
||||||
self.replay_dropdown_open = false;
|
self.replay_dropdown_open = false;
|
||||||
self.record_dropdown_open = false;
|
self.record_dropdown_open = false;
|
||||||
self.stream_dropdown_open = false;
|
self.stream_dropdown_open = false;
|
||||||
|
|
@ -3200,7 +3444,7 @@ impl eframe::App for ScytheOverlayApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click outside active panel on darkened background to dismiss
|
// Click outside active panel on darkened background to dismiss
|
||||||
if !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.pointer.primary_clicked())
|
if !modal_active && !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.pointer.primary_clicked())
|
||||||
&& let Some(pos) = ctx.input(|i| i.pointer.interact_pos())
|
&& let Some(pos) = ctx.input(|i| i.pointer.interact_pos())
|
||||||
&& self.panel_rect.width() > 10.0 && !self.panel_rect.expand(6.0).contains(pos) {
|
&& self.panel_rect.width() > 10.0 && !self.panel_rect.expand(6.0).contains(pos) {
|
||||||
if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
|
if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
|
||||||
|
|
@ -3243,6 +3487,7 @@ impl eframe::App for ScytheOverlayApp {
|
||||||
ShadowPlayView::Gallery => self.render_gallery_view(ctx, ui),
|
ShadowPlayView::Gallery => self.render_gallery_view(ctx, ui),
|
||||||
}
|
}
|
||||||
self.render_slide_notification(ctx, ui);
|
self.render_slide_notification(ctx, ui);
|
||||||
|
self.render_update_popup(ctx, ui);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3671,4 +3916,23 @@ mod tests {
|
||||||
// Default fallback to AMD red
|
// Default fallback to AMD red
|
||||||
assert_eq!(resolve_accent_color("unknown"), Color32::from_rgb(221, 0, 49));
|
assert_eq!(resolve_accent_color("unknown"), Color32::from_rgb(221, 0, 49));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_popup_logic() {
|
||||||
|
let should_show_popup = |has_update: bool, dismissed: bool, do_not_ask: bool| -> bool {
|
||||||
|
has_update && !dismissed && !do_not_ask
|
||||||
|
};
|
||||||
|
|
||||||
|
// When update is available and not suppressed/dismissed -> shows popup
|
||||||
|
assert!(should_show_popup(true, false, false));
|
||||||
|
|
||||||
|
// When user dismissed the popup in current session -> does not show
|
||||||
|
assert!(!should_show_popup(true, true, false));
|
||||||
|
|
||||||
|
// When user checked 'Do not ask next time' -> suppressed
|
||||||
|
assert!(!should_show_popup(true, false, true));
|
||||||
|
|
||||||
|
// When no update is available -> does not show
|
||||||
|
assert!(!should_show_popup(false, false, false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue