diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efc4dd7..395bb87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,11 +46,16 @@ jobs: - name: Build Debian Package (.deb) run: | - mkdir -p deb/scythe_0.1.0/DEBIAN deb/scythe_0.1.0/usr/bin deb/scythe_0.1.0/usr/share/applications - cat > deb/scythe_0.1.0/DEBIAN/control << 'EOF' + VERSION="${GITHUB_REF_NAME#v}" + if [ -z "$VERSION" ] || [ "$VERSION" = "main" ]; then + VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2) + fi + echo "Packaging Debian version: ${VERSION}" + mkdir -p "deb/scythe_${VERSION}/DEBIAN" "deb/scythe_${VERSION}/usr/bin" "deb/scythe_${VERSION}/usr/share/applications" + cat > "deb/scythe_${VERSION}/DEBIAN/control" << EOF Package: scythe Provides: vrec - Version: 0.1.0 + Version: ${VERSION} Section: video Priority: optional Architecture: amd64 @@ -58,18 +63,19 @@ jobs: Description: High-performance GPU hardware screen recorder and instant replay overlay Depends: libgtk-3-0, libgtk-layer-shell0, libpipewire-0.3-0, libva2, libasound2 EOF - cp target/release/scythe-daemon deb/scythe_0.1.0/usr/bin/ - cp target/release/scythe-ui deb/scythe_0.1.0/usr/bin/ - ln -sf scythe-daemon deb/scythe_0.1.0/usr/bin/vrec-daemon - ln -sf scythe-ui deb/scythe_0.1.0/usr/bin/vrec-ui - ln -sf scythe-ui deb/scythe_0.1.0/usr/bin/scythe - ln -sf scythe-ui deb/scythe_0.1.0/usr/bin/vrec - cp packaging/scythe.desktop deb/scythe_0.1.0/usr/share/applications/ - dpkg-deb --build deb/scythe_0.1.0 dist/scythe_0.1.0_amd64.deb + cp target/release/scythe-daemon "deb/scythe_${VERSION}/usr/bin/" + cp target/release/scythe-ui "deb/scythe_${VERSION}/usr/bin/" + ln -sf scythe-daemon "deb/scythe_${VERSION}/usr/bin/vrec-daemon" + ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/vrec-ui" + ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/scythe" + ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/vrec" + cp packaging/scythe.desktop "deb/scythe_${VERSION}/usr/share/applications/" + dpkg-deb --build "deb/scythe_${VERSION}" "dist/scythe_${VERSION}_amd64.deb" - name: Build RPM Package (.rpm) run: | - alien --to-rpm --scripts dist/scythe_0.1.0_amd64.deb + DEB_FILE=$(ls dist/*.deb | head -n 1) + alien --to-rpm --scripts "$DEB_FILE" mv *.rpm dist/ - name: Package Standalone AppImage @@ -175,28 +181,27 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name != 'main' && github.ref_name || 'v0.1.0' }} - name: "Scythe ${{ github.ref_name != 'main' && github.ref_name || 'v0.1.0' }}" + tag_name: ${{ github.ref_name != 'main' && github.ref_name || 'v0.2.0' }} + name: "Scythe ${{ github.ref_name != 'main' && github.ref_name || 'v0.2.0' }}" body: | High-performance GPU hardware screen recorder and instant replay overlay for Linux and Windows. - ### Highlights - - Zero-copy GPU screen capture (PipeWire/DMA-BUF on Wayland, XCB on X11, D3D11 on Windows) - - Low-overhead rolling ring buffer for instant replays - - Hardware encoding via VAAPI (AMD/Intel) and NVENC (NVIDIA) - - Stealth floating HUD dock built in egui - - Dynamic Hyprland keybinding and window rule injection - - Multi-channel audio routing (System audio, Microphone, Combined, and Mute) + ### What's New in v0.2.0 + - **Auto-Update System**: Integrated background release checker with voluntary update notifications in the HUD and dedicated settings manager. + - **Standardized Datetime Filenames**: Clips and replays now save with clear human-readable timestamps (`Replay-HH-MM-SS-DD-MM-YYYY.mp4` / `Recording-HH-MM-SS-DD-MM-YYYY.mp4`). + - **NVIDIA ShadowPlay Toast Overlay**: Sleek, non-intrusive floating toast notifications in the top-right corner for Replay Saved, Recording Started/Stopped, and Mouse Cursor toggle. + - **Obsidian Dark UI Refinement**: Pure pitch-black obsidian theme with smooth accent borders, squared geometry, and zero focus-stealing. + - **Interactive Keybind & Settings Manager**: Real-time hotkey listener, custom FPS input, replay buffer duration, and bitrate adjustment. ### Packages - Windows: `scythe-setup.exe` (Official installer with Start Menu & Desktop shortcuts) - Universal Linux: `scythe-x86_64.AppImage` - - Debian / Ubuntu / Mint: `scythe_0.1.0_amd64.deb` - - Fedora / RHEL / openSUSE: `scythe-0.1.0-2.x86_64.rpm` + - Debian / Ubuntu / Mint: `scythe_*_amd64.deb` + - Fedora / RHEL / openSUSE: `*.rpm` - Generic Linux: `scythe-linux-x86_64.tar.gz` files: | artifacts/linux-packages/* artifacts/windows-packages/* draft: false prerelease: false - generate_release_notes: false + generate_release_notes: true diff --git a/Cargo.lock b/Cargo.lock index 5c9229b..4078642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,7 +3700,7 @@ dependencies = [ [[package]] name = "scythe" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ashpd", "cpal", diff --git a/Cargo.toml b/Cargo.toml index 8616728..29b8f31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scythe" -version = "0.1.0" +version = "0.2.0" edition = "2024" [[bin]] diff --git a/src/bin/scythe-daemon.rs b/src/bin/scythe-daemon.rs index e6881f7..79d13b4 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/bin/scythe-daemon.rs @@ -392,8 +392,7 @@ async fn main() -> Result<(), Box> { thread::spawn(move || { while let Ok(drain) = mux_rx.recv() { - let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let filename = format!("replay_{}.mp4", ts); + let filename = scythe::config::ScytheConfig::format_video_filename("Replay", "mp4"); let full_path = scythe::config::ScytheConfig::load().resolve_save_path(&filename); println!("Saving replay to {}...", full_path); @@ -689,8 +688,7 @@ async fn main() -> Result<(), Box> { rec_base_video_pts = pkt.pts(); rec_base_audio_pts = -1; let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext; - let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let filename = format!("record_{}.mp4", ts); + let filename = scythe::config::ScytheConfig::format_video_filename("Recording", "mp4"); let full_path = config.resolve_save_path(&filename); let audio_codec_ctx = audio_codec_ctx_ptr.map(|p| p as *mut ffmpeg_next::ffi::AVCodecContext); normal_muxer = unsafe { Muxer::new(&full_path, codec_ctx, audio_codec_ctx).ok() }; diff --git a/src/bin/scythe-ui.rs b/src/bin/scythe-ui.rs index 4f70699..d0abdad 100644 --- a/src/bin/scythe-ui.rs +++ b/src/bin/scythe-ui.rs @@ -1,7 +1,7 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] use scythe::ipc::{Command, send_command, query_status}; -use scythe::overlay::show_notification; +use scythe::overlay::{show_shadowplay_toast, ToastIcon}; use std::env; use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent}; @@ -241,15 +241,15 @@ fn ensure_hotkeys_running() { } } -fn send_with_notification(cmd: Command, success_msg: &str) -> Result<(), Box> { +fn send_with_notification(cmd: Command, title: &str, subtitle: &str, icon: ToastIcon) -> Result<(), Box> { ensure_daemon_running(); match send_command(cmd) { Ok(()) => { - show_notification(success_msg); + show_shadowplay_toast(title, subtitle, icon); Ok(()) } Err(e) => { - show_notification("Error: failed to connect to scythe-daemon"); + show_shadowplay_toast("SCYTHE", "Error: failed to connect to daemon", ToastIcon::Error); Err(e) } } @@ -262,14 +262,14 @@ fn handle_toggle_recording() -> Result<(), Box { if is_rec { - show_notification("Recording saved"); + show_shadowplay_toast("RECORDING", "Recording saved", ToastIcon::Save); } else { - show_notification("Recording started"); + show_shadowplay_toast("RECORDING", "Recording started", ToastIcon::Record); } Ok(()) } Err(e) => { - show_notification("Error: failed to connect to scythe-daemon"); + show_shadowplay_toast("SCYTHE", "Error: failed to connect to daemon", ToastIcon::Error); Err(e) } } @@ -282,14 +282,14 @@ fn handle_toggle_cursor() -> Result<(), Box match send_command(Command::ToggleCursor) { Ok(()) => { if next_cursor { - show_notification("Cursor: Visible in recording"); + show_shadowplay_toast("MOUSE CURSOR", "Visible in recording", ToastIcon::Cursor); } else { - show_notification("Cursor: Hidden from recording"); + show_shadowplay_toast("MOUSE CURSOR", "Hidden from recording", ToastIcon::Cursor); } Ok(()) } Err(e) => { - show_notification("Error: failed to connect to scythe-daemon"); + show_shadowplay_toast("SCYTHE", "Error: failed to connect to daemon", ToastIcon::Error); Err(e) } } @@ -302,7 +302,26 @@ fn main() -> Result<(), Box> { if args.len() > 1 { match args[1].as_str() { "--save" => { - return send_with_notification(Command::SaveReplay, "Replay saved"); + return send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay); + } + "--notify-save" => { + show_shadowplay_toast("INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay); + return Ok(()); + } + "--notify-start" => { + show_shadowplay_toast("RECORDING", "Recording started", ToastIcon::Record); + return Ok(()); + } + "--notify-stop" => { + show_shadowplay_toast("RECORDING", "Recording saved", ToastIcon::Save); + return Ok(()); + } + "--toast" => { + let title = args.get(2).map(|s| s.as_str()).unwrap_or("SCYTHE"); + let subtitle = args.get(3).map(|s| s.as_str()).unwrap_or(""); + let icon_name = args.get(4).map(|s| s.as_str()).unwrap_or("info"); + show_shadowplay_toast(title, subtitle, ToastIcon::from_name(icon_name)); + return Ok(()); } "--menu" => { ensure_daemon_running_async(); @@ -341,10 +360,10 @@ fn main() -> Result<(), Box> { return handle_toggle_cursor(); } "--start" => { - return send_with_notification(Command::StartRecording, "Recording started"); + return send_with_notification(Command::StartRecording, "RECORDING", "Recording started", ToastIcon::Record); } "--stop" => { - return send_with_notification(Command::StopRecording, "Recording saved"); + return send_with_notification(Command::StopRecording, "RECORDING", "Recording saved", ToastIcon::Save); } "--reload" => { ensure_daemon_running(); @@ -381,6 +400,10 @@ fn main() -> Result<(), Box> { println!(" scythe-ui --hotkeys Run global hotkey manager in background"); println!(" scythe-ui --status Query current daemon status"); println!(" scythe-ui --save Save instant replay and show notification"); + println!(" scythe-ui --notify-save Show instant replay saved notification toast"); + println!(" scythe-ui --notify-start Show recording started notification toast"); + 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 --cursor Toggle mouse cursor recording on/off (with notification)"); println!(" scythe-ui --start Start normal recording"); @@ -464,7 +487,7 @@ fn main() -> Result<(), Box> { ensure_daemon_running(); let _ = get_ui_cmd().arg("--menu").spawn(); } else if event.id == hotkey_save.id() { - let _ = send_with_notification(Command::SaveReplay, "Replay saved"); + let _ = send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay); } else if event.id == hotkey_record.id() { let _ = handle_toggle_recording(); } else if event.id == hotkey_cursor.id() { diff --git a/src/config.rs b/src/config.rs index 538f1d6..79bca26 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,6 +66,8 @@ pub struct ScytheConfig { pub record_hotkey: String, #[serde(default = "default_cursor_hotkey")] pub cursor_hotkey: String, + #[serde(default = "default_true")] + pub auto_check_updates: bool, } pub type VrecConfig = ScytheConfig; @@ -96,6 +98,7 @@ impl Default for ScytheConfig { menu_hotkey: "Alt+Z".to_string(), record_hotkey: "Ctrl+Shift+F9".to_string(), cursor_hotkey: "Ctrl+Shift+F10".to_string(), + auto_check_updates: true, } } } @@ -183,6 +186,27 @@ impl ScytheConfig { pub fn notify_daemon_reload() { let _ = crate::ipc::send_command(crate::ipc::Command::ReloadConfig); } + + /// Formats video filenames with informative local timestamps, e.g. "Replay-18-30-00-05-09-2026.mp4" + pub fn format_video_filename(prefix: &str, ext: &str) -> String { + unsafe { + let t = libc::time(std::ptr::null_mut()); + let mut tm = std::mem::zeroed::(); + #[cfg(unix)] + libc::localtime_r(&t, &mut tm); + #[cfg(windows)] + libc::localtime_s(&mut tm, &t); + + let hour = tm.tm_hour; + let min = tm.tm_min; + let sec = tm.tm_sec; + let day = tm.tm_mday; + let month = tm.tm_mon + 1; + let year = tm.tm_year + 1900; + + format!("{}-{:02}-{:02}-{:02}-{:02}-{:02}-{:04}.{}", prefix, hour, min, sec, day, month, year, ext) + } + } } #[cfg(test)] @@ -207,5 +231,21 @@ mod tests { assert!(parsed.show_cursor); assert!((parsed.mic_volume - 0.60).abs() < 1e-4); assert!((parsed.system_volume - 1.00).abs() < 1e-4); + assert!(parsed.auto_check_updates); + } + + #[test] + fn test_format_video_filename() { + let name = ScytheConfig::format_video_filename("Replay", "mp4"); + assert!(name.starts_with("Replay-")); + assert!(name.ends_with(".mp4")); + // Check pattern Replay-HH-MM-SS-DD-MM-YYYY.mp4 (7 parts separated by hyphen) + let without_ext = name.strip_suffix(".mp4").unwrap(); + let parts: Vec<&str> = without_ext.split('-').collect(); + assert_eq!(parts.len(), 7, "Filename parts mismatch: {}", name); + assert_eq!(parts[0], "Replay"); + for part in &parts[1..] { + assert!(part.chars().all(|c| c.is_ascii_digit())); + } } } diff --git a/src/hyprland_binds.rs b/src/hyprland_binds.rs index 4ea8922..368e064 100644 --- a/src/hyprland_binds.rs +++ b/src/hyprland_binds.rs @@ -151,7 +151,9 @@ pub fn register_hyprland_binds(config: &ScytheConfig) { "move 0 0, class:^(scythe-hud)$", "size 100% 100%, class:^(scythe-hud)$", "pin, class:^(scythe-hud)$", - "noborder, class:^(scythe-hud)$", + "float, class:^(scythe-toast)$", + "pin, class:^(scythe-toast)$", + "noborder, class:^(scythe-toast)$", "float, class:^(vrec-overlay)$", "move 0 0, class:^(vrec-overlay)$", "size 100% 100%, class:^(vrec-overlay)$", diff --git a/src/lib.rs b/src/lib.rs index c95e9f1..eec9990 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,4 @@ pub mod config; pub mod settings_ui; pub mod overlay_egui; pub mod hyprland_binds; +pub mod updater; diff --git a/src/overlay.rs b/src/overlay.rs index af29366..66a69cc 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -13,53 +13,125 @@ use std::time::Duration; use crate::config::ScytheConfig; use crate::ipc::{self, Command}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastIcon { + Replay, + Record, + Save, + Cursor, + Info, + Error, +} + +impl ToastIcon { + pub fn from_name(name: &str) -> Self { + match name.to_lowercase().as_str() { + "replay" | "save_replay" => ToastIcon::Replay, + "record" | "recording" | "start" => ToastIcon::Record, + "save" | "saved" | "stop" => ToastIcon::Save, + "cursor" => ToastIcon::Cursor, + "error" => ToastIcon::Error, + _ => ToastIcon::Info, + } + } +} + +pub fn spawn_toast(title: &str, subtitle: &str, icon: ToastIcon) { + let icon_str = match icon { + ToastIcon::Replay => "replay", + ToastIcon::Record => "record", + ToastIcon::Save => "save", + ToastIcon::Cursor => "cursor", + ToastIcon::Error => "error", + ToastIcon::Info => "info", + }; + + let title_owned = title.to_string(); + let subtitle_owned = subtitle.to_string(); + let icon_owned = icon_str.to_string(); + + std::thread::spawn(move || { + let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("scythe-ui")); + let _ = std::process::Command::new(exe) + .args(["--toast", &title_owned, &subtitle_owned, &icon_owned]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + }); +} + pub fn show_notification_overlay() { - show_notification("Replay saved"); + show_shadowplay_toast("INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay); } pub fn show_notification(message: &str) { + let lower = message.to_lowercase(); + let (title, subtitle, icon) = if lower.contains("replay saved") || lower.contains("replay") { + ("INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay) + } else if lower.contains("recording started") { + ("RECORDING", "Recording started", ToastIcon::Record) + } else if lower.contains("recording saved") || lower.contains("stopped") { + ("RECORDING", "Recording saved", ToastIcon::Save) + } else if lower.contains("cursor") { + ("MOUSE CURSOR", message, ToastIcon::Cursor) + } else if lower.contains("error") { + ("SCYTHE", message, ToastIcon::Error) + } else { + ("SCYTHE", message, ToastIcon::Info) + }; + show_shadowplay_toast(title, subtitle, icon); +} + +pub fn show_shadowplay_toast(title: &str, subtitle: &str, icon: ToastIcon) { #[cfg(target_os = "windows")] { - let msg = message.to_string(); - std::thread::spawn(move || { - use std::os::windows::process::CommandExt; - let script = format!( - "[reflection.assembly]::loadwithpartialname('System.Windows.Forms') | Out-Null; \ - $notify = new-object system.windows.forms.notifyicon; \ - $notify.icon = [System.Drawing.SystemIcons]::Information; \ - $notify.visible = $true; \ - $notify.showballoontip(2000, 'scythe', '{}', [system.windows.forms.tooltipicon]::Info); \ - Start-Sleep -Seconds 2; \ - $notify.dispose()", - msg.replace('\'', "''") - ); - let _ = std::process::Command::new("powershell") - .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script]) - .creation_flags(0x08000000) - .output(); - }); + crate::overlay_egui::run_egui_toast(title, subtitle, icon); return; } #[cfg(not(target_os = "windows"))] { if std::env::var("WAYLAND_DISPLAY").is_err() && std::env::var("DISPLAY").is_err() { + crate::overlay_egui::run_egui_toast(title, subtitle, icon); return; } if gtk::init().is_err() { + crate::overlay_egui::run_egui_toast(title, subtitle, icon); return; } + let app_id = format!("com.scythe.toast.{}", std::process::id()); let app = Application::builder() - .application_id("com.scythe.notification") + .application_id(&app_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) .build(); - let msg_text = message.to_string(); + let title_text = title.to_string(); + let sub_text = subtitle.to_string(); + + let cfg = ScytheConfig::load(); + let accent_name = cfg.accent_color.to_lowercase(); + let (accent_hex, accent_rgb): (&str, (f64, f64, f64)) = match accent_name.as_str() { + "green" | "emerald" => ("#22c55e", (0.133, 0.773, 0.369)), + "cyan" | "ice" => ("#06b6d4", (0.024, 0.714, 0.831)), + "purple" | "violet" => ("#a855f7", (0.659, 0.333, 0.969)), + "amber" | "orange" => ("#f59e0b", (0.961, 0.620, 0.043)), + "red" | "crimson" => ("#ef4444", (0.937, 0.267, 0.267)), + "blue" | "sapphire" | _ => ("#38bdf8", (0.220, 0.741, 0.973)), + }; + + let active_accent_hex = if icon == ToastIcon::Record { + "#ef4444" + } else { + accent_hex + }; + app.connect_activate(move |app| { let window = ApplicationWindow::builder() .application(app) - .default_width(320) - .default_height(54) + .default_width(330) + .default_height(58) .build(); #[cfg(target_os = "linux")] @@ -71,17 +143,21 @@ pub fn show_notification(message: &str) { window.init_layer_shell(); window.set_layer(Layer::Overlay); window.set_namespace("scythe-notification"); - window.set_layer_shell_margin(gtk_layer_shell::Edge::Top, 40); window.set_anchor(gtk_layer_shell::Edge::Top, true); + window.set_anchor(gtk_layer_shell::Edge::Right, true); + window.set_layer_shell_margin(gtk_layer_shell::Edge::Top, 24); + window.set_layer_shell_margin(gtk_layer_shell::Edge::Right, 24); + window.set_keyboard_interactivity(false); } else { window.set_decorated(false); window.set_keep_above(true); window.set_skip_taskbar_hint(true); + window.set_accept_focus(false); if let Some(display) = gdk::Display::default() && let Some(mon) = display.primary_monitor().or_else(|| display.monitor(0)) { let geom = mon.geometry(); - let x = geom.x() + (geom.width() - 320) / 2; - let y = geom.y() + 40; + let x = geom.x() + geom.width() - 330 - 24; + let y = geom.y() + 24; window.move_(x, y); } } @@ -99,34 +175,36 @@ pub fn show_notification(message: &str) { }); let css_provider = CssProvider::new(); - let css = r#" - window, window.background, .background { + let css = format!( + r#" + window, window.background, .background {{ background-color: transparent !important; background: transparent !important; border: none !important; box-shadow: none !important; - } - .notify-box { - background-color: rgba(18, 24, 36, 0.78); - border-radius: 12px; - border: 1px solid rgba(118, 185, 0, 0.6); - box-shadow: 0px 16px 40px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2); - padding: 8px 22px; - } - .notify-badge { - background-color: #76b900; - color: #0b1204; - font-size: 10.5px; - font-weight: 900; - border-radius: 4px; - padding: 2px 7px; - } - .notify-label { + }} + .shadowplay-toast {{ + background-color: rgba(11, 11, 13, 0.96); + border: 1px solid rgba(255, 255, 255, 0.12); + border-left: 3.5px solid {accent}; + border-radius: 0px !important; + box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.85); + padding: 8px 14px; + }} + .toast-title {{ color: #ffffff; - font-weight: 700; - font-size: 13px; - } - "#; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.6px; + }} + .toast-subtitle {{ + color: #a1a1aa; + font-size: 11px; + font-weight: 500; + }} + "#, + accent = active_accent_hex + ); let _ = css_provider.load_from_data(css.as_bytes()); if let Some(screen) = gdk::Screen::default() { StyleContext::add_provider_for_screen( @@ -137,22 +215,117 @@ pub fn show_notification(message: &str) { } window.style_context().add_provider(&css_provider, gtk::STYLE_PROVIDER_PRIORITY_USER); - let hbox = Box::new(Orientation::Horizontal, 10); - hbox.style_context().add_class("notify-box"); - hbox.set_halign(gtk::Align::Center); + let hbox = Box::new(Orientation::Horizontal, 12); + hbox.style_context().add_class("shadowplay-toast"); + hbox.set_size_request(320, 54); - let badge = Label::new(Some("SCYTHE")); - badge.style_context().add_class("notify-badge"); - let label = Label::new(Some(&msg_text)); - label.style_context().add_class("notify-label"); + // Left DrawingArea for vector icon + let icon_area = DrawingArea::new(); + icon_area.set_size_request(32, 32); + icon_area.set_valign(gtk::Align::Center); + let icon_type = icon; + icon_area.connect_draw(move |_, cr| { + let cx = 16.0; + let cy = 16.0; + match icon_type { + ToastIcon::Replay => { + let r = 10.0; + cr.set_source_rgb(accent_rgb.0, accent_rgb.1, accent_rgb.2); + cr.set_line_width(2.2); + cr.arc(cx, cy, r, 0.25 * PI, 1.80 * PI); + let _ = cr.stroke(); - hbox.pack_start(&badge, false, false, 0); - hbox.pack_start(&label, false, false, 0); + let a_x = cx + r * (0.25 * PI).cos(); + let a_y = cy + r * (0.25 * PI).sin(); + cr.move_to(a_x, a_y); + cr.line_to(a_x - 4.5, a_y + 0.5); + cr.line_to(a_x - 0.5, a_y - 4.5); + cr.close_path(); + let _ = cr.fill(); + + let tri_r = 4.0; + cr.move_to(cx + tri_r + 0.5, cy); + cr.line_to(cx - tri_r * 0.6 + 0.5, cy - tri_r * 0.86); + cr.line_to(cx - tri_r * 0.6 + 0.5, cy + tri_r * 0.86); + cr.close_path(); + let _ = cr.fill(); + } + ToastIcon::Record => { + cr.set_source_rgba(0.937, 0.267, 0.267, 0.25); + cr.arc(cx, cy, 13.0, 0.0, PI * 2.0); + let _ = cr.fill(); + + cr.set_source_rgb(0.937, 0.267, 0.267); + cr.set_line_width(1.8); + cr.arc(cx, cy, 10.0, 0.0, PI * 2.0); + let _ = cr.stroke(); + + cr.arc(cx, cy, 5.0, 0.0, PI * 2.0); + let _ = cr.fill(); + } + ToastIcon::Save => { + cr.set_source_rgb(accent_rgb.0, accent_rgb.1, accent_rgb.2); + cr.set_line_width(2.5); + cr.set_line_cap(gtk::cairo::LineCap::Round); + cr.set_line_join(gtk::cairo::LineJoin::Round); + cr.move_to(cx - 7.0, cy); + cr.line_to(cx - 2.0, cy + 5.0); + cr.line_to(cx + 7.0, cy - 5.0); + let _ = cr.stroke(); + } + ToastIcon::Cursor => { + cr.set_source_rgb(accent_rgb.0, accent_rgb.1, accent_rgb.2); + cr.set_line_width(1.8); + cr.move_to(cx - 6.0, cy - 8.0); + cr.line_to(cx + 6.0, cy - 1.0); + cr.line_to(cx, cy + 1.0); + cr.line_to(cx + 2.0, cy + 7.0); + cr.line_to(cx - 1.0, cy + 8.0); + cr.line_to(cx - 3.0, cy + 2.0); + cr.line_to(cx - 6.0, cy + 4.0); + cr.close_path(); + let _ = cr.fill(); + } + ToastIcon::Error => { + cr.set_source_rgb(0.937, 0.267, 0.267); + cr.set_line_width(2.2); + cr.move_to(cx - 6.0, cy - 6.0); + cr.line_to(cx + 6.0, cy + 6.0); + cr.move_to(cx + 6.0, cy - 6.0); + cr.line_to(cx - 6.0, cy + 6.0); + let _ = cr.stroke(); + } + ToastIcon::Info => { + cr.set_source_rgb(accent_rgb.0, accent_rgb.1, accent_rgb.2); + cr.arc(cx, cy, 6.0, 0.0, PI * 2.0); + let _ = cr.fill(); + } + } + gtk::glib::Propagation::Proceed + }); + + // Right text column + let vbox = Box::new(Orientation::Vertical, 2); + vbox.set_valign(gtk::Align::Center); + + let title_lbl = Label::new(Some(&title_text)); + title_lbl.style_context().add_class("toast-title"); + title_lbl.set_halign(gtk::Align::Start); + + let sub_lbl = Label::new(Some(&sub_text)); + sub_lbl.style_context().add_class("toast-subtitle"); + sub_lbl.set_halign(gtk::Align::Start); + + vbox.pack_start(&title_lbl, false, false, 0); + vbox.pack_start(&sub_lbl, false, false, 0); + + hbox.pack_start(&icon_area, false, false, 0); + hbox.pack_start(&vbox, true, true, 0); window.add(&hbox); window.show_all(); let window_clone = window.clone(); - gtk::glib::timeout_add_local(Duration::from_millis(2200), move || { + gtk::glib::timeout_add_local(Duration::from_millis(2600), move || { window_clone.close(); gtk::glib::ControlFlow::Break }); diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index fd5b77c..382a7aa 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -127,8 +127,8 @@ fn render_vu_meter(ui: &mut egui::Ui, level: f32, width: f32, height: f32, label let (rect, _) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover()); let clamped = level.clamp(0.0, 1.0); - let bg_color = Color32::from_rgb(18, 24, 34); - let border_stroke = Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 30)); + let bg_color = Color32::from_rgb(14, 14, 16); + let border_stroke = Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 20)); ui.painter().rect(rect, CornerRadius::ZERO, bg_color, border_stroke, egui::StrokeKind::Inside); let fill_w = (rect.width() * clamped).max(0.0); @@ -183,7 +183,7 @@ fn scan_recordings(dir_str: &str) -> Vec { let ext_str = ext.to_string_lossy().to_lowercase(); if ext_str == "mp4" || ext_str == "mkv" { let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let is_replay = filename.starts_with("replay_"); + let is_replay = filename.to_lowercase().starts_with("replay"); let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0); let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); clips.push(VideoClipInfo { @@ -297,8 +297,8 @@ fn pick_folder(current_dir: &str, tx: Sender, is_active: Arc #[allow(dead_code)] fn render_keycap(ui: &mut egui::Ui, text: &str) { egui::Frame::NONE - .fill(Color32::from_rgb(18, 22, 30)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(50, 60, 78))) + .fill(Color32::from_rgb(15, 15, 17)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 28))) .corner_radius(CornerRadius::ZERO) .inner_margin(Margin::symmetric(6_i8, 2_i8)) .show(ui, |ui| { @@ -306,7 +306,7 @@ fn render_keycap(ui: &mut egui::Ui, text: &str) { egui::RichText::new(text) .font(FontId::monospace(9.5)) .strong() - .color(Color32::from_rgb(203, 213, 225)), + .color(Color32::from_rgb(215, 215, 220)), ); }); } @@ -331,16 +331,16 @@ fn render_keycap_button( ) -> bool { let (fill, stroke, text_color, label_text) = if listening { ( - Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 45), + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 28), Stroke::new(1.5_f32, accent), accent, "PRESS KEYS...".to_string(), ) } else { ( - Color32::from_rgba_unmultiplied(20, 26, 38, 220), - Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 35)), - Color32::from_rgb(226, 232, 240), + Color32::from_rgba_unmultiplied(16, 16, 19, 230), + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 28)), + Color32::from_rgb(220, 220, 225), text.to_string(), ) }; @@ -364,17 +364,17 @@ fn squared_button(ui: &mut egui::Ui, text: &str, active: bool, accent: Color32) let fill = if active { accent } else { - Color32::from_rgba_unmultiplied(255, 255, 255, 12) + Color32::from_rgba_unmultiplied(255, 255, 255, 10) }; let stroke = if active { Stroke::new(1.0_f32, accent) } else { - Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 25)) + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)) }; let text_color = if active { - Color32::from_rgb(10, 14, 22) + Color32::from_rgb(10, 10, 12) } else { - Color32::from_rgb(203, 213, 225) + Color32::from_rgb(200, 200, 205) }; let btn = egui::Button::new(egui::RichText::new(text).size(11.0).strong().color(text_color)) .fill(fill) @@ -403,9 +403,9 @@ fn toggle_switch(ui: &mut egui::Ui, on: &mut bool, accent: Color32) -> egui::Res let bg_color = if *on { accent } else { - Color32::from_rgb(30, 41, 59) + Color32::from_rgb(25, 25, 28) }; - let stroke = Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 40)); + let stroke = Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 26)); ui.painter().rect(rect, CornerRadius::ZERO, bg_color, stroke, egui::StrokeKind::Inside); let knob_w = rect.height() - 4.0; let knob_x = egui::lerp((rect.left() + 2.0)..=(rect.right() - knob_w - 2.0), how_on); @@ -421,7 +421,7 @@ fn draw_replay_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, is let color = if is_active { accent } else { - Color32::from_rgb(148, 163, 184) + Color32::from_rgb(150, 150, 155) }; // 1. Sleek circular track arc curving counter-clockwise (rewind) @@ -510,7 +510,7 @@ fn draw_record_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, is let core_r = radius * 0.40 + pulse * 1.5; painter.circle_filled(center, core_r, red_bright); } else { - let frame_color = Color32::from_rgb(148, 163, 184); + let frame_color = Color32::from_rgb(150, 150, 155); let core_color = Color32::from_rgb(203, 213, 225); // Viewfinder corner brackets (Slate) @@ -585,17 +585,19 @@ fn render_action_card( let hovered = response.hovered(); let bg = if dropdown_open { - Color32::from_rgba_unmultiplied(20, 28, 42, 245) + Color32::from_rgba_unmultiplied(22, 22, 26, 250) } else if hovered { - Color32::from_rgba_unmultiplied(18, 25, 38, 235) + Color32::from_rgba_unmultiplied(18, 18, 22, 245) } else { - Color32::from_rgba_unmultiplied(12, 16, 24, 225) + Color32::from_rgba_unmultiplied(11, 11, 13, 235) }; - let border = if dropdown_open || hovered { + let border = if dropdown_open { accent + } else if hovered { + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 190) } else { - Color32::from_rgba_unmultiplied(255, 255, 255, 30) + Color32::from_rgba_unmultiplied(255, 255, 255, 25) }; let painter = ui.painter(); @@ -603,7 +605,7 @@ fn render_action_card( painter.rect_filled( rect.translate(Vec2::new(0.0, 6.0)), CornerRadius::ZERO, - Color32::from_rgba_unmultiplied(0, 0, 0, 90), + Color32::from_rgba_unmultiplied(0, 0, 0, 110), ); // Card background - SQUARED painter.rect(rect, CornerRadius::ZERO, bg, Stroke::new(1.0_f32, border), egui::StrokeKind::Inside); @@ -636,7 +638,7 @@ fn render_action_card( egui::Align2::CENTER_CENTER, sub_text, FontId::proportional(10.0), - Color32::from_rgb(100, 116, 139), + Color32::from_rgb(130, 130, 136), ); response.clicked() @@ -651,8 +653,8 @@ fn render_dropdown_menu( ) { ui.add_space(4.0); egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(12, 16, 24, 252)) - .stroke(Stroke::new(1.0_f32, accent)) + .fill(Color32::from_rgba_unmultiplied(11, 11, 13, 252)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180))) .corner_radius(CornerRadius::ZERO) .inner_margin(Margin::ZERO) .show(ui, |ui| { @@ -669,7 +671,7 @@ fn render_menu_item(ui: &mut egui::Ui, label: &str, accent: Color32, is_last: bo let hovered = response.hovered(); let bg = if hovered { - Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 45) + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 28) } else { Color32::TRANSPARENT }; @@ -680,14 +682,14 @@ fn render_menu_item(ui: &mut egui::Ui, label: &str, accent: Color32, is_last: bo if !is_last { ui.painter().line_segment( [egui::pos2(rect.left(), rect.bottom()), egui::pos2(rect.right(), rect.bottom())], - Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 18)), + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 14)), ); } let text_color = if hovered { accent } else { - Color32::from_rgb(226, 232, 240) + Color32::from_rgb(220, 220, 225) }; ui.painter().text( @@ -704,8 +706,8 @@ fn render_menu_item(ui: &mut egui::Ui, label: &str, accent: Color32, is_last: bo // Section card helper for Settings view fn render_section_card(ui: &mut egui::Ui, header: &str, accent: Color32, add_contents: impl FnOnce(&mut egui::Ui)) { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(16, 22, 32, 200)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 18))) + .fill(Color32::from_rgba_unmultiplied(14, 14, 17, 230)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16))) .corner_radius(CornerRadius::ZERO) .inner_margin(Margin::symmetric(16_i8, 12_i8)) .show(ui, |ui| { @@ -757,6 +759,9 @@ pub struct ScytheOverlayApp { bitrate_input_str: String, replay_sec_input_str: String, panel_rect: egui::Rect, + update_status: Arc>, + update_dismissed: bool, + auto_check_updates: bool, } pub type VrecOverlayApp = ScytheOverlayApp; @@ -802,6 +807,12 @@ impl ScytheOverlayApp { let bitrate_input_str = bitrate_mbps.to_string(); let replay_sec_input_str = replay_sec.to_string(); + let auto_check_updates = config.auto_check_updates; + let update_status = Arc::new(std::sync::Mutex::new(crate::updater::UpdateStatus::Idle)); + if auto_check_updates { + crate::updater::spawn_update_check(update_status.clone()); + } + Self { config, status: DaemonStatus::default(), @@ -838,6 +849,9 @@ impl ScytheOverlayApp { bitrate_input_str, replay_sec_input_str, panel_rect: egui::Rect::NOTHING, + update_status, + update_dismissed: false, + auto_check_updates, } } @@ -924,8 +938,8 @@ impl ScytheOverlayApp { if let Some((msg, ts)) = &self.status_msg { if ts.elapsed() < Duration::from_secs(3) { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(12, 16, 24, 240)) - .stroke(Stroke::new(1.0_f32, accent)) + .fill(Color32::from_rgba_unmultiplied(10, 10, 12, 245)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180))) .corner_radius(CornerRadius::ZERO) .inner_margin(Margin::symmetric(14_i8, 5_i8)) .show(ui, |ui| { @@ -936,6 +950,41 @@ impl ScytheOverlayApp { .color(accent), ); }); + } + } + + // Voluntary, non-intrusive Update Notification Banner + let cur_update = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default(); + if let crate::updater::UpdateStatus::Available(ref info) = cur_update { + if !self.update_dismissed { + egui::Frame::NONE + .fill(Color32::from_rgba_unmultiplied(12, 12, 15, 250)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 170))) + .corner_radius(CornerRadius::ZERO) + .inner_margin(Margin::symmetric(14_i8, 7_i8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("UPDATE: v{}", info.version)) + .font(FontId::monospace(11.0)) + .strong() + .color(accent), + ); + ui.label( + egui::RichText::new(format!("(Installed: v{})", crate::updater::CURRENT_VERSION)) + .size(10.5) + .color(Color32::from_rgb(150, 150, 155)), + ); + ui.add_space(8.0); + if squared_button(ui, "DOWNLOAD", true, accent) { + crate::updater::open_browser_url(&info.html_url); + } + ui.add_space(4.0); + if squared_button(ui, "DISMISS", false, accent) { + self.update_dismissed = true; + } + }); + }); ui.add_space(8.0); } } @@ -957,7 +1006,7 @@ impl ScytheOverlayApp { draw_replay_icon(painter, center, 24.0, is_replay_active, accent); }, if is_replay_active { "Turned on" } else { "Turned off" }, - if is_replay_active { accent } else { Color32::from_rgb(148, 163, 184) }, + if is_replay_active { accent } else { Color32::from_rgb(150, 150, 155) }, "(Click for menu)", accent, ); @@ -982,6 +1031,7 @@ impl ScytheOverlayApp { if render_menu_item(ui, "Save Replay", accent, true) { let _ = ipc::send_command(Command::SaveReplay); self.status_msg = Some(("Replay Saved!".to_string(), Instant::now())); + crate::overlay::spawn_toast("INSTANT REPLAY", "Saved to Videos", crate::overlay::ToastIcon::Replay); self.replay_dropdown_open = false; } }); @@ -1014,7 +1064,7 @@ impl ScytheOverlayApp { draw_record_icon(painter, center, 24.0, is_recording, anim_time); }, &rec_status_str, - if is_recording { Color32::from_rgb(239, 68, 68) } else { Color32::from_rgb(148, 163, 184) }, + if is_recording { Color32::from_rgb(239, 68, 68) } else { Color32::from_rgb(150, 150, 155) }, "(Click for menu)", accent, ); @@ -1029,6 +1079,11 @@ impl ScytheOverlayApp { let rec_toggle_text = if is_recording { "Stop Recording" } else { "Start Recording" }; if render_menu_item(ui, rec_toggle_text, accent, true) { let _ = ipc::send_command(Command::ToggleRecording); + if is_recording { + crate::overlay::spawn_toast("RECORDING", "Recording saved", crate::overlay::ToastIcon::Save); + } else { + crate::overlay::spawn_toast("RECORDING", "Recording started", crate::overlay::ToastIcon::Record); + } self.record_dropdown_open = false; } }); @@ -1050,10 +1105,10 @@ impl ScytheOverlayApp { false, false, |painter, center| { - draw_settings_icon(painter, center, 24.0, Color32::from_rgb(148, 163, 184)); + draw_settings_icon(painter, center, 24.0, Color32::from_rgb(150, 150, 155)); }, "Hardware & Tuning", - Color32::from_rgb(148, 163, 184), + Color32::from_rgb(150, 150, 155), "(Click to open)", accent, ); @@ -1089,8 +1144,8 @@ impl ScytheOverlayApp { ui.allocate_new_ui(egui::UiBuilder::new().max_rect(modal_rect), |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(12, 16, 24, 250)) - .stroke(Stroke::new(1.0_f32, accent)) + .fill(Color32::from_rgba_unmultiplied(11, 11, 13, 252)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 160))) .corner_radius(CornerRadius::ZERO) .inner_margin(Margin::symmetric(24_i8, 20_i8)) .show(ui, |ui| { @@ -1104,8 +1159,8 @@ impl ScytheOverlayApp { .strong() .color(accent), ) - .fill(Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 30)) - .stroke(Stroke::new(1.0_f32, accent)) + .fill(Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 22)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180))) .corner_radius(CornerRadius::ZERO); if ui.add(back_btn).clicked() { @@ -1132,7 +1187,7 @@ impl ScytheOverlayApp { egui::RichText::new("ESC TO CLOSE") .font(FontId::monospace(10.0)) .strong() - .color(Color32::from_rgb(148, 163, 184)), + .color(Color32::from_rgb(150, 150, 155)), ); }); }); @@ -1172,7 +1227,7 @@ impl ScytheOverlayApp { ui.label(egui::RichText::new("Target Framerate (FPS):").size(12.0).strong().color(Color32::WHITE)); ui.add_space(4.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Custom:").size(11.0).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new("Custom:").size(11.0).color(Color32::from_rgb(150, 150, 155))); let edit_resp = ui.add( egui::TextEdit::singleline(&mut self.fps_input_str) .desired_width(55.0) @@ -1201,7 +1256,7 @@ impl ScytheOverlayApp { ui.label(egui::RichText::new("Video Bitrate:").size(12.0).strong().color(Color32::WHITE)); ui.add_space(4.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Custom:").size(11.0).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new("Custom:").size(11.0).color(Color32::from_rgb(150, 150, 155))); let edit_resp = ui.add( egui::TextEdit::singleline(&mut self.bitrate_input_str) .desired_width(55.0) @@ -1214,7 +1269,7 @@ impl ScytheOverlayApp { } } } - ui.label(egui::RichText::new("Mbps").size(10.5).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new("Mbps").size(10.5).color(Color32::from_rgb(150, 150, 155))); ui.add_space(8.0); for mbps in [10, 20, 35, 50, 80] { @@ -1237,7 +1292,7 @@ impl ScytheOverlayApp { ui.label(egui::RichText::new("Instant Replay Buffer Duration:").size(12.0).strong().color(Color32::WHITE)); ui.add_space(4.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Custom (sec):").size(11.0).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new("Custom (sec):").size(11.0).color(Color32::from_rgb(150, 150, 155))); let edit_resp = ui.add( egui::TextEdit::singleline(&mut self.replay_sec_input_str) .desired_width(55.0) @@ -1250,7 +1305,7 @@ impl ScytheOverlayApp { } } } - ui.label(egui::RichText::new("sec").size(10.5).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new("sec").size(10.5).color(Color32::from_rgb(150, 150, 155))); ui.add_space(8.0); for sec in [15, 30, 60, 120, 300] { @@ -1433,6 +1488,82 @@ impl ScytheOverlayApp { }); }); + ui.add_space(10.0); + + // ----------------------------------------------------------------- + // SECTION 6: ABOUT & AUTO-UPDATES + // ----------------------------------------------------------------- + render_section_card(ui, "ABOUT & AUTO-UPDATES", accent, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Installed Version: v{}", crate::updater::CURRENT_VERSION)) + .size(12.0) + .strong() + .color(Color32::WHITE), + ); + ui.add_space(10.0); + + let cur_status = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default(); + match cur_status { + crate::updater::UpdateStatus::Idle => { + if squared_button(ui, "Check for Updates", false, accent) { + crate::updater::spawn_update_check(self.update_status.clone()); + } + } + crate::updater::UpdateStatus::Checking => { + ui.label( + egui::RichText::new("Checking for updates...") + .size(11.5) + .color(Color32::from_rgb(150, 150, 155)), + ); + } + crate::updater::UpdateStatus::UpToDate { version } => { + ui.label( + egui::RichText::new(format!("✓ Up to date (v{})", version)) + .size(11.5) + .color(Color32::from_rgb(34, 197, 94)) + .strong(), + ); + ui.add_space(6.0); + if squared_button(ui, "Check Again", false, accent) { + crate::updater::spawn_update_check(self.update_status.clone()); + } + } + crate::updater::UpdateStatus::Available(info) => { + ui.label( + egui::RichText::new(format!("New version available: v{}", info.version)) + .size(11.5) + .color(Color32::from_rgb(245, 158, 11)) + .strong(), + ); + ui.add_space(6.0); + if squared_button(ui, "VIEW RELEASE / DOWNLOAD", true, accent) { + crate::updater::open_browser_url(&info.html_url); + } + } + crate::updater::UpdateStatus::Failed(err) => { + ui.label( + egui::RichText::new(format!("Offline or error: {}", err)) + .size(11.0) + .color(Color32::from_rgb(239, 68, 68)), + ); + ui.add_space(6.0); + if squared_button(ui, "Retry", false, accent) { + crate::updater::spawn_update_check(self.update_status.clone()); + } + } + } + }); + + ui.add_space(6.0); + ui.checkbox( + &mut self.auto_check_updates, + egui::RichText::new("Automatically check for updates on startup") + .size(11.5) + .color(Color32::from_rgb(226, 232, 240)), + ); + }); + ui.add_space(14.0); // APPLY & SAVE BUTTON @@ -1463,6 +1594,7 @@ impl ScytheOverlayApp { 3 => "muted".to_string(), _ => "system".to_string(), }; + self.config.auto_check_updates = self.auto_check_updates; let _ = self.config.save(); crate::hyprland_binds::register_hyprland_binds(&self.config); crate::config::ScytheConfig::notify_daemon_reload(); @@ -1487,8 +1619,8 @@ impl ScytheOverlayApp { ui.allocate_new_ui(egui::UiBuilder::new().max_rect(modal_rect), |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(12, 16, 24, 250)) - .stroke(Stroke::new(1.0_f32, accent)) + .fill(Color32::from_rgba_unmultiplied(11, 11, 13, 252)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 160))) .corner_radius(CornerRadius::ZERO) .inner_margin(Margin::symmetric(20_i8, 16_i8)) .show(ui, |ui| { @@ -1502,8 +1634,8 @@ impl ScytheOverlayApp { .strong() .color(accent), ) - .fill(Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 30)) - .stroke(Stroke::new(1.0_f32, accent)) + .fill(Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 22)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180))) .corner_radius(CornerRadius::ZERO); if ui.add(back_btn).clicked() { @@ -1556,7 +1688,7 @@ impl ScytheOverlayApp { // Left Column: Clips List ui.vertical(|ui| { ui.set_width(280.0); - ui.label(egui::RichText::new(format!("RECORDED CLIPS ({})", self.clips.len())).size(11.0).strong().color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new(format!("RECORDED CLIPS ({})", self.clips.len())).size(11.0).strong().color(Color32::from_rgb(150, 150, 155))); ui.add_space(4.0); egui::ScrollArea::vertical() @@ -1564,7 +1696,7 @@ impl ScytheOverlayApp { .id_salt("gallery_clips_scroll") .show(ui, |ui| { if self.clips.is_empty() { - ui.label(egui::RichText::new("No recordings found yet.\nPress hotkeys to capture clips.").size(11.0).color(Color32::from_rgb(100, 116, 139))); + ui.label(egui::RichText::new("No recordings found yet.\nPress hotkeys to capture clips.").size(11.0).color(Color32::from_rgb(120, 120, 126))); } else { for (idx, clip) in self.clips.iter().enumerate() { let is_sel = self.selected_clip_idx == Some(idx); @@ -1595,7 +1727,7 @@ impl ScytheOverlayApp { ui.add_space(4.0); let size_mb = clip.size_bytes as f64 / (1024.0 * 1024.0); - ui.label(egui::RichText::new(format!("{:.1} MB", size_mb)).size(10.0).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new(format!("{:.1} MB", size_mb)).size(10.0).color(Color32::from_rgb(150, 150, 155))); }); ui.add_space(2.0); ui.label(egui::RichText::new(&clip.filename).size(10.5).strong().color(Color32::WHITE)); @@ -1631,7 +1763,7 @@ impl ScytheOverlayApp { }); ui.horizontal(|ui| { ui.label(egui::RichText::new("Size:").size(11.0).strong().color(Color32::WHITE)); - ui.label(egui::RichText::new(format!("{:.2} MB ({} bytes)", size_mb, clip.size_bytes)).size(10.5).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new(format!("{:.2} MB ({} bytes)", size_mb, clip.size_bytes)).size(10.5).color(Color32::from_rgb(150, 150, 155))); }); if self.clip_duration_sec > 0.0 { ui.horizontal(|ui| { @@ -1694,7 +1826,7 @@ impl ScytheOverlayApp { let trimmed_dur = (self.trim_end_sec - self.trim_start_sec).max(0.0); ui.add_space(4.0); - ui.label(egui::RichText::new(format!("Trimmed output length: {:.1}s (Instant lossless copy)", trimmed_dur)).size(10.5).color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new(format!("Trimmed output length: {:.1}s (Instant lossless copy)", trimmed_dur)).size(10.5).color(Color32::from_rgb(150, 150, 155))); ui.add_space(8.0); let trim_btn = egui::Button::new(egui::RichText::new("TRIM & EXPORT COPY").size(11.5).strong().color(Color32::from_rgb(11, 18, 4))) @@ -1721,9 +1853,9 @@ impl ScytheOverlayApp { render_section_card(ui, "CLIP PREVIEW & TRIMMER", accent, |ui| { ui.add_space(40.0); ui.vertical_centered(|ui| { - ui.label(egui::RichText::new("No clip selected").font(FontId::proportional(13.0)).strong().color(Color32::from_rgb(148, 163, 184))); + ui.label(egui::RichText::new("No clip selected").font(FontId::proportional(13.0)).strong().color(Color32::from_rgb(150, 150, 155))); ui.add_space(6.0); - ui.label(egui::RichText::new("Select a recording or instant replay from the list on the left to inspect, play, or losslessly trim.").size(10.5).color(Color32::from_rgb(100, 116, 139))); + ui.label(egui::RichText::new("Select a recording or instant replay from the list on the left to inspect, play, or losslessly trim.").size(10.5).color(Color32::from_rgb(120, 120, 126))); }); ui.add_space(40.0); }); @@ -1889,3 +2021,144 @@ pub fn run_egui_overlay() { crate::ipc::clean_overlay_pid(); } + +pub struct ShadowPlayToastApp { + title: String, + subtitle: String, + icon: crate::overlay::ToastIcon, + accent: Color32, + created_at: Instant, + duration: Duration, +} + +impl eframe::App for ShadowPlayToastApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if self.created_at.elapsed() >= self.duration { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + std::process::exit(0); + } + ctx.request_repaint_after(Duration::from_millis(50)); + + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = Color32::TRANSPARENT; + visuals.window_fill = Color32::TRANSPARENT; + ctx.set_visuals(visuals); + + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(Color32::TRANSPARENT)) + .show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(320.0, 56.0), egui::Sense::hover()); + let painter = ui.painter(); + + // Drop shadow + painter.rect_filled( + rect.translate(Vec2::new(0.0, 4.0)), + CornerRadius::ZERO, + Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + // Obsidian toast card + painter.rect( + rect, + CornerRadius::ZERO, + Color32::from_rgba_unmultiplied(10, 10, 12, 248), + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 28)), + egui::StrokeKind::Inside, + ); + + // Left accent line + let line_color = if self.icon == crate::overlay::ToastIcon::Record { + Color32::from_rgb(239, 68, 68) + } else { + self.accent + }; + let accent_line = egui::Rect::from_min_size(rect.min, Vec2::new(3.5, rect.height())); + painter.rect_filled(accent_line, CornerRadius::ZERO, line_color); + + // Left icon area (32x32 at center-left) + let icon_center = egui::pos2(rect.left() + 24.0, rect.center().y); + match self.icon { + crate::overlay::ToastIcon::Replay => { + draw_replay_icon(painter, icon_center, 9.5, true, self.accent); + } + crate::overlay::ToastIcon::Record => { + painter.circle_filled(icon_center, 5.5, Color32::from_rgb(239, 68, 68)); + painter.circle_stroke(icon_center, 9.5, Stroke::new(1.2_f32, Color32::from_rgba_unmultiplied(239, 68, 68, 120))); + } + crate::overlay::ToastIcon::Save => { + let c = icon_center; + let p1 = c + Vec2::new(-5.5, 0.0); + let p2 = c + Vec2::new(-1.5, 4.0); + let p3 = c + Vec2::new(5.5, -4.0); + painter.line_segment([p1, p2], Stroke::new(2.2_f32, self.accent)); + painter.line_segment([p2, p3], Stroke::new(2.2_f32, self.accent)); + } + crate::overlay::ToastIcon::Cursor => { + let c = icon_center; + painter.text(c, egui::Align2::CENTER_CENTER, "⯈", FontId::proportional(14.0), self.accent); + } + crate::overlay::ToastIcon::Error => { + let c = icon_center; + let red = Color32::from_rgb(239, 68, 68); + painter.line_segment([c + Vec2::new(-4.0, -4.0), c + Vec2::new(4.0, 4.0)], Stroke::new(2.0_f32, red)); + painter.line_segment([c + Vec2::new(4.0, -4.0), c + Vec2::new(-4.0, 4.0)], Stroke::new(2.0_f32, red)); + } + crate::overlay::ToastIcon::Info => { + painter.circle_filled(icon_center, 5.0, self.accent); + } + } + + // Text stack (Title + Subtitle) + let text_left = rect.left() + 48.0; + painter.text( + egui::pos2(text_left, rect.top() + 19.0), + egui::Align2::LEFT_CENTER, + &self.title, + FontId::proportional(12.0), + Color32::WHITE, + ); + painter.text( + egui::pos2(text_left, rect.top() + 37.0), + egui::Align2::LEFT_CENTER, + &self.subtitle, + FontId::proportional(10.5), + Color32::from_rgb(160, 160, 168), + ); + }); + } +} + +pub fn run_egui_toast(title: &str, subtitle: &str, icon: crate::overlay::ToastIcon) { + let cfg = ScytheConfig::load(); + let accent = resolve_accent_color(&cfg.accent_color); + + let toast_w = 320.0; + let toast_h = 56.0; + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("Scythe Notification") + .with_app_id("scythe-toast") + .with_inner_size([toast_w, toast_h]) + .with_decorations(false) + .with_transparent(true) + .with_always_on_top() + .with_resizable(false), + ..Default::default() + }; + + let app = ShadowPlayToastApp { + title: title.to_string(), + subtitle: subtitle.to_string(), + icon, + accent, + created_at: Instant::now(), + duration: Duration::from_millis(2600), + }; + + let _ = eframe::run_native( + "scythe-toast", + options, + Box::new(|_cc| Ok(Box::new(app))), + ); +} diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..35523cb --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,155 @@ +use std::sync::{Arc, Mutex}; +use serde::Deserialize; + +pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const GITHUB_REPO: &str = "oiupoyt/scythe"; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ReleaseInfo { + pub tag_name: String, + pub version: String, + pub name: String, + pub html_url: String, + pub release_notes: String, + pub has_update: bool, +} + +#[derive(Deserialize, Debug)] +struct GitHubRelease { + tag_name: String, + name: Option, + html_url: String, + body: Option, +} + +pub fn is_newer(latest: &str, current: &str) -> bool { + let parse_nums = |s: &str| -> Vec { + let clean = s.trim().trim_start_matches('v'); + clean + .split('.') + .filter_map(|p| p.split('-').next().and_then(|n| n.parse::().ok())) + .collect() + }; + let l_nums = parse_nums(latest); + let c_nums = parse_nums(current); + for (l, c) in l_nums.iter().zip(c_nums.iter()) { + if l > c { + return true; + } else if l < c { + return false; + } + } + l_nums.len() > c_nums.len() +} + +pub fn check_for_updates() -> Option { + let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO); + + // Run curl with a 4-second timeout to avoid blocking + let output = std::process::Command::new("curl") + .args([ + "-s", + "-H", "User-Agent: scythe-updater", + "-H", "Accept: application/vnd.github.v3+json", + "--max-time", "4", + &url, + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let release: GitHubRelease = serde_json::from_slice(&output.stdout).ok()?; + let clean_ver = release.tag_name.trim().trim_start_matches('v').to_string(); + let has_update = is_newer(&clean_ver, CURRENT_VERSION); + + Some(ReleaseInfo { + tag_name: release.tag_name, + version: clean_ver, + name: release.name.unwrap_or_else(|| format!("Scythe v{}", CURRENT_VERSION)), + html_url: release.html_url, + release_notes: release.body.unwrap_or_default(), + has_update, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateStatus { + Idle, + Checking, + UpToDate { version: String }, + Available(ReleaseInfo), + Failed(String), +} + +impl Default for UpdateStatus { + fn default() -> Self { + UpdateStatus::Idle + } +} + +pub fn spawn_update_check(status: Arc>) { + if let Ok(mut lock) = status.lock() { + *lock = UpdateStatus::Checking; + } + std::thread::spawn(move || { + match check_for_updates() { + Some(info) => { + if let Ok(mut lock) = status.lock() { + if info.has_update { + *lock = UpdateStatus::Available(info); + } else { + *lock = UpdateStatus::UpToDate { version: info.version }; + } + } + } + None => { + if let Ok(mut lock) = status.lock() { + *lock = UpdateStatus::Failed("Could not reach update server".to_string()); + } + } + } + }); +} + +pub fn open_browser_url(url: &str) { + let u = url.to_string(); + std::thread::spawn(move || { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + let _ = std::process::Command::new("cmd") + .args(["/C", "start", "", &u]) + .creation_flags(0x08000000) + .spawn(); + } + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open").arg(&u).spawn(); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = std::process::Command::new("xdg-open").arg(&u).spawn(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_comparisons() { + assert!(is_newer("0.2.0", "0.1.0")); + assert!(is_newer("v0.2.0", "0.1.0")); + assert!(is_newer("1.0.0", "0.9.9")); + assert!(is_newer("0.1.1", "0.1.0")); + assert!(!is_newer("0.1.0", "0.1.0")); + assert!(!is_newer("v0.1.0", "0.1.0")); + assert!(!is_newer("0.2.0", "0.2.0")); + assert!(is_newer("0.2.1", "0.2.0")); + assert!(!is_newer("0.0.9", "0.1.0")); + } +}