Maemo 5: Fix window and button layout (Maemo bug 11499)
[gpodder.git] / src / gpodder / gtkui / model.py
blobad93e8a91aef1acafe8d190164d5658a52a638b4
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_TIME_VISIBLE, \
51 C_LOCKED = range(16)
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 ''.join((a, xml.sax.saxutils.escape(episode.title), b))
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_TIME_VISIBLE, episode.total_time, \
369 self.C_LOCKED, episode.is_locked)
371 def _get_icon_from_image(self,image_path, icon_size):
373 Load an local image file and transform it into an icon.
375 Return a pixbuf scaled to the desired size and may return None
376 if the icon creation is impossible (file not found etc).
378 if not os.path.exists(image_path):
379 return None
380 # load image from disc (code adapted from CoverDownloader
381 # except that no download is needed here)
382 loader = gtk.gdk.PixbufLoader()
383 pixbuf = None
384 try:
385 loader.write(open(image_path, 'rb').read())
386 loader.close()
387 pixbuf = loader.get_pixbuf()
388 except:
389 log('Data error while loading image %s', image_path, sender=self)
390 return None
391 # Now scale the image with ratio (copied from _resize_pixbuf_keep_ratio)
392 # Resize if too wide
393 if pixbuf.get_width() > icon_size:
394 f = float(icon_size)/pixbuf.get_width()
395 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
396 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
397 # Resize if too high
398 if pixbuf.get_height() > icon_size:
399 f = float(icon_size)/pixbuf.get_height()
400 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
401 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
402 return pixbuf
405 def _get_tree_icon(self, icon_name, add_bullet=False, \
406 add_padlock=False, add_missing=False, icon_size=32, \
407 build_icon_from_file = False):
409 Loads an icon from the current icon theme at the specified
410 size, suitable for display in a gtk.TreeView. Additional
411 emblems can be added on top of the icon.
413 Caching is used to speed up the icon lookup.
415 The `build_icon_from_file` argument indicates (when True) that
416 the icon has to be created on the fly from a given image
417 file. The `icon_name` argument is then interpreted as the path
418 to this file. Those specific icons will *not be cached*.
421 # Add all variables that modify the appearance of the icon, so
422 # our cache does not return the same icons for different requests
423 cache_id = (icon_name, add_bullet, add_padlock, add_missing, icon_size)
425 if cache_id in self._icon_cache:
426 return self._icon_cache[cache_id]
428 icon_theme = gtk.icon_theme_get_default()
430 try:
431 if build_icon_from_file:
432 icon = self._get_icon_from_image(icon_name,icon_size)
433 else:
434 icon = icon_theme.load_icon(icon_name, icon_size, 0)
435 except:
436 try:
437 log('Missing icon in theme: %s', icon_name, sender=self)
438 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, \
439 icon_size, 0)
440 except:
441 log('Please install the GNOME icon theme.', sender=self)
442 icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, \
443 True, 8, icon_size, icon_size)
445 if icon and (add_bullet or add_padlock or add_missing):
446 # We'll modify the icon, so use .copy()
447 if add_missing:
448 try:
449 icon = icon.copy()
450 # Desaturate the icon so it looks even more "missing"
451 icon.saturate_and_pixelate(icon, 0.0, False)
452 emblem = icon_theme.load_icon(self.ICON_MISSING, icon_size/2, 0)
453 (width, height) = (emblem.get_width(), emblem.get_height())
454 xpos = icon.get_width() - width
455 ypos = icon.get_height() - height
456 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
457 except:
458 pass
459 elif add_bullet:
460 try:
461 icon = icon.copy()
462 emblem = icon_theme.load_icon(self.ICON_UNPLAYED, icon_size/2, 0)
463 (width, height) = (emblem.get_width(), emblem.get_height())
464 xpos = icon.get_width() - width
465 ypos = icon.get_height() - height
466 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
467 except:
468 pass
469 if add_padlock:
470 try:
471 icon = icon.copy()
472 emblem = icon_theme.load_icon(self.ICON_LOCKED, icon_size/2, 0)
473 (width, height) = (emblem.get_width(), emblem.get_height())
474 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
475 except:
476 pass
478 self._icon_cache[cache_id] = icon
479 return icon
482 class PodcastChannelProxy(object):
483 ALL_EPISODES_PROXY = True
485 def __init__(self, db, config, channels):
486 self._db = db
487 self._config = config
488 self.channels = channels
489 self.title = _('All episodes')
490 self.description = _('from all podcasts')
491 self.parse_error = ''
492 self.url = ''
493 self.id = None
494 self._save_dir_size_set = False
495 self.save_dir_size = 0L
496 self.cover_file = os.path.join(gpodder.images_folder, 'podcast-all.png')
497 self.feed_update_enabled = True
499 def __getattribute__(self, name):
500 try:
501 return object.__getattribute__(self, name)
502 except AttributeError:
503 log('Unsupported method call (%s)', name, sender=self)
505 def get_statistics(self):
506 # Get the total statistics for all channels from the database
507 return self._db.get_total_count()
509 def get_all_episodes(self):
510 """Returns a generator that yields every episode"""
511 channel_lookup_map = dict((c.id, c) for c in self.channels)
512 return self._db.load_all_episodes(channel_lookup_map)
514 def request_save_dir_size(self):
515 if not self._save_dir_size_set:
516 self.update_save_dir_size()
517 self._save_dir_size_set = True
519 def update_save_dir_size(self):
520 self.save_dir_size = util.calculate_size(self._config.download_dir)
523 class PodcastListModel(gtk.ListStore):
524 C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
525 C_COVER, C_ERROR, C_PILL_VISIBLE, \
526 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
527 C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \
528 C_DOWNLOADS = range(14)
530 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION)
532 @classmethod
533 def row_separator_func(cls, model, iter):
534 return model.get_value(iter, cls.C_SEPARATOR)
536 def __init__(self, cover_downloader):
537 gtk.ListStore.__init__(self, str, str, str, gtk.gdk.Pixbuf, \
538 object, gtk.gdk.Pixbuf, str, bool, bool, bool, bool, \
539 bool, bool, int)
541 # Filter to allow hiding some episodes
542 self._filter = self.filter_new()
543 self._view_mode = -1
544 self._search_term = None
545 self._filter.set_visible_func(self._filter_visible_func)
547 self._cover_cache = {}
548 if gpodder.ui.fremantle:
549 self._max_image_side = 64
550 else:
551 self._max_image_side = 40
552 self._cover_downloader = cover_downloader
554 # "ICON" is used to mark icon names in source files
555 ICON = lambda x: x
557 #self.ICON_DISABLED = ICON('emblem-unreadable')
558 self.ICON_DISABLED = ICON('gtk-media-pause')
560 def _filter_visible_func(self, model, iter):
561 # If searching is active, set visibility based on search text
562 if self._search_term is not None:
563 key = self._search_term.lower()
564 columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS)
565 return any((key in c.lower() for c in columns if c is not None))
567 if model.get_value(iter, self.C_SEPARATOR):
568 return True
569 if self._view_mode == EpisodeListModel.VIEW_ALL:
570 return model.get_value(iter, self.C_HAS_EPISODES)
571 elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
572 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
573 elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
574 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
575 elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
576 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
578 return True
580 def get_filtered_model(self):
581 """Returns a filtered version of this episode model
583 The filtered version should be displayed in the UI,
584 as this model can have some filters set that should
585 be reflected in the UI.
587 return self._filter
589 def set_view_mode(self, new_mode):
590 """Sets a new view mode for this model
592 After setting the view mode, the filtered model
593 might be updated to reflect the new mode."""
594 if self._view_mode != new_mode:
595 self._view_mode = new_mode
596 self._filter.refilter()
598 def get_view_mode(self):
599 """Returns the currently-set view mode"""
600 return self._view_mode
602 def set_search_term(self, new_term):
603 if self._search_term != new_term:
604 self._search_term = new_term
605 self._filter.refilter()
607 def get_search_term(self):
608 return self._search_term
610 def enable_separators(self, channeltree):
611 channeltree.set_row_separator_func(self._show_row_separator)
613 def _show_row_separator(self, model, iter):
614 return model.get_value(iter, self.C_SEPARATOR)
616 def _resize_pixbuf_keep_ratio(self, url, pixbuf):
618 Resizes a GTK Pixbuf but keeps its aspect ratio.
619 Returns None if the pixbuf does not need to be
620 resized or the newly resized pixbuf if it does.
622 changed = False
623 result = None
625 if url in self._cover_cache:
626 return self._cover_cache[url]
628 # Resize if too wide
629 if pixbuf.get_width() > self._max_image_side:
630 f = float(self._max_image_side)/pixbuf.get_width()
631 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
632 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
633 changed = True
635 # Resize if too high
636 if pixbuf.get_height() > self._max_image_side:
637 f = float(self._max_image_side)/pixbuf.get_height()
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 if changed:
643 self._cover_cache[url] = pixbuf
644 result = pixbuf
646 return result
648 def _resize_pixbuf(self, url, pixbuf):
649 if pixbuf is None:
650 return None
652 return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
654 def _overlay_pixbuf(self, pixbuf, icon):
655 try:
656 icon_theme = gtk.icon_theme_get_default()
657 emblem = icon_theme.load_icon(icon, self._max_image_side/2, 0)
658 (width, height) = (emblem.get_width(), emblem.get_height())
659 xpos = pixbuf.get_width() - width
660 ypos = pixbuf.get_height() - height
661 if ypos < 0:
662 # need to resize overlay for none standard icon size
663 emblem = icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0)
664 (width, height) = (emblem.get_width(), emblem.get_height())
665 xpos = pixbuf.get_width() - width
666 ypos = pixbuf.get_height() - height
667 emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
668 except:
669 pass
671 return pixbuf
673 def _get_cover_image(self, channel, add_overlay=False):
674 if self._cover_downloader is None:
675 return None
677 pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
678 pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf)
679 if add_overlay and not channel.feed_update_enabled:
680 pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED)
681 pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False)
683 return pixbuf_overlay
685 def _get_pill_image(self, channel, count_downloaded, count_unplayed):
686 if count_unplayed > 0 or count_downloaded > 0:
687 return draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded))
688 else:
689 return None
691 def _format_description(self, channel, total, deleted, \
692 new, downloaded, unplayed):
693 title_markup = xml.sax.saxutils.escape(channel.title)
694 if channel.feed_update_enabled:
695 description_markup = xml.sax.saxutils.escape(util.get_first_line(channel.description) or ' ')
696 else:
697 description_markup = xml.sax.saxutils.escape(_('Subscription paused'))
698 d = []
699 if new:
700 d.append('<span weight="bold">')
701 d.append(title_markup)
702 if new:
703 d.append('</span>')
704 return ''.join(d+['\n', '<small>', description_markup, '</small>'])
706 def _format_error(self, channel):
707 if channel.parse_error:
708 return str(channel.parse_error)
709 else:
710 return None
712 def set_channels(self, db, config, channels):
713 # Clear the model and update the list of podcasts
714 self.clear()
716 def channel_to_row(channel, add_overlay=False):
717 return (channel.url, '', '', None, channel, \
718 self._get_cover_image(channel, add_overlay), '', True, True, True, \
719 True, True, False, 0)
721 if config.podcast_list_view_all and channels:
722 all_episodes = PodcastChannelProxy(db, config, channels)
723 iter = self.append(channel_to_row(all_episodes))
724 self.update_by_iter(iter)
726 # Separator item
727 self.append(('', '', '', None, None, None, '', True, True, \
728 True, True, True, True, 0))
730 for channel in channels:
731 iter = self.append(channel_to_row(channel, True))
732 self.update_by_iter(iter)
734 def get_filter_path_from_url(self, url):
735 # Return the path of the filtered model for a given URL
736 child_path = self.get_path_from_url(url)
737 if child_path is None:
738 return None
739 else:
740 return self._filter.convert_child_path_to_path(child_path)
742 def get_path_from_url(self, url):
743 # Return the tree model path for a given URL
744 if url is None:
745 return None
747 for row in self:
748 if row[self.C_URL] == url:
749 return row.path
750 return None
752 def update_first_row(self):
753 # Update the first row in the model (for "all episodes" updates)
754 self.update_by_iter(self.get_iter_first())
756 def update_by_urls(self, urls):
757 # Given a list of URLs, update each matching row
758 for row in self:
759 if row[self.C_URL] in urls:
760 self.update_by_iter(row.iter)
762 def iter_is_first_row(self, iter):
763 iter = self._filter.convert_iter_to_child_iter(iter)
764 path = self.get_path(iter)
765 return (path == (0,))
767 def update_by_filter_iter(self, iter):
768 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))
770 def update_all(self):
771 for row in self:
772 self.update_by_iter(row.iter)
774 def update_by_iter(self, iter):
775 # Given a GtkTreeIter, update volatile information
776 try:
777 channel = self.get_value(iter, self.C_CHANNEL)
778 except TypeError, te:
779 return
780 if channel is None:
781 return
782 total, deleted, new, downloaded, unplayed = channel.get_statistics()
783 description = self._format_description(channel, total, deleted, new, \
784 downloaded, unplayed)
786 if gpodder.ui.fremantle:
787 # We don't display the pill, so don't generate it
788 pill_image = None
789 else:
790 pill_image = self._get_pill_image(channel, downloaded, unplayed)
792 self.set(iter, \
793 self.C_TITLE, channel.title, \
794 self.C_DESCRIPTION, description, \
795 self.C_ERROR, self._format_error(channel), \
796 self.C_PILL, pill_image, \
797 self.C_PILL_VISIBLE, pill_image != None, \
798 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0, \
799 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0, \
800 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0, \
801 self.C_HAS_EPISODES, total > 0, \
802 self.C_DOWNLOADS, downloaded)
804 def add_cover_by_channel(self, channel, pixbuf):
805 # Resize and add the new cover image
806 pixbuf = self._resize_pixbuf(channel.url, pixbuf)
807 if not channel.feed_update_enabled:
808 pixbuf = self._overlay_pixbuf(pixbuf, self.ICON_DISABLED)
809 pixbuf.saturate_and_pixelate(pixbuf, 0.0, False)
811 for row in self:
812 if row[self.C_URL] == channel.url:
813 row[self.C_COVER] = pixbuf
814 break
816 def delete_cover_by_url(self, url):
817 # Remove the cover from the model
818 for row in self:
819 if row[self.C_URL] == url:
820 row[self.C_COVER] = None
821 break
823 # Remove the cover from the cache
824 if url in self._cover_cache:
825 del self._cover_cache[url]