Maemo 5: Proper empty episode list label handling
[gpodder.git] / src / gpodder / gtkui / model.py
blob182b6ee978106cd112b8ed9f05c9fe2e8f4a972f
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 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/>.
22 # gpodder.gtkui.model - GUI model classes for gPodder (2009-08-13)
23 # Based on code from libpodcasts.py (thp, 2005-10-29)
26 import gpodder
28 _ = gpodder.gettext
30 from gpodder import util
31 from gpodder.liblogger import log
33 from gpodder.gtkui import draw
35 import os
36 import gtk
37 import xml.sax.saxutils
39 try:
40 import gio
41 have_gio = True
42 except ImportError:
43 have_gio = False
45 class EpisodeListModel(gtk.ListStore):
46 C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \
47 C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \
48 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
49 C_VIEW_SHOW_UNPLAYED, C_FILESIZE, C_PUBLISHED, \
50 C_TIME, C_TIME1_VISIBLE, C_TIME2_VISIBLE, \
51 C_LOCKED = range(17)
53 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION)
55 VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = range(4)
57 # In which steps the UI is updated for "loading" animations
58 _UI_UPDATE_STEP = .03
60 def __init__(self, on_filter_changed=lambda has_episodes: None):
61 gtk.ListStore.__init__(self, str, str, str, object, \
62 str, str, str, str, bool, bool, bool, \
63 int, int, str, bool, bool, bool)
65 # Callback for when the filter / list changes, gets one parameter
66 # (has_episodes) that is True if the list has any episodes
67 self._on_filter_changed = on_filter_changed
69 # Filter to allow hiding some episodes
70 self._filter = self.filter_new()
71 self._sorter = gtk.TreeModelSort(self._filter)
72 self._view_mode = self.VIEW_ALL
73 self._search_term = None
74 self._filter.set_visible_func(self._filter_visible_func)
76 # Are we currently showing the "all episodes" view?
77 self._all_episodes_view = False
79 # "ICON" is used to mark icon names in source files
80 ICON = lambda x: x
82 self._icon_cache = {}
83 self.ICON_AUDIO_FILE = ICON('audio-x-generic')
84 self.ICON_VIDEO_FILE = ICON('video-x-generic')
85 self.ICON_IMAGE_FILE = ICON('image-x-generic')
86 self.ICON_GENERIC_FILE = ICON('text-x-generic')
87 self.ICON_DOWNLOADING = gtk.STOCK_GO_DOWN
88 self.ICON_DELETED = gtk.STOCK_DELETE
89 self.ICON_NEW = gtk.STOCK_ABOUT
90 self.ICON_UNPLAYED = ICON('emblem-new')
91 self.ICON_LOCKED = ICON('emblem-readonly')
92 self.ICON_MISSING = ICON('emblem-unreadable')
94 if 'KDE_FULL_SESSION' in os.environ:
95 # Workaround until KDE adds all the freedesktop icons
96 # See https://bugs.kde.org/show_bug.cgi?id=233505 and
97 # http://gpodder.org/bug/553
98 self.ICON_DELETED = ICON('archive-remove')
99 self.ICON_UNPLAYED = ICON('vcs-locally-modified')
100 self.ICON_LOCKED = ICON('emblem-locked')
101 self.ICON_MISSING = ICON('vcs-conflicting')
104 def _format_filesize(self, episode):
105 if episode.length > 0:
106 return util.format_filesize(episode.length, 1)
107 else:
108 return None
111 def _filter_visible_func(self, model, iter):
112 # If searching is active, set visibility based on search text
113 if self._search_term is not None:
114 key = self._search_term.lower()
115 return any((key in (model.get_value(iter, column) or '').lower()) for column in self.SEARCH_COLUMNS)
117 if self._view_mode == self.VIEW_ALL:
118 return True
119 elif self._view_mode == self.VIEW_UNDELETED:
120 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
121 elif self._view_mode == self.VIEW_DOWNLOADED:
122 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
123 elif self._view_mode == self.VIEW_UNPLAYED:
124 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
126 return True
128 def get_filtered_model(self):
129 """Returns a filtered version of this episode model
131 The filtered version should be displayed in the UI,
132 as this model can have some filters set that should
133 be reflected in the UI.
135 return self._sorter
137 def has_episodes(self):
138 """Returns True if episodes are visible (filtered)
140 If episodes are visible with the current filter
141 applied, return True (otherwise return False).
143 return bool(len(self._filter))
145 def set_view_mode(self, new_mode):
146 """Sets a new view mode for this model
148 After setting the view mode, the filtered model
149 might be updated to reflect the new mode."""
150 if self._view_mode != new_mode:
151 self._view_mode = new_mode
152 self._filter.refilter()
153 self._on_filter_changed(self.has_episodes())
155 def get_view_mode(self):
156 """Returns the currently-set view mode"""
157 return self._view_mode
159 def set_search_term(self, new_term):
160 if self._search_term != new_term:
161 self._search_term = new_term
162 self._filter.refilter()
163 self._on_filter_changed(self.has_episodes())
165 def get_search_term(self):
166 return self._search_term
168 def _format_description(self, episode, include_description=False, is_downloading=None):
169 a, b = '', ''
170 if episode.state != gpodder.STATE_DELETED and not episode.is_played:
171 a, b = '<b>', '</b>'
172 if include_description and self._all_episodes_view:
173 return '%s%s%s\n<small>%s</small>' % (a, xml.sax.saxutils.escape(episode.title), b,
174 _('from %s') % xml.sax.saxutils.escape(episode.channel.title))
175 elif include_description:
176 return '%s%s%s\n<small>%s</small>' % (a, xml.sax.saxutils.escape(episode.title), b,
177 xml.sax.saxutils.escape(episode.one_line_description()))
178 else:
179 return xml.sax.saxutils.escape(episode.title)
181 def replace_from_channel(self, channel, downloading=None, \
182 include_description=False, generate_thumbnails=False, \
183 treeview=None):
185 Add episode from the given channel to this model.
186 Downloading should be a callback.
187 include_description should be a boolean value (True if description
188 is to be added to the episode row, or False if not)
191 # Remove old episodes in the list store
192 self.clear()
194 if treeview is not None:
195 util.idle_add(treeview.queue_draw)
197 self._all_episodes_view = getattr(channel, 'ALL_EPISODES_PROXY', False)
199 episodes = channel.get_all_episodes()
200 if not isinstance(episodes, list):
201 episodes = list(episodes)
202 count = len(episodes)
204 for position, episode in enumerate(episodes):
205 iter = self.append((episode.url, \
206 episode.title, \
207 self._format_filesize(episode), \
208 episode, \
209 None, \
210 episode.cute_pubdate(), \
211 '', \
212 '', \
213 True, \
214 True, \
215 True, \
216 episode.length, \
217 episode.pubDate, \
218 episode.get_play_info_string(), \
219 episode.total_time and not episode.current_position, \
220 episode.total_time and episode.current_position, \
221 episode.is_locked))
223 self.update_by_iter(iter, downloading, include_description, \
224 generate_thumbnails, reload_from_db=False)
226 self._on_filter_changed(self.has_episodes())
228 def update_all(self, downloading=None, include_description=False, \
229 generate_thumbnails=False):
230 for row in self:
231 self.update_by_iter(row.iter, downloading, include_description, \
232 generate_thumbnails)
234 def update_by_urls(self, urls, downloading=None, include_description=False, \
235 generate_thumbnails=False):
236 for row in self:
237 if row[self.C_URL] in urls:
238 self.update_by_iter(row.iter, downloading, include_description, \
239 generate_thumbnails)
241 def update_by_filter_iter(self, iter, downloading=None, \
242 include_description=False, generate_thumbnails=False):
243 # Convenience function for use by "outside" methods that use iters
244 # from the filtered episode list model (i.e. all UI things normally)
245 iter = self._sorter.convert_iter_to_child_iter(None, iter)
246 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter), \
247 downloading, include_description, generate_thumbnails)
249 def update_by_iter(self, iter, downloading=None, include_description=False, \
250 generate_thumbnails=False, reload_from_db=True):
251 episode = self.get_value(iter, self.C_EPISODE)
252 if reload_from_db:
253 episode.reload_from_db()
255 if include_description or gpodder.ui.maemo:
256 icon_size = 32
257 else:
258 icon_size = 16
260 show_bullet = False
261 show_padlock = False
262 show_missing = False
263 status_icon = None
264 status_icon_to_build_from_file = False
265 tooltip = []
266 view_show_undeleted = True
267 view_show_downloaded = False
268 view_show_unplayed = False
269 icon_theme = gtk.icon_theme_get_default()
271 if downloading is not None and downloading(episode):
272 tooltip.append(_('Downloading'))
273 status_icon = self.ICON_DOWNLOADING
274 view_show_downloaded = True
275 view_show_unplayed = True
276 else:
277 if episode.state == gpodder.STATE_DELETED:
278 tooltip.append(_('Deleted'))
279 status_icon = self.ICON_DELETED
280 view_show_undeleted = False
281 elif episode.state == gpodder.STATE_NORMAL and \
282 not episode.is_played:
283 tooltip.append(_('New episode'))
284 status_icon = self.ICON_NEW
285 view_show_downloaded = True
286 view_show_unplayed = True
287 elif episode.state == gpodder.STATE_DOWNLOADED:
288 tooltip = []
289 view_show_downloaded = True
290 view_show_unplayed = not episode.is_played
291 show_bullet = not episode.is_played
292 show_padlock = episode.is_locked
293 show_missing = not episode.file_exists()
294 filename = episode.local_filename(create=False, check_only=True)
296 file_type = episode.file_type()
297 if file_type == 'audio':
298 tooltip.append(_('Downloaded episode'))
299 status_icon = self.ICON_AUDIO_FILE
300 elif file_type == 'video':
301 tooltip.append(_('Downloaded video episode'))
302 status_icon = self.ICON_VIDEO_FILE
303 elif file_type == 'image':
304 tooltip.append(_('Downloaded image'))
305 status_icon = self.ICON_IMAGE_FILE
307 # Optional thumbnailing for image downloads
308 if generate_thumbnails:
309 if filename is not None:
310 # set the status icon to the path itself (that
311 # should be a good identifier anyway)
312 status_icon = filename
313 status_icon_to_build_from_file = True
314 else:
315 tooltip.append(_('Downloaded file'))
316 status_icon = self.ICON_GENERIC_FILE
318 # Try to find a themed icon for this file
319 if filename is not None and have_gio:
320 file = gio.File(filename)
321 if file.query_exists():
322 file_info = file.query_info('*')
323 icon = file_info.get_icon()
324 for icon_name in icon.get_names():
325 if icon_theme.has_icon(icon_name):
326 status_icon = icon_name
327 break
329 if show_missing:
330 tooltip.append(_('missing file'))
331 else:
332 if show_bullet:
333 if file_type == 'image':
334 tooltip.append(_('never displayed'))
335 elif file_type in ('audio', 'video'):
336 tooltip.append(_('never played'))
337 else:
338 tooltip.append(_('never opened'))
339 else:
340 if file_type == 'image':
341 tooltip.append(_('displayed'))
342 elif file_type in ('audio', 'video'):
343 tooltip.append(_('played'))
344 else:
345 tooltip.append(_('opened'))
346 if show_padlock:
347 tooltip.append(_('deletion prevented'))
349 if episode.total_time > 0 and episode.current_position:
350 tooltip.append('%d%%' % (100.*float(episode.current_position)/float(episode.total_time),))
352 if episode.total_time:
353 total_time = util.format_time(episode.total_time)
354 if total_time:
355 tooltip.append(total_time)
357 tooltip = ', '.join(tooltip)
359 description = self._format_description(episode, include_description, downloading)
360 self.set(iter, \
361 self.C_STATUS_ICON, status_icon, \
362 self.C_VIEW_SHOW_UNDELETED, view_show_undeleted, \
363 self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded, \
364 self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed, \
365 self.C_DESCRIPTION, description, \
366 self.C_TOOLTIP, tooltip, \
367 self.C_TIME, episode.get_play_info_string(), \
368 self.C_TIME1_VISIBLE, episode.total_time and not episode.current_position, \
369 self.C_TIME2_VISIBLE, episode.total_time and episode.current_position, \
370 self.C_LOCKED, episode.is_locked)
372 def _get_icon_from_image(self,image_path, icon_size):
374 Load an local image file and transform it into an icon.
376 Return a pixbuf scaled to the desired size and may return None
377 if the icon creation is impossible (file not found etc).
379 if not os.path.exists(image_path):
380 return None
381 # load image from disc (code adapted from CoverDownloader
382 # except that no download is needed here)
383 loader = gtk.gdk.PixbufLoader()
384 pixbuf = None
385 try:
386 loader.write(open(image_path, 'rb').read())
387 loader.close()
388 pixbuf = loader.get_pixbuf()
389 except:
390 log('Data error while loading image %s', image_path, sender=self)
391 return None
392 # Now scale the image with ratio (copied from _resize_pixbuf_keep_ratio)
393 # Resize if too wide
394 if pixbuf.get_width() > icon_size:
395 f = float(icon_size)/pixbuf.get_width()
396 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
397 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
398 # Resize if too high
399 if pixbuf.get_height() > icon_size:
400 f = float(icon_size)/pixbuf.get_height()
401 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
402 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
403 return pixbuf
406 def _get_tree_icon(self, icon_name, add_bullet=False, \
407 add_padlock=False, add_missing=False, icon_size=32, \
408 build_icon_from_file = False):
410 Loads an icon from the current icon theme at the specified
411 size, suitable for display in a gtk.TreeView. Additional
412 emblems can be added on top of the icon.
414 Caching is used to speed up the icon lookup.
416 The `build_icon_from_file` argument indicates (when True) that
417 the icon has to be created on the fly from a given image
418 file. The `icon_name` argument is then interpreted as the path
419 to this file. Those specific icons will *not be cached*.
422 # Add all variables that modify the appearance of the icon, so
423 # our cache does not return the same icons for different requests
424 cache_id = (icon_name, add_bullet, add_padlock, add_missing, icon_size)
426 if cache_id in self._icon_cache:
427 return self._icon_cache[cache_id]
429 icon_theme = gtk.icon_theme_get_default()
431 try:
432 if build_icon_from_file:
433 icon = self._get_icon_from_image(icon_name,icon_size)
434 else:
435 icon = icon_theme.load_icon(icon_name, icon_size, 0)
436 except:
437 try:
438 log('Missing icon in theme: %s', icon_name, sender=self)
439 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, \
440 icon_size, 0)
441 except:
442 log('Please install the GNOME icon theme.', sender=self)
443 icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, \
444 True, 8, icon_size, icon_size)
446 if icon and (add_bullet or add_padlock or add_missing):
447 # We'll modify the icon, so use .copy()
448 if add_missing:
449 try:
450 icon = icon.copy()
451 # Desaturate the icon so it looks even more "missing"
452 icon.saturate_and_pixelate(icon, 0.0, False)
453 emblem = icon_theme.load_icon(self.ICON_MISSING, icon_size/2, 0)
454 (width, height) = (emblem.get_width(), emblem.get_height())
455 xpos = icon.get_width() - width
456 ypos = icon.get_height() - height
457 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
458 except:
459 pass
460 elif add_bullet:
461 try:
462 icon = icon.copy()
463 emblem = icon_theme.load_icon(self.ICON_UNPLAYED, icon_size/2, 0)
464 (width, height) = (emblem.get_width(), emblem.get_height())
465 xpos = icon.get_width() - width
466 ypos = icon.get_height() - height
467 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
468 except:
469 pass
470 if add_padlock:
471 try:
472 icon = icon.copy()
473 emblem = icon_theme.load_icon(self.ICON_LOCKED, icon_size/2, 0)
474 (width, height) = (emblem.get_width(), emblem.get_height())
475 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
476 except:
477 pass
479 self._icon_cache[cache_id] = icon
480 return icon
483 class PodcastChannelProxy(object):
484 ALL_EPISODES_PROXY = True
486 def __init__(self, db, config, channels):
487 self._db = db
488 self._config = config
489 self.channels = channels
490 self.title = _('All episodes')
491 self.description = _('from all podcasts')
492 self.parse_error = ''
493 self.url = ''
494 self.id = None
495 self._save_dir_size_set = False
496 self.save_dir_size = 0L
497 self.cover_file = os.path.join(gpodder.images_folder, 'podcast-all.png')
498 self.feed_update_enabled = True
500 def __getattribute__(self, name):
501 try:
502 return object.__getattribute__(self, name)
503 except AttributeError:
504 log('Unsupported method call (%s)', name, sender=self)
506 def get_statistics(self):
507 # Get the total statistics for all channels from the database
508 return self._db.get_total_count()
510 def get_all_episodes(self):
511 """Returns a generator that yields every episode"""
512 channel_lookup_map = dict((c.id, c) for c in self.channels)
513 return self._db.load_all_episodes(channel_lookup_map)
515 def request_save_dir_size(self):
516 if not self._save_dir_size_set:
517 self.update_save_dir_size()
518 self._save_dir_size_set = True
520 def update_save_dir_size(self):
521 self.save_dir_size = util.calculate_size(self._config.download_dir)
524 class PodcastListModel(gtk.ListStore):
525 C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
526 C_COVER, C_ERROR, C_PILL_VISIBLE, \
527 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
528 C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \
529 C_DOWNLOADS = range(14)
531 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION)
533 @classmethod
534 def row_separator_func(cls, model, iter):
535 return model.get_value(iter, cls.C_SEPARATOR)
537 def __init__(self, cover_downloader):
538 gtk.ListStore.__init__(self, str, str, str, gtk.gdk.Pixbuf, \
539 object, gtk.gdk.Pixbuf, str, bool, bool, bool, bool, \
540 bool, bool, int)
542 # Filter to allow hiding some episodes
543 self._filter = self.filter_new()
544 self._view_mode = -1
545 self._search_term = None
546 self._filter.set_visible_func(self._filter_visible_func)
548 self._cover_cache = {}
549 if gpodder.ui.fremantle:
550 self._max_image_side = 64
551 else:
552 self._max_image_side = 40
553 self._cover_downloader = cover_downloader
555 # "ICON" is used to mark icon names in source files
556 ICON = lambda x: x
558 #self.ICON_DISABLED = ICON('emblem-unreadable')
559 self.ICON_DISABLED = ICON('gtk-media-pause')
561 def _filter_visible_func(self, model, iter):
562 # If searching is active, set visibility based on search text
563 if self._search_term is not None:
564 key = self._search_term.lower()
565 columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS)
566 return any((key in c.lower() for c in columns if c is not None))
568 if model.get_value(iter, self.C_SEPARATOR):
569 return True
570 if self._view_mode == EpisodeListModel.VIEW_ALL:
571 return model.get_value(iter, self.C_HAS_EPISODES)
572 elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
573 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
574 elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
575 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
576 elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
577 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
579 return True
581 def get_filtered_model(self):
582 """Returns a filtered version of this episode model
584 The filtered version should be displayed in the UI,
585 as this model can have some filters set that should
586 be reflected in the UI.
588 return self._filter
590 def set_view_mode(self, new_mode):
591 """Sets a new view mode for this model
593 After setting the view mode, the filtered model
594 might be updated to reflect the new mode."""
595 if self._view_mode != new_mode:
596 self._view_mode = new_mode
597 self._filter.refilter()
599 def get_view_mode(self):
600 """Returns the currently-set view mode"""
601 return self._view_mode
603 def set_search_term(self, new_term):
604 if self._search_term != new_term:
605 self._search_term = new_term
606 self._filter.refilter()
608 def get_search_term(self):
609 return self._search_term
611 def enable_separators(self, channeltree):
612 channeltree.set_row_separator_func(self._show_row_separator)
614 def _show_row_separator(self, model, iter):
615 return model.get_value(iter, self.C_SEPARATOR)
617 def _resize_pixbuf_keep_ratio(self, url, pixbuf):
619 Resizes a GTK Pixbuf but keeps its aspect ratio.
620 Returns None if the pixbuf does not need to be
621 resized or the newly resized pixbuf if it does.
623 changed = False
624 result = None
626 if url in self._cover_cache:
627 return self._cover_cache[url]
629 # Resize if too wide
630 if pixbuf.get_width() > self._max_image_side:
631 f = float(self._max_image_side)/pixbuf.get_width()
632 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
633 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
634 changed = True
636 # Resize if too high
637 if pixbuf.get_height() > self._max_image_side:
638 f = float(self._max_image_side)/pixbuf.get_height()
639 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
640 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
641 changed = True
643 if changed:
644 self._cover_cache[url] = pixbuf
645 result = pixbuf
647 return result
649 def _resize_pixbuf(self, url, pixbuf):
650 if pixbuf is None:
651 return None
653 return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
655 def _overlay_pixbuf(self, pixbuf, icon):
656 try:
657 icon_theme = gtk.icon_theme_get_default()
658 emblem = icon_theme.load_icon(icon, self._max_image_side/2, 0)
659 (width, height) = (emblem.get_width(), emblem.get_height())
660 xpos = pixbuf.get_width() - width
661 ypos = pixbuf.get_height() - height
662 if ypos < 0:
663 # need to resize overlay for none standard icon size
664 emblem = icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0)
665 (width, height) = (emblem.get_width(), emblem.get_height())
666 xpos = pixbuf.get_width() - width
667 ypos = pixbuf.get_height() - height
668 emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
669 except:
670 pass
672 return pixbuf
674 def _get_cover_image(self, channel, add_overlay=False):
675 if self._cover_downloader is None:
676 return None
678 pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
679 pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf)
680 if add_overlay and not channel.feed_update_enabled:
681 pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED)
682 pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False)
684 return pixbuf_overlay
686 def _get_pill_image(self, channel, count_downloaded, count_unplayed):
687 if count_unplayed > 0 or count_downloaded > 0:
688 return draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded))
689 else:
690 return None
692 def _format_description(self, channel, total, deleted, \
693 new, downloaded, unplayed):
694 title_markup = xml.sax.saxutils.escape(channel.title)
695 if channel.feed_update_enabled:
696 description_markup = xml.sax.saxutils.escape(util.get_first_line(channel.description) or ' ')
697 else:
698 description_markup = xml.sax.saxutils.escape(_('Subscription paused.'))
699 d = []
700 if new:
701 d.append('<span weight="bold">')
702 d.append(title_markup)
703 if new:
704 d.append('</span>')
705 return ''.join(d+['\n', '<small>', description_markup, '</small>'])
707 def _format_error(self, channel):
708 if channel.parse_error:
709 return str(channel.parse_error)
710 else:
711 return None
713 def set_channels(self, db, config, channels):
714 # Clear the model and update the list of podcasts
715 self.clear()
717 def channel_to_row(channel, add_overlay=False):
718 return (channel.url, '', '', None, channel, \
719 self._get_cover_image(channel, add_overlay), '', True, True, True, \
720 True, True, False, 0)
722 if config.podcast_list_view_all and channels:
723 all_episodes = PodcastChannelProxy(db, config, channels)
724 iter = self.append(channel_to_row(all_episodes))
725 self.update_by_iter(iter)
727 # Separator item
728 self.append(('', '', '', None, None, None, '', True, True, \
729 True, True, True, True, 0))
731 for channel in channels:
732 iter = self.append(channel_to_row(channel, True))
733 self.update_by_iter(iter)
735 def get_filter_path_from_url(self, url):
736 # Return the path of the filtered model for a given URL
737 child_path = self.get_path_from_url(url)
738 if child_path is None:
739 return None
740 else:
741 return self._filter.convert_child_path_to_path(child_path)
743 def get_path_from_url(self, url):
744 # Return the tree model path for a given URL
745 if url is None:
746 return None
748 for row in self:
749 if row[self.C_URL] == url:
750 return row.path
751 return None
753 def update_first_row(self):
754 # Update the first row in the model (for "all episodes" updates)
755 self.update_by_iter(self.get_iter_first())
757 def update_by_urls(self, urls):
758 # Given a list of URLs, update each matching row
759 for row in self:
760 if row[self.C_URL] in urls:
761 self.update_by_iter(row.iter)
763 def iter_is_first_row(self, iter):
764 iter = self._filter.convert_iter_to_child_iter(iter)
765 path = self.get_path(iter)
766 return (path == (0,))
768 def update_by_filter_iter(self, iter):
769 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))
771 def update_all(self):
772 for row in self:
773 self.update_by_iter(row.iter)
775 def update_by_iter(self, iter):
776 # Given a GtkTreeIter, update volatile information
777 try:
778 channel = self.get_value(iter, self.C_CHANNEL)
779 except TypeError, te:
780 return
781 if channel is None:
782 return
783 total, deleted, new, downloaded, unplayed = channel.get_statistics()
784 description = self._format_description(channel, total, deleted, new, \
785 downloaded, unplayed)
787 if gpodder.ui.fremantle:
788 # We don't display the pill, so don't generate it
789 pill_image = None
790 else:
791 pill_image = self._get_pill_image(channel, downloaded, unplayed)
793 self.set(iter, \
794 self.C_TITLE, channel.title, \
795 self.C_DESCRIPTION, description, \
796 self.C_ERROR, self._format_error(channel), \
797 self.C_PILL, pill_image, \
798 self.C_PILL_VISIBLE, pill_image != None, \
799 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0, \
800 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0, \
801 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0, \
802 self.C_HAS_EPISODES, total > 0, \
803 self.C_DOWNLOADS, downloaded)
805 def add_cover_by_channel(self, channel, pixbuf):
806 # Resize and add the new cover image
807 pixbuf = self._resize_pixbuf(channel.url, pixbuf)
808 if not channel.feed_update_enabled:
809 pixbuf = self._overlay_pixbuf(pixbuf, self.ICON_DISABLED)
810 pixbuf.saturate_and_pixelate(pixbuf, 0.0, False)
812 for row in self:
813 if row[self.C_URL] == channel.url:
814 row[self.C_COVER] = pixbuf
815 break
817 def delete_cover_by_url(self, url):
818 # Remove the cover from the model
819 for row in self:
820 if row[self.C_URL] == url:
821 row[self.C_COVER] = None
822 break
824 # Remove the cover from the cache
825 if url in self._cover_cache:
826 del self._cover_cache[url]