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
14const ANIMATION_DURATION: u32 = 250;
16const 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 #[property(get, set = Self::set_fullscreened, explicit_notify)]
45 fullscreened: Cell<bool>,
46 #[property(get)]
48 room: glib::WeakRef<Room>,
49 event_id: RefCell<Option<OwnedEventId>>,
51 message: RefCell<Option<VisualMediaMessage>>,
53 #[property(get)]
55 filename: RefCell<Option<String>>,
56 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 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 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 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 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: >k::Snapshot) {
158 let obj = self.obj();
159
160 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 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 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 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 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 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 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 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 fn init_swipe_tracker(&self) {
279 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 imp.header_bar.set_opacity(0.0);
290
291 imp.swipe_progress.set(progress);
293
294 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 imp.close();
307 imp.header_bar.set_opacity(1.0);
308 return;
309 }
310
311 let target = adw::CallbackAnimationTarget::new(clone!(
314 #[weak]
315 imp,
316 move |value| {
317 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 imp.header_bar.set_opacity(1.0);
340 }
341 ));
342 animation.play();
343 }
344 ));
345 }
346
347 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 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 fn close(&self) {
388 if self.fullscreened.get() {
389 let _ = self.obj().activate_action("win.toggle-fullscreen", None);
391 }
392
393 self.media.stop_playback();
394
395 self.revealer.set_reveal_child(false);
397
398 let animation = self.animation();
400 animation.set_value_from(animation.value());
401 animation.set_value_to(0.0);
402 animation.play();
403 }
404
405 pub(super) fn reveal(&self, source_widget: >k::Widget) {
407 self.obj().set_visible(true);
408 self.menu.grab_focus();
409
410 self.swipe_progress.set(0.0);
412
413 self.revealer.set_source_widget(Some(source_widget));
415 self.revealer.set_reveal_child(true);
416
417 let animation = self.animation();
419 animation.set_value_from(animation.value());
420 animation.set_value_to(1.0);
421 animation.play();
422 }
423
424 fn reveal_headerbar(&self, reveal: bool) {
426 if self.fullscreened.get() {
427 self.toolbar_view.set_reveal_top_bars(reveal);
428 }
429 }
430
431 fn toggle_headerbar(&self) {
433 let revealed = self.toolbar_view.reveals_top_bars();
434 self.reveal_headerbar(!revealed);
435 }
436
437 #[template_callback]
439 fn handle_motion(&self, _x: f64, y: f64) {
440 if y <= 50.0 {
441 self.reveal_headerbar(true);
443 }
444 }
445
446 #[template_callback]
448 fn handle_click(&self, n_pressed: i32) {
449 if self.fullscreened.get() && n_pressed == 1 {
450 self.toggle_headerbar();
452 } else if n_pressed == 2 {
453 let _ = self.obj().activate_action("win.toggle-fullscreen", None);
455 }
456 }
457
458 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 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 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 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 pub(crate) fn reveal(&self, source_widget: &impl IsA<gtk::Widget>) {
519 self.imp().reveal(source_widget.upcast_ref());
520 }
521
522 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}