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