fractal/components/media/audio_player/
mod.rs1use 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 source: RefCell<Option<AudioPlayerSource>>,
55 media_file: RefCell<Option<gtk::MediaFile>>,
57 file: RefCell<Option<File>>,
61 #[property(get, set = Self::set_standalone, explicit_notify)]
65 standalone: Cell<bool>,
66 narrow: Cell<bool>,
68 #[property(get, builder(LoadingState::default()))]
70 state: Cell<LoadingState>,
71 duration: Cell<Duration>,
73 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 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 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 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 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 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 fn set_error(&self, error: &str) {
231 self.set_state(LoadingState::Error);
232 self.error_img.set_tooltip_text(Some(error));
233 }
234
235 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 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 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 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 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 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 async fn set_file(&self, file: File) {
319 let notifier = MEDIA_FILE_NOTIFIER.clone();
320 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 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 media_file.set_playing(true);
370
371 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 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 imp.media_notifier.take().is_some() {
417 imp.clear();
418 }
419 }
420 });
421
422 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 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 if let Some(notifier) = self.media_notifier.take() {
451 notifier.notify();
452 }
453 }
454
455 #[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 #[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 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 pub fn new() -> Self {
509 glib::Object::new()
510 }
511
512 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#[derive(Debug, Clone)]
526pub(crate) enum AudioPlayerSource {
527 File(gio::File),
529 Message(AudioPlayerMessage),
531}
532
533impl AudioPlayerSource {
534 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 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 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 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#[derive(Debug, Clone)]
594pub(crate) struct AudioPlayerMessage {
595 pub(crate) message: MediaMessage,
597 pub(crate) session: glib::WeakRef<Session>,
599 pub(crate) cache_key: MessageCacheKey,
605}
606
607impl AudioPlayerMessage {
608 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}