From f88b23493fe5527f38ff948a3339e89b33ccdcc7 Mon Sep 17 00:00:00 2001 From: magges Date: Tue, 30 Jun 2026 23:13:29 +0200 Subject: [PATCH] fix: play gif frames sequentially --- crates/aster-webui/src/display.rs | 307 +++++++++++++++++++----------- 1 file changed, 193 insertions(+), 114 deletions(-) diff --git a/crates/aster-webui/src/display.rs b/crates/aster-webui/src/display.rs index 1e7013a..88c05a2 100644 --- a/crates/aster-webui/src/display.rs +++ b/crates/aster-webui/src/display.rs @@ -19,6 +19,14 @@ use crate::{ const SYSTEM_FRAME_NAME: &str = "System Specs"; const IDLE_SLEEP_MS: u64 = 750; +#[derive(Clone, Debug)] +struct PlaybackCursor { + slot_idx: usize, + frame_idx: usize, + slot_started_at: Instant, + frame_started_at: Instant, +} + pub(crate) fn initial_display_status(config: &DisplayConfig) -> DisplayStatus { let mut status = DisplayStatus { native_enabled: config.native_enabled, @@ -115,9 +123,9 @@ fn open_screen(config: &DisplayConfig) -> Result { fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { let mut snapshot = RotationSnapshot::default(); - let mut cycle_started_at = Instant::now(); let mut current_frame_key: Option = None; let mut slots: Vec = Vec::new(); + let mut playback: Option = None; loop { let monitor = match load_monitor_json_sync(&state.monitor_path) { @@ -136,9 +144,9 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { let new_snapshot = rotation_snapshot(&monitor); if new_snapshot != snapshot { snapshot = new_snapshot; - cycle_started_at = Instant::now(); current_frame_key = None; slots = build_rotation_slots(&snapshot, &state.image_dir); + playback = new_playback_cursor(&slots, Instant::now()); } update_display_status(&state.display_status, |status| { @@ -162,8 +170,8 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { } 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) + if let Some(active_frame) = + current_slot_frame(&slots, specs_only, &mut playback, Instant::now()) { let send_due = current_frame_key.as_deref() != Some(active_frame.key.as_str()); if send_due { @@ -220,7 +228,8 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { thread::sleep(rotation_sleep( snapshot.rotation_active(), &slots, - cycle_started_at, + specs_only, + &playback, )); } } @@ -296,83 +305,77 @@ fn build_rotation_slots( fn current_slot_frame( slots: &[RotationSlot], specs_only: bool, - switch_time: u32, - cycle_started_at: Instant, -) -> Option<(usize, ActiveFrame)> { + playback: &mut Option, + now: Instant, +) -> Option { if specs_only { - return Some(( - 0, - ActiveFrame { - key: SYSTEM_FRAME_NAME.into(), - image_name: SYSTEM_FRAME_NAME.into(), - display_name: SYSTEM_FRAME_NAME.into(), - animated: false, - }, - )); + return Some(ActiveFrame { + key: SYSTEM_FRAME_NAME.into(), + image_name: SYSTEM_FRAME_NAME.into(), + display_name: SYSTEM_FRAME_NAME.into(), + animated: false, + }); } if slots.is_empty() { return None; } - let cycle_ms: u64 = slots - .iter() - .map(|slot| slot.total_duration.as_millis() as u64) - .sum::() - .max((switch_time as u64).max(1) * 1000); - let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; - let mut cursor = 0u64; - - for (slot_idx, slot) in slots.iter().enumerate() { - let slot_ms = slot.total_duration.as_millis() as u64; - if elapsed_ms < cursor + slot_ms { - let local_ms = elapsed_ms - cursor; - return slot_frame_at(slot, slot_idx, local_ms); - } - cursor += slot_ms; + if playback.is_none() { + *playback = new_playback_cursor(slots, now); } + let cursor = playback.as_mut()?; + advance_playback_cursor(cursor, slots, now); slots - .last() - .and_then(|slot| slot.frames.last().map(|(frame, _)| frame.clone())) - .map(|frame| (slots.len() - 1, frame)) + .get(cursor.slot_idx) + .and_then(|slot| slot.frames.get(cursor.frame_idx)) + .map(|(frame, _)| frame.clone()) } -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 = frame_cycle_ms(slot); - let frame_elapsed_ms = if frame_cycle == 0 { - 0 +fn new_playback_cursor(slots: &[RotationSlot], now: Instant) -> Option { + if slots.is_empty() { + None } else { - local_ms % frame_cycle - }; - 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; + Some(PlaybackCursor { + slot_idx: 0, + frame_idx: 0, + slot_started_at: now, + frame_started_at: now, + }) } - - 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(1) +fn advance_playback_cursor(cursor: &mut PlaybackCursor, slots: &[RotationSlot], now: Instant) { + if slots.is_empty() { + return; + } + + let mut guard = 0usize; + while guard < slots.len().saturating_mul(2).max(1) { + guard += 1; + let slot = &slots[cursor.slot_idx]; + + if now.duration_since(cursor.slot_started_at) >= slot.total_duration { + cursor.slot_idx = (cursor.slot_idx + 1) % slots.len(); + cursor.frame_idx = 0; + cursor.slot_started_at = now; + cursor.frame_started_at = now; + continue; + } + + if slot.frames.len() <= 1 { + return; + } + + let current_delay = slot.frames[cursor.frame_idx].1; + if now.duration_since(cursor.frame_started_at) >= current_delay { + cursor.frame_idx = (cursor.frame_idx + 1) % slot.frames.len(); + cursor.frame_started_at = now; + continue; + } + + return; + } } fn prepare_screen_for_frame(screen: &mut AooScreen, frame: &ActiveFrame) { @@ -385,51 +388,37 @@ fn prepare_screen_for_frame(screen: &mut AooScreen, frame: &ActiveFrame) { fn rotation_sleep( rotation_active: bool, slots: &[RotationSlot], - cycle_started_at: Instant, + specs_only: bool, + playback: &Option, ) -> Duration { - if !rotation_active || slots.is_empty() { - return Duration::from_millis(IDLE_SLEEP_MS); - } - let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum(); - if total_frames <= 1 { + if !rotation_active || specs_only || slots.is_empty() { return Duration::from_millis(IDLE_SLEEP_MS); } - let cycle_ms: u64 = slots - .iter() - .map(|slot| slot.total_duration.as_millis() as u64) - .sum::() - .max(1); - let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; - let mut cursor = 0u64; + let Some(cursor) = playback.as_ref() else { + return Duration::from_millis(IDLE_SLEEP_MS); + }; - 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(IDLE_SLEEP_MS); - } - 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 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(MIN_GIF_FRAME_DELAY_MS as u64, IDLE_SLEEP_MS), - ); - } - frame_cursor += delay_ms; - } - } - cursor += slot_ms; + let slot = &slots[cursor.slot_idx]; + if slot.frames.len() <= 1 { + let remaining = slot + .total_duration + .saturating_sub(cursor.slot_started_at.elapsed()); + return remaining.clamp( + Duration::from_millis(MIN_GIF_FRAME_DELAY_MS as u64), + Duration::from_millis(IDLE_SLEEP_MS), + ); } - Duration::from_millis(250) + let frame_delay = slot.frames[cursor.frame_idx].1; + let frame_remaining = frame_delay.saturating_sub(cursor.frame_started_at.elapsed()); + let slot_remaining = slot + .total_duration + .saturating_sub(cursor.slot_started_at.elapsed()); + frame_remaining.min(slot_remaining).clamp( + Duration::from_millis(MIN_GIF_FRAME_DELAY_MS as u64), + Duration::from_millis(IDLE_SLEEP_MS), + ) } pub(crate) fn read_display_status(status: &Arc>) -> DisplayStatus { @@ -521,21 +510,110 @@ mod tests { } #[test] - fn slot_frame_at_loops_animated_frames_for_full_slot() { - let slot = RotationSlot { + fn playback_cursor_advances_frames_sequentially() { + let slots = vec![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)), ], - }; + }]; + let start = Instant::now(); + let mut cursor = new_playback_cursor(&slots, start); - 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"); + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(50) + ) + .unwrap() + .key, + "gif#0" + ); + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(150) + ) + .unwrap() + .key, + "gif#1" + ); + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(250) + ) + .unwrap() + .key, + "gif#2" + ); + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(350) + ) + .unwrap() + .key, + "gif#0" + ); + } + + #[test] + fn playback_cursor_does_not_skip_overdue_frames() { + let slots = vec![RotationSlot { + total_duration: Duration::from_secs(10), + frames: vec![ + (test_frame("gif#0"), Duration::from_millis(50)), + (test_frame("gif#1"), Duration::from_millis(50)), + (test_frame("gif#2"), Duration::from_millis(50)), + ], + }]; + let start = Instant::now(); + let mut cursor = new_playback_cursor(&slots, start); + + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(220) + ) + .unwrap() + .key, + "gif#1" + ); + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(221) + ) + .unwrap() + .key, + "gif#1" + ); + assert_eq!( + current_slot_frame( + &slots, + false, + &mut cursor, + start + Duration::from_millis(271) + ) + .unwrap() + .key, + "gif#2" + ); } #[test] @@ -547,8 +625,9 @@ mod tests { (test_frame("gif#1"), Duration::from_millis(50)), ], }]; + let playback = new_playback_cursor(&slots, Instant::now()); - let sleep = rotation_sleep(true, &slots, Instant::now()); + let sleep = rotation_sleep(true, &slots, false, &playback); assert!(sleep <= Duration::from_millis(50)); assert!(sleep >= Duration::from_millis(MIN_GIF_FRAME_DELAY_MS as u64)); }