1# -*- coding: utf-8 -*-
2# Pitivi video editor
3# Copyright (c) 2011, Pier Carteri <pier.carteri@gmail.com>
4# Copyright (c) 2012, Thibault Saunier <tsaunier@gnome.org>
5#
6# This program is free software; you can redistribute it and/or
7# modify it under the terms of the GNU Lesser General Public
8# License as published by the Free Software Foundation; either
9# version 2.1 of the License, or (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14# Lesser General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public
17# License along with this program; if not, write to the
18# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
19# Boston, MA 02110-1301, USA.
20import html
21from gettext import gettext as _
22
23from gi.repository import Gdk
24from gi.repository import GdkPixbuf
25from gi.repository import GES
26from gi.repository import GLib
27from gi.repository import GObject
28from gi.repository import Gst
29from gi.repository import Gtk
30from gi.repository import Pango
31
32from pitivi.settings import GlobalSettings
33from pitivi.utils.loggable import Loggable
34from pitivi.utils.misc import uri_is_valid
35from pitivi.utils.pipeline import AssetPipeline
36from pitivi.utils.ui import beautify_length
37from pitivi.utils.ui import beautify_stream
38from pitivi.utils.ui import SPACING
39from pitivi.viewer.viewer import ViewerWidget
40
41PREVIEW_WIDTH = 250
42PREVIEW_HEIGHT = 100
43
44GlobalSettings.addConfigSection('filechooser-preview')
45GlobalSettings.addConfigOption('FCEnablePreview',
46                               section='filechooser-preview',
47                               key='do-preview-on-clip-import',
48                               default=True)
49GlobalSettings.addConfigOption('FCpreviewWidth',
50                               section='filechooser-preview',
51                               key='video-preview-width',
52                               default=PREVIEW_WIDTH)
53GlobalSettings.addConfigOption('FCpreviewHeight',
54                               section='filechooser-preview',
55                               key='video-preview-height',
56                               default=PREVIEW_HEIGHT)
57
58ACCEPTABLE_TAGS = [
59    Gst.TAG_ALBUM_ARTIST,
60    Gst.TAG_ARTIST,
61    Gst.TAG_TITLE,
62    Gst.TAG_ALBUM,
63    Gst.TAG_BITRATE,
64    Gst.TAG_COMPOSER,
65    Gst.TAG_GENRE,
66    Gst.TAG_PERFORMER,
67    Gst.TAG_DATE,
68    Gst.TAG_COPYRIGHT]
69
70
71class PreviewWidget(Gtk.Grid, Loggable):
72    """Widget for displaying a GStreamer sink with playback controls.
73
74    Args:
75        settings (GlobalSettings): The settings of the app.
76    """
77
78    def __init__(self, settings, minimal=False, discover_sync=False):
79        Gtk.Grid.__init__(self)
80        Loggable.__init__(self)
81
82        self.log("Init PreviewWidget")
83        self.settings = settings
84        self.error_message = None
85
86        # playbin for play pics
87        self.player = AssetPipeline(clip=None, name="preview-player")
88        self.player.connect('eos', self._pipelineEosCb)
89        self.player.connect('error', self._pipelineErrorCb)
90        self.player._bus.connect('message::tag', self._tag_found_cb)
91
92        # some global variables for preview handling
93        self.is_playing = False
94        self.at_eos = False
95        self.original_dims = (PREVIEW_WIDTH, PREVIEW_HEIGHT)
96        self.countinuous_seek = False
97        self.slider_being_used = False
98        self.current_selected_uri = ""
99        self.current_preview_type = ""
100        self.play_on_discover = False
101        self.description = ""
102
103        # Gui elements:
104        # Drawing area for video output
105        self.preview_video = ViewerWidget(self.player.sink_widget)
106        self.preview_video.props.hexpand = minimal
107        self.preview_video.props.vexpand = minimal
108        self.preview_video.show_all()
109        self.attach(self.preview_video, 0, 0, 1, 1)
110
111        # An image for images and audio
112        self.preview_image = Gtk.Image()
113        self.preview_image.set_size_request(
114            self.settings.FCpreviewWidth, self.settings.FCpreviewHeight)
115        self.preview_image.show()
116        self.attach(self.preview_image, 0, 1, 1, 1)
117
118        # Play button
119        self.bbox = Gtk.Box()
120        self.bbox.set_orientation(Gtk.Orientation.HORIZONTAL)
121        self.play_button = Gtk.ToolButton()
122        self.play_button.set_icon_name("media-playback-start")
123        self.play_button.connect("clicked", self._on_start_stop_clicked_cb)
124        self.bbox.pack_start(self.play_button, False, False, 0)
125
126        # Scale for position handling
127        self.pos_adj = Gtk.Adjustment()
128        self.seeker = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, self.pos_adj)
129        self.seeker.connect('button-press-event', self._on_seeker_press_cb)
130        self.seeker.connect('button-release-event', self._on_seeker_press_cb)
131        self.seeker.connect('motion-notify-event', self._on_motion_notify_cb)
132        self.seeker.set_draw_value(False)
133        self.seeker.show()
134        self.bbox.pack_start(self.seeker, True, True, 0)
135
136        # Zoom buttons
137        self.b_zoom_in = Gtk.ToolButton()
138        self.b_zoom_in.set_icon_name("zoom-in")
139        self.b_zoom_in.connect("clicked", self._on_zoom_clicked_cb, 1)
140        self.b_zoom_out = Gtk.ToolButton()
141        self.b_zoom_out.set_icon_name("zoom-out")
142        self.b_zoom_out.connect("clicked", self._on_zoom_clicked_cb, -1)
143        self.bbox.pack_start(self.b_zoom_in, False, False, 0)
144        self.bbox.pack_start(self.b_zoom_out, False, False, 0)
145        self.bbox.show_all()
146        self.attach(self.bbox, 0, 2, 1, 1)
147
148        # Label for metadata tags
149        self.l_tags = Gtk.Label()
150        self.l_tags.set_justify(Gtk.Justification.LEFT)
151        self.l_tags.set_ellipsize(Pango.EllipsizeMode.END)
152        self.l_tags.show()
153        self.attach(self.l_tags, 0, 3, 1, 1)
154
155        # Error handling
156        vbox = Gtk.Box()
157        vbox.set_orientation(Gtk.Orientation.VERTICAL)
158        vbox.set_spacing(SPACING)
159        self.l_error = Gtk.Label(label=_("Pitivi can not preview this file."))
160        self.b_details = Gtk.Button.new_with_label(_("More info"))
161        self.b_details.connect('clicked', self._on_b_details_clicked_cb)
162        vbox.pack_start(self.l_error, True, True, 0)
163        vbox.pack_start(self.b_details, False, False, 0)
164        vbox.show()
165        self.attach(vbox, 0, 4, 1, 1)
166
167        if minimal:
168            self.remove(self.l_tags)
169            self.bbox.remove(self.b_zoom_in)
170            self.bbox.remove(self.b_zoom_out)
171
172        self.clear_preview()
173        self._discover_sync = discover_sync
174
175    def update_preview_cb(self, file_chooser):
176        """Previews the URI of the specified file chooser.
177
178        Args:
179            file_chooser (Gtk.FileChooser): The file chooser providing the URI.
180        """
181        uri = file_chooser.get_preview_uri()
182        previewable = uri and uri_is_valid(uri)
183        if not previewable:
184            self.clear_preview()
185            return
186        self.preview_uri(uri)
187
188    def preview_uri(self, uri):
189        self.log("Preview request for %s", uri)
190        self.clear_preview()
191        self.current_selected_uri = uri
192
193        if not self._discover_sync:
194            GES.UriClipAsset.new(uri, None, self.__asset_loaded_cb)
195        else:
196            self._handle_new_asset(uri=uri)
197
198    def _handle_new_asset(self, async_result=None, uri=None):
199        try:
200            if uri:
201                asset = GES.UriClipAsset.request_sync(uri)
202            else:
203                asset = GES.Asset.request_finish(async_result)
204                uri = asset.get_id()
205        except GLib.Error as error:
206            self.log("Failed discovering %s: %s", uri, error.message)
207            self._show_error(error.message)
208            return
209
210        self.log("Discovered %s", uri)
211        if not self._show_preview(uri, asset.get_info()):
212            return
213        if self.play_on_discover:
214            self.play_on_discover = False
215            self.play()
216
217    def __asset_loaded_cb(self, source, res):
218        self._handle_new_asset(async_result=res)
219
220    def _show_preview(self, uri, info):
221        self.log("Show preview for %s", uri)
222        duration = info.get_duration()
223        pretty_duration = beautify_length(duration)
224
225        videos = info.get_video_streams()
226        if videos:
227            video = videos[0]
228            if video.is_image():
229                self.current_preview_type = 'image'
230                self.preview_video.hide()
231                path = Gst.uri_get_location(uri)
232                try:
233                    pixbuf = GdkPixbuf.Pixbuf.new_from_file(path)
234                except GLib.Error as error:
235                    self.debug("Failed loading image because: %s", error)
236                    self._show_error(error.message)
237                    return False
238                pixbuf_w = pixbuf.get_width()
239                pixbuf_h = pixbuf.get_height()
240                w, h = self.__get_best_size(pixbuf_w, pixbuf_h)
241                pixbuf = pixbuf.scale_simple(
242                    w, h, GdkPixbuf.InterpType.NEAREST)
243                self.preview_image.set_from_pixbuf(pixbuf)
244                self.preview_image.set_size_request(
245                    self.settings.FCpreviewWidth, self.settings.FCpreviewHeight)
246                self.preview_image.show()
247                self.bbox.show()
248                self.play_button.hide()
249                self.seeker.hide()
250                self.b_zoom_in.show()
251                self.b_zoom_out.show()
252            else:
253                self.current_preview_type = 'video'
254                self.preview_image.hide()
255                self.player.setClipUri(self.current_selected_uri)
256                self.player.setState(Gst.State.PAUSED)
257                self.pos_adj.props.upper = duration
258                video_width = video.get_square_width()
259                video_height = video.get_height()
260                w, h = self.__get_best_size(video_width, video_height)
261                self.preview_video.set_size_request(w, h)
262                aspect_ratio = video_width / video_height
263                self.preview_video.setDisplayAspectRatio(aspect_ratio)
264                self.preview_video.show()
265                self.bbox.show()
266                self.play_button.show()
267                self.seeker.show()
268                self.b_zoom_in.show()
269                self.b_zoom_out.show()
270                self.description = "\n".join([
271                    _("<b>Resolution</b>: %d×%d") % (
272                        video_width, video_height),
273                    _("<b>Duration</b>: %s") % pretty_duration])
274        else:
275            self.current_preview_type = 'audio'
276            self.preview_video.hide()
277            audio = info.get_audio_streams()
278            if not audio:
279                self.debug("Audio has no streams")
280                return False
281
282            audio = audio[0]
283            self.pos_adj.props.upper = duration
284            self.preview_image.set_from_icon_name(
285                "audio-x-generic", Gtk.IconSize.DIALOG)
286            self.preview_image.show()
287            self.preview_image.set_size_request(PREVIEW_WIDTH, PREVIEW_HEIGHT)
288            self.description = "\n".join([
289                beautify_stream(audio),
290                _("<b>Duration</b>: %s") % pretty_duration])
291            self.player.setState(Gst.State.NULL)
292            self.player.setClipUri(self.current_selected_uri)
293            self.player.setState(Gst.State.PAUSED)
294            self.play_button.show()
295            self.seeker.show()
296            self.b_zoom_in.hide()
297            self.b_zoom_out.hide()
298            self.bbox.show()
299        return True
300
301    def _show_error(self, error_message):
302        self.error_message = error_message
303        self.l_error.show()
304        self.b_details.show()
305
306    def play(self):
307        if not self.current_preview_type:
308            self.play_on_discover = True
309            return
310        if self.at_eos:
311            # The content played once already and the pipeline is at the end.
312            self.at_eos = False
313            self.player.simple_seek(0)
314        self.player.setState(Gst.State.PLAYING)
315        self.is_playing = True
316        self.play_button.set_stock_id(Gtk.STOCK_MEDIA_PAUSE)
317        GLib.timeout_add(250, self._update_position)
318        self.debug("Preview started")
319
320    def pause(self, state=Gst.State.PAUSED):
321        if state is not None:
322            self.player.setState(state)
323        self.is_playing = False
324        self.play_button.set_stock_id(Gtk.STOCK_MEDIA_PLAY)
325        self.log("Preview paused")
326
327    def togglePlayback(self):
328        if self.is_playing:
329            self.pause()
330        else:
331            self.play()
332
333    def clear_preview(self):
334        self.log("Reset PreviewWidget")
335        self.seeker.set_value(0)
336        self.bbox.hide()
337        self.l_error.hide()
338        self.b_details.hide()
339        self.description = ""
340        self.l_tags.set_markup("")
341        self.pause(state=Gst.State.NULL)
342        self.current_selected_uri = ""
343        self.current_preview_type = ""
344        self.preview_image.hide()
345        self.preview_video.hide()
346
347    def _on_seeker_press_cb(self, widget, event):
348        self.slider_being_used = True
349        if event.type == Gdk.EventType.BUTTON_PRESS:
350            self.countinuous_seek = True
351            if self.is_playing:
352                self.player.setState(Gst.State.PAUSED)
353        elif event.type == Gdk.EventType.BUTTON_RELEASE:
354            self.countinuous_seek = False
355            value = int(widget.get_value())
356            self.player.simple_seek(value)
357            self.at_eos = False
358            if self.is_playing:
359                self.player.setState(Gst.State.PLAYING)
360            # Now, allow gobject timeout to continue updating the slider pos:
361            self.slider_being_used = False
362
363    def _on_motion_notify_cb(self, widget, event):
364        if self.countinuous_seek:
365            value = int(widget.get_value())
366            self.player.simple_seek(value)
367            self.at_eos = False
368
369    def _pipelineEosCb(self, unused_pipeline):
370        self._update_position()
371        self.pause()
372        # The pipeline is at the end. Leave it like that so the last frame
373        # is displayed.
374        self.at_eos = True
375
376    def _pipelineErrorCb(self, unused_pipeline, unused_message, unused_detail):
377        self.pause(state=Gst.State.NULL)
378
379    def _update_position(self, *unused_args):
380        if self.is_playing and not self.slider_being_used:
381            curr_pos = self.player.getPosition()
382            self.pos_adj.set_value(int(curr_pos))
383        return self.is_playing
384
385    def _on_start_stop_clicked_cb(self, button):
386        self.togglePlayback()
387
388    def _on_zoom_clicked_cb(self, button, increment):
389        if self.current_preview_type == 'video':
390            w, h = self.preview_video.get_size_request()
391            if increment > 0:
392                w *= 1.2
393                h *= 1.2
394            else:
395                w *= 0.8
396                h *= 0.8
397                if (w, h) < self.original_dims:
398                    (w, h) = self.original_dims
399            self.preview_video.set_size_request(int(w), int(h))
400            self.settings.FCpreviewWidth = int(w)
401            self.settings.FCpreviewHeight = int(h)
402        elif self.current_preview_type == 'image':
403            pixbuf = self.preview_image.get_pixbuf()
404            w = pixbuf.get_width()
405            h = pixbuf.get_height()
406            if increment > 0:
407                w *= 1.2
408                h *= 1.2
409            else:
410                w *= 0.8
411                h *= 0.8
412                if (w, h) < self.original_dims:
413                    (w, h) = self.original_dims
414            pixbuf = GdkPixbuf.Pixbuf.new_from_file(
415                Gst.uri_get_location(self.current_selected_uri))
416            pixbuf = pixbuf.scale_simple(
417                int(w), int(h), GdkPixbuf.InterpType.BILINEAR)
418
419            self.preview_image.set_size_request(int(w), int(h))
420            self.preview_image.set_from_pixbuf(pixbuf)
421            self.preview_image.show()
422            self.settings.FCpreviewWidth = int(w)
423            self.settings.FCpreviewHeight = int(h)
424
425    def _append_tag(self, taglist, tag, tags):
426        if tag in ACCEPTABLE_TAGS:
427            name = Gst.tag_get_nick(tag)
428            type = Gst.tag_get_type(tag)
429            type_getters = {GObject.TYPE_STRING: 'get_string',
430                            GObject.TYPE_DOUBLE: 'get_double',
431                            GObject.TYPE_FLOAT: 'get_float',
432                            GObject.TYPE_INT: 'get_int',
433                            GObject.TYPE_UINT: 'get_uint'}
434            if type in type_getters:
435                res, value = getattr(taglist, type_getters[type])(tag)
436                assert res
437                if not type == GObject.TYPE_STRING:
438                    value = str(value)
439                tags[name] = value
440
441    def _tag_found_cb(self, unused_bus, message):
442        tag_list = message.parse_tag()
443        tags = {}
444        tag_list.foreach(self._append_tag, tags)
445        items = list(tags.items())
446        items.sort()
447        text = self.description + "\n\n"
448        for key, value in items:
449            capitalized = key.capitalize()
450            escaped = html.escape(value)
451            text = text + "<b>%s</b>: %s\n" % (key, escaped)
452        self.l_tags.set_markup(text)
453
454    def _on_b_details_clicked_cb(self, unused_button):
455        if not self.error_message:
456            return
457
458        dialog = Gtk.MessageDialog(transient_for=None,
459                                   modal=True,
460                                   message_type=Gtk.MessageType.WARNING,
461                                   buttons=Gtk.ButtonsType.OK,
462                                   text=self.error_message)
463        dialog.set_icon_name("pitivi")
464        dialog.set_title(_("Error while analyzing a file"))
465        dialog.run()
466        dialog.destroy()
467
468    def do_destroy(self):
469        """Handles the destruction of the widget."""
470        self.player.release()
471        self.is_playing = False
472
473    def __get_best_size(self, width_in, height_in):
474        if width_in > height_in:
475            if self.settings.FCpreviewWidth < width_in:
476                w = self.settings.FCpreviewWidth
477                h = height_in * w / width_in
478                return (w, h)
479        else:
480            if self.settings.FCpreviewHeight < height_in:
481                h = self.settings.FCpreviewHeight
482                w = width_in * h / height_in
483                return (w, h)
484        return (width_in, height_in)
485