Properly update existing episodes (bug 211)
[gpodder.git] / src / gpodder / trayicon.py
blob432ea99fde6025bc2aee08712b2c8fcc99bbfeb7
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 # trayicon.py -- Tray icon and notification support
22 # Jérôme Chabod (JCH) <jerome.chabod@ifrance.com> 2007-12-20
25 import gtk
26 import datetime
28 import gpodder
29 from gpodder.liblogger import log
30 from gpodder.libgpodder import gl
31 from gpodder.libpodcasts import podcastItem
33 try:
34 import pynotify
35 have_pynotify = True
36 except:
37 log('Cannot find pynotify. Please install the python-notify package.')
38 log('Notification bubbles have been disabled.')
39 have_pynotify = False
41 from gpodder import services
42 from gpodder import util
43 from gpodder import draw
45 if gpodder.interface == gpodder.MAEMO:
46 import hildon
48 class GPodderStatusIcon(gtk.StatusIcon):
49 """ this class display a status icon in the system tray
50 this icon serves to show or hide gPodder, notify dowload status
51 and provide a popupmenu for quick acces to some
52 gPodder functionalities
54 author: Jérôme Chabod <jerome.chabod at ifrance.com>
55 """
57 DEFAULT_TOOLTIP = _('gPodder media aggregator')
59 # status: they are displayed as tooltip and add a small icon to the main icon
60 STATUS_DOWNLOAD_IN_PROGRESS = (_('Downloading episodes'), gtk.STOCK_GO_DOWN)
61 STATUS_UPDATING_FEED_CACHE = (_('Looking for new episodes'), gtk.STOCK_REFRESH)
62 STATUS_SYNCHRONIZING = (_('Synchronizing to player'), 'multimedia-player')
63 STATUS_DELETING = (_('Cleaning files'), gtk.STOCK_DELETE)
65 # actions: buttons within the notify bubble
66 ACTION_SHOW = ('show', _('Show'))
67 ACTION_QUIT = ('quit', _('Quit gPodder'))
68 ACTION_FORCE_EXIT = ('force_quit', _('Quit anyway'))
69 ACTION_KEEP_DOWLOADING = ('keep_dowloading', _('Keep dowloading'))
70 ACTION_START_DOWNLOAD = ('download', _('Download'))
72 def __init__(self, gp, icon_filename):
73 gtk.StatusIcon.__init__(self)
74 log('Creating tray icon', sender=self)
76 self.__gpodder = gp
77 self.__finished_downloads = []
78 self.__icon_cache = {}
79 self.__icon_filename = icon_filename
80 self.__current_icon = -1
81 self.__is_downloading = False
82 self.__synchronisation_device = None
83 self.__download_start_time = None
84 self.__sync_progress = ''
86 self.__previous_notification = []
88 # try getting the icon
89 try:
90 if gpodder.interface == gpodder.GUI:
91 self.__icon = gtk.gdk.pixbuf_new_from_file(self.__icon_filename)
92 elif gpodder.interface == gpodder.MAEMO:
93 self.__icon = gtk.gdk.pixbuf_new_from_file_at_size(self.__icon_filename, 36, 36)
94 except Exception, exc:
95 log('Warning: Cannot load gPodder icon, will use the default icon (%s)', exc, sender=self)
96 self.__icon = gtk.icon_theme_get_default().load_icon(gtk.STOCK_DIALOG_QUESTION, 30, 30)
98 # Reset trayicon (default icon, default tooltip)
99 self.__current_pixbuf = None
100 self.__last_ratio = 1.0
101 self.set_status()
103 menu = self.__create_context_menu()
104 if gpodder.interface == gpodder.GUI:
105 self.connect('activate', self.__on_left_click)
106 self.connect('popup-menu', self.__on_right_click, menu)
107 elif gpodder.interface == gpodder.MAEMO:
108 # On Maemo, we show the popup menu on left-click
109 self.connect('activate', self.__on_right_click, menu)
111 self.set_visible(True)
113 # initialise pynotify
114 if have_pynotify:
115 if not pynotify.init('gPodder'):
116 log('Error: unable to initialise pynotify', sender=self)
118 # Register with the download status manager
119 dl_man = services.download_status_manager
120 dl_man.register('progress-changed', self.__on_download_progress_changed)
121 dl_man.register('download-complete', self.__on_download_complete)
123 def __create_context_menu(self):
124 # build and connect the popup menu
125 menu = gtk.Menu()
126 menuItem = gtk.ImageMenuItem(_("Check for new episodes"))
127 menuItem.set_image(gtk.image_new_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU))
128 # connect the "on_itemUpdate_activate" with the parameter notify_no_new_episodes set to True
129 menuItem.connect('activate', self.__gpodder.on_itemUpdate_activate, True)
130 menu.append(menuItem)
132 menuItem = gtk.ImageMenuItem(_("Download all new episodes"))
133 menuItem.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
134 menuItem.connect('activate', self.__gpodder.on_itemDownloadAllNew_activate)
135 menu.append(menuItem)
137 # menus's label will adapt to the synchronisation device name
138 if gl.config.device_type != 'none':
139 sync_label = _('Synchronize to %s') % (gl.get_device_name(),)
140 menuItem = gtk.ImageMenuItem(sync_label)
141 menuItem.set_sensitive(gl.config.device_type != 'none')
142 menuItem.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
143 menuItem.connect('activate', self.__gpodder.on_sync_to_ipod_activate)
144 menu.append(menuItem)
145 menu.append( gtk.SeparatorMenuItem())
147 self.menuItem_previous_msg = gtk.ImageMenuItem(_('Show previous message again'))
148 self.menuItem_previous_msg.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
149 self.menuItem_previous_msg.connect('activate', self.__on_show_previous_message_callback)
150 self.menuItem_previous_msg.set_sensitive(False)
151 menu.append(self.menuItem_previous_msg)
153 menu.append( gtk.SeparatorMenuItem())
155 menuItem = gtk.ImageMenuItem(gtk.STOCK_PREFERENCES)
156 menuItem.connect('activate', self.__gpodder.on_itemPreferences_activate)
157 menu.append(menuItem)
159 menuItem = gtk.ImageMenuItem(gtk.STOCK_ABOUT)
160 menuItem.connect('activate', self.__gpodder.on_itemAbout_activate)
161 menu.append(menuItem)
162 menu.append( gtk.SeparatorMenuItem())
164 if gpodder.interface == gpodder.MAEMO:
165 # On Maemo, we map the left-click to the popup-menu,
166 # so add a menu item to do the left-click action
167 menuItem = gtk.ImageMenuItem(_('Hide gPodder'))
168 self.item_showhide = menuItem
170 def show_hide_gpodder_maemo(menu_item):
171 visible = self.__gpodder.gPodder.get_property('visible')
172 (label, image) = menu_item.get_children()
173 if visible:
174 label.set_text(_('Show gPodder'))
175 menu_item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_UP, gtk.ICON_SIZE_MENU))
176 else:
177 label.set_text(_('Hide gPodder'))
178 menu_item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
179 self.__gpodder.gPodder.set_property('visible', not visible)
181 menuItem.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
182 menuItem.connect('activate', lambda widget: show_hide_gpodder_maemo(self.item_showhide))
183 menu.append(menuItem)
185 menuItem = gtk.ImageMenuItem(gtk.STOCK_QUIT)
186 menuItem.connect('activate', self.__on_exit_callback)
187 menu.append(menuItem)
189 return menu
191 def __on_exit_callback(self, widget, *args):
192 if self.__is_downloading and self.__is_notification_on():
193 self.send_notification(_("gPodder is downloading episodes\ndo you want to exit anyway?"""), "gPodder",[self.ACTION_FORCE_EXIT, self.ACTION_KEEP_DOWLOADING])
194 else:
195 self.__gpodder.close_gpodder()
197 def __on_show_previous_message_callback(self, widget, *args):
198 p = self.__previous_notification
199 if p != []:
200 self.send_notification(p[0], p[1], p[2], p[3])
202 def __on_right_click(self, widget, button=None, time=0, data=None):
203 """Open popup menu on right-click
205 if gpodder.interface == gpodder.MAEMO and data is None and button is not None:
206 # The left-click action has a different function
207 # signature, so we have to swap parameters here
208 data = button
209 if data is not None:
210 data.show_all()
211 data.popup(None, None, None, 3, time)
213 def __on_left_click(self, widget, data=None):
214 """Hide/unhide gPodder on left-click
216 if self.__gpodder.minimized:
217 self.__gpodder.uniconify_main_window()
218 else:
219 if not self.__gpodder.gpodder_main_window.is_active():
220 self.__gpodder.gpodder_main_window.present()
221 else:
222 self.__gpodder.iconify_main_window()
224 def __on_download_complete(self, episode):
225 """Remember finished downloads
227 self.__finished_downloads.append(episode)
229 def __on_download_progress_changed( self, count, percentage):
230 """ callback by download manager during dowloading.
231 It updates the tooltip with information on how many
232 files are dowloaded and the percentage of dowload
235 tooltip = []
236 if count > 0:
237 self.__is_downloading = True
238 if not self.__download_start_time:
239 self.__download_start_time = datetime.datetime.now()
240 if count == 1:
241 tooltip.append(_('downloading one episode'))
242 else:
243 tooltip.append(_('downloading %d episodes')%count)
245 tooltip.append(' (%d%%) :'%percentage)
247 downloading = []
248 for episode in self.__finished_downloads:
249 downloading.append(_("%s (completed)") % episode)
250 for status in services.download_status_manager.status_list.values():
251 downloading.append(status['thread'].episode.title + " (%d%% - %s)" % (status['progress'], status['speed']))
252 tooltip.append(self.format_episode_list(downloading))
254 if percentage <> 0:
255 date_diff = datetime.datetime.now() - self.__download_start_time
256 estim = date_diff.seconds * 100 // percentage - date_diff.seconds
257 tooltip.append('\n' + _('Estimated remaining time: '))
258 tooltip.append(util.format_seconds_to_hour_min_sec(estim))
260 self.set_status(self.STATUS_DOWNLOAD_IN_PROGRESS, ''.join(tooltip))
262 self.progress_bar(float(percentage)/100.)
263 else:
264 self.__is_downloading = False
265 self.__download_start_time = None
266 self.set_status()
267 num = len(self.__finished_downloads)
268 if num == 1:
269 title = _('one episodes downloaded:')
270 elif num > 1:
271 title = _('%d episodes downloaded:')%num
272 else:
273 # No episodes have finished downloading, ignore
274 return
276 message = self.format_episode_list(self.__finished_downloads, title)
277 self.send_notification(message, _('gPodder downloads finished'))
279 self.__finished_downloads = []
281 def __get_status_icon(self, icon):
282 if icon in self.__icon_cache:
283 return self.__icon_cache[icon]
285 try:
286 new_icon = self.__icon.copy()
287 emblem = gtk.icon_theme_get_default().load_icon(icon, int(new_icon.get_width()/1.5), 0)
288 (width, height) = (emblem.get_width(), emblem.get_height())
289 xpos = new_icon.get_width()-width
290 ypos = new_icon.get_height()-height
291 emblem.composite(new_icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
292 self.__icon_cache[icon] = new_icon
293 return new_icon
294 except Exception, exc:
295 pass
297 log('Warning: Cannot create status icon: %s', icon, sender=self)
298 return self.__icon
300 def __action_callback(self, n, action):
301 """ call back when a button is clicked in a notify bubble """
302 log("action triggered %s", action, sender = self)
303 n.close()
304 if action=='show':
305 self.__gpodder.uniconify_main_window()
306 elif action=='quit':
307 self.__gpodder.close_gpodder()
308 elif action=='keep_dowloading':
309 pass
310 elif action=='force_quit':
311 self.__gpodder.close_gpodder()
312 elif action=='download':
313 self.__gpodder.on_itemDownloadAllNew_activate(self.__gpodder)
314 else:
315 log("don't know what to do with action %s" % action, sender = self)
316 gtk.main_quit()
318 def __is_notification_on(self):
319 # tray icon not visible or notifications disabled
320 if not self.get_visible() or not gl.config.enable_notifications:
321 return False
322 return True
324 def send_notification( self, message, title = "gPodder", actions = [], is_error=False):
325 if not self.__is_notification_on(): return
327 message = message.strip()
328 log('Notification: %s', message, sender=self)
329 if gpodder.interface == gpodder.MAEMO:
330 pango_markup = '<b>%s</b>\n<small>%s</small>' % (title, message)
331 hildon.hildon_banner_show_information_with_markup(gtk.Label(''), None, pango_markup)
332 elif gpodder.interface == gpodder.GUI and have_pynotify:
333 notification = pynotify.Notification(title, message, self.__icon_filename)
334 if is_error: notification.set_urgency(pynotify.URGENCY_CRITICAL)
335 try:
336 notification.attach_to_status_icon(self)
337 except:
338 log('Warning: Cannot attach notification to status icon.', sender=self)
339 for action in actions:
340 notification.add_action(action[0], action[1], self.__action_callback)
341 if not notification.show():
342 log("Error: enable to send notification %s", message)
343 if len(actions) > 0:
344 gtk.main() # needed for action callback to be triggered
345 else:
346 return
348 # If we showed any kind of notification, remember it for next time
349 self.__previous_notification=[message, title, actions, is_error]
350 self.menuItem_previous_msg.set_sensitive(True)
352 def set_status(self, status=None, tooltip=None):
353 if status is None:
354 if tooltip is None:
355 tooltip = self.DEFAULT_TOOLTIP
356 if len(self.__gpodder.already_notified_new_episodes) > 0:
357 tooltip += "\n" + _("New episodes:")
358 for episode in self.__gpodder.already_notified_new_episodes:
359 tooltip += "\n" + episode.title
360 if episode.was_downloaded(): tooltip += _(" (downloaded)")
361 else:
362 tooltip = 'gPodder - %s' % tooltip
363 if self.__current_icon is not None:
364 self.__current_pixbuf = self.__icon
365 self.set_from_pixbuf(self.__current_pixbuf)
366 self.__current_icon = None
367 else:
368 (status_tooltip, icon) = status
369 if tooltip is None:
370 tooltip = 'gPodder - %s' % status_tooltip
371 else:
372 tooltip = 'gPodder - %s' % tooltip
373 if self.__current_icon != icon:
374 self.__current_pixbuf = self.__get_status_icon(icon)
375 self.set_from_pixbuf(self.__current_pixbuf)
376 self.__current_icon = icon
377 self.set_tooltip(tooltip)
379 def format_episode_list(self, episode_list, caption=None):
381 Format a list of episodes for tooltips and notifications
382 Return a listing of episodes title separated by a line break.
383 Long title are troncated: "xxxx...xxxx"
384 If the list is too long, it is cut and the string "x others episodes" is append
386 episode_list
387 can be either a list containing podcastItem objects
388 or a list of strings of episode's title.
390 return
391 the formatted list of episodes as a string
394 MAX_EPISODES = 10
395 MAX_TITLE_LENGTH = 100
397 result = []
398 if caption is not None:
399 result.append('\n%s' % caption)
400 for episode in episode_list[:min(len(episode_list),MAX_EPISODES)]:
401 if isinstance(episode, podcastItem):
402 episode_title = episode.title
403 else:
404 episode_title = episode
405 if len(episode_title) < MAX_TITLE_LENGTH:
406 title = episode_title
407 else:
408 middle = (MAX_TITLE_LENGTH/2)-2
409 title = '%s...%s' % (episode_title[0:middle], episode_title[-middle:])
410 result.append('\n%s' % title)
412 more_episodes = len(episode_list) - MAX_EPISODES
413 if more_episodes > 0:
414 result.append('\n(...')
415 if more_episodes == 1:
416 result.append(_('one more episode'))
417 else:
418 result.append(_('%d more episodes') % more_episodes)
419 result.append('...)')
421 return ''.join(result)
423 def set_synchronisation_device(self, synchronisation_device):
424 assert not self.__synchronisation_device, "a device was already set without have been released"
426 self.__synchronisation_device = synchronisation_device
427 self.__synchronisation_device.register('progress', self.__on_synchronisation_progress)
428 self.__synchronisation_device.register('status', self.__on_synchronisation_status)
429 self.__synchronisation_device.register('done', self.__on_synchronisation_done)
431 def release_synchronisation_device(self):
432 assert self.__synchronisation_device, "request for releasing a device which was never set"
434 self.__synchronisation_device.unregister('progress', self.__on_synchronisation_progress)
435 self.__synchronisation_device.unregister('status', self.__on_synchronisation_status)
436 self.__synchronisation_device.unregister('done', self.__on_synchronisation_done)
437 self.__synchronisation_device = None
439 def __on_synchronisation_progress(self, pos, max, text=None):
440 if text is None:
441 text = _('%d of %d done') % (pos, max)
442 self.__sync_progress = text
444 def __on_synchronisation_status(self, status):
445 tooltip = _('%s\n%s') % (status, self.__sync_progress)
446 self.set_status(self.STATUS_SYNCHRONIZING, tooltip)
447 log("tooltip: %s", tooltip, sender=self)
449 def __on_synchronisation_done(self):
450 if self.__gpodder.minimized:
451 # this might propably never appends so long gPodder synchronizes in a modal windows
452 self.send_notification(_('Your device has been updated by gPodder.'), _('Operation finished'))
453 self.set_status()
455 def progress_bar(self, ratio):
457 draw a progress bar on top of the tray icon.
458 Be sure to call this method the first time with ratio=0
459 in order to initialise background image
461 ratio
462 value between 0 and 1 (inclusive) indicating the ratio
463 of the progress bar to be drawn
467 # Only update in 3-percent-steps to save some resources
468 if abs(ratio-self.__last_ratio) < 0.03 and ratio > self.__last_ratio:
469 return
471 icon = self.__current_pixbuf.copy()
472 progressbar = draw.progressbar_pixbuf(icon.get_width(), icon.get_height(), ratio)
473 progressbar.composite(icon, 0, 0, icon.get_width(), icon.get_height(), 0, 0, 1, 1, gtk.gdk.INTERP_NEAREST, 255)
475 self.set_from_pixbuf(icon)
476 self.__last_ratio = ratio