media.rs (33885B)
1 use std::{collections::HashMap, path::Path}; 2 3 use egui::{ 4 Button, Color32, Context, CornerRadius, FontId, Image, Response, RichText, Sense, 5 TextureHandle, UiBuilder, Window, 6 }; 7 use notedeck::{ 8 fonts::get_font_size, note::MediaAction, show_one_error_message, supported_mime_hosted_at_url, 9 tr, GifState, GifStateMap, Images, JobPool, Localization, MediaCache, MediaCacheType, 10 NotedeckTextStyle, TexturedImage, TexturesCache, UrlMimes, 11 }; 12 13 use crate::{ 14 app_images, 15 blur::{compute_blurhash, Blur, ObfuscationType, PointDimensions}, 16 colors::PINK, 17 gif::{handle_repaint, retrieve_latest_texture}, 18 images::{fetch_no_pfp_promise, get_render_state, ImageType}, 19 jobs::{BlurhashParams, Job, JobId, JobParams, JobState, JobsCache}, 20 AnimationHelper, PulseAlpha, 21 }; 22 23 #[allow(clippy::too_many_arguments)] 24 pub(crate) fn image_carousel( 25 ui: &mut egui::Ui, 26 img_cache: &mut Images, 27 job_pool: &mut JobPool, 28 jobs: &mut JobsCache, 29 medias: &[RenderableMedia], 30 carousel_id: egui::Id, 31 trusted_media: bool, 32 i18n: &mut Localization, 33 ) -> Option<MediaAction> { 34 // let's make sure everything is within our area 35 36 let height = 360.0; 37 let width = ui.available_width(); 38 39 let show_popup = get_show_popup(ui, popup_id(carousel_id)); 40 let mut action = None; 41 42 //let has_touch_screen = ui.ctx().input(|i| i.has_touch_screen()); 43 ui.add_sized([width, height], |ui: &mut egui::Ui| { 44 egui::ScrollArea::horizontal() 45 .drag_to_scroll(false) 46 .id_salt(carousel_id) 47 .show(ui, |ui| { 48 ui.horizontal(|ui| { 49 for (i, media) in medias.iter().enumerate() { 50 let RenderableMedia { 51 url, 52 media_type, 53 obfuscation_type: blur_type, 54 } = media; 55 56 let cache = match media_type { 57 MediaCacheType::Image => &mut img_cache.static_imgs, 58 MediaCacheType::Gif => &mut img_cache.gifs, 59 }; 60 61 let media_state = get_content_media_render_state( 62 ui, 63 job_pool, 64 jobs, 65 trusted_media, 66 height, 67 &mut cache.textures_cache, 68 url, 69 *media_type, 70 &cache.cache_dir, 71 blur_type.clone(), 72 ); 73 74 if let Some(cur_action) = render_media( 75 ui, 76 &mut img_cache.gif_states, 77 media_state, 78 url, 79 height, 80 i18n, 81 ) { 82 // clicked the media, lets set the active index 83 if let MediaUIAction::Clicked = cur_action { 84 set_show_popup(ui, popup_id(carousel_id), true); 85 set_selected_index(ui, selection_id(carousel_id), i); 86 } 87 88 action = cur_action.to_media_action( 89 ui.ctx(), 90 url, 91 *media_type, 92 cache, 93 ImageType::Content, 94 ); 95 } 96 } 97 }) 98 .response 99 }) 100 .inner 101 }); 102 103 if show_popup { 104 if medias.is_empty() { 105 return None; 106 }; 107 108 let current_image_index = update_selected_image_index(ui, carousel_id, medias.len() as i32); 109 110 show_full_screen_media( 111 ui, 112 medias, 113 current_image_index, 114 img_cache, 115 carousel_id, 116 i18n, 117 ); 118 } 119 action 120 } 121 122 enum MediaUIAction { 123 Unblur, 124 Error, 125 DoneLoading, 126 Clicked, 127 } 128 129 impl MediaUIAction { 130 pub fn to_media_action( 131 &self, 132 ctx: &egui::Context, 133 url: &str, 134 cache_type: MediaCacheType, 135 cache: &mut MediaCache, 136 img_type: ImageType, 137 ) -> Option<MediaAction> { 138 match self { 139 MediaUIAction::Clicked => { 140 tracing::debug!("{} clicked", url); 141 None 142 } 143 144 MediaUIAction::Unblur => Some(MediaAction::FetchImage { 145 url: url.to_owned(), 146 cache_type, 147 no_pfp_promise: crate::images::fetch_img( 148 &cache.cache_dir, 149 ctx, 150 url, 151 img_type, 152 cache_type, 153 ), 154 }), 155 MediaUIAction::Error => { 156 if !matches!(img_type, ImageType::Profile(_)) { 157 return None; 158 }; 159 160 Some(MediaAction::FetchImage { 161 url: url.to_owned(), 162 cache_type, 163 no_pfp_promise: fetch_no_pfp_promise(ctx, cache), 164 }) 165 } 166 MediaUIAction::DoneLoading => Some(MediaAction::DoneLoading { 167 url: url.to_owned(), 168 cache_type, 169 }), 170 } 171 } 172 } 173 174 fn show_full_screen_media( 175 ui: &mut egui::Ui, 176 medias: &[RenderableMedia], 177 index: usize, 178 img_cache: &mut Images, 179 carousel_id: egui::Id, 180 i18n: &mut Localization, 181 ) { 182 Window::new("image_popup") 183 .title_bar(false) 184 .fixed_size(ui.ctx().screen_rect().size()) 185 .fixed_pos(ui.ctx().screen_rect().min) 186 .frame(egui::Frame::NONE) 187 .show(ui.ctx(), |ui| { 188 ui.centered_and_justified(|ui| 's: { 189 let image_url = medias[index].url; 190 191 let media_type = medias[index].media_type; 192 tracing::trace!( 193 "show_full_screen_media using img {} @ {} for carousel_id {:?}", 194 image_url, 195 index, 196 carousel_id 197 ); 198 199 let cur_state = get_render_state( 200 ui.ctx(), 201 img_cache, 202 media_type, 203 image_url, 204 ImageType::Content, 205 ); 206 207 let notedeck::TextureState::Loaded(textured_image) = cur_state.texture_state else { 208 break 's; 209 }; 210 211 render_full_screen_media( 212 ui, 213 medias.len(), 214 index, 215 textured_image, 216 cur_state.gifs, 217 image_url, 218 carousel_id, 219 i18n, 220 ); 221 }) 222 }); 223 } 224 225 fn set_selected_index(ui: &mut egui::Ui, sel_id: egui::Id, index: usize) { 226 ui.data_mut(|d| { 227 d.insert_temp(sel_id, index); 228 }); 229 } 230 231 fn get_selected_index(ui: &egui::Ui, selection_id: egui::Id) -> usize { 232 ui.data(|d| d.get_temp(selection_id).unwrap_or(0)) 233 } 234 235 /// Checks to see if we have any left/right key presses and updates the carousel index 236 fn update_selected_image_index(ui: &mut egui::Ui, carousel_id: egui::Id, num_urls: i32) -> usize { 237 if num_urls > 1 { 238 let (next_image, prev_image) = ui.data(|data| { 239 ( 240 data.get_temp(carousel_id.with("next_image")) 241 .unwrap_or_default(), 242 data.get_temp(carousel_id.with("prev_image")) 243 .unwrap_or_default(), 244 ) 245 }); 246 247 if next_image 248 || ui.input(|i| i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::L)) 249 { 250 let ind = select_next_media(ui, carousel_id, num_urls, 1); 251 tracing::debug!("carousel selecting right {}/{}", ind + 1, num_urls); 252 if next_image { 253 ui.data_mut(|data| data.remove_temp::<bool>(carousel_id.with("next_image"))); 254 } 255 ind 256 } else if prev_image 257 || ui.input(|i| i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::H)) 258 { 259 let ind = select_next_media(ui, carousel_id, num_urls, -1); 260 tracing::debug!("carousel selecting left {}/{}", ind + 1, num_urls); 261 if prev_image { 262 ui.data_mut(|data| data.remove_temp::<bool>(carousel_id.with("prev_image"))); 263 } 264 ind 265 } else { 266 get_selected_index(ui, selection_id(carousel_id)) 267 } 268 } else { 269 0 270 } 271 } 272 273 #[allow(clippy::too_many_arguments)] 274 pub fn get_content_media_render_state<'a>( 275 ui: &mut egui::Ui, 276 job_pool: &'a mut JobPool, 277 jobs: &'a mut JobsCache, 278 media_trusted: bool, 279 height: f32, 280 cache: &'a mut TexturesCache, 281 url: &'a str, 282 cache_type: MediaCacheType, 283 cache_dir: &Path, 284 obfuscation_type: ObfuscationType<'a>, 285 ) -> MediaRenderState<'a> { 286 let render_type = if media_trusted { 287 cache.handle_and_get_or_insert_loadable(url, || { 288 crate::images::fetch_img(cache_dir, ui.ctx(), url, ImageType::Content, cache_type) 289 }) 290 } else if let Some(render_type) = cache.get_and_handle(url) { 291 render_type 292 } else { 293 return MediaRenderState::Obfuscated(get_obfuscated( 294 ui, 295 url, 296 obfuscation_type, 297 job_pool, 298 jobs, 299 height, 300 )); 301 }; 302 303 match render_type { 304 notedeck::LoadableTextureState::Pending => MediaRenderState::Shimmering(get_obfuscated( 305 ui, 306 url, 307 obfuscation_type, 308 job_pool, 309 jobs, 310 height, 311 )), 312 notedeck::LoadableTextureState::Error(e) => MediaRenderState::Error(e), 313 notedeck::LoadableTextureState::Loading { actual_image_tex } => { 314 let obfuscation = get_obfuscated(ui, url, obfuscation_type, job_pool, jobs, height); 315 MediaRenderState::Transitioning { 316 image: actual_image_tex, 317 obfuscation, 318 } 319 } 320 notedeck::LoadableTextureState::Loaded(textured_image) => { 321 MediaRenderState::ActualImage(textured_image) 322 } 323 } 324 } 325 326 fn get_obfuscated<'a>( 327 ui: &mut egui::Ui, 328 url: &str, 329 obfuscation_type: ObfuscationType<'a>, 330 job_pool: &'a mut JobPool, 331 jobs: &'a mut JobsCache, 332 height: f32, 333 ) -> ObfuscatedTexture<'a> { 334 let ObfuscationType::Blurhash(renderable_blur) = obfuscation_type else { 335 return ObfuscatedTexture::Default; 336 }; 337 338 let params = BlurhashParams { 339 blurhash: renderable_blur.blurhash, 340 url, 341 ctx: ui.ctx(), 342 }; 343 344 let available_points = PointDimensions { 345 x: ui.available_width(), 346 y: height, 347 }; 348 349 let pixel_sizes = renderable_blur.scaled_pixel_dimensions(ui, available_points); 350 351 let job_state = jobs.get_or_insert_with( 352 job_pool, 353 &JobId::Blurhash(url), 354 Some(JobParams::Blurhash(params)), 355 move |params| compute_blurhash(params, pixel_sizes), 356 ); 357 358 let JobState::Completed(m_blur_job) = job_state else { 359 return ObfuscatedTexture::Default; 360 }; 361 362 #[allow(irrefutable_let_patterns)] 363 let Job::Blurhash(m_texture_handle) = m_blur_job 364 else { 365 tracing::error!("Did not get the correct job type: {:?}", m_blur_job); 366 return ObfuscatedTexture::Default; 367 }; 368 369 let Some(texture_handle) = m_texture_handle else { 370 return ObfuscatedTexture::Default; 371 }; 372 373 ObfuscatedTexture::Blur(texture_handle) 374 } 375 376 // simple selector memory 377 fn select_next_media( 378 ui: &mut egui::Ui, 379 carousel_id: egui::Id, 380 num_urls: i32, 381 direction: i32, 382 ) -> usize { 383 let sel_id = selection_id(carousel_id); 384 let current = get_selected_index(ui, sel_id) as i32; 385 let next = current + direction; 386 let next = if next >= num_urls { 387 0 388 } else if next < 0 { 389 num_urls - 1 390 } else { 391 next 392 }; 393 394 if next != current { 395 set_selected_index(ui, sel_id, next as usize); 396 } 397 398 next as usize 399 } 400 401 #[allow(clippy::too_many_arguments)] 402 fn render_full_screen_media( 403 ui: &mut egui::Ui, 404 num_urls: usize, 405 index: usize, 406 renderable_media: &mut TexturedImage, 407 gifs: &mut HashMap<String, GifState>, 408 image_url: &str, 409 carousel_id: egui::Id, 410 i18n: &mut Localization, 411 ) { 412 const TOP_BAR_HEIGHT: f32 = 30.0; 413 const BOTTOM_BAR_HEIGHT: f32 = 60.0; 414 415 let screen_rect = ui.ctx().screen_rect(); 416 let screen_size = screen_rect.size(); 417 418 // Escape key closes popup 419 if ui.input(|i| i.key_pressed(egui::Key::Escape)) { 420 ui.ctx().memory_mut(|mem| { 421 mem.data.insert_temp(carousel_id.with("show_popup"), false); 422 }); 423 } 424 425 // Draw background 426 ui.painter() 427 .rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230)); 428 429 let background_response = ui.interact( 430 screen_rect, 431 carousel_id.with("background"), 432 egui::Sense::click(), 433 ); 434 435 // Zoom & pan state 436 let zoom_id = carousel_id.with("zoom_level"); 437 let pan_id = carousel_id.with("pan_offset"); 438 439 let mut zoom: f32 = ui 440 .ctx() 441 .memory(|mem| mem.data.get_temp(zoom_id).unwrap_or(1.0)); 442 let mut pan_offset = ui 443 .ctx() 444 .memory(|mem| mem.data.get_temp(pan_id).unwrap_or(egui::Vec2::ZERO)); 445 446 // Handle scroll to zoom 447 if ui.input(|i| i.pointer.hover_pos()).is_some() { 448 let scroll_delta = ui.input(|i| i.smooth_scroll_delta); 449 if scroll_delta.y != 0.0 { 450 let zoom_factor = if scroll_delta.y > 0.0 { 1.05 } else { 0.95 }; 451 zoom = (zoom * zoom_factor).clamp(0.1, 5.0); 452 if zoom <= 1.0 { 453 pan_offset = egui::Vec2::ZERO; 454 } 455 ui.ctx().memory_mut(|mem| { 456 mem.data.insert_temp(zoom_id, zoom); 457 mem.data.insert_temp(pan_id, pan_offset); 458 }); 459 } 460 } 461 462 // Fetch image 463 let texture = handle_repaint( 464 ui, 465 retrieve_latest_texture(image_url, gifs, renderable_media), 466 ); 467 468 let texture_size = texture.size_vec2(); 469 470 let topbar_rect = egui::Rect::from_min_max( 471 screen_rect.min + egui::vec2(0.0, 0.0), 472 screen_rect.min + egui::vec2(screen_size.x, TOP_BAR_HEIGHT), 473 ); 474 475 let topbar_response = ui.interact( 476 topbar_rect, 477 carousel_id.with("topbar"), 478 egui::Sense::click(), 479 ); 480 481 let mut keep_popup_open = false; 482 if topbar_response.clicked() { 483 keep_popup_open = true; 484 } 485 486 ui.allocate_new_ui( 487 UiBuilder::new() 488 .max_rect(topbar_rect) 489 .layout(egui::Layout::top_down(egui::Align::RIGHT)), 490 |ui| { 491 let color = ui.style().visuals.noninteractive().fg_stroke.color; 492 493 ui.add_space(10.0); 494 495 ui.horizontal(|ui| { 496 let label_reponse = ui 497 .label(RichText::new(image_url).color(color).small()) 498 .on_hover_text(image_url); 499 if label_reponse.double_clicked() 500 || label_reponse.clicked() 501 || label_reponse.hovered() 502 { 503 keep_popup_open = true; 504 505 ui.ctx().copy_text(image_url.to_owned()); 506 } 507 }); 508 }, 509 ); 510 511 // Calculate available rect for image 512 let image_rect = egui::Rect::from_min_max( 513 screen_rect.min + egui::vec2(0.0, TOP_BAR_HEIGHT), 514 screen_rect.max - egui::vec2(0.0, BOTTOM_BAR_HEIGHT), 515 ); 516 517 let image_area_size = image_rect.size(); 518 let scale = (image_area_size.x / texture_size.x) 519 .min(image_area_size.y / texture_size.y) 520 .min(1.0); 521 let scaled_size = texture_size * scale * zoom; 522 523 let visible_width = scaled_size.x.min(image_area_size.x); 524 let visible_height = scaled_size.y.min(image_area_size.y); 525 526 let max_pan_x = ((scaled_size.x - visible_width) / 2.0).max(0.0); 527 let max_pan_y = ((scaled_size.y - visible_height) / 2.0).max(0.0); 528 529 pan_offset.x = if max_pan_x > 0.0 { 530 pan_offset.x.clamp(-max_pan_x, max_pan_x) 531 } else { 532 0.0 533 }; 534 pan_offset.y = if max_pan_y > 0.0 { 535 pan_offset.y.clamp(-max_pan_y, max_pan_y) 536 } else { 537 0.0 538 }; 539 540 let render_rect = egui::Rect::from_center_size( 541 image_rect.center(), 542 egui::vec2(visible_width, visible_height), 543 ); 544 545 // Compute UVs for zoom & pan 546 let uv_min = egui::pos2( 547 0.5 - (visible_width / scaled_size.x) / 2.0 + pan_offset.x / scaled_size.x, 548 0.5 - (visible_height / scaled_size.y) / 2.0 + pan_offset.y / scaled_size.y, 549 ); 550 let uv_max = egui::pos2( 551 uv_min.x + visible_width / scaled_size.x, 552 uv_min.y + visible_height / scaled_size.y, 553 ); 554 555 // Paint image 556 ui.painter().image( 557 texture.id(), 558 render_rect, 559 egui::Rect::from_min_max(uv_min, uv_max), 560 Color32::WHITE, 561 ); 562 563 // image actions 564 let response = ui.interact( 565 render_rect, 566 carousel_id.with("img"), 567 Sense::click_and_drag(), 568 ); 569 570 let swipe_accum_id = carousel_id.with("swipe_accum"); 571 let mut swipe_delta = ui.ctx().memory(|mem| { 572 mem.data 573 .get_temp::<egui::Vec2>(swipe_accum_id) 574 .unwrap_or(egui::Vec2::ZERO) 575 }); 576 577 // Handle pan via drag 578 if response.dragged() { 579 let delta = response.drag_delta(); 580 swipe_delta += delta; 581 ui.ctx().memory_mut(|mem| { 582 mem.data.insert_temp(swipe_accum_id, swipe_delta); 583 }); 584 pan_offset -= delta; 585 pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); 586 pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); 587 ui.ctx() 588 .memory_mut(|mem| mem.data.insert_temp(pan_id, pan_offset)); 589 } 590 591 // Double click to reset 592 if response.double_clicked() { 593 zoom = 1.0; 594 pan_offset = egui::Vec2::ZERO; 595 ui.ctx().memory_mut(|mem| { 596 mem.data.insert_temp(pan_id, pan_offset); 597 mem.data.insert_temp(zoom_id, zoom); 598 }); 599 } 600 601 let swipe_threshold = 50.0; 602 if response.drag_stopped() { 603 if swipe_delta.x.abs() > swipe_threshold && swipe_delta.y.abs() < swipe_threshold { 604 if swipe_delta.x < 0.0 { 605 ui.ctx().data_mut(|data| { 606 keep_popup_open = true; 607 data.insert_temp(carousel_id.with("next_image"), true); 608 }); 609 } else if swipe_delta.x > 0.0 { 610 ui.ctx().data_mut(|data| { 611 keep_popup_open = true; 612 data.insert_temp(carousel_id.with("prev_image"), true); 613 }); 614 } 615 } 616 617 ui.ctx().memory_mut(|mem| { 618 mem.data.remove::<egui::Vec2>(swipe_accum_id); 619 }); 620 } 621 622 // bottom bar 623 if num_urls > 1 { 624 let bottom_rect = egui::Rect::from_min_max( 625 screen_rect.max - egui::vec2(screen_size.x, BOTTOM_BAR_HEIGHT), 626 screen_rect.max, 627 ); 628 629 let full_response = ui.interact( 630 bottom_rect, 631 carousel_id.with("bottom_bar"), 632 egui::Sense::click(), 633 ); 634 635 if full_response.clicked() { 636 keep_popup_open = true; 637 } 638 639 let mut clicked_index: Option<usize> = None; 640 641 #[allow(deprecated)] 642 ui.allocate_ui_at_rect(bottom_rect, |ui| { 643 let dot_radius = 7.0; 644 let dot_spacing = 20.0; 645 let color_active = PINK; 646 let color_inactive: Color32 = ui.style().visuals.widgets.inactive.bg_fill; 647 648 let center = bottom_rect.center(); 649 650 for i in 0..num_urls { 651 let distance = egui::vec2( 652 (i as f32 - (num_urls as f32 - 1.0) / 2.0) * dot_spacing, 653 0.0, 654 ); 655 let pos = center + distance; 656 657 let circle_color = if i == index { 658 color_active 659 } else { 660 color_inactive 661 }; 662 663 let circle_rect = egui::Rect::from_center_size( 664 pos, 665 egui::vec2(dot_radius * 2.0, dot_radius * 2.0), 666 ); 667 668 let resp = ui.interact(circle_rect, carousel_id.with(i), egui::Sense::click()); 669 670 ui.painter().circle_filled(pos, dot_radius, circle_color); 671 672 if i != index && resp.hovered() { 673 ui.painter() 674 .circle_stroke(pos, dot_radius + 2.0, (1.0, PINK)); 675 } 676 677 if resp.clicked() { 678 keep_popup_open = true; 679 if i != index { 680 clicked_index = Some(i); 681 } 682 } 683 } 684 }); 685 686 if let Some(new_index) = clicked_index { 687 ui.ctx().data_mut(|data| { 688 data.insert_temp(selection_id(carousel_id), new_index); 689 }); 690 } 691 } 692 693 if keep_popup_open || response.clicked() { 694 ui.data_mut(|data| { 695 data.insert_temp(carousel_id.with("show_popup"), true); 696 }); 697 } else if background_response.clicked() || response.clicked_elsewhere() { 698 ui.data_mut(|data| { 699 data.insert_temp(carousel_id.with("show_popup"), false); 700 }); 701 } 702 703 copy_link(i18n, image_url, &response); 704 } 705 706 fn copy_link(i18n: &mut Localization, url: &str, img_resp: &Response) { 707 img_resp.context_menu(|ui| { 708 if ui 709 .button(tr!( 710 i18n, 711 "Copy Link", 712 "Button to copy media link to clipboard" 713 )) 714 .clicked() 715 { 716 ui.ctx().copy_text(url.to_owned()); 717 ui.close_menu(); 718 } 719 }); 720 } 721 722 #[allow(clippy::too_many_arguments)] 723 fn render_media( 724 ui: &mut egui::Ui, 725 gifs: &mut GifStateMap, 726 render_state: MediaRenderState, 727 url: &str, 728 height: f32, 729 i18n: &mut Localization, 730 ) -> Option<MediaUIAction> { 731 match render_state { 732 MediaRenderState::ActualImage(image) => { 733 if render_success_media(ui, url, image, gifs, height, i18n).clicked() { 734 Some(MediaUIAction::Clicked) 735 } else { 736 None 737 } 738 } 739 MediaRenderState::Transitioning { image, obfuscation } => match obfuscation { 740 ObfuscatedTexture::Blur(texture) => { 741 if render_blur_transition(ui, url, height, texture, image.get_first_texture()) { 742 Some(MediaUIAction::DoneLoading) 743 } else { 744 None 745 } 746 } 747 ObfuscatedTexture::Default => { 748 ui.add(texture_to_image(image.get_first_texture(), height)); 749 Some(MediaUIAction::DoneLoading) 750 } 751 }, 752 MediaRenderState::Error(e) => { 753 ui.allocate_space(egui::vec2(height, height)); 754 show_one_error_message(ui, &format!("Could not render media {url}: {e}")); 755 Some(MediaUIAction::Error) 756 } 757 MediaRenderState::Shimmering(obfuscated_texture) => { 758 match obfuscated_texture { 759 ObfuscatedTexture::Blur(texture_handle) => { 760 shimmer_blurhash(texture_handle, ui, url, height); 761 } 762 ObfuscatedTexture::Default => { 763 render_default_blur_bg(ui, height, url, true); 764 } 765 } 766 None 767 } 768 MediaRenderState::Obfuscated(obfuscated_texture) => { 769 let resp = match obfuscated_texture { 770 ObfuscatedTexture::Blur(texture_handle) => { 771 let resp = ui.add(texture_to_image(texture_handle, height)); 772 render_blur_text(ui, i18n, url, resp.rect) 773 } 774 ObfuscatedTexture::Default => render_default_blur(ui, i18n, height, url), 775 }; 776 777 if resp 778 .on_hover_cursor(egui::CursorIcon::PointingHand) 779 .clicked() 780 { 781 Some(MediaUIAction::Unblur) 782 } else { 783 None 784 } 785 } 786 } 787 } 788 789 fn render_blur_text( 790 ui: &mut egui::Ui, 791 i18n: &mut Localization, 792 url: &str, 793 render_rect: egui::Rect, 794 ) -> egui::Response { 795 let helper = AnimationHelper::new_from_rect(ui, ("show_media", url), render_rect); 796 797 let painter = ui.painter_at(helper.get_animation_rect()); 798 799 let text_style = NotedeckTextStyle::Button; 800 801 let icon_size = helper.scale_1d_pos(30.0); 802 let animation_fontid = FontId::new( 803 helper.scale_1d_pos(get_font_size(ui.ctx(), &text_style)), 804 text_style.font_family(), 805 ); 806 let info_galley = painter.layout( 807 tr!( 808 i18n, 809 "Media from someone you don't follow", 810 "Text shown on blurred media from unfollowed users" 811 ) 812 .to_owned(), 813 animation_fontid.clone(), 814 ui.visuals().text_color(), 815 render_rect.width() / 2.0, 816 ); 817 818 let load_galley = painter.layout_no_wrap( 819 tr!(i18n, "Tap to Load", "Button text to load blurred media"), 820 animation_fontid, 821 egui::Color32::BLACK, 822 // ui.visuals().widgets.inactive.bg_fill, 823 ); 824 825 let items_height = info_galley.rect.height() + load_galley.rect.height() + icon_size; 826 827 let spacing = helper.scale_1d_pos(8.0); 828 let icon_rect = { 829 let mut center = helper.get_animation_rect().center(); 830 center.y -= (items_height / 2.0) + (spacing * 3.0) - (icon_size / 2.0); 831 832 egui::Rect::from_center_size(center, egui::vec2(icon_size, icon_size)) 833 }; 834 835 (if ui.visuals().dark_mode { 836 app_images::eye_slash_dark_image() 837 } else { 838 app_images::eye_slash_light_image() 839 }) 840 .max_width(icon_size) 841 .paint_at(ui, icon_rect); 842 843 let info_galley_pos = { 844 let mut pos = icon_rect.center(); 845 pos.x -= info_galley.rect.width() / 2.0; 846 pos.y = icon_rect.bottom() + spacing; 847 pos 848 }; 849 850 let load_galley_pos = { 851 let mut pos = icon_rect.center(); 852 pos.x -= load_galley.rect.width() / 2.0; 853 pos.y = icon_rect.bottom() + info_galley.rect.height() + (4.0 * spacing); 854 pos 855 }; 856 857 let button_rect = egui::Rect::from_min_size(load_galley_pos, load_galley.size()).expand(8.0); 858 859 let button_fill = egui::Color32::from_rgba_unmultiplied(0xFF, 0xFF, 0xFF, 0x1F); 860 861 painter.rect( 862 button_rect, 863 egui::CornerRadius::same(8), 864 button_fill, 865 egui::Stroke::NONE, 866 egui::StrokeKind::Middle, 867 ); 868 869 painter.galley(info_galley_pos, info_galley, egui::Color32::WHITE); 870 painter.galley(load_galley_pos, load_galley, egui::Color32::WHITE); 871 872 helper.take_animation_response() 873 } 874 875 fn render_default_blur( 876 ui: &mut egui::Ui, 877 i18n: &mut Localization, 878 height: f32, 879 url: &str, 880 ) -> egui::Response { 881 let rect = render_default_blur_bg(ui, height, url, false); 882 render_blur_text(ui, i18n, url, rect) 883 } 884 885 fn render_default_blur_bg(ui: &mut egui::Ui, height: f32, url: &str, shimmer: bool) -> egui::Rect { 886 let (rect, _) = ui.allocate_exact_size(egui::vec2(height, height), egui::Sense::click()); 887 888 let painter = ui.painter_at(rect); 889 890 let mut color = crate::colors::MID_GRAY; 891 if shimmer { 892 let [r, g, b, _a] = color.to_srgba_unmultiplied(); 893 let cur_alpha = get_blur_current_alpha(ui, url); 894 color = Color32::from_rgba_unmultiplied(r, g, b, cur_alpha) 895 } 896 897 painter.rect_filled(rect, CornerRadius::same(8), color); 898 899 rect 900 } 901 902 pub(crate) struct RenderableMedia<'a> { 903 url: &'a str, 904 media_type: MediaCacheType, 905 obfuscation_type: ObfuscationType<'a>, 906 } 907 908 pub enum MediaRenderState<'a> { 909 ActualImage(&'a mut TexturedImage), 910 Transitioning { 911 image: &'a mut TexturedImage, 912 obfuscation: ObfuscatedTexture<'a>, 913 }, 914 Error(&'a notedeck::Error), 915 Shimmering(ObfuscatedTexture<'a>), 916 Obfuscated(ObfuscatedTexture<'a>), 917 } 918 919 pub enum ObfuscatedTexture<'a> { 920 Blur(&'a TextureHandle), 921 Default, 922 } 923 924 pub(crate) fn find_renderable_media<'a>( 925 urls: &mut UrlMimes, 926 blurhashes: &'a HashMap<&'a str, Blur<'a>>, 927 url: &'a str, 928 ) -> Option<RenderableMedia<'a>> { 929 let media_type = supported_mime_hosted_at_url(urls, url)?; 930 931 let obfuscation_type = match blurhashes.get(url) { 932 Some(blur) => ObfuscationType::Blurhash(blur.clone()), 933 None => ObfuscationType::Default, 934 }; 935 936 Some(RenderableMedia { 937 url, 938 media_type, 939 obfuscation_type, 940 }) 941 } 942 943 #[inline] 944 fn selection_id(carousel_id: egui::Id) -> egui::Id { 945 carousel_id.with("sel") 946 } 947 948 /// get the popup carousel window state 949 #[inline] 950 fn get_show_popup(ui: &egui::Ui, popup_id: egui::Id) -> bool { 951 ui.data(|data| data.get_temp(popup_id).unwrap_or(false)) 952 } 953 954 /// set the popup carousel window state 955 #[inline] 956 fn set_show_popup(ui: &mut egui::Ui, popup_id: egui::Id, show_popup: bool) { 957 ui.data_mut(|data| data.insert_temp(popup_id, show_popup)); 958 } 959 960 #[inline] 961 fn popup_id(carousel_id: egui::Id) -> egui::Id { 962 carousel_id.with("show_popup") 963 } 964 965 fn render_success_media( 966 ui: &mut egui::Ui, 967 url: &str, 968 tex: &mut TexturedImage, 969 gifs: &mut GifStateMap, 970 height: f32, 971 i18n: &mut Localization, 972 ) -> Response { 973 let texture = handle_repaint(ui, retrieve_latest_texture(url, gifs, tex)); 974 let img = texture_to_image(texture, height); 975 let img_resp = ui.add(Button::image(img).frame(false)); 976 977 copy_link(i18n, url, &img_resp); 978 979 img_resp 980 } 981 982 fn texture_to_image(tex: &TextureHandle, max_height: f32) -> egui::Image { 983 Image::new(tex) 984 .max_height(max_height) 985 .corner_radius(5.0) 986 .maintain_aspect_ratio(true) 987 } 988 989 static BLUR_SHIMMER_ID: fn(&str) -> egui::Id = |url| egui::Id::new(("blur_shimmer", url)); 990 991 fn get_blur_current_alpha(ui: &mut egui::Ui, url: &str) -> u8 { 992 let id = BLUR_SHIMMER_ID(url); 993 994 let (alpha_min, alpha_max) = if ui.visuals().dark_mode { 995 (150, 255) 996 } else { 997 (220, 255) 998 }; 999 PulseAlpha::new(ui.ctx(), id, alpha_min, alpha_max) 1000 .with_speed(0.3) 1001 .start_max_alpha() 1002 .animate() 1003 } 1004 1005 fn shimmer_blurhash(tex: &TextureHandle, ui: &mut egui::Ui, url: &str, max_height: f32) { 1006 let cur_alpha = get_blur_current_alpha(ui, url); 1007 1008 let scaled = ScaledTexture::new(tex, max_height); 1009 let img = scaled.get_image(); 1010 show_blurhash_with_alpha(ui, img, cur_alpha); 1011 } 1012 1013 fn fade_color(alpha: u8) -> egui::Color32 { 1014 Color32::from_rgba_unmultiplied(255, 255, 255, alpha) 1015 } 1016 1017 fn show_blurhash_with_alpha(ui: &mut egui::Ui, img: Image, alpha: u8) { 1018 let cur_color = fade_color(alpha); 1019 1020 let img = img.tint(cur_color); 1021 1022 ui.add(img); 1023 } 1024 1025 type FinishedTransition = bool; 1026 1027 // return true if transition is finished 1028 fn render_blur_transition( 1029 ui: &mut egui::Ui, 1030 url: &str, 1031 max_height: f32, 1032 blur_texture: &TextureHandle, 1033 image_texture: &TextureHandle, 1034 ) -> FinishedTransition { 1035 let scaled_texture = ScaledTexture::new(image_texture, max_height); 1036 1037 let blur_img = texture_to_image(blur_texture, max_height); 1038 match get_blur_transition_state(ui.ctx(), url) { 1039 BlurTransitionState::StoppingShimmer { cur_alpha } => { 1040 show_blurhash_with_alpha(ui, blur_img, cur_alpha); 1041 false 1042 } 1043 BlurTransitionState::FadingBlur => render_blur_fade(ui, url, blur_img, &scaled_texture), 1044 } 1045 } 1046 1047 struct ScaledTexture<'a> { 1048 tex: &'a TextureHandle, 1049 max_height: f32, 1050 pub scaled_size: egui::Vec2, 1051 } 1052 1053 impl<'a> ScaledTexture<'a> { 1054 pub fn new(tex: &'a TextureHandle, max_height: f32) -> Self { 1055 let scaled_size = { 1056 let mut size = tex.size_vec2(); 1057 1058 if size.y > max_height { 1059 let old_y = size.y; 1060 size.y = max_height; 1061 size.x *= max_height / old_y; 1062 } 1063 1064 size 1065 }; 1066 1067 Self { 1068 tex, 1069 max_height, 1070 scaled_size, 1071 } 1072 } 1073 1074 pub fn get_image(&self) -> Image { 1075 texture_to_image(self.tex, self.max_height) 1076 .max_size(self.scaled_size) 1077 .shrink_to_fit() 1078 } 1079 } 1080 1081 fn render_blur_fade( 1082 ui: &mut egui::Ui, 1083 url: &str, 1084 blur_img: Image, 1085 image_texture: &ScaledTexture, 1086 ) -> FinishedTransition { 1087 let blur_fade_id = ui.id().with(("blur_fade", url)); 1088 1089 let cur_alpha = { 1090 PulseAlpha::new(ui.ctx(), blur_fade_id, 0, 255) 1091 .start_max_alpha() 1092 .with_speed(0.3) 1093 .animate() 1094 }; 1095 1096 let img = image_texture.get_image(); 1097 1098 let blur_img = blur_img.tint(fade_color(cur_alpha)); 1099 1100 let alloc_size = image_texture.scaled_size; 1101 1102 let (rect, _) = ui.allocate_exact_size(alloc_size, egui::Sense::hover()); 1103 1104 img.paint_at(ui, rect); 1105 blur_img.paint_at(ui, rect); 1106 1107 cur_alpha == 0 1108 } 1109 1110 fn get_blur_transition_state(ctx: &Context, url: &str) -> BlurTransitionState { 1111 let shimmer_id = BLUR_SHIMMER_ID(url); 1112 1113 let max_alpha = 255.0; 1114 let cur_shimmer_alpha = ctx.animate_value_with_time(shimmer_id, max_alpha, 0.3); 1115 if cur_shimmer_alpha == max_alpha { 1116 BlurTransitionState::FadingBlur 1117 } else { 1118 let cur_alpha = (cur_shimmer_alpha).clamp(0.0, max_alpha) as u8; 1119 BlurTransitionState::StoppingShimmer { cur_alpha } 1120 } 1121 } 1122 1123 enum BlurTransitionState { 1124 StoppingShimmer { cur_alpha: u8 }, 1125 FadingBlur, 1126 }