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>
This commit is contained in:
William Casarin
2025-08-04 13:38:27 -07:00
parent 54b86ee5a6
commit b94e715539
8 changed files with 206 additions and 73 deletions

View File

@@ -1,5 +1,6 @@
use crate::media::gif::ensure_latest_texture_from_cache; use crate::media::gif::ensure_latest_texture_from_cache;
use crate::media::images::ImageType; use crate::media::images::ImageType;
use crate::media::AnimationMode;
use crate::urls::{UrlCache, UrlMimes}; use crate::urls::{UrlCache, UrlMimes};
use crate::ImageMetadata; use crate::ImageMetadata;
use crate::ObfuscationType; use crate::ObfuscationType;
@@ -464,6 +465,7 @@ impl Images {
ui: &mut egui::Ui, ui: &mut egui::Ui,
url: &str, url: &str,
img_type: ImageType, img_type: ImageType,
animation_mode: AnimationMode,
) -> Option<TextureHandle> { ) -> Option<TextureHandle> {
let cache_type = crate::urls::supported_mime_hosted_at_url(&mut self.urls, url)?; 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, 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 { pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache {

View File

@@ -3,14 +3,18 @@ use std::{
time::{Instant, SystemTime}, time::{Instant, SystemTime},
}; };
use crate::media::AnimationMode;
use crate::Animation;
use crate::{GifState, GifStateMap, TextureState, TexturedImage, TexturesCache}; use crate::{GifState, GifStateMap, TextureState, TexturedImage, TexturesCache};
use egui::TextureHandle; use egui::TextureHandle;
use std::time::Duration;
pub fn ensure_latest_texture_from_cache( pub fn ensure_latest_texture_from_cache(
ui: &egui::Ui, ui: &egui::Ui,
url: &str, url: &str,
gifs: &mut GifStateMap, gifs: &mut GifStateMap,
textures: &mut TexturesCache, textures: &mut TexturesCache,
animation_mode: AnimationMode,
) -> Option<TextureHandle> { ) -> Option<TextureHandle> {
let tstate = textures.cache.get_mut(url)?; let tstate = textures.cache.get_mut(url)?;
@@ -18,7 +22,102 @@ pub fn ensure_latest_texture_from_cache(
return None; 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( pub fn ensure_latest_texture(
@@ -26,6 +125,7 @@ pub fn ensure_latest_texture(
url: &str, url: &str,
gifs: &mut GifStateMap, gifs: &mut GifStateMap,
img: &mut TexturedImage, img: &mut TexturedImage,
animation_mode: AnimationMode,
) -> TextureHandle { ) -> TextureHandle {
match img { match img {
TexturedImage::Static(handle) => handle.clone(), TexturedImage::Static(handle) => handle.clone(),
@@ -45,80 +145,21 @@ pub fn ensure_latest_texture(
} }
} }
let now = Instant::now(); let next_state = process_gif_frame(animation, gifs.get(url), animation_mode);
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 { if let Some(new_state) = next_state.maybe_new_state {
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 = 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 {
gifs.insert(url.to_owned(), 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 // TODO(jb55): make a continuous gif rendering setting
// 24fps for gif is fine // 24fps for gif is fine
/*
tracing::trace!("requesting repaint for {url} after {req:?}"); tracing::trace!("requesting repaint for {url} after {req:?}");
ui.ctx() ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(41)); .request_repaint_after(std::time::Duration::from_millis(41));
*/
} }
texture.clone() next_state.texture
} }
} }
} }

View File

@@ -12,3 +12,21 @@ pub use blur::{
}; };
pub use images::ImageType; pub use images::ImageType;
pub use renderable::RenderableMedia; 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)
}
}

View File

