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