notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }