1# -*- coding: utf-8 -*- 2 3# Copyright (C) 2012 Osmo Salomaa 4# 5# This program is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# This program is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18"""Loading and interacting with video.""" 19 20import aeidon 21import gaupol 22import os 23import sys 24 25from aeidon.i18n import _ 26from gi.repository import GLib 27from gi.repository import Gtk 28 29with aeidon.util.silent(Exception): 30 from gi.repository import Gst 31 32 33class VideoAgent(aeidon.Delegate): 34 35 """Loading and interacting with video.""" 36 37 def __init__(self, master): 38 """Initialize an :class:`VideoAgent` instance.""" 39 aeidon.Delegate.__init__(self, master) 40 # Maintain an up-to-date cache of subtitle positions in seconds and 41 # subtitle texts in order to allow fast polled updates in video player. 42 # This cache must be updated when page or subtitle data changes. 43 self._cache = [] 44 self._update_handlers = [] 45 46 def _clear_subtitle_cache(self): 47 """Clear subtitle position and text cache.""" 48 self._cache = [] 49 50 def _init_cache_updates(self): 51 """Initialize cache updates on application signals.""" 52 self.connect("page-added", self._update_subtitle_cache) 53 self.connect("page-changed", self._update_subtitle_cache) 54 self.connect("page-closed", self._update_subtitle_cache) 55 self.connect("page-switched", self._update_subtitle_cache) 56 57 def _init_player_toolbar(self): 58 """Initialize the video player toolbar.""" 59 self.player_toolbar = Gtk.Toolbar() 60 self.player_toolbar.set_style(Gtk.ToolbarStyle.ICONS) 61 if sys.platform == "win32": 62 self.player_toolbar.set_icon_size(Gtk.IconSize.MENU) 63 # win.play-pause 64 button = Gtk.ToolButton( 65 label=_("_Play/Pause"), icon_name="media-playback-start") 66 button.set_action_name("win.play-pause") 67 button.set_tooltip_text(_("Play or pause video")) 68 self.player_toolbar.insert(button, -1) 69 self.player_toolbar.insert(Gtk.SeparatorToolItem(), -1) 70 self.play_button = button 71 # win.seek-previous 72 button = Gtk.ToolButton( 73 label=_("Seek _Previous"), icon_name="media-skip-backward") 74 button.set_action_name("win.seek-previous") 75 button.set_tooltip_text(_("Seek to the start of the previous subtitle")) 76 self.player_toolbar.insert(button, -1) 77 # win.seek-next 78 button = Gtk.ToolButton( 79 label=_("Seek _Next"), icon_name="media-skip-forward") 80 button.set_action_name("win.seek-next") 81 button.set_tooltip_text(_("Seek to the start of the next subtitle")) 82 self.player_toolbar.insert(button, -1) 83 self.player_toolbar.insert(Gtk.SeparatorToolItem(), -1) 84 # win.seek-backward 85 button = Gtk.ToolButton( 86 label=_("Seek _Backward"), icon_name="media-seek-backward") 87 button.set_action_name("win.seek-backward") 88 button.set_tooltip_text(_("Seek backward")) 89 self.player_toolbar.insert(button, -1) 90 # win.seek-forward 91 button = Gtk.ToolButton( 92 label=_("Seek _Forward"), icon_name="media-seek-forward") 93 button.set_action_name("win.seek-forward") 94 button.set_tooltip_text(_("Seek forward")) 95 self.player_toolbar.insert(button, -1) 96 self.player_toolbar.insert(Gtk.SeparatorToolItem(), -1) 97 # Volume button 98 self.volume_button = Gtk.VolumeButton() 99 self.volume_button.props.use_symbolic = False 100 adjustment = self.volume_button.get_adjustment() 101 adjustment.set_lower(0) 102 adjustment.set_upper(1) 103 adjustment.set_value(self.player.volume) 104 aeidon.util.connect(self, "volume_button", "value-changed") 105 item = Gtk.ToolItem() 106 item.add(self.volume_button) 107 item.set_tooltip_text(_("Volume")) 108 self.player_toolbar.insert(item, -1) 109 # Seekbar 110 self.seekbar = Gtk.Scale( 111 orientation=Gtk.Orientation.HORIZONTAL, 112 adjustment=Gtk.Adjustment(value=0, 113 lower=0, 114 upper=1, 115 step_increment=0.01, 116 page_increment=0.05, 117 page_size=0.05)) 118 119 self.seekbar.set_draw_value(False) 120 self.seekbar.connect("change-value", self._on_seekbar_change_value) 121 item = Gtk.ToolItem() 122 item.set_expand(True) 123 item.add(self.seekbar) 124 self.player_toolbar.insert(item, -1) 125 126 def _init_player_widgets(self): 127 """Initialize the video player and related widgets.""" 128 vbox = gaupol.util.new_vbox(spacing=0) 129 self.player = gaupol.VideoPlayer() 130 aeidon.util.connect(self, "player", "state-changed") 131 gaupol.util.pack_start_expand(vbox, self.player.widget) 132 self._init_player_toolbar() 133 gaupol.util.pack_start_fill(vbox, self.player_toolbar) 134 gaupol.util.pack_start_expand(self.player_box, vbox) 135 self.player_box.show_all() 136 self.paned.add1(self.player_box) 137 orientation = self.paned.get_orientation() 138 size = ( 139 self.notebook.get_window().get_width() 140 if orientation == Gtk.Orientation.HORIZONTAL 141 else self.notebook.get_window().get_height()) 142 self.paned.set_position(int(size/2)) 143 self.get_action("toggle-player").set_state(True) 144 145 def _init_update_handlers(self): 146 """Initialize timed updates of widgets.""" 147 while self._update_handlers: 148 GLib.source_remove(self._update_handlers.pop()) 149 self._update_handlers = [ 150 GLib.timeout_add( 10, self._on_player_update_subtitle), 151 GLib.timeout_add( 50, self._on_player_update_seekbar), 152 GLib.timeout_add(100, self._on_player_update_volume), 153 ] 154 155 @aeidon.deco.export 156 def load_video(self, path): 157 """Load a video file.""" 158 page = self.get_current_page() 159 page.project.video_path = path 160 if self.player is None: 161 self._init_player_widgets() 162 self._init_cache_updates() 163 self._init_update_handlers() 164 self._update_subtitle_cache() 165 else: # Player exists 166 if self.player.is_playing(): 167 self.get_action("play-pause").activate() 168 adjustment = self.seekbar.get_adjustment() 169 adjustment.set_value(0) 170 self.player.stop() 171 self.player.set_path(path) 172 # Playback initialization can fail, e.g. due to missing codecs, 173 # in which case the player itself has shown an error dialog. 174 if not self.player.ready: return 175 self._update_languages_menu() 176 self.update_gui() 177 self.player.play() 178 if not gaupol.conf.video_player.autoplay: 179 self.player.pause() 180 181 @aeidon.deco.export 182 def _on_load_video_activate(self, *args): 183 """Load a video file.""" 184 gaupol.util.set_cursor_busy(self.window) 185 page = self.get_current_page() 186 dialog = gaupol.VideoDialog( 187 self.window, title=_("Load Video"), button_label=_("_Load")) 188 if page.project.main_file is not None: 189 directory = os.path.dirname(page.project.main_file.path) 190 dialog.set_current_folder(directory) 191 if page.project.video_path is not None: 192 dialog.set_filename(page.project.video_path) 193 gaupol.util.set_cursor_normal(self.window) 194 response = gaupol.util.run_dialog(dialog) 195 path = dialog.get_filename() 196 dialog.destroy() 197 if response != Gtk.ResponseType.OK: return 198 self.load_video(path) 199 200 @aeidon.deco.export 201 def _on_play_pause_activate(self, *args): 202 """Play or pause video.""" 203 if self.player.is_playing(): 204 self.player.pause() 205 else: # Not playing. 206 self.player.play() 207 208 @aeidon.deco.export 209 def _on_play_selection_activate(self, *args): 210 """Play the selected subtitles.""" 211 page = self.get_current_page() 212 rows = page.view.get_selected_rows() 213 offset = gaupol.conf.video_player.context_length 214 start = page.project.subtitles[rows[0]].start_seconds - offset 215 end = page.project.subtitles[rows[-1]].end_seconds + offset 216 self.player.play_segment(start, end) 217 218 def _on_player_state_changed(self, player, state): 219 """Update UI to match `state` of `player`.""" 220 self.play_button.set_icon_name( 221 "media-playback-pause" 222 if state == Gst.State.PLAYING 223 else "media-playback-start") 224 225 def _on_player_update_seekbar(self, data=None): 226 """Update seekbar from video position.""" 227 duration = self.player.get_duration(mode=None) 228 position = self.player.get_position(mode=None) 229 if duration is not None and position is not None: 230 adjustment = self.seekbar.get_adjustment() 231 adjustment.set_value(position/duration) 232 return True # to be called again. 233 234 def _on_player_update_subtitle(self, data=None): 235 """Update subtitle overlay from video position.""" 236 pos = self.player.get_position(aeidon.modes.SECONDS) 237 if pos is None: 238 return True # to be called again. 239 subtitles = list(filter(lambda x: x[0] <= pos <= x[1], self._cache)) 240 if subtitles: 241 text = subtitles[-1][2] 242 if text != self.player.subtitle_text_raw: 243 self.player.subtitle_text = text 244 else: 245 if self.player.subtitle_text: 246 self.player.subtitle_text = "" 247 return True # to be called again. 248 249 def _on_player_update_volume(self, data=None): 250 """Update volume from player.""" 251 self.volume_button.set_value(self.player.volume) 252 return True # to be called again. 253 254 @aeidon.deco.export 255 def _on_seek_backward_activate(self, *args): 256 """Seek backward.""" 257 pos = self.player.get_position(aeidon.modes.SECONDS) 258 if pos is None: return 259 pos = pos - gaupol.conf.video_player.seek_length 260 pos = max(pos, 0) 261 self.player.seek(pos) 262 263 @aeidon.deco.export 264 def _on_seek_forward_activate(self, *args): 265 """Seek forward.""" 266 position = self.player.get_position(aeidon.modes.SECONDS) 267 duration = self.player.get_duration(aeidon.modes.SECONDS) 268 if position is None: return 269 if duration is None: return 270 position = position + gaupol.conf.video_player.seek_length 271 position = min(position, duration) 272 self.player.seek(position) 273 274 @aeidon.deco.export 275 def _on_seek_next_activate(self, *args): 276 """Seek to the start of the next subtitle.""" 277 pos = self.player.get_position(aeidon.modes.SECONDS) 278 subtitles = list(filter(lambda x: x[0] > pos + 0.001, self._cache)) 279 if not subtitles: return 280 self.player.seek(subtitles[0][0]) 281 282 @aeidon.deco.export 283 def _on_seek_previous_activate(self, *args): 284 """Seek to the start of the previous subtitle.""" 285 pos = self.player.get_position(aeidon.modes.SECONDS) 286 subtitles = list(filter(lambda x: x[1] < pos - 0.001, self._cache)) 287 if not subtitles: return 288 self.player.seek(subtitles[-1][0]) 289 290 @aeidon.deco.export 291 def _on_seek_selection_end_activate(self, *args): 292 """Seek to the end of selection.""" 293 page = self.get_current_page() 294 rows = page.view.get_selected_rows() 295 pos = page.project.subtitles[rows[-1]].end_seconds 296 offset = gaupol.conf.video_player.context_length 297 self.player.seek(max(pos - offset, 0)) 298 299 @aeidon.deco.export 300 def _on_seek_selection_start_activate(self, *args): 301 """Seek to the start of selection.""" 302 page = self.get_current_page() 303 rows = page.view.get_selected_rows() 304 pos = page.project.subtitles[rows[0]].start_seconds 305 offset = gaupol.conf.video_player.context_length 306 self.player.seek(max(pos - offset, 0)) 307 308 def _on_seekbar_change_value(self, seekbar, scroll, value, data=None): 309 """Seek to specified position in video.""" 310 self.player.subtitle_text = "" 311 duration = self.player.get_duration(aeidon.modes.SECONDS) 312 if duration is None: return 313 self.player.seek(value * duration) 314 315 @aeidon.deco.export 316 def _on_set_audio_language_activate(self, action, parameter): 317 """Set the audio language to use.""" 318 # Avoid freeze by pausing prior and playing after. 319 # https://github.com/otsaloma/gaupol/issues/58 320 self.player.pause() 321 index = int(parameter.get_string()) 322 self.player.audio_track = index 323 self._update_languages_menu() 324 self.player.play() 325 326 def _on_volume_button_value_changed(self, button, value): 327 """Update video player volume.""" 328 self.player.volume = value 329 self.update_gui() 330 331 @aeidon.deco.export 332 def _on_volume_down_activate(self, *args): 333 """Decrease volume.""" 334 self.player.volume = self.player.volume - 0.05 335 self.volume_button.set_value(self.player.volume) 336 self.update_gui() 337 338 @aeidon.deco.export 339 def _on_volume_up_activate(self, *args): 340 """Increase volume.""" 341 self.player.volume = self.player.volume + 0.05 342 self.volume_button.set_value(self.player.volume) 343 self.update_gui() 344 345 def _update_languages_menu(self): 346 """Update the audio language selection menu.""" 347 menu = self.get_menubar_section("audio-languages-placeholder") 348 menu.remove_all() 349 languages = self.player.get_audio_languages() 350 for i, language in enumerate(languages): 351 language = language or _("Undefined") 352 action = "win.set-audio-language::{:d}".format(i) 353 menu.append(language, action) 354 if i == self.player.audio_track: 355 action = self.get_action("set-audio-language") 356 action.set_state(str(i)) 357 358 def _update_subtitle_cache(self, *args, **kwargs): 359 """Update subtitle position and text cache.""" 360 page = self.get_current_page() 361 if self.player is None or page is None: 362 return self._clear_subtitle_cache() 363 self._cache = [(x.start_seconds, x.end_seconds, x.main_text) 364 for x in page.project.subtitles] 365