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