media.rs (20366B)
1 use bitflags::bitflags; 2 use egui::{ 3 vec2, Button, Color32, Context, CornerRadius, FontId, Image, InnerResponse, Response, 4 TextureHandle, Vec2, 5 }; 6 use notedeck::media::latest::ObfuscatedTexture; 7 use notedeck::{ 8 fonts::get_font_size, show_one_error_message, tr, Images, Localization, MediaAction, 9 MediaCacheType, NotedeckTextStyle, RenderableMedia, 10 }; 11 use notedeck::{MediaJobSender, PointDimensions}; 12 13 use crate::NoteOptions; 14 use notedeck::media::images::ImageType; 15 use notedeck::media::{AnimationMode, MediaRenderState}; 16 use notedeck::media::{MediaInfo, ViewMediaInfo}; 17 18 use crate::{app_images, AnimationHelper, PulseAlpha}; 19 20 pub enum MediaViewAction { 21 /// Used to handle escape presses when the media viewer is open 22 EscapePressed, 23 } 24 25 #[allow(clippy::too_many_arguments)] 26 #[profiling::function] 27 pub fn image_carousel( 28 ui: &mut egui::Ui, 29 img_cache: &mut Images, 30 jobs: &MediaJobSender, 31 medias: &[RenderableMedia], 32 carousel_id: egui::Id, 33 i18n: &mut Localization, 34 note_options: NoteOptions, 35 ) -> Option<MediaAction> { 36 // let's make sure everything is within our area 37 38 let size = { 39 let height = 360.0; 40 let width = ui.available_width(); 41 egui::vec2(width, height) 42 }; 43 44 let mut action = None; 45 46 //let has_touch_screen = ui.ctx().input(|i| i.has_touch_screen()); 47 ui.add_sized(size, |ui: &mut egui::Ui| { 48 egui::ScrollArea::horizontal() 49 .drag_to_scroll(false) 50 .id_salt(carousel_id) 51 .show(ui, |ui| { 52 let response = ui 53 .horizontal(|ui| { 54 let spacing = ui.spacing_mut(); 55 spacing.item_spacing.x = 8.0; 56 57 let mut media_infos: Vec<MediaInfo> = Vec::with_capacity(medias.len()); 58 let mut media_action: Option<(usize, MediaUIAction)> = None; 59 60 for (i, media) in medias.iter().enumerate() { 61 let media_response = render_media( 62 ui, 63 img_cache, 64 jobs, 65 media, 66 note_options.contains(NoteOptions::TrustMedia) 67 || img_cache.user_trusts_img(&media.url, media.media_type), 68 i18n, 69 size, 70 if note_options.contains(NoteOptions::NoAnimations) { 71 Some(AnimationMode::NoAnimation) 72 } else { 73 None 74 }, 75 if note_options.contains(NoteOptions::Wide) { 76 ScaledTextureFlags::SCALE_TO_WIDTH 77 } else { 78 ScaledTextureFlags::empty() 79 }, 80 ); 81 82 if let Some(action) = media_response.inner { 83 media_action = Some((i, action)) 84 } 85 86 let rect = media_response.response.rect; 87 media_infos.push(MediaInfo { 88 url: media.url.clone(), 89 original_position: rect, 90 }) 91 } 92 93 if let Some((i, media_action)) = media_action { 94 action = media_action.into_media_action( 95 medias, 96 media_infos, 97 i, 98 img_cache, 99 ImageType::Content(Some( 100 PointDimensions::from_vec(size).to_pixels(ui), 101 )), 102 ); 103 } 104 }) 105 .response; 106 ui.add_space(8.0); 107 response 108 }) 109 .inner 110 }); 111 112 action 113 } 114 115 #[allow(clippy::too_many_arguments)] 116 pub fn render_media( 117 ui: &mut egui::Ui, 118 img_cache: &mut Images, 119 jobs: &MediaJobSender, 120 media: &RenderableMedia, 121 trusted_media: bool, 122 i18n: &mut Localization, 123 size: Vec2, 124 animation_mode: Option<AnimationMode>, 125 scale_flags: ScaledTextureFlags, 126 ) -> InnerResponse<Option<MediaUIAction>> { 127 let RenderableMedia { 128 url, 129 media_type, 130 obfuscation_type: blur_type, 131 } = media; 132 133 let animation_mode = animation_mode.unwrap_or_else(|| { 134 // if animations aren't disabled, we cap it at 24fps for gifs in carousels 135 let fps = match media_type { 136 MediaCacheType::Gif => Some(24.0), 137 MediaCacheType::Image => None, 138 }; 139 AnimationMode::Continuous { fps } 140 }); 141 let media_state = if trusted_media { 142 img_cache.trusted_texture_loader().latest( 143 jobs, 144 ui, 145 url, 146 *media_type, 147 ImageType::Content(Some(PointDimensions::from_vec(size).to_pixels(ui))), 148 animation_mode, 149 blur_type, 150 size, 151 ) 152 } else { 153 img_cache 154 .untrusted_texture_loader() 155 .latest(jobs, ui, url, blur_type, size) 156 }; 157 158 render_media_internal(ui, media_state, url, size, i18n, scale_flags) 159 } 160 161 pub enum MediaUIAction { 162 Unblur, 163 Error, 164 DoneLoading, 165 Clicked, 166 } 167 168 impl MediaUIAction { 169 pub fn into_media_action( 170 self, 171 medias: &[RenderableMedia], 172 responses: Vec<MediaInfo>, 173 selected: usize, 174 img_cache: &Images, 175 img_type: ImageType, 176 ) -> Option<MediaAction> { 177 match self { 178 // We've clicked on some media, let's package up 179 // all of the rendered media responses, and send 180 // them to the ViewMedias action so that our fullscreen 181 // media viewer can smoothly transition from them 182 MediaUIAction::Clicked => Some(MediaAction::ViewMedias(ViewMediaInfo { 183 clicked_index: selected, 184 medias: responses, 185 })), 186 187 MediaUIAction::Unblur => { 188 let url = &medias[selected].url; 189 let cache = img_cache.get_cache(medias[selected].media_type); 190 let cache_type = cache.cache_type; 191 Some(MediaAction::FetchImage { 192 url: url.to_owned(), 193 cache_type, 194 }) 195 } 196 197 MediaUIAction::Error => { 198 if !matches!(img_type, ImageType::Profile(_)) { 199 return None; 200 }; 201 202 let cache = img_cache.get_cache(medias[selected].media_type); 203 let cache_type = cache.cache_type; 204 Some(MediaAction::FetchImage { 205 url: medias[selected].url.to_owned(), 206 cache_type, 207 }) 208 } 209 MediaUIAction::DoneLoading => Some(MediaAction::DoneLoading { 210 url: medias[selected].url.to_owned(), 211 cache_type: img_cache.get_cache(medias[selected].media_type).cache_type, 212 }), 213 } 214 } 215 } 216 217 fn copy_link(i18n: &mut Localization, url: &str, img_resp: &Response) { 218 img_resp.context_menu(|ui| { 219 if ui 220 .button(tr!( 221 i18n, 222 "Copy Link", 223 "Button to copy media link to clipboard" 224 )) 225 .clicked() 226 { 227 ui.ctx().copy_text(url.to_owned()); 228 ui.close_menu(); 229 } 230 }); 231 } 232 233 #[allow(clippy::too_many_arguments)] 234 fn render_media_internal( 235 ui: &mut egui::Ui, 236 render_state: MediaRenderState, 237 url: &str, 238 size: egui::Vec2, 239 i18n: &mut Localization, 240 scale_flags: ScaledTextureFlags, 241 ) -> egui::InnerResponse<Option<MediaUIAction>> { 242 match render_state { 243 MediaRenderState::ActualImage(image) => { 244 let resp = render_success_media(ui, url, image, size, i18n, scale_flags); 245 if resp.clicked() { 246 egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp) 247 } else { 248 egui::InnerResponse::new(None, resp) 249 } 250 } 251 MediaRenderState::Transitioning { 252 image: img_tex, 253 obfuscation, 254 } => match obfuscation { 255 ObfuscatedTexture::Blur(blur_tex) => { 256 let resp = render_blur_transition(ui, url, size, blur_tex, img_tex, scale_flags); 257 if resp.inner { 258 egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp.response) 259 } else { 260 egui::InnerResponse::new(None, resp.response) 261 } 262 } 263 ObfuscatedTexture::Default => { 264 let scaled = ScaledTexture::new(img_tex, size, scale_flags); 265 let resp = ui.add(scaled.get_image()); 266 egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp) 267 } 268 }, 269 MediaRenderState::Error(e) => { 270 let response = ui.allocate_response(size, egui::Sense::hover()); 271 show_one_error_message(ui, &format!("Could not render media {url}: {e}")); 272 egui::InnerResponse::new(Some(MediaUIAction::Error), response) 273 } 274 MediaRenderState::Shimmering(obfuscated_texture) => match obfuscated_texture { 275 ObfuscatedTexture::Blur(texture_handle) => egui::InnerResponse::new( 276 None, 277 shimmer_blurhash(texture_handle, ui, url, size, scale_flags), 278 ), 279 ObfuscatedTexture::Default => { 280 let shimmer = true; 281 egui::InnerResponse::new( 282 None, 283 render_default_blur_bg( 284 ui, 285 size, 286 url, 287 shimmer, 288 scale_flags.contains(ScaledTextureFlags::SCALE_TO_WIDTH), 289 ), 290 ) 291 } 292 }, 293 MediaRenderState::Obfuscated(obfuscated_texture) => { 294 let resp = match obfuscated_texture { 295 ObfuscatedTexture::Blur(texture_handle) => { 296 let scaled = ScaledTexture::new(texture_handle, size, scale_flags); 297 298 let resp = ui.add(scaled.get_image()); 299 render_blur_text(ui, i18n, url, resp.rect) 300 } 301 ObfuscatedTexture::Default => render_default_blur( 302 ui, 303 i18n, 304 size, 305 url, 306 scale_flags.contains(ScaledTextureFlags::SCALE_TO_WIDTH), 307 ), 308 }; 309 310 let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); 311 if resp.clicked() { 312 egui::InnerResponse::new(Some(MediaUIAction::Unblur), resp) 313 } else { 314 egui::InnerResponse::new(None, resp) 315 } 316 } 317 } 318 } 319 320 fn render_blur_text( 321 ui: &mut egui::Ui, 322 i18n: &mut Localization, 323 url: &str, 324 render_rect: egui::Rect, 325 ) -> egui::Response { 326 let helper = AnimationHelper::new_from_rect(ui, ("show_media", url), render_rect); 327 328 let painter = ui.painter_at(helper.get_animation_rect()); 329 330 let text_style = NotedeckTextStyle::Button; 331 332 let icon_size = helper.scale_1d_pos(30.0); 333 let animation_fontid = FontId::new( 334 helper.scale_1d_pos(get_font_size(ui.ctx(), &text_style)), 335 text_style.font_family(), 336 ); 337 let info_galley = painter.layout( 338 tr!( 339 i18n, 340 "Media from someone you don't follow", 341 "Text shown on blurred media from unfollowed users" 342 ) 343 .to_owned(), 344 animation_fontid.clone(), 345 ui.visuals().text_color(), 346 render_rect.width() / 2.0, 347 ); 348 349 let load_galley = painter.layout_no_wrap( 350 tr!(i18n, "Tap to Load", "Button text to load blurred media"), 351 animation_fontid, 352 egui::Color32::BLACK, 353 // ui.visuals().widgets.inactive.bg_fill, 354 ); 355 356 let items_height = info_galley.rect.height() + load_galley.rect.height() + icon_size; 357 358 let spacing = helper.scale_1d_pos(8.0); 359 let icon_rect = { 360 let mut center = helper.get_animation_rect().center(); 361 center.y -= (items_height / 2.0) + (spacing * 3.0) - (icon_size / 2.0); 362 363 egui::Rect::from_center_size(center, egui::vec2(icon_size, icon_size)) 364 }; 365 366 (if ui.visuals().dark_mode { 367 app_images::eye_slash_dark_image() 368 } else { 369 app_images::eye_slash_light_image() 370 }) 371 .max_width(icon_size) 372 .paint_at(ui, icon_rect); 373 374 let info_galley_pos = { 375 let mut pos = icon_rect.center(); 376 pos.x -= info_galley.rect.width() / 2.0; 377 pos.y = icon_rect.bottom() + spacing; 378 pos 379 }; 380 381 let load_galley_pos = { 382 let mut pos = icon_rect.center(); 383 pos.x -= load_galley.rect.width() / 2.0; 384 pos.y = icon_rect.bottom() + info_galley.rect.height() + (4.0 * spacing); 385 pos 386 }; 387 388 let button_rect = egui::Rect::from_min_size(load_galley_pos, load_galley.size()).expand(8.0); 389 390 let button_fill = egui::Color32::from_rgba_unmultiplied(0xFF, 0xFF, 0xFF, 0x1F); 391 392 painter.rect( 393 button_rect, 394 egui::CornerRadius::same(8), 395 button_fill, 396 egui::Stroke::NONE, 397 egui::StrokeKind::Middle, 398 ); 399 400 painter.galley(info_galley_pos, info_galley, egui::Color32::WHITE); 401 painter.galley(load_galley_pos, load_galley, egui::Color32::WHITE); 402 403 helper.take_animation_response() 404 } 405 406 fn render_default_blur( 407 ui: &mut egui::Ui, 408 i18n: &mut Localization, 409 size: egui::Vec2, 410 url: &str, 411 is_scaled: bool, 412 ) -> egui::Response { 413 let shimmer = false; 414 let response = render_default_blur_bg(ui, size, url, shimmer, is_scaled); 415 render_blur_text(ui, i18n, url, response.rect) 416 } 417 418 fn render_default_blur_bg( 419 ui: &mut egui::Ui, 420 size: egui::Vec2, 421 url: &str, 422 shimmer: bool, 423 is_scaled: bool, 424 ) -> egui::Response { 425 let size = if is_scaled { 426 size 427 } else { 428 vec2(size.y, size.y) 429 }; 430 431 let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); 432 433 let painter = ui.painter_at(rect); 434 435 let mut color = crate::colors::MID_GRAY; 436 if shimmer { 437 let [r, g, b, _a] = color.to_srgba_unmultiplied(); 438 let cur_alpha = get_blur_current_alpha(ui, url); 439 color = Color32::from_rgba_unmultiplied(r, g, b, cur_alpha) 440 } 441 442 painter.rect_filled(rect, CornerRadius::same(8), color); 443 444 response 445 } 446 447 #[allow(clippy::too_many_arguments)] 448 fn render_success_media( 449 ui: &mut egui::Ui, 450 url: &str, 451 tex: &TextureHandle, 452 size: Vec2, 453 i18n: &mut Localization, 454 scale_flags: ScaledTextureFlags, 455 ) -> Response { 456 let scaled = ScaledTexture::new(tex, size, scale_flags); 457 458 let img_resp = ui.add(Button::image(scaled.get_image()).frame(false)); 459 460 copy_link(i18n, url, &img_resp); 461 462 img_resp 463 } 464 465 fn texture_to_image<'a>(tex: &TextureHandle, size: Vec2) -> egui::Image<'a> { 466 Image::new(tex) 467 .corner_radius(5.0) 468 .fit_to_exact_size(size) 469 .maintain_aspect_ratio(true) 470 } 471 472 static BLUR_SHIMMER_ID: fn(&str) -> egui::Id = |url| egui::Id::new(("blur_shimmer", url)); 473 474 fn get_blur_current_alpha(ui: &mut egui::Ui, url: &str) -> u8 { 475 let id = BLUR_SHIMMER_ID(url); 476 477 let (alpha_min, alpha_max) = if ui.visuals().dark_mode { 478 (150, 255) 479 } else { 480 (220, 255) 481 }; 482 PulseAlpha::new(ui.ctx(), id, alpha_min, alpha_max) 483 .with_speed(0.3) 484 .start_max_alpha() 485 .animate() 486 } 487 488 fn shimmer_blurhash( 489 tex: &TextureHandle, 490 ui: &mut egui::Ui, 491 url: &str, 492 size: Vec2, 493 scale_flags: ScaledTextureFlags, 494 ) -> egui::Response { 495 let cur_alpha = get_blur_current_alpha(ui, url); 496 497 let scaled = ScaledTexture::new(tex, size, scale_flags); 498 let img = scaled.get_image(); 499 show_blurhash_with_alpha(ui, img, cur_alpha) 500 } 501 502 fn fade_color(alpha: u8) -> egui::Color32 { 503 Color32::from_rgba_unmultiplied(255, 255, 255, alpha) 504 } 505 506 fn show_blurhash_with_alpha(ui: &mut egui::Ui, img: Image, alpha: u8) -> egui::Response { 507 let cur_color = fade_color(alpha); 508 let img = img.tint(cur_color); 509 510 ui.add(img) 511 } 512 513 type FinishedTransition = bool; 514 515 // return true if transition is finished 516 fn render_blur_transition( 517 ui: &mut egui::Ui, 518 url: &str, 519 size: Vec2, 520 blur_texture: &TextureHandle, 521 image_texture: &TextureHandle, 522 scale_flags: ScaledTextureFlags, 523 ) -> egui::InnerResponse<FinishedTransition> { 524 let scaled_texture = ScaledTexture::new(image_texture, size, scale_flags); 525 let scaled_blur_img = ScaledTexture::new(blur_texture, size, scale_flags); 526 527 match get_blur_transition_state(ui.ctx(), url) { 528 BlurTransitionState::StoppingShimmer { cur_alpha } => egui::InnerResponse::new( 529 false, 530 show_blurhash_with_alpha(ui, scaled_blur_img.get_image(), cur_alpha), 531 ), 532 BlurTransitionState::FadingBlur => { 533 render_blur_fade(ui, url, scaled_blur_img.get_image(), &scaled_texture) 534 } 535 } 536 } 537 538 struct ScaledTexture<'a> { 539 tex: &'a TextureHandle, 540 size: Vec2, 541 pub scaled_size: Vec2, 542 } 543 544 bitflags! { 545 #[repr(transparent)] 546 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 547 pub struct ScaledTextureFlags: u8 { 548 const SCALE_TO_WIDTH = 1u8; 549 const RESPECT_MAX_DIMS = 2u8; 550 } 551 } 552 553 impl<'a> ScaledTexture<'a> { 554 pub fn new(tex: &'a TextureHandle, max_size: Vec2, flags: ScaledTextureFlags) -> Self { 555 let tex_size = tex.size_vec2(); 556 557 if flags.contains(ScaledTextureFlags::RESPECT_MAX_DIMS) { 558 return Self::respecting_max(tex, max_size); 559 } 560 561 let scaled_size = if !flags.contains(ScaledTextureFlags::SCALE_TO_WIDTH) { 562 if tex_size.y > max_size.y { 563 let scale = max_size.y / tex_size.y; 564 tex_size * scale 565 } else { 566 tex_size 567 } 568 } else if tex_size.x != max_size.x { 569 let scale = max_size.x / tex_size.x; 570 tex_size * scale 571 } else { 572 tex_size 573 }; 574 575 Self { 576 tex, 577 size: max_size, 578 scaled_size, 579 } 580 } 581 582 pub fn respecting_max(tex: &'a TextureHandle, max_size: Vec2) -> Self { 583 let tex_size = tex.size_vec2(); 584 585 let s = (max_size.x / tex_size.x).min(max_size.y / tex_size.y); 586 let scaled_size = tex_size * s; 587 588 Self { 589 tex, 590 size: max_size, 591 scaled_size, 592 } 593 } 594 595 pub fn get_image(&self) -> Image<'_> { 596 texture_to_image(self.tex, self.size).fit_to_exact_size(self.scaled_size) 597 } 598 } 599 600 fn render_blur_fade( 601 ui: &mut egui::Ui, 602 url: &str, 603 blur_img: Image, 604 image_texture: &ScaledTexture, 605 ) -> egui::InnerResponse<FinishedTransition> { 606 let blur_fade_id = ui.id().with(("blur_fade", url)); 607 608 let cur_alpha = { 609 PulseAlpha::new(ui.ctx(), blur_fade_id, 0, 255) 610 .start_max_alpha() 611 .with_speed(0.3) 612 .animate() 613 }; 614 615 let img = image_texture.get_image(); 616 617 let blur_img = blur_img.tint(fade_color(cur_alpha)); 618 619 let alloc_size = image_texture.scaled_size; 620 621 let (rect, resp) = ui.allocate_exact_size(alloc_size, egui::Sense::hover()); 622 623 img.paint_at(ui, rect); 624 blur_img.paint_at(ui, rect); 625 626 egui::InnerResponse::new(cur_alpha == 0, resp) 627 } 628 629 fn get_blur_transition_state(ctx: &Context, url: &str) -> BlurTransitionState { 630 let shimmer_id = BLUR_SHIMMER_ID(url); 631 632 let max_alpha = 255.0; 633 let cur_shimmer_alpha = ctx.animate_value_with_time(shimmer_id, max_alpha, 0.3); 634 if cur_shimmer_alpha == max_alpha { 635 BlurTransitionState::FadingBlur 636 } else { 637 let cur_alpha = (cur_shimmer_alpha).clamp(0.0, max_alpha) as u8; 638 BlurTransitionState::StoppingShimmer { cur_alpha } 639 } 640 } 641 642 enum BlurTransitionState { 643 StoppingShimmer { cur_alpha: u8 }, 644 FadingBlur, 645 }