# -*- coding: utf-8 -*- # # gPodder - A media aggregator and podcast client # Copyright (c) 2005-2018 The gPodder Team # # gPodder is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # gPodder is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import html import logging from urllib.parse import urlparse import gpodder from gpodder import util from gpodder.gtkui.draw import (draw_text_box_centered, get_background_color, get_foreground_color) from gpodder.gtkui.interface.common import have_touchscreen # from gpodder.gtkui.draw import investigate_widget_colors import gi # isort:skip gi.require_version('Gdk', '3.0') # isort:skip gi.require_version('Gtk', '3.0') # isort:skip gi.require_version('Handy', '1') # isort:skip from gi.repository import Gdk, Gio, GLib, Gtk, Pango # isort:skip from gi.repository import Handy # isort:skip _ = gpodder.gettext logger = logging.getLogger(__name__) has_webkit2 = False try: gi.require_version('WebKit2', '4.0') from gi.repository import WebKit2 has_webkit2 = True except (ImportError, ValueError): logger.info('No WebKit2 gobject bindings, so no HTML shownotes') def get_shownotes(enable_html, pane, keyboard_callback=None): if enable_html and has_webkit2: return gPodderShownotesHTML(pane, keyboard_callback) else: return gPodderShownotesLabel(pane, keyboard_callback) class gPodderShownotes: def __init__(self, shownotes_pane, keyboard_callback=None): self.shownotes_pane = shownotes_pane self.keyboard_callback = keyboard_callback self.details_fmt = _('%(date)s | %(size)s | %(duration)s') self.scrolled_window = Gtk.ScrolledWindow() self.scrolled_window.set_shadow_type(Gtk.ShadowType.IN) self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.ALWAYS) self.scrolled_window.add(self.init()) # Swipe up to go back hack self.navigable = self.shownotes_pane.get_parent().get_parent() # FIXME if (isinstance(self.navigable, (Handy.Deck, Handy.Leaflet)) and have_touchscreen()): self.scrolled_window.connect( 'edge-overshot', self.on_scrolled_window_edge_overshot) self.status = Gtk.Label.new() self.status.set_halign(Gtk.Align.START) self.status.set_valign(Gtk.Align.END) self.status.set_property('ellipsize', Pango.EllipsizeMode.END) self.set_status(None) self.status_bg = None self.color_set = False self.background_color = None self.foreground_color = None self.link_color = None self.visited_color = None self.overlay = Gtk.Overlay() self.overlay.add(self.scrolled_window) # need an EventBox for an opaque background behind the label box = Gtk.EventBox() self.status_bg = box box.add(self.status) box.set_hexpand(False) box.set_vexpand(False) box.set_valign(Gtk.Align.END) box.set_halign(Gtk.Align.START) self.overlay.add_overlay(box) self.overlay.set_overlay_pass_through(box, True) self.main_component = self.overlay self.main_component.show_all() self.da_message = Gtk.DrawingArea() self.da_message.set_property('expand', True) self.da_message.connect('draw', self.on_shownotes_message_expose_event) self.shownotes_pane.add(self.da_message) self.shownotes_pane.add(self.main_component) self.set_complain_about_selection(True) self.hide_pane() def on_scrolled_window_edge_overshot(self, scrolled_window, pos, *args): if pos == Gtk.PositionType.TOP: self.navigable.navigate(Handy.NavigationDirection.BACK) # Either show the shownotes *or* a message, 'Please select an episode' def set_complain_about_selection(self, message=True): if message: self.scrolled_window.hide() self.da_message.show() else: self.da_message.hide() self.scrolled_window.show() def set_episodes(self, selected_episodes): if self.pane_is_visible: if len(selected_episodes) == 1: episode = selected_episodes[0] self.update(episode) self.set_complain_about_selection(False) else: self.set_complain_about_selection(True) def show_pane(self, selected_episodes): self.pane_is_visible = True self.set_episodes(selected_episodes) self.shownotes_pane.show() def hide_pane(self): self.pane_is_visible = False self.shownotes_pane.hide() def toggle_pane_visibility(self, selected_episodes): if self.pane_is_visible: self.hide_pane() else: self.show_pane(selected_episodes) def on_shownotes_message_expose_event(self, drawingarea, ctx): background = get_background_color() if background is None: background = Gdk.RGBA(1, 1, 1, 1) ctx.set_source_rgba(background.red, background.green, background.blue, 1) x1, y1, x2, y2 = ctx.clip_extents() ctx.rectangle(x1, y1, x2 - x1, y2 - y1) ctx.fill() width, height = drawingarea.get_allocated_width(), drawingarea.get_allocated_height(), text = _('Please select an episode') draw_text_box_centered(ctx, drawingarea, width, height, text, None, None) return False def set_status(self, text): self.status.set_label(text or " ") def define_colors(self): if not self.color_set: self.color_set = True # investigate_widget_colors([ # ([(Gtk.Window, 'background', '')], self.status.get_toplevel()), # ([(Gtk.Window, 'background', ''), (Gtk.Label, '', '')], self.status), # ([(Gtk.Window, 'background', ''), (Gtk.TextView, 'view', '')], self.text_view), # ([(Gtk.Window, 'background', ''), (Gtk.TextView, 'view', 'text')], self.text_view), # ]) dummy_tv = Gtk.TextView() self.background_color = get_background_color(Gtk.StateFlags.NORMAL, widget=dummy_tv) or Gdk.RGBA() self.foreground_color = get_foreground_color(Gtk.StateFlags.NORMAL, widget=dummy_tv) or Gdk.RGBA(0, 0, 0) self.link_color = get_foreground_color(state=Gtk.StateFlags.LINK, widget=dummy_tv) or Gdk.RGBA(0, 0, 0) self.visited_color = get_foreground_color(state=Gtk.StateFlags.VISITED, widget=dummy_tv) or self.link_color fc = self.foreground_color self.foreground_color_text = "#%X%X%X" % ( int(255 * fc.red), int(255 * fc.green), int(255 * fc.blue)) del dummy_tv self.status_bg.override_background_color(Gtk.StateFlags.NORMAL, self.background_color) if hasattr(self, "text_buffer"): self.text_buffer.create_tag('hyperlink', foreground=self.link_color.to_string(), underline=Pango.Underline.SINGLE) elif hasattr(self, "label"): self.label.override_color(Gtk.StateFlags.NORMAL, self.foreground_color) self.label.override_color(Gtk.StateFlags.LINK, self.link_color) self.label.override_color(Gtk.StateFlags.VISITED, self.visited_color) self.label.override_background_color(Gtk.StateFlags.NORMAL, self.background_color) self.label_bg.override_background_color(Gtk.StateFlags.NORMAL, self.background_color) class gPodderShownotesText(gPodderShownotes): def init(self): self.text_view = Gtk.TextView() self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.text_view.set_border_width(10) self.text_view.set_editable(False) self.text_buffer = Gtk.TextBuffer() self.text_buffer.create_tag('heading', scale=1.2, weight=Pango.Weight.BOLD) self.text_buffer.create_tag('subheading', scale=1.0) self.text_buffer.create_tag('details', scale=0.9) self.text_view.set_buffer(self.text_buffer) self.text_view.set_property('expand', True) self.text_view.connect('button-release-event', self.on_button_release) self.text_view.connect('key-press-event', self.on_key_press) self.text_view.connect('motion-notify-event', self.on_hover_hyperlink) self.populate_popup_id = None return self.text_view def update(self, episode): self.scrolled_window.get_vadjustment().set_value(0) heading = episode.title subheading = _('from %s') % (episode.channel.title) details = self.details_fmt % { 'date': '{} {}'.format(datetime.datetime.fromtimestamp(episode.published).strftime('%H:%M'), util.format_date(episode.published)), 'size': util.format_filesize(episode.file_size, digits=1) if episode.file_size > 0 else "-", 'duration': episode.get_play_info_string()} self.define_colors() hyperlinks = [(0, None)] self.text_buffer.set_text('') if episode.link: hyperlinks.append((self.text_buffer.get_char_count(), episode.link)) self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), heading, 'heading') if episode.link: hyperlinks.append((self.text_buffer.get_char_count(), None)) self.text_buffer.insert_at_cursor('\n') self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), subheading, 'subheading') self.text_buffer.insert_at_cursor('\n') self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), details, 'details') self.text_buffer.insert_at_cursor('\n\n') for target, text in util.extract_hyperlinked_text(episode.html_description()): hyperlinks.append((self.text_buffer.get_char_count(), target)) if target: self.text_buffer.insert_with_tags_by_name( self.text_buffer.get_end_iter(), text, 'hyperlink') else: self.text_buffer.insert( self.text_buffer.get_end_iter(), text) hyperlinks.append((self.text_buffer.get_char_count(), None)) self.hyperlinks = [(start, end, url) for (start, url), (end, _) in zip(hyperlinks, hyperlinks[1:]) if url] self.text_buffer.place_cursor(self.text_buffer.get_start_iter()) if self.populate_popup_id is not None: self.text_view.disconnect(self.populate_popup_id) self.populate_popup_id = self.text_view.connect('populate-popup', self.on_populate_popup) self.episode = episode def on_populate_popup(self, textview, context_menu): # TODO: Remove items from context menu that are always insensitive in a read-only buffer if self.episode.link: # TODO: It is currently not possible to copy links in description. # Detect if context menu was opened on a hyperlink and add # "Open Link" and "Copy Link Address" menu items. # See https://github.com/gpodder/gpodder/issues/1097 item = Gtk.SeparatorMenuItem() item.show() context_menu.append(item) # label links can be opened from context menu or by clicking them, do the same here item = Gtk.MenuItem(label=_('Open Episode Title Link')) item.connect('activate', lambda i: util.open_website(self.episode.link)) item.show() context_menu.append(item) # hack to allow copying episode.link item = Gtk.MenuItem(label=_('Copy Episode Title Link Address')) item.connect('activate', lambda i: util.copy_text_to_clipboard(self.episode.link)) item.show() context_menu.append(item) def on_button_release(self, widget, event): if event.button == 1: self.activate_links() def on_key_press(self, widget, event): if event.keyval == Gdk.KEY_Return: self.activate_links() return True if self.keyboard_callback is not None: self.keyboard_callback(widget, event) return True return False def hyperlink_at_pos(self, pos): """ :param int pos: offset in text buffer :return str: hyperlink target at pos if any or None """ return next((url for start, end, url in self.hyperlinks if start < pos < end), None) def activate_links(self): if self.text_buffer.get_selection_bounds() == (): pos = self.text_buffer.props.cursor_position target = self.hyperlink_at_pos(pos) if target is not None: util.open_website(target) def on_hover_hyperlink(self, textview, e): x, y = textview.window_to_buffer_coords(Gtk.TextWindowType.TEXT, e.x, e.y) w = self.text_view.get_window(Gtk.TextWindowType.TEXT) success, it = textview.get_iter_at_location(x, y) if success: pos = it.get_offset() target = self.hyperlink_at_pos(pos) if target: self.set_status(target) w.set_cursor(Gdk.Cursor.new_from_name(w.get_display(), 'pointer')) return self.set_status('') w.set_cursor(None) class gPodderShownotesHTML(gPodderShownotes): def init(self): self.episode = None self._base_uri = None # basic restrictions self.stylesheet = None self.manager = WebKit2.UserContentManager() self.html_view = WebKit2.WebView.new_with_user_content_manager(self.manager) settings = self.html_view.get_settings() settings.set_enable_java(False) settings.set_enable_plugins(False) settings.set_enable_javascript(False) # uncomment to show web inspector # settings.set_enable_developer_extras(True) self.html_view.set_property('expand', True) self.html_view.connect('mouse-target-changed', self.on_mouse_over) self.html_view.connect('context-menu', self.on_context_menu) self.html_view.connect('decide-policy', self.on_decide_policy) self.html_view.connect('authenticate', self.on_authenticate) self.html_view.connect('key-press-event', self.on_key_press) return self.html_view def update(self, episode): self.scrolled_window.get_vadjustment().set_value(0) self.define_colors() if episode.has_website_link(): self._base_uri = episode.link else: self._base_uri = episode.channel.url # for incomplete base URI (e.g. http://919.noagendanotes.com) baseURI = urlparse(self._base_uri) if baseURI.path == '': self._base_uri += '/' self._loaded = False stylesheet = self.get_stylesheet() if stylesheet: self.manager.add_style_sheet(stylesheet) heading = '

