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