[fractal/fractal-next] room-history: Show audio messages in timeline



commit 3079b7faca97ec41d54b07af380421fee2a31562
Author: Kévin Commaille <zecakeh tedomum fr>
Date:   Thu Feb 3 11:36:50 2022 +0100

    room-history: Show audio messages in timeline

 data/resources/resources.gresource.xml             |   2 +
 data/resources/ui/components-audio-player.ui       |  10 +
 data/resources/ui/content-message-audio.ui         |  45 ++++
 po/POTFILES.in                                     |   1 +
 src/components/audio_player.rs                     | 109 ++++++++
 src/components/mod.rs                              |   2 +
 .../content/room_history/message_row/audio.rs      | 275 +++++++++++++++++++++
 .../content/room_history/message_row/mod.rs        |  18 +-
 8 files changed, 459 insertions(+), 3 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index c8d2868df..9ccb23230 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -16,6 +16,7 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="account-settings.ui">ui/account-settings.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="add-account-row.ui">ui/add-account-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="avatar-with-selection.ui">ui/avatar-with-selection.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="components-audio-player.ui">ui/components-audio-player.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="components-auth-dialog.ui">ui/components-auth-dialog.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="components-avatar.ui">ui/components-avatar.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="components-loading-listbox-row.ui">ui/components-loading-listbox-row.ui</file>
@@ -33,6 +34,7 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-item.ui">ui/content-member-item.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-page.ui">ui/content-member-page.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-row.ui">ui/content-member-row.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-audio.ui">ui/content-message-audio.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-file.ui">ui/content-message-file.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-media.ui">ui/content-message-media.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-reaction-list.ui">ui/content-message-reaction-list.ui</file>
diff --git a/data/resources/ui/components-audio-player.ui b/data/resources/ui/components-audio-player.ui
new file mode 100644
index 000000000..f28d91233
--- /dev/null
+++ b/data/resources/ui/components-audio-player.ui
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ComponentsAudioPlayer" parent="AdwBin">
+    <child>
+      <object class="GtkMediaControls">
+        <property name="media-stream" bind-source="ComponentsAudioPlayer" bind-property="media-file" 
bind-flags="sync-create"/>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-message-audio.ui b/data/resources/ui/content-message-audio.ui
new file mode 100644
index 000000000..55108544a
--- /dev/null
+++ b/data/resources/ui/content-message-audio.ui
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentMessageAudio" parent="AdwBin">
+    <child>
+      <object class="GtkBox">
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="GtkBox">
+            <property name="margin-top">6</property>
+            <property name="spacing">6</property>
+            <child>
+              <object class="GtkImage">
+                <property name="visible" bind-source="ContentMessageAudio" bind-property="compact" 
bind-flags="sync-create"/>
+                <property name="icon-name">audio-x-generic-symbolic</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkLabel">
+                <property name="ellipsize">end</property>
+                <property name="xalign">0.0</property>
+                <property name="hexpand">true</property>
+                <property name="label" bind-source="ContentMessageAudio" bind-property="body" 
bind-flags="sync-create"/>
+              </object>
+            </child>
+            <child type="end">
+              <object class="GtkSpinner" id="state_spinner">
+                <property name="spinning">true</property>
+              </object>
+            </child>
+            <child type="end">
+              <object class="GtkImage" id="state_error">
+                <property name="icon-name">dialog-error-symbolic</property>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child>
+          <object class="ComponentsAudioPlayer" id="player">
+            <property name="visible" bind-source="ContentMessageAudio" bind-property="compact" 
bind-flags="sync-create|invert-boolean"/>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/po/POTFILES.in b/po/POTFILES.in
index a32604cbd..fcb9078a5 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -45,6 +45,7 @@ src/session/content/explore/public_room_row.rs
 src/session/content/room_details/member_page/mod.rs
 src/session/content/room_details/mod.rs
 src/session/content/room_history/item_row.rs
+src/session/content/room_history/message_row/audio.rs
 src/session/content/room_history/message_row/media.rs
 src/session/content/room_history/message_row/mod.rs
 src/session/content/room_history/state_row/creation.rs
