media/viewer: fullscreen transition animations
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -460,7 +460,6 @@ fn process_render_nav_action(
|
||||
ctx.zaps,
|
||||
ctx.img_cache,
|
||||
&mut app.view_state,
|
||||
&mut app.options,
|
||||
ui,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
mod viewer;
|
||||
|
||||
pub use viewer::{MediaViewer, MediaViewerState};
|
||||
pub use viewer::{MediaViewer, MediaViewerFlags, MediaViewerState};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user