diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index c8d2868d..9ccb2323 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -16,6 +16,7 @@ ui/account-settings.ui ui/add-account-row.ui ui/avatar-with-selection.ui + ui/components-audio-player.ui ui/components-auth-dialog.ui ui/components-avatar.ui ui/components-loading-listbox-row.ui @@ -33,6 +34,7 @@ ui/content-member-item.ui ui/content-member-page.ui ui/content-member-row.ui + ui/content-message-audio.ui ui/content-message-file.ui ui/content-message-media.ui ui/content-message-reaction-list.ui diff --git a/data/resources/ui/components-audio-player.ui b/data/resources/ui/components-audio-player.ui new file mode 100644 index 00000000..f28d9123 --- /dev/null +++ b/data/resources/ui/components-audio-player.ui @@ -0,0 +1,10 @@ + + + + diff --git a/data/resources/ui/content-message-audio.ui b/data/resources/ui/content-message-audio.ui new file mode 100644 index 00000000..55108544 --- /dev/null +++ b/data/resources/ui/content-message-audio.ui @@ -0,0 +1,45 @@ + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index a32604cb..fcb9078a 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 00000000..ff67d011 --- /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>, + } + + #[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) { + obj.init_template(); + } + } + + impl ObjectImpl for AudioPlayer { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = 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) + @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 { + self.imp().media_file.borrow().clone() + } + + /// Set the media_file to play. + pub fn set_media_file(&self, media_file: Option) { + 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 ba7c9f5e..8e7df87c 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 00000000..e2ed8e87 --- /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>, + /// The state of the audio file. + pub state: Cell, + /// Whether to display this audio message in a compact format. + pub compact: Cell, + #[template_child] + pub player: TemplateChild, + #[template_child] + pub state_spinner: TemplateChild, + #[template_child] + pub state_error: TemplateChild, + } + + #[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) { + obj.init_template(); + } + } + + impl ObjectImpl for MessageAudio { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = 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) + @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 { + self.imp().body.borrow().to_owned() + } + + /// Set the body of the audio message. + fn set_body(&self, body: Option) { + 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 ca3848e7..b08d0c11 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::()) + { + 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::())