@@ -15,6 +15,7 @@ use egui::{
use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool};
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use notedeck::media::gif::ensure_latest_texture; use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::AnimationMode;
use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState}; use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState};
use notedeck_ui::{ use notedeck_ui::{
@@ -37,6 +38,7 @@ pub struct PostView<'a, 'd> {
inner_rect: egui::Rect, inner_rect: egui::Rect,
note_options: NoteOptions, note_options: NoteOptions,
jobs: &'a mut JobsCache, jobs: &'a mut JobsCache,
animation_mode: AnimationMode,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -110,6 +112,11 @@ impl<'a, 'd> PostView<'a, 'd> {
note_options: NoteOptions, note_options: NoteOptions,
jobs: &'a mut JobsCache, jobs: &'a mut JobsCache,
) -> Self { ) -> Self {
let animation_mode = if note_options.contains(NoteOptions::NoAnimations) {
AnimationMode::NoAnimation
} else {
AnimationMode::Continuous { fps: None }
};
PostView { PostView {
note_context, note_context,
draft, draft,
@@ -117,6 +124,7 @@ impl<'a, 'd> PostView<'a, 'd> {
post_type, post_type,
inner_rect, inner_rect,
note_options, note_options,
animation_mode,
jobs, jobs,
} }
} }
@@ -129,6 +137,11 @@ impl<'a, 'd> PostView<'a, 'd> {
PostView::id().with("scroll") 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 { fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response {
ui.spacing_mut().item_spacing.x = 12.0; ui.spacing_mut().item_spacing.x = 12.0;
@@ -492,6 +505,7 @@ impl<'a, 'd> PostView<'a, 'd> {
height, height,
cur_state, cur_state,
url, url,
self.animation_mode,
) )
} }
to_remove.reverse(); to_remove.reverse();
@@ -582,6 +596,7 @@ fn render_post_view_media(
height: u32, height: u32,
render_state: RenderState, render_state: RenderState,
url: &str, url: &str,
animation_mode: AnimationMode,
) { ) {
match render_state.texture_state { match render_state.texture_state {
notedeck::TextureState::Pending => { notedeck::TextureState::Pending => {
@@ -605,7 +620,7 @@ fn render_post_view_media(
.to_vec(); .to_vec();
let texture_handle = 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( let img_resp = ui.add(
egui::Image::new(&texture_handle) egui::Image::new(&texture_handle)
.max_size(size) .max_size(size)

View File

@@ -1,6 +1,6 @@
use bitflags::bitflags; use bitflags::bitflags;
use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect}; use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect};
use notedeck::media::{MediaInfo, ViewMediaInfo}; use notedeck::media::{AnimationMode, MediaInfo, ViewMediaInfo};
use notedeck::{ImageType, Images}; use notedeck::{ImageType, Images};
bitflags! { bitflags! {
@@ -176,7 +176,12 @@ impl<'a> MediaViewer<'a> {
/// we have image layouts /// we have image layouts
fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect { fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect {
// fetch image texture // 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"); tracing::error!("could not get latest texture in first_image_rect");
return Rect::ZERO; return Rect::ZERO;
}; };
@@ -206,7 +211,14 @@ impl<'a> MediaViewer<'a> {
let url = &info.url; let url = &info.url;
// fetch image texture // 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; continue;
}; };

View File

@@ -13,6 +13,7 @@ use notedeck::{
use crate::NoteOptions; use crate::NoteOptions;
use notedeck::media::gif::ensure_latest_texture; use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; use notedeck::media::images::{fetch_no_pfp_promise, ImageType};
use notedeck::media::AnimationMode;
use notedeck::media::{MediaInfo, ViewMediaInfo}; use notedeck::media::{MediaInfo, ViewMediaInfo};
use crate::{app_images, AnimationHelper, PulseAlpha}; use crate::{app_images, AnimationHelper, PulseAlpha};
@@ -82,6 +83,18 @@ pub fn image_carousel(
blur_type, 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( let media_response = render_media(
ui, ui,
&mut img_cache.gif_states, &mut img_cache.gif_states,
@@ -90,6 +103,7 @@ pub fn image_carousel(
size, size,
i18n, i18n,
note_options.contains(NoteOptions::Wide), note_options.contains(NoteOptions::Wide),
animation_mode,
); );
if let Some(action) = media_response.inner { if let Some(action) = media_response.inner {
@@ -324,10 +338,12 @@ fn render_media(
size: egui::Vec2, size: egui::Vec2,
i18n: &mut Localization, i18n: &mut Localization,
is_scaled: bool, is_scaled: bool,
animation_mode: AnimationMode,
) -> egui::InnerResponse<Option<MediaUIAction>> { ) -> egui::InnerResponse<Option<MediaUIAction>> {
match render_state { match render_state {
MediaRenderState::ActualImage(image) => { 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() { if resp.clicked() {
egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp) egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp)
} else { } else {
@@ -559,6 +575,7 @@ pub(crate) fn find_renderable_media<'a>(
} }
*/ */
#[allow(clippy::too_many_arguments)]
fn render_success_media( fn render_success_media(
ui: &mut egui::Ui, ui: &mut egui::Ui,
url: &str, url: &str,
@@ -567,8 +584,9 @@ fn render_success_media(
size: Vec2, size: Vec2,
i18n: &mut Localization, i18n: &mut Localization,
is_scaled: bool, is_scaled: bool,
animation_mode: AnimationMode,
) -> Response { ) -> 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); let scaled = ScaledTexture::new(&texture, size, is_scaled);

View File

@@ -35,6 +35,9 @@ bitflags! {
/// Note has an unread reply indicator /// Note has an unread reply indicator
const UnreadIndicator = 1 << 16; const UnreadIndicator = 1 << 16;
/// no animation override (accessibility)
const NoAnimations = 1 << 17;
} }
} }

View File

@@ -3,6 +3,7 @@ use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle};
use notedeck::get_render_state; use notedeck::get_render_state;
use notedeck::media::gif::ensure_latest_texture; use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; use notedeck::media::images::{fetch_no_pfp_promise, ImageType};
use notedeck::media::AnimationMode;
use notedeck::MediaAction; use notedeck::MediaAction;
use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images}; use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images};
@@ -12,12 +13,21 @@ pub struct ProfilePic<'cache, 'url> {
size: f32, size: f32,
sense: Sense, sense: Sense,
border: Option<Stroke>, border: Option<Stroke>,
animation_mode: AnimationMode,
pub action: Option<MediaAction>, pub action: Option<MediaAction>,
} }
impl egui::Widget for &mut ProfilePic<'_, '_> { impl egui::Widget for &mut ProfilePic<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response { 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; self.action = inner.inner;
@@ -35,6 +45,7 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> {
sense, sense,
url, url,
size, size,
animation_mode: AnimationMode::Reactive,
border: None, border: None,
action: None, action: None,
} }
@@ -45,6 +56,11 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> {
self self
} }
pub fn animation_mode(mut self, mode: AnimationMode) -> Self {
self.animation_mode = mode;
self
}
pub fn border_stroke(ui: &egui::Ui) -> Stroke { pub fn border_stroke(ui: &egui::Ui) -> Stroke {
Stroke::new(4.0, ui.visuals().panel_fill) Stroke::new(4.0, ui.visuals().panel_fill)
} }
@@ -109,6 +125,7 @@ fn render_pfp(
ui_size: f32, ui_size: f32,
border: Option<Stroke>, border: Option<Stroke>,
sense: Sense, sense: Sense,
animation_mode: AnimationMode,
) -> InnerResponse<Option<MediaAction>> { ) -> InnerResponse<Option<MediaAction>> {
// We will want to downsample these so it's not blurry on hi res displays // We will want to downsample these so it's not blurry on hi res displays
let img_size = 128u32; let img_size = 128u32;
@@ -141,7 +158,8 @@ fn render_pfp(
) )
} }
notedeck::TextureState::Loaded(textured_image) => { 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)) egui::InnerResponse::new(None, pfp_image(ui, &texture_handle, ui_size, border, sense))
} }