diff --git a/Cargo.lock b/Cargo.lock index 5e46bfe..63bf36f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,7 +3700,7 @@ dependencies = [ [[package]] name = "scythe" -version = "0.2.11" +version = "0.2.12" dependencies = [ "ashpd", "cpal", diff --git a/Cargo.toml b/Cargo.toml index 21d9f40..37a9f42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scythe" -version = "0.2.11" +version = "0.2.12" edition = "2024" [[bin]] diff --git a/src/bin/scythe-daemon.rs b/src/bin/scythe-daemon.rs index e238bca..c3f9379 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/bin/scythe-daemon.rs @@ -65,6 +65,8 @@ async fn main() -> Result<(), Box> { let is_recording_state = Arc::new(AtomicBool::new(false)); 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 audio_muted_state = Arc::new(AtomicBool::new(false)); let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new()); @@ -112,6 +114,8 @@ async fn main() -> Result<(), Box> { let cmd_tx_ipc = cmd_tx.clone(); let is_recording_state_ipc = Arc::clone(&is_recording_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 audio_muted_state_ipc = Arc::clone(&audio_muted_state); let audio_levels_ipc = Arc::clone(&audio_levels); @@ -123,6 +127,8 @@ async fn main() -> Result<(), Box> { let cmd_tx_ipc = cmd_tx_ipc.clone(); let is_recording_state_ipc = Arc::clone(&is_recording_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 audio_muted_state_ipc = Arc::clone(&audio_muted_state_ipc); let audio_levels_ipc = Arc::clone(&audio_levels_ipc); @@ -140,12 +146,19 @@ async fn main() -> Result<(), Box> { Command::GetStatus => { let rec = is_recording_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 duration = if rec && start_ts > 0 { now.saturating_sub(start_ts) } else { 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 status = DaemonStatus { is_recording: rec, @@ -158,6 +171,8 @@ async fn main() -> Result<(), Box> { system_volume: cfg.system_volume, mic_level_peak: audio_levels_ipc.get_mic_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) { let len_resp = (resp.len() as u32).to_le_bytes(); @@ -346,6 +361,8 @@ async fn main() -> Result<(), Box> { let rec_state_clone = Arc::clone(&is_recording_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 audio_muted_clone = Arc::clone(&audio_muted_state); let audio_tx_clone = audio_tx.clone(); @@ -627,6 +644,30 @@ async fn main() -> Result<(), Box> { audio_muted_clone.store(!cur, Ordering::SeqCst); 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); + } + }, _ => {} } } diff --git a/src/bin/scythe-ui.rs b/src/bin/scythe-ui.rs index c4e8bc3..f560ce7 100644 --- a/src/bin/scythe-ui.rs +++ b/src/bin/scythe-ui.rs @@ -279,6 +279,31 @@ fn handle_save_replay() -> Result<(), Box> send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay) } +fn handle_toggle_streaming() -> Result<(), Box> { + 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> { let args: Vec = env::args().collect(); @@ -350,6 +375,9 @@ fn main() -> Result<(), Box> { "--record" | "--toggle" => { return handle_toggle_recording(); } + "--stream" | "--toggle-stream" => { + return handle_toggle_streaming(); + } "--cursor" | "--toggle-cursor" => { return handle_toggle_cursor(); } @@ -369,6 +397,7 @@ fn main() -> Result<(), Box> { Ok(st) => { println!("Daemon Status:"); 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!(" Audio Mode: {}", st.audio_mode); println!(" Mic Volume: {:.0}%", st.mic_volume * 100.0); @@ -400,6 +429,7 @@ fn main() -> Result<(), Box> { println!(" scythe-ui --notify-stop Show recording saved notification toast"); println!(" scythe-ui --toast Show custom ShadowPlay notification toast"); 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 --start Start normal recording"); println!(" scythe-ui --stop Stop normal recording"); diff --git a/src/config.rs b/src/config.rs index 347f919..ef39acf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,9 @@ fn default_mic_volume() -> f32 { 0.60 } fn default_system_volume() -> f32 { 1.00 } 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)] pub struct ScytheConfig { #[serde(default = "default_accent_color")] @@ -72,6 +75,14 @@ pub struct ScytheConfig { pub cursor_hotkey: String, #[serde(default = "default_true")] 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; @@ -105,6 +116,10 @@ impl Default for ScytheConfig { record_hotkey: "Ctrl+Shift+F9".to_string(), cursor_hotkey: "Ctrl+Shift+F10".to_string(), 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.system_volume - 1.00).abs() < 1e-4); 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] diff --git a/src/encoder.rs b/src/encoder.rs index 78172a9..dd3f521 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -321,12 +321,10 @@ impl VaapiEncoder { 0, ); if p != libc::MAP_FAILED { - if self.dma_mmap_cache.len() >= 16 { - if 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) { - libc::munmap(old_p, old_size); - } - } + if self.dma_mmap_cache.len() >= 16 + && let Some(old_fd) = self.dma_mmap_cache.keys().next().copied() + && let Some((old_p, old_size)) = self.dma_mmap_cache.remove(&old_fd) { + libc::munmap(old_p, old_size); } self.dma_mmap_cache.insert(*fd, (p, mmap_size)); p diff --git a/src/hyprland_binds.rs b/src/hyprland_binds.rs index 8e4c831..e246d18 100644 --- a/src/hyprland_binds.rs +++ b/src/hyprland_binds.rs @@ -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 })"#]) .output(); 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(); 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(); let _ = Command::new("hyprctl") .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)$", "pin, 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)$", "pin, title:^(Select Recordings Directory)$", "stayfocused, title:^(Select Recordings Directory)$", diff --git a/src/ipc.rs b/src/ipc.rs index 4526cd1..5e3e647 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -6,6 +6,9 @@ pub enum Command { StartRecording, StopRecording, ToggleRecording, + StartStreaming, + StopStreaming, + ToggleStreaming, ToggleAudio, CycleAudioMode, ToggleCursor, @@ -33,6 +36,10 @@ pub struct DaemonStatus { pub mic_level_peak: f32, #[serde(default)] pub system_level_peak: f32, + #[serde(default)] + pub is_streaming: bool, + #[serde(default)] + pub streaming_duration_sec: u64, } fn default_true() -> bool { @@ -274,3 +281,35 @@ pub fn get_toast_pid_path() -> std::path::PathBuf { pub fn clean_toast_pid() { 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); + } +} diff --git a/src/overlay.rs b/src/overlay.rs index 9a991dc..3dd1766 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -283,22 +283,20 @@ pub fn show_shadowplay_toast(title: &str, subtitle: &str, icon: ToastIcon) { }); let css_provider = CssProvider::new(); - let css = format!( - r#" - window, window.background {{ + let css = r#" + window, window.background { background-color: transparent; background: transparent; border: none; box-shadow: none; - }} - .toast-card {{ + } + .toast-card { background-color: rgba(10, 10, 10, 0.65); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 0px; box-shadow: none; - }} - "#, - ); + } + "#; let _ = css_provider.load_from_data(css.as_bytes()); if let Some(screen) = gdk::Screen::default() { StyleContext::add_provider_for_screen( diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index 5a99d3b..46f7ed2 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -494,14 +494,13 @@ fn render_keycap(ui: &mut egui::Ui, text: &str) { pub fn resolve_accent_color(accent: &str) -> Color32 { let trimmed = accent.trim(); - if trimmed.starts_with('#') && trimmed.len() == 7 { - if let (Ok(r), Ok(g), Ok(b)) = ( + if trimmed.starts_with('#') && trimmed.len() == 7 + && let (Ok(r), Ok(g), Ok(b)) = ( u8::from_str_radix(&trimmed[1..3], 16), u8::from_str_radix(&trimmed[3..5], 16), u8::from_str_radix(&trimmed[5..7], 16), ) { return Color32::from_rgb(r, g, b); - } } match trimmed.to_lowercase().as_str() { "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 #[allow(clippy::too_many_arguments)] fn render_gsr_card( @@ -917,6 +956,7 @@ pub struct ScytheOverlayApp { update_status: Arc>, #[allow(dead_code)] update_dismissed: bool, + popup_do_not_ask: bool, auto_check_updates: bool, autostart_replay: bool, autostart_overlay: bool, @@ -959,6 +999,18 @@ impl ScytheOverlayApp { _ => 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::(); std::thread::spawn(move || { loop { @@ -992,9 +1044,9 @@ impl ScytheOverlayApp { replay_dropdown_open: false, record_dropdown_open: false, stream_dropdown_open: false, - stream_service_idx: 0, - stream_key: String::new(), - stream_url: "rtmp://live.twitch.tv/app/".to_string(), + stream_service_idx, + stream_key, + stream_url, textures: None, output_dir, replay_sec, @@ -1027,6 +1079,7 @@ impl ScytheOverlayApp { frame_count: 0, update_status, update_dismissed: false, + popup_do_not_ask: false, auto_check_updates, autostart_replay, autostart_overlay, @@ -1051,6 +1104,13 @@ impl ScytheOverlayApp { 3 => "muted".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.autostart_replay = self.autostart_replay; 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.autostart_replay = def.autostart_replay; 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); } @@ -1161,8 +1224,8 @@ impl ScytheOverlayApp { } fn render_top_bar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { - let top_left = ui.max_rect().min; - let screen_w = ui.max_rect().width(); + let top_left = egui::pos2(0.0, 0.0); + let screen_w = ctx.screen_rect().width().max(ui.available_width()); let bar_h = 48.0_f32; let bar_rect = egui::Rect::from_min_size(top_left, Vec2::new(screen_w, bar_h)); let accent = self.accent_color(); @@ -1346,6 +1409,16 @@ impl ScytheOverlayApp { 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 let c2_clicked = render_gsr_card( ui, @@ -1353,10 +1426,10 @@ impl ScytheOverlayApp { "Livestream", tex_stream, |painter, center| { - draw_settings_icon(painter, center, 24.0, Color32::WHITE); + draw_stream_icon(painter, center, 24.0, is_streaming, accent); }, - "Not streaming", - false, + &stream_status_str, + is_streaming, self.stream_dropdown_open, 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 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) { - self.show_hud_notification("LIVESTREAM", "Streaming not configured", crate::overlay::ToastIcon::Info); + let stream_toggle_title = if is_streaming { "Stop Streaming" } else { "Start Streaming" }; + 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; } @@ -2531,6 +2616,15 @@ impl ScytheOverlayApp { .size(11.5) .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 => { @@ -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( painter: &egui::Painter, rect: egui::Rect, @@ -3184,9 +3419,18 @@ impl eframe::App for ScytheOverlayApp { } } } 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 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.record_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 - 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()) && 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 { @@ -3243,6 +3487,7 @@ impl eframe::App for ScytheOverlayApp { ShadowPlayView::Gallery => self.render_gallery_view(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 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)); + } }