1/*
2 * Copyright 2013-2019 elementary, Inc. (https://elementary.io)
3 *
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public
6 * License as published by the Free Software Foundation; either
7 * version 3 of the License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12 * General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18namespace Audience {
19    private const string[] SUBTITLE_EXTENSIONS = {
20        "sub",
21        "srt",
22        "smi",
23        "ssa",
24        "ass",
25        "asc"
26    };
27
28    public class PlayerPage : Gtk.EventBox {
29        public signal void unfullscreen_clicked ();
30        public signal void ended ();
31
32        private GtkClutter.Actor bottom_actor;
33        private GtkClutter.Embed clutter;
34        private GnomeMediaKeys mediakeys;
35        private ClutterGst.Playback playback;
36        private unowned Gst.Pipeline pipeline;
37        private Clutter.Stage stage;
38        private Gtk.Revealer unfullscreen_bar;
39        private GtkClutter.Actor unfullscreen_actor;
40        private Clutter.Actor video_actor;
41        private uint inhibit_token = 0;
42
43        public Audience.Widgets.BottomBar bottom_bar {get; private set;}
44
45        private bool mouse_primary_down = false;
46
47        public bool repeat {
48            get {
49                return bottom_bar.repeat;
50            }
51            set {
52                bottom_bar.repeat = value;
53            }
54        }
55
56        public bool playing {
57            get {
58                return playback.playing;
59            }
60            set {
61                if (playback.playing == value)
62                    return;
63
64                playback.playing = value;
65            }
66        }
67
68        private bool _fullscreened = false;
69        public bool fullscreened {
70            get {
71                return _fullscreened;
72            }
73            set {
74                _fullscreened = value;
75                bottom_bar.fullscreen = value;
76            }
77        }
78
79        public PlayerPage () {
80        }
81
82        construct {
83            events |= Gdk.EventMask.POINTER_MOTION_MASK;
84            events |= Gdk.EventMask.KEY_PRESS_MASK;
85            events |= Gdk.EventMask.KEY_RELEASE_MASK;
86            playback = new ClutterGst.Playback ();
87            pipeline = (Gst.Pipeline)(playback.get_pipeline ());
88
89            playback.set_seek_flags (ClutterGst.SeekFlags.ACCURATE);
90
91            clutter = new GtkClutter.Embed ();
92            stage = (Clutter.Stage)clutter.get_stage ();
93            stage.background_color = {0, 0, 0, 0};
94
95            video_actor = new Clutter.Actor ();
96#if VALA_0_34
97            var aspect_ratio = new ClutterGst.Aspectratio ();
98#else
99            var aspect_ratio = ClutterGst.Aspectratio.@new ();
100#endif
101            ((ClutterGst.Aspectratio) aspect_ratio).paint_borders = false;
102            ((ClutterGst.Content) aspect_ratio).player = playback;
103            /* Commented because of a bug in the compositor
104            ((ClutterGst.Content) aspect_ratio).size_change.connect ((width, height) => {
105                double aspect = ((double) width)/((double) height);
106                var geometry = Gdk.Geometry ();
107                geometry.min_aspect = aspect;
108                geometry.max_aspect = aspect;
109                ((Gtk.Window) get_toplevel ()).set_geometry_hints (get_toplevel (), geometry, Gdk.WindowHints.ASPECT);
110            });
111            */
112            video_actor.content = aspect_ratio;
113
114            video_actor.add_constraint (new Clutter.BindConstraint (stage, Clutter.BindCoordinate.WIDTH, 0));
115            video_actor.add_constraint (new Clutter.BindConstraint (stage, Clutter.BindCoordinate.HEIGHT, 0));
116
117            Signal.connect (clutter, "button-press-event", (GLib.Callback) navigation_event, this);
118            Signal.connect (clutter, "button-release-event", (GLib.Callback) navigation_event, this);
119            Signal.connect (clutter, "key-press-event", (GLib.Callback) navigation_event, this);
120            Signal.connect (clutter, "key-release-event", (GLib.Callback) navigation_event, this);
121            Signal.connect (clutter, "motion-notify-event", (GLib.Callback) navigation_event, this);
122
123            stage.add_child (video_actor);
124
125            bottom_bar = new Widgets.BottomBar (playback);
126            bottom_bar.bind_property ("playing", playback, "playing", BindingFlags.BIDIRECTIONAL);
127            bottom_bar.unfullscreen.connect (() => unfullscreen_clicked ());
128
129            unfullscreen_bar = bottom_bar.get_unfullscreen_button ();
130
131            bottom_actor = new GtkClutter.Actor.with_contents (bottom_bar);
132            bottom_actor.opacity = GLOBAL_OPACITY;
133            bottom_actor.add_constraint (new Clutter.BindConstraint (stage, Clutter.BindCoordinate.WIDTH, 0));
134            bottom_actor.add_constraint (new Clutter.AlignConstraint (stage, Clutter.AlignAxis.Y_AXIS, 1));
135            stage.add_child (bottom_actor);
136
137            unfullscreen_actor = new GtkClutter.Actor.with_contents (unfullscreen_bar);
138            unfullscreen_actor.opacity = GLOBAL_OPACITY;
139            unfullscreen_actor.add_constraint (new Clutter.AlignConstraint (stage, Clutter.AlignAxis.X_AXIS, 1));
140            unfullscreen_actor.add_constraint (new Clutter.AlignConstraint (stage, Clutter.AlignAxis.Y_AXIS, 0));
141            stage.add_child (unfullscreen_actor);
142
143            //media keys
144            try {
145                mediakeys = Bus.get_proxy_sync (BusType.SESSION,
146                    "org.gnome.SettingsDaemon", "/org/gnome/SettingsDaemon/MediaKeys");
147                mediakeys.media_player_key_pressed.connect ((bus, app, key) => {
148                    if (app != "audience")
149                       return;
150                    switch (key) {
151                        case "Previous":
152                            get_playlist_widget ().previous ();
153                            break;
154                        case "Next":
155                            get_playlist_widget ().next ();
156                            break;
157                        case "Play":
158                            playback.playing = !playback.playing;
159                            break;
160                        default:
161                            break;
162                    }
163                });
164
165                mediakeys.grab_media_player_keys ("audience", 0);
166            } catch (Error e) {
167                warning (e.message);
168            }
169
170            motion_notify_event.connect (event => {
171                if (mouse_primary_down && settings.get_boolean ("move-window")) {
172                    mouse_primary_down = false;
173                    App.get_instance ().mainwindow.begin_move_drag (Gdk.BUTTON_PRIMARY,
174                        (int)event.x_root, (int)event.y_root, event.time);
175                }
176
177                Gtk.Allocation allocation;
178                clutter.get_allocation (out allocation);
179                return update_pointer_position (event.y, allocation.height);
180            });
181
182            button_press_event.connect (event => {
183                if (event.button == Gdk.BUTTON_PRIMARY) {
184                    mouse_primary_down = true;
185                }
186
187                return false;
188            });
189
190            button_release_event.connect (event => {
191                if (event.button == Gdk.BUTTON_PRIMARY) {
192                    mouse_primary_down = false;
193                }
194
195                return false;
196            });
197
198            leave_notify_event.connect (event => {
199                Gtk.Allocation allocation;
200                clutter.get_allocation (out allocation);
201
202                if (event.x == event.window.get_width ()) {
203                    return update_pointer_position (event.window.get_height (), allocation.height);
204                } else if (event.x == 0) {
205                    return update_pointer_position (event.window.get_height (), allocation.height);
206                }
207
208                return update_pointer_position (event.y, allocation.height);
209            });
210
211            destroy.connect (() => {
212                // FIXME:should find better way to decide if its end of playlist
213                if (playback.progress > 0.99) {
214                    settings.set_double ("last-stopped", 0);
215                } else if (playback.uri != "") {
216                    /* The progress is only valid if the uri has not been reset as the current video setting is not
217                     * updated.  The playback.uri has been reset when the window is destroyed from the Welcome page */
218                    settings.set_double ("last-stopped", playback.progress);
219                }
220
221                get_playlist_widget ().save_playlist ();
222
223                if (inhibit_token != 0) {
224                    ((Gtk.Application) GLib.Application.get_default ()).uninhibit (inhibit_token);
225                    inhibit_token = 0;
226                }
227            });
228
229            //end
230            playback.eos.connect (() => {
231                Idle.add (() => {
232                    playback.progress = 0;
233                    if (!get_playlist_widget ().next ()) {
234                        if (repeat) {
235                            string file = get_playlist_widget ().get_first_item ().get_uri ();
236                            App.get_instance ().mainwindow.open_files ({ File.new_for_uri (file) });
237                        } else {
238                            playback.playing = false;
239                            settings.set_double ("last-stopped", 0);
240                            ended ();
241                        }
242                    }
243                    return false;
244                });
245            });
246
247            //playlist wants us to open a file
248            get_playlist_widget ().play.connect ((file) => {
249                App.get_instance ().mainwindow.open_files ({ File.new_for_uri (file.get_uri ()) });
250            });
251
252            get_playlist_widget ().stop_video.connect (() => {
253                settings.set_double ("last-stopped", 0);
254                settings.set_strv ("last-played-videos", {});
255                settings.set_string ("current-video", "");
256
257                /* We do not want to emit an "ended" signal if already ended - it can cause premature
258                 * ending of next video and other side-effects
259                 */
260                if (playback.playing) {
261                    playback.playing = false;
262                    playback.progress = 1.0;
263                    ended ();
264                }
265            });
266
267            bottom_bar.notify["child-revealed"].connect (() => {
268                if (bottom_bar.child_revealed == true) {
269                    App.get_instance ().mainwindow.show_mouse_cursor ();
270                } else {
271                    App.get_instance ().mainwindow.hide_mouse_cursor ();
272                }
273            });
274
275            playback.notify["playing"].connect (() => {
276                unowned Gtk.Application app = (Gtk.Application) GLib.Application.get_default ();
277                if (playback.playing) {
278                    if (inhibit_token != 0) {
279                        app.uninhibit (inhibit_token);
280                    }
281
282                    inhibit_token = app.inhibit (
283                        app.get_active_window (),
284                        Gtk.ApplicationInhibitFlags.IDLE | Gtk.ApplicationInhibitFlags.SUSPEND,
285                        _("A video is playing")
286                    );
287                } else if (inhibit_token != 0) {
288                    app.uninhibit (inhibit_token);
289                    inhibit_token = 0;
290                }
291            });
292
293            add (clutter);
294            show_all ();
295        }
296
297        public void play_file (string uri, bool from_beginning = true) {
298            debug ("Opening %s", uri);
299            pipeline.set_state (Gst.State.NULL);
300            var file = File.new_for_uri (uri);
301            try {
302                FileInfo info = file.query_info (GLib.FileAttribute.STANDARD_CONTENT_TYPE + "," + GLib.FileAttribute.STANDARD_NAME, 0);
303                unowned string content_type = info.get_content_type ();
304
305                if (!GLib.ContentType.is_a (content_type, "video/*")) {
306                    debug ("Unrecognized file format: %s", content_type);
307                    var unsupported_file_dialog = new UnsupportedFileDialog (uri, info.get_name (), content_type);
308                    unsupported_file_dialog.present ();
309
310                    unsupported_file_dialog.response.connect (type => {
311                        if (type == Gtk.ResponseType.CANCEL) {
312                            // Play next video if available or else go to welcome page
313                            if (!get_playlist_widget ().next ()) {
314                                ended ();
315                            }
316                        }
317
318                        unsupported_file_dialog.destroy ();
319                    });
320                }
321            } catch (Error e) {
322                debug (e.message);
323            }
324
325            get_playlist_widget ().set_current (uri);
326            playback.uri = uri;
327
328
329            App.get_instance ().mainwindow.title = get_title (uri);
330
331            /* Set progress before subtitle uri else it gets reset to zero */
332            if (from_beginning) {
333                playback.progress = 0.0;
334            } else {
335                playback.progress = settings.get_double ("last-stopped");
336            }
337
338            string sub_uri = "";
339            if (!from_beginning) { //We are resuming the current video - fetch the current subtitles
340                /* Should not bind to this setting else may cause loop */
341                sub_uri = settings.get_string ("current-external-subtitles-uri");
342            } else {
343                sub_uri = get_subtitle_for_uri (uri);
344            }
345
346            set_subtitle (sub_uri);
347
348            playback.playing = !settings.get_boolean ("playback-wait");
349            Gtk.RecentManager recent_manager = Gtk.RecentManager.get_default ();
350            recent_manager.add_item (uri);
351
352            bottom_bar.preferences_popover.is_setup = false;
353
354            settings.set_string ("current-video", uri);
355        }
356
357        public double get_progress () {
358            return playback.progress;
359        }
360
361        public string get_played_uri () {
362            return playback.uri;
363        }
364
365        public void reset_played_uri () {
366            playback.uri = "";
367        }
368
369        public void next () {
370            get_playlist_widget ().next ();
371        }
372
373        public void prev () {
374            get_playlist_widget ().next (); //Is this right??
375        }
376
377        public void resume_last_videos () {
378            play_file (settings.get_string ("current-video"));
379            playback.playing = false;
380            if (settings.get_boolean ("resume-videos")) {
381                playback.progress = settings.get_double ("last-stopped");
382            } else {
383                playback.progress = 0.0;
384            }
385
386            playback.playing = !settings.get_boolean ("playback-wait");
387        }
388
389        public void append_to_playlist (File file) {
390            if (is_subtitle (file.get_uri ())) {
391                set_subtitle (file.get_uri ());
392            } else {
393                get_playlist_widget ().add_item (file);
394            }
395        }
396
397        public void play_first_in_playlist () {
398            var file = get_playlist_widget ().get_first_item ();
399            play_file (file.get_uri ());
400        }
401
402        public void next_audio () {
403            bottom_bar.preferences_popover.next_audio ();
404        }
405
406        public void next_text () {
407            bottom_bar.preferences_popover.next_text ();
408        }
409
410        public void seek_jump_seconds (int seconds) {
411            var duration = playback.duration;
412            var progress = playback.progress;
413            var new_progress = ((duration * progress) + (double)seconds) / duration;
414            playback.progress = new_progress.clamp (0.0, 1.0);
415            bottom_bar.reveal_control ();
416        }
417
418        public Widgets.Playlist get_playlist_widget () {
419            return bottom_bar.playlist_popover.playlist;
420        }
421
422        public void hide_preview_popover () {
423            var popover = bottom_bar.time_widget.preview_popover;
424            if (popover != null) {
425                popover.schedule_hide ();
426            }
427        }
428
429        private string get_subtitle_for_uri (string uri) {
430            /* This assumes that the subtitle file has the same basename as the video file but with
431             * one of the subtitle extensions, and is in the same folder. */
432            string without_ext;
433            int last_dot = uri.last_index_of (".", 0);
434            int last_slash = uri.last_index_of ("/", 0);
435
436            if (last_dot < last_slash) {//we dont have extension
437                without_ext = uri;
438            } else {
439                without_ext = uri.slice (0, last_dot);
440            }
441
442            foreach (string ext in SUBTITLE_EXTENSIONS) {
443                string sub_uri = without_ext + "." + ext;
444                if (File.new_for_uri (sub_uri).query_exists ()) {
445                    return sub_uri;
446                }
447            }
448
449            return "";
450        }
451
452        private bool is_subtitle (string uri) {
453            if (uri.length < 4 || uri.get_char (uri.length - 4) != '.') {
454                return false;
455            }
456
457            foreach (string ext in SUBTITLE_EXTENSIONS) {
458                if (uri.down ().has_suffix (ext)) {
459                    return true;
460                }
461            }
462
463            return false;
464        }
465
466        private ulong ready_handler_id = 0;
467        public void set_subtitle (string uri) {
468            var progress = playback.progress;
469            var is_playing = playback.playing;
470
471            /* Temporarily connect to the ready signal so that we can restore the progress setting
472             * after resetting the pipeline in order to set the subtitle uri */
473            ready_handler_id = playback.ready.connect (() => {
474                playback.progress = progress;
475                // Pause video if it was in Paused state before adding the subtitle
476                if (!is_playing) {
477                    pipeline.set_state (Gst.State.PAUSED);
478                }
479
480                playback.disconnect (ready_handler_id);
481            });
482
483            pipeline.set_state (Gst.State.NULL); // Does not work otherwise
484            playback.set_subtitle_uri (uri);
485            pipeline.set_state (Gst.State.PLAYING);
486
487            settings.set_string ("current-external-subtitles-uri", uri);
488        }
489
490        public bool update_pointer_position (double y, int window_height) {
491            App.get_instance ().mainwindow.get_window ().set_cursor (null);
492
493            bottom_bar.reveal_control ();
494
495            return false;
496        }
497
498        [CCode (instance_pos = -1)]
499        private bool navigation_event (GtkClutter.Embed embed, Clutter.Event event) {
500            var video_sink = playback.get_video_sink ();
501            var frame = video_sink.get_frame ();
502            if (frame == null) {
503                return true;
504            }
505
506            float x, y;
507            event.get_coords (out x, out y);
508            // Transform event coordinates into the actor's coordinates
509            video_actor.transform_stage_point (x, y, out x, out y);
510            float actor_width, actor_height;
511            video_actor.get_size (out actor_width, out actor_height);
512
513            /* Convert event's coordinates into the frame's coordinates. */
514            x = x * frame.resolution.width / actor_width;
515            y = y * frame.resolution.height / actor_height;
516
517            switch (event.type) {
518                case Clutter.EventType.MOTION:
519                    ((Gst.Video.Navigation) video_sink).send_mouse_event ("mouse-move", 0, x, y);
520                    break;
521                case Clutter.EventType.BUTTON_PRESS:
522                    ((Gst.Video.Navigation) video_sink).send_mouse_event ("mouse-button-press", (int)event.button.button, x, y);
523                    break;
524                case Clutter.EventType.KEY_PRESS:
525                    warning (X.keysym_to_string (event.key.keyval));
526                    ((Gst.Video.Navigation) video_sink).send_key_event ("key-press", X.keysym_to_string (event.key.keyval));
527                    break;
528                case Clutter.EventType.KEY_RELEASE:
529                    ((Gst.Video.Navigation) video_sink).send_key_event ("key-release", X.keysym_to_string (event.key.keyval));
530                    break;
531            }
532
533            return false;
534        }
535    }
536}
537