From 8193be8e2c8352a4c8e777880f7ee3f358a9b3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 9 Dec 2021 12:06:11 +0100 Subject: [PATCH] room-history: Show video messages in the timeline --- build-aux/org.gnome.FractalNext.Devel.json | 1 + data/resources/resources.gresource.xml | 1 + data/resources/style.css | 5 + data/resources/ui/components-video-player.ui | 32 +++ data/resources/ui/media-viewer.ui | 4 +- po/POTFILES.in | 3 + src/components/mod.rs | 2 + src/components/video_player.rs | 84 +++++++ src/meson.build | 2 + .../content/room_history/message_row/image.rs | 48 +--- .../content/room_history/message_row/mod.rs | 8 +- .../content/room_history/message_row/video.rs | 233 ++++++++++++++++++ src/session/media_viewer.rs | 63 ++++- src/session/room/event.rs | 12 +- src/utils.rs | 17 ++ 15 files changed, 470 insertions(+), 45 deletions(-) create mode 100644 data/resources/ui/components-video-player.ui create mode 100644 src/components/video_player.rs create mode 100644 src/session/content/room_history/message_row/video.rs diff --git a/build-aux/org.gnome.FractalNext.Devel.json b/build-aux/org.gnome.FractalNext.Devel.json index 6c3c8f98..5fbafef2 100644 --- a/build-aux/org.gnome.FractalNext.Devel.json +++ b/build-aux/org.gnome.FractalNext.Devel.json @@ -11,6 +11,7 @@ "finish-args" : [ "--socket=fallback-x11", "--socket=wayland", + "--socket=pulseaudio", "--share=network", "--share=ipc", "--device=dri", diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 8840f519..e9d7f1b8 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -54,6 +54,7 @@ ui/verification-emoji.ui ui/incoming-verification.ui ui/qr-code-scanner.ui + ui/components-video-player.ui style.css icons/scalable/actions/send-symbolic.svg icons/scalable/status/welcome.svg diff --git a/data/resources/style.css b/data/resources/style.css index 23b12be7..6b4a32cb 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -214,6 +214,11 @@ headerbar.flat { border-radius: 6px; } +.room-history .event-content .thumbnail .timestamp { + border-radius: 4px; + padding: 2px 5px; +} + .room-history .event-content .quote { border-left: 2px solid @theme_selected_bg_color; padding-left: 6px; diff --git a/data/resources/ui/components-video-player.ui b/data/resources/ui/components-video-player.ui new file mode 100644 index 00000000..85dfc25d --- /dev/null +++ b/data/resources/ui/components-video-player.ui @@ -0,0 +1,32 @@ + + + + diff --git a/data/resources/ui/media-viewer.ui b/data/resources/ui/media-viewer.ui index a3bccafb..3102ddc3 100644 --- a/data/resources/ui/media-viewer.ui +++ b/data/resources/ui/media-viewer.ui @@ -24,7 +24,7 @@ go-previous-symbolic - session.show-content + media-viewer.close @@ -44,6 +44,8 @@ center + center + true diff --git a/po/POTFILES.in b/po/POTFILES.in index ff8fc197..9bba3ee6 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -12,6 +12,7 @@ data/resources/ui/account-settings-devices-page.ui data/resources/ui/components-auth-dialog.ui data/resources/ui/components-avatar.ui data/resources/ui/components-loading-listbox-row.ui +data/resources/ui/components-video-player.ui data/resources/ui/avatar-with-selection.ui data/resources/ui/content-divider-row.ui data/resources/ui/content-item.ui @@ -58,6 +59,7 @@ src/components/in_app_notification.rs src/components/mod.rs src/components/spinner_button.rs src/components/pill.rs +src/components/video_player.rs src/contrib/mod.rs src/contrib/qr_code.rs src/error.rs @@ -89,6 +91,7 @@ src/session/content/room_history/message_row/file.rs src/session/content/room_history/message_row/image.rs src/session/content/room_history/message_row/mod.rs src/session/content/room_history/message_row/text.rs +src/session/content/room_history/message_row/video.rs src/session/content/room_history/mod.rs src/session/content/room_history/state_row.rs src/session/content/room_history/state_row/mod.rs diff --git a/src/components/mod.rs b/src/components/mod.rs index 5ea7aa17..5c005a97 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -9,6 +9,7 @@ mod loading_listbox_row; mod pill; mod room_title; mod spinner_button; +mod video_player; pub use self::auth_dialog::{AuthData, AuthDialog}; pub use self::avatar::Avatar; @@ -21,3 +22,4 @@ pub use self::loading_listbox_row::LoadingListBoxRow; pub use self::pill::Pill; pub use self::room_title::RoomTitle; pub use self::spinner_button::SpinnerButton; +pub use self::video_player::VideoPlayer; diff --git a/src/components/video_player.rs b/src/components/video_player.rs new file mode 100644 index 00000000..7cd04b67 --- /dev/null +++ b/src/components/video_player.rs @@ -0,0 +1,84 @@ +use adw::subclass::prelude::*; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/components-video-player.ui")] + pub struct VideoPlayer { + pub media_file: RefCell>, + #[template_child] + pub video: TemplateChild, + #[template_child] + pub timestamp: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for VideoPlayer { + const NAME: &'static str = "ComponentsVideoPlayer"; + type Type = super::VideoPlayer; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for VideoPlayer {} + + impl WidgetImpl for VideoPlayer {} + + impl BinImpl for VideoPlayer {} +} + +glib::wrapper! { + /// A widget displaying a video media file. + pub struct VideoPlayer(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl VideoPlayer { + pub fn new(media_file: >k::MediaFile) -> Self { + let self_: Self = glib::Object::new(&[]).expect("Failed to create VideoPlayer"); + self_.build(media_file); + self_ + } + + pub fn build(&self, media_file: >k::MediaFile) { + let priv_ = imp::VideoPlayer::from_instance(self); + + priv_.video.set_paintable(Some(media_file)); + let timestamp = &*priv_.timestamp; + media_file.connect_duration_notify(clone!(@weak timestamp => move |media_file| { + timestamp.set_label(&duration(media_file)); + })); + } +} + +/// Get the duration of `media_file` as a `String`. +fn duration(media_file: >k::MediaFile) -> String { + let mut time = media_file.duration() / 1000000; + + let sec = time % 60; + time = time - sec; + let min = (time % (60 * 60)) / 60; + time = time - (min * 60); + let hour = time / (60 * 60); + + if hour > 0 { + // FIXME: Find how to localize this. + // hour:minutes:seconds + format!("{}:{:02}:{:02}", hour, min, sec) + } else { + // FIXME: Find how to localize this. + // minutes:seconds + format!("{:02}:{:02}", min, sec) + } +} diff --git a/src/meson.build b/src/meson.build index 5b1a394d..a4e93ffb 100644 --- a/src/meson.build +++ b/src/meson.build @@ -37,6 +37,7 @@ sources = files( 'components/in_app_notification.rs', 'components/spinner_button.rs', 'components/loading_listbox_row.rs', + 'components/video_player.rs', 'config.rs', 'error.rs', 'main.rs', @@ -66,6 +67,7 @@ sources = files( 'session/content/room_history/message_row/image.rs', 'session/content/room_history/message_row/mod.rs', 'session/content/room_history/message_row/text.rs', + 'session/content/room_history/message_row/video.rs', 'session/content/room_history/mod.rs', 'session/content/room_history/state_row/creation.rs', 'session/content/room_history/state_row/mod.rs', diff --git a/src/session/content/room_history/message_row/image.rs b/src/session/content/room_history/message_row/image.rs index 29f5496e..8a46e784 100644 --- a/src/session/content/room_history/message_row/image.rs +++ b/src/session/content/room_history/message_row/image.rs @@ -1,5 +1,3 @@ -use std::convert::TryInto; - use adw::{prelude::BinExt, subclass::prelude::*}; use gettextrs::gettext; use gtk::{ @@ -15,15 +13,12 @@ use matrix_sdk::{ media::{MediaEventContent, MediaThumbnailSize}, ruma::{ api::client::r0::media::get_content_thumbnail::Method, - events::{ - room::{message::ImageMessageEventContent, ImageInfo}, - sticker::StickerEventContent, - }, + events::{room::message::ImageMessageEventContent, sticker::StickerEventContent}, uint, }, }; -use crate::{session::Session, spawn, spawn_tokio}; +use crate::{session::Session, spawn, spawn_tokio, utils::uint_to_i32}; #[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::GEnum)] #[repr(u32)] @@ -222,7 +217,9 @@ glib::wrapper! { impl MessageImage { pub fn image(image: ImageMessageEventContent, session: &Session) -> Self { - let (width, height) = get_width_height(image.info.as_deref()); + let info = image.info.as_deref(); + let width = uint_to_i32(info.and_then(|info| info.width)); + let height = uint_to_i32(info.and_then(|info| info.height)); let self_: Self = glib::Object::new(&[("width", &width), ("height", &height)]) .expect("Failed to create MessageImage"); @@ -231,7 +228,9 @@ impl MessageImage { } pub fn sticker(sticker: StickerEventContent, session: &Session) -> Self { - let (width, height) = get_width_height(Some(&sticker.info)); + let info = &sticker.info; + let width = uint_to_i32(info.width); + let height = uint_to_i32(info.height); let self_: Self = glib::Object::new(&[ ("media-type", &MediaType::Sticker), @@ -319,34 +318,3 @@ impl MessageImage { ); } } - -/// Gets the width and height of the full image in info. -/// -/// Returns a (width, height) tuple with either value set to -1 if it wasn't found. -fn get_width_height(info: Option<&ImageInfo>) -> (i32, i32) { - let width = info - .and_then(|info| info.width) - .and_then(|ui| { - let u: Option = ui.try_into().ok(); - u - }) - .and_then(|u| { - let i: i32 = u.into(); - Some(i) - }) - .unwrap_or(-1); - - let height = info - .and_then(|info| info.height) - .and_then(|ui| { - let u: Option = ui.try_into().ok(); - u - }) - .and_then(|u| { - let i: i32 = u.into(); - Some(i) - }) - .unwrap_or(-1); - - (width, height) -} diff --git a/src/session/content/room_history/message_row/mod.rs b/src/session/content/room_history/message_row/mod.rs index 9e88e210..d39e9b63 100644 --- a/src/session/content/room_history/message_row/mod.rs +++ b/src/session/content/room_history/message_row/mod.rs @@ -1,6 +1,7 @@ mod file; mod image; mod text; +mod video; use crate::components::Avatar; use adw::{prelude::*, subclass::prelude::*}; @@ -15,7 +16,7 @@ use matrix_sdk::ruma::events::{ AnyMessageEventContent, AnySyncMessageEvent, AnySyncRoomEvent, }; -use self::{file::MessageFile, image::MessageImage, text::MessageText}; +use self::{file::MessageFile, image::MessageImage, text::MessageText, video::MessageVideo}; use crate::prelude::*; use crate::session::room::Event; @@ -285,7 +286,10 @@ impl MessageRow { let child = MessageText::markup(message.formatted, message.body); priv_.content.set_child(Some(&child)); } - MessageType::Video(_message) => {} + MessageType::Video(message) => { + let child = MessageVideo::new(message, &event.room().session()); + priv_.content.set_child(Some(&child)); + } MessageType::VerificationRequest(_message) => {} _ => { warn!("Event not supported: {:?}", msgtype) diff --git a/src/session/content/room_history/message_row/video.rs b/src/session/content/room_history/message_row/video.rs new file mode 100644 index 00000000..d88c1058 --- /dev/null +++ b/src/session/content/room_history/message_row/video.rs @@ -0,0 +1,233 @@ +use adw::{prelude::BinExt, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gio, + glib::{self, clone}, + prelude::*, + subclass::prelude::*, +}; +use log::warn; +use matrix_sdk::ruma::events::room::message::VideoMessageEventContent; + +use crate::{ + components::VideoPlayer, + session::Session, + spawn, spawn_tokio, + utils::{cache_dir, uint_to_i32}, +}; + +mod imp { + use std::cell::Cell; + + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default)] + pub struct MessageVideo { + /// The intended display width of the video. + pub width: Cell, + /// The intended display height of the video. + pub height: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageVideo { + const NAME: &'static str = "ContentMessageVideo"; + type Type = super::MessageVideo; + type ParentType = adw::Bin; + } + + impl ObjectImpl for MessageVideo { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_int( + "width", + "Width", + "The intended display width of the video", + -1, + i32::MAX, + -1, + glib::ParamFlags::WRITABLE, + ), + glib::ParamSpec::new_int( + "height", + "Height", + "The intended display height of the video", + -1, + i32::MAX, + -1, + glib::ParamFlags::WRITABLE, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + _obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "width" => { + self.width.set(value.get().unwrap()); + } + "height" => { + self.height.set(value.get().unwrap()); + } + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + // We need to control the value returned by `measure`. + obj.set_layout_manager(gtk::NONE_LAYOUT_MANAGER); + } + } + + impl WidgetImpl for MessageVideo { + fn measure( + &self, + obj: &Self::Type, + orientation: gtk::Orientation, + for_size: i32, + ) -> (i32, i32, i32, i32) { + match obj.child() { + Some(child) => { + let original_width = self.width.get(); + let original_height = self.height.get(); + + if orientation == gtk::Orientation::Vertical { + // We limit the width to 320 pixels. + let width = for_size.min(320); + + let nat_height = if original_height > 0 && original_width > 0 { + // We don't want the paintable to be upscaled. + let width = width.min(original_width); + width * original_height / original_width + } else { + // Get the natural height of the data. + child.measure(orientation, width).1 + }; + + // We limit the height to 240 pixels. + let height = nat_height.min(240); + (0, height, -1, -1) + } else { + // We limit the height to 240 pixels. + let height = for_size.min(240); + + let nat_width = if original_height > 0 && original_width > 0 { + // We don't want the paintable to be upscaled. + let height = height.min(original_height); + height * original_width / original_height + } else { + // Get the natural height of the data. + child.measure(orientation, height).1 + }; + + // We limit the width to 320 pixels. + let width = nat_width.min(320); + (0, width, -1, -1) + } + } + None => (0, 0, -1, -1), + } + } + + fn request_mode(&self, _obj: &Self::Type) -> gtk::SizeRequestMode { + gtk::SizeRequestMode::HeightForWidth + } + + fn size_allocate(&self, obj: &Self::Type, _width: i32, height: i32, baseline: i32) { + if let Some(child) = obj.child() { + // We need to allocate just enough width to the child so it doesn't expand. + let original_width = self.width.get(); + let original_height = self.height.get(); + let width = if original_height > 0 && original_width > 0 { + height * original_width / original_height + } else { + // Get the natural width of the video data. + child.measure(gtk::Orientation::Horizontal, height).1 + }; + + child.allocate(width, height, baseline, None); + } + } + } + + impl BinImpl for MessageVideo {} +} + +glib::wrapper! { + /// A widget displaying an message's thumbnail. + pub struct MessageVideo(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl MessageVideo { + pub fn new(video: VideoMessageEventContent, session: &Session) -> Self { + let info = video.info.as_deref(); + let width = uint_to_i32(info.and_then(|info| info.width)); + let height = uint_to_i32(info.and_then(|info| info.height)); + + let self_: Self = glib::Object::new(&[("width", &width), ("height", &height)]) + .expect("Failed to create MessageVideo"); + self_.build(video, session); + self_ + } + + fn build(&self, video: VideoMessageEventContent, session: &Session) { + let body = video.body.clone(); + let client = session.client(); + let handle = spawn_tokio!(async move { client.get_file(video, true,).await }); + + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + match handle.await.unwrap() { + Ok(Some(data)) => { + // The GStreamer backend of GtkVideo doesn't work with input streams so + // we need to store the file. + // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062 + let mut path = cache_dir(); + path.push(body); + let file = gio::File::for_path(path); + file.replace_contents( + &data, + None, + false, + gio::FileCreateFlags::REPLACE_DESTINATION, + gio::NONE_CANCELLABLE, + ) + .unwrap(); + let media_file = gtk::MediaFile::for_file(&file); + media_file.set_muted(true); + media_file.connect_prepared_notify(|media_file| media_file.play()); + + let video_player = VideoPlayer::new(&media_file); + + obj.set_child(Some(&video_player)); + } + Ok(None) => { + warn!("Could not retrieve invalid image file"); + let child = gtk::Label::new(Some(&gettext("Could not retrieve image"))); + obj.set_child(Some(&child)); + } + Err(error) => { + warn!("Could not retrieve image file: {}", error); + let child = gtk::Label::new(Some(&gettext("Could not retrieve image"))); + obj.set_child(Some(&child)); + } + } + }) + ); + } +} diff --git a/src/session/media_viewer.rs b/src/session/media_viewer.rs index 28eda93e..9dd65536 100644 --- a/src/session/media_viewer.rs +++ b/src/session/media_viewer.rs @@ -9,7 +9,9 @@ use matrix_sdk::ruma::events::{room::message::MessageType, AnyMessageEventConten use crate::{ components::{ContextMenuBin, ContextMenuBinImpl}, session::room::Event, - spawn, Window, + spawn, + utils::cache_dir, + Window, }; use super::room::EventActions; @@ -45,6 +47,28 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); + + klass.install_action("media-viewer.close", None, move |obj, _, _| { + let priv_ = imp::MediaViewer::from_instance(obj); + if let Some(stream) = priv_ + .media + .child() + .and_then(|w| w.downcast::().ok()) + .and_then(|video| video.media_stream()) + { + if stream.is_playing() { + stream.pause(); + stream.seek(0); + } + } + obj.activate_action("session.show-content", None); + }); + klass.add_binding_action( + gdk::keys::constants::Escape, + gdk::ModifierType::empty(), + "media-viewer.close", + None, + ); } fn instance_init(obj: &InitializingObject) { @@ -264,6 +288,43 @@ impl MediaViewer { }) ); } + MessageType::Video(video) => { + self.set_body(Some(video.body.clone())); + + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + let priv_ = imp::MediaViewer::from_instance(&obj); + + match event.get_media_content().await { + Ok((_, data)) => { + // The GStreamer backend of GtkVideo doesn't work with input streams so + // we need to store the file. + // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062 + let mut path = cache_dir(); + path.push(video.body); + let file = gio::File::for_path(path); + file.replace_contents( + &data, + None, + false, + gio::FileCreateFlags::REPLACE_DESTINATION, + gio::NONE_CANCELLABLE, + ) + .unwrap(); + let child = gtk::Video::builder().file(&file).autoplay(true).build(); + + priv_.media.set_child(Some(&child)); + } + Err(error) => { + warn!("Could not retrieve video file: {}", error); + let child = gtk::Label::new(Some(&gettext("Could not retrieve video"))); + priv_.media.set_child(Some(&child)); + } + } + }) + ); + } _ => {} } } diff --git a/src/session/room/event.rs b/src/session/room/event.rs index 9c63fb78..64141075 100644 --- a/src/session/room/event.rs +++ b/src/session/room/event.rs @@ -516,6 +516,7 @@ impl Event { /// /// - File message (`MessageType::File`). /// - Image message (`MessageType::Image`). + /// - Video message (`MessageType::Video`). /// /// Returns `Ok((filename, binary_content))` on success, `Err` if an error occured while /// fetching the content. Panics on an incompatible event. @@ -535,6 +536,12 @@ impl Event { let data = handle.await.unwrap()?.unwrap(); return Ok((filename, data)); } + MessageType::Video(content) => { + let filename = content.body.clone(); + let handle = spawn_tokio!(async move { client.get_file(content, true).await }); + let data = handle.await.unwrap()?.unwrap(); + return Ok((filename, data)); + } _ => {} }; }; @@ -546,7 +553,10 @@ impl Event { pub fn can_view_media(&self) -> bool { match self.message_content() { Some(AnyMessageEventContent::RoomMessage(message)) => { - matches!(message.msgtype, MessageType::Image(_)) + matches!( + message.msgtype, + MessageType::Image(_) | MessageType::Video(_) + ) } _ => false, } diff --git a/src/utils.rs b/src/utils.rs index e4b336a2..447a0d25 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -58,10 +58,12 @@ macro_rules! spawn_tokio { }; } +use std::convert::TryInto; use std::path::PathBuf; use gtk::gio::{self, prelude::*}; use gtk::glib::{self, Object}; +use matrix_sdk::ruma::UInt; /// Returns an expression looking up the given property on `object`. pub fn prop_expr>(object: &T, prop: &str) -> gtk::Expression { @@ -121,3 +123,18 @@ pub fn cache_dir() -> PathBuf { path } + +/// Converts a `UInt` to `i32`. +/// +/// Returns `-1` if the conversion didn't work. +pub fn uint_to_i32(u: Option) -> i32 { + u.and_then(|ui| { + let u: Option = ui.try_into().ok(); + u + }) + .and_then(|u| { + let i: i32 = u.into(); + Some(i) + }) + .unwrap_or(-1) +}