notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit b94e715539b2251626d568f518a62e7ec355d811
parent 54b86ee5a61e027e10c98a79c909f774a281a9bd
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  4 Aug 2025 13:38:27 -0700

ui: add AnimationMode to control GIF rendering behavior

Introduces an `AnimationMode` enum with `Reactive`, `Continuous`, and
`NoAnimation` variants to allow fine-grained control over GIF playback
across the UI. This supports performance optimizations and accessibility
features, such as disabling animations when requested.

- Plumbs AnimationMode through image rendering paths
- Replaces hardcoded gif frame logic with reusable `process_gif_frame`
- Supports customizable FPS in Continuous mode
- Enables global animation opt-out via `NoteOptions::NoAnimations`
- Applies mode-specific logic in profile pictures, posts, media carousels, and viewer

Animation behavior by context
-----------------------------

- Profile pictures: Reactive (render only on interaction/activity)
- PostView: NoAnimation if disabled in NoteOptions, else Continuous (uncapped)
- Media carousels: NoAnimation or Continuous (capped at 24fps)
- Viewer/gallery: Always Continuous (full animation)

In the future, we can customize these by power settings.

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck/src/imgcache.rs | 10+++++++++-
Mcrates/notedeck/src/media/gif.rs | 169+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/notedeck/src/media/mod.rs | 18++++++++++++++++++
Mcrates/notedeck_columns/src/ui/note/post.rs | 17++++++++++++++++-
Mcrates/notedeck_ui/src/media/viewer.rs | 18+++++++++++++++---
Mcrates/notedeck_ui/src/note/media.rs | 22++++++++++++++++++++--
Mcrates/notedeck_ui/src/note/options.rs | 3+++
Mcrates/notedeck_ui/src/profile/picture.rs | 22++++++++++++++++++++--
8 files changed, 206 insertions(+), 73 deletions(-)

