Episode list icons for image episodes (bug 740)
[gpodder.git] / src / gpodder / gtkui / model.py
blobaa363318e5ddd6eb196dafdab882b38e204d0542
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
32 from gpodder.gtkui import draw
34 import gtk
35 import xml.sax.saxutils
37 class EpisodeListModel(gtk.ListStore):
38 C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \
39 C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \
40 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
41 C_VIEW_SHOW_UNPLAYED = range(11)
43 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION)
45 VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = range(4)
47 def __init__(self):
48 gtk.ListStore.__init__(self, str, str, str, object, \
49 gtk.gdk.Pixbuf, str, str, str, bool, bool, bool)
51 # Filter to allow hiding some episodes
52 self._filter = self.filter_new()
53 self._view_mode = self.VIEW_ALL
54 self._search_term = None
55 self._filter.set_visible_func(self._filter_visible_func)
57 # "ICON" is used to mark icon names in source files
58 ICON = lambda x: x
60 self._icon_cache = {}
61 self.ICON_AUDIO_FILE = ICON('audio-x-generic')
62 self.ICON_VIDEO_FILE = ICON('video-x-generic')
63 self.ICON_IMAGE_FILE = ICON('image-x-generic')
64 self.ICON_GENERIC_FILE = ICON('text-x-generic')
65 self.ICON_DOWNLOADING = gtk.STOCK_GO_DOWN
66 self.ICON_DELETED = gtk.STOCK_DELETE
67 self.ICON_NEW = gtk.STOCK_ABOUT
68 self.ICON_UNPLAYED = ICON('emblem-new')
69 self.ICON_LOCKED = ICON('emblem-readonly')
70 self.ICON_MISSING = ICON('emblem-unreadable')
73 def _format_filesize(self, episode):
74 if episode.length > 0:
75 return util.format_filesize(episode.length, 1)
76 else:
77 return None
80 def _filter_visible_func(self, model, iter):
81 # If searching is active, set visibility based on search text
82 if self._search_term is not None:
83 key = self._search_term.lower()
84 return any((key in (model.get_value(iter, column) or '').lower()) for column in self.SEARCH_COLUMNS)
86 if self._view_mode == self.VIEW_ALL:
87 return True
88 elif self._view_mode == self.VIEW_UNDELETED:
89 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
90 elif self._view_mode == self.VIEW_DOWNLOADED:
91 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
92 elif self._view_mode == self.VIEW_UNPLAYED:
93 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
95 return True
97 def get_filtered_model(self):
98 """Returns a filtered version of this episode model
100 The filtered version should be displayed in the UI,
101 as this model can have some filters set that should
102 be reflected in the UI.
104 return self._filter
106 def set_view_mode(self, new_mode):
107 """Sets a new view mode for this model
109 After setting the view mode, the filtered model
110 might be updated to reflect the new mode."""
111 if self._view_mode != new_mode:
112 self._view_mode = new_mode
113 self._filter.refilter()
115 def get_view_mode(self):
116 """Returns the currently-set view mode"""
117 return self._view_mode
119 def set_search_term(self, new_term):
120 if self._search_term != new_term:
121 self._search_term = new_term
122 self._filter.refilter()
124 def get_search_term(self):
125 return self._search_term
127 def _format_description(self, episode, include_description=False, is_downloading=None):
128 if include_description:
129 return '%s\n<small>%s</small>' % (xml.sax.saxutils.escape(episode.title),
130 xml.sax.saxutils.escape(episode.one_line_description()))
131 else:
132 return xml.sax.saxutils.escape(episode.title)
134 def add_from_channel(self, channel, downloading=None, \
135 include_description=False):
137 Add episode from the given channel to this model.
138 Downloading should be a callback.
139 include_description should be a boolean value (True if description
140 is to be added to the episode row, or False if not)
142 def insert_and_update(episode):
143 description_stripped = util.remove_html_tags(episode.description)
145 iter = self.append()
146 self.set(iter, \
147 self.C_URL, episode.url, \
148 self.C_TITLE, episode.title, \
149 self.C_FILESIZE_TEXT, self._format_filesize(episode), \
150 self.C_EPISODE, episode, \
151 self.C_PUBLISHED_TEXT, episode.cute_pubdate(), \
152 self.C_TOOLTIP, description_stripped)
154 self.update_by_iter(iter, downloading, include_description)
156 for episode in channel.get_all_episodes():
157 util.idle_add(insert_and_update, episode)
159 def update_all(self, downloading=None, include_description=False):
160 for row in self:
161 self.update_by_iter(row.iter, downloading, include_description)
163 def update_by_urls(self, urls, downloading=None, include_description=False):
164 for row in self:
165 if row[self.C_URL] in urls:
166 self.update_by_iter(row.iter, downloading, include_description)
168 def update_by_filter_iter(self, iter, downloading=None, \
169 include_description=False):
170 # Convenience function for use by "outside" methods that use iters
171 # from the filtered episode list model (i.e. all UI things normally)
172 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter), \
173 downloading, include_description)
175 def update_by_iter(self, iter, downloading=None, include_description=False):
176 episode = self.get_value(iter, self.C_EPISODE)
177 episode.reload_from_db()
179 if include_description or gpodder.ui.maemo:
180 icon_size = 32
181 else:
182 icon_size = 16
184 show_bullet = False
185 show_padlock = False
186 show_missing = False
187 status_icon = None
188 tooltip = ''
189 view_show_undeleted = True
190 view_show_downloaded = False
191 view_show_unplayed = False
193 if downloading is not None and downloading(episode):
194 tooltip = _('Downloading')
195 status_icon = self.ICON_DOWNLOADING
196 view_show_downloaded = True
197 view_show_unplayed = True
198 else:
199 if episode.state == gpodder.STATE_DELETED:
200 tooltip = _('Deleted')
201 status_icon = self.ICON_DELETED
202 view_show_undeleted = False
203 elif episode.state == gpodder.STATE_NORMAL and \
204 not episode.is_played:
205 tooltip = _('New episode')
206 status_icon = self.ICON_NEW
207 view_show_downloaded = True
208 view_show_unplayed = True
209 elif episode.state == gpodder.STATE_DOWNLOADED:
210 tooltip = []
211 view_show_downloaded = True
212 view_show_unplayed = not episode.is_played
213 show_bullet = not episode.is_played
214 show_padlock = episode.is_locked
215 show_missing = not episode.file_exists()
217 file_type = episode.file_type()
218 if file_type == 'audio':
219 tooltip.append(_('Downloaded episode'))
220 status_icon = self.ICON_AUDIO_FILE
221 elif file_type == 'video':
222 tooltip.append(_('Downloaded video episode'))
223 status_icon = self.ICON_VIDEO_FILE
224 elif file_type == 'image':
225 tooltip.append(_('Downloaded image'))
226 status_icon = self.ICON_IMAGE_FILE
227 else:
228 tooltip.append(_('Downloaded file'))
229 status_icon = self.ICON_GENERIC_FILE
231 if show_missing:
232 tooltip.append(_('missing file'))
233 else:
234 if show_bullet:
235 if file_type == 'image':
236 tooltip.append(_('never displayed'))
237 else:
238 tooltip.append(_('never played'))
239 else:
240 if file_type == 'image':
241 tooltip.append(_('displayed'))
242 else:
243 tooltip.append(_('played'))
244 if show_padlock:
245 tooltip.append(_('deletion prevented'))
247 tooltip = ', '.join(tooltip)
249 if status_icon is not None:
250 status_icon = self._get_tree_icon(status_icon, show_bullet, \
251 show_padlock, show_missing, icon_size)
253 description = self._format_description(episode, include_description, downloading)
254 self.set(iter, \
255 self.C_STATUS_ICON, status_icon, \
256 self.C_VIEW_SHOW_UNDELETED, view_show_undeleted, \
257 self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded, \
258 self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed, \
259 self.C_DESCRIPTION, description, \
260 self.C_TOOLTIP, tooltip)
262 def _get_tree_icon(self, icon_name, add_bullet=False, \
263 add_padlock=False, add_missing=False, icon_size=32):
265 Loads an icon from the current icon theme at the specified
266 size, suitable for display in a gtk.TreeView. Additional
267 emblems can be added on top of the icon.
269 Caching is used to speed up the icon lookup.
272 # Add all variables that modify the appearance of the icon, so
273 # our cache does not return the same icons for different requests
274 cache_id = (icon_name, add_bullet, add_padlock, add_missing, icon_size)
276 if cache_id in self._icon_cache:
277 return self._icon_cache[cache_id]
279 icon_theme = gtk.icon_theme_get_default()
281 try:
282 icon = icon_theme.load_icon(icon_name, icon_size, 0)
283 except:
284 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, icon_size, 0)
286 if icon and (add_bullet or add_padlock or add_missing):
287 # We'll modify the icon, so use .copy()
288 if add_missing:
289 try:
290 icon = icon.copy()
291 # Desaturate the icon so it looks even more "missing"
292 icon.saturate_and_pixelate(icon, 0.0, False)
293 emblem = icon_theme.load_icon(self.ICON_MISSING, icon_size/2, 0)
294 (width, height) = (emblem.get_width(), emblem.get_height())
295 xpos = icon.get_width() - width
296 ypos = icon.get_height() - height
297 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
298 except:
299 pass
300 elif add_bullet:
301 try:
302 icon = icon.copy()
303 emblem = icon_theme.load_icon(self.ICON_UNPLAYED, icon_size/2, 0)
304 (width, height) = (emblem.get_width(), emblem.get_height())
305 xpos = icon.get_width() - width
306 ypos = icon.get_height() - height
307 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
308 except:
309 pass
310 if add_padlock:
311 try:
312 icon = icon.copy()
313 emblem = icon_theme.load_icon(self.ICON_LOCKED, icon_size/2, 0)
314 (width, height) = (emblem.get_width(), emblem.get_height())
315 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
316 except:
317 pass
319 self._icon_cache[cache_id] = icon
320 return icon
323 class PodcastChannelProxy(object):
324 def __init__(self, db, config, channels):
325 self._db = db
326 self._config = config
327 self.channels = channels
328 self.title = _('All episodes')
329 self.description = _('from all podcasts')
330 self.parse_error = ''
331 self.url = ''
332 self.id = None
333 self._save_dir_size_set = False
334 self.save_dir_size = 0L
335 self.icon = None
337 def __getattribute__(self, name):
338 try:
339 return object.__getattribute__(self, name)
340 except AttributeError:
341 log('Unsupported method call (%s)', name, sender=self)
343 def get_statistics(self):
344 # Get the total statistics for all channels from the database
345 return self._db.get_total_count()
347 def get_all_episodes(self):
348 """Returns a generator that yields every episode"""
349 for channel in self.channels:
350 for episode in channel.get_all_episodes():
351 yield episode
353 def request_save_dir_size(self):
354 if not self._save_dir_size_set:
355 self.update_save_dir_size()
356 self._save_dir_size_set = True
358 def update_save_dir_size(self):
359 self.save_dir_size = util.calculate_size(self._config.download_dir)
362 class PodcastListModel(gtk.ListStore):
363 C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
364 C_COVER, C_ERROR, C_PILL_VISIBLE, \
365 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
366 C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR = range(13)
368 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION)
370 @classmethod
371 def row_separator_func(cls, model, iter):
372 return model.get_value(iter, cls.C_SEPARATOR)
374 def __init__(self, max_image_side, cover_downloader):
375 gtk.ListStore.__init__(self, str, str, str, gtk.gdk.Pixbuf, \
376 object, gtk.gdk.Pixbuf, str, bool, bool, bool, bool, bool, bool)
378 # Filter to allow hiding some episodes
379 self._filter = self.filter_new()
380 self._view_mode = -1
381 self._search_term = None
382 self._filter.set_visible_func(self._filter_visible_func)
384 self._cover_cache = {}
385 if gpodder.ui.fremantle:
386 self._max_image_side = 64
387 else:
388 self._max_image_side = max_image_side
389 self._cover_downloader = cover_downloader
391 def _filter_visible_func(self, model, iter):
392 # If searching is active, set visibility based on search text
393 if self._search_term is not None:
394 key = self._search_term.lower()
395 return any((key in model.get_value(iter, column).lower()) for column in self.SEARCH_COLUMNS)
397 if model.get_value(iter, self.C_SEPARATOR):
398 return True
399 if self._view_mode == EpisodeListModel.VIEW_ALL:
400 return model.get_value(iter, self.C_HAS_EPISODES)
401 elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
402 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
403 elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
404 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
405 elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
406 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
408 return True
410 def get_filtered_model(self):
411 """Returns a filtered version of this episode model
413 The filtered version should be displayed in the UI,
414 as this model can have some filters set that should
415 be reflected in the UI.
417 return self._filter
419 def set_view_mode(self, new_mode):
420 """Sets a new view mode for this model
422 After setting the view mode, the filtered model
423 might be updated to reflect the new mode."""
424 if self._view_mode != new_mode:
425 self._view_mode = new_mode
426 self._filter.refilter()
428 def get_view_mode(self):
429 """Returns the currently-set view mode"""
430 return self._view_mode
432 def set_search_term(self, new_term):
433 if self._search_term != new_term:
434 self._search_term = new_term
435 self._filter.refilter()
437 def get_search_term(self):
438 return self._search_term
440 def enable_separators(self, channeltree):
441 channeltree.set_row_separator_func(self._show_row_separator)
443 def _show_row_separator(self, model, iter):
444 return model.get_value(iter, self.C_SEPARATOR)
446 def _resize_pixbuf_keep_ratio(self, url, pixbuf):
448 Resizes a GTK Pixbuf but keeps its aspect ratio.
449 Returns None if the pixbuf does not need to be
450 resized or the newly resized pixbuf if it does.
452 changed = False
453 result = None
455 if url in self._cover_cache:
456 return self._cover_cache[url]
458 # Resize if too wide
459 if pixbuf.get_width() > self._max_image_side:
460 f = float(self._max_image_side)/pixbuf.get_width()
461 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
462 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
463 changed = True
465 # Resize if too high
466 if pixbuf.get_height() > self._max_image_side:
467 f = float(self._max_image_side)/pixbuf.get_height()
468 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
469 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
470 changed = True
472 if changed:
473 self._cover_cache[url] = pixbuf
474 result = pixbuf
476 return result
478 def _resize_pixbuf(self, url, pixbuf):
479 if pixbuf is None:
480 return None
482 return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
484 def _get_cover_image(self, channel):
485 if self._cover_downloader is None:
486 return None
488 pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
489 return self._resize_pixbuf(channel.url, pixbuf)
491 def _get_pill_image(self, channel, count_downloaded, count_unplayed):
492 if count_unplayed > 0 or count_downloaded > 0:
493 return draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded))
494 else:
495 return None
497 def _format_description(self, channel, total, deleted, \
498 new, downloaded, unplayed):
499 title_markup = xml.sax.saxutils.escape(channel.title)
500 description_markup = xml.sax.saxutils.escape(util.get_first_line(channel.description) or ' ')
501 d = []
502 if new:
503 d.append('<span weight="bold">')
504 d.append(title_markup)
505 if new:
506 d.append('</span>')
507 return ''.join(d+['\n', '<small>', description_markup, '</small>'])
509 def _format_error(self, channel):
510 if channel.parse_error:
511 return str(channel.parse_error)
512 else:
513 return None
515 def set_channels(self, db, config, channels):
516 # Clear the model and update the list of podcasts
517 self.clear()
519 if config.podcast_list_view_all:
520 all_episodes = PodcastChannelProxy(db, config, channels)
521 iter = self.append()
522 self.set(iter, \
523 self.C_URL, all_episodes.url, \
524 self.C_CHANNEL, all_episodes, \
525 self.C_COVER, all_episodes.icon, \
526 self.C_SEPARATOR, False)
527 self.update_by_iter(iter)
529 iter = self.append()
530 self.set(iter, self.C_SEPARATOR, True)
532 for channel in channels:
533 iter = self.append()
534 self.set(iter, \
535 self.C_URL, channel.url, \
536 self.C_CHANNEL, channel, \
537 self.C_COVER, self._get_cover_image(channel), \
538 self.C_SEPARATOR, False)
539 self.update_by_iter(iter)
541 def get_filter_path_from_url(self, url):
542 # Return the path of the filtered model for a given URL
543 child_path = self.get_path_from_url(url)
544 if child_path is None:
545 return None
546 else:
547 return self._filter.convert_child_path_to_path(child_path)
549 def get_path_from_url(self, url):
550 # Return the tree model path for a given URL
551 if url is None:
552 return None
554 for row in self:
555 if row[self.C_URL] == url:
556 return row.path
557 return None
559 def update_by_urls(self, urls):
560 # Given a list of URLs, update each matching row
561 for row in self:
562 if row[self.C_URL] in urls:
563 self.update_by_iter(row.iter)
565 def update_by_filter_iter(self, iter):
566 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))
568 def update_all(self):
569 for row in self:
570 self.update_by_iter(row.iter)
572 def update_by_iter(self, iter):
573 # Given a GtkTreeIter, update volatile information
574 channel = self.get_value(iter, self.C_CHANNEL)
575 if channel is None:
576 return
577 total, deleted, new, downloaded, unplayed = channel.get_statistics()
578 description = self._format_description(channel, total, deleted, new, \
579 downloaded, unplayed)
581 pill_image = self._get_pill_image(channel, downloaded, unplayed)
582 self.set(iter, \
583 self.C_TITLE, channel.title, \
584 self.C_DESCRIPTION, description, \
585 self.C_ERROR, self._format_error(channel), \
586 self.C_PILL, pill_image, \
587 self.C_PILL_VISIBLE, pill_image != None, \
588 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0, \
589 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0, \
590 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0, \
591 self.C_HAS_EPISODES, total > 0)
593 def add_cover_by_url(self, url, pixbuf):
594 # Resize and add the new cover image
595 pixbuf = self._resize_pixbuf(url, pixbuf)
596 for row in self:
597 if row[self.C_URL] == url:
598 row[self.C_COVER] = pixbuf
599 break
601 def delete_cover_by_url(self, url):
602 # Remove the cover from the model
603 for row in self:
604 if row[self.C_URL] == url:
605 row[self.C_COVER] = None
606 break
608 # Remove the cover from the cache
609 if url in self._cover_cache:
610 del self._cover_cache[url]