feat: auto-update system, datetime file naming, obsidian theme, and shadowplay toast overlay
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
5ecd9a9910
commit
900b7a9629
11 changed files with 833 additions and 163 deletions
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -3700,7 +3700,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "scythe"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"cpal",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "scythe"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -392,8 +392,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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() };
|
||||
|
|
|
|||
|
|
@ -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<dyn std::error::Error + Send + Sync>> {
|
||||
fn send_with_notification(cmd: Command, title: &str, subtitle: &str, icon: ToastIcon) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sy
|
|||
match send_command(Command::ToggleRecording) {
|
||||
Ok(()) => {
|
||||
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<dyn std::error::Error + Send + Sync>
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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<dyn std::error::Error + Send + Sync>> {
|
|||
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 <t> <s> 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<dyn std::error::Error + Send + Sync>> {
|
|||
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() {
|
||||
|
|
|
|||
|
|
@ -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::<libc::tm>();
|
||||
#[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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)$",
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ pub mod config;
|
|||
pub mod settings_ui;
|
||||
pub mod overlay_egui;
|
||||
pub mod hyprland_binds;
|
||||
pub mod updater;
|
||||
|
|
|
|||
291
src/overlay.rs
291
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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<VideoClipInfo> {
|
|||
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<String>, is_active: Arc<AtomicBool>
|
|||
#[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<std::sync::Mutex<crate::updater::UpdateStatus>>,
|
||||
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))),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
155
src/updater.rs
Normal file
155
src/updater.rs
Normal file
|
|
@ -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<String>,
|
||||
html_url: String,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||
let parse_nums = |s: &str| -> Vec<u64> {
|
||||
let clean = s.trim().trim_start_matches('v');
|
||||
clean
|
||||
.split('.')
|
||||
.filter_map(|p| p.split('-').next().and_then(|n| n.parse::<u64>().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<ReleaseInfo> {
|
||||
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<Mutex<UpdateStatus>>) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue