Skip to main content

fractal/components/media/audio_player/
mod.rs

1use std::time::Duration;
2
3use adw::{prelude::*, subclass::prelude::*};
4use gettextrs::gettext;
5use gtk::{gio, glib, glib::clone};
6use matrix_sdk::attachment::BaseAudioInfo;
7use tracing::warn;
8
9mod waveform;
10mod waveform_paintable;
11
12use self::waveform::Waveform;
13use crate::{
14    MEDIA_FILE_NOTIFIER,
15    session::Session,
16    spawn,
17    utils::{
18        File, LoadingState, OneshotNotifier,
19        matrix::{AudioMessageExt, MediaMessage, MessageCacheKey},
20        media::{self, MediaFileError, audio::load_audio_info},
21    },
22};
23
24mod imp {
25    use std::cell::{Cell, RefCell};
26
27    use glib::subclass::InitializingObject;
28
29    use super::*;
30
31    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
32    #[template(resource = "/org/gnome/Fractal/ui/components/media/audio_player/mod.ui")]
33    #[properties(wrapper_type = super::AudioPlayer)]
34    pub struct AudioPlayer {
35        #[template_child]
36        position_label: TemplateChild<gtk::Label>,
37        #[template_child]
38        waveform: TemplateChild<Waveform>,
39        #[template_child]
40        spinner: TemplateChild<adw::Spinner>,
41        #[template_child]
42        error_img: TemplateChild<gtk::Image>,
43        #[template_child]
44        remaining_label: TemplateChild<gtk::Label>,
45        #[template_child]
46        bottom_box: TemplateChild<gtk::Box>,
47        #[template_child]
48        play_button: TemplateChild<gtk::Button>,
49        #[template_child]
50        name_label: TemplateChild<gtk::Label>,
51        #[template_child]
52        position_label_narrow: TemplateChild<gtk::Label>,
53        /// The source to play.
54        source: RefCell<Option<AudioPlayerSource>>,
55        /// The API used to play the audio file.
56        media_file: RefCell<Option<gtk::MediaFile>>,
57        /// The audio file that is currently loaded.
58        ///
59        /// This is used to keep a strong reference to the temporary file.
60        file: RefCell<Option<File>>,
61        /// Whether the audio player is the main widget of the current view.
62        ///
63        /// This hides the filename and centers the play button.
64        #[property(get, set = Self::set_standalone, explicit_notify)]
65        standalone: Cell<bool>,
66        /// Whether we are in narrow mode.
67        narrow: Cell<bool>,
68        /// The state of the audio file.
69        #[property(get, builder(LoadingState::default()))]
70        state: Cell<LoadingState>,
71        /// The duration of the audio stream, in microseconds.
72        duration: Cell<Duration>,
73        /// The notifier for the media file, if any.
74        media_notifier: RefCell<Option<OneshotNotifier>>,
75    }
76
77    #[glib::object_subclass]
78    impl ObjectSubclass for AudioPlayer {
79        const NAME: &'static str = "AudioPlayer";
80        type Type = super::AudioPlayer;
81        type ParentType = adw::BreakpointBin;
82
83        fn class_init(klass: &mut Self::Class) {
84            Self::bind_template(klass);
85            Self::bind_template_callbacks(klass);
86
87            klass.set_css_name("audio-player");
88        }
89
90        fn instance_init(obj: &InitializingObject<Self>) {
91            obj.init_template();
92        }
93    }
94
95    #[glib::derived_properties]
96    impl ObjectImpl for AudioPlayer {
97        fn constructed(&self) {
98            self.parent_constructed();
99
100            let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length(
101                adw::BreakpointConditionLengthType::MaxWidth,
102                360.0,
103                adw::LengthUnit::Px,
104            ));
105            breakpoint.connect_apply(clone!(
106                #[weak(rename_to = imp)]
107                self,
108                move |_| {
109                    imp.set_narrow(true);
110                }
111            ));
112            breakpoint.connect_unapply(clone!(
113                #[weak(rename_to = imp)]
114                self,
115                move |_| {
116                    imp.set_narrow(false);
117                }
118            ));
119            self.obj().add_breakpoint(breakpoint);
120
121            self.waveform.connect_position_notify(clone!(
122                #[weak(rename_to = imp)]
123                self,
124                move |_| {
125                    imp.update_position_labels();
126                }
127            ));
128
129            self.update_play_button();
130        }
131
132        fn dispose(&self) {
133            self.clear();
134        }
135    }
136
137    impl WidgetImpl for AudioPlayer {}
138    impl BreakpointBinImpl for AudioPlayer {}
139
140    #[gtk::template_callbacks]
141    impl AudioPlayer {
142        /// Set the source to play.
143        pub(super) fn set_source(&self, source: Option<AudioPlayerSource>) {
144            let should_reload = source.as_ref().is_none_or(|source| {
145                self.source
146                    .borrow()
147                    .as_ref()
148                    .is_none_or(|old_source| old_source.should_reload(source))
149            });
150
151            if should_reload {
152                self.clear();
153            }
154
155            self.source.replace(source);
156
157            if should_reload {
158                spawn!(clone!(
159                    #[weak(rename_to = imp)]
160                    self,
161                    async move {
162                        imp.load_source_info().await;
163                    }
164                ));
165
166                self.update_source_name();
167            }
168
169            self.update_play_button();
170        }
171
172        /// Set whether the audio player is the main widget of the current view.
173        fn set_standalone(&self, standalone: bool) {
174            if self.standalone.get() == standalone {
175                return;
176            }
177
178            self.standalone.set(standalone);
179            self.update_layout();
180            self.obj().notify_standalone();
181        }
182
183        /// Set whether we are in narrow mode.
184        fn set_narrow(&self, narrow: bool) {
185            if self.narrow.get() == narrow {
186                return;
187            }
188
189            self.narrow.set(narrow);
190            self.update_layout();
191        }
192
193        /// Update the layout for the current state.
194        fn update_layout(&self) {
195            let standalone = self.standalone.get();
196            let narrow = self.narrow.get();
197
198            self.position_label.set_visible(!narrow);
199            self.remaining_label.set_visible(!narrow);
200            self.name_label.set_visible(!standalone);
201            self.position_label_narrow
202                .set_visible(narrow && !standalone);
203
204            self.bottom_box.set_halign(if standalone {
205                gtk::Align::Center
206            } else {
207                gtk::Align::Fill
208            });
209        }
210
211        /// Set the state of the audio stream.
212        fn set_state(&self, state: LoadingState) {
213            if self.state.get() == state {
214                return;
215            }
216
217            self.waveform
218                .set_sensitive(matches!(state, LoadingState::Initial | LoadingState::Ready));
219            self.spinner
220                .set_visible(matches!(state, LoadingState::Loading));
221            self.error_img
222                .set_visible(matches!(state, LoadingState::Error));
223
224            self.state.set(state);
225            self.obj().notify_state();
226        }
227
228        /// Convenience method to set the state to `Error` with the given error
229        /// message.
230        fn set_error(&self, error: &str) {
231            self.set_state(LoadingState::Error);
232            self.error_img.set_tooltip_text(Some(error));
233        }
234
235        /// Set the duration of the audio stream.
236        fn set_duration(&self, duration: Duration) {
237            if self.duration.get() == duration {
238                return;
239            }
240
241            self.duration.set(duration);
242            self.update_duration_labels_width();
243            self.update_position_labels();
244        }
245
246        /// Update the width of labels presenting a duration.
247        fn update_duration_labels_width(&self) {
248            let has_hours = self.duration.get().as_secs() > 60 * 60;
249            let time_width = if has_hours { 8 } else { 5 };
250
251            self.position_label.set_width_chars(time_width);
252            self.remaining_label.set_width_chars(time_width + 1);
253        }
254
255        /// Load the information of the current source.
256        async fn load_source_info(&self) {
257            let Some(source) = self.source.borrow().clone() else {
258                self.set_duration(Duration::default());
259                self.waveform.set_waveform(vec![]);
260                return;
261            };
262
263            let info = source.info().await;
264            self.set_duration(info.duration.unwrap_or_default());
265            self.waveform
266                .set_waveform(info.waveform.unwrap_or_default());
267        }
268
269        /// Update the name of the source.
270        fn update_source_name(&self) {
271            let name = self
272                .source
273                .borrow()
274                .as_ref()
275                .map(AudioPlayerSource::name)
276                .unwrap_or_default();
277
278            self.name_label.set_label(&name);
279        }
280
281        /// Update the labels displaying the position in the audio stream.
282        fn update_position_labels(&self) {
283            let duration = self.duration.get();
284            let position = self.waveform.position();
285
286            let position = duration.mul_f32(position);
287            let remaining = duration.saturating_sub(position);
288
289            self.position_label
290                .set_label(&media::time_to_label(&position));
291            self.remaining_label
292                .set_label(&format!("-{}", media::time_to_label(&remaining)));
293        }
294
295        /// Update the play button.
296        fn update_play_button(&self) {
297            let is_playing = self
298                .media_file
299                .borrow()
300                .as_ref()
301                .is_some_and(MediaStreamExt::is_playing);
302
303            let (icon_name, tooltip) = if is_playing {
304                ("pause-symbolic", gettext("Pause"))
305            } else {
306                ("play-symbolic", gettext("Play"))
307            };
308
309            self.play_button.set_icon_name(icon_name);
310            self.play_button.set_tooltip_text(Some(&tooltip));
311
312            if is_playing {
313                self.set_state(LoadingState::Ready);
314            }
315        }
316
317        /// Set the media file to play.
318        async fn set_file(&self, file: File) {
319            let notifier = MEDIA_FILE_NOTIFIER.clone();
320            // Send a notification to make sure that other media files are dropped before
321            // playing this one.
322            notifier.notify();
323
324            let media_file = gtk::MediaFile::new();
325
326            media_file.connect_duration_notify(clone!(
327                #[weak(rename_to = imp)]
328                self,
329                move |media_file| {
330                    let duration = Duration::from_micros(media_file.duration().cast_unsigned());
331                    imp.set_duration(duration);
332                }
333            ));
334            media_file.connect_timestamp_notify(clone!(
335                #[weak(rename_to = imp)]
336                self,
337                move |media_file| {
338                    let mut duration = media_file.duration();
339                    let timestamp = media_file.timestamp();
340
341                    // The duration should always be bigger than the timestamp, but let's be safe.
342                    if duration != 0 && timestamp > duration {
343                        duration = timestamp;
344                    }
345
346                    let position = if duration == 0 {
347                        0.0
348                    } else {
349                        (timestamp as f64 / duration as f64) as f32
350                    };
351
352                    imp.waveform.set_position(position);
353                }
354            ));
355            media_file.connect_playing_notify(clone!(
356                #[weak(rename_to = imp)]
357                self,
358                move |_| {
359                    imp.update_play_button();
360                }
361            ));
362            media_file.connect_prepared_notify(clone!(
363                #[weak(rename_to = imp)]
364                self,
365                move |media_file| {
366                    if media_file.is_prepared() {
367                        // The media file should only become prepared after the user clicked play,
368                        // so start playing it.
369                        media_file.set_playing(true);
370
371                        // If the user selected a position while we didn't have a media file, seek
372                        // to it.
373                        let position = imp.waveform.position();
374                        if position > 0.0 {
375                            media_file
376                                .seek((media_file.duration() as f64 * f64::from(position)) as i64);
377                        }
378                    }
379                }
380            ));
381            media_file.connect_error_notify(clone!(
382                #[weak(rename_to = imp)]
383                self,
384                move |media_file| {
385                    if let Some(error) = media_file.error() {
386                        warn!("Could not read audio file: {error}");
387                        imp.set_error(&gettext("Error reading audio file"));
388                    }
389                }
390            ));
391
392            let gfile = file.as_gfile();
393            media_file.set_file(Some(&gfile));
394            self.media_file.replace(Some(media_file));
395            self.file.replace(Some(file));
396
397            // We use a shared notifier to make sure that only a single media file can be
398            // loaded at a time.
399            // We cannot keep a strong reference while `.await`ing, or it will prevent from
400            // destroying the player.
401            let weak_imp = self.downgrade();
402            spawn!(async move {
403                let receiver = if let Some(imp) = weak_imp.upgrade() {
404                    let receiver = notifier.listen();
405                    imp.media_notifier.replace(Some(notifier));
406                    receiver
407                } else {
408                    return;
409                };
410
411                receiver.await;
412
413                if let Some(imp) = weak_imp.upgrade() {
414                    // If we still have a copy of the notifier now, it means that this was called
415                    // from outside this instance, so we need to clear it.
416                    if imp.media_notifier.take().is_some() {
417                        imp.clear();
418                    }
419                }
420            });
421
422            // Reload the waveform if we got it from a message, because we cannot trust the
423            // sender.
424            if self
425                .source
426                .borrow()
427                .as_ref()
428                .is_some_and(|source| matches!(source, AudioPlayerSource::Message(_)))
429                && let Some(waveform) = load_audio_info(&gfile).await.waveform
430            {
431                self.waveform.set_waveform(waveform);
432            }
433        }
434
435        /// Clear the media file, if any.
436        fn clear(&self) {
437            self.set_state(LoadingState::Initial);
438
439            if let Some(media_file) = self.media_file.take() {
440                if media_file.is_playing() {
441                    media_file.set_playing(false);
442                }
443
444                media_file.clear();
445            }
446
447            self.file.take();
448
449            // Send a notification to drop the spawned task.
450            if let Some(notifier) = self.media_notifier.take() {
451                notifier.notify();
452            }
453        }
454
455        /// Play or pause the media.
456        #[template_callback]
457        async fn toggle_playing(&self) {
458            if let Some(media_file) = self.media_file.borrow().clone() {
459                media_file.set_playing(!media_file.is_playing());
460                return;
461            }
462
463            let Some(source) = self.source.borrow().clone() else {
464                return;
465            };
466
467            self.set_state(LoadingState::Loading);
468
469            match source.to_file().await {
470                Ok(file) => {
471                    self.set_file(file).await;
472                }
473                Err(error) => {
474                    warn!("Could not retrieve audio file: {error}");
475                    self.set_error(&gettext("Could not retrieve audio file"));
476                }
477            }
478        }
479
480        /// Seek to the given relative position.
481        ///
482        /// The position must be a value between 0 and 1.
483        #[template_callback]
484        fn seek(&self, new_position: f32) {
485            if let Some(media_file) = self.media_file.borrow().clone() {
486                let duration = self.duration.get();
487
488                if !duration.is_zero() {
489                    let timestamp = duration.as_micros() as f64 * f64::from(new_position);
490                    media_file.seek(timestamp as i64);
491                }
492            } else {
493                self.waveform.set_position(new_position);
494            }
495        }
496    }
497}
498
499glib::wrapper! {
500    /// A widget displaying a video media file.
501    pub struct AudioPlayer(ObjectSubclass<imp::AudioPlayer>)
502        @extends gtk::Widget, adw::BreakpointBin,
503        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
504}
505
506impl AudioPlayer {
507    /// Create a new audio player.
508    pub fn new() -> Self {
509        glib::Object::new()
510    }
511
512    /// Set the source to play.
513    pub(crate) fn set_source(&self, source: Option<AudioPlayerSource>) {
514        self.imp().set_source(source);
515    }
516}
517
518impl Default for AudioPlayer {
519    fn default() -> Self {
520        Self::new()
521    }
522}
523
524/// The possible sources accepted by the audio player.
525#[derive(Debug, Clone)]
526pub(crate) enum AudioPlayerSource {
527    /// An audio file.
528    File(gio::File),
529    /// An audio message.
530    Message(AudioPlayerMessage),
531}
532
533impl AudioPlayerSource {
534    /// Get the name of the source.
535    fn name(&self) -> String {
536        match self {
537            Self::File(file) => file
538                .path()
539                .and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned()))
540                .unwrap_or_default(),
541            Self::Message(message) => message.message.display_name(),
542        }
543    }
544
545    /// Whether the source should be reloaded because it has changed.
546    fn should_reload(&self, new_source: &Self) -> bool {
547        match (self, new_source) {
548            (Self::File(file), Self::File(new_file)) => file != new_file,
549            (Self::Message(message), Self::Message(new_message)) => {
550                message.cache_key.should_reload(&new_message.cache_key)
551            }
552            _ => true,
553        }
554    }
555
556    /// Get the information of this source.
557    async fn info(&self) -> BaseAudioInfo {
558        match self {
559            Self::File(file) => load_audio_info(file).await,
560            Self::Message(message) => {
561                let mut info = BaseAudioInfo::default();
562
563                if let MediaMessage::Audio(content) = &message.message {
564                    info.duration = content.info.as_deref().and_then(|info| info.duration);
565                    info.waveform = content.normalized_waveform();
566                }
567
568                info
569            }
570        }
571    }
572
573    /// Get a file to play this source.
574    async fn to_file(&self) -> Result<File, MediaFileError> {
575        match self {
576            Self::File(file) => Ok(file.clone().into()),
577            Self::Message(message) => {
578                let Some(session) = message.session.upgrade() else {
579                    return Err(MediaFileError::NoSession);
580                };
581
582                message
583                    .message
584                    .clone()
585                    .into_tmp_file(&session.client())
586                    .await
587            }
588        }
589    }
590}
591
592/// The data required to play an audio message.
593#[derive(Debug, Clone)]
594pub(crate) struct AudioPlayerMessage {
595    /// The audio message.
596    pub(crate) message: MediaMessage,
597    /// The session that will be used to load the file.
598    pub(crate) session: glib::WeakRef<Session>,
599    /// The cache key for the audio message.
600    ///
601    /// The audio is only reloaded if the cache key changes. This is to
602    /// avoid reloading the audio when the local echo is updated to a remote
603    /// echo.
604    pub(crate) cache_key: MessageCacheKey,
605}
606
607impl AudioPlayerMessage {
608    /// Construct a new `AudioPlayerMessage`.
609    pub(crate) fn new(
610        message: MediaMessage,
611        session: &Session,
612        cache_key: MessageCacheKey,
613    ) -> Self {
614        let session_weak = glib::WeakRef::new();
615        session_weak.set(Some(session));
616
617        Self {
618            message,
619            session: session_weak,
620            cache_key,
621        }
622    }
623}