import os import sys import copy import tempfile import urllib import urlparse from lvc.converter import ConverterInfo from lvc.video import VideoFile from lvc.resources import image_path from lvc.utils import size_string, round_even, convert_path_for_subprocess from lvc import openfiles from lvc.widgets import (initialize, idle_add, mainloop_start, mainloop_stop, attach_menubar, reveal_file, get_conversion_directory) from lvc.widgets import menus from lvc.widgets import widgetset from lvc.widgets import cellpack from lvc.widgets import widgetconst from lvc.widgets import widgetutil from lvc.widgets import app import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) try: import lvc except ImportError: lvc_path = os.path.join(os.path.dirname(__file__), '..', '..') sys.path.append(lvc_path) import lvc BUTTON_FONT = widgetutil.font_scale_from_osx_points(15.0) LARGE_FONT = widgetutil.font_scale_from_osx_points(13.0) SMALL_FONT = widgetutil.font_scale_from_osx_points(10.0) DEFAULT_FONT = "Helvetica" CONVERT_TO_FONT = "Gill Sans Light" CONVERT_TO_FONTSIZE = widgetutil.font_scale_from_osx_points(14.0) SETTINGS_FONT = "Gill Sans Light" SETTINGS_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) CONVERT_NOW_FONT = "Gill Sans Light" CONVERT_NOW_FONTSIZE = widgetutil.font_scale_from_osx_points(18.0) DND_FONT = "Gill Sans Light" DND_LARGE_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) DND_SMALL_FONTSIZE = widgetutil.font_scale_from_osx_points(12.0) ITEM_TITLE_FONT = "Futura Medium" ITEM_TITLE_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) ITEM_ICONS_FONT = "Century Gothic" ITEM_ICONS_FONTSIZE = widgetutil.font_scale_from_osx_points(10.0) GRADIENT_TOP = widgetutil.css_to_color('#585f63') GRADIENT_BOTTOM = widgetutil.css_to_color('#383d40') DRAG_AREA = widgetutil.css_to_color('#2b2e31') TEXT_DISABLED = widgetutil.css_to_color('#333333') TEXT_ACTIVE = widgetutil.css_to_color('#ffffff') TEXT_CLICKED = widgetutil.css_to_color('#cccccc') TEXT_INFO = widgetutil.css_to_color('#808080') TEXT_COLOR = widgetutil.css_to_color('#ffffff') TEXT_SHADOW = widgetutil.css_to_color('#000000') TABLE_WIDTH, TABLE_HEIGHT = 470, 87 class CustomLabel(widgetset.Background): def __init__(self, text=''): widgetset.Background.__init__(self) self.text = text self.font = DEFAULT_FONT self.font_scale = LARGE_FONT self.color = TEXT_COLOR def set_text(self, text): self.text = text self.invalidate_size_request() def set_color(self, color): self.color = color self.queue_redraw() def set_font(self, font, font_scale): self.font = font self.font_scale = font_scale self.invalidate_size_request() def textbox(self, layout_manager): layout_manager.set_text_color(self.color) layout_manager.set_font(self.font_scale, family=self.font) font = layout_manager.set_font(self.font_scale, family=self.font) return layout_manager.textbox(self.text) def draw(self, context, layout_manager): layout_manager.set_text_color(self.color) layout_manager.set_font(LARGE_FONT, family=self.font) textbox = self.textbox(layout_manager) size = textbox.get_size() textbox.draw(context, 0, (context.height - size[1]) // 2, context.width, context.height) def size_request(self, layout_manager): return self.textbox(layout_manager).get_size() class WebStyleButton(widgetset.CustomButton): def __init__(self): super(WebStyleButton, self).__init__() self.set_cursor(widgetconst.CURSOR_POINTING_HAND) self.text = '' self.font = DEFAULT_FONT self.font_scale = LARGE_FONT def set_text(self, text): self.text = text self.invalidate_size_request() def set_font(self, font, font_scale): self.font = font self.font_scale = font_scale self.invalidate_size_request() def textbox(self, layout_manager): return layout_manager.textbox(self.text, underline=True) def size_request(self, layout_manager): textbox = self.textbox(layout_manager) return textbox.get_size() def draw(self, context, layout_manager): layout_manager.set_text_color(TEXT_COLOR) layout_manager.set_font(self.font_scale, family=self.font) textbox = self.textbox(layout_manager) size = textbox.get_size() textbox.draw(context, 0, (context.height - size[1]) // 2, context.width, context.height) class FileDropTarget(widgetset.SolidBackground): dropoff_on = widgetset.ImageDisplay(widgetset.Image( image_path("dropoff-icon-on.png"))) dropoff_off = widgetset.ImageDisplay(widgetset.Image( image_path("dropoff-icon-off.png"))) dropoff_small_on = widgetset.ImageDisplay(widgetset.Image( image_path("dropoff-icon-small-on.png"))) dropoff_small_off = widgetset.ImageDisplay(widgetset.Image( image_path("dropoff-icon-small-off.png"))) def __init__(self): super(FileDropTarget, self).__init__() self.set_background_color(DRAG_AREA) self.alignment = widgetset.Alignment( xscale=0.0, yscale=0.5, xalign=0.5, yalign=0.5, top_pad=10, right_pad=40, bottom_pad=10, left_pad=40) self.add(self.alignment) self.widgets = { False: self.build_large_widgets(), True: self.build_small_widgets() } self.normal, self.drag = self.widgets[False] self.alignment.add(self.normal) self.in_drag = False self.small = False def build_large_widgets(self): height = 40 # arbitrary, but the same for both normal = widgetset.VBox(spacing=20) normal.pack_start(widgetutil.align_center(self.dropoff_off, top_pad=60)) label = CustomLabel("Drag videos here or") label.set_color(TEXT_COLOR) label.set_font(DND_FONT, DND_LARGE_FONTSIZE) hbox = widgetset.HBox(spacing=4) hbox.pack_start(widgetutil.align_middle(label)) cfb = WebStyleButton() cfb.set_font(DND_FONT, DND_LARGE_FONTSIZE) cfb.set_text('Choose Files...') cfb.connect('clicked', self.choose_file) hbox.pack_start(widgetutil.align_middle(cfb)) hbox.set_size_request(-1, height) normal.pack_start(hbox) drag = widgetset.VBox(spacing=20) drag.pack_start(widgetutil.align_center(self.dropoff_on, top_pad=60)) hbox = widgetset.HBox(spacing=4) hbox.pack_start(widgetutil.align_center( widgetset.Label("Release button to drop off", color=TEXT_COLOR))) hbox.set_size_request(-1, height) drag.pack_start(hbox) return normal, drag def build_small_widgets(self): height = 40 # arbitrary, but the same for both normal = widgetset.HBox(spacing=4) normal.pack_start(widgetutil.align_middle(self.dropoff_small_off, right_pad=7)) drag_label = CustomLabel('Drag more videos here or') drag_label.set_font(DND_FONT, DND_SMALL_FONTSIZE) drag_label.set_color(TEXT_COLOR) normal.pack_start(widgetutil.align_middle(drag_label)) cfb = WebStyleButton() cfb.set_text('Choose Files...') cfb.set_font(DND_FONT, DND_SMALL_FONTSIZE) cfb.connect('clicked', self.choose_file) normal.pack_start(cfb) normal.set_size_request(-1, height) drop_label = CustomLabel('Release button to drop off') drop_label.set_font(DND_FONT, DND_SMALL_FONTSIZE) drop_label.set_color(TEXT_COLOR) drag = widgetset.HBox(spacing=10) drag.pack_start(widgetutil.align_middle(self.dropoff_small_on)) drag.pack_start(widgetutil.align_middle(drop_label)) drag.set_size_request(-1, height) return normal, drag def set_small(self, small): if small != self.small: self.small = small self.normal, self.drag = self.widgets[small] self.set_in_drag(self.in_drag, force=True) def set_in_drag(self, in_drag, force=False): if force or in_drag != self.in_drag: self.in_drag = in_drag if in_drag: self.alignment.set_child(self.drag) else: self.alignment.set_child(self.normal) self.queue_redraw() def choose_file(self, widget): app.widgetapp.choose_file() BUTTON_BACKGROUND = widgetutil.ThreeImageSurface('settings-base') class SettingsButton(widgetset.CustomButton): arrow_on = widgetset.ImageSurface(widgetset.Image( image_path('arrow-down-on.png'))) arrow_off = widgetset.ImageSurface(widgetset.Image( image_path('arrow-down-off.png'))) def __init__(self, name): super(SettingsButton, self).__init__() if name != 'settings': self.name = name.title() else: self.name = None self.selected = False if name != 'format': self.surface_on = widgetset.ImageSurface(widgetset.Image( image_path('%s-icon-on.png' % name))) self.surface_off = widgetset.ImageSurface(widgetset.Image( image_path('%s-icon-off.png' % name))) if self.surface_on.height != self.surface_off.height: raise ValueError('invalid surface: height mismatch') self.image_padding = self.calc_image_padding(name) else: self.surface_on = self.surface_off = None def calc_image_padding(self, name): """Add some padding to the bottom of our image icon. This can be used to fine tune where it gets placed. :returns: padding in as a (top, right, bottom, left) tuple """ # NOTE: we vertically center the images, so in order to move it X # pickels up, we need X*2 pixels of bottom padding if name == 'android': return (0, 0, 2, 0) elif name in ('apple', 'other'): return (0, 0, 4, 0) else: return (0, 0, 0, 0) def textbox(self, layout_manager): layout_manager.set_font(SETTINGS_FONTSIZE, family=SETTINGS_FONT) return layout_manager.textbox(self.name) def size_request(self, layout_manager): hbox = self.build_hbox(layout_manager) size = hbox.get_size() height = max(BUTTON_BACKGROUND.height, size[1]) return int(size[0]) + 2, int(height) + 2 # padding def build_hbox(self, layout_manager): hbox = cellpack.HBox(spacing=5) if self.selected: image = self.surface_on arrow = self.arrow_on layout_manager.set_text_color(TEXT_ACTIVE) else: image = self.surface_off arrow = self.arrow_off layout_manager.set_text_color(TEXT_DISABLED) if image: padding = cellpack.Padding(image, *self.image_padding) hbox.pack(cellpack.Alignment(padding, xscale=0, yscale=0, yalign=0.5)) if self.name: vbox = cellpack.VBox() textbox = self.textbox(layout_manager) vbox.pack(textbox) vbox.pack_space(1) hbox.pack(cellpack.Alignment(vbox, yscale=0, yalign=0.5), expand=True) a = cellpack.Alignment(arrow, xscale=0, yscale=0, yalign=0.5) hbox.pack(cellpack.Padding(a, left=5, right=12)) alignment = cellpack.Padding(hbox, left=5) return alignment def draw(self, context, layout_manager): BUTTON_BACKGROUND.draw(context, 1, 1, context.width - 2) alignment = self.build_hbox(layout_manager) padding = cellpack.Padding(alignment, top=1, right=3, bottom=1, left=3) padding.render_layout(context) def set_selected(self, selected): self.selected = selected self.queue_redraw() class OptionMenuBackground(widgetset.Background): def __init__(self): widgetset.Background.__init__(self) self.surface = widgetutil.ThreeImageSurface('settings-depth') def set_child(self, child): widgetset.Background.set_child(self, child) # re-create the image surface and scale it as it needs to cover # the whole of the height of the child _, h = child.get_size_request() self.surface = widgetutil.ThreeImageSurface('settings-depth', height=h) self.invalidate_size_request() def size_request(self, layout_manager): return -1, self.surface.height def draw(self, context, layout_manager): child_width = self.child.get_size_request()[0] self.surface.draw(context, 0, 0, child_width) class BottomBackground(widgetset.Background): def draw(self, context, layout_manager): gradient = widgetset.Gradient(0, 0, 0, context.height) gradient.set_start_color(GRADIENT_TOP) gradient.set_end_color(GRADIENT_BOTTOM) context.rectangle(0, 0, context.width, context.height) context.gradient_fill(gradient) class LabeledNumberEntry(widgetset.HBox): def __init__(self, label): super(LabeledNumberEntry, self).__init__(spacing=5) self.label = widgetset.Label(label, color=TEXT_COLOR) self.label.set_size(widgetconst.SIZE_SMALL) self.entry = widgetset.NumberEntry() self.entry.set_size_request(50, 20) self.pack_start(self.label) self.pack_start(self.entry) self.entry.connect('focus-out', lambda x: self.emit('focus-out')) def get_text(self): return self.entry.get_text() def set_text(self, text): self.entry.set_text(text) def get_value(self): try: return int(self.entry.get_text()) except ValueError: return None class CustomOptions(widgetset.Background): background = widgetset.ImageSurface(widgetset.Image( image_path('settings-dropdown-bottom-bg.png'))) def __init__(self): super(CustomOptions, self).__init__() self.create_signal('setting-changed') self.reset() def reset(self): self.options = { 'destination': None, 'custom-size': False, 'width': None, 'height': None, 'custom-aspect': False, 'aspect-ratio': 4.0/3.0, 'dont-upsize': True } self.top = self.create_top() self.top.set_size_request(390, 50) self.left = self.create_left() self.left.set_size_request(212, 70) self.right = self.create_right() self.right.set_size_request(178, 70) vbox = widgetset.VBox() vbox.pack_start(self.top) hbox = widgetset.HBox() hbox.pack_start(self.left) hbox.pack_start(self.right) vbox.pack_start(hbox) self.box = widgetutil.align_left(vbox) if self.child: self.set_child(self.box) def create_top(self): hbox = widgetset.HBox(spacing=0) path_label = WebStyleButton() path_label.set_text('Show output folder') path_label.set_font(DEFAULT_FONT, widgetconst.SIZE_SMALL) path_label.connect('clicked', self.on_path_label_clicked) create_thumbnails = widgetset.Checkbox('Create Thumbnails', color=TEXT_COLOR) create_thumbnails.set_size(widgetconst.SIZE_SMALL) create_thumbnails.connect('toggled', self.on_create_thumbnails_changed) hbox.pack_start(widgetutil.align(path_label, xalign=0.5), expand=True) hbox.pack_start(widgetutil.align(create_thumbnails, xalign=0.5), expand=True) # XXX: disabled until we can figure out how to do this properly. # button = widgetset.Button('...') # button.connect('clicked', self.on_destination_clicked) # reset = widgetset.Button('Reset') # reset.connect('clicked', self.on_destination_reset) # hbox.pack_start(button) # hbox.pack_start(reset) return widgetutil.align(hbox, xscale=1.0, yalign=0.5) def _get_save_to_path(self): if self.options['destination'] is None: return get_conversion_directory() else: return self.options['destination'] def on_path_label_clicked(self, label): save_path = self._get_save_to_path() save_path = convert_path_for_subprocess(save_path) openfiles.reveal_folder(save_path) def create_left(self): self.custom_size = widgetset.Checkbox('Custom Size', color=TEXT_COLOR) self.custom_size.set_size(widgetconst.SIZE_SMALL) self.custom_size.connect('toggled', self.on_custom_size_changed) dont_upsize = widgetset.Checkbox('Don\'t Upsize', color=TEXT_COLOR) dont_upsize.set_checked(self.options['dont-upsize']) dont_upsize.set_size(widgetconst.SIZE_SMALL) dont_upsize.connect('toggled', self.on_dont_upsize_changed) bottom = widgetset.HBox(spacing=5) self.width_widget = LabeledNumberEntry('Width') self.width_widget.connect('focus-out', self.on_width_changed) self.width_widget.entry.connect('activate', self.on_width_changed) self.width_widget.disable() self.height_widget = LabeledNumberEntry('Height') self.height_widget.connect('focus-out', self.on_height_changed) self.height_widget.entry.connect('activate', self.on_height_changed) self.height_widget.disable() bottom.pack_start(self.width_widget) bottom.pack_start(self.height_widget) hbox = widgetset.HBox(spacing=5) hbox.pack_start(self.custom_size) hbox.pack_start(dont_upsize) vbox = widgetset.VBox(spacing=5) vbox.pack_start(widgetutil.align_left(hbox, left_pad=10)) vbox.pack_start(widgetutil.align_center(bottom)) return widgetutil.align_middle(vbox) def create_right(self): aspect = widgetset.Checkbox('Custom Aspect Ratio', color=TEXT_COLOR) aspect.set_size(widgetconst.SIZE_SMALL) aspect.connect('toggled', self.on_aspect_changed) self.aspect_widget = aspect self.button_group = widgetset.RadioButtonGroup() b1 = widgetset.RadioButton('4:3', self.button_group, color=TEXT_COLOR) b2 = widgetset.RadioButton('3:2', self.button_group, color=TEXT_COLOR) b3 = widgetset.RadioButton('16:9', self.button_group, color=TEXT_COLOR) b1.set_selected() b1.set_size(widgetconst.SIZE_SMALL) b2.set_size(widgetconst.SIZE_SMALL) b3.set_size(widgetconst.SIZE_SMALL) self.aspect_map = dict() self.aspect_map[b1] = (4, 3) self.aspect_map[b2] = (3, 2) self.aspect_map[b3] = (16, 9) hbox = widgetset.HBox(spacing=5) # Because the custom size starts off as disabled, so should aspect # ratio as aspect ratio is dependent on a custom size set. self.aspect_widget.disable() for button in self.button_group.get_buttons(): button.disable() button.set_size(widgetconst.SIZE_SMALL) hbox.pack_start(button) button.connect('clicked', self.on_aspect_size_changed) vbox = widgetset.VBox() vbox.pack_start(widgetutil.align_center(aspect)) vbox.pack_start(widgetutil.align_center(hbox)) return widgetutil.align_middle(vbox) def draw(self, context, layout_manager): self.background.draw(context, 0, 0, self.background.width, self.background.height) def enable_custom_size(self): self.custom_size.enable() def disable_custom_size(self): self.custom_size.disable() self.custom_size.set_checked(False) def update_setting(self, setting, value): self.options[setting] = value if setting in ('width', 'height'): if value is not None: widget_text = str(value) else: widget_text = '' if setting == 'width': self.width_widget.set_text(widget_text) elif setting == 'height': self.height_widget.set_text(widget_text) def do_setting_changed(self, setting, value): logging.info('setting-changed: %s -> %s', setting, value) def _change_setting(self, setting, value): """Handles setting changes in response to widget changes.""" self.options[setting] = value self.emit('setting-changed', setting, value) def force_width_to_aspect_ratio(self): aspect_ratio = self.options['aspect-ratio'] width = self.width_widget.get_text() height = self.height_widget.get_text() if not height: return new_width = round_even(float(height) * aspect_ratio) if new_width != width: self.update_setting('width', new_width) self.emit('setting-changed', 'width', new_width) def force_height_to_aspect_ratio(self): aspect_ratio = self.options['aspect-ratio'] width = self.width_widget.get_text() height = self.height_widget.get_text() if not width: return new_height = round_even(float(width) / aspect_ratio) if new_height != height: self.update_setting('height', new_height) self.emit('setting-changed', 'height', new_height) def show(self): self.set_child(self.box) self.set_size_request(self.background.width, self.background.height + 28) self.queue_redraw() def hide(self): self.remove() self.set_size_request(0, 0) self.queue_redraw() def toggle(self): if self.child: self.hide() else: self.show() # signal handlers def on_destination_clicked(self, widget): dialog = widgetset.DirectorySelectDialog('Destination Directory') r = dialog.run() if r == 0: # picked a directory self._change_setting('destination', directory) def on_destination_reset(self, widget): self._change_setting('destination', None) def on_dont_upsize_changed(self, widget): self._change_setting('dont-upsize', widget.get_checked()) def on_custom_size_changed(self, widget): self._change_setting('custom-size', widget.get_checked()) if widget.get_checked(): self.width_widget.enable() self.height_widget.enable() self.aspect_widget.enable() self.on_aspect_changed(self.aspect_widget) else: self.width_widget.disable() self.height_widget.disable() self.aspect_widget.disable() self.on_aspect_changed(self.aspect_widget) for button in self.button_group.get_buttons(): button.disable() def on_create_thumbnails_changed(self, widget): self._change_setting('create-thumbnails', widget.get_checked()) def on_width_changed(self, widget): self._change_setting('width', self.width_widget.get_value()) if self.options['custom-aspect']: self.force_height_to_aspect_ratio() def on_height_changed(self, widget): self._change_setting('height', self.height_widget.get_value()) if self.options['custom-aspect']: self.force_width_to_aspect_ratio() def on_aspect_changed(self, widget): self._change_setting('custom-aspect', widget.get_checked()) if widget.get_checked(): self.force_height_to_aspect_ratio() for button in self.button_group.get_buttons(): button.enable() else: for button in self.button_group.get_buttons(): button.disable() def on_aspect_size_changed(self, widget): if self.options['custom-aspect']: width_ratio, height_ratio = [float(v) for v in self.aspect_map[widget]] ratio = width_ratio / height_ratio self._change_setting('aspect-ratio', ratio) self.force_height_to_aspect_ratio() EMPTY_CONVERTER = ConverterInfo("") class ConversionModel(widgetset.TableModel): def __init__(self): super(ConversionModel, self).__init__( 'text', # filename 'numeric', # output_size 'text', # converter 'text', # status 'numeric', # duration 'numeric', # progress 'numeric', # eta, 'object', # image 'object', # the actual conversion ) self.conversion_to_iter = {} self.thumbnail_to_image = {None: widgetset.Image( image_path('audio.png'))} def conversions(self): return iter(self.conversion_to_iter) def all_conversions_done(self): has_conversions = any(self.conversions()) all_done = ((set(c.status for c in self.conversions()) - set(['canceled', 'finished', 'failed'])) == set()) return all_done and has_conversions def get_image(self, path): if path not in self.thumbnail_to_image: try: image = widgetset.Image(path) except ValueError: image = self.thumbnail_to_image[None] self.thumbnail_to_image[path] = image return self.thumbnail_to_image[path] def update_conversion(self, conversion): try: output_size = os.stat(conversion.output).st_size except OSError: output_size = 0 def complete(): # needs to do it on the update_conversion() from app object # which calls model_changed() and redraws for us app.widgetapp.update_conversion(conversion) values = (conversion.video.filename, output_size, conversion.converter.name, conversion.status, conversion.duration or 0, conversion.progress or 0, conversion.eta or 0, self.get_image(conversion.video.get_thumbnail(complete, 90, 70)), conversion) iter_ = self.conversion_to_iter.get(conversion) if iter_ is None: self.conversion_to_iter[conversion] = self.append(*values) else: self.update(iter_, *values) def remove(self, iter_): conversion = self[iter_][-1] del self.conversion_to_iter[conversion] # XXX If we add/remove too quickly, we could still be processing # thumbnails and this may return null, and the self.thumbnail_to_image # dictionary may get out of sync def complete(path): logging.info('calling completion handler for get_thumbnail on ' 'removal') thumbnail_path = conversion.video.get_thumbnail(complete, 90, 70) if thumbnail_path: del self.thumbnail_to_image[thumbnail_path] return super(ConversionModel, self).remove(iter_) class IconWithText(cellpack.HBox): def __init__(self, icon, textbox): super(IconWithText, self).__init__(spacing=5) self.pack(cellpack.Alignment(icon, yalign=0.5, xscale=0, yscale=0)) self.pack(textbox) class ConversionCellRenderer(widgetset.CustomCellRenderer): IGNORE_PADDING = True clear = widgetset.ImageSurface(widgetset.Image( image_path("clear-icon.png"))) converted_to = widgetset.ImageSurface(widgetset.Image( image_path("converted_to-icon.png"))) queued = widgetset.ImageSurface(widgetset.Image( image_path("queued-icon.png"))) showfile = widgetset.ImageSurface(widgetset.Image( image_path("showfile-icon.png"))) show_ffmpeg = widgetset.ImageSurface(widgetset.Image( image_path("error-icon.png"))) progressbar_base = widgetset.ImageSurface(widgetset.Image( image_path("progressbar-base.png"))) delete_on = widgetset.ImageSurface(widgetset.Image( image_path("item-delete-button-on.png"))) delete_off = widgetset.ImageSurface(widgetset.Image( image_path("item-delete-button-off.png"))) error = widgetset.ImageSurface(widgetset.Image( image_path("item-error.png"))) completed = widgetset.ImageSurface(widgetset.Image( image_path("item-completed.png"))) def __init__(self): super(ConversionCellRenderer, self).__init__() self.alignment = None def get_size(self, style, layout_manager): return TABLE_WIDTH, TABLE_HEIGHT def render(self, context, layout_manager, selected, hotspot, hover): left_right = cellpack.HBox() top_bottom = cellpack.VBox() left_right.pack(self.layout_left(layout_manager)) left_right.pack(top_bottom, expand=True) layout_manager.set_text_color(TEXT_COLOR) layout_manager.set_font(ITEM_TITLE_FONTSIZE, bold=True, family=ITEM_TITLE_FONT) title = layout_manager.textbox(os.path.basename(self.input)) title.set_wrap_style('truncated-char') alignment = cellpack.Padding(cellpack.TruncatedTextLine(title), top=25) top_bottom.pack(alignment) layout_manager.set_font(ITEM_ICONS_FONTSIZE, family=ITEM_ICONS_FONT) bottom = self.layout_bottom(layout_manager, hotspot) if bottom is not None: top_bottom.pack(bottom) left_right.pack(self.layout_right(layout_manager, hotspot)) alignment = cellpack.Alignment(left_right, yscale=0, yalign=0.5) self.alignment = alignment background = cellpack.Background(alignment) background.set_callback(self.draw_background) background.render_layout(context) @staticmethod def draw_background(context, x, y, width, height): # draw main background gradient = widgetset.Gradient(x, y, x, height) gradient.set_start_color(GRADIENT_TOP) gradient.set_end_color(GRADIENT_BOTTOM) context.rectangle(x, y, width, height) context.gradient_fill(gradient) # draw bottom line context.set_line_width(1) context.set_color((0, 0, 0)) context.move_to(0, height-0.5) context.line_to(context.width, height-0.5) context.stroke() def draw_progressbar(self, context, x, y, _, height, width): # We're only drawing a certain amount of width, not however much we're # allocated. So, we ignore the passed-in width and just use what we # set in layout_bottom. widgetutil.circular_rect(context, x, y, width-1, height-1) context.set_color((1, 1, 1)) context.fill() def layout_left(self, layout_manager): surface = widgetset.ImageSurface(self.thumbnail) return cellpack.Padding(surface, 10, 10, 10, 10) def layout_right(self, layout_manager, hotspot): alignment_kwargs = dict( xalign=0.5, xscale=0, yalign=0.5, yscale=0, min_width=80) if self.status == 'finished': return cellpack.Alignment(self.completed, **alignment_kwargs) elif self.status in ('canceled', 'failed'): return cellpack.Alignment(self.error, **alignment_kwargs) else: if hotspot == 'cancel': image = self.delete_on else: image = self.delete_off return cellpack.Alignment(cellpack.Hotspot('cancel', image), **alignment_kwargs) def layout_bottom(self, layout_manager, hotspot): layout_manager.set_text_color(TEXT_COLOR) if self.status in ('converting', 'staging'): box = cellpack.HBox(spacing=5) stack = cellpack.Stack() stack.pack(cellpack.Alignment(self.progressbar_base, yalign=0.5, xscale=0, yscale=0)) percent = self.progress / self.duration width = max(int(percent * self.progressbar_base.width), 5) stack.pack(cellpack.DrawingArea( width, self.progressbar_base.height, self.draw_progressbar, width)) box.pack(cellpack.Alignment(stack, yalign=0.5, xscale=0, yscale=0)) textbox = layout_manager.textbox("%d%%" % ( 100 * percent)) box.pack(textbox) return box elif self.status == 'initialized': # queued vbox = cellpack.VBox() vbox.pack_space(2) vbox.pack(IconWithText(self.queued, layout_manager.textbox("Queued"))) return vbox elif self.status in ('finished', 'failed', 'canceled'): vbox = cellpack.VBox(spacing=5) vbox.pack_space(4) top = cellpack.HBox(spacing=5) if self.status == 'finished': if hotspot == 'show-file': layout_manager.set_text_color(TEXT_CLICKED) top.pack(cellpack.Hotspot('show-file', IconWithText( self.showfile, layout_manager.textbox('Show File', underline=True)))) elif self.status in ('failed', 'canceled'): color = TEXT_CLICKED if hotspot == 'show-log' else TEXT_COLOR layout_manager.set_text_color(color) # XXX Missing grey error icon if self.status == 'failed': text = 'Error - Show FFmpeg Output' else: text = 'Canceled - Show FFmpeg Output' top.pack(cellpack.Hotspot('show-log', IconWithText( self.show_ffmpeg, layout_manager.textbox(text, underline=True)))) color = TEXT_CLICKED if hotspot == 'clear' else TEXT_COLOR layout_manager.set_text_color(color) top.pack(cellpack.Hotspot('clear', IconWithText( self.showfile, layout_manager.textbox('Clear', underline=True)))) vbox.pack(top) if self.status == 'finished': layout_manager.set_text_color(TEXT_INFO) vbox.pack(IconWithText( self.converted_to, layout_manager.textbox("Converted to %s" % ( size_string(self.output_size))))) return vbox def hotspot_test(self, style, layout_manager, x, y, width, height): if self.alignment is None: return hotspot_info = self.alignment.find_hotspot(x, y, width, height) if hotspot_info: return hotspot_info[0] class ConvertButton(widgetset.CustomButton): off = widgetset.ImageSurface(widgetset.Image( image_path("convert-button-off.png"))) clear = widgetset.ImageSurface(widgetset.Image( image_path("convert-button-off.png"))) on = widgetset.ImageSurface(widgetset.Image( image_path("convert-button-on.png"))) stop = widgetset.ImageSurface(widgetset.Image( image_path("convert-button-stop.png"))) def __init__(self): super(ConvertButton, self).__init__() self.hidden = False self.set_off() def set_on(self): self.label = 'Convert to %s' % app.widgetapp.current_converter.name self.image = self.on self.set_cursor(widgetconst.CURSOR_POINTING_HAND) self.queue_redraw() def set_clear(self): self.label = 'Clear and Start Over' self.image = self.clear self.set_cursor(widgetconst.CURSOR_POINTING_HAND) self.queue_redraw() def set_off(self): self.label = 'Convert Now' self.image = self.off self.set_cursor(widgetconst.CURSOR_NORMAL) self.queue_redraw() def set_stop(self): self.label = 'Stop All Conversions' self.image = self.stop self.set_cursor(widgetconst.CURSOR_POINTING_HAND) self.queue_redraw() def hide(self): self.hidden = True self.invalidate_size_request() self.queue_redraw() def show(self): self.hidden = False self.invalidate_size_request() self.queue_redraw() def size_request(self, layout_manager): if self.hidden: return 0, 0 return self.off.width, self.off.height def draw(self, context, layout_manager): if self.hidden: return self.image.draw(context, 0, 0, self.image.width, self.image.height) layout_manager.set_font(CONVERT_NOW_FONTSIZE, family=CONVERT_NOW_FONT) if self.image == self.off: layout_manager.set_text_shadow(widgetutil.Shadow(TEXT_SHADOW, 0.5, (-1, -1), 0)) layout_manager.set_text_color(TEXT_DISABLED) else: layout_manager.set_text_shadow(widgetutil.Shadow(TEXT_SHADOW, 0.5, (1, 1), 0)) layout_manager.set_text_color(TEXT_ACTIVE) textbox = layout_manager.textbox(self.label) alignment = cellpack.Alignment(textbox, xalign=0.5, xscale=0.0, yalign=0.5, yscale=0) alignment.render_layout(context) # XXX do we want to export this for general purpose use? class TextDialog(widgetset.Dialog): def __init__(self, title, description, window): widgetset.Dialog.__init__(self, title, description) self.set_transient_for(window) self.add_button('OK') self.textbox = widgetset.MultilineTextEntry() self.textbox.set_editable(False) scroller = widgetset.Scroller(False, True) scroller.set_has_borders(True) scroller.add(self.textbox) scroller.set_size_request(400, 500) self.set_extra_widget(scroller) def set_text(self, text): self.textbox.set_text(text) class Application(lvc.Application): def __init__(self, simultaneous=None): lvc.Application.__init__(self, simultaneous) self.create_signal('window-shown') self.sent_window_shown = False def startup(self): if self.started: return self.current_converter = EMPTY_CONVERTER lvc.Application.startup(self) self.menu_manager = menus.MenuManager() self.menu_manager.setup_menubar(self.menubar) self.window = widgetset.Window("Libre Video Converter") self.window.connect('on-shown', self.on_window_shown) self.window.connect('will-close', self.destroy) # # table on top self.model = ConversionModel() self.table = widgetset.TableView(self.model) self.table.draws_selection = False self.table.set_row_spacing(0) self.table.enable_album_view_focus_hack() self.table.set_fixed_height(True) self.table.set_grid_lines(False, False) self.table.set_show_headers(False) c = widgetset.TableColumn("Data", ConversionCellRenderer(), **dict((n, v) for (v, n) in enumerate(( 'input', 'output_size', 'converter', 'status', 'duration', 'progress', 'eta', 'thumbnail', 'conversion')))) c.set_min_width(TABLE_WIDTH) self.table.add_column(c) self.table.connect('hotspot-clicked', self.hotspot_clicked) # bottom buttons converter_types = ('apple', 'android', 'other', 'format') converters = {} for c in self.converter_manager.list_converters(): media_type = c.media_type if media_type not in converter_types: media_type = 'others' brand = self.converter_manager.converter_to_brand(c) # None = top level. Otherwise tack on the brand name. if brand is None: converters.setdefault(media_type, set()).add(c) else: converters.setdefault(media_type, set()).add(brand) self.menus = [] self.button_bar = widgetset.HBox() buttons = widgetset.HBox() for type_ in converter_types: options = [] more_devices = None for c in converters[type_]: if isinstance(c, str): rconverters = self.converter_manager.brand_to_converters(c) values = [] for r in rconverters: values.append((r.name, r.identifier)) # yuck if c == 'More Devices': more_devices = (c, values) else: options.append((c, values)) else: options.append((c.name, c.identifier)) # Don't sort if formats.. self.sort_converter_menu(type_, options) if more_devices: options.append(more_devices) menu = SettingsButton(type_) menu.connect('clicked', self.show_options_menu, options) self.menus.append(menu) buttons.pack_start(menu) omb = OptionMenuBackground() omb.set_child(widgetutil.pad(buttons, top=2, bottom=2, left=2, right=2)) self.button_bar.pack_start(omb) self.settings_button = SettingsButton('settings') omb = OptionMenuBackground() omb.set_child(widgetutil.pad(self.settings_button, top=2, bottom=2, left=2, right=2)) self.button_bar.pack_end(omb) self.drop_target = FileDropTarget() self.drop_target.set_size_request(-1, 70) # # finish up vbox = widgetset.VBox() self.vbox = vbox # add menubars, if we're not on windows if sys.platform != 'win32': attach_menubar() self.scroller = widgetset.Scroller(False, True) self.scroller.set_size_request(0, 0) self.scroller.set_background_color(DRAG_AREA) self.scroller.add(self.table) vbox.pack_start(self.scroller) vbox.pack_start(self.drop_target, expand=True) bottom = BottomBackground() bottom_box = widgetset.VBox() self.convert_label = CustomLabel('Convert to') self.convert_label.set_font(CONVERT_TO_FONT, CONVERT_TO_FONTSIZE) self.convert_label.set_color(TEXT_COLOR) bottom_box.pack_start(widgetutil.align_left(self.convert_label, top_pad=10, bottom_pad=10)) bottom_box.pack_start(self.button_bar) self.options = CustomOptions() self.options.connect('setting-changed', self.on_setting_changed) self.settings_button.connect('clicked', self.on_settings_toggle) bottom_box.pack_start(widgetutil.align_right(self.options, right_pad=5)) self.convert_button = ConvertButton() self.convert_button.connect('clicked', self.convert) bottom_box.pack_start(widgetutil.align(self.convert_button, xalign=0.5, yalign=0.5, top_pad=50, bottom_pad=50)) bottom.set_child(widgetutil.pad(bottom_box, left=20, right=20)) vbox.pack_start(bottom) self.window.set_content_widget(vbox) idle_add(self.conversion_manager.check_notifications, 1) self.window.connect('file-drag-motion', self.drag_motion) self.window.connect('file-drag-received', self.drag_data_received) self.window.connect('file-drag-leave', self.drag_finished) self.window.accept_file_drag(True) self.window.center() self.window.show() self.update_table_size() def sort_converter_menu(self, menu_type, options): """Sort a list of converter options for the menus :param menu_type: type of the menu :param options: list of (name, menu) tuples, where menu is either a ConverterInfo or list of ConverterInfos. """ if menu_type == 'format': order = ['Audio', 'Video', 'Ingest Formats', 'Same Format'] options.sort(key=lambda (name, menu): order.index(name)) else: options.sort() def drag_finished(self, widget): self.drop_target.set_in_drag(False) def drag_motion(self, widget): self.drop_target.set_in_drag(True) def drag_data_received(self, widget, values): for uri in values: parsed = urlparse.urlparse(uri) if parsed.scheme == 'file': pathname = urllib.url2pathname(parsed.path) self.file_activated(widget, pathname) def on_window_shown(self, window): # only emit window-shown once, even if our window gets shown, hidden, # and shown again if not self.sent_window_shown: self.emit("window-shown") self.sent_window_shown = True def destroy(self, widget): for conversion in self.conversion_manager.in_progress.copy(): conversion.stop() mainloop_stop() def run(self): mainloop_start() def choose_file(self): dialog = widgetset.FileOpenDialog('Choose Files...') dialog.set_select_multiple(True) if dialog.run() == 0: # success for filename in dialog.get_filenames(): self.file_activated(None, filename) dialog.destroy() def about(self): dialog = widgetset.AboutDialog() dialog.set_transient_for(self.window) try: dialog.run() finally: dialog.destroy() def quit(self): self.window.close() def _generate_suboptions_menu(self, widget, options): submenu = [] for option, id_ in options: def callback(x, i): return self.on_select_converter(widget, options[i][1]) # callback = lambda x, i: self.on_select_converter(widget, # options[i][1]) value = (option, callback) submenu.append(value) return submenu def show_options_menu(self, widget, options): optionlist = [] identifiers = dict() for option, submenu in options: if isinstance(submenu, list): callback = self._generate_suboptions_menu(widget, submenu) else: def callback(x, i): return self.on_select_converter(widget, options[i][1]) # callback = lambda x, i: self.on_select_converter(widget, # options[i][1]) value = (option, callback) optionlist.append(value) menu = widgetset.ContextMenu(optionlist) menu.popup() def update_convert_button(self): can_cancel = False can_start = False has_conversions = any(self.model.conversions()) all_done = self.model.all_conversions_done() for c in self.model.conversions(): if c.status == 'converting': can_cancel = True break elif c.status == 'initialized': can_start = True # if there are no conversions ... these can't be set if not has_conversions: for m in self.menus: m.set_selected(False) self.settings_button.set_selected(False) self.convert_label.set_color(TEXT_DISABLED) # Set the colors - all are enabled if all conversions complete, or # if we have conversions conversions but the converter has not yet # been set. # the converter has not been set. if ((self.current_converter is EMPTY_CONVERTER and has_conversions) or all_done): for m in self.menus: m.set_selected(True) self.settings_button.set_selected(True) if self.current_converter is EMPTY_CONVERTER: self.convert_label.set_text('Convert to') elif can_cancel: target = self.current_converter.name self.convert_label.set_text('Converting to %s' % target) elif can_start: target = self.current_converter.name self.convert_label.set_text('Will convert to %s' % target) self.convert_label.set_color(TEXT_ACTIVE) if all_done: self.convert_button.set_clear() elif (self.current_converter is EMPTY_CONVERTER or not (can_cancel or can_start)): self.convert_button.set_off() elif (self.current_converter is not EMPTY_CONVERTER and self.options.options['custom-size'] and (not self.options.options['width'] or not self.options.options['height'])): self.convert_button.set_off() else: self.convert_button.set_on() if can_cancel: self.convert_button.set_stop() self.button_bar.disable() else: if has_conversions: self.button_bar.enable() else: self.button_bar.disable() def file_activated(self, widget, filename): filename = os.path.realpath(filename) for c in self.model.conversions(): if c.video.filename == filename: logger.info('ignoring duplicate: %r', filename) return # XXX disabled - don't want to allow individualized file outputs # since the workflow isn't entirely clear for now. # if self.options.options['destination'] is None: # try: # tempfile.TemporaryFile(dir=os.path.dirname(filename)) # except EnvironmentError: # # can't write to the destination directory; ask for a new one # self.options.on_destination_clicked(None) try: vf = VideoFile(filename) except ValueError: logging.info('invalid file %r, cannot parse', filename, exc_info=True) return c = self.conversion_manager.get_conversion( vf, self.current_converter, output_dir=self.options.options['destination']) c.listen(self.update_conversion) if self.conversion_manager.running: # start running automatically if a conversion is already in # progress self.conversion_manager.run_conversion(c) self.update_conversion(c) self.update_table_size() def on_select_converter(self, widget, identifier): self.current_converter = self.converter_manager.get_by_id(identifier) self.options.reset() self.converter_changed(widget) def converter_changed(self, widget): if hasattr(self, '_doing_conversion_change'): return self._doing_conversion_change = True # If all conversions are done, then change the status of them back # to 'initialized'. # # XXX TODO: what happens if the state is 'failed'? Should we reset? all_done = self.model.all_conversions_done() if all_done: for c in self.model.conversions(): c.status = 'initialized' if self.current_converter is not EMPTY_CONVERTER: self.convert_label.set_text( 'Will convert to %s' % self.current_converter.name) else: self.convert_label.set_text('Convert to') if not self.current_converter.audio_only: self.options.enable_custom_size() self.options.update_setting('width', self.current_converter.width) self.options.update_setting('height', self.current_converter.height) else: self.options.disable_custom_size() for c in self.model.conversions(): if c.status == 'initialized': c.set_converter(self.current_converter) self.model.update_conversion(c) # We likely either reset the status or we've changed the conversion # output so let's just reload the table model. self.table.model_changed() self.update_convert_button() widget.set_selected(True) for menu in self.menus: if menu is not widget: menu.set_selected(False) del self._doing_conversion_change def convert(self, widget): self.convert_button.disable() if not self.conversion_manager.running: if self.current_converter is not EMPTY_CONVERTER: valid_resolution = True if (self.options.options['custom-size'] and not (self.options.options['width'] and self.options.options['height'])): valid_resolution = False if valid_resolution: for conversion in self.model.conversions(): if conversion.status == 'initialized': self.conversion_manager.run_conversion(conversion) self.button_bar.disable() # all done: no conversion job should be running at this point all_done = self.model.all_conversions_done() if all_done: # take stuff off one by one from the list # until we have none! # might not be very efficient. iter_ = self.model.first_iter() while iter_ is not None: conversion = self.model[iter_][-1] if conversion.status in ('finished', 'failed', 'canceled', 'initialized'): try: self.conversion_manager.remove(conversion) except ValueError: pass iter_ = self.model.remove(iter_) self.update_table_size() else: for conversion in self.model.conversions(): conversion.stop() self.update_conversion(conversion) self.conversion_manager.running = False self.update_convert_button() self.convert_button.enable() def update_conversion(self, conversion): self.model.update_conversion(conversion) self.update_table_size() def update_table_size(self): conversions = len(self.model) total_height = 380 if not conversions: self.scroller.set_size_request(-1, 0) self.drop_target.set_small(False) self.drop_target.set_size_request(-1, total_height) else: height = min(TABLE_HEIGHT * conversions, 320) self.scroller.set_size_request(-1, height) self.drop_target.set_small(True) self.drop_target.set_size_request(-1, total_height - height) self.update_convert_button() self.table.model_changed() def hotspot_clicked(self, widget, name, iter_): conversion = self.model[iter_][-1] if name == 'show-file': reveal_file(conversion.output) elif name == 'clear': self.model.remove(iter_) self.update_table_size() elif name == 'show-log': lines = ''.join(conversion.lines) d = TextDialog('Log', '', self.window) d.set_text(lines) try: d.run() finally: d.destroy() elif name == 'cancel': if conversion.status == 'initialized': self.model.remove(iter_) try: self.conversion_manager.remove(conversion) except ValueError: pass self.update_table_size() else: conversion.stop() self.update_conversion(conversion) def on_settings_toggle(self, widget): if not self.options.child: # hidden, going to show self.convert_button.hide() self.options.toggle() if not self.options.child: # was shown, not hidden self.convert_button.show() def on_setting_changed(self, widget, setting, value): if setting == 'destination': for c in self.model.conversions(): if c.status == 'initialized': if value is None: c.output_dir = os.path.dirname(c.video.filename) else: c.output_dir = value # update final path c.set_converter(self.current_converter) return elif setting == 'dont-upsize': setattr(self.current_converter, 'dont_upsize', value) return if (self.current_converter.identifier != 'custom' and setting != 'create-thumbnails'): if hasattr(self.current_converter, 'simple'): self.current_converter = self.current_converter.simple( self.current_converter.name) else: if self.current_converter is EMPTY_CONVERTER: self.current_converter = copy.copy( self.converter_manager.get_by_id('sameformat')) else: self.current_converter = copy.copy(self.current_converter) # If the current converter name is resize only, then we don't # want to call it a custom conversion. if self.current_converter.identifier != 'sameformat': self.current_converter.name = 'Custom' self.current_converter.width = self.options.options['width'] self.current_converter.height = self.options.options['height'] self.converter_changed(self.menus[-1]) # formats menu if setting in ('width', 'height'): setattr(self.current_converter, setting, value) elif setting == 'custom-size': if not value: self.current_converter.old_size = ( self.current_converter.width, self.current_converter.height) self.current_converter.width = None self.current_converter.height = None elif hasattr(self.current_converter, 'old_size'): old_size = self.current_converter.old_size (self.current_converter.width, self.current_converter.height) = old_size elif setting == 'create-thumbnails': self.conversion_manager.create_thumbnails = bool(value) if __name__ == "__main__": sys.dont_write_bytecode = True app.widgetapp = Application() initialize(app.widgetapp)