media/viewer: fullscreen transition animations
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
@@ -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