diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs index 12069cf..bcb8208 100644 --- a/crates/aster-webui/src/main.rs +++ b/crates/aster-webui/src/main.rs @@ -83,12 +83,15 @@ struct RotationSnapshot { switch_time: u32, specs_enabled: bool, memes_enabled: bool, + gifs_enabled: bool, active_images: Vec, } impl RotationSnapshot { fn rotation_active(&self) -> bool { - self.custom_panel && (self.specs_enabled || (self.memes_enabled && !self.active_images.is_empty())) + self.custom_panel + && (self.specs_enabled + || ((self.memes_enabled || self.gifs_enabled) && !self.active_images.is_empty())) } } @@ -107,7 +110,7 @@ struct GifSetManifest { frames: Vec, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] struct ActiveFrame { key: String, image_name: String, @@ -137,6 +140,7 @@ struct SetupView { switch_time: String, specs_enabled: bool, memes_enabled: bool, + gifs_enabled: bool, } #[derive(Clone, Serialize)] @@ -158,6 +162,7 @@ struct DisplayStatus { custom_panel: bool, specs_enabled: bool, memes_enabled: bool, + gifs_enabled: bool, rotation_active: bool, switch_time: String, active_images: Vec, @@ -200,6 +205,7 @@ struct ActivateRequest { switch_time: Option, specs_enabled: Option, memes_enabled: Option, + gifs_enabled: Option, } #[derive(Deserialize)] @@ -272,6 +278,7 @@ fn initial_display_status(config: &DisplayConfig) -> DisplayStatus { custom_panel: false, specs_enabled: false, memes_enabled: false, + gifs_enabled: false, rotation_active: false, switch_time: "10".into(), active_images: Vec::new(), @@ -309,6 +316,7 @@ async fn ensure_layout(state: &AppState) -> Result<()> { "switchTime": "10", "nativeSpecs": false, "nativeMemes": true, + "nativeGifs": true, "operationMode": 0, "theme": 1, "diskUpdate": 300, @@ -435,14 +443,18 @@ async fn api_activate( } } - let memes_enabled = payload - .memes_enabled - .unwrap_or(!valid.is_empty()); + let has_static_selection = valid.iter().any(|name| !is_gif_manifest_name(name)); + let has_gif_selection = valid.iter().any(|name| is_gif_manifest_name(name)); + let memes_enabled = payload.memes_enabled.unwrap_or(has_static_selection); + let gifs_enabled = payload.gifs_enabled.unwrap_or(has_gif_selection); - if !specs_enabled && (!memes_enabled || valid.is_empty()) { + if !specs_enabled + && !(memes_enabled && has_static_selection) + && !(gifs_enabled && has_gif_selection) + { return error_response( StatusCode::BAD_REQUEST, - "Enable specs or select at least one meme image", + "Enable specs, memes with a still image, or GIFs with an animated image", ); } @@ -451,7 +463,14 @@ async fn api_activate( Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), }; - set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &valid); + set_custom_panels( + &mut monitor, + switch_time, + specs_enabled, + memes_enabled, + gifs_enabled, + &valid, + ); if let Err(err) = save_monitor_json(&state, &monitor).await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); @@ -462,6 +481,7 @@ async fn api_activate( "switchTime": switch_time, "specsEnabled": specs_enabled, "memesEnabled": memes_enabled, + "gifsEnabled": gifs_enabled, "activeImages": valid, })) .into_response() @@ -475,7 +495,7 @@ async fn api_disable(State(state): State>) -> Response { let switch_time = current_switch_time(&monitor); let active_images = current_active_images(&monitor); - set_custom_panels(&mut monitor, switch_time, false, false, &active_images); + set_custom_panels(&mut monitor, switch_time, false, false, false, &active_images); if let Err(err) = save_monitor_json(&state, &monitor).await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); @@ -510,10 +530,18 @@ async fn api_delete( let switch_time = current_switch_time(&monitor); let specs_enabled = current_specs_enabled(&monitor); let memes_enabled = current_memes_enabled(&monitor); + let gifs_enabled = current_gifs_enabled(&monitor); let before = active_images.len(); active_images.retain(|name| name != &payload.name); if active_images.len() != before { - set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images); + set_custom_panels( + &mut monitor, + switch_time, + specs_enabled, + memes_enabled, + gifs_enabled, + &active_images, + ); if let Err(err) = save_monitor_json(&state, &monitor).await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } @@ -581,6 +609,7 @@ async fn build_state_response(state: &AppState) -> Result { .to_string(); let specs_enabled = current_specs_enabled(&monitor); let memes_enabled = current_memes_enabled(&monitor); + let gifs_enabled = current_gifs_enabled(&monitor); Ok(StateResponse { monitor_path: state.monitor_path.display().to_string(), @@ -590,6 +619,7 @@ async fn build_state_response(state: &AppState) -> Result { switch_time, specs_enabled, memes_enabled, + gifs_enabled, }, active_images: current_active_images(&monitor), images: list_images(state).await?, @@ -816,12 +846,22 @@ fn current_memes_enabled(monitor: &Value) -> bool { .unwrap_or_else(|| !current_active_images(monitor).is_empty()) } +fn current_gifs_enabled(monitor: &Value) -> bool { + monitor + .get("setup") + .and_then(Value::as_object) + .and_then(|setup| setup.get("nativeGifs")) + .and_then(Value::as_bool) + .unwrap_or(true) +} + fn rotation_snapshot(monitor: &Value) -> RotationSnapshot { RotationSnapshot { custom_panel: current_custom_panel_enabled(monitor), switch_time: current_switch_time(monitor), specs_enabled: current_specs_enabled(monitor), memes_enabled: current_memes_enabled(monitor), + gifs_enabled: current_gifs_enabled(monitor), active_images: current_active_images(monitor), } } @@ -831,9 +871,10 @@ fn set_custom_panels( switch_time: u32, specs_enabled: bool, memes_enabled: bool, + gifs_enabled: bool, images: &[String], ) { - let enabled = specs_enabled || (memes_enabled && !images.is_empty()); + let enabled = specs_enabled || ((memes_enabled || gifs_enabled) && !images.is_empty()); let setup = monitor .as_object_mut() .expect("monitor config must be an object") @@ -845,6 +886,7 @@ fn set_custom_panels( setup.insert("switchTime".into(), Value::String(switch_time.to_string())); setup.insert("nativeSpecs".into(), Value::Bool(specs_enabled)); setup.insert("nativeMemes".into(), Value::Bool(memes_enabled)); + setup.insert("nativeGifs".into(), Value::Bool(gifs_enabled)); } monitor["mianban"] = Value::Array( @@ -1224,15 +1266,16 @@ fn load_gif_manifest(image_dir: &PathBuf, name: &str) -> Result fn build_rotation_slots(snapshot: &RotationSnapshot, image_dir: &PathBuf) -> Vec { let mut slots = Vec::new(); - if snapshot.memes_enabled { + if snapshot.memes_enabled || snapshot.gifs_enabled { for name in &snapshot.active_images { if is_gif_manifest_name(name) { + if !snapshot.gifs_enabled { + continue; + } match load_gif_manifest(image_dir, name) { Ok(manifest) if !manifest.frames.is_empty() => { - let mut total_ms = 0u64; let mut frames = Vec::new(); for (idx, frame) in manifest.frames.iter().enumerate() { - total_ms += frame.delay_ms.max(100) as u64; frames.push(( ActiveFrame { key: format!("{name}#{idx}"), @@ -1243,14 +1286,14 @@ fn build_rotation_slots(snapshot: &RotationSnapshot, image_dir: &PathBuf) -> Vec )); } slots.push(RotationSlot { - total_duration: Duration::from_millis(total_ms.max(100)), + total_duration: Duration::from_secs(snapshot.switch_time as u64), frames, }); } Ok(_) => {} Err(err) => warn!("Skipping GIF set {name}: {err}"), } - } else { + } else if snapshot.memes_enabled { slots.push(RotationSlot { total_duration: Duration::from_secs(snapshot.switch_time as u64), frames: vec![( @@ -1312,22 +1355,8 @@ fn current_slot_frame( for (slot_idx, slot) in slots.iter().enumerate() { let slot_ms = slot.total_duration.as_millis() as u64; if elapsed_ms < cursor + slot_ms { - if slot.frames.len() == 1 { - return Some((slot_idx, slot.frames[0].0.clone())); - } let local_ms = elapsed_ms - cursor; - let mut frame_cursor = 0u64; - for (frame, delay) in &slot.frames { - let delay_ms = delay.as_millis() as u64; - if local_ms < frame_cursor + delay_ms { - return Some((slot_idx, frame.clone())); - } - frame_cursor += delay_ms; - } - return slot - .frames - .last() - .map(|(frame, _)| (slot_idx, frame.clone())); + return slot_frame_at(slot, slot_idx, local_ms); } cursor += slot_ms; } @@ -1338,6 +1367,40 @@ fn current_slot_frame( .map(|frame| (slots.len() - 1, frame)) } +fn slot_frame_at(slot: &RotationSlot, slot_idx: usize, local_ms: u64) -> Option<(usize, ActiveFrame)> { + if slot.frames.len() == 1 { + return Some((slot_idx, slot.frames[0].0.clone())); + } + + let frame_cycle_ms = frame_cycle_ms(slot); + let frame_elapsed_ms = if frame_cycle_ms == 0 { + 0 + } else { + local_ms % frame_cycle_ms + }; + let mut frame_cursor = 0u64; + + for (frame, delay) in &slot.frames { + let delay_ms = delay.as_millis() as u64; + if frame_elapsed_ms < frame_cursor + delay_ms { + return Some((slot_idx, frame.clone())); + } + frame_cursor += delay_ms; + } + + slot.frames + .last() + .map(|(frame, _)| (slot_idx, frame.clone())) +} + +fn frame_cycle_ms(slot: &RotationSlot) -> u64 { + slot.frames + .iter() + .map(|(_, delay)| delay.as_millis() as u64) + .sum::() + .max(100) +} + fn spawn_display_worker(state: Arc, config: DisplayConfig) { thread::spawn(move || run_display_worker(state, config)); } @@ -1439,6 +1502,7 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { status.custom_panel = snapshot.custom_panel; status.specs_enabled = snapshot.specs_enabled; status.memes_enabled = snapshot.memes_enabled; + status.gifs_enabled = snapshot.gifs_enabled; status.rotation_active = snapshot.rotation_active(); status.switch_time = snapshot.switch_time.to_string(); status.active_images = snapshot.active_images.clone(); @@ -1454,7 +1518,7 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { continue; } - let specs_only = snapshot.specs_enabled && (!snapshot.memes_enabled || snapshot.active_images.is_empty()); + let specs_only = snapshot.specs_enabled && slots.is_empty(); if let Some((_slot_idx, active_frame)) = current_slot_frame(&slots, specs_only, snapshot.switch_time, cycle_started_at) { @@ -1547,13 +1611,18 @@ fn rotation_sleep(rotation_active: bool, slots: &[RotationSlot], cycle_started_a for slot in slots { let slot_ms = slot.total_duration.as_millis() as u64; if elapsed_ms < cursor + slot_ms { + if slot.frames.len() <= 1 { + return Duration::from_millis(750); + } let local_ms = elapsed_ms - cursor; + let frame_elapsed_ms = local_ms % frame_cycle_ms(slot); let mut frame_cursor = 0u64; for (_, delay) in &slot.frames { let delay_ms = delay.as_millis() as u64; - if local_ms < frame_cursor + delay_ms { - let remaining = frame_cursor + delay_ms - local_ms; - return Duration::from_millis(remaining.clamp(100, 750)); + if frame_elapsed_ms < frame_cursor + delay_ms { + let frame_remaining = frame_cursor + delay_ms - frame_elapsed_ms; + let slot_remaining = slot_ms.saturating_sub(local_ms); + return Duration::from_millis(frame_remaining.min(slot_remaining).clamp(100, 750)); } frame_cursor += delay_ms; } @@ -1564,6 +1633,119 @@ fn rotation_sleep(rotation_active: bool, slots: &[RotationSlot], cycle_started_a Duration::from_millis(250) } +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_frame(key: &str) -> ActiveFrame { + ActiveFrame { + key: key.into(), + image_name: format!("{key}.jpg"), + display_name: "gif".into(), + } + } + + #[test] + fn slot_frame_at_loops_animated_frames_for_full_slot() { + let slot = RotationSlot { + total_duration: Duration::from_secs(10), + frames: vec![ + (test_frame("gif#0"), Duration::from_millis(100)), + (test_frame("gif#1"), Duration::from_millis(100)), + (test_frame("gif#2"), Duration::from_millis(100)), + ], + }; + + assert_eq!(slot_frame_at(&slot, 0, 50).unwrap().1.key, "gif#0"); + assert_eq!(slot_frame_at(&slot, 0, 150).unwrap().1.key, "gif#1"); + assert_eq!(slot_frame_at(&slot, 0, 250).unwrap().1.key, "gif#2"); + assert_eq!(slot_frame_at(&slot, 0, 350).unwrap().1.key, "gif#0"); + assert_eq!(slot_frame_at(&slot, 0, 9_950).unwrap().1.key, "gif#0"); + } + + #[test] + fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-test-{unique}")); + fs::create_dir_all(&image_dir).unwrap(); + let manifest_name = "panel-test.gifset.json"; + fs::write( + image_dir.join(manifest_name), + r#"{ + "kind": "gif_set", + "version": 1, + "label": "panel-test.gif", + "preview": ".gifframe-test-000.jpg", + "frames": [ + { "name": ".gifframe-test-000.jpg", "delay_ms": 100 }, + { "name": ".gifframe-test-001.jpg", "delay_ms": 100 } + ] + }"#, + ) + .unwrap(); + + let snapshot = RotationSnapshot { + custom_panel: true, + switch_time: 7, + specs_enabled: false, + memes_enabled: false, + gifs_enabled: true, + active_images: vec![manifest_name.into()], + }; + + let slots = build_rotation_slots(&snapshot, &image_dir); + fs::remove_dir_all(&image_dir).unwrap(); + + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].total_duration, Duration::from_secs(7)); + assert_eq!(slots[0].frames.len(), 2); + } + + #[test] + fn build_rotation_slots_skips_gif_when_gifs_are_disabled() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-disabled-test-{unique}")); + fs::create_dir_all(&image_dir).unwrap(); + let manifest_name = "panel-test.gifset.json"; + fs::write( + image_dir.join(manifest_name), + r#"{ + "kind": "gif_set", + "version": 1, + "label": "panel-test.gif", + "preview": ".gifframe-test-000.jpg", + "frames": [ + { "name": ".gifframe-test-000.jpg", "delay_ms": 100 }, + { "name": ".gifframe-test-001.jpg", "delay_ms": 100 } + ] + }"#, + ) + .unwrap(); + + let snapshot = RotationSnapshot { + custom_panel: true, + switch_time: 7, + specs_enabled: false, + memes_enabled: false, + gifs_enabled: false, + active_images: vec![manifest_name.into()], + }; + + let slots = build_rotation_slots(&snapshot, &image_dir); + fs::remove_dir_all(&image_dir).unwrap(); + + assert!(slots.is_empty()); + } +} + fn read_display_status(status: &Arc>) -> DisplayStatus { status .read() @@ -1763,6 +1945,7 @@ const INDEX_HTML: &str = r##"
+
@@ -1803,7 +1986,7 @@ const INDEX_HTML: &str = r##" const state = { images: [], activeImages: [], - setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false }, + setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false, gifs_enabled: true }, display: {}, system: {}, dirty: false, @@ -1814,6 +1997,7 @@ const INDEX_HTML: &str = r##" switchTime: document.getElementById("switchTime"), specsEnabled: document.getElementById("specsEnabled"), memesEnabled: document.getElementById("memesEnabled"), + gifsEnabled: document.getElementById("gifsEnabled"), customPanelValue: document.getElementById("customPanelValue"), imageCountValue: document.getElementById("imageCountValue"), displayStatusValue: document.getElementById("displayStatusValue"), @@ -1913,6 +2097,7 @@ const INDEX_HTML: &str = r##" switch_time: el.switchTime.value || data.setup.switch_time || "10", specs_enabled: el.specsEnabled.checked, memes_enabled: el.memesEnabled.checked, + gifs_enabled: el.gifsEnabled.checked, } : data.setup; state.display = data.display || {}; @@ -1921,10 +2106,12 @@ const INDEX_HTML: &str = r##" el.switchTime.value = data.setup.switch_time || "10"; el.specsEnabled.checked = !!data.setup.specs_enabled; el.memesEnabled.checked = !!data.setup.memes_enabled; + el.gifsEnabled.checked = data.setup.gifs_enabled !== false; } const enabledModes = [ state.setup.specs_enabled ? "Specs" : "", state.setup.memes_enabled ? "Memes" : "", + state.setup.gifs_enabled ? "GIFs" : "", ].filter(Boolean); el.customPanelValue.textContent = state.setup.custom_panel ? (enabledModes.join(" + ") || "Active") @@ -1972,6 +2159,7 @@ const INDEX_HTML: &str = r##" switch_time: Number.parseInt(el.switchTime.value || "10", 10), specs_enabled: el.specsEnabled.checked, memes_enabled: el.memesEnabled.checked, + gifs_enabled: el.gifsEnabled.checked, }), }); clearDirty(); @@ -2091,6 +2279,7 @@ const INDEX_HTML: &str = r##" el.switchTime.addEventListener("change", markDirty); el.specsEnabled.addEventListener("change", markDirty); el.memesEnabled.addEventListener("change", markDirty); + el.gifsEnabled.addEventListener("change", markDirty); load({ force: true }).catch((err) => toast(err.message)); setInterval(() => load().catch(() => {}), 5000);