diff --git a/src/components/audio_player.rs b/src/components/audio_player.rs
new file mode 100644
index 000000000..ff67d0110
--- /dev/null
+++ b/src/components/audio_player.rs
@@ -0,0 +1,109 @@
+use adw::subclass::prelude::*;
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod imp {
+    use std::cell::RefCell;
+
+    use glib::subclass::InitializingObject;
+    use once_cell::sync::Lazy;
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/components-audio-player.ui")]
+    pub struct AudioPlayer {
+        /// The media file to play.
+        pub media_file: RefCell<Option<gtk::MediaFile>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for AudioPlayer {
+        const NAME: &'static str = "ComponentsAudioPlayer";
+        type Type = super::AudioPlayer;
+        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 AudioPlayer {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpecObject::new(
+                    "media-file",
+                    "Media File",
+                    "The media file to play",
+                    gtk::MediaFile::static_type(),
+                    glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                )]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "media-file" => {
+                    obj.set_media_file(value.get().unwrap());
+                }
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "media-file" => obj.media_file().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl WidgetImpl for AudioPlayer {}
+
+    impl BinImpl for AudioPlayer {}
+}
+
+glib::wrapper! {
+    /// A widget displaying a video media file.
+    pub struct AudioPlayer(ObjectSubclass<imp::AudioPlayer>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl AudioPlayer {
+    /// Create a new audio player.
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create AudioPlayer")
+    }
+
+    /// The media file that is playing.
+    pub fn media_file(&self) -> Option<gtk::MediaFile> {
+        self.imp().media_file.borrow().clone()
+    }
+
+    /// Set the media_file to play.
+    pub fn set_media_file(&self, media_file: Option<gtk::MediaFile>) {
+        if self.media_file() == media_file {
+            return;
+        }
+
+        self.imp().media_file.replace(media_file);
+        self.notify("media-file");
+    }
+}
+
+impl Default for AudioPlayer {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/components/mod.rs b/src/components/mod.rs
index ba7c9f5e5..8e7df87cf 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -1,3 +1,4 @@
+mod audio_player;
 mod auth_dialog;
 mod avatar;
 mod badge;
@@ -14,6 +15,7 @@ mod video_player;
 mod video_player_renderer;
 
 pub use self::{
+    audio_player::AudioPlayer,
     auth_dialog::{AuthData, AuthDialog},
     avatar::Avatar,
     badge::Badge,
diff --git a/src/session/content/room_history/message_row/audio.rs 
b/src/session/content/room_history/message_row/audio.rs
new file mode 100644
index 000000000..e2ed8e87e
--- /dev/null
+++ b/src/session/content/room_history/message_row/audio.rs
@@ -0,0 +1,275 @@
+use adw::{prelude::*, subclass::prelude::*};
+use gettextrs::gettext;
+use gtk::{
+    gio,
+    glib::{self, clone},
+    subclass::prelude::*,
+    CompositeTemplate,
+};
+use log::warn;
+use matrix_sdk::{media::MediaEventContent, ruma::events::room::message::AudioMessageEventContent};
+
+use super::media::MediaState;
+use crate::{components::AudioPlayer, session::Session, spawn, spawn_tokio, utils::media_type_uid};
+
+mod imp {
+    use std::cell::{Cell, RefCell};
+
+    use glib::subclass::InitializingObject;
+    use once_cell::sync::Lazy;
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-message-audio.ui")]
+    pub struct MessageAudio {
+        /// The body of the audio message.
+        pub body: RefCell<Option<String>>,
+        /// The state of the audio file.
+        pub state: Cell<MediaState>,
+        /// Whether to display this audio message in a compact format.
+        pub compact: Cell<bool>,
+        #[template_child]
+        pub player: TemplateChild<AudioPlayer>,
+        #[template_child]
+        pub state_spinner: TemplateChild<gtk::Spinner>,
+        #[template_child]
+        pub state_error: TemplateChild<gtk::Image>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MessageAudio {
+        const NAME: &'static str = "ContentMessageAudio";
+        type Type = super::MessageAudio;
+        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 MessageAudio {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpecString::new(
+                        "body",
+                        "Body",
+                        "The body of the audio message",
+                        None,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpecEnum::new(
+                        "state",
+                        "State",
+                        "The state of the audio file",
+                        MediaState::static_type(),
+                        MediaState::default() as i32,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
+                    glib::ParamSpecBoolean::new(
+                        "compact",
+                        "Compact",
+                        "Whether to display this audio message in a compact format",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "state" => obj.set_state(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "body" => obj.body().to_value(),
+                "state" => obj.state().to_value(),
+                "compact" => obj.compact().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl WidgetImpl for MessageAudio {}
+
+    impl BinImpl for MessageAudio {}
+}
+
+glib::wrapper! {
+    /// A widget displaying an audio message in the timeline.
+    pub struct MessageAudio(ObjectSubclass<imp::MessageAudio>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl MessageAudio {
+    /// Create a new audio message.
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create MessageAudio")
+    }
+
+    /// The body of the audio message.
+    pub fn body(&self) -> Option<String> {
+        self.imp().body.borrow().to_owned()
+    }
+
+    /// Set the body of the audio message.
+    fn set_body(&self, body: Option<String>) {
+        if self.body() == body {
+            return;
+        }
+
+        self.imp().body.replace(body);
+        self.notify("body");
+    }
+
+    /// Whether to display this audio message in a compact format.
+    pub fn compact(&self) -> bool {
+        self.imp().compact.get()
+    }
+
+    /// Set the compact format of this audio message.
+    fn set_compact(&self, compact: bool) {
+        self.imp().compact.set(compact);
+
+        if compact {
+            self.remove_css_class("osd");
+            self.remove_css_class("toolbar");
+        } else {
+            self.add_css_class("osd");
+            self.add_css_class("toolbar");
+        }
+
+        self.notify("compact");
+    }
+
+    /// The state of the audio file.
+    pub fn state(&self) -> MediaState {
+        self.imp().state.get()
+    }
+
+    /// Set the state of the audio file.
+    fn set_state(&self, state: MediaState) {
+        let priv_ = self.imp();
+
+        if self.state() == state {
+            return;
+        }
+
+        match state {
+            MediaState::Loading | MediaState::Initial => {
+                priv_.state_spinner.set_visible(true);
+                priv_.state_error.set_visible(false);
+            }
+            MediaState::Ready => {
+                priv_.state_spinner.set_visible(false);
+                priv_.state_error.set_visible(false);
+            }
+            MediaState::Error => {
+                priv_.state_spinner.set_visible(false);
+                priv_.state_error.set_visible(true);
+            }
+        }
+
+        priv_.state.set(state);
+        self.notify("state");
+    }
+
+    /// Convenience method to set the state to `Error` with the given error
+    /// message.
+    fn set_error(&self, error: String) {
+        self.set_state(MediaState::Error);
+        self.imp().state_error.set_tooltip_text(Some(&error));
+    }
+
+    /// Display the given `audio` message.
+    pub fn audio(&self, audio: AudioMessageEventContent, session: &Session, compact: bool) {
+        self.set_body(Some(audio.body.clone()));
+
+        self.set_compact(compact);
+        if compact {
+            self.set_state(MediaState::Ready);
+            return;
+        }
+
+        self.set_state(MediaState::Loading);
+
+        let mut path = glib::tmp_dir();
+        path.push(media_type_uid(audio.file()));
+        let file = gio::File::for_path(path);
+
+        if file.query_exists(gio::Cancellable::NONE) {
+            self.display_file(file);
+            return;
+        }
+
+        let client = session.client();
+        let handle = spawn_tokio!(async move { client.get_file(audio, true).await });
+
+        spawn!(
+            glib::PRIORITY_LOW,
+            clone!(@weak self as obj => async move {
+                match handle.await.unwrap() {
+                    Ok(Some(data)) => {
+                        // The GStreamer backend doesn't work with input streams so
+                        // we need to store the file.
+                        // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062
+                        file.replace_contents(
+                            &data,
+                            None,
+                            false,
+                            gio::FileCreateFlags::REPLACE_DESTINATION,
+                            gio::Cancellable::NONE,
+                        )
+                        .unwrap();
+                        obj.display_file(file);
+                    }
+                    Ok(None) => {
+                        warn!("Could not retrieve invalid audio file");
+                        obj.set_error(gettext("Could not retrieve audio file"));
+                    }
+                    Err(error) => {
+                        warn!("Could not retrieve audio file: {}", error);
+                        obj.set_error(gettext("Could not retrieve audio file"));
+                    }
+                }
+            })
+        );
+    }
+
+    fn display_file(&self, file: gio::File) {
+        let media_file = gtk::MediaFile::for_file(&file);
+
+        media_file.connect_error_notify(clone!(@weak self as obj => move |media_file| {
+            if let Some(error) = media_file.error() {
+                warn!("Error reading audio file: {}", error);
+                obj.set_error(gettext("Error reading audio file"));
+            }
+        }));
+
+        self.imp().player.set_media_file(Some(media_file));
+        self.set_state(MediaState::Ready);
+    }
+}
+
+impl Default for MessageAudio {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/session/content/room_history/message_row/mod.rs 
b/src/session/content/room_history/message_row/mod.rs
index ca3848e75..b08d0c112 100644
--- a/src/session/content/room_history/message_row/mod.rs
+++ b/src/session/content/room_history/message_row/mod.rs
@@ -1,3 +1,4 @@
+mod audio;
 mod file;
 mod media;
 mod reaction;
@@ -20,8 +21,8 @@ use matrix_sdk::ruma::events::{
 };
 
 use self::{
-    file::MessageFile, media::MessageMedia, reaction_list::MessageReactionList,
-    reply::MessageReply, text::MessageText,
+    audio::MessageAudio, file::MessageFile, media::MessageMedia,
+    reaction_list::MessageReactionList, reply::MessageReply, text::MessageText,
 };
 use crate::{
     components::Avatar, prelude::*, session::room::Event, spawn, utils::filename_for_mime,
@@ -245,7 +246,18 @@ fn build_content(parent: &adw::Bin, event: &Event, compact: bool) {
                 message.msgtype
             };
             match msgtype {
-                MessageType::Audio(_message) => {}
+                MessageType::Audio(message) => {
+                    let child = if let Some(Ok(child)) =
+                        parent.child().map(|w| w.downcast::<MessageAudio>())
+                    {
+                        child
+                    } else {
+                        let child = MessageAudio::new();
+                        parent.set_child(Some(&child));
+                        child
+                    };
+                    child.audio(message, &event.room().session(), compact);
+                }
                 MessageType::Emote(message) => {
                     let child = if let Some(Ok(child)) =
                         parent.child().map(|w| w.downcast::<MessageText>())


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]