1# Copyright (C) 2009-2010 2# Adam Olsen <arolsen@gmail.com> 3# Abhishek Mukherjee <abhishek.mukher.g@gmail.com> 4# Steve Dodier <sidnioulzg@gmail.com> 5# Copyright (C) 2017 Christian Stadelmann 6# 7# This program is free software; you can redistribute it and/or modify 8# it under the terms of the GNU General Public License as published by 9# the Free Software Foundation; either version 1, or (at your option) 10# any later version. 11# 12# This program is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with this program; if not, write to the Free Software 19# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 21import html 22import logging 23 24import gi 25from gi.repository import Gtk, GLib 26 27from xl.player.adapters import PlaybackAdapter 28from xl import covers, common 29from xl import event as xl_event 30from xl import player as xl_player 31from xl import settings as xl_settings 32from xl.nls import gettext as _ 33from xlgui import icons 34from xlgui.guiutil import pixbuf_from_data 35 36from . import notifyprefs 37 38 39# For documentation on libnotify see also the "Desktop Notifications Specification": 40# https://developer.gnome.org/notification-spec/ 41gi.require_version('Notify', '0.7') 42from gi.repository import Notify 43 44 45LOGGER = logging.getLogger(__name__) 46DEFAULT_ICON_SIZE = (48, 48) 47 48BODY_ARTIST_ALBUM = _('from {album} by {artist}') 49BODY_ARTIST = _('by {artist}') 50BODY_ALBUM = _('by {album}') 51 52 53class NotifierSettings: 54 @staticmethod 55 def __inner_preference(klass): 56 """Function will make a property for a given subclass of Preference""" 57 58 def getter(self): 59 return xl_settings.get_option(klass.name, klass.default or None) 60 61 def setter(self, val): 62 xl_settings.set_option(klass.name, val) 63 64 # Migration: 65 if hasattr(klass, 'pre_migration_name'): 66 if xl_settings.get_option(klass.name, None) is None: 67 old_value = xl_settings.get_option( 68 klass.pre_migration_name, klass.default or None 69 ) 70 if old_value is not None: 71 xl_settings.set_option(klass.name, old_value) 72 xl_settings.MANAGER.remove_option(klass.name) 73 74 return property(getter, setter) 75 76 notify_pause = __inner_preference(notifyprefs.NotifyPause) 77 resize_covers = __inner_preference(notifyprefs.ResizeCovers) 78 show_covers = __inner_preference(notifyprefs.ShowCovers) 79 show_when_focused = __inner_preference(notifyprefs.ShowWhenFocused) 80 tray_hover = __inner_preference(notifyprefs.TrayHover) 81 use_media_icons = __inner_preference(notifyprefs.UseMediaIcons) 82 83 84class Notifier(PlaybackAdapter): 85 86 settings = NotifierSettings() 87 88 def __init__(self, exaile, caps): 89 self.__exaile = exaile 90 self.__old_icon = None 91 self.__tray_connection = None 92 93 notification = Notify.Notification.new("", None, None) 94 95 if 'sound' in caps: 96 notification.set_hint('suppress-sound', GLib.Variant.new_boolean(True)) 97 self.settings.can_show_markup = 'body-markup' in caps 98 99 notification.set_urgency(Notify.Urgency.LOW) 100 notification.set_timeout(Notify.EXPIRES_DEFAULT) 101 102 # default action is invoked on clicking the notification 103 notification.add_action( 104 "default", "this should never be displayed", self.__on_default_action 105 ) 106 107 self.notification = notification 108 PlaybackAdapter.__init__(self, xl_player.PLAYER) 109 110 xl_event.add_callback(self.on_option_set, 'plugin_notify_option_set') 111 xl_event.add_callback(self.on_option_set, 'gui_option_set') 112 # initial setup through options: 113 self.on_option_set(None, xl_settings, notifyprefs.TrayHover.name) 114 self.on_option_set(None, xl_settings, notifyprefs.ShowCovers.name) 115 116 def __on_default_action(self, _notification, _action): 117 self.__exaile.gui.main.window.present() 118 119 def on_playback_player_end(self, _event, player, track): 120 if self.settings.notify_pause: 121 self.update_track_notify(player, track, 'media-playback-stop') 122 123 def on_playback_track_start(self, _event, player, track): 124 self.update_track_notify(player, track) 125 126 def on_playback_toggle_pause(self, _event, player, track): 127 if self.settings.notify_pause: 128 if player.is_paused(): 129 self.update_track_notify(player, track, 'media-playback-pause') 130 else: 131 self.update_track_notify(player, track, 'media-playback-start') 132 133 def on_playback_error(self, _event, _player, message): 134 self.__maybe_show_notification("Playback error", message, 'dialog-error') 135 136 def __on_query_tooltip(self, *_args): 137 if self.settings.tray_hover: 138 self.__maybe_show_notification(force_show=True) 139 140 @common.idle_add() 141 def __try_connect_tray(self): 142 tray_icon = self.__exaile.gui.tray_icon 143 if tray_icon: 144 self.__tray_connection = tray_icon.connect( 145 'query-tooltip', self.__on_query_tooltip 146 ) 147 else: 148 LOGGER.warning("Tried to connect to non-existing tray icon") 149 150 def __set_tray_hover_state(self, state): 151 # Interacting with the tray might break if our option handler is being 152 # invoked when exaile.gui.tray_icon == None. This might happen because 153 # it is not defined which 'option_set' handler is being invoked first. 154 tray_icon = self.__exaile.gui.tray_icon 155 if state and not self.__tray_connection: 156 if tray_icon: 157 self.__tray_connection = tray_icon.connect( 158 'query-tooltip', self.__on_query_tooltip 159 ) 160 else: 161 self.__try_connect_tray() 162 elif not state and self.__tray_connection: 163 if tray_icon: # xlgui.main might already have destroyed the tray icon 164 self.__exaile.gui.tray_icon.disconnect(self.__tray_connection) 165 self.__tray_connection = None 166 167 def on_option_set(self, _event, settings, option): 168 if option == notifyprefs.TrayHover.name or option == 'gui/use_tray': 169 has_tray = settings.get_option('gui/use_tray') 170 shall_show_tray_hover = self.settings.tray_hover and has_tray 171 self.__set_tray_hover_state(shall_show_tray_hover) 172 elif option == notifyprefs.ShowCovers.name: 173 # delete current cover if user doesn't want to see more covers 174 if not self.settings.show_covers: 175 if self.settings.resize_covers: 176 size = DEFAULT_ICON_SIZE[1] 177 else: 178 size = Gtk.IconSize.DIALOG 179 new_icon = icons.MANAGER.pixbuf_from_icon_name('exaile', size) 180 self.notification.set_image_from_pixbuf(new_icon) 181 182 def __maybe_show_notification( 183 self, summary=None, body='', icon_name=None, force_show=False 184 ): 185 # If summary is none, don't update the Notification 186 if summary is not None: 187 try: 188 self.notification.update(summary, body, icon_name) 189 except GLib.Error: 190 LOGGER.exception("Could not set new notification status.") 191 return 192 # decide whether to show the notification or not 193 if ( 194 self.settings.show_when_focused 195 or not self.__exaile.gui.main.window.is_active() 196 or force_show 197 ): 198 try: 199 self.notification.show() 200 except GLib.Error: 201 LOGGER.exception("Could not set new notification status.") 202 self.__exaile.plugins.disable_plugin(__name__) 203 204 def __get_body_str(self, track): 205 artist_str = html.escape( 206 track.get_tag_display('artist', artist_compilations=False) 207 ) 208 album_str = html.escape(track.get_tag_display('album')) 209 210 if self.settings.can_show_markup: 211 if artist_str: 212 artist_str = '<i>%s</i>' % artist_str 213 if album_str: 214 album_str = '<i>%s</i>' % album_str 215 216 if artist_str and album_str: 217 body = BODY_ARTIST_ALBUM.format(artist=artist_str, album=album_str) 218 elif artist_str: 219 body = BODY_ARTIST.format(artist=artist_str) 220 elif album_str: 221 body = BODY_ALBUM.format(album=album_str) 222 else: 223 body = "" 224 return body 225 226 def __get_icon(self, track, media_icon): 227 # TODO: icons are too small, even with settings.resize_covers=False 228 icon_name = None 229 if media_icon and self.settings.use_media_icons: 230 icon_name = media_icon 231 elif self.settings.show_covers: 232 cover_data = covers.MANAGER.get_cover( 233 track, set_only=True, use_default=True 234 ) 235 size = DEFAULT_ICON_SIZE if self.settings.resize_covers else None 236 new_icon = pixbuf_from_data(cover_data, size) 237 self.notification.set_image_from_pixbuf(new_icon) 238 return icon_name 239 240 def update_track_notify(self, _player, track, media_icon=None): 241 # TODO: notification.add_action(): previous, play/pause, next ? 242 title = html.escape(track.get_tag_display('title')) 243 244 summary = title 245 body = self.__get_body_str(track) 246 icon_name = self.__get_icon(track, media_icon) 247 248 # If icon_name is None, the previous icon will not be replaced 249 self.__maybe_show_notification(summary, body, icon_name) 250 251 def destroy(self): 252 PlaybackAdapter.destroy(self) 253 self.__set_tray_hover_state(False) 254 notification = self.notification 255 # must be called on separate thread, since it is a synchronous call and might block 256 self.__close_notification(notification) 257 self.notification.clear_actions() 258 self.notification = None 259 self.__exaile = None 260 261 @staticmethod 262 @common.threaded 263 def __close_notification(notification): 264 try: 265 notification.close() 266 except GLib.Error: 267 LOGGER.exception("Failed to close notification") 268 269 270class NotifyPlugin: 271 def __init__(self): 272 self.__notifier = None 273 self.__exaile = None 274 275 def enable(self, exaile): 276 self.__exaile = exaile 277 self.__init_notify() 278 279 @common.threaded 280 def __init_notify(self): 281 can_continue = True 282 caps = None 283 if not Notify.is_initted(): 284 can_continue = Notify.init('Exaile') 285 if not can_continue: 286 LOGGER.error("Notify.init() returned false.") 287 288 if can_continue: 289 # This is the first synchronous call to the Notify server. 290 # This call might fail if no server is present or it is broken. 291 # Test it on window manager sessions (e.g. Weston) without 292 # libnotify support, not on a Desktop Environment (such as 293 # GNOME, KDE) to reproduce. 294 available, name, vendor, version, spec_version = Notify.get_server_info() 295 if available: 296 LOGGER.info( 297 "Connected with notify server %s (version %s) by %s", 298 name, 299 version, 300 vendor, 301 ) 302 LOGGER.info("Supported spec version: %s", spec_version) 303 # This is another synchronous, blocking call: 304 caps = Notify.get_server_caps() 305 # Example from Fedora 26 Linux with GNOME on Wayland: 306 # ['actions', 'body', 'body-markup', 'icon-static', 'persistence', 'sound'] 307 LOGGER.debug("Notify server caps: %s", caps) 308 if not caps or not isinstance(caps, list): 309 can_continue = False 310 else: 311 LOGGER.error( 312 "Failed to retrieve capabilities from notify server. " 313 "This may happen if the desktop environment does not support " 314 "the org.freedesktop.Notifications DBus interface." 315 ) 316 can_continue = False 317 self.__handle_init(can_continue, caps) 318 319 # Must be run on main thread because we need to make sure that the plugin 320 # is not being disabled while this function runs. Otherwise, race 321 # conditions might trigger obscure bugs. 322 @common.idle_add() 323 def __handle_init(self, can_continue, caps): 324 exaile = self.__exaile 325 if exaile is None: # Plugin has been disabled in the mean time 326 return 327 328 if can_continue: # check again, might have changed 329 if exaile.loading: 330 xl_event.add_ui_callback(self.__init_notifier, 'gui_loaded', None, caps) 331 else: 332 self.__init_notifier(caps) 333 else: 334 LOGGER.warning("Disabling NotifyPlugin.") 335 exaile.plugins.disable_plugin(__name__) 336 if not exaile.loading: 337 # TODO: send error to GUI 338 pass 339 340 def __init_notifier(self, caps): 341 self.__notifier = Notifier(self.__exaile, caps) 342 return GLib.SOURCE_REMOVE 343 344 def disable(self, exaile): 345 self.teardown(exaile) 346 347 self.__exaile = None 348 349 def teardown(self, exaile): 350 if self.__notifier: 351 self.__notifier.destroy() 352 self.__notifier = None 353 354 def get_preferences_pane(self): 355 return notifyprefs 356 357 358plugin_class = NotifyPlugin 359