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>
319 lines
10 KiB
Rust
319 lines
10 KiB
Rust
use bitflags::bitflags;
|
|
use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect};
|
|
use notedeck::media::{AnimationMode, MediaInfo, ViewMediaInfo};
|
|
use notedeck::{ImageType, Images};
|
|
|
|
bitflags! {
|
|
#[repr(transparent)]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
|
pub struct MediaViewerFlags: u64 {
|
|
/// Open the media viewer fullscreen
|
|
const Fullscreen = 1 << 0;
|
|
|
|
/// Enable a transition animation
|
|
const Transition = 1 << 1;
|
|
|
|
/// Are we open or closed?
|
|
const Open = 1 << 2;
|
|
}
|
|
}
|
|
|
|
/// State used in the MediaViewer ui widget.
|
|
pub struct MediaViewerState {
|
|
/// When
|
|
pub media_info: ViewMediaInfo,
|
|
pub scene_rect: Option<Rect>,
|
|
pub flags: MediaViewerFlags,
|
|
pub anim_id: egui::Id,
|
|
}
|
|
|
|
impl Default for MediaViewerState {
|
|
fn default() -> Self {
|
|
Self {
|
|
anim_id: egui::Id::new("notedeck-fullscreen-media-viewer"),
|
|
media_info: Default::default(),
|
|
scene_rect: None,
|
|
flags: MediaViewerFlags::Transition | MediaViewerFlags::Fullscreen,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MediaViewerState {
|
|
pub fn new(anim_id: egui::Id) -> Self {
|
|
Self {
|
|
anim_id,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// How much is our media viewer open
|
|
pub fn open_amount(&self, ui: &mut egui::Ui) -> f32 {
|
|
ui.ctx().animate_bool_with_time_and_easing(
|
|
self.anim_id,
|
|
self.flags.contains(MediaViewerFlags::Open),
|
|
0.3,
|
|
egui::emath::easing::cubic_out,
|
|
)
|
|
}
|
|
|
|
/// Should we show the control even if we're closed?
|
|
/// Needed for transition animation
|
|
pub fn should_show(&self, ui: &mut egui::Ui) -> bool {
|
|
if self.flags.contains(MediaViewerFlags::Open) {
|
|
return true;
|
|
}
|
|
|
|
// we are closing
|
|
self.open_amount(ui) > 0.0
|
|
}
|
|
}
|
|
|
|
/// A panning, scrolling, optionally fullscreen, and tiling media viewer
|
|
pub struct MediaViewer<'a> {
|
|
state: &'a mut MediaViewerState,
|
|
}
|
|
|
|
impl<'a> MediaViewer<'a> {
|
|
pub fn new(state: &'a mut MediaViewerState) -> Self {
|
|
Self { state }
|
|
}
|
|
|
|
/// Is this
|
|
pub fn fullscreen(self, enable: bool) -> Self {
|
|
self.state.flags.set(MediaViewerFlags::Fullscreen, enable);
|
|
self
|
|
}
|
|
|
|
/// Enable open transition animation
|
|
pub fn transition(self, enable: bool) -> Self {
|
|
self.state.flags.set(MediaViewerFlags::Transition, enable);
|
|
self
|
|
}
|
|
|
|
pub fn ui(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response {
|
|
if self.state.flags.contains(MediaViewerFlags::Fullscreen) {
|
|
egui::Window::new("Media Viewer")
|
|
.title_bar(false)
|
|
.fixed_size(ui.ctx().screen_rect().size())
|
|
.fixed_pos(ui.ctx().screen_rect().min)
|
|
.frame(egui::Frame::NONE)
|
|
.show(ui.ctx(), |ui| self.ui_content(images, ui))
|
|
.unwrap() // SAFETY: we are always open
|
|
.inner
|
|
.unwrap()
|
|
} else {
|
|
self.ui_content(images, ui)
|
|
}
|
|
}
|
|
|
|
fn ui_content(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response {
|
|
let avail_rect = ui.available_rect_before_wrap();
|
|
|
|
let scene_rect = if let Some(scene_rect) = self.state.scene_rect {
|
|
scene_rect
|
|
} else {
|
|
self.state.scene_rect = Some(avail_rect);
|
|
avail_rect
|
|
};
|
|
|
|
let zoom_range: egui::Rangef = (0.0..=10.0).into();
|
|
|
|
let is_open = self.state.flags.contains(MediaViewerFlags::Open);
|
|
let can_transition = self.state.flags.contains(MediaViewerFlags::Transition);
|
|
let open_amount = self.state.open_amount(ui);
|
|
let transitioning = if !can_transition {
|
|
false
|
|
} else if is_open {
|
|
open_amount < 1.0
|
|
} else {
|
|
open_amount > 0.0
|
|
};
|
|
|
|
let mut trans_rect = if transitioning {
|
|
let clicked_img = &self.state.media_info.clicked_media();
|
|
let src_pos = &clicked_img.original_position;
|
|
let in_scene_pos = Self::first_image_rect(ui, clicked_img, images);
|
|
transition_scene_rect(
|
|
&avail_rect,
|
|
&zoom_range,
|
|
&in_scene_pos,
|
|
src_pos,
|
|
open_amount,
|
|
)
|
|
} else {
|
|
scene_rect
|
|
};
|
|
|
|
// Draw background
|
|
ui.painter().rect_filled(
|
|
avail_rect,
|
|
0.0,
|
|
egui::Color32::from_black_alpha((200.0 * open_amount) as u8),
|
|
);
|
|
|
|
let scene = egui::Scene::new().zoom_range(zoom_range);
|
|
|
|
// We are opening, so lock controls
|
|
/* TODO(jb55): 0.32
|
|
if transitioning {
|
|
scene = scene.sense(egui::Sense::hover());
|
|
}
|
|
*/
|
|
|
|
let resp = scene.show(ui, &mut trans_rect, |ui| {
|
|
Self::render_image_tiles(&self.state.media_info.medias, images, ui, open_amount);
|
|
});
|
|
|
|
self.state.scene_rect = Some(trans_rect);
|
|
|
|
resp.response
|
|
}
|
|
|
|
/// The rect of the first image to be placed.
|
|
/// This is mainly used for the transition animation
|
|
///
|
|
/// TODO(jb55): replace this with a "placed" variant once
|
|
/// 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),
|
|
AnimationMode::NoAnimation,
|
|
) else {
|
|
tracing::error!("could not get latest texture in first_image_rect");
|
|
return Rect::ZERO;
|
|
};
|
|
|
|
// the area the next image will be put in.
|
|
let mut img_rect = ui.available_rect_before_wrap();
|
|
|
|
let size = texture.size_vec2();
|
|
img_rect.set_height(size.y);
|
|
img_rect.set_width(size.x);
|
|
img_rect
|
|
}
|
|
|
|
///
|
|
/// Tile a scene with images.
|
|
///
|
|
/// TODO(jb55): Let's improve image tiling over time, spiraling outward. We
|
|
/// should have a way to click "next" and have the scene smoothly transition and
|
|
/// focus on the next image
|
|
fn render_image_tiles(
|
|
infos: &[MediaInfo],
|
|
images: &mut Images,
|
|
ui: &mut egui::Ui,
|
|
open_amount: f32,
|
|
) {
|
|
for info in infos {
|
|
let url = &info.url;
|
|
|
|
// fetch image texture
|
|
|
|
// 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;
|
|
};
|
|
|
|
// the area the next image will be put in.
|
|
let mut img_rect = ui.available_rect_before_wrap();
|
|
/*
|
|
if !ui.is_rect_visible(img_rect) {
|
|
// just stop rendering images if we're going out of the scene
|
|
// basic culling when we have lots of images
|
|
break;
|
|
}
|
|
*/
|
|
|
|
{
|
|
let size = texture.size_vec2();
|
|
img_rect.set_height(size.y);
|
|
img_rect.set_width(size.x);
|
|
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
|
|
|
// image actions
|
|
//let response = ui.interact(render_rect, carousel_id.with("img"), Sense::click());
|
|
|
|
/*
|
|
if response.clicked() {
|
|
} else if background_response.clicked() {
|
|
}
|
|
*/
|
|
|
|
// Paint image
|
|
ui.painter().image(
|
|
texture.id(),
|
|
img_rect,
|
|
uv,
|
|
Color32::from_white_alpha((open_amount * 255.0) as u8),
|
|
);
|
|
|
|
ui.advance_cursor_after_rect(img_rect);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper: lerp a TSTransform (uniform scale + translation)
|
|
fn lerp_ts(a: TSTransform, b: TSTransform, t: f32) -> TSTransform {
|
|
let s = egui::lerp(a.scaling..=b.scaling, t);
|
|
let p = a.translation + (b.translation - a.translation) * t;
|
|
TSTransform {
|
|
scaling: s,
|
|
translation: p,
|
|
}
|
|
}
|
|
|
|
/// Calculate the open/close amount and transition rect
|
|
pub fn transition_scene_rect(
|
|
outer_rect: &Rect,
|
|
zoom_range: &Rangef,
|
|
image_rect_in_scene: &Rect, // e.g. Rect::from_min_size(Pos2::ZERO, image_size)
|
|
timeline_global_rect: &Rect, // saved from timeline Response.rect
|
|
open_amt: f32, // stable ID per media item
|
|
) -> Rect {
|
|
// Compute the two endpoints:
|
|
let from = fit_to_rect_in_scene(timeline_global_rect, image_rect_in_scene, zoom_range);
|
|
let to = fit_to_rect_in_scene(outer_rect, image_rect_in_scene, zoom_range);
|
|
|
|
// Interpolate transform and convert to scene_rect expected by Scene::show:
|
|
let lerped = lerp_ts(from, to, open_amt);
|
|
|
|
lerped.inverse() * (*outer_rect)
|
|
}
|
|
|
|
/// Creates a transformation that fits a given scene rectangle into the available screen size.
|
|
///
|
|
/// The resulting visual scene bounds can be larger, due to letterboxing.
|
|
///
|
|
/// Returns the transformation from `scene` to `global` coordinates.
|
|
fn fit_to_rect_in_scene(
|
|
rect_in_global: &Rect,
|
|
rect_in_scene: &Rect,
|
|
zoom_range: &Rangef,
|
|
) -> TSTransform {
|
|
// Compute the scale factor to fit the bounding rectangle into the available screen size:
|
|
let scale = rect_in_global.size() / rect_in_scene.size();
|
|
|
|
// Use the smaller of the two scales to ensure the whole rectangle fits on the screen:
|
|
let scale = scale.min_elem();
|
|
|
|
// Clamp scale to what is allowed
|
|
let scale = zoom_range.clamp(scale);
|
|
|
|
// Compute the translation to center the bounding rect in the screen:
|
|
let center_in_global = rect_in_global.center().to_vec2();
|
|
let center_scene = rect_in_scene.center().to_vec2();
|
|
|
|
// Set the transformation to scale and then translate to center.
|
|
TSTransform::from_translation(center_in_global - scale * center_scene)
|
|
* TSTransform::from_scaling(scale)
|
|
}
|