Fixed height mode; episode list optimization
[gpodder.git] / src / gpodder / gtkui / model.py
blob2cb1436e8b95c56a7d0791db58f89078dad25255
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):
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 # Update progress (if we're currently being updated)
66 self._update_progress = 0.
67 self._last_redraw_progress = 0.
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_update_progress(self):
129 return self._update_progress
131 def reset_update_progress(self):
132 self._update_progress = 0.
134 def get_filtered_model(self):
135 """Returns a filtered version of this episode model
137 The filtered version should be displayed in the UI,
138 as this model can have some filters set that should
139 be reflected in the UI.
141 return self._sorter
143 def set_view_mode(self, new_mode):
144 """Sets a new view mode for this model
146 After setting the view mode, the filtered model
147 might be updated to reflect the new mode."""
148 if self._view_mode != new_mode:
149 self._view_mode = new_mode
150 self._filter.refilter()
152 def get_view_mode(self):
153 """Returns the currently-set view mode"""
154 return self._view_mode
156 def set_search_term(self, new_term):
157 if self._search_term != new_term:
158 self._search_term = new_term
159 self._filter.refilter()
161 def get_search_term(self):
162 return self._search_term
164 def _format_description(self, episode, include_description=False, is_downloading=None):
165 a, b = '', ''
166 if episode.state != gpodder.STATE_DELETED and not episode.is_played:
167 a, b = '<b>', '</b>'
168 if include_description and self._all_episodes_view:
169 return '%s%s%s\n<small>%s</small>' % (a, xml.sax.saxutils.escape(episode.title), b,
170 _('from %s') % xml.sax.saxutils.escape(episode.channel.title))
171 elif include_description:
172 return '%s%s%s\n<small>%s</small>' % (a, xml.sax.saxutils.escape(episode.title), b,
173 xml.sax.saxutils.escape(episode.one_line_description()))
174 else:
175 return xml.sax.saxutils.escape(episode.title)
177 def replace_from_channel(self, channel, downloading=None, \
178 include_description=False, generate_thumbnails=False, \
179 treeview=None):
181 Add episode from the given channel to this model.
182 Downloading should be a callback.
183 include_description should be a boolean value (True if description
184 is to be added to the episode row, or False if not)
187 # Remove old episodes in the list store
188 self.clear()
190 self._update_progress = 0.
191 self._last_redraw_progress = 0.
192 if treeview is not None:
193 util.idle_add(treeview.queue_draw)
195 self._all_episodes_view = getattr(channel, 'ALL_EPISODES_PROXY', False)
197 episodes = channel.get_all_episodes()
198 if not isinstance(episodes, list):
199 episodes = list(episodes)
200 count = len(episodes)
202 for position, episode in enumerate(episodes):
203 iter = self.append((episode.url, \
204 episode.title, \
205 self._format_filesize(episode), \
206 episode, \
207 None, \
208 episode.cute_pubdate(), \
209 '', \
210 '', \
211 True, \
212 True, \
213 True, \
214 episode.length, \
215 episode.pubDate, \
216 episode.get_play_info_string(), \
217 episode.total_time and not episode.current_position, \
218 episode.total_time and episode.current_position, \
219 episode.is_locked))
221 self.update_by_iter(iter, downloading, include_description, \
222 generate_thumbnails, reload_from_db=False)
224 self._update_progress = float(position+1)/count
225 if treeview is not None and \
226 (self._update_progress > self._last_redraw_progress + self._UI_UPDATE_STEP or position+1 == count):
227 def in_gtk_main_thread():
228 treeview.queue_draw()
229 while gtk.events_pending():
230 gtk.main_iteration(False)
231 util.idle_add(in_gtk_main_thread)
232 self._last_redraw_progress = self._update_progress
234 def update_all(self, downloading=None, include_description=False, \
235 generate_thumbnails=False):
236 for row in self:
237 self.update_by_iter(row.iter, downloading, include_description, \
238 generate_thumbnails)
240 def update_by_urls(self, urls, downloading=None, include_description=False, \
241 generate_thumbnails=False):
242 for row in self:
243 if row[self.C_URL] in urls:
244 self.update_by_iter(row.iter, downloading, include_description, \
245 generate_thumbnails)
247 def update_by_filter_iter(self, iter, downloading=None, \
248 include_description=False, generate_thumbnails=False):
249 # Convenience function for use by "outside" methods that use iters
250 # from the filtered episode list model (i.e. all UI things normally)
251 iter = self._sorter.convert_iter_to_child_iter(None, iter)
252 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter), \
253 downloading, include_description, generate_thumbnails)
255 def update_by_iter(self, iter, downloading=None, include_description=False, \
256 generate_thumbnails=False, reload_from_db=True):
257 episode = self.get_value(iter, self.C_EPISODE)
258 if reload_from_db:
259 episode.reload_from_db()
261 if include_description or gpodder.ui.maemo:
262 icon_size = 32
263 else:
264 icon_size = 16
266 show_bullet = False
267 show_padlock = False
268 show_missing = False
269 status_icon = None
270 status_icon_to_build_from_file = False
271 tooltip = []
272 view_show_undeleted = True
273 view_show_downloaded = False
274 view_show_unplayed = False
275 icon_theme = gtk.icon_theme_get_default()
277 if downloading is not None and downloading(episode):
278 tooltip.append(_('Downloading'))
279 status_icon = self.ICON_DOWNLOADING
280 view_show_downloaded = True
281 view_show_unplayed = True
282 else:
283 if episode.state == gpodder.STATE_DELETED:
284 tooltip.append(_('Deleted'))
285 status_icon = self.ICON_DELETED
286 view_show_undeleted = False
287 elif episode.state == gpodder.STATE_NORMAL and \
288 not episode.is_played:
289 tooltip.append(_('New episode'))
290 status_icon = self.ICON_NEW
291 view_show_downloaded = True
292 view_show_unplayed = True
293 elif episode.state == gpodder.STATE_DOWNLOADED:
294 tooltip = []
295 view_show_downloaded = True
296 view_show_unplayed = not episode.is_played
297 show_bullet = not episode.is_played
298 show_padlock = episode.is_locked
299 show_missing = not episode.file_exists()
300 filename = episode.local_filename(create=False, check_only=True)
302 file_type = episode.file_type()
303 if file_type == 'audio':
304 tooltip.append(_('Downloaded episode'))
305 status_icon = self.ICON_AUDIO_FILE
306 elif file_type == 'video':
307 tooltip.append(_('Downloaded video episode'))
308 status_icon = self.ICON_VIDEO_FILE
309 elif file_type == 'image':
310 tooltip.append(_('Downloaded image'))
311 status_icon = self.ICON_IMAGE_FILE
313 # Optional thumbnailing for image downloads
314 if generate_thumbnails:
315 if filename is not None:
316 # set the status icon to the path itself (that
317 # should be a good identifier anyway)
318 status_icon = filename
319 status_icon_to_build_from_file = True
320 else:
321 tooltip.append(_('Downloaded file'))
322 status_icon = self.ICON_GENERIC_FILE
324 # Try to find a themed icon for this file
325 if filename is not None and have_gio:
326 file = gio.File(filename)
327 if file.query_exists():
328 file_info = file.query_info('*')
329 icon = file_info.get_icon()
330 for icon_name in icon.get_names():
331 if icon_theme.has_icon(icon_name):
332 status_icon = icon_name
333 break
335 if show_missing:
336 tooltip.append(_('missing file'))
337 else:
338 if show_bullet:
339 if file_type == 'image':
340 tooltip.append(_('never displayed'))
341 elif file_type in ('audio', 'video'):
342 tooltip.append(_('never played'))
343 else:
344 tooltip.append(_('never opened'))
345 else:
346 if file_type == 'image':
347 tooltip.append(_('displayed'))
348 elif file_type in ('audio', 'video'):
349 tooltip.append(_('played'))
350 else:
351 tooltip.append(_('opened'))
352 if show_padlock:
353 tooltip.append(_('deletion prevented'))
355 if episode.total_time > 0 and episode.current_position:
356 tooltip.append('%d%%' % (100.*float(episode.current_position)/float(episode.total_time),))
358 if episode.total_time:
359 total_time = util.format_time(episode.total_time)
360 if total_time:
361 tooltip.append(total_time)
363 tooltip = ', '.join(tooltip)
365 description = self._format_description(episode, include_description, downloading)
366 self.set(iter, \
367 self.C_STATUS_ICON, status_icon, \
368 self.C_VIEW_SHOW_UNDELETED, view_show_undeleted, \
369 self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded, \
370 self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed, \
371 self.C_DESCRIPTION, description, \
372 self.C_TOOLTIP, tooltip, \
373 self.C_TIME, episode.get_play_info_string(), \
374 self.C_TIME1_VISIBLE, episode.total_time and not episode.current_position, \
375 self.C_TIME2_VISIBLE, episode.total_time and episode.current_position, \
376 self.C_LOCKED, episode.is_locked)
378 def _get_icon_from_image(self,image_path, icon_size):
380 Load an local image file and transform it into an icon.
382 Return a pixbuf scaled to the desired size and may return None
383 if the icon creation is impossible (file not found etc).
385 if not os.path.exists(image_path):
386 return None
387 # load image from disc (code adapted from CoverDownloader
388 # except that no download is needed here)
389 loader = gtk.gdk.PixbufLoader()
390 pixbuf = None
391 try:
392 loader.write(open(image_path, 'rb').read())
393 loader.close()
394 pixbuf = loader.get_pixbuf()
395 except:
396 log('Data error while loading image %s', image_path, sender=self)
397 return None
398 # Now scale the image with ratio (copied from _resize_pixbuf_keep_ratio)
399 # Resize if too wide
400 if pixbuf.get_width() > icon_size:
401 f = float(icon_size)/pixbuf.get_width()
402 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
403 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
404 # Resize if too high
405 if pixbuf.get_height() > icon_size:
406 f = float(icon_size)/pixbuf.get_height()
407 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
408 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
409 return pixbuf
412 def _get_tree_icon(self, icon_name, add_bullet=False, \
413 add_padlock=False, add_missing=False, icon_size=32, \
414 build_icon_from_file = False):
416 Loads an icon from the current icon theme at the specified
417 size, suitable for display in a gtk.TreeView. Additional
418 emblems can be added on top of the icon.
420 Caching is used to speed up the icon lookup.
422 The `build_icon_from_file` argument indicates (when True) that
423 the icon has to be created on the fly from a given image
424 file. The `icon_name` argument is then interpreted as the path
425 to this file. Those specific icons will *not be cached*.
428 # Add all variables that modify the appearance of the icon, so
429 # our cache does not return the same icons for different requests
430 cache_id = (icon_name, add_bullet, add_padlock, add_missing, icon_size)
432 if cache_id in self._icon_cache:
433 return self._icon_cache[cache_id]
435 icon_theme = gtk.icon_theme_get_default()
437 try:
438 if build_icon_from_file:
439 icon = self._get_icon_from_image(icon_name,icon_size)
440 else:
441 icon = icon_theme.load_icon(icon_name, icon_size, 0)
442 except:
443 try:
444 log('Missing icon in theme: %s', icon_name, sender=self)
445 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, \
446 icon_size, 0)
447 except:
448 log('Please install the GNOME icon theme.', sender=self)
449 icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, \
450 True, 8, icon_size, icon_size)
452 if icon and (add_bullet or add_padlock or add_missing):
453 # We'll modify the icon, so use .copy()
454 if add_missing:
455 try:
456 icon = icon.copy()
457 # Desaturate the icon so it looks even more "missing"
458 icon.saturate_and_pixelate(icon, 0.0, False)
459 emblem = icon_theme.load_icon(self.ICON_MISSING, icon_size/2, 0)
460 (width, height) = (emblem.get_width(), emblem.get_height())
461 xpos = icon.get_width() - width
462 ypos = icon.get_height() - height
463 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
464 except:
465 pass
466 elif add_bullet:
467 try:
468 icon = icon.copy()
469 emblem = icon_theme.load_icon(self.ICON_UNPLAYED, icon_size/2, 0)
470 (width, height) = (emblem.get_width(), emblem.get_height())
471 xpos = icon.get_width() - width
472 ypos = icon.get_height() - height
473 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
474 except:
475 pass
476 if add_padlock:
477 try:
478 icon = icon.copy()
479 emblem = icon_theme.load_icon(self.ICON_LOCKED, icon_size/2, 0)
480 (width, height) = (emblem.get_width(), emblem.get_height())
481 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
482 except:
483 pass
485 self._icon_cache[cache_id] = icon
486 return icon
489 class PodcastChannelProxy(object):
490 ALL_EPISODES_PROXY = True
492 def __init__(self, db, config, channels):
493 self._db = db
494 self._config = config
495 self.channels = channels
496 self.title = _('All episodes')
497 self.description = _('from all podcasts')
498 self.parse_error = ''
499 self.url = ''
500 self.id = None
501 self._save_dir_size_set = False
502 self.save_dir_size = 0L
503 self.cover_file = os.path.join(gpodder.images_folder, 'podcast-all.png')
504 self.feed_update_enabled = True
506 def __getattribute__(self, name):
507 try:
508 return object.__getattribute__(self, name)
509 except AttributeError:
510 log('Unsupported method call (%s)', name, sender=self)
512 def get_statistics(self):
513 # Get the total statistics for all channels from the database
514 return self._db.get_total_count()
516 def get_all_episodes(self):
517 """Returns a generator that yields every episode"""
518 channel_lookup_map = dict((c.id, c) for c in self.channels)
519 return self._db.load_all_episodes(channel_lookup_map)
521 def request_save_dir_size(self):
522 if not self._save_dir_size_set:
523 self.update_save_dir_size()
524 self._save_dir_size_set = True
526 def update_save_dir_size(self):
527 self.save_dir_size = util.calculate_size(self._config.download_dir)
530 class PodcastListModel(gtk.ListStore):
531 C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
532 C_COVER, C_ERROR, C_PILL_VISIBLE, \
533 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
534 C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \
535 C_DOWNLOADS = range(14)
537 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION)
539 @classmethod
540 def row_separator_func(cls, model, iter):
541 return model.get_value(iter, cls.C_SEPARATOR)
543 def __init__(self, cover_downloader):
544 gtk.ListStore.__init__(self, str, str, str, gtk.gdk.Pixbuf, \
545 object, gtk.gdk.Pixbuf, str, bool, bool, bool, bool, \
546 bool, bool, int)
548 # Filter to allow hiding some episodes
549 self._filter = self.filter_new()
550 self._view_mode = -1
551 self._search_term = None
552 self._filter.set_visible_func(self._filter_visible_func)
554 self._cover_cache = {}
555 if gpodder.ui.fremantle:
556 self._max_image_side = 64
557 else:
558 self._max_image_side = 40
559 self._cover_downloader = cover_downloader
561 # "ICON" is used to mark icon names in source files
562 ICON = lambda x: x
564 #self.ICON_DISABLED = ICON('emblem-unreadable')
565 self.ICON_DISABLED = ICON('gtk-media-pause')
567 def _filter_visible_func(self, model, iter):
568 # If searching is active, set visibility based on search text
569 if self._search_term is not None:
570 key = self._search_term.lower()
571 columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS)
572 return any((key in c.lower() for c in columns if c is not None))
574 if model.get_value(iter, self.C_SEPARATOR):
575 return True
576 if self._view_mode == EpisodeListModel.VIEW_ALL:
577 return model.get_value(iter, self.C_HAS_EPISODES)
578 elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
579 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
580 elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
581 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
582 elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
583 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
585 return True
587 def get_filtered_model(self):
588 """Returns a filtered version of this episode model
590 The filtered version should be displayed in the UI,
591 as this model can have some filters set that should
592 be reflected in the UI.
594 return self._filter
596 def set_view_mode(self, new_mode):
597 """Sets a new view mode for this model
599 After setting the view mode, the filtered model
600 might be updated to reflect the new mode."""
601 if self._view_mode != new_mode:
602 self._view_mode = new_mode
603 self._filter.refilter()
605 def get_view_mode(self):
606 """Returns the currently-set view mode"""
607 return self._view_mode
609 def set_search_term(self, new_term):
610 if self._search_term != new_term:
611 self._search_term = new_term
612 self._filter.refilter()
614 def get_search_term(self):
615 return self._search_term
617 def enable_separators(self, channeltree):
618 channeltree.set_row_separator_func(self._show_row_separator)
620 def _show_row_separator(self, model, iter):
621 return model.get_value(iter, self.C_SEPARATOR)
623 def _resize_pixbuf_keep_ratio(self, url, pixbuf):
625 Resizes a GTK Pixbuf but keeps its aspect ratio.
626 Returns None if the pixbuf does not need to be
627 resized or the newly resized pixbuf if it does.
629 changed = False
630 result = None
632 if url in self._cover_cache:
633 return self._cover_cache[url]
635 # Resize if too wide
636 if pixbuf.get_width() > self._max_image_side:
637 f = float(self._max_image_side)/pixbuf.get_width()
638 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
639 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
640 changed = True
642 # Resize if too high
643 if pixbuf.get_height() > self._max_image_side:
644 f = float(self._max_image_side)/pixbuf.get_height()
645 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
646 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
647 changed = True
649 if changed:
650 self._cover_cache[url] = pixbuf
651 result = pixbuf
653 return result
655 def _resize_pixbuf(self, url, pixbuf):
656 if pixbuf is None:
657 return None
659 return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
661 def _overlay_pixbuf(self, pixbuf, icon):
662 try:
663 icon_theme = gtk.icon_theme_get_default()
664 emblem = icon_theme.load_icon(icon, self._max_image_side/2, 0)
665 (width, height) = (emblem.get_width(), emblem.get_height())
666 xpos = pixbuf.get_width() - width
667 ypos = pixbuf.get_height() - height
668 if ypos < 0:
669 # need to resize overlay for none standard icon size
670 emblem = icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0)
671 (width, height) = (emblem.get_width(), emblem.get_height())
672 xpos = pixbuf.get_width() - width
673 ypos = pixbuf.get_height() - height
674 emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
675 except:
676 pass
678 return pixbuf
680 def _get_cover_image(self, channel, add_overlay=False):
681 if self._cover_downloader is None:
682 return None
684 pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
685 pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf)
686 if add_overlay and not channel.feed_update_enabled:
687 pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED)
688 pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False)
690 return pixbuf_overlay
692 def _get_pill_image(self, channel, count_downloaded, count_unplayed):
693 if count_unplayed > 0 or count_downloaded > 0:
694 return draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded))
695 else:
696 return None
698 def _format_description(self, channel, total, deleted, \
699 new, downloaded, unplayed):
700 title_markup = xml.sax.saxutils.escape(channel.title)
701 if channel.feed_update_enabled:
702 description_markup = xml.sax.saxutils.escape(util.get_first_line(channel.description) or ' ')
703 else:
704 description_markup = xml.sax.saxutils.escape(_('Subscription paused.'))
705 d = []
706 if new:
707 d.append('<span weight="bold">')
708 d.append(title_markup)
709 if new:
710 d.append('</span>')
711 return ''.join(d+['\n', '<small>', description_markup, '</small>'])
713 def _format_error(self, channel):
714 if channel.parse_error:
715 return str(channel.parse_error)
716 else:
717 return None
719 def set_channels(self, db, config, channels):
720 # Clear the model and update the list of podcasts
721 self.clear()
723 def channel_to_row(channel, add_overlay=False):
724 return (channel.url, '', '', None, channel, \
725 self._get_cover_image(channel, add_overlay), '', True, True, True, \
726 True, True, False, 0)
728 if config.podcast_list_view_all and channels:
729 all_episodes = PodcastChannelProxy(db, config, channels)
730 iter = self.append(channel_to_row(all_episodes))
731 self.update_by_iter(iter)
733 # Separator item
734 self.append(('', '', '', None, None, None, '', True, True, \
735 True, True, True, True, 0))
737 for channel in channels:
738 iter = self.append(channel_to_row(channel, True))
739 self.update_by_iter(iter)
741 def get_filter_path_from_url(self, url):
742 # Return the path of the filtered model for a given URL
743 child_path = self.get_path_from_url(url)
744 if child_path is None:
745 return None
746 else:
747 return self._filter.convert_child_path_to_path(child_path)
749 def get_path_from_url(self, url):
750 # Return the tree model path for a given URL
751 if url is None:
752 return None
754 for row in self:
755 if row[self.C_URL] == url:
756 return row.path
757 return None
759 def update_first_row(self):
760 # Update the first row in the model (for "all episodes" updates)
761 self.update_by_iter(self.get_iter_first())
763 def update_by_urls(self, urls):
764 # Given a list of URLs, update each matching row
765 for row in self:
766 if row[self.C_URL] in urls:
767 self.update_by_iter(row.iter)
769 def iter_is_first_row(self, iter):
770 iter = self._filter.convert_iter_to_child_iter(iter)
771 path = self.get_path(iter)
772 return (path == (0,))
774 def update_by_filter_iter(self, iter):
775 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))
777 def update_all(self):
778 for row in self:
779 self.update_by_iter(row.iter)
781 def update_by_iter(self, iter):
782 # Given a GtkTreeIter, update volatile information
783 try:
784 channel = self.get_value(iter, self.C_CHANNEL)
785 except TypeError, te:
786 return
787 if channel is None:
788 return
789 total, deleted, new, downloaded, unplayed = channel.get_statistics()
790 description = self._format_description(channel, total, deleted, new, \
791 downloaded, unplayed)
793 if gpodder.ui.fremantle:
794 # We don't display the pill, so don't generate it
795 pill_image = None
796 else:
797 pill_image = self._get_pill_image(channel, downloaded, unplayed)
799 self.set(iter, \
800 self.C_TITLE, channel.title, \
801 self.C_DESCRIPTION, description, \
802 self.C_ERROR, self._format_error(channel), \
803 self.C_PILL, pill_image, \
804 self.C_PILL_VISIBLE, pill_image != None, \
805 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0, \
806 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0, \
807 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0, \
808 self.C_HAS_EPISODES, total > 0, \
809 self.C_DOWNLOADS, downloaded)
811 def add_cover_by_channel(self, channel, pixbuf):
812 # Resize and add the new cover image
813 pixbuf = self._resize_pixbuf(channel.url, pixbuf)
814 if not channel.feed_update_enabled:
815 pixbuf = self._overlay_pixbuf(pixbuf, self.ICON_DISABLED)
816 pixbuf.saturate_and_pixelate(pixbuf, 0.0, False)
818 for row in self:
819 if row[self.C_URL] == channel.url:
820 row[self.C_COVER] = pixbuf
821 break
823 def delete_cover_by_url(self, url):
824 # Remove the cover from the model
825 for row in self:
826 if row[self.C_URL] == url:
827 row[self.C_COVER] = None
828 break
830 # Remove the cover from the cache
831 if url in self._cover_cache:
832 del self._cover_cache[url]