Browse Source

room-history: Add support for Blurhashes

It is displayed while the media is being downloaded, or instead of the
preview if the preview is hidden.
merge-requests/2003/head
Kévin Commaille 11 months ago
parent
commit
5ea7f82c2f
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 7
      Cargo.lock
  2. 1
      Cargo.toml
  3. 31
      data/resources/stylesheet/_room_history.scss
  4. 1
      data/resources/stylesheet/_vendor.scss
  5. 2
      src/session/view/content/room_history/message_row/content.rs
  6. 79
      src/session/view/content/room_history/message_row/visual_media.rs
  7. 8
      src/session/view/content/room_history/message_row/visual_media.ui
  8. 14
      src/utils/matrix/media_message.rs
  9. 132
      src/utils/media/image/mod.rs
  10. 27
      src/utils/media/video.rs

7
Cargo.lock generated

@ -450,6 +450,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "blurhash"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc"
[[package]]
name = "bs58"
version = "0.5.1"
@ -1213,6 +1219,7 @@ dependencies = [
"aperture",
"ashpd",
"assert_matches2",
"blurhash",
"cfg-if",
"diff",
"djb_hash",

1
Cargo.toml

@ -24,6 +24,7 @@ codegen-units = 16
# Please keep dependencies sorted.
[dependencies]
blurhash = "0.2"
cfg-if = "1"
diff = "0.1"
djb_hash = "0.1"

31
data/resources/stylesheet/_room_history.scss

@ -270,6 +270,37 @@ state-group-row.room-history-row {
background-color: var(--border-color);
}
.instructions {
padding: 12px;
border-radius: vendor.$menu_radius;
}
// Copied from .osd button style in https://gitlab.gnome.org/GNOME/libadwaita/-/blob/main/src/stylesheet/widgets/_buttons.scss
&.has-placeholder {
.instructions {
color: vendor.$osd_fg_color;
background-color: rgb(0 0 0 / 65%);
@if config.$contrast == 'high' {
box-shadow: 0 0 0 1px currentColor;
}
}
&:hover {
.instructions {
color: white;
background-color: color-mix(in srgb, black calc(0.85 * 65%), currentColor calc(0.15 * 65%));
}
}
&:active {
.instructions {
color: white;
background-color: color-mix(in srgb, black calc(0.75 * 65%), currentColor calc(0.25 * 65%));
}
}
}
.spinner {
min-width: 32px;
min-height: 32px;

1
data/resources/stylesheet/_vendor.scss

@ -10,6 +10,7 @@ $active_color: color-mix(in srgb, currentColor 16%, transparent);
$selected_color: color-mix(in srgb, currentColor 10%, transparent);
$selected_hover_color: color-mix(in srgb, currentColor 13%, transparent);
$selected_active_color: color-mix(in srgb, currentColor 19%, transparent);
$osd_fg_color: RGB(255 255 255 / 90%);
// https://gitlab.gnome.org/GNOME/libadwaita/-/blob/1.6.1/src/stylesheet/widgets/_buttons.scss
$button_color: color-mix(in srgb, currentColor 10%, transparent);

2
src/session/view/content/room_history/message_row/content.rs

@ -489,7 +489,7 @@ pub(crate) struct MessageCacheKey {
impl MessageCacheKey {
/// Whether the given new `MessageCacheKey` should trigger a reload of the
/// mmessage compared to this one.
/// message compared to this one.
pub(super) fn should_reload(&self, new: &MessageCacheKey) -> bool {
if new.is_edited {
return true;

79
src/session/view/content/room_history/message_row/visual_media.rs

@ -31,6 +31,8 @@ const MAX_COMPACT_DIMENSIONS: FrameDimensions = FrameDimensions {
width: 75,
height: 50,
};
/// The name of the empty stack page.
const EMPTY_PAGE: &str = "empty";
/// The name of the placeholder stack page.
const PLACEHOLDER_PAGE: &str = "placeholder";
/// The name of the media stack page.
@ -87,6 +89,11 @@ mod imp {
#[property(get)]
activatable: Cell<bool>,
gesture_click: glib::WeakRef<gtk::GestureClick>,
/// The current placeholder, if any.
///
/// This is the low-quality image shown while the content is loading or
/// when the preview is hidden.
placeholder: RefCell<Option<gtk::Picture>>,
/// The current video file, if any.
file: RefCell<Option<File>>,
paintable_animation_ref: RefCell<Option<CountedRef>>,
@ -274,7 +281,13 @@ mod imp {
self.error.set_visible(state == LoadingState::Error);
let visible_page = match state {
LoadingState::Initial | LoadingState::Loading => Some(PLACEHOLDER_PAGE),
LoadingState::Initial | LoadingState::Loading => {
if self.placeholder.borrow().is_some() {
Some(PLACEHOLDER_PAGE)
} else {
Some(EMPTY_PAGE)
}
}
LoadingState::Ready => Some(MEDIA_PAGE),
LoadingState::Error => None,
};
@ -373,6 +386,29 @@ mod imp {
should_reload
}
/// Set the texture to use as a placeholder.
fn set_placeholder(&self, texture: Option<gdk::Texture>) {
if let Some(texture) = texture {
let placeholder = self.placeholder.borrow().clone();
let placeholder = if let Some(placeholder) = placeholder {
placeholder
} else {
let placeholder = gtk::Picture::new();
self.placeholder.replace(Some(placeholder.clone()));
self.stack.add_named(&placeholder, Some(PLACEHOLDER_PAGE));
self.overlay.add_css_class("has-placeholder");
placeholder
};
placeholder.set_paintable(Some(&texture));
} else if let Some(placeholder) = self.placeholder.take() {
self.stack.remove(&placeholder);
self.overlay.remove_css_class("has-placeholder");
}
self.update_visible_page();
}
/// Set the visual media message to display.
pub(super) fn set_media_message(
&self,
@ -381,6 +417,8 @@ mod imp {
format: ContentFormat,
cache_key: MessageCacheKey,
) {
self.media_message.replace(Some(media_message));
if !self.set_cache_key(cache_key) {
// We do not need to reload the media.
return;
@ -419,13 +457,47 @@ mod imp {
.replace(Some(session_settings_handler));
self.room.set(Some(room));
self.media_message.replace(Some(media_message));
self.load_placeholder();
self.update_accessible_label();
self.update_preview_instructions_icon();
self.update_media();
}
/// Load the placeholder.
fn load_placeholder(&self) {
let Some((original_dimensions, blurhash)) = self
.media_message
.borrow()
.as_ref()
.and_then(|media_message| media_message.dimensions().zip(media_message.blurhash()))
else {
// Nothing to load.
self.set_placeholder(None);
return;
};
let max_dimensions = FrameDimensions::thumbnail_max_dimensions(1);
let dimensions =
original_dimensions.scale_to_fit(max_dimensions, gtk::ContentFit::ScaleDown);
let cache_key = self.cache_key.borrow().clone();
spawn!(clone!(
#[weak(rename_to = imp)]
self,
async move {
let placeholder_texture = blurhash.into_texture(dimensions).await;
if imp.cache_key.borrow().should_reload(&cache_key) {
// The media has changed while this was loading, drop the placeholder.
return;
}
imp.set_placeholder(placeholder_texture);
}
));
}
/// Update the accessible label for the current state.
fn update_accessible_label(&self) {
let Some((filename, visual_media_type)) =
@ -457,6 +529,8 @@ mod imp {
};
self.obj()
.update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
self.overlay.set_tooltip_text(Some(&filename));
}
/// Update the preview instructions icon for the current state.
@ -594,7 +668,6 @@ mod imp {
};
child.set_paintable(Some(&gdk::Paintable::from(image)));
child.set_tooltip_text(Some(&media_message.filename()));
if matches!(&media_message, VisualMediaMessage::Sticker(_)) {
self.overlay.remove_css_class("opaque-bg");
} else {

8
src/session/view/content/room_history/message_row/visual_media.ui

@ -18,7 +18,7 @@
<property name="interpolate-size">True</property>
<child>
<object class="GtkStackPage">
<property name="name">placeholder</property>
<property name="name">empty</property>
<property name="child">
<object class="AdwBin"/>
</property>
@ -32,14 +32,14 @@
<property name="spacing">6</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="instructions"/>
</style>
<layout>
<property name="measure">true</property>
</layout>
<child>
<object class="GtkImage" id="preview_instructions_icon">
<style>
<class name="dimmed"/>
</style>
<property name="icon-size">large</property>
<property name="accessible-role">presentation</property>
</object>

14
src/utils/matrix/media_message.rs

@ -17,8 +17,8 @@ use crate::{
utils::{
media::{
image::{
Image, ImageError, ImageRequestPriority, ImageSource, ThumbnailDownloader,
ThumbnailSettings,
Blurhash, Image, ImageError, ImageRequestPriority, ImageSource,
ThumbnailDownloader, ThumbnailSettings,
},
FrameDimensions, MediaFileError,
},
@ -262,6 +262,16 @@ impl VisualMediaMessage {
}
}
/// Get the Blurhash of the media, if any.
pub(crate) fn blurhash(&self) -> Option<Blurhash> {
match self {
Self::Image(image_content) => image_content.info.as_deref()?.blurhash.clone(),
Self::Sticker(sticker_content) => sticker_content.info.blurhash.clone(),
Self::Video(video_content) => video_content.info.as_deref()?.blurhash.clone(),
}
.map(Blurhash)
}
/// Fetch a thumbnail of the media with the given client and thumbnail
/// settings.
///

132
src/utils/media/image/mod.rs

@ -1,9 +1,9 @@
//! Collection of methods for images.
use std::{error::Error, fmt, str::FromStr, sync::Arc};
use std::{cmp::Ordering, error::Error, fmt, str::FromStr, sync::Arc};
use gettextrs::gettext;
use gtk::{gdk, gio, graphene, gsk, prelude::*};
use gtk::{gdk, gio, glib, graphene, gsk, prelude::*};
use matrix_sdk::{
attachment::{BaseImageInfo, Thumbnail},
media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},
@ -27,7 +27,9 @@ mod queue;
pub(crate) use queue::{ImageRequestPriority, IMAGE_QUEUE};
use super::{FrameDimensions, MediaFileError};
use crate::{components::AnimatedImagePaintable, spawn_tokio, utils::File, DISABLE_GLYCIN_SANDBOX};
use crate::{
components::AnimatedImagePaintable, spawn_tokio, utils::File, DISABLE_GLYCIN_SANDBOX, RUNTIME,
};
/// The maximum dimensions of a thumbnail in the timeline.
pub(crate) const THUMBNAIL_MAX_DIMENSIONS: FrameDimensions = FrameDimensions {
@ -182,7 +184,7 @@ impl ImageInfoLoader {
return (BaseImageInfo::default(), None);
};
let info = frame.info();
let mut info = frame.info();
// Generate the same thumbnail dimensions as we will need in the timeline.
let scale_factor = widget.scale_factor();
@ -195,6 +197,8 @@ impl ImageInfoLoader {
.is_some_and(|d| d.needs_thumbnail(max_thumbnail_dimensions))
{
// It is not worth it to generate a thumbnail.
info.blurhash = frame.generate_blurhash().map(|blurhash| blurhash.0);
return (info, None);
}
@ -208,7 +212,10 @@ impl ImageInfoLoader {
return (info, None);
};
let thumbnail = frame.generate_thumbnail(scale_factor, &renderer);
let (thumbnail, blurhash) = frame
.generate_thumbnail_and_blurhash(scale_factor, &renderer)
.unzip();
info.blurhash = blurhash.map(|blurhash| blurhash.0);
(info, thumbnail)
}
@ -266,20 +273,44 @@ impl Frame {
}
}
/// Generate a thumbnail of this frame.
fn generate_thumbnail(self, scale_factor: i32, renderer: &gsk::Renderer) -> Option<Thumbnail> {
/// Generate a Blurhash of this frame.
fn generate_blurhash(self) -> Option<Blurhash> {
let texture = match self {
Self::Glycin(frame) => frame.texture(),
Self::Texture(texture) => texture,
};
let blurhash = Blurhash::with_texture(&texture);
if blurhash.is_none() {
warn!("Could not generate Blurhash from GdkTexture");
}
blurhash
}
/// Generate a thumbnail and a Blurhash of this frame.
///
/// We use the thumbnail to compute the blurhash, which should be less
/// expensive than using the original frame.
fn generate_thumbnail_and_blurhash(
self,
scale_factor: i32,
renderer: &gsk::Renderer,
) -> Option<(Thumbnail, Blurhash)> {
let texture = match self {
Self::Glycin(frame) => frame.texture(),
Self::Texture(texture) => texture,
};
let thumbnail = TextureThumbnailer(texture).generate_thumbnail(scale_factor, renderer);
let thumbnail_blurhash =
TextureThumbnailer(texture).generate_thumbnail_and_blurhash(scale_factor, renderer);
if thumbnail.is_none() {
warn!("Could not generate thumbnail from GdkTexture");
if thumbnail_blurhash.is_none() {
warn!("Could not generate thumbnail and Blurhash from GdkTexture");
}
thumbnail
thumbnail_blurhash
}
}
@ -413,16 +444,21 @@ impl TextureThumbnailer {
}
/// Generate the thumbnail for the given scale factor, with the given
/// `GskRenderer`.
pub(super) fn generate_thumbnail(
/// `GskRenderer`, and a Blurhash.
///
/// We use the thumbnail to compute the blurhash, which should be less
/// expensive than using the original texture.
pub(super) fn generate_thumbnail_and_blurhash(
self,
scale_factor: i32,
renderer: &gsk::Renderer,
) -> Option<Thumbnail> {
) -> Option<(Thumbnail, Blurhash)> {
let max_thumbnail_dimensions = FrameDimensions::thumbnail_max_dimensions(scale_factor);
let thumbnail = self.downscale_texture_if_needed(max_thumbnail_dimensions, renderer)?;
let dimensions = FrameDimensions::with_texture(&thumbnail)?;
let blurhash = Blurhash::with_texture(&thumbnail)?;
let (downloader_format, webp_layout) =
Self::texture_format_to_thumbnail_format(thumbnail.format())?;
@ -437,13 +473,79 @@ impl TextureThumbnailer {
let content_type =
mime::Mime::from_str(WEBP_CONTENT_TYPE).expect("content type should be valid");
Some(Thumbnail {
let thumbnail = Thumbnail {
data,
content_type,
width: dimensions.width.into(),
height: dimensions.height.into(),
size,
};
Some((thumbnail, blurhash))
}
}
/// A [Blurhash].
///
/// [Blurhash]: https://blurha.sh/
#[derive(Debug, Clone)]
pub(crate) struct Blurhash(pub(crate) String);
impl Blurhash {
/// Try to compute the Blurhash for the given `GdkTexture`.
pub(super) fn with_texture(texture: &gdk::Texture) -> Option<Self> {
let dimensions = FrameDimensions::with_texture(texture)?;
let mut downloader = gdk::TextureDownloader::new(texture);
downloader.set_format(gdk::MemoryFormat::R8g8b8a8);
let (data, _) = downloader.download_bytes();
let (components_x, components_y) = match dimensions.width.cmp(&dimensions.height) {
Ordering::Less => (3, 4),
Ordering::Equal => (3, 3),
Ordering::Greater => (4, 3),
};
let hash = blurhash::encode(
components_x,
components_y,
dimensions.width,
dimensions.height,
&data,
)
.inspect_err(|error| {
warn!("Could not encode Blurhash: {error}");
})
.ok()?;
Some(Self(hash))
}
/// Try to convert this Blurhash to a `GdkTexture` with the given
/// dimensions.
pub(crate) async fn into_texture(self, dimensions: FrameDimensions) -> Option<gdk::Texture> {
// Because it can take some time, spawn on a separate thread.
RUNTIME
.spawn_blocking(move || {
let data = blurhash::decode(&self.0, dimensions.width, dimensions.height, 1.0)
.inspect_err(|error| {
warn!("Could not decode Blurhash: {error}");
})
.ok()?;
Some(
gdk::MemoryTexture::new(
dimensions.width.try_into().ok()?,
dimensions.height.try_into().ok()?,
gdk::MemoryFormat::R8g8b8a8,
&glib::Bytes::from_owned(data),
4 * dimensions.width as usize,
)
.upcast(),
)
})
.await
.expect("task was not aborted")
}
}

27
src/utils/media/video.rs

@ -9,7 +9,10 @@ use gtk::{gdk, gio, glib, glib::clone, prelude::*};
use matrix_sdk::attachment::{BaseVideoInfo, Thumbnail};
use tracing::{error, warn};
use super::{image::TextureThumbnailer, load_gstreamer_media_info};
use super::{
image::{Blurhash, TextureThumbnailer},
load_gstreamer_media_info,
};
/// A channel sender to send the result of a video thumbnail.
type ThumbnailResultSender = oneshot::Sender<Result<gdk::Texture, ()>>;
@ -37,13 +40,19 @@ pub(crate) async fn load_video_info(
info.height = Some(stream_info.height().into());
}
let thumbnail = generate_video_thumbnail(file, widget.upcast_ref()).await;
let (thumbnail, blurhash) = generate_video_thumbnail_and_blurhash(file, widget.upcast_ref())
.await
.unzip();
info.blurhash = blurhash.map(|blurhash| blurhash.0);
(info, thumbnail)
}
/// Generate a thumbnail for the video in the given file.
async fn generate_video_thumbnail(file: &gio::File, widget: &gtk::Widget) -> Option<Thumbnail> {
/// Generate a thumbnail and a Blurhash for the video in the given file.
async fn generate_video_thumbnail_and_blurhash(
file: &gio::File,
widget: &gtk::Widget,
) -> Option<(Thumbnail, Blurhash)> {
let Some(renderer) = widget
.root()
.and_downcast::<gtk::Window>()
@ -119,14 +128,14 @@ async fn generate_video_thumbnail(file: &gio::File, widget: &gtk::Widget) -> Opt
bus.set_flushing(true);
let texture = texture.ok()?.ok()?;
let thumbnail =
TextureThumbnailer(texture).generate_thumbnail(widget.scale_factor(), &renderer);
let thumbnail_blurhash = TextureThumbnailer(texture)
.generate_thumbnail_and_blurhash(widget.scale_factor(), &renderer);
if thumbnail.is_none() {
warn!("Could not generate thumbnail from GdkTexture");
if thumbnail_blurhash.is_none() {
warn!("Could not generate thumbnail and Blurhash from GdkTexture");
}
thumbnail
thumbnail_blurhash
}
/// Create a pipeline to get a thumbnail of the first frame.

Loading…
Cancel
Save