diff --git a/crates/notedeck/src/imgcache.rs b/crates/notedeck/src/imgcache.rs @@ -1,5 +1,6 @@ use crate::media::gif::ensure_latest_texture_from_cache; use crate::media::images::ImageType; +use crate::media::AnimationMode; use crate::urls::{UrlCache, UrlMimes}; use crate::ImageMetadata; use crate::ObfuscationType; @@ -464,6 +465,7 @@ impl Images { ui: &mut egui::Ui, url: &str, img_type: ImageType, + animation_mode: AnimationMode, ) -> Option<TextureHandle> { let cache_type = crate::urls::supported_mime_hosted_at_url(&mut self.urls, url)?; @@ -485,7 +487,13 @@ impl Images { MediaCacheType::Gif => &mut self.gifs, }; - ensure_latest_texture_from_cache(ui, url, &mut self.gif_states, &mut cache.textures_cache) + ensure_latest_texture_from_cache( + ui, + url, + &mut self.gif_states, + &mut cache.textures_cache, + animation_mode, + ) } pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache { diff --git a/crates/notedeck/src/media/gif.rs b/crates/notedeck/src/media/gif.rs @@ -3,14 +3,18 @@ use std::{ time::{Instant, SystemTime}, }; +use crate::media::AnimationMode; +use crate::Animation; use crate::{GifState, GifStateMap, TextureState, TexturedImage, TexturesCache}; use egui::TextureHandle; +use std::time::Duration; pub fn ensure_latest_texture_from_cache( ui: &egui::Ui, url: &str, gifs: &mut GifStateMap, textures: &mut TexturesCache, + animation_mode: AnimationMode, ) -> Option<TextureHandle> { let tstate = textures.cache.get_mut(url)?; @@ -18,7 +22,102 @@ pub fn ensure_latest_texture_from_cache( return None; }; - Some(ensure_latest_texture(ui, url, gifs, img)) + Some(ensure_latest_texture(ui, url, gifs, img, animation_mode)) +} + +struct ProcessedGifFrame { + texture: TextureHandle, + maybe_new_state: Option<GifState>, + repaint_at: Option<SystemTime>, +} + +/// Process a gif state frame, and optionally present a new +/// state and when to repaint it +fn process_gif_frame( + animation: &Animation, + frame_state: Option<&GifState>, + animation_mode: AnimationMode, +) -> ProcessedGifFrame { + let now = Instant::now(); + + match frame_state { + Some(prev_state) => { + let should_advance = animation_mode.can_animate() + && (now - prev_state.last_frame_rendered >= prev_state.last_frame_duration); + + if should_advance { + let maybe_new_index = if animation.receiver.is_some() + || prev_state.last_frame_index < animation.num_frames() - 1 + { + prev_state.last_frame_index + 1 + } else { + 0 + }; + + match animation.get_frame(maybe_new_index) { + Some(frame) => { + let next_frame_time = match animation_mode { + AnimationMode::Continuous { fps } => match fps { + Some(fps) => { + let max_delay_ms = Duration::from_millis((1000.0 / fps) as u64); + SystemTime::now().checked_add(frame.delay.max(max_delay_ms)) + } + None => SystemTime::now().checked_add(frame.delay), + }, + + AnimationMode::NoAnimation | AnimationMode::Reactive => None, + }; + + ProcessedGifFrame { + texture: frame.texture.clone(), + maybe_new_state: Some(GifState { + last_frame_rendered: now, + last_frame_duration: frame.delay, + next_frame_time, + last_frame_index: maybe_new_index, + }), + repaint_at: next_frame_time, + } + } + None => { + let (texture, maybe_new_state) = + match animation.get_frame(prev_state.last_frame_index) { + Some(frame) => (frame.texture.clone(), None), + None => (animation.first_frame.texture.clone(), None), + }; + + ProcessedGifFrame { + texture, + maybe_new_state, + repaint_at: prev_state.next_frame_time, + } + } + } + } else { + let (texture, maybe_new_state) = + match animation.get_frame(prev_state.last_frame_index) { + Some(frame) => (frame.texture.clone(), None), + None => (animation.first_frame.texture.clone(), None), + }; + + ProcessedGifFrame { + texture, + maybe_new_state, + repaint_at: prev_state.next_frame_time, + } + } + } + None => ProcessedGifFrame { + texture: animation.first_frame.texture.clone(), + maybe_new_state: Some(GifState { + last_frame_rendered: now, + last_frame_duration: animation.first_frame.delay, + next_frame_time: None, + last_frame_index: 0, + }), + repaint_at: None, + }, + } } pub fn ensure_latest_texture( @@ -26,6 +125,7 @@ pub fn ensure_latest_texture( url: &str, gifs: &mut GifStateMap, img: &mut TexturedImage, + animation_mode: AnimationMode, ) -> TextureHandle { match img { TexturedImage::Static(handle) => handle.clone(), @@ -45,80 +145,21 @@ pub fn ensure_latest_texture( } } - let now = Instant::now(); - let (texture, maybe_new_state, request_next_repaint) = match gifs.get(url) { - Some(prev_state) => { - let should_advance = - now - prev_state.last_frame_rendered >= prev_state.last_frame_duration; - - if should_advance { - let maybe_new_index = if animation.receiver.is_some() - || prev_state.last_frame_index < animation.num_frames() - 1 - { - prev_state.last_frame_index + 1 - } else { - 0 - }; + let next_state = process_gif_frame(animation, gifs.get(url), animation_mode); - match animation.get_frame(maybe_new_index) { - Some(frame) => { - let next_frame_time = SystemTime::now().checked_add(frame.delay); - ( - &frame.texture, - Some(GifState { - last_frame_rendered: now, - last_frame_duration: frame.delay, - next_frame_time, - last_frame_index: maybe_new_index, - }), - next_frame_time, - ) - } - None => { - let (tex, state) = - match animation.get_frame(prev_state.last_frame_index) { - Some(frame) => (&frame.texture, None), - None => (&animation.first_frame.texture, None), - }; - - (tex, state, prev_state.next_frame_time) - } - } - } else { - let (tex, state) = match animation.get_frame(prev_state.last_frame_index) { - Some(frame) => (&frame.texture, None), - None => (&animation.first_frame.texture, None), - }; - (tex, state, prev_state.next_frame_time) - } - } - None => ( - &animation.first_frame.texture, - Some(GifState { - last_frame_rendered: now, - last_frame_duration: animation.first_frame.delay, - next_frame_time: None, - last_frame_index: 0, - }), - None, - ), - }; - - if let Some(new_state) = maybe_new_state { + if let Some(new_state) = next_state.maybe_new_state { gifs.insert(url.to_owned(), new_state); } - if let Some(req) = request_next_repaint { + if let Some(req) = next_state.repaint_at { // TODO(jb55): make a continuous gif rendering setting // 24fps for gif is fine - /* tracing::trace!("requesting repaint for {url} after {req:?}"); ui.ctx() .request_repaint_after(std::time::Duration::from_millis(41)); - */ } - texture.clone() + next_state.texture } } } diff --git a/crates/notedeck/src/media/mod.rs b/crates/notedeck/src/media/mod.rs @@ -12,3 +12,21 @@ pub use blur::{ }; pub use images::ImageType; pub use renderable::RenderableMedia; + +#[derive(Copy, Clone, Debug)] +pub enum AnimationMode { + /// Only render when scrolling, network activity, etc + Reactive, + + /// Continuous with an optional target fps + Continuous { fps: Option<f32> }, + + /// Disable animation + NoAnimation, +} + +impl AnimationMode { + pub fn can_animate(&self) -> bool { + !matches!(self, Self::NoAnimation) + } +} diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -15,6 +15,7 @@ use egui::{ use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck::media::gif::ensure_latest_texture; +use notedeck::media::AnimationMode; use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState}; use notedeck_ui::{ @@ -37,6 +38,7 @@ pub struct PostView<'a, 'd> { inner_rect: egui::Rect, note_options: NoteOptions, jobs: &'a mut JobsCache, + animation_mode: AnimationMode, } #[derive(Clone)] @@ -110,6 +112,11 @@ impl<'a, 'd> PostView<'a, 'd> { note_options: NoteOptions, jobs: &'a mut JobsCache, ) -> Self { + let animation_mode = if note_options.contains(NoteOptions::NoAnimations) { + AnimationMode::NoAnimation + } else { + AnimationMode::Continuous { fps: None } + }; PostView { note_context, draft, @@ -117,6 +124,7 @@ impl<'a, 'd> PostView<'a, 'd> { post_type, inner_rect, note_options, + animation_mode, jobs, } } @@ -129,6 +137,11 @@ impl<'a, 'd> PostView<'a, 'd> { PostView::id().with("scroll") } + pub fn animation_mode(mut self, animation_mode: AnimationMode) -> Self { + self.animation_mode = animation_mode; + self + } + fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response { ui.spacing_mut().item_spacing.x = 12.0; @@ -492,6 +505,7 @@ impl<'a, 'd> PostView<'a, 'd> { height, cur_state, url, + self.animation_mode, ) } to_remove.reverse(); @@ -582,6 +596,7 @@ fn render_post_view_media( height: u32, render_state: RenderState, url: &str, + animation_mode: AnimationMode, ) { match render_state.texture_state { notedeck::TextureState::Pending => { @@ -605,7 +620,7 @@ fn render_post_view_media( .to_vec(); let texture_handle = - ensure_latest_texture(ui, url, render_state.gifs, renderable_media); + ensure_latest_texture(ui, url, render_state.gifs, renderable_media, animation_mode); let img_resp = ui.add( egui::Image::new(&texture_handle) .max_size(size) diff --git a/crates/notedeck_ui/src/media/viewer.rs b/crates/notedeck_ui/src/media/viewer.rs @@ -1,6 +1,6 @@ use bitflags::bitflags; use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect}; -use notedeck::media::{MediaInfo, ViewMediaInfo}; +use notedeck::media::{AnimationMode, MediaInfo, ViewMediaInfo}; use notedeck::{ImageType, Images}; bitflags! { @@ -176,7 +176,12 @@ impl<'a> MediaViewer<'a> { /// we have image layouts fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect { // fetch image texture - let Some(texture) = images.latest_texture(ui, &media.url, ImageType::Content(None)) else { + let Some(texture) = images.latest_texture( + ui, + &media.url, + ImageType::Content(None), + AnimationMode::NoAnimation, + ) else { tracing::error!("could not get latest texture in first_image_rect"); return Rect::ZERO; }; @@ -206,7 +211,14 @@ impl<'a> MediaViewer<'a> { let url = &info.url; // fetch image texture - let Some(texture) = images.latest_texture(ui, url, ImageType::Content(None)) else { + + // we want to continually redraw things in the gallery + let Some(texture) = images.latest_texture( + ui, + url, + ImageType::Content(None), + AnimationMode::Continuous { fps: None }, // media viewer has continuous rendering + ) else { continue; }; diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -13,6 +13,7 @@ use notedeck::{ use crate::NoteOptions; use notedeck::media::gif::ensure_latest_texture; use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; +use notedeck::media::AnimationMode; use notedeck::media::{MediaInfo, ViewMediaInfo}; use crate::{app_images, AnimationHelper, PulseAlpha}; @@ -82,6 +83,18 @@ pub fn image_carousel( blur_type, ); + let animation_mode = if note_options.contains(NoteOptions::NoAnimations) + { + AnimationMode::NoAnimation + } else { + // if animations aren't disabled, we cap it at 24fps for gifs in carousels + let fps = match media_type { + MediaCacheType::Gif => Some(24.0), + MediaCacheType::Image => None, + }; + AnimationMode::Continuous { fps } + }; + let media_response = render_media( ui, &mut img_cache.gif_states, @@ -90,6 +103,7 @@ pub fn image_carousel( size, i18n, note_options.contains(NoteOptions::Wide), + animation_mode, ); if let Some(action) = media_response.inner { @@ -324,10 +338,12 @@ fn render_media( size: egui::Vec2, i18n: &mut Localization, is_scaled: bool, + animation_mode: AnimationMode, ) -> egui::InnerResponse<Option<MediaUIAction>> { match render_state { MediaRenderState::ActualImage(image) => { - let resp = render_success_media(ui, url, image, gifs, size, i18n, is_scaled); + let resp = + render_success_media(ui, url, image, gifs, size, i18n, is_scaled, animation_mode); if resp.clicked() { egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp) } else { @@ -559,6 +575,7 @@ pub(crate) fn find_renderable_media<'a>( } */ +#[allow(clippy::too_many_arguments)] fn render_success_media( ui: &mut egui::Ui, url: &str, @@ -567,8 +584,9 @@ fn render_success_media( size: Vec2, i18n: &mut Localization, is_scaled: bool, + animation_mode: AnimationMode, ) -> Response { - let texture = ensure_latest_texture(ui, url, gifs, tex); + let texture = ensure_latest_texture(ui, url, gifs, tex, animation_mode); let scaled = ScaledTexture::new(&texture, size, is_scaled); diff --git a/crates/notedeck_ui/src/note/options.rs b/crates/notedeck_ui/src/note/options.rs @@ -35,6 +35,9 @@ bitflags! { /// Note has an unread reply indicator const UnreadIndicator = 1 << 16; + + /// no animation override (accessibility) + const NoAnimations = 1 << 17; } } diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs @@ -3,6 +3,7 @@ use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle}; use notedeck::get_render_state; use notedeck::media::gif::ensure_latest_texture; use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; +use notedeck::media::AnimationMode; use notedeck::MediaAction; use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images}; @@ -12,12 +13,21 @@ pub struct ProfilePic<'cache, 'url> { size: f32, sense: Sense, border: Option<Stroke>, + animation_mode: AnimationMode, pub action: Option<MediaAction>, } impl egui::Widget for &mut ProfilePic<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - let inner = render_pfp(ui, self.cache, self.url, self.size, self.border, self.sense); + let inner = render_pfp( + ui, + self.cache, + self.url, + self.size, + self.border, + self.sense, + self.animation_mode, + ); self.action = inner.inner; @@ -35,6 +45,7 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { sense, url, size, + animation_mode: AnimationMode::Reactive, border: None, action: None, } @@ -45,6 +56,11 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { self } + pub fn animation_mode(mut self, mode: AnimationMode) -> Self { + self.animation_mode = mode; + self + } + pub fn border_stroke(ui: &egui::Ui) -> Stroke { Stroke::new(4.0, ui.visuals().panel_fill) } @@ -109,6 +125,7 @@ fn render_pfp( ui_size: f32, border: Option<Stroke>, sense: Sense, + animation_mode: AnimationMode, ) -> InnerResponse<Option<MediaAction>> { // We will want to downsample these so it's not blurry on hi res displays let img_size = 128u32; @@ -141,7 +158,8 @@ fn render_pfp( ) } notedeck::TextureState::Loaded(textured_image) => { - let texture_handle = ensure_latest_texture(ui, url, cur_state.gifs, textured_image); + let texture_handle = + ensure_latest_texture(ui, url, cur_state.gifs, textured_image, animation_mode); egui::InnerResponse::new(None, pfp_image(ui, &texture_handle, ui_size, border, sense)) }