1# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*-
2# Copyright (C) 2010-2012 Kevin Mehall <km@kevinmehall.net>
3# This program is free software: you can redistribute it and/or modify it
4# under the terms of the GNU General Public License version 3, as published
5# by the Free Software Foundation.
6#
7# This program is distributed in the hope that it will be useful, but
8# WITHOUT ANY WARRANTY; without even the implied warranties of
9# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
10# PURPOSE.  See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License along
13# with this program.  If not, see <http://www.gnu.org/licenses/>.
14
15
16import contextlib
17import html
18import json
19import logging
20import math
21import re
22import os
23import sys
24import time
25import tempfile
26import urllib.error
27import urllib.parse
28import urllib.request
29from enum import Enum
30
31import gi
32gi.require_version('Gst', '1.0')
33gi.require_version('GstAudio', '1.0')
34gi.require_version('GstPbutils', '1.0')
35from gi.repository import Gst, GstAudio, GstPbutils, GObject, Gtk, Gdk, Pango, GdkPixbuf, Gio, GLib
36from .gi_composites import GtkTemplate
37
38if Gtk.get_major_version() < 3 or Gtk.get_minor_version() < 14:
39    sys.exit('Gtk 3.14 is required')
40
41from . import AboutPithosDialog, PreferencesPithosDialog, StationsDialog
42from .StationsPopover import StationsPopover
43from .gobject_worker import GObjectWorker
44from .pandora import *
45from .pandora.data import *
46from .plugin import load_plugins
47from .util import parse_proxy, open_browser, SecretService, popup_at_pointer, is_flatpak
48from .migrate_settings import maybe_migrate_settings
49
50try:
51    import pacparser
52except ImportError:
53    pacparser = None
54
55# Older versions of Gstreamer may not have these constants
56try:
57    RESAMPLER_QUALITY_MAX = GstAudio.AUDIO_RESAMPLER_QUALITY_MAX
58    RESAMPLER_FILTER_MODE_FULL = GstAudio.AudioResamplerFilterMode.FULL
59except AttributeError:
60    RESAMPLER_QUALITY_MAX = 10
61    RESAMPLER_FILTER_MODE_FULL = 1
62
63ALBUM_ART_SIZE = 96
64TEXT_X_PADDING = 12
65
66FALLBACK_BLACK = Gdk.RGBA(red=0.0, green=0.0, blue=0.0, alpha=1.0)
67FALLBACK_WHITE = Gdk.RGBA(red=1.0, green=1.0, blue=1.0, alpha=1.0)
68
69RATING_BG_SVG = '''
70<svg height="20" width="20">
71<g transform="translate(0,-1032.3622)">
72<path d="m 12,1032.3622 a 12,12 0 0 0 -12,12 12,12 0 0 0 3.0742188,
738 l 16.9257812,0 0,-16.9277 a 12,12 0 0 0 -8,-3.0723 z"
74style="fill:{bg}" /></g></svg>
75'''
76
77BACKGROUND_SVG = '''
78<svg><rect y="0" x="0" height="{px}" width="{px}" style="fill:{fg}" /></svg>
79'''
80
81class PseudoGst(Enum):
82    """Create aliases to Gst.State so that we can add our own BUFFERING Pseudo state"""
83    PLAYING = 1
84    PAUSED = 2
85    BUFFERING = 3
86    STOPPED = 4
87
88    @property
89    def state(self):
90        value = self.value
91        if value == 1:
92            return Gst.State.PLAYING
93        elif value == 2:
94            return Gst.State.PAUSED
95        elif value == 3:
96            return Gst.State.PAUSED
97        elif value == 4:
98            return Gst.State.NULL
99
100
101class CellRendererAlbumArt(Gtk.CellRenderer):
102    def __init__(self):
103        super().__init__(height=ALBUM_ART_SIZE, width=ALBUM_ART_SIZE)
104        self.icon = None
105        self.pixbuf = None
106        self.love_icon = None
107        self.ban_icon = None
108        self.tired_icon = None
109        self.generic_audio_icon = None
110        self.background = None
111        self.rate_bg = None
112
113    __gproperties__ = {
114        'icon': (str, 'icon', 'icon', '', GObject.ParamFlags.READWRITE),
115        'pixbuf': (GdkPixbuf.Pixbuf, 'pixmap', 'pixmap',  GObject.ParamFlags.READWRITE)
116    }
117
118    def do_set_property(self, pspec, value):
119        setattr(self, pspec.name, value)
120    def do_get_property(self, pspec):
121        return getattr(self, pspec.name)
122    def do_render(self, ctx, widget, background_area, cell_area, flags):
123        if self.pixbuf is not None:
124            Gdk.cairo_set_source_pixbuf(ctx, self.pixbuf, cell_area.x, cell_area.y)
125            ctx.paint()
126        else:
127            Gdk.cairo_set_source_pixbuf(ctx, self.background, cell_area.x, cell_area.y)
128            ctx.paint()
129            x = cell_area.x + (ALBUM_ART_SIZE - self.generic_audio_icon.get_width()) // 2
130            y = cell_area.y + (ALBUM_ART_SIZE - self.generic_audio_icon.get_height()) // 2
131            Gdk.cairo_set_source_pixbuf(ctx, self.generic_audio_icon, x, y)
132            ctx.paint()
133
134        if self.icon is not None:
135            x = cell_area.x + (cell_area.width - self.rate_bg.get_width()) # right
136            y = cell_area.y + (cell_area.height - self.rate_bg.get_height()) # bottom
137            Gdk.cairo_set_source_pixbuf(ctx, self.rate_bg, x, y)
138            ctx.paint()
139
140            if self.icon == 'love':
141                rating_icon = self.love_icon
142            elif self.icon == 'tired':
143                rating_icon = self.tired_icon
144            elif self.icon == 'ban':
145                rating_icon = self.ban_icon
146
147            x = x + (rating_icon.get_width() // 2)
148            y = y + (rating_icon.get_height() // 2)
149
150            Gdk.cairo_set_source_pixbuf(ctx, rating_icon, x, y)
151            ctx.paint()
152
153    def update_icons(self, style_context):
154        # Dynamically change the color of backgrounds and icons
155        # to match the current theme at theme changes.
156        # Attempt to look up the background and foreground colors
157        # in the theme's CSS file. Otherwise if they aren't found
158        # fallback to black and white. *Most* new themes use 'theme_bg_color' and 'theme_fg_color'.
159        # Some(older) themes use 'bg_color' and 'fg_color'.(like Ubuntu light themes)
160        for key in ('theme_bg_color', 'bg_color'):
161            bg_bool, bg_color = style_context.lookup_color(key)
162            if bg_bool:
163                break
164        if not bg_bool:
165            bg_color = FALLBACK_BLACK
166            logging.debug("Could not find theme's background color falling back to black.")
167
168        for key in ('theme_fg_color', 'fg_color'):
169            fg_bool, fg_color = style_context.lookup_color(key)
170            if fg_bool:
171                break
172        if not fg_bool:
173            fg_color = FALLBACK_WHITE
174            logging.debug("Could not find theme's foreground color falling back to white.")
175
176        fg_rgb = fg_color.to_string()
177        bg_rgb = bg_color.to_string()
178
179        # Use our color values to create strings representing valid SVG's
180        # for backgound and rate_bg, then load them with PixbufLoader.
181        background = BACKGROUND_SVG.format(px=ALBUM_ART_SIZE, fg=fg_rgb).encode()
182        rating_bg = RATING_BG_SVG.format(bg=bg_rgb).encode()
183
184        with contextlib.closing(GdkPixbuf.PixbufLoader()) as loader:
185            loader.write(background)
186        self.background = loader.get_pixbuf()
187
188        with contextlib.closing(GdkPixbuf.PixbufLoader()) as loader:
189            loader.write(rating_bg)
190        self.rate_bg = loader.get_pixbuf()
191
192        current_theme = Gtk.IconTheme.get_default()
193
194        # Pithos requires an icon theme with symbolic icons.
195
196        # Manually color audio-x-generic-symbolic 48px icon to be used as part of the "default cover".
197        info = current_theme.lookup_icon('audio-x-generic-symbolic', 48, 0)
198        self.generic_audio_icon, was_symbolic = info.load_symbolic(bg_color, bg_color, bg_color, bg_color)
199
200        # We request 24px icons because what we really want is 12px icons,
201        # and they doesn't exist in many(or any?) icon themes. We then manually color
202        # and scale them down to 12px.
203        info = current_theme.lookup_icon('emblem-favorite-symbolic', 24, 0)
204        icon, was_symbolic = info.load_symbolic(fg_color, fg_color, fg_color, fg_color)
205        self.love_icon = icon.scale_simple(12, 12, GdkPixbuf.InterpType.BILINEAR)
206
207        info = current_theme.lookup_icon('dialog-error-symbolic', 24, 0)
208        icon, was_symbolic = info.load_symbolic(fg_color, fg_color, fg_color, fg_color)
209        self.ban_icon = icon.scale_simple(12, 12, GdkPixbuf.InterpType.BILINEAR)
210
211        info = current_theme.lookup_icon('go-jump-symbolic', 24, 0)
212        icon, was_symbolic = info.load_symbolic(fg_color, fg_color, fg_color, fg_color)
213        self.tired_icon = icon.scale_simple(12, 12, GdkPixbuf.InterpType.BILINEAR)
214
215@GtkTemplate(ui='/io/github/Pithos/ui/PithosWindow.ui')
216class PithosWindow(Gtk.ApplicationWindow):
217    __gtype_name__ = "PithosWindow"
218    __gsignals__ = {
219        "song-changed": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
220        "song-ended": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
221        "play-state-changed": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_BOOLEAN,)),
222        "user-changed-play-state": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_BOOLEAN,)),
223        "metadata-changed": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
224        "buffering-finished": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
225        "station-changed": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
226        "stations-processed": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
227        "station-added": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
228        "stations-dlg-ready": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_BOOLEAN,)),
229        "songs-added": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_INT,)),
230        "player-ready": (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_BOOLEAN,)),
231    }
232
233    volume = GtkTemplate.Child()
234    playpause_image = GtkTemplate.Child()
235    statusbar = GtkTemplate.Child()
236    song_menu = GtkTemplate.Child()
237    song_menu_love = GtkTemplate.Child()
238    song_menu_unlove = GtkTemplate.Child()
239    song_menu_ban = GtkTemplate.Child()
240    song_menu_unban = GtkTemplate.Child()
241    song_menu_create_station = GtkTemplate.Child()
242    song_menu_create_song_station = GtkTemplate.Child()
243    song_menu_create_artist_station = GtkTemplate.Child()
244    songs_treeview = GtkTemplate.Child()
245    stations_button = GtkTemplate.Child()
246    stations_label = GtkTemplate.Child()
247
248    api_update_dialog_real = GtkTemplate.Child()
249    error_dialog_real = GtkTemplate.Child()
250    fatal_error_dialog_real = GtkTemplate.Child()
251
252    def __init__(self, app, test_mode):
253        super().__init__(application=app)
254        self.init_template()
255
256        self.settings = Gio.Settings.new('io.github.Pithos')
257        self.settings.connect('changed::audio-quality', self.set_audio_quality)
258        self.settings.connect('changed::proxy', self.set_proxy)
259        self.settings.connect('changed::control-proxy', self.set_proxy)
260        self.settings.connect('changed::control-proxy-pac', self.set_proxy)
261
262        self.prefs_dlg = PreferencesPithosDialog.PreferencesPithosDialog(transient_for=self)
263        self.prefs_dlg.connect_after('response', self.on_prefs_response)
264        self.prefs_dlg.connect('login-changed', self.pandora_reconnect)
265
266        self.init_core()
267        self.init_ui()
268        self.init_actions(app)
269
270        self.plugins = {}
271        load_plugins(self)
272
273        self.pandora = make_pandora(test_mode)
274        self.set_proxy(reconnect=False)
275        self.set_audio_quality()
276        SecretService.unlock_keyring(self.on_keyring_unlocked)
277
278    def on_keyring_unlocked(self, error):
279        if error:
280            logging.error('You need to install a service such as gnome-keyring. Error: {}'.format(error))
281            self.fatal_error_dialog(
282                error.message,
283                _('You need to install a service such as gnome-keyring.'),
284            )
285
286        else:
287            maybe_migrate_settings()
288            self.pandora_connect()
289
290
291    def init_core(self):
292        #                                Song object            display text  icon  album art
293        self.songs_model = Gtk.ListStore(GObject.TYPE_PYOBJECT, str,          str,  GdkPixbuf.Pixbuf)
294        #                                   Station object         station name  index
295        self.stations_model = Gtk.ListStore(GObject.TYPE_PYOBJECT, str,          int)
296
297        Gst.init(None)
298        self._query_duration = Gst.Query.new_duration(Gst.Format.TIME)
299        self._query_position = Gst.Query.new_position(Gst.Format.TIME)
300        self._query_buffer = Gst.Query.new_buffering(Gst.Format.PERCENT)
301
302        self.player = Gst.ElementFactory.make("playbin", "player")
303        self.player.set_property('buffer-duration', 3 * Gst.SECOND)
304        self.rgvolume = Gst.ElementFactory.make("rgvolume", "rgvolume")
305        self.rgvolume.set_property("album-mode", False)
306        self.rglimiter = Gst.ElementFactory.make("rglimiter", "rglimiter")
307        self.rglimiter.set_property("enabled", False)
308        self.equalizer = Gst.ElementFactory.make("equalizer-10bands", "equalizer-10bands")
309        audioconvert = Gst.ElementFactory.make("audioconvert", "audioconvert")
310        audioresample = Gst.ElementFactory.make("audioresample", "audioresample")
311        audioresample.set_property("quality", RESAMPLER_QUALITY_MAX)
312        audioresample.set_property("sinc-filter-mode", RESAMPLER_FILTER_MODE_FULL)
313        audiosink = Gst.ElementFactory.make("autoaudiosink", "audiosink")
314        sinkbin = Gst.Bin()
315        sinkbin.add(self.rgvolume)
316        sinkbin.add(self.rglimiter)
317        sinkbin.add(self.equalizer)
318        sinkbin.add(audioconvert)
319        sinkbin.add(audioresample)
320        sinkbin.add(audiosink)
321
322        self.rgvolume.link(self.rglimiter)
323        self.rglimiter.link(self.equalizer)
324        self.equalizer.link(audioconvert)
325        audioconvert.link(audioresample)
326        audioresample.link(audiosink)
327
328        sinkbin.add_pad(Gst.GhostPad.new("sink", self.rgvolume.get_static_pad("sink")))
329        self.player.set_property("audio-sink", sinkbin)
330
331        self.emit('player-ready', True)
332
333        bus = self.player.get_bus()
334        bus.add_signal_watch()
335        bus.connect("message::stream-start", self.on_gst_stream_start)
336        bus.connect("message::eos", self.on_gst_eos)
337        bus.connect("message::buffering", self.on_gst_buffering)
338        bus.connect("message::error", self.on_gst_error)
339        bus.connect("message::element", self.on_gst_element)
340        self.player.connect("notify::volume", self.on_gst_volume)
341        self.player.connect("notify::source", self.on_gst_source)
342
343        self.stations_dlg = None
344
345        self._current_state = PseudoGst.STOPPED
346        self._buffer_recovery_state = PseudoGst.STOPPED
347
348        self.current_song_index = None
349        self.current_station = None
350        self.current_station_id = self.settings['last-station-id']
351
352        self.filter_state = None
353        self.auto_retrying_auth = False
354        self.have_stations = False
355        self.playcount = 0
356        self.gstreamer_errorcount_1 = 0
357        self.gstreamer_errorcount_2 = 0
358        self.gstreamer_error = ''
359        self.waiting_for_playlist = False
360        self.start_new_playlist = False
361        self.buffering_timer_id = 0
362        self.ui_loop_timer_id = 0
363        self.playlist_update_timer_id = 0
364        display = self.props.screen.get_display()
365        self.not_in_x = not type(display).__name__.endswith('X11Display')
366        self.worker = GObjectWorker()
367
368        try:
369            tempdir_base = '/var/tmp' # Prefered over /tmp as lots of icons can be large in size.
370            if is_flatpak():
371                # However in flatpak that path is not readable by the host.
372                tempdir_base = os.path.join(GLib.get_user_cache_dir(), 'tmp')
373            self.tempdir = tempfile.TemporaryDirectory(prefix='pithos-', dir=tempdir_base)
374            logging.info("Created temporary directory {}".format(self.tempdir.name))
375        except IOError as e:
376            self.tempdir = None
377            logging.warning('Failed to create a temporary directory: {}'.format(e))
378
379    @property
380    def playing(self):
381        # Recreate the old "playing" attribute as a property.
382        # Track self._buffer_recovery_state because that's the state
383        # we wish we were in.
384        return self._buffer_recovery_state is not PseudoGst.PAUSED
385
386    def init_ui(self):
387        GLib.set_application_name("Pithos")
388        Gtk.Window.set_default_icon_name('pithos')
389
390        self.volume.set_relief(Gtk.ReliefStyle.NORMAL)  # It ignores glade...
391        self.settings.bind('volume', self.volume, 'value', Gio.SettingsBindFlags.DEFAULT)
392
393        self.songs_treeview.set_model(self.songs_model)
394
395        title_col   = Gtk.TreeViewColumn()
396
397        render_cover_art = CellRendererAlbumArt()
398        title_col.pack_start(render_cover_art, False)
399        title_col.add_attribute(render_cover_art, "icon", 2)
400        title_col.add_attribute(render_cover_art, "pixbuf", 3)
401
402        render_text = Gtk.CellRendererText(xpad=TEXT_X_PADDING)
403        render_text.props.ellipsize = Pango.EllipsizeMode.END
404        title_col.pack_start(render_text, True)
405        title_col.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
406        title_col.add_attribute(render_text, "markup", 1)
407
408        self.songs_treeview.append_column(title_col)
409
410        self.get_style_context().connect('changed', lambda sc: render_cover_art.update_icons(sc))
411
412        self.songs_treeview.connect('button_press_event', self.on_treeview_button_press_event)
413
414        self.stations_popover = StationsPopover()
415        self.stations_popover.set_relative_to(self.stations_button)
416        self.stations_popover.set_model(self.stations_model)
417        self.stations_popover.listbox.connect('row-activated', self.active_station_changed)
418        self.stations_button.set_popover(self.stations_popover)
419        self.stations_popover.search.connect('activate', self.search_activate_handler)
420
421    def init_actions(self, app):
422        action = Gio.SimpleAction.new('playpause', None)
423        self.add_action(action)
424        app.add_accelerator('space', 'win.playpause', None)
425        action.connect('activate', self.user_playpause)
426
427        action = Gio.SimpleAction.new('playselected', None)
428        self.add_action(action)
429        app.add_accelerator('Return', 'win.playselected', None)
430        action.connect('activate', self.start_selected_song)
431
432        action = Gio.SimpleAction.new('songinfo', None)
433        self.add_action(action)
434        app.add_accelerator('<Primary>i', 'win.songinfo', None)
435        action.connect('activate', self.info_song)
436
437        action = Gio.SimpleAction.new('volumeup', None)
438        self.add_action(action)
439        app.add_accelerator('<Primary>Up', 'win.volumeup', None)
440        action.connect('activate', self.volume_up)
441
442        action = Gio.SimpleAction.new('volumedown', None)
443        self.add_action(action)
444        app.add_accelerator('<Primary>Down', 'win.volumedown', None)
445        action.connect('activate', self.volume_down)
446
447        action = Gio.SimpleAction.new('skip', None)
448        self.add_action(action)
449        app.add_accelerator('<Primary>Right', 'win.skip', None)
450        action.connect('activate', self.next_song)
451
452        action = Gio.SimpleAction.new('love', None)
453        self.add_action(action)
454        app.add_accelerator('<Primary>l', 'win.love', None)
455        action.connect('activate', self.love_song)
456
457        action = Gio.SimpleAction.new('ban', None)
458        self.add_action(action)
459        app.add_accelerator('<Primary>b', 'win.ban', None)
460        action.connect('activate', self.ban_song)
461
462        action = Gio.SimpleAction.new('tired', None)
463        self.add_action(action)
464        app.add_accelerator('<Primary>t', 'win.tired', None)
465        action.connect('activate', self.tired_song)
466
467        action = Gio.SimpleAction.new('unrate', None)
468        self.add_action(action)
469        app.add_accelerator('<Primary>u', 'win.unrate', None)
470        action.connect('activate', self.unrate_song)
471
472        action = Gio.SimpleAction.new('bookmark', None)
473        self.add_action(action)
474        app.add_accelerator('<Primary>d', 'win.bookmark', None)
475        action.connect('activate', self.bookmark_song)
476
477    def worker_run(self, fn, args=(), callback=None, message=None, context='net', errorback=None, user_data=None):
478        if context and message:
479            self.statusbar.push(self.statusbar.get_context_id(context), message)
480
481        if isinstance(fn,str):
482            fn = getattr(self.pandora, fn)
483
484        def cb(v):
485            if context: self.statusbar.pop(self.statusbar.get_context_id(context))
486            if callback:
487                if user_data:
488                    callback(v, user_data)
489                else:
490                    callback(v)
491
492        def eb(e):
493            if context and message:
494                self.statusbar.pop(self.statusbar.get_context_id(context))
495
496            def retry_cb():
497                self.auto_retrying_auth = False
498                if fn is not self.pandora.connect:
499                    self.worker_run(fn, args, callback, message, context)
500
501            if isinstance(e, PandoraAuthTokenInvalid) and not self.auto_retrying_auth:
502                self.auto_retrying_auth = True
503                logging.info("Automatic reconnect after invalid auth token")
504                self.pandora_connect(message="Reconnecting...", callback=retry_cb)
505            elif isinstance(e, PandoraAPIVersionError):
506                self.api_update_dialog()
507            elif isinstance(e, PandoraError):
508                self.error_dialog(e.message, retry_cb, submsg=e.submsg)
509            else:
510                logging.warning(e.traceback)
511
512        err = errorback or eb
513
514        self.worker.send(fn, args, cb, err)
515
516    def get_proxy(self):
517        """ Get HTTP proxy, first trying preferences then system proxy """
518
519        proxy = self.settings['proxy']
520        if proxy:
521            return proxy
522
523        system_proxies = urllib.request.getproxies()
524        if 'http' in system_proxies:
525            return system_proxies['http']
526
527        return None
528
529    def on_explicit_content_filter_checkbox(self, *ignore):
530        if self.pandora.connected:
531            current_checkbox_state = self.prefs_dlg.explicit_content_filter_checkbutton.get_active()
532
533            def set_content_filter(current_state):
534                self.pandora.set_explicit_content_filter(current_state)
535
536            def get_new_playlist(*ignore):
537                if current_checkbox_state:
538                    logging.info('Getting a new playlist.')
539                    self.waiting_for_playlist = False
540                    self.stop()
541                    self.current_song_index = None
542                    self.songs_model.clear()
543                    self.get_playlist(start = True)
544
545            if self.filter_state is not None and self.filter_state != current_checkbox_state:
546                self.worker_run(set_content_filter, (current_checkbox_state, ), get_new_playlist)
547
548    def set_proxy(self, *ignore, reconnect=True):
549        # proxy preference is used for all Pithos HTTP traffic
550        # control proxy preference is used only for Pandora traffic and
551        # overrides proxy
552        #
553        # If neither option is set, urllib2.build_opener uses urllib.getproxies()
554        # by default
555
556        handlers = []
557        global_proxy = self.settings['proxy']
558        if global_proxy:
559            handlers.append(urllib.request.ProxyHandler({'http': global_proxy, 'https': global_proxy}))
560        global_opener = pandora.Pandora.build_opener(*handlers)
561        urllib.request.install_opener(global_opener)
562
563        control_opener = global_opener
564        control_proxy = self.settings['control-proxy']
565        control_proxy_pac = self.settings['control-proxy-pac']
566
567        if not control_proxy and (control_proxy_pac and pacparser):
568            pacparser.init()
569            with urllib.request.urlopen(control_proxy_pac) as f:
570                pacstring = f.read().decode('utf-8')
571                try:
572                    pacparser.parse_pac_string(pacstring)
573                except pacparser._pacparser.error:
574                    logging.warning('Failed to parse PAC.')
575            try:
576                proxies = pacparser.find_proxy("http://pandora.com", "pandora.com").split(";")
577                for proxy in proxies:
578                    match = re.search("PROXY (.*)", proxy)
579                    if match:
580                        control_proxy = match.group(1)
581                        break
582            except pacparser._pacparser.error:
583                logging.warning('Failed to find proxy via PAC.')
584            pacparser.cleanup()
585        elif not control_proxy and (control_proxy_pac and not pacparser):
586            logging.warning("Disabled proxy auto-config support because python-pacparser module was not found.")
587
588        if control_proxy:
589            control_opener = pandora.Pandora.build_opener(urllib.request.ProxyHandler({'http': control_proxy, 'https': control_proxy}))
590
591        self.pandora.set_url_opener(control_opener)
592
593        if reconnect:
594            self.pandora_connect()
595
596    def set_audio_quality(self, *ignore):
597        self.pandora.set_audio_quality(self.settings['audio-quality'])
598
599    def pandora_connect(self, *ignore, message="Logging in...", callback=None):
600        def cb(password):
601            if not password:
602                self.show_preferences()
603            else:
604                self._pandora_connect_real(message, callback, email, password)
605
606        email = self.settings['email']
607        if not email:
608            self.show_preferences()
609        else:
610            SecretService.get_account_password(email, cb)
611
612    def _pandora_connect_real(self, message, callback, email, password):
613        if self.settings['pandora-one']:
614            client = client_keys[default_one_client_id]
615        else:
616            client = client_keys[default_client_id]
617
618        # Allow user to override client settings
619        force_client = self.settings['force-client']
620        if force_client in client_keys:
621            client = client_keys[force_client]
622        elif force_client and force_client[0] == '{':
623            try:
624                client = json.loads(force_client)
625            except json.JSONDecodeError:
626                logging.error("Could not parse force_client json")
627
628        args = (
629            client,
630            email,
631            password,
632        )
633
634        def on_got_stations(*ignore):
635            self.process_stations(self)
636            if callback:
637                callback()
638
639        def pandora_ready(*ignore):
640            logging.info("Pandora connected")
641            if self.settings['pandora-one'] != self.pandora.isSubscriber:
642                self.settings['pandora-one'] = self.pandora.isSubscriber
643                self._pandora_connect_real(message, callback, email, password)
644            else:
645                self.worker_run('get_stations', (), on_got_stations, 'Getting stations...', 'login')
646
647        self.worker_run('connect', args, pandora_ready, message, 'login')
648
649    def pandora_reconnect(self, prefs_dialog, email_password):
650        ''' Stop everything and reconnect '''
651        email, password = email_password
652        self.stop()
653        self.waiting_for_playlist = False
654        self.current_song_index = None
655        self.start_new_playlist = False
656        self.current_station = None
657        self.current_station_id = None
658        self.have_stations = False
659        self.playcount = 0
660        self.songs_model.clear()
661        self._pandora_connect_real("Logging in...", None, email, password)
662
663    def sync_explicit_content_filter_setting(self, *ignore):
664        #reset checkbox to default state
665        self.prefs_dlg.explicit_content_filter_checkbutton.set_label(_('Explicit Content Filter'))
666        self.prefs_dlg.explicit_content_filter_checkbutton.set_sensitive(False)
667        self.prefs_dlg.explicit_content_filter_checkbutton.set_active(False)
668        self.prefs_dlg.explicit_content_filter_checkbutton.set_inconsistent(True)
669        self.filter_state = None
670
671        if self.pandora.connected:
672            def get_filter_and_pin_protected_state(*ignore):
673                return self.pandora.explicit_content_filter_state
674
675            def sync_checkbox(current_state):
676                self.filter_state, pin_protected = current_state[0], current_state[1]
677                self.prefs_dlg.explicit_content_filter_checkbutton.set_inconsistent(False)
678                self.prefs_dlg.explicit_content_filter_checkbutton.set_active(self.filter_state)
679                if pin_protected:
680                    self.prefs_dlg.explicit_content_filter_checkbutton.set_label(_('Explicit Content Filter - PIN Protected'))
681                else:
682                    self.prefs_dlg.explicit_content_filter_checkbutton.set_sensitive(True)
683
684            self.worker_run(get_filter_and_pin_protected_state, (), sync_checkbox)
685
686    def process_stations(self, *ignore):
687        self.stations_model.clear()
688        self.stations_popover.clear()
689        self.current_station = None
690        selected = None
691        # Make sure that the Thumprint Radio Station is always 2nd.
692        for i, s in enumerate(self.pandora.stations):
693            if s.isThumbprint:
694                self.pandora.stations.insert(1, self.pandora.stations.pop(i))
695                break
696        for i, s in enumerate(self.pandora.stations):
697            if s.isQuickMix and s.isCreator:
698                self.stations_model.append((s, "QuickMix", i))
699            else:
700                self.stations_model.append((s, s.name, i))
701            if s.id == self.current_station_id:
702                logging.info("Restoring saved station: id = %s"%(s.id))
703                selected = s
704        if not selected and len(self.stations_model):
705            selected=self.stations_model[0][0]
706        if selected:
707            self.station_changed(selected, reconnecting = self.have_stations)
708            self.have_stations = True
709            self.emit('stations-processed', self.pandora.stations)
710        else:
711            # User has no stations, open dialog
712            self.show_stations()
713
714    @property
715    def current_song(self):
716        if self.current_song_index is not None:
717            return self.songs_model[self.current_song_index][0]
718
719    def start_song(self, song_index):
720        songs_remaining = len(self.songs_model) - song_index
721        if songs_remaining <= 0:
722            # We don't have this song yet. Get a new playlist.
723            return self.get_playlist(start = True)
724        elif songs_remaining == 1:
725            # Preload next playlist so there's no delay
726            self.get_playlist()
727
728        prev = self.current_song
729
730        self.stop()
731        self.current_song_index = song_index
732
733        if prev:
734            self.update_song_row(prev)
735
736        if not self.current_song.is_still_valid():
737            self.current_song.message = 'Song expired'
738            self.update_song_row()
739            return self.next_song()
740
741        if self.current_song.tired or self.current_song.rating == RATE_BAN:
742            return self.next_song()
743
744        logging.info("Starting song: index = %i"%(song_index))
745        song = self.current_song
746        audioUrl = song.audioUrl
747        os.environ['PULSE_PROP_media.title'] = song.title
748        os.environ['PULSE_PROP_media.artist'] = song.artist
749        os.environ['PULSE_PROP_media.name'] = '{}: {}'.format(song.artist, song.title)
750        os.environ['PULSE_PROP_media.filename'] = audioUrl
751        self.player.set_property('buffer-size', int(song.bitrate) * 375)
752        self.player.set_property('connection-speed', int(song.bitrate))
753        self.player.set_property("uri", audioUrl)
754        self._set_player_state(PseudoGst.BUFFERING)
755        self.playcount += 1
756
757        self.current_song.start_time = time.time()
758        self.songs_treeview.scroll_to_cell(song_index, use_align=True, row_align = 1.0)
759        self.songs_treeview.set_cursor(song_index, None, 0)
760        self.set_title("%s by %s - Pithos" % (song.title, song.artist))
761
762        self.update_song_row()
763
764        self.emit('song-changed', song)
765        self.emit('metadata-changed', song)
766
767    @GtkTemplate.Callback
768    def next_song(self, *ignore):
769        if self.current_song_index is not None:
770            self.start_song(self.current_song_index + 1)
771
772    def _set_player_state(self, target, change_gst_state=False):
773        change_gst_state = change_gst_state or self._current_state is not PseudoGst.BUFFERING
774        if change_gst_state:
775            ret = self.player.set_state(target.state)
776            if ret == Gst.StateChangeReturn.FAILURE:
777                current_state = self.player.state_get_name(self._current_state.state)
778                target_state = self.player.state_get_name(target.state)
779                logging.warning('Error changing player state from: {} to: {}'.format(current_state, target_state))
780                return False
781            self._current_state = target
782            if self._current_state is PseudoGst.PLAYING:
783                self.create_ui_loop()
784            else:
785                self.destroy_ui_loop()
786        if target is not PseudoGst.BUFFERING:
787            self._buffer_recovery_state = target
788        self.update_song_row()
789        return True
790
791    def user_play(self, *ignore):
792        if self.play():
793            self.emit('user-changed-play-state', True)
794
795    def play(self, change_gst_state=False):
796        # Edge case. If we try to go to Play while we're reconnecting
797        # to Pandora self.current_song will be None.
798        if self.current_song is None:
799            return False
800        if not self.current_song.is_still_valid():
801            self.current_song.message = 'Song expired'
802            self.update_song_row()
803            return self.next_song()
804
805        if self._set_player_state(PseudoGst.PLAYING, change_gst_state=change_gst_state):
806            self.playpause_image.set_from_icon_name('media-playback-pause-symbolic', Gtk.IconSize.SMALL_TOOLBAR)
807            self.emit('play-state-changed', True)
808        return True
809
810    def user_pause(self, *ignore):
811        self.pause()
812        self.emit('user-changed-play-state', False)
813
814    def pause(self):
815        if self._set_player_state(PseudoGst.PAUSED):
816            self.playpause_image.set_from_icon_name('media-playback-start-symbolic', Gtk.IconSize.SMALL_TOOLBAR)
817            self.emit('play-state-changed', False)
818
819
820    def stop(self):
821        prev = self.current_song
822        if prev and prev.start_time:
823            prev.finished = True
824            prev.position = self.query_position()
825            self.emit("song-ended", prev)
826
827        if self._set_player_state(PseudoGst.STOPPED, change_gst_state=True):
828            # We need to reset the icon at song changes since our default
829            # desired state is playing when going to a new song.
830            self.playpause_image.set_from_icon_name('media-playback-pause-symbolic', Gtk.IconSize.SMALL_TOOLBAR)
831
832    @GtkTemplate.Callback
833    def user_playpause(self, *ignore):
834        self.playpause_notify()
835
836    def playpause(self, *ignore):
837        logging.info("playpause")
838        if self.playing:
839            self.pause()
840        else:
841            self.play()
842
843    def playpause_notify(self, *ignore):
844        if self.playing:
845            self.user_pause()
846        else:
847            self.user_play()
848
849    def get_playlist(self, start = False):
850        if self.playlist_update_timer_id:
851            GLib.source_remove(self.playlist_update_timer_id)
852        self.playlist_update_timer_id = 0
853        songs_left_to_process = 0
854        song_count = 0
855        self.start_new_playlist = self.start_new_playlist or start
856        if self.waiting_for_playlist: return
857
858        if self.gstreamer_errorcount_1 >= self.playcount and self.gstreamer_errorcount_2 >=1:
859            logging.warning("Too many gstreamer errors. Not retrying")
860            self.waiting_for_playlist = 1
861            self.error_dialog(self.gstreamer_error, self.get_playlist)
862            return
863
864        def emit_songs_added(song_count):
865            self.playlist_update_timer_id = 0
866            self.emit('songs-added', song_count)
867            return False
868
869        def get_album_art(url, tmpdir, *extra):
870            try:
871                with urllib.request.urlopen(url) as f:
872                    image = f.read()
873            except urllib.error.HTTPError:
874                logging.warning('Invalid image url received')
875                return (None, None,) + extra
876
877            file_url = None
878            if tmpdir:
879                try:
880                    with tempfile.NamedTemporaryFile(prefix='art-', suffix='.jpeg', dir=tmpdir.name, delete=False) as f:
881                        f.write(image)
882                        file_url = urllib.parse.urljoin('file://', urllib.parse.quote(f.name))
883                except IOError:
884                    logging.warning("Failed to write art tempfile")
885
886            with contextlib.closing(GdkPixbuf.PixbufLoader()) as loader:
887                loader.set_size(ALBUM_ART_SIZE, ALBUM_ART_SIZE)
888                loader.write(image)
889            return (loader.get_pixbuf(), file_url,) + extra
890
891        def art_callback(t):
892            nonlocal songs_left_to_process
893            pixbuf, file_url, song, index = t
894            songs_left_to_process -= 1
895            if index<len(self.songs_model) and self.songs_model[index][0] is song: # in case the playlist has been reset
896                logging.info("Downloaded album art for %i"%song.index)
897                song.art_pixbuf = pixbuf
898                self.songs_model[index][3]=pixbuf
899                self.update_song_row(song)
900                if file_url:
901                    song.artUrl = file_url
902                    # The song is either the current song or we got the cover after
903                    # after the timeout has expired.
904                    if song is self.current_song or not self.playlist_update_timer_id:
905                        self.emit('metadata-changed', song)
906                # We tried to get covers for all the songs in the playlist,
907                # and the timeout is still live. Cancel it and emit
908                # a 'songs-added' signal.
909                if not songs_left_to_process and self.playlist_update_timer_id:
910                    GLib.source_remove(self.playlist_update_timer_id)
911                    emit_songs_added(song_count)
912
913        def callback(l):
914            nonlocal songs_left_to_process
915            nonlocal song_count
916            songs_left_to_process = song_count = len(l)
917            start_index = len(self.songs_model)
918            for i in l:
919                i.index = len(self.songs_model)
920                self.songs_model.append((i, '', None, None))
921                self.update_song_row(i)
922                i.art_pixbuf = None
923                if i.artRadio:
924                    self.worker_run(get_album_art, (i.artRadio, self.tempdir, i, i.index), art_callback)
925                else:
926                    songs_left_to_process -= 1
927            # Give Pandora about 1 secs per song to return the playlist's cover art
928            # after that emit a 'songs-added' Anyway. We can't wait forever after all.
929            self.playlist_update_timer_id = GLib.timeout_add_seconds(song_count, emit_songs_added, song_count)
930
931            self.statusbar.pop(self.statusbar.get_context_id('net'))
932            if self.start_new_playlist:
933                self.start_song(start_index)
934
935            self.gstreamer_errorcount_2 = self.gstreamer_errorcount_1
936            self.gstreamer_errorcount_1 = 0
937            self.playcount = 0
938            self.waiting_for_playlist = False
939            self.start_new_playlist = False
940
941        self.waiting_for_playlist = True
942        self.worker_run(self.current_station.get_playlist, (), callback, "Getting songs...")
943
944    def error_dialog(self, message, retry_cb, submsg=None):
945        dialog = self.error_dialog_real
946
947        dialog.props.text = message
948        dialog.props.secondary_text = submsg
949
950        btn = dialog.get_widget_for_response(2)
951        if retry_cb is None:
952            btn.hide()
953        else:
954            btn.show()
955
956        response = dialog.run()
957        dialog.hide()
958
959        if response == 2:
960            self.gstreamer_errorcount_2 = 0
961            logging.info("Manual retry")
962            return retry_cb()
963        elif response == 3:
964            self.show_preferences()
965
966    def fatal_error_dialog(self, message, submsg):
967        dialog = self.fatal_error_dialog_real
968        dialog.props.text = message
969        dialog.props.secondary_text = submsg
970
971        dialog.run()
972        dialog.hide()
973
974        self.quit()
975
976    def api_update_dialog(self):
977        dialog = self.api_update_dialog_real
978        response = dialog.run()
979        if response:
980            open_browser("http://pithos.github.io/itbroke", self)
981        self.quit()
982
983    def station_changed(self, station, reconnecting=False):
984        if station is self.current_station: return
985        self.waiting_for_playlist = False
986        if not reconnecting:
987            self.stop()
988            self.current_song_index = None
989            self.songs_model.clear()
990        logging.info("Selecting station %s; total = %i" % (station.id, len(self.stations_model)))
991        self.current_station_id = station.id
992        self.current_station = station
993        self.settings.set_string('last-station-id', self.current_station_id)
994        if not reconnecting:
995            self.get_playlist(start = True)
996        self.stations_label.set_text(station.name)
997        self.stations_popover.select_station(station)
998        self.emit('station-changed', station)
999
1000    def station_added(self, station, user_data):
1001        music_type, description = user_data
1002        for existing_station in self.stations_model:
1003            if existing_station[0].id == station.id:
1004                self.station_already_exists(existing_station[0], description, music_type, self)
1005                return
1006        # We shouldn't actually add the station to the pandora stations list
1007        # until we know it's not a duplicate.
1008        self.pandora.stations.append(station)
1009        self.stations_model.insert_with_valuesv(0, (0, 1, 2), (station, station.name, 0))
1010        self.emit('station-added', station)
1011        self.station_changed(station)
1012
1013    def station_already_exists(self, station, description, music_type, parent):
1014        def on_response(dialog, response):
1015            if response == Gtk.ResponseType.YES:
1016                self.station_changed(station)
1017            dialog.destroy()
1018
1019        sub_title = _('Pandora does not permit multiple stations with the same seed.')
1020
1021        if music_type == 'song':
1022            seed = _('Song Seed:')
1023        elif music_type == 'artist':
1024            seed = _('Artist Seed:')
1025        else:
1026            seed = _('Genre Seed:')
1027
1028        if station is self.current_station:
1029            button_type = Gtk.ButtonsType.OK
1030            message = _('{0}\n"{1}", the Station you are currently listening to already contains the {2} {3}.')
1031        else:
1032            button_type = Gtk.ButtonsType.YES_NO
1033            message = _('{0}\nYour Station "{1}" already contains the {2} {3}.\nWould you like to listen to it now?')
1034
1035        message = message.format(sub_title, station.name, seed, description)
1036
1037        dialog = Gtk.MessageDialog(
1038            parent=parent,
1039            flags=Gtk.DialogFlags.MODAL,
1040            type=Gtk.MessageType.WARNING,
1041            buttons=button_type,
1042            text=_('A New Station could not be created'),
1043            secondary_text=message,
1044        )
1045
1046        dialog.connect('response', on_response)
1047        dialog.show()
1048
1049    def query_position(self):
1050      pos_stat = self.player.query(self._query_position)
1051      if pos_stat:
1052        _, position = self._query_position.parse_position()
1053        return position
1054
1055    def query_duration(self):
1056      dur_stat = self.player.query(self._query_duration)
1057      if dur_stat:
1058        _, duration = self._query_duration.parse_duration()
1059        return duration
1060
1061    def query_buffer(self):
1062        buffer_stat = self.player.query(self._query_buffer)
1063        if buffer_stat:
1064            return self._query_buffer.parse_buffering_percent()[0]
1065        else:
1066            return True
1067
1068    def on_gst_stream_start(self, bus, message):
1069        # Edge case. We might get this singal while we're reconnecting to Pandora.
1070        # If so self.current_song will be None.
1071        if self.current_song is None:
1072            return
1073        # Fallback to using song.trackLength which is in seconds and converted to nanoseconds
1074        self.current_song.duration = self.query_duration() or self.current_song.trackLength * Gst.SECOND
1075        self.current_song.duration_message = self.format_time(self.current_song.duration)
1076        self.update_song_row()
1077        self.check_if_song_is_ad()
1078        # We can't seek so duration in MPRIS is just for display purposes if it's not off by more than a sec it's OK.
1079        if self.current_song.get_duration_sec() != self.current_song.trackLength:
1080            self.emit('metadata-changed', self.current_song)
1081
1082    def on_gst_eos(self, bus, message):
1083        logging.info("EOS")
1084        self.next_song()
1085
1086    def on_gst_plugin_installed(self, result, userdata):
1087        if result == GstPbutils.InstallPluginsReturn.SUCCESS:
1088            self.fatal_error_dialog(_("Codec installation successful"),
1089                        submsg=_("The required codec was installed, please restart Pithos."))
1090        else:
1091            self.error_dialog(_("Codec installation failed"), None,
1092                        submsg=_("The required codec failed to install. Either manually install it or try another quality setting."))
1093
1094    def on_gst_element(self, bus, message):
1095        if GstPbutils.is_missing_plugin_message(message):
1096            if GstPbutils.install_plugins_supported():
1097                details = GstPbutils.missing_plugin_message_get_installer_detail(message)
1098                GstPbutils.install_plugins_async([details,], None, self.on_gst_plugin_installed, None)
1099            else:
1100                self.error_dialog(_("Missing codec"), None,
1101                        submsg=_("GStreamer is missing a plugin and it could not be automatically installed. Either manually install it or try another quality setting."))
1102
1103    def on_gst_error(self, bus, message):
1104        err, debug = message.parse_error()
1105        logging.error("Gstreamer error: %s, %s, %s" % (err, debug, err.code))
1106        if self.current_song:
1107            self.current_song.message = "Error: "+str(err)
1108            self.update_song_row()
1109
1110        self.gstreamer_error = str(err)
1111        self.gstreamer_errorcount_1 += 1
1112
1113        if not GstPbutils.install_plugins_installation_in_progress():
1114            self.next_song()
1115
1116    def check_if_song_is_ad(self):
1117        if self.current_song.is_ad is None:
1118            if self.current_song.duration:
1119                if self.current_song.get_duration_sec() < 45:  # Less than 45 seconds we assume it's an ad
1120                    logging.info('Ad detected!')
1121                    self.current_song.is_ad = True
1122                    self.update_song_row()
1123                    self.set_title("Commercial Advertisement - Pithos")
1124                else:
1125                    logging.info('Not an Ad..')
1126                    self.current_song.is_ad = False
1127            else:
1128                logging.warning('dur_stat is False. The assumption that duration is available once the stream-start messages feeds is bad.')
1129
1130    def on_gst_buffering(self, bus, message):
1131        # React to the buffer message immediately and also fire a short repeating timeout
1132        # to check the buffering state that cancels only if we're not buffering or there's a pending timeout.
1133        # This will insure we don't get stuck in a buffering state if we're really not buffering.
1134
1135        self.react_to_buffering_mesage(False)
1136
1137        if self.buffering_timer_id:
1138            GLib.source_remove(self.buffering_timer_id)
1139            self.buffering_timer_id = 0
1140        self.buffering_timer_id = GLib.timeout_add(200, self.react_to_buffering_mesage, True)
1141
1142    def react_to_buffering_mesage(self, from_timeout):
1143        # If the pipeline signals that it is buffering set the player to PseudoGst.BUFFERING
1144        # (which is an alias to Gst.State.PAUSED). During buffering if the user goes to Pause
1145        # or Play(an/or back again) go though all the motions but don't actaully change the
1146        # player's state to the desired state until buffering has completed. The player only
1147        # cares about the actual state, the rest of Pithos only cares about our buffer_recovery
1148        # state, the state we *wish* we were in.
1149
1150        # Reset the timer_id only if called from the timeout
1151        # to avoid GLib.source_remove warnings.
1152        if from_timeout:
1153            self.buffering_timer_id = 0
1154        buffering = self.query_buffer()
1155
1156        if buffering and self._current_state is not PseudoGst.BUFFERING:
1157            logging.debug("Buffer underrun")
1158            if self._set_player_state(PseudoGst.BUFFERING):
1159                logging.debug("Pausing pipeline")
1160        elif not buffering and self._current_state is PseudoGst.BUFFERING:
1161            logging.debug("Buffer overrun")
1162            if self._buffer_recovery_state is PseudoGst.STOPPED:
1163                self.play(change_gst_state=True)
1164                logging.debug("Song starting")
1165            elif self._buffer_recovery_state is PseudoGst.PLAYING:
1166                if self._set_player_state(PseudoGst.PLAYING, change_gst_state=True):
1167                    logging.debug("Restarting pipeline")
1168            elif self._buffer_recovery_state is PseudoGst.PAUSED:
1169                if self._set_player_state(PseudoGst.PAUSED, change_gst_state=True):
1170                    logging.debug("User paused")
1171            # Tell everyone to update their clocks after we're done buffering or
1172            # in case it takes a while after the song-changed signal for actual playback to begin.
1173            self.emit('buffering-finished', self.query_position() or 0)
1174        return buffering
1175
1176    def set_volume_cb(self, volume):
1177        # Convert to the cubic scale that the volume slider uses
1178        scaled_volume = math.pow(volume, 1.0/3.0)
1179        self.volume.handler_block_by_func(self.on_volume_change_event)
1180        self.volume.set_property("value", scaled_volume)
1181        self.volume.handler_unblock_by_func(self.on_volume_change_event)
1182
1183    def on_gst_volume(self, player, volumespec):
1184        vol = self.player.get_property('volume')
1185        GLib.idle_add(self.set_volume_cb, vol)
1186
1187    def on_gst_source(self, player, params):
1188        """ Setup httpsoupsrc to match Pithos proxy settings """
1189        soup = player.props.source.props
1190        proxy = self.get_proxy()
1191        if proxy and hasattr(soup, 'proxy'):
1192            scheme, user, password, hostport = parse_proxy(proxy)
1193            soup.proxy = hostport
1194            soup.proxy_id = user
1195            soup.proxy_pw = password
1196
1197    def song_text(self, song):
1198        title = html.escape(song.title)
1199        artist = html.escape(song.artist)
1200        album = html.escape(song.album)
1201        msg = []
1202        if song is self.current_song:
1203            song.position = self.query_position()
1204            if not song.bitrate is None:
1205                msg.append("%skbit/s" % (song.bitrate))
1206
1207            if song.position is not None and song.duration is not None:
1208                pos_str = self.format_time(song.position)
1209                msg.append("%s / %s" % (pos_str, song.duration_message))
1210                if self.playing is False:
1211                    msg.append("Paused")
1212            if self._current_state is PseudoGst.BUFFERING:
1213                msg.append("Buffering…")
1214        if song.message:
1215            msg.append(song.message)
1216        msg = " - ".join(msg)
1217        if not msg:
1218            msg = " "
1219
1220        if song.is_ad:
1221            description = "<b><big>Commercial Advertisement</big></b>\n<b>Pandora</b>"
1222        else:
1223            description = "<b><big>%s</big></b>\nby <b>%s</b>\n<small>from <i>%s</i></small>" % (title, artist, album)
1224
1225        return "%s\n<small>%s</small>" % (description, msg)
1226
1227    @staticmethod
1228    def song_icon(song):
1229        if song.tired:
1230            return 'tired'
1231        if song.rating == RATE_LOVE:
1232            return 'love'
1233        if song.rating == RATE_BAN:
1234            return 'ban'
1235        return None
1236
1237    def update_song_row(self, song = None):
1238        if song is None:
1239            song = self.current_song
1240        if song:
1241            self.songs_model[song.index][1] = self.song_text(song)
1242            self.songs_model[song.index][2] = self.song_icon(song)
1243        return True
1244
1245    def create_ui_loop(self):
1246        if not self.ui_loop_timer_id:
1247            self.ui_loop_timer_id = GLib.timeout_add_seconds(1, self.update_song_row)
1248
1249    def destroy_ui_loop(self):
1250        if self.ui_loop_timer_id:
1251            GLib.source_remove(self.ui_loop_timer_id)
1252            self.ui_loop_timer_id = 0
1253
1254    def search_activate_handler(self,station):
1255        row = self.stations_popover.listbox.get_row_at_y(0)
1256        if not row:
1257            return
1258        self.station_changed(row.station)
1259        self.stations_popover.on_row_activated(self.stations_popover.listbox, row)
1260
1261    def active_station_changed(self, listbox, row):
1262        self.station_changed(row.station)
1263
1264    @staticmethod
1265    def format_time(time_int):
1266        if time_int is None:
1267          return None
1268
1269        time_int //= 1000000000
1270        s = time_int % 60
1271        time_int //= 60
1272        m = time_int % 60
1273        time_int //= 60
1274        h = time_int
1275
1276        if h:
1277            return "%i:%02i:%02i"%(h,m,s)
1278        else:
1279            return "%i:%02i"%(m,s)
1280
1281    def selected_song(self):
1282        sel = self.songs_treeview.get_selection().get_selected()
1283        if sel:
1284            return self.songs_treeview.get_model().get_value(sel[1], 0)
1285
1286    def start_selected_song(self, *ignore):
1287        playable = self.selected_song().index > self.current_song_index
1288        if playable:
1289            self.start_song(self.selected_song().index)
1290        return playable
1291
1292    def love_song(self, *ignore, song=None):
1293        song = song or self.current_song
1294        def callback(l):
1295            self.update_song_row(song)
1296            self.emit('metadata-changed', song)
1297        self.worker_run(song.rate, (RATE_LOVE,), callback, "Loving song...")
1298
1299
1300    def ban_song(self, *ignore, song=None):
1301        song = song or self.current_song
1302        def callback(l):
1303            self.update_song_row(song)
1304            self.emit('metadata-changed', song)
1305        self.worker_run(song.rate, (RATE_BAN,), callback, "Banning song...")
1306        if song is self.current_song:
1307            self.next_song()
1308
1309    def unrate_song(self, *ignore, song=None):
1310        song = song or self.current_song
1311        def callback(l):
1312            self.update_song_row(song)
1313            self.emit('metadata-changed', song)
1314        self.worker_run(song.rate, (RATE_NONE,), callback, "Removing song rating...")
1315
1316    def tired_song(self, *ignore, song=None):
1317        song = song or self.current_song
1318        def callback(l):
1319            self.update_song_row(song)
1320            self.emit('metadata-changed', song)
1321        self.worker_run(song.set_tired, (), callback, "Putting song on shelf...")
1322        if song is self.current_song:
1323            self.next_song()
1324
1325    def bookmark_song(self, *ignore, song=None):
1326        song = song or self.current_song
1327        self.worker_run(song.bookmark, (), None, "Bookmarking...")
1328
1329    def bookmark_song_artist(self, *ignore, song=None):
1330        song = song or self.current_song
1331        self.worker_run(song.bookmark_artist, (), None, "Bookmarking...")
1332
1333    def info_song(self, *ignore, song=None):
1334        song = song or self.current_song
1335        open_browser(song.songDetailURL)
1336
1337    @GtkTemplate.Callback
1338    def on_menuitem_love(self, widget):
1339        self.love_song(song=self.selected_song())
1340
1341    @GtkTemplate.Callback
1342    def on_menuitem_ban(self, widget):
1343        self.ban_song(song=self.selected_song())
1344
1345    @GtkTemplate.Callback
1346    def on_menuitem_unrate(self, widget):
1347        self.unrate_song(song=self.selected_song())
1348
1349    @GtkTemplate.Callback
1350    def on_menuitem_tired(self, widget):
1351        self.tired_song(song=self.selected_song())
1352
1353    @GtkTemplate.Callback
1354    def on_menuitem_info(self, widget):
1355        self.info_song(song=self.selected_song())
1356
1357    @GtkTemplate.Callback
1358    def on_menuitem_bookmark_song(self, widget):
1359        self.bookmark_song(song=self.selected_song())
1360
1361    @GtkTemplate.Callback
1362    def on_menuitem_bookmark_artist(self, widget):
1363        self.bookmark_song_artist(self.selected_song())
1364
1365    @GtkTemplate.Callback
1366    def on_menuitem_create_artist_station(self, widget):
1367        user_date = 'artist', html.escape(self.selected_song().artist)
1368        self.worker_run(
1369            'add_station_by_track_token',
1370            (self.selected_song().trackToken, 'artist'),
1371            self.station_added,
1372            user_data=user_date,
1373        )
1374
1375    @GtkTemplate.Callback
1376    def on_menuitem_create_song_station(self, widget):
1377        title = html.escape(self.selected_song().title)
1378        artist = html.escape(self.selected_song().artist)
1379        user_date = 'song', '{} by {}'.format(title, artist)
1380        self.worker_run(
1381            'add_station_by_track_token',
1382            (self.selected_song().trackToken, 'song'),
1383            self.station_added,
1384            user_data=user_date,
1385        )
1386
1387    def on_treeview_button_press_event(self, treeview, event):
1388        x = int(event.x)
1389        y = int(event.y)
1390        pthinfo = treeview.get_path_at_pos(x, y)
1391        if pthinfo is not None:
1392            path, col, cellx, celly = pthinfo
1393            treeview.grab_focus()
1394            treeview.set_cursor( path, col, 0)
1395
1396            if event.button == 3:
1397                rating = self.selected_song().rating
1398                self.song_menu_love.set_property("visible", rating != RATE_LOVE)
1399                self.song_menu_unlove.set_property("visible", rating == RATE_LOVE)
1400                self.song_menu_ban.set_property("visible", rating != RATE_BAN)
1401                self.song_menu_unban.set_property("visible", rating == RATE_BAN)
1402
1403                popup_at_pointer(self.song_menu, event)
1404                return True
1405
1406            if event.button == 1 and event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS:
1407                logging.info("Double clicked on song %s", self.selected_song().index)
1408                return self.start_selected_song()
1409
1410    def set_player_volume(self, value):
1411        # Use a cubic scale for volume. This matches what PulseAudio uses.
1412        volume = math.pow(value, 3)
1413        self.player.set_property("volume", volume)
1414
1415    def adjust_volume(self, amount):
1416        old_volume = self.volume.get_property("value")
1417        new_volume = max(0.0, min(1.0, old_volume + 0.02 * amount))
1418
1419        if new_volume != old_volume:
1420            self.volume.set_property("value", new_volume)
1421
1422    def volume_up(self, *ignore):
1423        self.adjust_volume(+2)
1424
1425    def volume_down(self, *ignore):
1426        self.adjust_volume(-2)
1427
1428    @GtkTemplate.Callback
1429    def on_volume_change_event(self, volumebutton, value):
1430        self.set_player_volume(value)
1431
1432    def show_about(self, version):
1433        """about - display the about box for pithos """
1434        about = AboutPithosDialog.AboutPithosDialog(transient_for=self)
1435        about.set_version(version)
1436        about.run()
1437        about.destroy()
1438
1439    def on_prefs_response(self, widget, response):
1440        self.prefs_dlg.hide()
1441
1442        if response == Gtk.ResponseType.APPLY:
1443            self.on_explicit_content_filter_checkbox()
1444        else:
1445            if not self.settings['email']:
1446                self.quit()
1447
1448    def show_preferences(self):
1449        """preferences - display the preferences window for pithos """
1450        self.sync_explicit_content_filter_setting()
1451        self.prefs_dlg.show()
1452
1453    def show_stations(self):
1454        if self.stations_dlg:
1455            self.stations_dlg.present()
1456        else:
1457            self.stations_dlg = StationsDialog.StationsDialog(self, transient_for=self)
1458            self.stations_dlg.show_all()
1459            self.emit('stations-dlg-ready', True)
1460
1461    def refresh_stations(self, *ignore):
1462        self.worker_run(self.pandora.get_stations, (), self.process_stations, "Refreshing stations...")
1463
1464    def remove_station(self, station):
1465        def station_index(model, s):
1466            return [i[0] for i in model].index(s)
1467        del self.stations_model[station_index(self.stations_model, station)]
1468        self.stations_popover.remove_station(station)
1469
1470    def restore_position(self):
1471        """ Moves window to position stored in preferences """
1472        # Getting and setting window position does not work in Wayland.
1473        if self.not_in_x:
1474            return
1475        x, y = self.settings['win-pos']
1476        self.handler_block_by_func(self.on_configure_event)
1477        self.move(x, y)
1478        self.handler_unblock_by_func(self.on_configure_event)
1479
1480    def bring_to_top(self, *ignore):
1481        timestamp = Gtk.get_current_event_time()
1482        self.present_with_time(timestamp)
1483
1484    def present_with_time(self, timestamp):
1485        self.restore_position()
1486        Gtk.Window.present_with_time(self, timestamp)
1487
1488    def present(self):
1489        self.restore_position()
1490        Gtk.Window.present(self)
1491
1492    @GtkTemplate.Callback
1493    def on_configure_event(self, *ignore):
1494        # Getting and setting window position does not work in Wayland.
1495        if self.not_in_x:
1496            return
1497        x, y = self.get_position() # This can return None
1498        self.settings.set_value('win-pos', GLib.Variant('(ii)', (x or 0, y or 0)))
1499
1500    def quit(self, widget=None, data=None):
1501        """quit - signal handler for closing the PithosWindow"""
1502        Gio.Application.get_default().quit()
1503
1504    @GtkTemplate.Callback
1505    def on_destroy(self, widget, data=None):
1506        """on_destroy - called when the PithosWindow is close. """
1507        self.stop()
1508        self.quit()
1509