fractal/session/view/
media_viewer.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{gdk, glib, glib::clone, graphene, CompositeTemplate};
4use ruma::OwnedEventId;
5use tracing::warn;
6
7use crate::{
8    components::{MediaContentViewer, ScaleRevealer},
9    session::model::Room,
10    spawn, toast,
11    utils::matrix::VisualMediaMessage,
12};
13
14/// The duration of the animation to fade the background, in ms.
15const ANIMATION_DURATION: u32 = 250;
16/// The duration of the animation to cancel a swipe, in ms.
17const CANCEL_SWIPE_ANIMATION_DURATION: u32 = 400;
18
19mod imp {
20    use std::{
21        cell::{Cell, OnceCell, RefCell},
22        collections::HashMap,
23    };
24
25    use glib::subclass::InitializingObject;
26
27    use super::*;
28
29    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
30    #[template(resource = "/org/gnome/Fractal/ui/session/view/media_viewer.ui")]
31    #[properties(wrapper_type = super::MediaViewer)]
32    pub struct MediaViewer {
33        #[template_child]
34        toolbar_view: TemplateChild<adw::ToolbarView>,
35        #[template_child]
36        header_bar: TemplateChild<gtk::HeaderBar>,
37        #[template_child]
38        menu: TemplateChild<gtk::MenuButton>,
39        #[template_child]
40        revealer: TemplateChild<ScaleRevealer>,
41        #[template_child]
42        media: TemplateChild<MediaContentViewer>,
43        /// Whether the viewer is fullscreened.
44        #[property(get, set = Self::set_fullscreened, explicit_notify)]
45        fullscreened: Cell<bool>,
46        /// The room containing the media message.
47        #[property(get)]
48        room: glib::WeakRef<Room>,
49        /// The ID of the event containing the media message.
50        event_id: RefCell<Option<OwnedEventId>>,
51        /// The media message to display.
52        message: RefCell<Option<VisualMediaMessage>>,
53        /// The filename of the media.
54        #[property(get)]
55        filename: RefCell<Option<String>>,
56        /// The API to keep track of the animation to fade the background.
57        animation: OnceCell<adw::TimedAnimation>,
58        swipe_tracker: OnceCell<adw::SwipeTracker>,
59        swipe_progress: Cell<f64>,
60        actions_expression_watches: RefCell<HashMap<&'static str, gtk::ExpressionWatch>>,
61    }
62
63    #[glib::object_subclass]
64    impl ObjectSubclass for MediaViewer {
65        const NAME: &'static str = "MediaViewer";
66        type Type = super::MediaViewer;
67        type ParentType = gtk::Widget;
68        type Interfaces = (adw::Swipeable,);
69
70        fn class_init(klass: &mut Self::Class) {
71            Self::bind_template(klass);
72            Self::bind_template_callbacks(klass);
73
74            klass.set_css_name("media-viewer");
75
76            klass.install_action("media-viewer.close", None, |obj, _, _| {
77                obj.imp().close();
78            });
79            klass.add_binding_action(
80                gdk::Key::Escape,
81                gdk::ModifierType::empty(),
82                "media-viewer.close",
83            );
84
85            // Menu actions
86            klass.install_action("media-viewer.copy-image", None, |obj, _, _| {
87                obj.imp().copy_image();
88            });
89
90            klass.install_action_async("media-viewer.save-image", None, |obj, _, _| async move {
91                obj.imp().save_file().await;
92            });
93
94            klass.install_action_async("media-viewer.save-video", None, |obj, _, _| async move {
95                obj.imp().save_file().await;
96            });
97
98            klass.install_action_async("media-viewer.permalink", None, |obj, _, _| async move {
99                obj.imp().copy_permalink().await;
100            });
101        }
102
103        fn instance_init(obj: &InitializingObject<Self>) {
104            obj.init_template();
105        }
106    }
107
108    #[glib::derived_properties]
109    impl ObjectImpl for MediaViewer {
110        fn constructed(&self) {
111            self.parent_constructed();
112            let obj = self.obj();
113
114            self.init_swipe_tracker();
115
116            // Bind `fullscreened` to the window property of the same name.
117            obj.connect_root_notify(|obj| {
118                if let Some(window) = obj.root().and_downcast::<gtk::Window>() {
119                    window
120                        .bind_property("fullscreened", obj, "fullscreened")
121                        .sync_create()
122                        .build();
123                }
124            });
125
126            self.revealer.connect_transition_done(clone!(
127                #[weak]
128                obj,
129                move |revealer| {
130                    if !revealer.reveal_child() {
131                        // Hide the viewer when the hiding transition is done.
132                        obj.set_visible(false);
133                    }
134                }
135            ));
136
137            self.update_menu_actions();
138        }
139
140        fn dispose(&self) {
141            self.toolbar_view.unparent();
142
143            for expr_watch in self.actions_expression_watches.take().values() {
144                expr_watch.unwatch();
145            }
146        }
147    }
148
149    impl WidgetImpl for MediaViewer {
150        fn size_allocate(&self, width: i32, height: i32, baseline: i32) {
151            // Follow the swipe on the y axis.
152            let swipe_y_offset = -f64::from(height) * self.swipe_progress.get();
153            let allocation = gtk::Allocation::new(0, swipe_y_offset as i32, width, height);
154            self.toolbar_view.size_allocate(&allocation, baseline);
155        }
156
157        fn snapshot(&self, snapshot: &gtk::Snapshot) {
158            let obj = self.obj();
159
160            // Compute the progress between the swipe and the animation.
161            let progress = {
162                let swipe_progress = 1.0 - self.swipe_progress.get().abs();
163                let animation_progress = self.animation().value();
164                swipe_progress.min(animation_progress)
165            };
166
167            if progress > 0.0 {
168                // Change the background opacity depending on the progress.
169                let background_color = gdk::RGBA::new(0.0, 0.0, 0.0, 1.0 * progress as f32);
170                let bounds = graphene::Rect::new(0.0, 0.0, obj.width() as f32, obj.height() as f32);
171                snapshot.append_color(&background_color, &bounds);
172            }
173
174            obj.snapshot_child(&*self.toolbar_view, snapshot);
175        }
176    }
177
178    impl SwipeableImpl for MediaViewer {
179        fn cancel_progress(&self) -> f64 {
180            0.0
181        }
182
183        fn distance(&self) -> f64 {
184            self.obj().height().into()
185        }
186
187        fn progress(&self) -> f64 {
188            self.swipe_progress.get()
189        }
190
191        fn snap_points(&self) -> Vec<f64> {
192            vec![-1.0, 0.0, 1.0]
193        }
194
195        fn swipe_area(&self, _: adw::NavigationDirection, _: bool) -> gdk::Rectangle {
196            let obj = self.obj();
197            gdk::Rectangle::new(0, 0, obj.width(), obj.height())
198        }
199    }
200
201    #[gtk::template_callbacks]
202    impl MediaViewer {
203        /// Set whether the viewer is fullscreened.
204        fn set_fullscreened(&self, fullscreened: bool) {
205            if fullscreened == self.fullscreened.get() {
206                return;
207            }
208
209            self.fullscreened.set(fullscreened);
210
211            if fullscreened {
212                // Upscale the media on fullscreen.
213                self.media.set_halign(gtk::Align::Fill);
214                self.toolbar_view
215                    .set_top_bar_style(adw::ToolbarStyle::Raised);
216            } else {
217                self.media.set_halign(gtk::Align::Center);
218                self.toolbar_view.set_top_bar_style(adw::ToolbarStyle::Flat);
219            }
220
221            self.obj().notify_fullscreened();
222        }
223
224        /// Set the media message to display.
225        pub(super) fn set_message(
226            &self,
227            room: &Room,
228            event_id: OwnedEventId,
229            message: VisualMediaMessage,
230        ) {
231            self.room.set(Some(room));
232            self.event_id.replace(Some(event_id));
233            self.set_filename(message.filename());
234            self.message.replace(Some(message));
235
236            self.update_menu_actions();
237            self.media.show_loading();
238
239            spawn!(clone!(
240                #[weak(rename_to = imp)]
241                self,
242                async move {
243                    imp.build().await;
244                }
245            ));
246
247            self.obj().notify_room();
248        }
249
250        /// Set the filename of the media.
251        fn set_filename(&self, filename: String) {
252            if Some(&filename) == self.filename.borrow().as_ref() {
253                return;
254            }
255
256            self.filename.replace(Some(filename));
257            self.obj().notify_filename();
258        }
259
260        /// The API to keep track of the animation to fade the background.
261        fn animation(&self) -> &adw::TimedAnimation {
262            self.animation.get_or_init(|| {
263                let target = adw::CallbackAnimationTarget::new(clone!(
264                    #[weak(rename_to = imp)]
265                    self,
266                    move |value| {
267                        // Fade the header bar content too.
268                        imp.header_bar.set_opacity(value);
269
270                        imp.obj().queue_draw();
271                    }
272                ));
273                adw::TimedAnimation::new(&*self.obj(), 0.0, 1.0, ANIMATION_DURATION, target)
274            })
275        }
276
277        /// Initialize the swipe tracker.
278        fn init_swipe_tracker(&self) {
279            // Initialize the swipe tracker.
280            let swipe_tracker = self
281                .swipe_tracker
282                .get_or_init(|| adw::SwipeTracker::new(&*self.obj()));
283            swipe_tracker.set_orientation(gtk::Orientation::Vertical);
284            swipe_tracker.connect_update_swipe(clone!(
285                #[weak(rename_to = imp)]
286                self,
287                move |_, progress| {
288                    // Hide the header bar.
289                    imp.header_bar.set_opacity(0.0);
290
291                    // Update the swipe progress to follow the position on the y axis.
292                    imp.swipe_progress.set(progress);
293
294                    // Reposition and redraw the widget.
295                    let obj = imp.obj();
296                    obj.queue_allocate();
297                    obj.queue_draw();
298                }
299            ));
300            swipe_tracker.connect_end_swipe(clone!(
301                #[weak(rename_to = imp)]
302                self,
303                move |_, _, to| {
304                    if to != 0.0 {
305                        // The swipe is complete, close the viewer.
306                        imp.close();
307                        imp.header_bar.set_opacity(1.0);
308                        return;
309                    }
310
311                    // The swipe is cancelled, reset the position of the viewer and animate the
312                    // transition.
313                    let target = adw::CallbackAnimationTarget::new(clone!(
314                        #[weak]
315                        imp,
316                        move |value| {
317                            // Update the swipe progress to fake a swipe back.
318                            imp.swipe_progress.set(value);
319
320                            let obj = imp.obj();
321                            obj.queue_allocate();
322                            obj.queue_draw();
323                        }
324                    ));
325                    let swipe_progress = imp.swipe_progress.get();
326                    let animation = adw::TimedAnimation::new(
327                        &*imp.obj(),
328                        swipe_progress,
329                        0.0,
330                        CANCEL_SWIPE_ANIMATION_DURATION,
331                        target,
332                    );
333                    animation.set_easing(adw::Easing::EaseOutCubic);
334                    animation.connect_done(clone!(
335                        #[weak]
336                        imp,
337                        move |_| {
338                            // Show the header bar again.
339                            imp.header_bar.set_opacity(1.0);
340                        }
341                    ));
342                    animation.play();
343                }
344            ));
345        }
346
347        /// Update the actions of the menu according to the current message.
348        fn update_menu_actions(&self) {
349            let borrowed_message = self.message.borrow();
350            let message = borrowed_message.as_ref();
351            let has_image = message.is_some_and(|m| matches!(m, VisualMediaMessage::Image(_)));
352            let has_video = message.is_some_and(|m| matches!(m, VisualMediaMessage::Video(_)));
353
354            let has_event_id = self.event_id.borrow().is_some();
355
356            let obj = self.obj();
357            obj.action_set_enabled("media-viewer.copy-image", has_image);
358            obj.action_set_enabled("media-viewer.save-image", has_image);
359            obj.action_set_enabled("media-viewer.save-video", has_video);
360            obj.action_set_enabled("media-viewer.permalink", has_event_id);
361        }
362
363        /// Build the content of this viewer.
364        async fn build(&self) {
365            let Some(session) = self.room.upgrade().and_then(|r| r.session()) else {
366                return;
367            };
368            let Some(message) = self.message.borrow().clone() else {
369                return;
370            };
371
372            let content_type = message.content_type();
373
374            let client = session.client();
375            match message.into_tmp_file(&client).await {
376                Ok(file) => {
377                    self.media.view_file(file, Some(content_type)).await;
378                }
379                Err(error) => {
380                    warn!("Could not retrieve media file: {error}");
381                    self.media.show_fallback(content_type);
382                }
383            }
384        }
385
386        /// Close the viewer.
387        fn close(&self) {
388            if self.fullscreened.get() {
389                // Deactivate the fullscreen.
390                let _ = self.obj().activate_action("win.toggle-fullscreen", None);
391            }
392
393            self.media.stop_playback();
394
395            // Trigger the revealer animation.
396            self.revealer.set_reveal_child(false);
397
398            // Fade out the background.
399            let animation = self.animation();
400            animation.set_value_from(animation.value());
401            animation.set_value_to(0.0);
402            animation.play();
403        }
404
405        /// Reveal this widget by transitioning from `source_widget`.
406        pub(super) fn reveal(&self, source_widget: &gtk::Widget) {
407            self.obj().set_visible(true);
408            self.menu.grab_focus();
409
410            // Reset the swipe.
411            self.swipe_progress.set(0.0);
412
413            // Trigger the revealer.
414            self.revealer.set_source_widget(Some(source_widget));
415            self.revealer.set_reveal_child(true);
416
417            // Fade in the background.
418            let animation = self.animation();
419            animation.set_value_from(animation.value());
420            animation.set_value_to(1.0);
421            animation.play();
422        }
423
424        /// Reveal or hide the headerbar.
425        fn reveal_headerbar(&self, reveal: bool) {
426            if self.fullscreened.get() {
427                self.toolbar_view.set_reveal_top_bars(reveal);
428            }
429        }
430
431        /// Toggle whether the header bar is revealed.
432        fn toggle_headerbar(&self) {
433            let revealed = self.toolbar_view.reveals_top_bars();
434            self.reveal_headerbar(!revealed);
435        }
436
437        /// Handle when motion was detected in the viewer.
438        #[template_callback]
439        fn handle_motion(&self, _x: f64, y: f64) {
440            if y <= 50.0 {
441                // Reveal the header bar if the pointer is at the top of the view.
442                self.reveal_headerbar(true);
443            }
444        }
445
446        /// Handle a click in the viewer.
447        #[template_callback]
448        fn handle_click(&self, n_pressed: i32) {
449            if self.fullscreened.get() && n_pressed == 1 {
450                // When the view if fullscreened, clicking reveals and hides the header bar.
451                self.toggle_headerbar();
452            } else if n_pressed == 2 {
453                // A double-click toggles fullscreen.
454                let _ = self.obj().activate_action("win.toggle-fullscreen", None);
455            }
456        }
457
458        /// Copy the current image to the clipboard.
459        fn copy_image(&self) {
460            let Some(texture) = self.media.texture() else {
461                return;
462            };
463
464            let obj = self.obj();
465            obj.clipboard().set_texture(&texture);
466            toast!(obj, gettext("Image copied to clipboard"));
467        }
468
469        /// Save the current file to the clipboard.
470        async fn save_file(&self) {
471            let Some(room) = self.room.upgrade() else {
472                return;
473            };
474            let Some(media_message) = self.message.borrow().clone() else {
475                return;
476            };
477            let Some(session) = room.session() else {
478                return;
479            };
480            let client = session.client();
481
482            media_message.save_to_file(&client, &*self.obj()).await;
483        }
484
485        /// Copy the permalink of the event of the media message to the
486        /// clipboard.
487        async fn copy_permalink(&self) {
488            let Some(room) = self.room.upgrade() else {
489                return;
490            };
491            let Some(event_id) = self.event_id.borrow().clone() else {
492                return;
493            };
494
495            let permalink = room.matrix_to_event_uri(event_id).await;
496
497            let obj = self.obj();
498            obj.clipboard().set_text(&permalink.to_string());
499            toast!(obj, gettext("Message link copied to clipboard"));
500        }
501    }
502}
503
504glib::wrapper! {
505    /// A widget allowing to view a media file.
506    ///
507    /// Swiping to the top or bottom closes this viewer.
508    pub struct MediaViewer(ObjectSubclass<imp::MediaViewer>)
509        @extends gtk::Widget, @implements gtk::Accessible, adw::Swipeable;
510}
511
512impl MediaViewer {
513    pub fn new() -> Self {
514        glib::Object::new()
515    }
516
517    /// Reveal this widget by transitioning from `source_widget`.
518    pub(crate) fn reveal(&self, source_widget: &impl IsA<gtk::Widget>) {
519        self.imp().reveal(source_widget.upcast_ref());
520    }
521
522    /// Set the media message to display in the given room.
523    pub(crate) fn set_message(
524        &self,
525        room: &Room,
526        event_id: OwnedEventId,
527        message: VisualMediaMessage,
528    ) {
529        self.imp().set_message(room, event_id, message);
530    }
531}