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]]
|
||||
name = "scythe"
|
||||
version = "0.2.11"
|
||||
version = "0.2.12"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"cpal",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "scythe"
|
||||
version = "0.2.11"
|
||||
version = "0.2.12"
|
||||
edition = "2024"
|
||||
|
||||
[[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 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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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);
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
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>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
|
|
@ -350,6 +375,9 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
"--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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
println!(" scythe-ui --notify-stop Show recording saved 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 --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");
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)$",
|
||||
|
|
|
|||
39
src/ipc.rs
39
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<std::sync::Mutex<crate::updater::UpdateStatus>>,
|
||||
#[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::<DaemonStatus>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue