media/viewer: fullscreen transition animations

Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
William Casarin
2025-07-28 16:12:29 -07:00
parent 6d393c9c37
commit 51f7744149
8 changed files with 230 additions and 51 deletions

View File

@@ -25,6 +25,12 @@ pub struct ViewMediaInfo {
pub medias: Vec<MediaInfo>,
}
impl ViewMediaInfo {
pub fn clicked_media(&self) -> &MediaInfo {
&self.medias[self.clicked_index]
}
}
/// Actions generated by media ui interactions
pub enum MediaAction {
/// An image was clicked on in a carousel, we have

View File

@@ -696,7 +696,6 @@ fn chrome_handle_app_action(
ctx.zaps,
ctx.img_cache,
&mut columns.view_state,
&mut columns.options,
ui,
);
@@ -753,7 +752,6 @@ fn columns_route_to_profile(
ctx.zaps,
ctx.img_cache,
&mut columns.view_state,
&mut columns.options,
ui,
);

View File

@@ -1,7 +1,6 @@
use crate::{
column::Columns,
nav::{RouterAction, RouterType},
options::AppOptions,
route::Route,
timeline::{
thread::{
@@ -18,6 +17,7 @@ use notedeck::{
get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache,
NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps,
};
use notedeck_ui::media::MediaViewerFlags;
use tracing::error;
pub struct NewNotes {
@@ -54,7 +54,6 @@ fn execute_note_action(
zaps: &mut Zaps,
images: &mut Images,
view_state: &mut ViewState,
app_options: &mut AppOptions,
router_type: RouterType,
ui: &mut egui::Ui,
col: usize,
@@ -160,7 +159,10 @@ fn execute_note_action(
media_action.on_view_media(|medias| {
view_state.media_viewer.media_info = medias.clone();
tracing::debug!("on_view_media {:?}", &medias);
app_options.set(AppOptions::FullscreenMedia, true);
view_state
.media_viewer
.flags
.set(MediaViewerFlags::Open, true);
});
media_action.process_default_media_actions(images)
@@ -191,7 +193,6 @@ pub fn execute_and_process_note_action(
zaps: &mut Zaps,
images: &mut Images,
view_state: &mut ViewState,
app_options: &mut AppOptions,
ui: &mut egui::Ui,
) -> Option<RouterAction> {
let router_type = {
@@ -217,7 +218,6 @@ pub fn execute_and_process_note_action(
zaps,
images,
view_state,
app_options,
router_type,
ui,
col,

View File

@@ -23,7 +23,7 @@ use notedeck::{
Images, JobsCache, Localization, UnknownIds,
};
use notedeck_ui::{
media::{MediaViewer, MediaViewerState},
media::{MediaViewer, MediaViewerFlags, MediaViewerState},
NoteOptions,
};
use std::collections::{BTreeSet, HashMap};
@@ -368,12 +368,7 @@ fn render_damus(
render_damus_desktop(damus, app_ctx, ui)
};
fullscreen_media_viewer_ui(
ui,
&mut damus.options,
&mut damus.view_state.media_viewer,
app_ctx.img_cache,
);
fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache);
// We use this for keeping timestamps and things up to date
ui.ctx().request_repaint_after(Duration::from_secs(5));
@@ -386,33 +381,35 @@ fn render_damus(
/// an image is clicked
fn fullscreen_media_viewer_ui(
ui: &mut egui::Ui,
options: &mut AppOptions,
viewer_state: &mut MediaViewerState,
state: &mut MediaViewerState,
img_cache: &mut Images,
) {
if !options.contains(AppOptions::FullscreenMedia) || viewer_state.media_info.medias.is_empty() {
if !state.should_show(ui) {
if state.scene_rect.is_some() {
// if we shouldn't show yet we will have a scene
// rect, then we should clear it for next time
tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect");
state.scene_rect = None;
}
return;
}
// Close it?
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
fullscreen_media_close(options, viewer_state);
fullscreen_media_close(state);
return;
}
let resp = MediaViewer::new(viewer_state)
.fullscreen(true)
.ui(img_cache, ui);
let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui);
if resp.clicked() {
fullscreen_media_close(options, viewer_state);
fullscreen_media_close(state);
}
}
/// Close the fullscreen media player. This also resets the scene_rect state
fn fullscreen_media_close(options: &mut AppOptions, state: &mut MediaViewerState) {
options.set(AppOptions::FullscreenMedia, false);
state.scene_rect = None;
fn fullscreen_media_close(state: &mut MediaViewerState) {
state.flags.set(MediaViewerFlags::Open, false);
}
/*

View File

@@ -460,7 +460,6 @@ fn process_render_nav_action(
ctx.zaps,
ctx.img_cache,
&mut app.view_state,
&mut app.options,
ui,
)
}

View File

@@ -16,9 +16,6 @@ bitflags! {
/// Should we scroll to top on the active column?
const ScrollToTop = 1 << 3;
/// Are we showing fullscreen media?
const FullscreenMedia = 1 << 4;
}
}

View File

@@ -1,3 +1,3 @@
mod viewer;
pub use viewer::{MediaViewer, MediaViewerState};
pub use viewer::{MediaViewer, MediaViewerFlags, MediaViewerState};

View File

@@ -1,34 +1,93 @@
use egui::{pos2, Color32, Rect};
use bitflags::bitflags;
use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect};
use notedeck::media::{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.
#[derive(Default)]
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_responsive(self.anim_id, self.flags.contains(MediaViewerFlags::Open))
}
/// 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,
fullscreen: bool,
}
impl<'a> MediaViewer<'a> {
pub fn new(state: &'a mut MediaViewerState) -> Self {
let fullscreen = false;
Self { state, fullscreen }
Self { state }
}
pub fn fullscreen(mut self, enable: bool) -> Self {
self.fullscreen = enable;
/// 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.fullscreen {
if self.state.flags.contains(MediaViewerFlags::Fullscreen) {
egui::Window::new("Media Viewer")
.title_bar(false)
.fixed_size(ui.ctx().screen_rect().size())
@@ -45,37 +104,100 @@ impl<'a> MediaViewer<'a> {
fn ui_content(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response {
let avail_rect = ui.available_rect_before_wrap();
//let id = ui.id().with("media_viewer");
let mut scene_rect = if let Some(scene_rect) = self.state.scene_rect {
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(128));
ui.painter().rect_filled(
avail_rect,
0.0,
egui::Color32::from_black_alpha((128.0 * open_amount) as u8),
);
let resp = egui::Scene::new()
.zoom_range(0.0..=10.0) // enhance 🔬
.show(ui, &mut scene_rect, |ui| {
Self::render_image_tiles(&self.state.media_info.medias, images, ui);
});
let scene = egui::Scene::new().zoom_range(zoom_range);
self.state.scene_rect = Some(scene_rect);
// 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)) 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) {
fn render_image_tiles(
infos: &[MediaInfo],
images: &mut Images,
ui: &mut egui::Ui,
open_amount: f32,
) {
for info in infos {
let url = &info.url;
@@ -108,11 +230,71 @@ impl<'a> MediaViewer<'a> {
*/
// Paint image
ui.painter()
.image(texture.id(), img_rect, uv, Color32::WHITE);
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)
}