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