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