notedeck

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

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 }