%s

' % html.escape(episode.title) subheading = _('from %s') % html.escape(episode.channel.title) details = '%s' % html.escape(self.details_fmt % { 'date': '{} {}'.format(datetime.datetime.fromtimestamp(episode.published).strftime('%H:%M'), util.format_date(episode.published)), 'size': util.format_filesize(episode.file_size, digits=1) if episode.file_size > 0 else "-", 'duration': episode.get_play_info_string()}) header_html = _('
\n%(heading)s\n

%(subheading)s

\n

%(details)s

\n') \ % dict(heading=heading, subheading=subheading, details=details) # uncomment to prevent background override in html shownotes # self.manager.remove_all_style_sheets () logger.debug("base uri: %s (chan:%s)", self._base_uri, episode.channel.url) self.html_view.load_html(header_html + episode.html_description(), self._base_uri) # uncomment to show web inspector # self.html_view.get_inspector().show() self.episode = episode def on_key_press(self, widget, event): if self.keyboard_callback is not None: self.keyboard_callback(widget, event) return True return False def on_mouse_over(self, webview, hit_test_result, modifiers): if hit_test_result.context_is_link(): self.set_status(hit_test_result.get_link_uri()) else: self.set_status(None) def on_context_menu(self, webview, context_menu, event, hit_test_result): whitelist_actions = [ WebKit2.ContextMenuAction.NO_ACTION, WebKit2.ContextMenuAction.STOP, WebKit2.ContextMenuAction.RELOAD, WebKit2.ContextMenuAction.COPY, WebKit2.ContextMenuAction.CUT, WebKit2.ContextMenuAction.PASTE, WebKit2.ContextMenuAction.DELETE, WebKit2.ContextMenuAction.SELECT_ALL, WebKit2.ContextMenuAction.INPUT_METHODS, WebKit2.ContextMenuAction.COPY_VIDEO_LINK_TO_CLIPBOARD, WebKit2.ContextMenuAction.COPY_AUDIO_LINK_TO_CLIPBOARD, WebKit2.ContextMenuAction.COPY_LINK_TO_CLIPBOARD, WebKit2.ContextMenuAction.COPY_IMAGE_TO_CLIPBOARD, WebKit2.ContextMenuAction.COPY_IMAGE_URL_TO_CLIPBOARD ] items = context_menu.get_items() for item in items: if item.get_stock_action() not in whitelist_actions: context_menu.remove(item) if hit_test_result.get_context() == WebKit2.HitTestResultContext.DOCUMENT: item = self.create_open_item( 'shownotes-in-browser', _('Open shownotes in web browser'), self._base_uri) context_menu.insert(item, -1) elif hit_test_result.context_is_link(): item = self.create_open_item( 'link-in-browser', _('Open link in web browser'), hit_test_result.get_link_uri()) context_menu.insert(item, -1) return False def on_decide_policy(self, webview, decision, decision_type): if decision_type == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION: decision.ignore() return False elif decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION: req = decision.get_request() # about:blank is for plain text shownotes if req.get_uri() in (self._base_uri, 'about:blank'): decision.use() else: # Avoid opening the page inside the WebView and open in the browser instead decision.ignore() util.open_website(req.get_uri()) return False else: decision.use() return False def on_open_in_browser(self, action, var): util.open_website(var.get_string()) def on_authenticate(self, view, request): if request.is_retry(): return False if not self.episode or not self.episode.channel.auth_username: return False chan = self.episode.channel u = urlparse(chan.url) host = u.hostname if u.port: port = u.port elif u.scheme == 'https': port = 443 else: port = 80 logger.debug("on_authenticate(chan=%s:%s req=%s:%s (scheme=%s))", host, port, request.get_host(), request.get_port(), request.get_scheme()) if host == request.get_host() and port == request.get_port() \ and request.get_scheme() == WebKit2.AuthenticationScheme.HTTP_BASIC: persistence = WebKit2.CredentialPersistence.FOR_SESSION request.authenticate(WebKit2.Credential(chan.auth_username, chan.auth_password, persistence)) return True else: return False def create_open_item(self, name, label, url): action = Gio.SimpleAction.new(name, GLib.VariantType.new('s')) action.connect('activate', self.on_open_in_browser) var = GLib.Variant.new_string(url) return WebKit2.ContextMenuItem.new_from_gaction(action, label, var) def get_stylesheet(self): if self.stylesheet is None: style = ("html { background: %s; color: %s;}" " a { color: %s; }" " a:visited { color: %s; }" " #gpodder-title h3, #gpodder-title p { margin: 0}" " #gpodder-title {margin-block-end: 1em;}") % \ (self.background_color.to_string(), self.foreground_color.to_string(), self.link_color.to_string(), self.visited_color.to_string()) self.stylesheet = WebKit2.UserStyleSheet(style, 0, 1, None, None) return self.stylesheet class gPodderShownotesLabel(gPodderShownotes): def init(self): self.label = Gtk.Label() self.label.set_line_wrap(True) self.label.set_property('margin', 10) self.label.set_property('xalign', 0.0) self.label.set_property('yalign', 0.0) self.label.set_property('expand', True) self.label.set_property('has-tooltip', True) # self.label.connect('query-tooltip', self.on_query_tooltip) self.label.connect('activate-link', self.on_activate_link) self.label.connect('key-press-event', self.on_key_press) # need an EventBox for an opaque background behind the label box = Gtk.EventBox() self.label_bg = box box.add(self.label) return self.label_bg def update(self, episode): self.scrolled_window.get_vadjustment().set_value(0) heading = html.escape(episode.title) subheading = _('from %s') % (html.escape(episode.channel.title)) self.define_colors() ltext = '' if episode.link: ltext += '%s\n' % ( self.foreground_color_text, episode.link, episode.link, heading) else: ltext += '' + heading + '\n' ltext += subheading + '\n' ltext += '%s\n\n' % html.escape(self.details_fmt % { 'date': util.format_date(episode.published), 'size': util.format_filesize(episode.file_size, digits=1) if episode.file_size > 0 else "-", 'duration': episode.get_play_info_string()}) for target, text in util.extract_hyperlinked_text(episode.description_html or episode.description): if target: tesc = html.escape(target) # Link title really needs a double escape ltext += '%s' % ( tesc, html.escape(tesc), html.escape(text)) else: ltext += html.escape(text) self.label.set_markup(ltext) def on_button_release(self, widget, event): if event.button == 1: self.activate_links() def on_key_press(self, widget, event): if self.keyboard_callback is not None: self.keyboard_callback(widget, event) return True return False def on_activate_link(self, label, target): if target is not None: util.open_website(target) return True # def on_query_tooltip(self, label, x, y, keyboard_tooltip, tooltip): # uri = label.get_current_uri() # self.set_status(uri) # return True