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.frmntl.model -- Model customizations for Maemo 5 (2009-11-16)
33 from gpodder
.gtkui
import download
34 from gpodder
.gtkui
import model
35 from gpodder
.gtkui
.frmntl
import style
37 from gpodder
import util
39 class DownloadStatusModel(download
.DownloadStatusModel
):
41 download
.DownloadStatusModel
.__init
__(self
)
42 head_font
= style
.get_font_desc('SystemFont')
43 head_color
= style
.get_color('ButtonTextColor')
44 head
= (head_font
.to_string(), head_color
.to_string())
45 head
= '<span font_desc="%s" foreground="%s">%%s</span>' % head
46 sub_font
= style
.get_font_desc('SmallSystemFont')
47 sub_color
= style
.get_color('SecondaryTextColor')
48 sub
= (sub_font
.to_string(), sub_color
.to_string())
49 sub
= '<span font_desc="%s" foreground="%s">%%s - %%s</span>' % sub
50 self
._markup
_template
= '\n'.join((head
, sub
))
52 def _format_message(self
, episode
, message
, podcast
):
53 return self
._markup
_template
% (episode
, message
, podcast
)
56 class EpisodeListModel(gtk
.GenericTreeModel
):
59 C_URL
, C_TITLE
, C_FILESIZE_TEXT
, C_EPISODE
, C_STATUS_ICON
, \
60 C_PUBLISHED_TEXT
, C_DESCRIPTION
, C_TOOLTIP
, \
61 C_VIEW_SHOW_UNDELETED
, C_VIEW_SHOW_DOWNLOADED
, \
62 C_VIEW_SHOW_UNPLAYED
, C_FILESIZE
, C_PUBLISHED
, \
63 C_TIME
, C_TIME1_VISIBLE
, C_TIME2_VISIBLE
, \
64 C_LOCKED
= range(N_COLUMNS
)
66 DATA_TYPES
= (str, str, str, object, str , str, str, \
67 str, bool, bool, bool, int, int, str, bool, bool, \
70 SEARCH_COLUMNS
= (C_TITLE
,)
72 VIEW_ALL
, VIEW_UNDELETED
, VIEW_DOWNLOADED
, VIEW_UNPLAYED
= range(4)
74 # ---------------------
76 def on_get_flags(self
):
77 return gtk
.TREE_MODEL_LIST_ONLY
79 def on_get_n_columns(self
):
82 def on_get_column_type(self
, index
):
83 return self
.DATA_TYPES
[index
]
85 def on_get_iter(self
, path
):
88 def on_get_path(self
, rowref
):
91 def on_get_value(self
, rowref
, column
):
92 if rowref
>= len(self
._episodes
):
95 episode
= self
._episodes
[rowref
]
96 downloading
= self
._downloading
98 if column
== self
.C_URL
:
100 elif column
== self
.C_TITLE
:
102 elif column
== self
.C_FILESIZE_TEXT
:
103 return self
._format
_filesize
(episode
)
104 elif column
== self
.C_EPISODE
:
106 elif column
== self
.C_STATUS_ICON
:
107 if downloading(episode
):
108 return self
.ICON_DOWNLOADING
109 elif episode
.state
== gpodder
.STATE_DELETED
:
110 return self
.ICON_DELETED
111 elif episode
.state
== gpodder
.STATE_NORMAL
and \
112 not episode
.is_played
and \
113 not downloading(episode
):
115 elif episode
.state
== gpodder
.STATE_DOWNLOADED
:
116 filename
= episode
.local_filename(create
=False, \
119 file_type
= episode
.file_type()
120 if file_type
== 'audio':
121 status_icon
= self
.ICON_AUDIO_FILE
122 elif file_type
== 'video':
123 status_icon
= self
.ICON_VIDEO_FILE
124 elif file_type
== 'image':
125 status_icon
= self
.ICON_IMAGE_FILE
127 status_icon
= self
.ICON_GENERIC_FILE
132 icon_theme
= gtk
.icon_theme_get_default()
133 if filename
is not None and have_gio
:
134 file = gio
.File(filename
)
135 if file.query_exists():
136 file_info
= file.query_info('*')
137 icon
= file_info
.get_icon()
138 for icon_name
in icon
.get_names():
139 if icon_theme
.has_icon(icon_name
):
145 elif column
== self
.C_PUBLISHED_TEXT
:
146 return episode
.cute_pubdate()
147 elif column
== self
.C_DESCRIPTION
:
148 return self
._format
_description
(episode
)
149 elif column
== self
.C_TOOLTIP
:
151 elif column
== self
.C_VIEW_SHOW_UNDELETED
:
152 return episode
.state
!= gpodder
.STATE_DELETED
or downloading(episode
)
153 elif column
== self
.C_VIEW_SHOW_DOWNLOADED
:
154 return episode
.state
== gpodder
.STATE_DOWNLOADED
or \
155 (episode
.state
== gpodder
.STATE_NORMAL
and \
156 not episode
.is_played
) or \
158 elif column
== self
.C_VIEW_SHOW_UNPLAYED
:
159 return (not episode
.is_played
and (episode
.state
in \
160 (gpodder
.STATE_DOWNLOADED
, gpodder
.STATE_NORMAL
))) or \
162 elif column
== self
.C_FILESIZE
:
163 return episode
.length
164 elif column
== self
.C_PUBLISHED
:
165 return episode
.pubDate
166 elif column
== self
.C_TIME
:
167 return episode
.get_play_info_string()
168 elif column
== self
.C_TIME1_VISIBLE
:
169 return (episode
.total_time
and not episode
.current_position
)
170 elif column
== self
.C_TIME2_VISIBLE
:
171 return (episode
.total_time
and episode
.current_position
)
172 elif column
== self
.C_LOCKED
:
173 return episode
.is_locked
and \
174 episode
.state
== gpodder
.STATE_DOWNLOADED
and \
175 episode
.file_exists()
177 raise Exception('could not find column index: ' + str(column
))
179 def on_iter_next(self
, rowref
):
180 if len(self
._episodes
) > rowref
+ 1:
185 def on_iter_children(self
, parent
):
192 def on_iter_has_child(self
, rowref
):
195 def on_iter_n_children(self
, rowref
):
197 return len(self
._episodes
)
201 def on_iter_nth_child(self
, parent
, n
):
207 def on_iter_parent(self
, child
):
210 # ---------------------
213 def __init__(self
, on_filter_changed
=lambda has_episodes
: None):
214 gtk
.GenericTreeModel
.__init
__(self
)
216 # Callback for when the filter / list changes, gets one parameter
217 # (has_episodes) that is True if the list has any episodes
218 self
._on
_filter
_changed
= on_filter_changed
220 self
._downloading
= lambda x
: False
221 self
._include
_description
= False
222 self
._generate
_thumbnails
= False
226 # Filter to allow hiding some episodes
227 self
._filter
= self
.filter_new()
228 self
._view
_mode
= self
.VIEW_ALL
229 self
._search
_term
= None
230 self
._filter
.set_visible_func(self
._filter
_visible
_func
)
232 # Are we currently showing the "all episodes" view?
233 self
._all
_episodes
_view
= False
235 # "ICON" is used to mark icon names in source files
238 self
._icon
_cache
= {}
239 self
.ICON_AUDIO_FILE
= ICON('general_audio_file')
240 self
.ICON_VIDEO_FILE
= ICON('general_video_file')
241 self
.ICON_IMAGE_FILE
= ICON('general_image')
242 self
.ICON_GENERIC_FILE
= ICON('filemanager_unknown_file')
243 self
.ICON_DOWNLOADING
= ICON('email_inbox')
244 self
.ICON_DELETED
= ICON('camera_delete')
245 self
.ICON_UNPLAYED
= ICON('emblem-new')
246 self
.ICON_LOCKED
= ICON('emblem-readonly')
247 self
.ICON_MISSING
= ICON('emblem-unreadable')
248 self
.ICON_NEW
= gtk
.STOCK_ABOUT
250 normal_font
= style
.get_font_desc('SystemFont')
251 normal_color
= style
.get_color('DefaultTextColor')
252 normal
= (normal_font
.to_string(), normal_color
.to_string())
253 self
._normal
_markup
= '<span font_desc="%s" foreground="%s">%%s</span>' % normal
255 active_font
= style
.get_font_desc('SystemFont')
256 active_color
= style
.get_color('ActiveTextColor')
257 active
= (active_font
.to_string(), active_color
.to_string())
258 self
._active
_markup
= '<span font_desc="%s" foreground="%s">%%s</span>' % active
260 sub_font
= style
.get_font_desc('SmallSystemFont')
261 sub_color
= style
.get_color('SecondaryTextColor')
262 sub
= (sub_font
.to_string(), sub_color
.to_string())
263 sub
= '\n<span font_desc="%s" foreground="%s">%%s</span>' % sub
265 self
._unplayed
_markup
= self
._normal
_markup
+ sub
266 self
._active
_markup
+= sub
270 def _format_filesize(self
, episode
):
271 if episode
.length
> 0:
272 return util
.format_filesize(episode
.length
, 1)
277 def _filter_visible_func(self
, model
, iter):
278 # If searching is active, set visibility based on search text
279 if self
._search
_term
is not None:
280 key
= self
._search
_term
.lower()
281 return any((key
in (model
.get_value(iter, column
) or '').lower()) for column
in self
.SEARCH_COLUMNS
)
283 if self
._view
_mode
== self
.VIEW_ALL
:
285 elif self
._view
_mode
== self
.VIEW_UNDELETED
:
286 return model
.get_value(iter, self
.C_VIEW_SHOW_UNDELETED
)
287 elif self
._view
_mode
== self
.VIEW_DOWNLOADED
:
288 return model
.get_value(iter, self
.C_VIEW_SHOW_DOWNLOADED
)
289 elif self
._view
_mode
== self
.VIEW_UNPLAYED
:
290 return model
.get_value(iter, self
.C_VIEW_SHOW_UNPLAYED
)
294 def has_episodes(self
):
295 """Returns True if episodes are visible (filtered)
297 If episodes are visible with the current filter
298 applied, return True (otherwise return False).
301 # XXX: This must be kept in sync with the behaviour of _filter_visible_func
302 if self
._search
_term
is not None:
303 key
= self
._search
_term
.lower()
304 is_visible
= lambda episode
: key
in (episode
.title
or '').lower()
305 elif self
._view
_mode
== self
.VIEW_ALL
:
306 return bool(self
._episodes
)
307 elif self
._view
_mode
== self
.VIEW_UNDELETED
:
308 is_visible
= lambda episode
: episode
.state
!= gpodder
.STATE_DELETED
or \
309 self
._downloading
(episode
)
310 elif self
._view
_mode
== self
.VIEW_DOWNLOADED
:
311 is_visible
= lambda episode
: episode
.state
== gpodder
.STATE_DOWNLOADED
or \
312 (episode
.state
== gpodder
.STATE_NORMAL
and \
313 not episode
.is_played
) or \
314 self
._downloading
(episode
)
315 elif self
._view
_mode
== self
.VIEW_UNPLAYED
:
316 is_visible
= lambda episode
: (not episode
.is_played
and (episode
.state
in \
317 (gpodder
.STATE_DOWNLOADED
, gpodder
.STATE_NORMAL
))) or \
318 self
._downloading
(episode
)
320 log('Should never reach this in has_episodes()!', sender
=self
)
323 return any(is_visible(episode
) for episode
in self
._episodes
)
325 def get_filtered_model(self
):
326 """Returns a filtered version of this episode model
328 The filtered version should be displayed in the UI,
329 as this model can have some filters set that should
330 be reflected in the UI.
334 def set_view_mode(self
, new_mode
):
335 """Sets a new view mode for this model
337 After setting the view mode, the filtered model
338 might be updated to reflect the new mode."""
339 if self
._view
_mode
!= new_mode
:
340 self
._view
_mode
= new_mode
341 self
._filter
.refilter()
342 self
._on
_filter
_changed
(self
.has_episodes())
344 def get_view_mode(self
):
345 """Returns the currently-set view mode"""
346 return self
._view
_mode
348 def set_search_term(self
, new_term
):
349 if self
._search
_term
!= new_term
:
350 self
._search
_term
= new_term
351 self
._filter
.refilter()
352 self
._on
_filter
_changed
(self
.has_episodes())
354 def get_search_term(self
):
355 return self
._search
_term
358 def _format_description(self
, episode
):
359 if self
._downloading
(episode
):
360 sub
= _('in downloads list')
361 if self
._all
_episodes
_view
:
362 sub
= '; '.join((sub
, _('from %s') % cgi
.escape(episode
.channel
.title
,)))
363 return self
._unplayed
_markup
% (cgi
.escape(episode
.title
), sub
)
364 elif episode
.is_played
:
365 if self
._all
_episodes
_view
:
366 sub
= _('from %s') % cgi
.escape(episode
.channel
.title
,)
367 return self
._unplayed
_markup
% (cgi
.escape(episode
.title
), sub
)
369 return self
._normal
_markup
% (cgi
.escape(episode
.title
),)
371 if episode
.was_downloaded(and_exists
=True):
372 sub
= _('unplayed download')
374 sub
= _('new episode')
376 if self
._all
_episodes
_view
:
377 sub
= '; '.join((sub
, _('from %s') % cgi
.escape(episode
.channel
.title
,)))
379 return self
._active
_markup
% (cgi
.escape(episode
.title
), sub
)
382 count
= len(self
._episodes
)
383 for i
in reversed(range(count
)):
384 self
.emit('row-deleted', (i
,))
387 def replace_from_channel(self
, channel
, downloading
=None, \
388 include_description
=False, generate_thumbnails
=False):
390 Add episode from the given channel to this model.
391 Downloading should be a callback.
392 include_description should be a boolean value (True if description
393 is to be added to the episode row, or False if not)
396 self
._downloading
= downloading
397 self
._include
_description
= include_description
398 self
._generate
_thumbnails
= generate_thumbnails
400 self
._all
_episodes
_view
= getattr(channel
, 'ALL_EPISODES_PROXY', False)
402 old_length
= len(self
._episodes
)
403 self
._episodes
= channel
.get_all_episodes()
404 new_length
= len(self
._episodes
)
406 for i
in range(min(old_length
, new_length
)):
407 self
.emit('row-changed', (i
,), self
.create_tree_iter(i
))
409 if old_length
> new_length
:
410 for i
in reversed(range(new_length
, old_length
)):
411 self
.emit('row-deleted', (i
,))
412 elif old_length
< new_length
:
413 for i
in range(old_length
, new_length
):
414 self
.emit('row-inserted', (i
,), self
.create_tree_iter(i
))
416 self
._on
_filter
_changed
(self
.has_episodes())
419 def update_all(self
, downloading
=None, include_description
=False, \
420 generate_thumbnails
=False):
422 self
._downloading
= downloading
423 self
._include
_description
= include_description
424 self
._generate
_thumbnails
= generate_thumbnails
426 for i
in range(len(self
._episodes
)):
427 self
.emit('row-changed', (i
,), self
.create_tree_iter(i
))
429 def update_by_urls(self
, urls
, downloading
=None, include_description
=False, \
430 generate_thumbnails
=False):
432 self
._downloading
= downloading
433 self
._include
_description
= include_description
434 self
._generate
_thumbnails
= generate_thumbnails
436 for index
, episode
in enumerate(self
._episodes
):
437 if episode
.url
in urls
:
438 self
.emit('row-changed', (index
,), self
.create_tree_iter(index
))
440 def update_by_filter_iter(self
, iter, downloading
=None, \
441 include_description
=False, generate_thumbnails
=False):
442 # Convenience function for use by "outside" methods that use iters
443 # from the filtered episode list model (i.e. all UI things normally)
444 self
.update_by_iter(self
._filter
.convert_iter_to_child_iter(iter), \
445 downloading
, include_description
, generate_thumbnails
)
447 def update_by_iter(self
, iter, downloading
=None, include_description
=False, \
448 generate_thumbnails
=False, reload_from_db
=True):
450 self
._downloading
= downloading
451 self
._include
_description
= include_description
452 self
._generate
_thumbnails
= generate_thumbnails
454 index
= self
.get_user_data(iter)
455 episode
= self
._episodes
[index
]
457 episode
.reload_from_db()
459 self
.emit('row-changed', (index
,), self
.create_tree_iter(index
))
461 def _get_icon_from_image(self
,image_path
, icon_size
):
463 Load an local image file and transform it into an icon.
465 Return a pixbuf scaled to the desired size and may return None
466 if the icon creation is impossible (file not found etc).
468 if not os
.path
.exists(image_path
):
470 # load image from disc (code adapted from CoverDownloader
471 # except that no download is needed here)
472 loader
= gtk
.gdk
.PixbufLoader()
475 loader
.write(open(image_path
, 'rb').read())
477 pixbuf
= loader
.get_pixbuf()
479 log('Data error while loading image %s', image_path
, sender
=self
)
481 # Now scale the image with ratio (copied from _resize_pixbuf_keep_ratio)
483 if pixbuf
.get_width() > icon_size
:
484 f
= float(icon_size
)/pixbuf
.get_width()
485 (width
, height
) = (int(pixbuf
.get_width()*f
), int(pixbuf
.get_height()*f
))
486 pixbuf
= pixbuf
.scale_simple(width
, height
, gtk
.gdk
.INTERP_BILINEAR
)
488 if pixbuf
.get_height() > icon_size
:
489 f
= float(icon_size
)/pixbuf
.get_height()
490 (width
, height
) = (int(pixbuf
.get_width()*f
), int(pixbuf
.get_height()*f
))
491 pixbuf
= pixbuf
.scale_simple(width
, height
, gtk
.gdk
.INTERP_BILINEAR
)
495 def _get_tree_icon(self
, icon_name
, add_bullet
=False, \
496 add_padlock
=False, add_missing
=False, icon_size
=32, \
497 build_icon_from_file
= False):
499 Loads an icon from the current icon theme at the specified
500 size, suitable for display in a gtk.TreeView. Additional
501 emblems can be added on top of the icon.
503 Caching is used to speed up the icon lookup.
505 The `build_icon_from_file` argument indicates (when True) that
506 the icon has to be created on the fly from a given image
507 file. The `icon_name` argument is then interpreted as the path
508 to this file. Those specific icons will *not be cached*.
511 # Add all variables that modify the appearance of the icon, so
512 # our cache does not return the same icons for different requests
513 cache_id
= (icon_name
, add_bullet
, add_padlock
, add_missing
, icon_size
)
515 if cache_id
in self
._icon
_cache
:
516 return self
._icon
_cache
[cache_id
]
518 icon_theme
= gtk
.icon_theme_get_default()
521 if build_icon_from_file
:
522 icon
= self
._get
_icon
_from
_image
(icon_name
,icon_size
)
524 icon
= icon_theme
.load_icon(icon_name
, icon_size
, 0)
527 log('Missing icon in theme: %s', icon_name
, sender
=self
)
528 icon
= icon_theme
.load_icon(gtk
.STOCK_DIALOG_QUESTION
, \
531 log('Please install the GNOME icon theme.', sender
=self
)
532 icon
= gtk
.gdk
.Pixbuf(gtk
.gdk
.COLORSPACE_RGB
, \
533 True, 8, icon_size
, icon_size
)
535 if icon
and (add_bullet
or add_padlock
or add_missing
):
536 # We'll modify the icon, so use .copy()
540 # Desaturate the icon so it looks even more "missing"
541 icon
.saturate_and_pixelate(icon
, 0.0, False)
542 emblem
= icon_theme
.load_icon(self
.ICON_MISSING
, icon_size
/2, 0)
543 (width
, height
) = (emblem
.get_width(), emblem
.get_height())
544 xpos
= icon
.get_width() - width
545 ypos
= icon
.get_height() - height
546 emblem
.composite(icon
, xpos
, ypos
, width
, height
, xpos
, ypos
, 1, 1, gtk
.gdk
.INTERP_BILINEAR
, 255)
552 emblem
= icon_theme
.load_icon(self
.ICON_UNPLAYED
, icon_size
/2, 0)
553 (width
, height
) = (emblem
.get_width(), emblem
.get_height())
554 xpos
= icon
.get_width() - width
555 ypos
= icon
.get_height() - height
556 emblem
.composite(icon
, xpos
, ypos
, width
, height
, xpos
, ypos
, 1, 1, gtk
.gdk
.INTERP_BILINEAR
, 255)
562 emblem
= icon_theme
.load_icon(self
.ICON_LOCKED
, icon_size
/2, 0)
563 (width
, height
) = (emblem
.get_width(), emblem
.get_height())
564 emblem
.composite(icon
, 0, 0, width
, height
, 0, 0, 1, 1, gtk
.gdk
.INTERP_BILINEAR
, 255)
568 self
._icon
_cache
[cache_id
] = icon
573 class PodcastListModel(model
.PodcastListModel
):
574 def __init__(self
, *args
):
575 model
.PodcastListModel
.__init
__(self
, *args
)
577 normal_font
= style
.get_font_desc('SystemFont')
578 normal_color
= style
.get_color('DefaultTextColor')
579 normal
= (normal_font
.to_string(), normal_color
.to_string())
580 self
._normal
_markup
= '<span font_desc="%s" foreground="%s">%%s</span>' % normal
582 active_font
= style
.get_font_desc('SystemFont')
583 active_color
= style
.get_color('ActiveTextColor')
584 active
= (active_font
.to_string(), active_color
.to_string())
585 self
._active
_markup
= '<span font_desc="%s" foreground="%s">%%s</span>' % active
587 sub_font
= style
.get_font_desc('SmallSystemFont')
588 sub_color
= style
.get_color('SecondaryTextColor')
589 sub
= (sub_font
.to_string(), sub_color
.to_string())
590 sub
= '\n<span font_desc="%s" foreground="%s">%%s</span>' % sub
592 self
._unplayed
_markup
= self
._normal
_markup
+ sub
593 self
._active
_markup
+= sub
595 def _format_description(self
, channel
, total
, deleted
, \
596 new
, downloaded
, unplayed
):
597 title_markup
= cgi
.escape(channel
.title
)
598 if not channel
.feed_update_enabled
:
599 disabled_text
= cgi
.escape(_('Subscription paused'))
601 return self
._active
_markup
% (title_markup
, disabled_text
)
603 return self
._unplayed
_markup
% (title_markup
, disabled_text
)
604 if not unplayed
and not new
:
605 return self
._normal
_markup
% title_markup
607 new_text
= N_('%(count)d new episode', '%(count)d new episodes', new
) % {'count':new
}
608 unplayed_text
= N_('%(count)d unplayed download', '%(count)d unplayed downloads', unplayed
) % {'count':unplayed
}
610 return self
._active
_markup
% (title_markup
, ', '.join((new_text
, unplayed_text
)))
612 return self
._active
_markup
% (title_markup
, new_text
)
614 return self
._unplayed
_markup
% (title_markup
, unplayed_text
)