Skip to main content

fractal/components/media/
content_viewer.rs

1use adw::{prelude::*, subclass::prelude::*};
2use geo_uri::GeoUri;
3use gettextrs::gettext;
4use gtk::{gdk, gio, glib};
5
6use super::{AnimatedImagePaintable, AudioPlayer, AudioPlayerSource, LocationViewer};
7use crate::{
8    MEDIA_FILE_NOTIFIER,
9    components::ContextMenuBin,
10    prelude::*,
11    utils::{CountedRef, File, media::image::IMAGE_QUEUE},
12};
13
14/// The types of content supported by the [`MediaContentViewer`].
15#[derive(Debug, Default, Clone, Copy)]
16pub enum ContentType {
17    /// An image.
18    Image,
19    /// An audio file.
20    Audio,
21    /// A video.
22    Video,
23    /// An other content type.
24    ///
25    /// These types are not supported and will result in a fallback screen.
26    #[default]
27    Other,
28}
29
30impl ContentType {
31    /// The name of the icon to represent this content type.
32    pub(crate) fn icon_name(self) -> &'static str {
33        match self {
34            ContentType::Image => "image-symbolic",
35            ContentType::Audio => "audio-symbolic",
36            ContentType::Video => "video-symbolic",
37            ContentType::Other => "document-symbolic",
38        }
39    }
40}
41
42impl From<&str> for ContentType {
43    fn from(string: &str) -> Self {
44        match string {
45            "image" => Self::Image,
46            "audio" => Self::Audio,
47            "video" => Self::Video,
48            _ => Self::Other,
49        }
50    }
51}
52
53mod imp {
54    use std::cell::{Cell, RefCell};
55
56    use glib::subclass::InitializingObject;
57
58    use super::*;
59
60    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
61    #[template(resource = "/org/gnome/Fractal/ui/components/media/content_viewer.ui")]
62    #[properties(wrapper_type = super::MediaContentViewer)]
63    pub struct MediaContentViewer {
64        #[template_child]
65        stack: TemplateChild<gtk::Stack>,
66        #[template_child]
67        viewer: TemplateChild<adw::Bin>,
68        #[template_child]
69        fallback: TemplateChild<adw::StatusPage>,
70        /// Whether to play the media content automatically.
71        #[property(get, construct_only)]
72        autoplay: Cell<bool>,
73        /// The current media file.
74        file: RefCell<Option<File>>,
75        paintable_animation_ref: RefCell<Option<CountedRef>>,
76    }
77
78    #[glib::object_subclass]
79    impl ObjectSubclass for MediaContentViewer {
80        const NAME: &'static str = "MediaContentViewer";
81        type Type = super::MediaContentViewer;
82        type ParentType = ContextMenuBin;
83
84        fn class_init(klass: &mut Self::Class) {
85            Self::bind_template(klass);
86            Self::bind_template_callbacks(klass);
87
88            klass.set_css_name("media-content-viewer");
89        }
90
91        fn instance_init(obj: &InitializingObject<Self>) {
92            obj.init_template();
93        }
94    }
95
96    #[glib::derived_properties]
97    impl ObjectImpl for MediaContentViewer {
98        fn dispose(&self) {
99            self.clear();
100        }
101    }
102
103    impl WidgetImpl for MediaContentViewer {}
104    impl ContextMenuBinImpl for MediaContentViewer {}
105
106    #[gtk::template_callbacks]
107    impl MediaContentViewer {
108        /// Update the visible child.
109        pub(super) fn set_visible_child(&self, name: &str) {
110            self.stack.set_visible_child_name(name);
111        }
112
113        /// The media child of the given type, if any.
114        pub(super) fn media_child<T: IsA<gtk::Widget>>(&self) -> Option<T> {
115            self.viewer.child().and_downcast()
116        }
117
118        /// Show the fallback message for the given content type.
119        pub(super) fn show_fallback(&self, content_type: ContentType) {
120            let title = match content_type {
121                ContentType::Image => gettext("Image not Viewable"),
122                ContentType::Audio => gettext("Audio Clip not Playable"),
123                ContentType::Video => gettext("Video not Playable"),
124                ContentType::Other => gettext("File not Viewable"),
125            };
126            self.fallback.set_title(&title);
127            self.fallback.set_icon_name(Some(content_type.icon_name()));
128
129            self.set_visible_child("fallback");
130            self.clear();
131        }
132
133        /// View the given image as bytes.
134        ///
135        /// If you have an image file, you can also use
136        /// [`MediaContentViewer::view_file()`].
137        pub(super) fn view_image(&self, image: &gdk::Paintable) {
138            self.set_visible_child("loading");
139            self.clear();
140
141            let picture = if let Some(picture) = self.media_child::<gtk::Picture>() {
142                picture
143            } else {
144                let picture = gtk::Picture::builder()
145                    .valign(gtk::Align::Center)
146                    .halign(gtk::Align::Center)
147                    .build();
148                self.viewer.set_child(Some(&picture));
149                picture
150            };
151
152            picture.set_paintable(Some(image));
153            self.update_animated_paintable_state();
154            self.set_visible_child("viewer");
155        }
156
157        /// View the given file.
158        pub(super) async fn view_file(&self, file: File, content_type: Option<ContentType>) {
159            self.set_visible_child("loading");
160            self.clear();
161            self.file.replace(Some(file.clone()));
162
163            let content_type = if let Some(content_type) = content_type {
164                content_type
165            } else {
166                let file_info = file
167                    .as_gfile()
168                    .query_info_future(
169                        gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
170                        gio::FileQueryInfoFlags::NONE,
171                        glib::Priority::DEFAULT,
172                    )
173                    .await
174                    .ok();
175
176                file_info
177                    .as_ref()
178                    .and_then(gio::FileInfo::content_type)
179                    .and_then(|content_type| gio::content_type_get_mime_type(&content_type))
180                    .and_then(|mime| mime.split('/').next().map(Into::into))
181                    .unwrap_or_default()
182            };
183
184            match content_type {
185                ContentType::Image => {
186                    let handle = IMAGE_QUEUE.add_file_request(file, None);
187                    if let Ok(image) = handle.await {
188                        self.view_image(&gdk::Paintable::from(image));
189
190                        return;
191                    }
192                }
193                ContentType::Audio => {
194                    let audio = if let Some(audio) = self.media_child::<AudioPlayer>() {
195                        audio
196                    } else {
197                        let audio = AudioPlayer::new();
198                        audio.set_standalone(true);
199                        audio.set_margin_start(12);
200                        audio.set_margin_end(12);
201                        audio.set_valign(gtk::Align::Center);
202                        self.viewer.set_child(Some(&audio));
203                        audio
204                    };
205
206                    audio.set_source(Some(AudioPlayerSource::File(file.as_gfile())));
207                    self.set_visible_child("viewer");
208
209                    return;
210                }
211                ContentType::Video => {
212                    let video = if let Some(video) = self.media_child::<gtk::Video>() {
213                        video
214                    } else {
215                        let video = gtk::Video::builder()
216                            .autoplay(self.autoplay.get())
217                            .valign(gtk::Align::Center)
218                            .halign(gtk::Align::Center)
219                            .build();
220                        self.viewer.set_child(Some(&video));
221                        video
222                    };
223
224                    // Make sure that no other media file is playing. We do not need to listen for
225                    // this one because it should not be possible to play another media when this is
226                    // opened.
227                    MEDIA_FILE_NOTIFIER.notify();
228
229                    video.set_file(Some(&file.as_gfile()));
230                    self.set_visible_child("viewer");
231
232                    return;
233                }
234                // Other types are not supported.
235                ContentType::Other => {}
236            }
237
238            self.show_fallback(content_type);
239        }
240
241        /// View the given location as a geo URI.
242        pub(super) fn view_location(&self, geo_uri: &GeoUri) {
243            let location = self.viewer.child_or_default::<LocationViewer>();
244
245            location.set_location(geo_uri);
246            self.set_visible_child("viewer");
247            self.clear();
248        }
249
250        /// Update the state of the animated paintable, if any.
251        #[template_callback]
252        fn update_animated_paintable_state(&self) {
253            self.paintable_animation_ref.take();
254
255            let Some(paintable) = self
256                .media_child::<gtk::Picture>()
257                .and_then(|p| p.paintable())
258                .and_downcast::<AnimatedImagePaintable>()
259            else {
260                return;
261            };
262
263            if self.viewer.is_mapped() {
264                self.paintable_animation_ref
265                    .replace(Some(paintable.animation_ref()));
266            }
267        }
268
269        /// Clear the viewer.
270        pub(super) fn clear(&self) {
271            if let Some(video) = self.media_child::<gtk::Video>() {
272                video.set_file(None::<&gio::File>);
273            }
274
275            self.paintable_animation_ref.take();
276            self.file.take();
277        }
278    }
279}
280
281glib::wrapper! {
282    /// Widget to view any media file.
283    pub struct MediaContentViewer(ObjectSubclass<imp::MediaContentViewer>)
284        @extends gtk::Widget, ContextMenuBin,
285        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
286}
287
288impl MediaContentViewer {
289    pub fn new(autoplay: bool) -> Self {
290        glib::Object::builder()
291            .property("autoplay", autoplay)
292            .build()
293    }
294
295    /// Clear the viewer.
296    ///
297    /// Should be called when the viewer is closed to drop the current file.
298    pub(crate) fn clear(&self) {
299        self.imp().clear();
300    }
301
302    /// Show the loading screen.
303    pub(crate) fn show_loading(&self) {
304        self.imp().set_visible_child("loading");
305    }
306
307    /// Show the fallback message for the given content type.
308    pub(crate) fn show_fallback(&self, content_type: ContentType) {
309        self.imp().show_fallback(content_type);
310    }
311
312    /// View the given image as bytes.
313    ///
314    /// If you have an image file, you can also use
315    /// [`MediaContentViewer::view_file()`].
316    pub(crate) fn view_image(&self, image: &impl IsA<gdk::Paintable>) {
317        self.imp().view_image(image.upcast_ref());
318    }
319
320    /// View the given file.
321    ///
322    /// If the content type is not provided, it will be guessed from the file.
323    pub(crate) async fn view_file(&self, file: File, content_type: Option<ContentType>) {
324        self.imp().view_file(file, content_type).await;
325    }
326
327    /// View the given location as a geo URI.
328    pub(crate) fn view_location(&self, geo_uri: &GeoUri) {
329        self.imp().view_location(geo_uri);
330    }
331
332    /// Get the texture displayed by this widget, if any.
333    pub(crate) fn texture(&self) -> Option<gdk::Texture> {
334        let paintable = self
335            .imp()
336            .media_child::<gtk::Picture>()
337            .and_then(|p| p.paintable())?;
338
339        if let Some(paintable) = paintable.downcast_ref::<AnimatedImagePaintable>() {
340            paintable.current_texture()
341        } else {
342            paintable.downcast::<gdk::Texture>().ok()
343        }
344    }
345}