Browse Source

room-history: Show video messages in the timeline

merge-requests/1327/merge
Kévin Commaille 4 years ago committed by Julian Sparber
parent
commit
8193be8e2c
  1. 1
      build-aux/org.gnome.FractalNext.Devel.json
  2. 1
      data/resources/resources.gresource.xml
  3. 5
      data/resources/style.css
  4. 32
      data/resources/ui/components-video-player.ui
  5. 4
      data/resources/ui/media-viewer.ui
  6. 3
      po/POTFILES.in
  7. 2
      src/components/mod.rs
  8. 84
      src/components/video_player.rs
  9. 2
      src/meson.build
  10. 48
      src/session/content/room_history/message_row/image.rs
  11. 8
      src/session/content/room_history/message_row/mod.rs
  12. 233
      src/session/content/room_history/message_row/video.rs
  13. 63
      src/session/media_viewer.rs
  14. 12
      src/session/room/event.rs
  15. 17
      src/utils.rs

1
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",

1
data/resources/resources.gresource.xml

@ -54,6 +54,7 @@
<file compressed="true" preprocess="xml-stripblanks" alias="verification-emoji.ui">ui/verification-emoji.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="incoming-verification.ui">ui/incoming-verification.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="qr-code-scanner.ui">ui/qr-code-scanner.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="components-video-player.ui">ui/components-video-player.ui</file>
<file compressed="true">style.css</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/send-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/welcome.svg</file>

5
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;

32
data/resources/ui/components-video-player.ui

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ComponentsVideoPlayer" parent="AdwBin">
<style>
<class name="thumbnail"/>
</style>
<property name="overflow">hidden</property>
<child>
<object class="GtkOverlay">
<child>
<object class="GtkPicture" id="video"/>
</child>
<child type="overlay">
<object class="GtkLabel" id="timestamp">
<style>
<class name="osd"/>
<class name="timestamp"/>
</style>
<property name="halign">1</property>
<property name="valign">1</property>
<property name="margin-start">5</property>
<property name="margin-top">5</property>
<property name="label">00:00</property>
<layout>
<property name="measure">1</property>
</layout>
</object>
</child>
</object>
</child>
</template>
</interface>

4
data/resources/ui/media-viewer.ui

@ -24,7 +24,7 @@
<child type="start">
<object class="GtkButton" id="back">
<property name="icon-name">go-previous-symbolic</property>
<property name="action-name">session.show-content</property>
<property name="action-name">media-viewer.close</property>
</object>
</child>
<child type="end">
@ -44,6 +44,8 @@
<child>
<object class="AdwBin" id="media">
<property name="halign">center</property>
<property name="valign">center</property>
<property name="vexpand">true</property>
</object>
</child>
</object>

3
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

2
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;

84
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<Option<gtk::MediaFile>>,
#[template_child]
pub video: TemplateChild<gtk::Picture>,
#[template_child]
pub timestamp: TemplateChild<gtk::Label>,
}
#[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<Self>) {
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<imp::VideoPlayer>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl VideoPlayer {
pub fn new(media_file: &gtk::MediaFile) -> Self {
let self_: Self = glib::Object::new(&[]).expect("Failed to create VideoPlayer");
self_.build(media_file);
self_
}
pub fn build(&self, media_file: &gtk::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: &gtk::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)
}
}

2
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',

48
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<u16> = 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<u16> = ui.try_into().ok();
u
})
.and_then(|u| {
let i: i32 = u.into();
Some(i)
})
.unwrap_or(-1);
(width, height)
}

8
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)

233
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<i32>,
/// The intended display height of the video.
pub height: Cell<i32>,
}
#[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<Vec<glib::ParamSpec>> = 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<imp::MessageVideo>)
@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));
}
}
})
);
}
}

63
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::<gtk::Video>().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<Self>) {
@ -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));
}
}
})
);
}
_ => {}
}
}

12
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,
}

17
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<T: IsA<Object>>(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<UInt>) -> i32 {
u.and_then(|ui| {
let u: Option<u16> = ui.try_into().ok();
u
})
.and_then(|u| {
let i: i32 = u.into();
Some(i)
})
.unwrap_or(-1)
}

Loading…
Cancel
Save