@@ -1,6 +1,6 @@ |
| 1 | 1 | //! Daemon state management |
| 2 | 2 | |
| 3 | | -use anyhow::Result; |
| 3 | +use anyhow::{Context, Result}; |
| 4 | 4 | use std::collections::HashMap; |
| 5 | 5 | use std::time::Duration; |
| 6 | 6 | use tokio::net::UnixStream; |
@@ -8,9 +8,9 @@ use tokio::net::UnixStream; |
| 8 | 8 | use crate::config::{Config, ScaleMode}; |
| 9 | 9 | use crate::ipc::{Command, GarEvent, GarIpcClient, IpcServer, Response}; |
| 10 | 10 | use crate::ipc::server::IpcClient; |
| 11 | | -use crate::media::{scale_image, ImageLoader}; |
| 11 | +use crate::media::{scale_image, AnimatedGif, ImageLoader}; |
| 12 | 12 | use crate::state::{detect_source_type, PlaylistState}; |
| 13 | | -use crate::x11::Connection; |
| 13 | +use crate::x11::{AnimationRenderer, Connection}; |
| 14 | 14 | |
| 15 | 15 | /// Current wallpaper state for a monitor |
| 16 | 16 | #[derive(Debug, Clone)] |
@@ -66,6 +66,48 @@ impl DaemonState { |
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | +/// Active animation state |
| 70 | +pub struct ActiveAnimation { |
| 71 | + /// The animated GIF data |
| 72 | + gif: AnimatedGif, |
| 73 | + /// Pre-scaled frames for quick rendering |
| 74 | + scaled_frames: Vec<image::RgbaImage>, |
| 75 | + /// Animation renderer (double-buffered) |
| 76 | + renderer: AnimationRenderer, |
| 77 | + /// Current frame index |
| 78 | + current_frame: usize, |
| 79 | + /// Max FPS |
| 80 | + max_fps: u32, |
| 81 | + /// Scale mode |
| 82 | + scale_mode: ScaleMode, |
| 83 | + /// Source URI (for status) |
| 84 | + source: String, |
| 85 | +} |
| 86 | + |
| 87 | +impl ActiveAnimation { |
| 88 | + /// Get the delay for the current frame |
| 89 | + fn current_delay(&self) -> Duration { |
| 90 | + let frame_delay = self.gif.frames()[self.current_frame].delay; |
| 91 | + let min_delay = if self.max_fps > 0 { |
| 92 | + Duration::from_secs_f64(1.0 / self.max_fps as f64) |
| 93 | + } else { |
| 94 | + Duration::ZERO |
| 95 | + }; |
| 96 | + frame_delay.max(min_delay) |
| 97 | + } |
| 98 | + |
| 99 | + /// Advance to next frame, returning true if looped |
| 100 | + fn advance(&mut self) -> bool { |
| 101 | + self.current_frame += 1; |
| 102 | + if self.current_frame >= self.scaled_frames.len() { |
| 103 | + self.current_frame = 0; |
| 104 | + true |
| 105 | + } else { |
| 106 | + false |
| 107 | + } |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 69 | 111 | /// Main daemon struct |
| 70 | 112 | pub struct Daemon { |
| 71 | 113 | /// X11 connection |
@@ -73,15 +115,35 @@ pub struct Daemon { |
| 73 | 115 | |
| 74 | 116 | /// Daemon state |
| 75 | 117 | state: DaemonState, |
| 118 | + |
| 119 | + /// Current animation (if playing) |
| 120 | + animation: Option<ActiveAnimation>, |
| 76 | 121 | } |
| 77 | 122 | |
| 78 | 123 | impl Daemon { |
| 79 | 124 | /// Create a new daemon |
| 125 | + /// |
| 126 | + /// Establishes X11 connection and initializes state. |
| 127 | + /// Fails early with helpful error messages if X11 is unavailable. |
| 80 | 128 | pub fn new(config: Config) -> Result<Self> { |
| 81 | | - let conn = Connection::new()?; |
| 129 | + let conn = Connection::new() |
| 130 | + .context("Failed to initialize X11 connection for daemon")?; |
| 131 | + |
| 132 | + // Verify connection is working |
| 133 | + if !conn.is_alive() { |
| 134 | + anyhow::bail!("X11 connection established but not responding"); |
| 135 | + } |
| 136 | + |
| 137 | + let (width, height) = conn.screen_dimensions(); |
| 138 | + tracing::info!("X11 connection established (screen: {}x{})", width, height); |
| 139 | + |
| 82 | 140 | let state = DaemonState::new(config); |
| 83 | 141 | |
| 84 | | - Ok(Self { conn, state }) |
| 142 | + Ok(Self { |
| 143 | + conn, |
| 144 | + state, |
| 145 | + animation: None, |
| 146 | + }) |
| 85 | 147 | } |
| 86 | 148 | |
| 87 | 149 | /// Run the daemon event loop |
@@ -108,6 +170,9 @@ impl Daemon { |
| 108 | 170 | tracing::info!("Slideshow enabled: {:?} interval", interval); |
| 109 | 171 | } |
| 110 | 172 | |
| 173 | + // Track next animation frame time |
| 174 | + let mut next_animation_frame: Option<tokio::time::Instant> = None; |
| 175 | + |
| 111 | 176 | // Main event loop |
| 112 | 177 | loop { |
| 113 | 178 | tokio::select! { |
@@ -121,6 +186,12 @@ impl Daemon { |
| 121 | 186 | // Update slideshow timer if interval changed |
| 122 | 187 | next_slideshow = self.state.slideshow_interval |
| 123 | 188 | .map(|d| tokio::time::Instant::now() + d); |
| 189 | + // Update animation timer if animation started |
| 190 | + if self.animation.is_some() && next_animation_frame.is_none() { |
| 191 | + next_animation_frame = Some(tokio::time::Instant::now()); |
| 192 | + } else if self.animation.is_none() { |
| 193 | + next_animation_frame = None; |
| 194 | + } |
| 124 | 195 | } |
| 125 | 196 | Err(e) => { |
| 126 | 197 | tracing::warn!("Accept error: {}", e); |
@@ -128,10 +199,33 @@ impl Daemon { |
| 128 | 199 | } |
| 129 | 200 | } |
| 130 | 201 | |
| 131 | | - // Slideshow timer (only if enabled and not paused) |
| 202 | + // Animation frame timer (highest priority when active) |
| 132 | 203 | _ = async { |
| 133 | | - match (next_slideshow, self.state.paused) { |
| 134 | | - (Some(deadline), false) => { |
| 204 | + match (next_animation_frame, self.state.paused, &self.animation) { |
| 205 | + (Some(deadline), false, Some(_)) => { |
| 206 | + tokio::time::sleep_until(deadline).await; |
| 207 | + } |
| 208 | + _ => { |
| 209 | + std::future::pending::<()>().await; |
| 210 | + } |
| 211 | + } |
| 212 | + } => { |
| 213 | + if let Err(e) = self.render_animation_frame() { |
| 214 | + tracing::warn!("Animation frame render failed: {}", e); |
| 215 | + // Stop animation on error |
| 216 | + self.animation = None; |
| 217 | + next_animation_frame = None; |
| 218 | + } else if let Some(ref anim) = self.animation { |
| 219 | + // Schedule next frame |
| 220 | + let delay = anim.current_delay(); |
| 221 | + next_animation_frame = Some(tokio::time::Instant::now() + delay); |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + // Slideshow timer (only if enabled, not paused, and no animation) |
| 226 | + _ = async { |
| 227 | + match (next_slideshow, self.state.paused, &self.animation) { |
| 228 | + (Some(deadline), false, None) => { |
| 135 | 229 | tokio::time::sleep_until(deadline).await; |
| 136 | 230 | } |
| 137 | 231 | _ => { |
@@ -171,6 +265,21 @@ impl Daemon { |
| 171 | 265 | } |
| 172 | 266 | } |
| 173 | 267 | |
| 268 | + /// Render the next animation frame |
| 269 | + fn render_animation_frame(&mut self) -> Result<()> { |
| 270 | + let anim = self.animation.as_mut() |
| 271 | + .ok_or_else(|| anyhow::anyhow!("No active animation"))?; |
| 272 | + |
| 273 | + // Render current frame |
| 274 | + let frame = &anim.scaled_frames[anim.current_frame]; |
| 275 | + anim.renderer.render_and_present(&mut self.conn, frame)?; |
| 276 | + |
| 277 | + // Advance to next frame |
| 278 | + anim.advance(); |
| 279 | + |
| 280 | + Ok(()) |
| 281 | + } |
| 282 | + |
| 174 | 283 | /// Handle a single client connection |
| 175 | 284 | async fn handle_client(&mut self, stream: UnixStream) -> Result<()> { |
| 176 | 285 | let mut client = IpcClient::new(stream); |
@@ -187,10 +296,32 @@ impl Daemon { |
| 187 | 296 | /// Handle an IPC command |
| 188 | 297 | fn handle_command(&mut self, cmd: Command) -> Response { |
| 189 | 298 | match cmd { |
| 190 | | - Command::Set { source, mode, monitor: _, interval_secs, shuffle } => { |
| 299 | + Command::Set { source, mode, monitor: _, interval_secs, shuffle, animate, max_fps } => { |
| 191 | 300 | let scale_mode = mode.unwrap_or(self.state.config.general.mode); |
| 192 | 301 | |
| 193 | | - // Set up slideshow with the new source |
| 302 | + // Stop any existing animation first |
| 303 | + self.animation = None; |
| 304 | + |
| 305 | + // Check if we should animate (GIF with animate flag) |
| 306 | + let is_gif = source.to_lowercase().ends_with(".gif") |
| 307 | + || source.to_lowercase().contains(".gif?") |
| 308 | + || source.to_lowercase().contains("/gif/"); |
| 309 | + |
| 310 | + if animate && is_gif { |
| 311 | + // Try to start animation |
| 312 | + match self.start_animation(&source, scale_mode, max_fps) { |
| 313 | + Ok(_) => { |
| 314 | + tracing::info!("Animation started: {}", source); |
| 315 | + return Response::ok(); |
| 316 | + } |
| 317 | + Err(e) => { |
| 318 | + tracing::warn!("Failed to start animation: {}, falling back to static", e); |
| 319 | + // Fall through to static handling |
| 320 | + } |
| 321 | + } |
| 322 | + } |
| 323 | + |
| 324 | + // Set up slideshow with the new source (static image) |
| 194 | 325 | match self.set_wallpaper_with_options(&source, scale_mode, shuffle, interval_secs) { |
| 195 | 326 | Ok(_) => { |
| 196 | 327 | // Update slideshow interval |
@@ -332,6 +463,70 @@ impl Daemon { |
| 332 | 463 | self.set_wallpaper_from_source(&source, mode, shuffle) |
| 333 | 464 | } |
| 334 | 465 | |
| 466 | + /// Start an animated GIF playback |
| 467 | + fn start_animation(&mut self, source: &str, mode: ScaleMode, max_fps: u32) -> Result<()> { |
| 468 | + let is_remote = source.starts_with("http://") || source.starts_with("https://"); |
| 469 | + |
| 470 | + // Load the GIF |
| 471 | + let gif = if is_remote { |
| 472 | + tracing::info!("Fetching remote GIF: {}", source); |
| 473 | + let bytes = self.fetch_bytes(source)?; |
| 474 | + AnimatedGif::load_from_bytes(&bytes)? |
| 475 | + } else { |
| 476 | + let expanded = shellexpand::tilde(source); |
| 477 | + AnimatedGif::load(expanded.as_ref())? |
| 478 | + }; |
| 479 | + |
| 480 | + if !gif.is_animated() { |
| 481 | + anyhow::bail!("GIF is not animated (single frame)"); |
| 482 | + } |
| 483 | + |
| 484 | + // Pre-scale all frames |
| 485 | + let (width, height) = self.conn.screen_dimensions(); |
| 486 | + let scaled_frames: Vec<image::RgbaImage> = gif |
| 487 | + .frames() |
| 488 | + .iter() |
| 489 | + .map(|frame| scale_image(&frame.image, width as u32, height as u32, mode)) |
| 490 | + .collect(); |
| 491 | + |
| 492 | + // Create animation renderer |
| 493 | + let renderer = AnimationRenderer::new(&self.conn)?; |
| 494 | + |
| 495 | + tracing::info!( |
| 496 | + "Animation loaded: {} frames, {:.1} FPS", |
| 497 | + gif.frame_count(), |
| 498 | + gif.average_fps() |
| 499 | + ); |
| 500 | + |
| 501 | + self.animation = Some(ActiveAnimation { |
| 502 | + gif, |
| 503 | + scaled_frames, |
| 504 | + renderer, |
| 505 | + current_frame: 0, |
| 506 | + max_fps, |
| 507 | + scale_mode: mode, |
| 508 | + source: source.to_string(), |
| 509 | + }); |
| 510 | + |
| 511 | + Ok(()) |
| 512 | + } |
| 513 | + |
| 514 | + /// Fetch raw bytes from a URL |
| 515 | + fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>> { |
| 516 | + let client = reqwest::blocking::Client::builder() |
| 517 | + .user_agent("garbg/0.1") |
| 518 | + .build()?; |
| 519 | + |
| 520 | + let response = client.get(url).send()?; |
| 521 | + let status = response.status(); |
| 522 | + |
| 523 | + if !status.is_success() { |
| 524 | + anyhow::bail!("HTTP error {}: {}", status, url); |
| 525 | + } |
| 526 | + |
| 527 | + Ok(response.bytes()?.to_vec()) |
| 528 | + } |
| 529 | + |
| 335 | 530 | /// Set wallpaper with full options (used by IPC Set command) |
| 336 | 531 | fn set_wallpaper_with_options( |
| 337 | 532 | &mut self, |