Cleaned-up and re-designed preferences dialog
[gpodder.git] / src / gpodder / gui.py
blobb28befb0f7d0019d168d3425b73382eb1dcea7c4
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/>.
20 import os
21 import cgi
22 import gtk
23 import gtk.gdk
24 import gobject
25 import pango
26 import sys
27 import shutil
28 import subprocess
29 import glob
30 import time
31 import urllib
32 import urllib2
33 import tempfile
34 import collections
35 import threading
37 from xml.sax import saxutils
39 import gpodder
41 try:
42 import dbus
43 import dbus.service
44 import dbus.mainloop
45 import dbus.glib
46 except ImportError:
47 # Mock the required D-Bus interfaces with no-ops (ugly? maybe.)
48 class dbus:
49 class SessionBus:
50 def __init__(self, *args, **kwargs):
51 pass
52 class glib:
53 class DBusGMainLoop:
54 pass
55 class service:
56 @staticmethod
57 def method(*args, **kwargs):
58 return lambda x: x
59 class BusName:
60 def __init__(self, *args, **kwargs):
61 pass
62 class Object:
63 def __init__(self, *args, **kwargs):
64 pass
67 from gpodder import feedcore
68 from gpodder import util
69 from gpodder import opml
70 from gpodder import download
71 from gpodder import my
72 from gpodder.liblogger import log
74 _ = gpodder.gettext
75 N_ = gpodder.ngettext
77 from gpodder.model import PodcastChannel
78 from gpodder.model import PodcastEpisode
79 from gpodder.dbsqlite import Database
81 from gpodder.gtkui.model import PodcastListModel
82 from gpodder.gtkui.model import EpisodeListModel
83 from gpodder.gtkui.config import UIConfig
84 from gpodder.gtkui.services import CoverDownloader
85 from gpodder.gtkui.widgets import SimpleMessageArea
86 from gpodder.gtkui.desktopfile import UserAppsReader
88 from gpodder.gtkui.draw import draw_text_box_centered
90 from gpodder.gtkui.interface.common import BuilderWidget
91 from gpodder.gtkui.interface.common import TreeViewHelper
92 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
93 from gpodder.gtkui.mygpodder import MygPodderSettings
95 if gpodder.ui.desktop:
96 from gpodder.gtkui.download import DownloadStatusModel
98 from gpodder.gtkui.desktop.sync import gPodderSyncUI
100 from gpodder.gtkui.desktop.channel import gPodderChannel
101 from gpodder.gtkui.desktop.preferences import gPodderPreferences
102 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
103 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
104 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
105 from gpodder.gtkui.desktop.dependencymanager import gPodderDependencyManager
106 try:
107 from gpodder.gtkui.desktop.trayicon import GPodderStatusIcon
108 have_trayicon = True
109 except Exception, exc:
110 log('Warning: Could not import gpodder.trayicon.', traceback=True)
111 log('Warning: This probably means your PyGTK installation is too old!')
112 have_trayicon = False
113 elif gpodder.ui.diablo:
114 from gpodder.gtkui.download import DownloadStatusModel
116 from gpodder.gtkui.maemo.channel import gPodderChannel
117 from gpodder.gtkui.maemo.preferences import gPodderPreferences
118 from gpodder.gtkui.maemo.shownotes import gPodderShownotes
119 from gpodder.gtkui.maemo.episodeselector import gPodderEpisodeSelector
120 from gpodder.gtkui.maemo.podcastdirectory import gPodderPodcastDirectory
121 have_trayicon = False
122 elif gpodder.ui.fremantle:
123 from gpodder.gtkui.frmntl.model import DownloadStatusModel
124 from gpodder.gtkui.frmntl.model import EpisodeListModel
125 from gpodder.gtkui.frmntl.model import PodcastListModel
127 from gpodder.gtkui.maemo.channel import gPodderChannel
128 from gpodder.gtkui.frmntl.preferences import gPodderPreferences
129 from gpodder.gtkui.frmntl.shownotes import gPodderShownotes
130 from gpodder.gtkui.frmntl.episodeselector import gPodderEpisodeSelector
131 from gpodder.gtkui.frmntl.podcastdirectory import gPodderPodcastDirectory
132 from gpodder.gtkui.frmntl.episodes import gPodderEpisodes
133 from gpodder.gtkui.frmntl.downloads import gPodderDownloads
134 have_trayicon = False
136 from gpodder.gtkui.frmntl.portrait import FremantleRotation
138 from gpodder.gtkui.interface.common import Orientation
140 from gpodder.gtkui.interface.welcome import gPodderWelcome
141 from gpodder.gtkui.interface.progress import ProgressIndicator
143 if gpodder.ui.maemo:
144 import hildon
146 from gpodder.dbusproxy import DBusPodcastsProxy
148 class gPodder(BuilderWidget, dbus.service.Object):
149 finger_friendly_widgets = ['btnCleanUpDownloads', 'button_search_episodes_clear']
151 ICON_GENERAL_ADD = 'general_add'
152 ICON_GENERAL_REFRESH = 'general_refresh'
153 ICON_GENERAL_CLOSE = 'general_close'
155 def __init__(self, bus_name, config):
156 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
157 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels, \
158 self.on_itemUpdate_activate, \
159 self.playback_episodes, \
160 self.download_episode_list, \
161 bus_name)
162 self.db = Database(gpodder.database_file)
163 self.config = config
164 BuilderWidget.__init__(self, None)
166 def new(self):
167 if gpodder.ui.diablo:
168 import hildon
169 self.app = hildon.Program()
170 self.app.add_window(self.main_window)
171 self.main_window.add_toolbar(self.toolbar)
172 menu = gtk.Menu()
173 for child in self.main_menu.get_children():
174 child.reparent(menu)
175 self.main_window.set_menu(self.set_finger_friendly(menu))
176 self.bluetooth_available = False
177 elif gpodder.ui.fremantle:
178 import hildon
179 self.app = hildon.Program()
180 self.app.add_window(self.main_window)
182 appmenu = hildon.AppMenu()
184 for filter in (self.item_view_podcasts_all, \
185 self.item_view_podcasts_downloaded, \
186 self.item_view_podcasts_unplayed):
187 button = gtk.ToggleButton()
188 filter.connect_proxy(button)
189 appmenu.add_filter(button)
191 for action in (self.itemPreferences, \
192 self.item_downloads, \
193 self.itemRemoveOldEpisodes, \
194 self.item_unsubscribe, \
195 self.item_support, \
196 self.item_report_bug):
197 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
198 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
199 action.connect_proxy(button)
200 if action == self.item_downloads:
201 button.set_title(_('Downloads'))
202 button.set_value(_('Idle'))
203 self.button_downloads = button
204 appmenu.append(button)
205 appmenu.show_all()
206 self.main_window.set_app_menu(appmenu)
208 # Initialize portrait mode / rotation manager
209 self._fremantle_rotation = FremantleRotation('gPodder', \
210 self.main_window, \
211 gpodder.__version__, \
212 self.config.rotation_mode)
214 if self.config.rotation_mode == FremantleRotation.ALWAYS:
215 util.idle_add(self.on_window_orientation_changed, \
216 Orientation.PORTRAIT)
217 self._last_orientation = Orientation.PORTRAIT
218 else:
219 self._last_orientation = Orientation.LANDSCAPE
221 self.bluetooth_available = False
222 else:
223 self._last_orientation = Orientation.LANDSCAPE
224 self.bluetooth_available = util.bluetooth_available()
225 self.toolbar.set_property('visible', self.config.show_toolbar)
227 self.config.connect_gtk_window(self.gPodder, 'main_window')
228 if not gpodder.ui.fremantle:
229 self.config.connect_gtk_paned('paned_position', self.channelPaned)
230 self.main_window.show()
232 self.gPodder.connect('key-press-event', self.on_key_press)
234 self.preferences_dialog = None
235 self.config.add_observer(self.on_config_changed)
237 self.tray_icon = None
238 self.episode_shownotes_window = None
239 self.new_episodes_window = None
241 if gpodder.ui.desktop:
242 self.sync_ui = gPodderSyncUI(self.config, self.notification, \
243 self.main_window, self.show_confirmation, \
244 self.update_episode_list_icons, \
245 self.update_podcast_list_model, self.toolPreferences, \
246 gPodderEpisodeSelector, \
247 self.commit_changes_to_database)
248 else:
249 self.sync_ui = None
251 self.download_status_model = DownloadStatusModel()
252 self.download_queue_manager = download.DownloadQueueManager(self.config)
254 if gpodder.ui.desktop:
255 self.show_hide_tray_icon()
256 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
257 self.itemShowToolbar.set_active(self.config.show_toolbar)
258 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
260 if not gpodder.ui.fremantle:
261 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
262 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
263 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
264 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
266 # When the amount of maximum downloads changes, notify the queue manager
267 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
268 self.spinMaxDownloads.connect('value-changed', changed_cb)
270 self.default_title = 'gPodder'
271 if gpodder.__version__.rfind('git') != -1:
272 self.set_title('gPodder %s' % gpodder.__version__)
273 else:
274 title = self.gPodder.get_title()
275 if title is not None:
276 self.set_title(title)
277 else:
278 self.set_title(_('gPodder'))
280 self.cover_downloader = CoverDownloader()
282 # Generate list models for podcasts and their episodes
283 self.podcast_list_model = PodcastListModel(self.cover_downloader)
285 self.cover_downloader.register('cover-available', self.cover_download_finished)
286 self.cover_downloader.register('cover-removed', self.cover_file_removed)
288 if gpodder.ui.fremantle:
289 # Work around Maemo bug #4718
290 self.button_refresh.set_name('HildonButton-finger')
291 self.button_subscribe.set_name('HildonButton-finger')
293 self.button_refresh.set_sensitive(False)
294 self.button_subscribe.set_sensitive(False)
296 self.button_subscribe.set_image(gtk.image_new_from_icon_name(\
297 self.ICON_GENERAL_ADD, gtk.ICON_SIZE_BUTTON))
298 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
299 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
301 # Make the button scroll together with the TreeView contents
302 action_area_box = self.treeChannels.get_action_area_box()
303 for child in self.buttonbox:
304 child.reparent(action_area_box)
305 self.vbox.remove(self.buttonbox)
306 action_area_box.set_spacing(2)
307 action_area_box.set_border_width(3)
308 self.treeChannels.set_action_area_visible(True)
310 from gpodder.gtkui.frmntl import style
311 sub_font = style.get_font_desc('SmallSystemFont')
312 sub_color = style.get_color('SecondaryTextColor')
313 sub = (sub_font.to_string(), sub_color.to_string())
314 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
315 self.label_footer.set_markup(sub % gpodder.__copyright__)
317 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
318 while gtk.events_pending():
319 gtk.main_iteration(False)
321 try:
322 # Try to get the real package version from dpkg
323 p = subprocess.Popen(['dpkg-query', '-W', '-f=${Version}', 'gpodder'], stdout=subprocess.PIPE)
324 version, _stderr = p.communicate()
325 del _stderr
326 del p
327 except:
328 version = gpodder.__version__
329 self.label_footer.set_markup(sub % ('v %s' % version))
330 self.label_footer.hide()
332 self.episodes_window = gPodderEpisodes(self.main_window, \
333 on_treeview_expose_event=self.on_treeview_expose_event, \
334 show_episode_shownotes=self.show_episode_shownotes, \
335 update_podcast_list_model=self.update_podcast_list_model, \
336 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
337 item_view_episodes_all=self.item_view_episodes_all, \
338 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
339 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
340 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
341 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
342 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
343 hide_episode_search=self.hide_episode_search, \
344 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
345 playback_episodes=self.playback_episodes, \
346 delete_episode_list=self.delete_episode_list, \
347 episode_list_status_changed=self.episode_list_status_changed, \
348 download_episode_list=self.download_episode_list, \
349 episode_is_downloading=self.episode_is_downloading, \
350 show_episode_in_download_manager=self.show_episode_in_download_manager, \
351 add_download_task_monitor=self.add_download_task_monitor, \
352 remove_download_task_monitor=self.remove_download_task_monitor, \
353 for_each_episode_set_task_status=self.for_each_episode_set_task_status)
355 # Expose objects for episode list type-ahead find
356 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
357 self.entry_search_episodes = self.episodes_window.entry_search_episodes
358 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
360 self.downloads_window = gPodderDownloads(self.main_window, \
361 on_treeview_expose_event=self.on_treeview_expose_event, \
362 on_btnCleanUpDownloads_clicked=self.on_btnCleanUpDownloads_clicked, \
363 _for_each_task_set_status=self._for_each_task_set_status, \
364 downloads_list_get_selection=self.downloads_list_get_selection, \
365 _config=self.config)
367 self.treeAvailable = self.episodes_window.treeview
368 self.treeDownloads = self.downloads_window.treeview
370 # Init the treeviews that we use
371 self.init_podcast_list_treeview()
372 self.init_episode_list_treeview()
373 self.init_download_list_treeview()
375 if self.config.podcast_list_hide_boring:
376 self.item_view_hide_boring_podcasts.set_active(True)
378 self.currently_updating = False
380 if gpodder.ui.maemo:
381 self.context_menu_mouse_button = 1
382 else:
383 self.context_menu_mouse_button = 3
385 if self.config.start_iconified:
386 self.iconify_main_window()
388 self.download_tasks_seen = set()
389 self.download_list_update_enabled = False
390 self.last_download_count = 0
391 self.download_task_monitors = set()
393 # Subscribed channels
394 self.active_channel = None
395 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
396 self.channel_list_changed = True
397 self.update_podcasts_tab()
399 # load list of user applications for audio playback
400 self.user_apps_reader = UserAppsReader(['audio', 'video'])
401 threading.Thread(target=self.user_apps_reader.read).start()
403 # Set the "Device" menu item for the first time
404 if gpodder.ui.desktop:
405 self.update_item_device()
407 # Set up the first instance of MygPoClient
408 self.mygpo_client = my.MygPoClient(self.config)
410 # Now, update the feed cache, when everything's in place
411 if not gpodder.ui.fremantle:
412 self.btnUpdateFeeds.show()
413 self.updating_feed_cache = False
414 self.feed_cache_update_cancelled = False
415 self.update_feed_cache(force_update=self.config.update_on_startup)
417 # Look for partial file downloads
418 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
420 # Message area
421 self.message_area = None
423 resumable_episodes = []
424 if len(partial_files) > 0:
425 for f in partial_files:
426 correct_name = f[:-len('.partial')] # strip ".partial"
427 log('Searching episode for file: %s', correct_name, sender=self)
428 found_episode = False
429 for c in self.channels:
430 for e in c.get_all_episodes():
431 if e.local_filename(create=False, check_only=True) == correct_name:
432 log('Found episode: %s', e.title, sender=self)
433 resumable_episodes.append(e)
434 found_episode = True
435 if found_episode:
436 break
437 if found_episode:
438 break
439 if not found_episode:
440 log('Partial file without episode: %s', f, sender=self)
441 util.delete_file(f)
443 if len(resumable_episodes):
444 self.download_episode_list_paused(resumable_episodes)
445 if not gpodder.ui.fremantle:
446 self.message_area = SimpleMessageArea(_('There are unfinished downloads from your last session.\nPick the ones you want to continue downloading.'))
447 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
448 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
449 self.message_area.show_all()
450 self.wNotebook.set_current_page(1)
452 self.clean_up_downloads(delete_partial=False)
453 else:
454 self.clean_up_downloads(delete_partial=True)
456 # Start the auto-update procedure
457 self._auto_update_timer_source_id = None
458 if self.config.auto_update_feeds:
459 self.restart_auto_update_timer()
461 # Connect the auto cleanup button to the configuration
462 if gpodder.ui.desktop or gpodder.ui.diablo:
463 self.config.connect_gtk_togglebutton('auto_cleanup_downloads', \
464 self.btnCleanUpDownloads)
466 # Delete old episodes if the user wishes to
467 if self.config.auto_remove_played_episodes and \
468 self.config.episode_old_age > 0:
469 old_episodes = list(self.get_expired_episodes())
470 if len(old_episodes) > 0:
471 self.delete_episode_list(old_episodes, confirm=False)
472 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
474 if gpodder.ui.fremantle:
475 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
476 self.button_refresh.set_sensitive(True)
477 self.button_subscribe.set_sensitive(True)
478 self.main_window.set_title(_('gPodder'))
479 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
481 # Do the initial sync with the web service
482 util.idle_add(self.mygpo_client.flush, True)
484 # First-time users should be asked if they want to see the OPML
485 if not self.channels and not gpodder.ui.fremantle:
486 util.idle_add(self.on_itemUpdate_activate)
488 def on_add_remove_podcasts_mygpo(self):
489 actions = self.mygpo_client.get_received_actions()
490 if not actions:
491 return False
493 existing_urls = [c.url for c in self.channels]
495 # Columns for the episode selector window - just one...
496 columns = (
497 ('description', None, None, _('Action')),
500 # A list of actions that have to be chosen from
501 changes = []
503 # Actions that are ignored (already carried out)
504 ignored = []
506 for action in actions:
507 if action.is_add and action.url not in existing_urls:
508 changes.append(my.Change(action))
509 elif action.is_remove and action.url in existing_urls:
510 podcast_object = None
511 for podcast in self.channels:
512 if podcast.url == action.url:
513 podcast_object = podcast
514 break
515 changes.append(my.Change(action, podcast_object))
516 else:
517 log('Ignoring action: %s', action, sender=self)
518 ignored.append(action)
520 # Confirm all ignored changes
521 self.mygpo_client.confirm_received_actions(ignored)
523 def execute_podcast_actions(selected):
524 add_list = [c.action.url for c in selected if c.action.is_add]
525 remove_list = [c.podcast for c in selected if c.action.is_remove]
527 # Apply the accepted changes locally
528 self.add_podcast_list(add_list)
529 self.remove_podcast_list(remove_list, confirm=False)
531 # All selected items are now confirmed
532 self.mygpo_client.confirm_received_actions(c.action for c in selected)
534 # Revert the changes on the server
535 rejected = [c.action for c in changes if c not in selected]
536 self.mygpo_client.reject_received_actions(rejected)
538 def ask():
539 # We're abusing the Episode Selector again ;) -- thp
540 gPodderEpisodeSelector(self.main_window, \
541 title=_('Confirm changes from my.gpodder.org'), \
542 instructions=_('Select the actions you want to carry out.'), \
543 episodes=changes, \
544 columns=columns, \
545 size_attribute=None, \
546 stock_ok_button=gtk.STOCK_APPLY, \
547 callback=execute_podcast_actions, \
548 _config=self.config)
550 # There are some actions that need the user's attention
551 if changes:
552 util.idle_add(ask)
553 return True
555 # We have no remaining actions - no selection happens
556 return False
558 def rewrite_urls_mygpo(self):
559 # Check if we have to rewrite URLs since the last add
560 rewritten_urls = self.mygpo_client.get_rewritten_urls()
562 for rewritten_url in rewritten_urls:
563 if not rewritten_url.new_url:
564 continue
566 for channel in self.channels:
567 if channel.url == rewritten_url.old_url:
568 log('Updating URL of %s to %s', channel, \
569 rewritten_url.new_url, sender=self)
570 channel.url = rewritten_url.new_url
571 channel.save()
572 self.channel_list_changed = True
573 util.idle_add(self.update_episode_list_model)
574 break
576 def on_send_full_subscriptions(self):
577 # Send the full subscription list to the my.gpodder.org client
578 # (this will overwrite the subscription list on the server)
579 indicator = ProgressIndicator(_('Uploading subscriptions'), \
580 _('Your subscriptions are being uploaded to the server.'), \
581 False, self.main_window)
583 try:
584 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
585 util.idle_add(self.show_message, _('List uploaded successfully.'))
586 except Exception, e:
587 def show_error(e):
588 message = str(e)
589 if not message:
590 message = e.__class__.__name__
591 self.show_message(message, \
592 _('Error while uploading'), \
593 important=True)
594 util.idle_add(show_error, e)
596 util.idle_add(indicator.on_finished)
598 def on_podcast_selected(self, treeview, path, column):
599 # for Maemo 5's UI
600 model = treeview.get_model()
601 channel = model.get_value(model.get_iter(path), \
602 PodcastListModel.C_CHANNEL)
603 self.active_channel = channel
604 self.update_episode_list_model()
605 self.episodes_window.channel = self.active_channel
606 self.episodes_window.show()
608 def on_button_subscribe_clicked(self, button):
609 self.on_itemImportChannels_activate(button)
611 def on_button_downloads_clicked(self, widget):
612 self.downloads_window.show()
614 def show_episode_in_download_manager(self, episode):
615 self.downloads_window.show()
616 model = self.treeDownloads.get_model()
617 selection = self.treeDownloads.get_selection()
618 selection.unselect_all()
619 it = model.get_iter_first()
620 while it is not None:
621 task = model.get_value(it, DownloadStatusModel.C_TASK)
622 if task.episode.url == episode.url:
623 selection.select_iter(it)
624 # FIXME: Scroll to selection in pannable area
625 break
626 it = model.iter_next(it)
628 def for_each_episode_set_task_status(self, episodes, status):
629 episode_urls = set(episode.url for episode in episodes)
630 model = self.treeDownloads.get_model()
631 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
632 model.get_value(row.iter, \
633 DownloadStatusModel.C_TASK)) for row in model \
634 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
635 in episode_urls]
636 self._for_each_task_set_status(selected_tasks, status)
638 def on_window_orientation_changed(self, orientation):
639 self._last_orientation = orientation
640 if self.preferences_dialog is not None:
641 self.preferences_dialog.on_window_orientation_changed(orientation)
643 treeview = self.treeChannels
644 if orientation == Orientation.PORTRAIT:
645 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
646 # Work around Maemo bug #4718
647 self.button_subscribe.set_name('HildonButton-thumb')
648 self.button_refresh.set_name('HildonButton-thumb')
649 else:
650 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
651 # Work around Maemo bug #4718
652 self.button_subscribe.set_name('HildonButton-finger')
653 self.button_refresh.set_name('HildonButton-finger')
655 def on_treeview_podcasts_selection_changed(self, selection):
656 model, iter = selection.get_selected()
657 if iter is None:
658 self.active_channel = None
659 self.episode_list_model.clear()
661 def on_treeview_button_pressed(self, treeview, event):
662 if event.window != treeview.get_bin_window():
663 return False
665 TreeViewHelper.save_button_press_event(treeview, event)
667 if getattr(treeview, TreeViewHelper.ROLE) == \
668 TreeViewHelper.ROLE_PODCASTS:
669 return self.currently_updating
671 return event.button == self.context_menu_mouse_button and \
672 gpodder.ui.desktop
674 def on_treeview_podcasts_button_released(self, treeview, event):
675 if event.window != treeview.get_bin_window():
676 return False
678 if gpodder.ui.maemo:
679 return self.treeview_channels_handle_gestures(treeview, event)
680 return self.treeview_channels_show_context_menu(treeview, event)
682 def on_treeview_episodes_button_released(self, treeview, event):
683 if event.window != treeview.get_bin_window():
684 return False
686 if gpodder.ui.maemo:
687 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
688 return self.treeview_available_handle_gestures(treeview, event)
690 return self.treeview_available_show_context_menu(treeview, event)
692 def on_treeview_downloads_button_released(self, treeview, event):
693 if event.window != treeview.get_bin_window():
694 return False
696 return self.treeview_downloads_show_context_menu(treeview, event)
698 def on_entry_search_podcasts_changed(self, editable):
699 if self.hbox_search_podcasts.get_property('visible'):
700 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
702 def on_entry_search_podcasts_key_press(self, editable, event):
703 if event.keyval == gtk.keysyms.Escape:
704 self.hide_podcast_search()
705 return True
707 def hide_podcast_search(self, *args):
708 self.hbox_search_podcasts.hide()
709 self.entry_search_podcasts.set_text('')
710 self.podcast_list_model.set_search_term(None)
711 self.treeChannels.grab_focus()
713 def show_podcast_search(self, input_char):
714 self.hbox_search_podcasts.show()
715 self.entry_search_podcasts.insert_text(input_char, -1)
716 self.entry_search_podcasts.grab_focus()
717 self.entry_search_podcasts.set_position(-1)
719 def init_podcast_list_treeview(self):
720 # Set up podcast channel tree view widget
721 if gpodder.ui.fremantle:
722 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
723 self.item_view_podcasts_downloaded.set_active(True)
724 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
725 self.item_view_podcasts_unplayed.set_active(True)
726 else:
727 self.item_view_podcasts_all.set_active(True)
728 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
730 iconcolumn = gtk.TreeViewColumn('')
731 iconcell = gtk.CellRendererPixbuf()
732 iconcolumn.pack_start(iconcell, False)
733 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
734 self.treeChannels.append_column(iconcolumn)
736 namecolumn = gtk.TreeViewColumn('')
737 namecell = gtk.CellRendererText()
738 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
739 namecolumn.pack_start(namecell, True)
740 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
742 iconcell = gtk.CellRendererPixbuf()
743 iconcell.set_property('xalign', 1.0)
744 namecolumn.pack_start(iconcell, False)
745 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
746 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
747 self.treeChannels.append_column(namecolumn)
749 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
751 # When no podcast is selected, clear the episode list model
752 selection = self.treeChannels.get_selection()
753 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
755 # Set up type-ahead find for the podcast list
756 def on_key_press(treeview, event):
757 if event.keyval == gtk.keysyms.Escape:
758 self.hide_podcast_search()
759 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
760 self.hide_podcast_search()
761 elif event.state & gtk.gdk.CONTROL_MASK:
762 # Don't handle type-ahead when control is pressed (so shortcuts
763 # with the Ctrl key still work, e.g. Ctrl+A, ...)
764 return True
765 else:
766 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
767 if unicode_char_id == 0:
768 return False
769 input_char = unichr(unicode_char_id)
770 self.show_podcast_search(input_char)
771 return True
772 self.treeChannels.connect('key-press-event', on_key_press)
774 # Enable separators to the podcast list to separate special podcasts
775 # from others (this is used for the "all episodes" view)
776 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
778 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
780 def on_entry_search_episodes_changed(self, editable):
781 if self.hbox_search_episodes.get_property('visible'):
782 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
784 def on_entry_search_episodes_key_press(self, editable, event):
785 if event.keyval == gtk.keysyms.Escape:
786 self.hide_episode_search()
787 return True
789 def hide_episode_search(self, *args):
790 self.hbox_search_episodes.hide()
791 self.entry_search_episodes.set_text('')
792 self.episode_list_model.set_search_term(None)
793 self.treeAvailable.grab_focus()
795 def show_episode_search(self, input_char):
796 self.hbox_search_episodes.show()
797 self.entry_search_episodes.insert_text(input_char, -1)
798 self.entry_search_episodes.grab_focus()
799 self.entry_search_episodes.set_position(-1)
801 def init_episode_list_treeview(self):
802 # For loading the list model
803 self.empty_episode_list_model = EpisodeListModel()
804 self.episode_list_model = EpisodeListModel()
806 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
807 self.item_view_episodes_undeleted.set_active(True)
808 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
809 self.item_view_episodes_downloaded.set_active(True)
810 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
811 self.item_view_episodes_unplayed.set_active(True)
812 else:
813 self.item_view_episodes_all.set_active(True)
815 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
817 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
819 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
821 iconcell = gtk.CellRendererPixbuf()
822 if gpodder.ui.maemo:
823 iconcell.set_fixed_size(50, 50)
824 status_column_label = ''
825 else:
826 status_column_label = _('Status')
827 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
829 namecell = gtk.CellRendererText()
830 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
831 namecolumn = gtk.TreeViewColumn(_('Episode'), namecell, markup=EpisodeListModel.C_DESCRIPTION)
832 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
833 namecolumn.set_resizable(True)
834 namecolumn.set_expand(True)
836 sizecell = gtk.CellRendererText()
837 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
839 releasecell = gtk.CellRendererText()
840 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
842 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
843 itemcolumn.set_reorderable(True)
844 self.treeAvailable.append_column(itemcolumn)
846 if gpodder.ui.maemo:
847 sizecolumn.set_visible(False)
848 releasecolumn.set_visible(False)
850 # Set up type-ahead find for the episode list
851 def on_key_press(treeview, event):
852 if event.keyval == gtk.keysyms.Escape:
853 self.hide_episode_search()
854 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
855 self.hide_episode_search()
856 elif event.state & gtk.gdk.CONTROL_MASK:
857 # Don't handle type-ahead when control is pressed (so shortcuts
858 # with the Ctrl key still work, e.g. Ctrl+A, ...)
859 return False
860 else:
861 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
862 if unicode_char_id == 0:
863 return False
864 input_char = unichr(unicode_char_id)
865 self.show_episode_search(input_char)
866 return True
867 self.treeAvailable.connect('key-press-event', on_key_press)
869 if gpodder.ui.desktop:
870 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
871 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
872 def drag_data_get(tree, context, selection_data, info, timestamp):
873 if self.config.on_drag_mark_played:
874 for episode in self.get_selected_episodes():
875 episode.mark(is_played=True)
876 self.on_selected_episodes_status_changed()
877 uris = ['file://'+e.local_filename(create=False) \
878 for e in self.get_selected_episodes() \
879 if e.was_downloaded(and_exists=True)]
880 uris.append('') # for the trailing '\r\n'
881 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
882 self.treeAvailable.connect('drag-data-get', drag_data_get)
884 selection = self.treeAvailable.get_selection()
885 if gpodder.ui.diablo:
886 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
887 selection.set_mode(gtk.SELECTION_SINGLE)
888 else:
889 selection.set_mode(gtk.SELECTION_MULTIPLE)
890 elif gpodder.ui.fremantle:
891 selection.set_mode(gtk.SELECTION_SINGLE)
892 else:
893 selection.set_mode(gtk.SELECTION_MULTIPLE)
894 # Update the sensitivity of the toolbar buttons on the Desktop
895 selection.connect('changed', lambda s: self.play_or_download())
897 if gpodder.ui.diablo:
898 # Set up the tap-and-hold context menu for podcasts
899 menu = gtk.Menu()
900 menu.append(self.itemUpdateChannel.create_menu_item())
901 menu.append(self.itemEditChannel.create_menu_item())
902 menu.append(gtk.SeparatorMenuItem())
903 menu.append(self.itemRemoveChannel.create_menu_item())
904 menu.append(gtk.SeparatorMenuItem())
905 item = gtk.ImageMenuItem(_('Close this menu'))
906 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
907 gtk.ICON_SIZE_MENU))
908 menu.append(item)
909 menu.show_all()
910 menu = self.set_finger_friendly(menu)
911 self.treeChannels.tap_and_hold_setup(menu)
914 def init_download_list_treeview(self):
915 # enable multiple selection support
916 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
917 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
919 # columns and renderers for "download progress" tab
920 # First column: [ICON] Episodename
921 column = gtk.TreeViewColumn(_('Episode'))
923 cell = gtk.CellRendererPixbuf()
924 if gpodder.ui.maemo:
925 cell.set_fixed_size(50, 50)
926 cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
927 column.pack_start(cell, expand=False)
928 column.add_attribute(cell, 'stock-id', \
929 DownloadStatusModel.C_ICON_NAME)
931 cell = gtk.CellRendererText()
932 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
933 column.pack_start(cell, expand=True)
934 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
935 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
936 column.set_expand(True)
937 self.treeDownloads.append_column(column)
939 # Second column: Progress
940 cell = gtk.CellRendererProgress()
941 cell.set_property('yalign', .5)
942 cell.set_property('ypad', 6)
943 column = gtk.TreeViewColumn(_('Progress'), cell,
944 value=DownloadStatusModel.C_PROGRESS, \
945 text=DownloadStatusModel.C_PROGRESS_TEXT)
946 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
947 column.set_expand(False)
948 self.treeDownloads.append_column(column)
949 column.set_property('min-width', 150)
950 column.set_property('max-width', 150)
952 self.treeDownloads.set_model(self.download_status_model)
953 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
955 def on_treeview_expose_event(self, treeview, event):
956 if event.window == treeview.get_bin_window():
957 model = treeview.get_model()
958 if (model is not None and model.get_iter_first() is not None):
959 return False
961 role = getattr(treeview, TreeViewHelper.ROLE)
962 ctx = event.window.cairo_create()
963 ctx.rectangle(event.area.x, event.area.y,
964 event.area.width, event.area.height)
965 ctx.clip()
967 x, y, width, height, depth = event.window.get_geometry()
968 progress = None
970 if role == TreeViewHelper.ROLE_EPISODES:
971 if self.currently_updating:
972 text = _('Loading episodes') + '...'
973 progress = self.episode_list_model.get_update_progress()
974 elif self.config.episode_list_view_mode != \
975 EpisodeListModel.VIEW_ALL:
976 text = _('No episodes in current view')
977 else:
978 text = _('No episodes available')
979 elif role == TreeViewHelper.ROLE_PODCASTS:
980 if self.config.episode_list_view_mode != \
981 EpisodeListModel.VIEW_ALL and \
982 self.config.podcast_list_hide_boring and \
983 len(self.channels) > 0:
984 text = _('No podcasts in this view')
985 else:
986 text = _('No subscriptions')
987 elif role == TreeViewHelper.ROLE_DOWNLOADS:
988 text = _('No active downloads')
989 else:
990 raise Exception('on_treeview_expose_event: unknown role')
992 if gpodder.ui.fremantle:
993 from gpodder.gtkui.frmntl import style
994 font_desc = style.get_font_desc('LargeSystemFont')
995 else:
996 font_desc = None
998 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1000 return False
1002 def enable_download_list_update(self):
1003 if not self.download_list_update_enabled:
1004 gobject.timeout_add(1500, self.update_downloads_list)
1005 self.download_list_update_enabled = True
1007 def on_btnCleanUpDownloads_clicked(self, button=None):
1008 model = self.download_status_model
1010 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1011 changed_episode_urls = []
1012 for row_reference, task in all_tasks:
1013 if task.status in (task.DONE, task.CANCELLED) or \
1014 (task.status == task.FAILED and gpodder.ui.fremantle):
1015 model.remove(model.get_iter(row_reference.get_path()))
1016 try:
1017 # We don't "see" this task anymore - remove it;
1018 # this is needed, so update_episode_list_icons()
1019 # below gets the correct list of "seen" tasks
1020 self.download_tasks_seen.remove(task)
1021 except KeyError, key_error:
1022 log('Cannot remove task from "seen" list: %s', task, sender=self)
1023 changed_episode_urls.append(task.url)
1024 # Tell the task that it has been removed (so it can clean up)
1025 task.removed_from_list()
1027 # Tell the podcasts tab to update icons for our removed podcasts
1028 self.update_episode_list_icons(changed_episode_urls)
1030 # Tell the shownotes window that we have removed the episode
1031 if self.episode_shownotes_window is not None and \
1032 self.episode_shownotes_window.episode is not None and \
1033 self.episode_shownotes_window.episode.url in changed_episode_urls:
1034 self.episode_shownotes_window._download_status_changed(None)
1036 # Update the tab title and downloads list
1037 self.update_downloads_list(from_cleanup=True)
1039 def on_tool_downloads_toggled(self, toolbutton):
1040 if toolbutton.get_active():
1041 self.wNotebook.set_current_page(1)
1042 else:
1043 self.wNotebook.set_current_page(0)
1045 def add_download_task_monitor(self, monitor):
1046 self.download_task_monitors.add(monitor)
1047 model = self.download_status_model
1048 if model is None:
1049 model = ()
1050 for row in model:
1051 task = row[self.download_status_model.C_TASK]
1052 monitor.task_updated(task)
1054 def remove_download_task_monitor(self, monitor):
1055 self.download_task_monitors.remove(monitor)
1057 def update_downloads_list(self, from_cleanup=False):
1058 try:
1059 model = self.download_status_model
1061 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1062 total_speed, total_size, done_size = 0, 0, 0
1064 # Keep a list of all download tasks that we've seen
1065 download_tasks_seen = set()
1067 # Remember the DownloadTask object for the episode that
1068 # has been opened in the episode shownotes dialog (if any)
1069 if self.episode_shownotes_window is not None:
1070 shownotes_episode = self.episode_shownotes_window.episode
1071 shownotes_task = None
1072 else:
1073 shownotes_episode = None
1074 shownotes_task = None
1076 # Do not go through the list of the model is not (yet) available
1077 if model is None:
1078 model = ()
1080 failed_downloads = []
1081 for row in model:
1082 self.download_status_model.request_update(row.iter)
1084 task = row[self.download_status_model.C_TASK]
1085 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1087 # Let the download task monitors know of changes
1088 for monitor in self.download_task_monitors:
1089 monitor.task_updated(task)
1091 total_size += size
1092 done_size += size*progress
1094 if shownotes_episode is not None and \
1095 shownotes_episode.url == task.episode.url:
1096 shownotes_task = task
1098 download_tasks_seen.add(task)
1100 if status == download.DownloadTask.DOWNLOADING:
1101 downloading += 1
1102 total_speed += speed
1103 elif status == download.DownloadTask.FAILED:
1104 failed_downloads.append(task)
1105 failed += 1
1106 elif status == download.DownloadTask.DONE:
1107 finished += 1
1108 elif status == download.DownloadTask.QUEUED:
1109 queued += 1
1110 elif status == download.DownloadTask.PAUSED:
1111 paused += 1
1112 else:
1113 others += 1
1115 # Remember which tasks we have seen after this run
1116 self.download_tasks_seen = download_tasks_seen
1118 if gpodder.ui.desktop:
1119 text = [_('Downloads')]
1120 if downloading + failed + finished + queued > 0:
1121 s = []
1122 if downloading > 0:
1123 s.append(N_('%d active', '%d active', downloading) % downloading)
1124 if failed > 0:
1125 s.append(N_('%d failed', '%d failed', failed) % failed)
1126 if finished > 0:
1127 s.append(N_('%d done', '%d done', finished) % finished)
1128 if queued > 0:
1129 s.append(N_('%d queued', '%d queued', queued) % queued)
1130 text.append(' (' + ', '.join(s)+')')
1131 self.labelDownloads.set_text(''.join(text))
1132 elif gpodder.ui.diablo:
1133 sum = downloading + failed + finished + queued + paused + others
1134 if sum:
1135 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
1136 else:
1137 self.tool_downloads.set_label(_('Downloads'))
1138 elif gpodder.ui.fremantle:
1139 if downloading + queued > 0:
1140 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1141 elif failed > 0:
1142 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1143 elif paused > 0:
1144 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1145 else:
1146 self.button_downloads.set_value(_('Idle'))
1148 title = [self.default_title]
1150 # We have to update all episodes/channels for which the status has
1151 # changed. Accessing task.status_changed has the side effect of
1152 # re-setting the changed flag, so we need to get the "changed" list
1153 # of tuples first and split it into two lists afterwards
1154 changed = [(task.url, task.podcast_url) for task in \
1155 self.download_tasks_seen if task.status_changed]
1156 episode_urls = [episode_url for episode_url, channel_url in changed]
1157 channel_urls = [channel_url for episode_url, channel_url in changed]
1159 count = downloading + queued
1160 if count > 0:
1161 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1163 if total_size > 0:
1164 percentage = 100.0*done_size/total_size
1165 else:
1166 percentage = 0.0
1167 total_speed = util.format_filesize(total_speed)
1168 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1169 if self.tray_icon is not None:
1170 # Update the tray icon status and progress bar
1171 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1172 self.tray_icon.draw_progress_bar(percentage/100.)
1173 elif self.last_download_count > 0 and not from_cleanup:
1174 if self.tray_icon is not None:
1175 # Update the tray icon status
1176 self.tray_icon.set_status()
1177 if gpodder.ui.desktop:
1178 self.downloads_finished(self.download_tasks_seen)
1179 if gpodder.ui.diablo:
1180 hildon.hildon_banner_show_information(self.gPodder, None, 'gPodder: %s' % _('All downloads finished'))
1181 log('All downloads have finished.', sender=self)
1182 if self.config.cmd_all_downloads_complete:
1183 util.run_external_command(self.config.cmd_all_downloads_complete)
1185 if gpodder.ui.fremantle and failed:
1186 message = '\n'.join(['%s: %s' % (str(task), \
1187 task.error_message) for task in failed_downloads])
1188 self.show_message(message, _('Downloads failed'), important=True)
1190 # Automatically remove finished downloads from the list
1191 if self.config.auto_cleanup_downloads:
1192 self.on_btnCleanUpDownloads_clicked()
1193 self.last_download_count = count
1195 if not gpodder.ui.fremantle:
1196 self.gPodder.set_title(' - '.join(title))
1198 self.update_episode_list_icons(episode_urls)
1199 if self.episode_shownotes_window is not None:
1200 if (shownotes_task and shownotes_task.url in episode_urls) or \
1201 shownotes_task != self.episode_shownotes_window.task:
1202 self.episode_shownotes_window._download_status_changed(shownotes_task)
1203 self.episode_shownotes_window._download_status_progress()
1204 self.play_or_download()
1205 if channel_urls:
1206 self.update_podcast_list_model(channel_urls)
1208 if not self.download_queue_manager.are_queued_or_active_tasks():
1209 self.download_list_update_enabled = False
1211 return self.download_list_update_enabled
1212 except Exception, e:
1213 log('Exception happened while updating download list.', sender=self, traceback=True)
1214 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1215 # We return False here, so the update loop won't be called again,
1216 # that's why we require the restart of gPodder in the message.
1217 return False
1219 def on_config_changed(self, *args):
1220 util.idle_add(self._on_config_changed, *args)
1222 def _on_config_changed(self, name, old_value, new_value):
1223 if name == 'show_toolbar' and gpodder.ui.desktop:
1224 self.toolbar.set_property('visible', new_value)
1225 elif name == 'episode_list_descriptions':
1226 self.update_episode_list_model()
1227 elif name == 'episode_list_thumbnails':
1228 self.update_episode_list_icons(all=True)
1229 elif name == 'rotation_mode':
1230 self._fremantle_rotation.set_mode(new_value)
1231 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1232 self.restart_auto_update_timer()
1233 elif name == 'podcast_list_view_all':
1234 # Force a update of the podcast list model
1235 self.channel_list_changed = True
1236 if gpodder.ui.fremantle and self.preferences_dialog is not None:
1237 hildon.hildon_gtk_window_set_progress_indicator(self.preferences_dialog.main_window, True)
1238 while gtk.events_pending():
1239 gtk.main_iteration(False)
1240 self.update_podcast_list_model()
1241 if gpodder.ui.fremantle and self.preferences_dialog is not None:
1242 hildon.hildon_gtk_window_set_progress_indicator(self.preferences_dialog.main_window, False)
1243 elif name == 'auto_cleanup_downloads' and new_value:
1244 # Always cleanup when this option is enabled
1245 self.on_btnCleanUpDownloads_clicked()
1247 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1248 # With get_bin_window, we get the window that contains the rows without
1249 # the header. The Y coordinate of this window will be the height of the
1250 # treeview header. This is the amount we have to subtract from the
1251 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1252 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1253 y -= x_bin
1254 y -= y_bin
1255 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1257 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1258 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1259 return False
1261 if path is not None:
1262 model = treeview.get_model()
1263 iter = model.get_iter(path)
1264 role = getattr(treeview, TreeViewHelper.ROLE)
1266 if role == TreeViewHelper.ROLE_EPISODES:
1267 id = model.get_value(iter, EpisodeListModel.C_URL)
1268 elif role == TreeViewHelper.ROLE_PODCASTS:
1269 id = model.get_value(iter, PodcastListModel.C_URL)
1271 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1272 if last_tooltip is not None and last_tooltip != id:
1273 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1274 return False
1275 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1277 if role == TreeViewHelper.ROLE_EPISODES:
1278 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1279 if description:
1280 tooltip.set_text(description)
1281 else:
1282 return False
1283 elif role == TreeViewHelper.ROLE_PODCASTS:
1284 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1285 if channel is None:
1286 return False
1287 channel.request_save_dir_size()
1288 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1289 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1290 if error_str:
1291 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1292 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1293 table = gtk.Table(rows=3, columns=3)
1294 table.set_row_spacings(5)
1295 table.set_col_spacings(5)
1296 table.set_border_width(5)
1298 heading = gtk.Label()
1299 heading.set_alignment(0, 1)
1300 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1301 table.attach(heading, 0, 1, 0, 1)
1302 size_info = gtk.Label()
1303 size_info.set_alignment(1, 1)
1304 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1305 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1306 table.attach(size_info, 2, 3, 0, 1)
1308 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1310 if len(channel.description) < 500:
1311 description = channel.description
1312 else:
1313 pos = channel.description.find('\n\n')
1314 if pos == -1 or pos > 500:
1315 description = channel.description[:498]+'[...]'
1316 else:
1317 description = channel.description[:pos]
1319 description = gtk.Label(description)
1320 if error_str:
1321 description.set_markup(error_str)
1322 description.set_alignment(0, 0)
1323 description.set_line_wrap(True)
1324 table.attach(description, 0, 3, 2, 3)
1326 table.show_all()
1327 tooltip.set_custom(table)
1329 return True
1331 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1332 return False
1334 def treeview_allow_tooltips(self, treeview, allow):
1335 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1337 def update_m3u_playlist_clicked(self, widget):
1338 if self.active_channel is not None:
1339 self.active_channel.update_m3u_playlist()
1340 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1342 def treeview_handle_context_menu_click(self, treeview, event):
1343 x, y = int(event.x), int(event.y)
1344 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1346 selection = treeview.get_selection()
1347 model, paths = selection.get_selected_rows()
1349 if path is None or (path not in paths and \
1350 event.button == self.context_menu_mouse_button):
1351 # We have right-clicked, but not into the selection,
1352 # assume we don't want to operate on the selection
1353 paths = []
1355 if path is not None and not paths and \
1356 event.button == self.context_menu_mouse_button:
1357 # No selection or clicked outside selection;
1358 # select the single item where we clicked
1359 treeview.grab_focus()
1360 treeview.set_cursor(path, column, 0)
1361 paths = [path]
1363 if not paths:
1364 # Unselect any remaining items (clicked elsewhere)
1365 if hasattr(treeview, 'is_rubber_banding_active'):
1366 if not treeview.is_rubber_banding_active():
1367 selection.unselect_all()
1368 else:
1369 selection.unselect_all()
1371 return model, paths
1373 def downloads_list_get_selection(self, model=None, paths=None):
1374 if model is None and paths is None:
1375 selection = self.treeDownloads.get_selection()
1376 model, paths = selection.get_selected_rows()
1378 can_queue, can_cancel, can_pause, can_remove = (True,)*4
1379 selected_tasks = [(gtk.TreeRowReference(model, path), \
1380 model.get_value(model.get_iter(path), \
1381 DownloadStatusModel.C_TASK)) for path in paths]
1383 for row_reference, task in selected_tasks:
1384 if task.status not in (download.DownloadTask.PAUSED, \
1385 download.DownloadTask.FAILED, \
1386 download.DownloadTask.CANCELLED):
1387 can_queue = False
1388 if task.status not in (download.DownloadTask.PAUSED, \
1389 download.DownloadTask.QUEUED, \
1390 download.DownloadTask.DOWNLOADING):
1391 can_cancel = False
1392 if task.status not in (download.DownloadTask.QUEUED, \
1393 download.DownloadTask.DOWNLOADING):
1394 can_pause = False
1395 if task.status not in (download.DownloadTask.CANCELLED, \
1396 download.DownloadTask.FAILED, \
1397 download.DownloadTask.DONE):
1398 can_remove = False
1400 return selected_tasks, can_queue, can_cancel, can_pause, can_remove
1402 def downloads_finished(self, download_tasks_seen):
1403 # FIXME: Filter all tasks that have already been reported
1404 finished_downloads = [str(task) for task in download_tasks_seen if task.status == task.DONE]
1405 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.status == task.FAILED]
1407 if finished_downloads and failed_downloads:
1408 message = self.format_episode_list(finished_downloads, 5)
1409 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1410 message += self.format_episode_list(failed_downloads, 5)
1411 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1412 elif finished_downloads:
1413 message = self.format_episode_list(finished_downloads)
1414 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1415 elif failed_downloads:
1416 message = self.format_episode_list(failed_downloads)
1417 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1419 def format_episode_list(self, episode_list, max_episodes=10):
1421 Format a list of episode names for notifications
1423 Will truncate long episode names and limit the amount of
1424 episodes displayed (max_episodes=10).
1426 The episode_list parameter should be a list of strings.
1428 MAX_TITLE_LENGTH = 100
1430 result = []
1431 for title in episode_list[:min(len(episode_list), max_episodes)]:
1432 if len(title) > MAX_TITLE_LENGTH:
1433 middle = (MAX_TITLE_LENGTH/2)-2
1434 title = '%s...%s' % (title[0:middle], title[-middle:])
1435 result.append(saxutils.escape(title))
1436 result.append('\n')
1438 more_episodes = len(episode_list) - max_episodes
1439 if more_episodes > 0:
1440 result.append('(...')
1441 result.append(N_('%d more episode', '%d more episodes', more_episodes) % more_episodes)
1442 result.append('...)')
1444 return (''.join(result)).strip()
1446 def _for_each_task_set_status(self, tasks, status):
1447 episode_urls = set()
1448 model = self.treeDownloads.get_model()
1449 for row_reference, task in tasks:
1450 if status == download.DownloadTask.QUEUED:
1451 # Only queue task when its paused/failed/cancelled
1452 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED):
1453 self.download_queue_manager.add_task(task)
1454 self.enable_download_list_update()
1455 elif status == download.DownloadTask.CANCELLED:
1456 # Cancelling a download allowed when downloading/queued
1457 if task.status in (task.QUEUED, task.DOWNLOADING):
1458 task.status = status
1459 # Cancelling paused downloads requires a call to .run()
1460 elif task.status == task.PAUSED:
1461 task.status = status
1462 # Call run, so the partial file gets deleted
1463 task.run()
1464 elif status == download.DownloadTask.PAUSED:
1465 # Pausing a download only when queued/downloading
1466 if task.status in (task.DOWNLOADING, task.QUEUED):
1467 task.status = status
1468 elif status is None:
1469 # Remove the selected task - cancel downloading/queued tasks
1470 if task.status in (task.QUEUED, task.DOWNLOADING):
1471 task.status = task.CANCELLED
1472 model.remove(model.get_iter(row_reference.get_path()))
1473 # Remember the URL, so we can tell the UI to update
1474 try:
1475 # We don't "see" this task anymore - remove it;
1476 # this is needed, so update_episode_list_icons()
1477 # below gets the correct list of "seen" tasks
1478 self.download_tasks_seen.remove(task)
1479 except KeyError, key_error:
1480 log('Cannot remove task from "seen" list: %s', task, sender=self)
1481 episode_urls.add(task.url)
1482 # Tell the task that it has been removed (so it can clean up)
1483 task.removed_from_list()
1484 else:
1485 # We can (hopefully) simply set the task status here
1486 task.status = status
1487 # Tell the podcasts tab to update icons for our removed podcasts
1488 self.update_episode_list_icons(episode_urls)
1489 # Update the tab title and downloads list
1490 self.update_downloads_list()
1492 def treeview_downloads_show_context_menu(self, treeview, event):
1493 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1494 if not paths:
1495 if not hasattr(treeview, 'is_rubber_banding_active'):
1496 return True
1497 else:
1498 return not treeview.is_rubber_banding_active()
1500 if event.button == self.context_menu_mouse_button:
1501 selected_tasks, can_queue, can_cancel, can_pause, can_remove = \
1502 self.downloads_list_get_selection(model, paths)
1504 def make_menu_item(label, stock_id, tasks, status, sensitive):
1505 # This creates a menu item for selection-wide actions
1506 item = gtk.ImageMenuItem(label)
1507 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1508 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status))
1509 item.set_sensitive(sensitive)
1510 return self.set_finger_friendly(item)
1512 menu = gtk.Menu()
1514 item = gtk.ImageMenuItem(_('Episode details'))
1515 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1516 if len(selected_tasks) == 1:
1517 row_reference, task = selected_tasks[0]
1518 episode = task.episode
1519 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1520 else:
1521 item.set_sensitive(False)
1522 menu.append(self.set_finger_friendly(item))
1523 menu.append(gtk.SeparatorMenuItem())
1524 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue))
1525 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1526 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1527 menu.append(gtk.SeparatorMenuItem())
1528 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1530 if gpodder.ui.maemo:
1531 # Because we open the popup on left-click for Maemo,
1532 # we also include a non-action to close the menu
1533 menu.append(gtk.SeparatorMenuItem())
1534 item = gtk.ImageMenuItem(_('Close this menu'))
1535 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1537 menu.append(self.set_finger_friendly(item))
1539 menu.show_all()
1540 menu.popup(None, None, None, event.button, event.time)
1541 return True
1543 def treeview_channels_show_context_menu(self, treeview, event):
1544 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1545 if not paths:
1546 return True
1548 # Check for valid channel id, if there's no id then
1549 # assume that it is a proxy channel or equivalent
1550 # and cannot be operated with right click
1551 if self.active_channel.id is None:
1552 return True
1554 if event.button == 3:
1555 menu = gtk.Menu()
1557 ICON = lambda x: x
1559 item = gtk.ImageMenuItem( _('Open download folder'))
1560 item.set_image( gtk.image_new_from_icon_name(ICON('folder-open'), gtk.ICON_SIZE_MENU))
1561 item.connect('activate', lambda x: util.gui_open(self.active_channel.save_dir))
1562 menu.append( item)
1564 item = gtk.ImageMenuItem( _('Update Feed'))
1565 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1566 item.connect('activate', self.on_itemUpdateChannel_activate )
1567 item.set_sensitive( not self.updating_feed_cache )
1568 menu.append( item)
1570 item = gtk.ImageMenuItem(_('Update M3U playlist'))
1571 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1572 item.connect('activate', self.update_m3u_playlist_clicked)
1573 menu.append(item)
1575 if self.active_channel.link:
1576 item = gtk.ImageMenuItem(_('Visit website'))
1577 item.set_image(gtk.image_new_from_icon_name(ICON('web-browser'), gtk.ICON_SIZE_MENU))
1578 item.connect('activate', lambda w: util.open_website(self.active_channel.link))
1579 menu.append(item)
1581 if self.active_channel.channel_is_locked:
1582 item = gtk.ImageMenuItem(_('Allow deletion of all episodes'))
1583 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1584 item.connect('activate', self.on_channel_toggle_lock_activate)
1585 menu.append(self.set_finger_friendly(item))
1586 else:
1587 item = gtk.ImageMenuItem(_('Prohibit deletion of all episodes'))
1588 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1589 item.connect('activate', self.on_channel_toggle_lock_activate)
1590 menu.append(self.set_finger_friendly(item))
1593 menu.append( gtk.SeparatorMenuItem())
1595 item = gtk.ImageMenuItem(gtk.STOCK_EDIT)
1596 item.connect( 'activate', self.on_itemEditChannel_activate)
1597 menu.append( item)
1599 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1600 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1601 menu.append( item)
1603 menu.show_all()
1604 # Disable tooltips while we are showing the menu, so
1605 # the tooltip will not appear over the menu
1606 self.treeview_allow_tooltips(self.treeChannels, False)
1607 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1608 menu.popup( None, None, None, event.button, event.time)
1610 return True
1612 def on_itemClose_activate(self, widget):
1613 if self.tray_icon is not None:
1614 self.iconify_main_window()
1615 else:
1616 self.on_gPodder_delete_event(widget)
1618 def cover_file_removed(self, channel_url):
1620 The Cover Downloader calls this when a previously-
1621 available cover has been removed from the disk. We
1622 have to update our model to reflect this change.
1624 self.podcast_list_model.delete_cover_by_url(channel_url)
1626 def cover_download_finished(self, channel_url, pixbuf):
1628 The Cover Downloader calls this when it has finished
1629 downloading (or registering, if already downloaded)
1630 a new channel cover, which is ready for displaying.
1632 self.podcast_list_model.add_cover_by_url(channel_url, pixbuf)
1634 def save_episode_as_file(self, episode):
1635 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1636 if episode.was_downloaded(and_exists=True):
1637 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1638 copy_from = episode.local_filename(create=False)
1639 assert copy_from is not None
1640 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1641 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1642 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1644 def copy_episodes_bluetooth(self, episodes):
1645 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1647 def convert_and_send_thread(episode):
1648 for episode in episodes:
1649 filename = episode.local_filename(create=False)
1650 assert filename is not None
1651 destfile = os.path.join(tempfile.gettempdir(), \
1652 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1653 (base, ext) = os.path.splitext(filename)
1654 if not destfile.endswith(ext):
1655 destfile += ext
1657 try:
1658 shutil.copyfile(filename, destfile)
1659 util.bluetooth_send_file(destfile)
1660 except:
1661 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1662 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1664 util.delete_file(destfile)
1666 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1668 def get_device_name(self):
1669 if self.config.device_type == 'ipod':
1670 return _('iPod')
1671 elif self.config.device_type in ('filesystem', 'mtp'):
1672 return _('MP3 player')
1673 else:
1674 return '(unknown device)'
1676 def _treeview_button_released(self, treeview, event):
1677 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1678 dy = int(abs(event.y-ypos))
1679 dx = int(event.x-xpos)
1681 selection = treeview.get_selection()
1682 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1683 if path is None or dy > 30:
1684 return (False, dx, dy)
1686 path, column, x, y = path
1687 selection.select_path(path)
1688 treeview.set_cursor(path)
1689 treeview.grab_focus()
1691 return (True, dx, dy)
1693 def treeview_channels_handle_gestures(self, treeview, event):
1694 if self.currently_updating:
1695 return False
1697 selected, dx, dy = self._treeview_button_released(treeview, event)
1699 if selected:
1700 if self.config.maemo_enable_gestures:
1701 if dx > 70:
1702 self.on_itemUpdateChannel_activate()
1703 elif dx < -70:
1704 self.on_itemEditChannel_activate(treeview)
1706 return False
1708 def treeview_available_handle_gestures(self, treeview, event):
1709 selected, dx, dy = self._treeview_button_released(treeview, event)
1711 if selected:
1712 if self.config.maemo_enable_gestures:
1713 if dx > 70:
1714 self.on_playback_selected_episodes(None)
1715 return True
1716 elif dx < -70:
1717 self.on_shownotes_selected_episodes(None)
1718 return True
1720 # Pass the event to the context menu handler for treeAvailable
1721 self.treeview_available_show_context_menu(treeview, event)
1723 return True
1725 def treeview_available_show_context_menu(self, treeview, event):
1726 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1727 if not paths:
1728 if not hasattr(treeview, 'is_rubber_banding_active'):
1729 return True
1730 else:
1731 return not treeview.is_rubber_banding_active()
1733 if event.button == self.context_menu_mouse_button:
1734 episodes = self.get_selected_episodes()
1735 any_locked = any(e.is_locked for e in episodes)
1736 any_played = any(e.is_played for e in episodes)
1737 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1739 menu = gtk.Menu()
1741 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1743 if open_instead_of_play:
1744 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1745 else:
1746 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1748 item.set_sensitive(can_play)
1749 item.connect('activate', self.on_playback_selected_episodes)
1750 menu.append(self.set_finger_friendly(item))
1752 if not can_cancel:
1753 item = gtk.ImageMenuItem(_('Download'))
1754 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1755 item.set_sensitive(can_download)
1756 item.connect('activate', self.on_download_selected_episodes)
1757 menu.append(self.set_finger_friendly(item))
1758 else:
1759 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1760 item.connect('activate', self.on_item_cancel_download_activate)
1761 menu.append(self.set_finger_friendly(item))
1763 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1764 item.set_sensitive(can_delete)
1765 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1766 menu.append(self.set_finger_friendly(item))
1768 if one_is_new:
1769 item = gtk.ImageMenuItem(_('Do not download'))
1770 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1771 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1772 menu.append(self.set_finger_friendly(item))
1773 elif can_download:
1774 item = gtk.ImageMenuItem(_('Mark as new'))
1775 item.set_image(gtk.image_new_from_stock(gtk.STOCK_ABOUT, gtk.ICON_SIZE_MENU))
1776 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1777 menu.append(self.set_finger_friendly(item))
1779 ICON = lambda x: x
1781 # Ok, this probably makes sense to only display for downloaded files
1782 if can_play and not can_download:
1783 menu.append( gtk.SeparatorMenuItem())
1784 item = gtk.ImageMenuItem(_('Save to disk'))
1785 item.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE_AS, gtk.ICON_SIZE_MENU))
1786 item.connect('activate', lambda w: [self.save_episode_as_file(e) for e in episodes])
1787 menu.append(self.set_finger_friendly(item))
1788 if self.bluetooth_available:
1789 item = gtk.ImageMenuItem(_('Send via bluetooth'))
1790 item.set_image(gtk.image_new_from_icon_name(ICON('bluetooth'), gtk.ICON_SIZE_MENU))
1791 item.connect('activate', lambda w: self.copy_episodes_bluetooth(episodes))
1792 menu.append(self.set_finger_friendly(item))
1793 if can_transfer:
1794 item = gtk.ImageMenuItem(_('Transfer to %s') % self.get_device_name())
1795 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
1796 item.connect('activate', lambda w: self.on_sync_to_ipod_activate(w, episodes))
1797 menu.append(self.set_finger_friendly(item))
1799 if can_play:
1800 menu.append( gtk.SeparatorMenuItem())
1801 if any_played:
1802 item = gtk.ImageMenuItem(_('Mark as unplayed'))
1803 item.set_image( gtk.image_new_from_stock( gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU))
1804 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, False))
1805 menu.append(self.set_finger_friendly(item))
1806 else:
1807 item = gtk.ImageMenuItem(_('Mark as played'))
1808 item.set_image( gtk.image_new_from_stock( gtk.STOCK_APPLY, gtk.ICON_SIZE_MENU))
1809 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, True))
1810 menu.append(self.set_finger_friendly(item))
1812 if any_locked:
1813 item = gtk.ImageMenuItem(_('Allow deletion'))
1814 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1815 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, False))
1816 menu.append(self.set_finger_friendly(item))
1817 else:
1818 item = gtk.ImageMenuItem(_('Prohibit deletion'))
1819 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1820 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, True))
1821 menu.append(self.set_finger_friendly(item))
1823 menu.append(gtk.SeparatorMenuItem())
1824 # Single item, add episode information menu item
1825 item = gtk.ImageMenuItem(_('Episode details'))
1826 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1827 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
1828 menu.append(self.set_finger_friendly(item))
1830 # If we have it, also add episode website link
1831 if episodes[0].link and episodes[0].link != episodes[0].url:
1832 item = gtk.ImageMenuItem(_('Visit website'))
1833 item.set_image(gtk.image_new_from_icon_name(ICON('web-browser'), gtk.ICON_SIZE_MENU))
1834 item.connect('activate', lambda w: util.open_website(episodes[0].link))
1835 menu.append(self.set_finger_friendly(item))
1837 if gpodder.ui.maemo:
1838 # Because we open the popup on left-click for Maemo,
1839 # we also include a non-action to close the menu
1840 menu.append(gtk.SeparatorMenuItem())
1841 item = gtk.ImageMenuItem(_('Close this menu'))
1842 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1843 menu.append(self.set_finger_friendly(item))
1845 menu.show_all()
1846 # Disable tooltips while we are showing the menu, so
1847 # the tooltip will not appear over the menu
1848 self.treeview_allow_tooltips(self.treeAvailable, False)
1849 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
1850 menu.popup( None, None, None, event.button, event.time)
1852 return True
1854 def set_title(self, new_title):
1855 if not gpodder.ui.fremantle:
1856 self.default_title = new_title
1857 self.gPodder.set_title(new_title)
1859 def update_episode_list_icons(self, urls=None, selected=False, all=False):
1861 Updates the status icons in the episode list.
1863 If urls is given, it should be a list of URLs
1864 of episodes that should be updated.
1866 If urls is None, set ONE OF selected, all to
1867 True (the former updates just the selected
1868 episodes and the latter updates all episodes).
1870 additional_args = (self.episode_is_downloading, \
1871 self.config.episode_list_descriptions and gpodder.ui.desktop, \
1872 self.config.episode_list_thumbnails and gpodder.ui.desktop)
1874 if urls is not None:
1875 # We have a list of URLs to walk through
1876 self.episode_list_model.update_by_urls(urls, *additional_args)
1877 elif selected and not all:
1878 # We should update all selected episodes
1879 selection = self.treeAvailable.get_selection()
1880 model, paths = selection.get_selected_rows()
1881 for path in reversed(paths):
1882 iter = model.get_iter(path)
1883 self.episode_list_model.update_by_filter_iter(iter, \
1884 *additional_args)
1885 elif all and not selected:
1886 # We update all (even the filter-hidden) episodes
1887 self.episode_list_model.update_all(*additional_args)
1888 else:
1889 # Wrong/invalid call - have to specify at least one parameter
1890 raise ValueError('Invalid call to update_episode_list_icons')
1892 def episode_list_status_changed(self, episodes):
1893 self.update_episode_list_icons(set(e.url for e in episodes))
1894 self.update_podcast_list_model(set(e.channel.url for e in episodes))
1895 self.db.commit()
1897 def clean_up_downloads(self, delete_partial=False):
1898 # Clean up temporary files left behind by old gPodder versions
1899 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
1901 if delete_partial:
1902 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
1904 for tempfile in temporary_files:
1905 util.delete_file(tempfile)
1907 # Clean up empty download folders and abandoned download folders
1908 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
1909 for ddir in download_dirs:
1910 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
1911 globr = glob.glob(os.path.join(ddir, '*'))
1912 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
1913 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
1914 shutil.rmtree(ddir, ignore_errors=True)
1916 def streaming_possible(self):
1917 if gpodder.ui.desktop:
1918 # User has to have a media player set on the Desktop, or else we
1919 # would probably open the browser when giving a URL to xdg-open..
1920 return (self.config.player and self.config.player != 'default')
1921 elif gpodder.ui.maemo:
1922 # On Maemo, the default is to use the Nokia Media Player, which is
1923 # already able to deal with HTTP URLs the right way, so we
1924 # unconditionally enable streaming always on Maemo
1925 return True
1927 return False
1929 def playback_episodes_for_real(self, episodes):
1930 groups = collections.defaultdict(list)
1931 for episode in episodes:
1932 file_type = episode.file_type()
1933 if file_type == 'video' and self.config.videoplayer and \
1934 self.config.videoplayer != 'default':
1935 player = self.config.videoplayer
1936 if gpodder.ui.diablo:
1937 # Use the wrapper script if it's installed to crop 3GP YouTube
1938 # videos to fit the screen (looks much nicer than w/ black border)
1939 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
1940 player = 'gpodder-mplayer'
1941 elif file_type == 'audio' and self.config.player and \
1942 self.config.player != 'default':
1943 player = self.config.player
1944 else:
1945 player = 'default'
1947 if file_type not in ('audio', 'video') or \
1948 (file_type == 'audio' and not self.config.audio_played_dbus) or \
1949 (file_type == 'video' and not self.config.video_played_dbus):
1950 # Mark episode as played in the database
1951 episode.mark(is_played=True)
1952 self.mygpo_client.on_playback([episode])
1954 filename = episode.local_filename(create=False)
1955 if filename is None or not os.path.exists(filename):
1956 filename = episode.url
1957 groups[player].append(filename)
1959 # Open episodes with system default player
1960 if 'default' in groups:
1961 for filename in groups['default']:
1962 log('Opening with system default: %s', filename, sender=self)
1963 util.gui_open(filename)
1964 del groups['default']
1965 elif gpodder.ui.maemo:
1966 # When on Maemo and not opening with default, show a notification
1967 # (no startup notification for Panucci / MPlayer yet...)
1968 if len(episodes) == 1:
1969 text = _('Opening %s') % episodes[0].title
1970 else:
1971 count = len(episodes)
1972 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
1974 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
1976 def destroy_banner_later(banner):
1977 banner.destroy()
1978 return False
1979 gobject.timeout_add(5000, destroy_banner_later, banner)
1981 # For each type now, go and create play commands
1982 for group in groups:
1983 for command in util.format_desktop_command(group, groups[group]):
1984 log('Executing: %s', repr(command), sender=self)
1985 subprocess.Popen(command)
1987 # Flush updated episode status
1988 self.mygpo_client.flush()
1990 def playback_episodes(self, episodes):
1991 # We need to create a list, because we run through it more than once
1992 episodes = list(PodcastEpisode.sort_by_pubdate(e for e in episodes if \
1993 e.was_downloaded(and_exists=True) or self.streaming_possible()))
1995 try:
1996 self.playback_episodes_for_real(episodes)
1997 except Exception, e:
1998 log('Error in playback!', sender=self, traceback=True)
1999 if gpodder.ui.desktop:
2000 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2001 _('Error opening player'), widget=self.toolPreferences)
2002 else:
2003 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2005 channel_urls = set()
2006 episode_urls = set()
2007 for episode in episodes:
2008 channel_urls.add(episode.channel.url)
2009 episode_urls.add(episode.url)
2010 self.update_episode_list_icons(episode_urls)
2011 self.update_podcast_list_model(channel_urls)
2013 def play_or_download(self):
2014 if not gpodder.ui.fremantle:
2015 if self.wNotebook.get_current_page() > 0:
2016 if gpodder.ui.desktop:
2017 self.toolCancel.set_sensitive(True)
2018 return
2020 if self.currently_updating:
2021 return (False, False, False, False, False, False)
2023 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
2024 ( is_played, is_locked ) = (False,)*2
2026 open_instead_of_play = False
2028 selection = self.treeAvailable.get_selection()
2029 if selection.count_selected_rows() > 0:
2030 (model, paths) = selection.get_selected_rows()
2032 for path in paths:
2033 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2035 if episode.file_type() not in ('audio', 'video'):
2036 open_instead_of_play = True
2038 if episode.was_downloaded():
2039 can_play = episode.was_downloaded(and_exists=True)
2040 can_delete = True
2041 is_played = episode.is_played
2042 is_locked = episode.is_locked
2043 if not can_play:
2044 can_download = True
2045 else:
2046 if self.episode_is_downloading(episode):
2047 can_cancel = True
2048 else:
2049 can_download = True
2051 can_download = can_download and not can_cancel
2052 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2053 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
2055 if gpodder.ui.desktop:
2056 if open_instead_of_play:
2057 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2058 else:
2059 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2060 self.toolPlay.set_sensitive( can_play)
2061 self.toolDownload.set_sensitive( can_download)
2062 self.toolTransfer.set_sensitive( can_transfer)
2063 self.toolCancel.set_sensitive( can_cancel)
2065 if not gpodder.ui.fremantle:
2066 self.item_cancel_download.set_sensitive(can_cancel)
2067 self.itemDownloadSelected.set_sensitive(can_download)
2068 self.itemOpenSelected.set_sensitive(can_play)
2069 self.itemPlaySelected.set_sensitive(can_play)
2070 self.itemDeleteSelected.set_sensitive(can_play and not can_download)
2071 self.item_toggle_played.set_sensitive(can_play)
2072 self.item_toggle_lock.set_sensitive(can_play)
2073 self.itemOpenSelected.set_visible(open_instead_of_play)
2074 self.itemPlaySelected.set_visible(not open_instead_of_play)
2076 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
2078 def on_cbMaxDownloads_toggled(self, widget, *args):
2079 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2081 def on_cbLimitDownloads_toggled(self, widget, *args):
2082 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2084 def episode_new_status_changed(self, urls):
2085 self.update_podcast_list_model()
2086 self.update_episode_list_icons(urls)
2088 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2089 """Update the podcast list treeview model
2091 If urls is given, it should list the URLs of each
2092 podcast that has to be updated in the list.
2094 If selected is True, only update the model contents
2095 for the currently-selected podcast - nothing more.
2097 The caller can optionally specify "select_url",
2098 which is the URL of the podcast that is to be
2099 selected in the list after the update is complete.
2100 This only works if the podcast list has to be
2101 reloaded; i.e. something has been added or removed
2102 since the last update of the podcast list).
2104 selection = self.treeChannels.get_selection()
2105 model, iter = selection.get_selected()
2107 if self.config.podcast_list_view_all and not self.channel_list_changed:
2108 # Update "all episodes" view in any case (if enabled)
2109 self.podcast_list_model.update_first_row()
2111 if selected:
2112 # very cheap! only update selected channel
2113 if iter is not None:
2114 # If we have selected the "all episodes" view, we have
2115 # to update all channels for selected episodes:
2116 if self.config.podcast_list_view_all and \
2117 self.podcast_list_model.iter_is_first_row(iter):
2118 urls = self.get_podcast_urls_from_selected_episodes()
2119 self.podcast_list_model.update_by_urls(urls)
2120 else:
2121 # Otherwise just update the selected row (a podcast)
2122 self.podcast_list_model.update_by_filter_iter(iter)
2123 elif not self.channel_list_changed:
2124 # we can keep the model, but have to update some
2125 if urls is None:
2126 # still cheaper than reloading the whole list
2127 self.podcast_list_model.update_all()
2128 else:
2129 # ok, we got a bunch of urls to update
2130 self.podcast_list_model.update_by_urls(urls)
2131 else:
2132 if model and iter and select_url is None:
2133 # Get the URL of the currently-selected podcast
2134 select_url = model.get_value(iter, PodcastListModel.C_URL)
2136 # Update the podcast list model with new channels
2137 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2139 try:
2140 selected_iter = model.get_iter_first()
2141 # Find the previously-selected URL in the new
2142 # model if we have an URL (else select first)
2143 if select_url is not None:
2144 pos = model.get_iter_first()
2145 while pos is not None:
2146 url = model.get_value(pos, PodcastListModel.C_URL)
2147 if url == select_url:
2148 selected_iter = pos
2149 break
2150 pos = model.iter_next(pos)
2152 if not gpodder.ui.fremantle:
2153 if selected_iter is not None:
2154 selection.select_iter(selected_iter)
2155 self.on_treeChannels_cursor_changed(self.treeChannels)
2156 except:
2157 log('Cannot select podcast in list', traceback=True, sender=self)
2158 self.channel_list_changed = False
2160 def episode_is_downloading(self, episode):
2161 """Returns True if the given episode is being downloaded at the moment"""
2162 if episode is None:
2163 return False
2165 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2167 def update_episode_list_model(self):
2168 if self.channels and self.active_channel is not None:
2169 if gpodder.ui.diablo:
2170 banner = hildon.hildon_banner_show_animation(self.gPodder, None, _('Loading episodes'))
2171 else:
2172 banner = None
2174 if gpodder.ui.fremantle:
2175 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2177 self.currently_updating = True
2178 self.episode_list_model.clear()
2179 self.episode_list_model.reset_update_progress()
2180 self.treeAvailable.set_model(self.empty_episode_list_model)
2181 def do_update_episode_list_model():
2182 additional_args = (self.episode_is_downloading, \
2183 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2184 self.config.episode_list_thumbnails and gpodder.ui.desktop, \
2185 self.treeAvailable)
2186 self.episode_list_model.add_from_channel(self.active_channel, *additional_args)
2188 def on_episode_list_model_updated():
2189 if banner is not None:
2190 banner.destroy()
2191 if gpodder.ui.fremantle:
2192 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2193 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
2194 self.treeAvailable.columns_autosize()
2195 self.currently_updating = False
2196 self.play_or_download()
2197 util.idle_add(on_episode_list_model_updated)
2198 threading.Thread(target=do_update_episode_list_model).start()
2199 else:
2200 self.episode_list_model.clear()
2202 def offer_new_episodes(self, channels=None):
2203 new_episodes = self.get_new_episodes(channels)
2204 if new_episodes:
2205 self.new_episodes_show(new_episodes)
2206 return True
2207 return False
2209 def add_podcast_list(self, urls, auth_tokens=None):
2210 """Subscribe to a list of podcast given their URLs
2212 If auth_tokens is given, it should be a dictionary
2213 mapping URLs to (username, password) tuples."""
2215 if auth_tokens is None:
2216 auth_tokens = {}
2218 # Sort and split the URL list into five buckets
2219 queued, failed, existing, worked, authreq = [], [], [], [], []
2220 for input_url in urls:
2221 url = util.normalize_feed_url(input_url)
2222 if url is None:
2223 # Fail this one because the URL is not valid
2224 failed.append(input_url)
2225 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2226 # A podcast already exists in the list for this URL
2227 existing.append(url)
2228 else:
2229 # This URL has survived the first round - queue for add
2230 queued.append(url)
2231 if url != input_url and input_url in auth_tokens:
2232 auth_tokens[url] = auth_tokens[input_url]
2234 error_messages = {}
2235 redirections = {}
2237 progress = ProgressIndicator(_('Adding podcasts'), \
2238 _('Please wait while episode information is downloaded.'), \
2239 parent=self.main_window)
2241 def on_after_update():
2242 progress.on_finished()
2243 # Report already-existing subscriptions to the user
2244 if existing:
2245 title = _('Existing subscriptions skipped')
2246 message = _('You are already subscribed to these podcasts:') \
2247 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2248 self.show_message(message, title, widget=self.treeChannels)
2250 # Report subscriptions that require authentication
2251 if authreq:
2252 retry_podcasts = {}
2253 for url in authreq:
2254 title = _('Podcast requires authentication')
2255 message = _('Please login to %s:') % (saxutils.escape(url),)
2256 success, auth_tokens = self.show_login_dialog(title, message)
2257 if success:
2258 retry_podcasts[url] = auth_tokens
2259 else:
2260 # Stop asking the user for more login data
2261 retry_podcasts = {}
2262 for url in authreq:
2263 error_messages[url] = _('Authentication failed')
2264 failed.append(url)
2265 break
2267 # If we have authentication data to retry, do so here
2268 if retry_podcasts:
2269 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2271 # Report website redirections
2272 for url in redirections:
2273 title = _('Website redirection detected')
2274 message = _('The URL %(url)s redirects to %(target)s.') \
2275 + '\n\n' + _('Do you want to visit the website now?')
2276 message = message % {'url': url, 'target': redirections[url]}
2277 if self.show_confirmation(message, title):
2278 util.open_website(url)
2279 else:
2280 break
2282 # Report failed subscriptions to the user
2283 if failed:
2284 title = _('Could not add some podcasts')
2285 message = _('Some podcasts could not be added to your list:') \
2286 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2287 error_messages.get(url, _('Unknown')))) for url in failed)
2288 self.show_message(message, title, important=True)
2290 # Upload subscription changes to my.gpodder.org
2291 self.mygpo_client.on_subscribe(worked)
2293 # If at least one podcast has been added, save and update all
2294 if self.channel_list_changed:
2295 # Fix URLs if mygpo has rewritten them
2296 self.rewrite_urls_mygpo()
2298 self.save_channels_opml()
2300 # If only one podcast was added, select it after the update
2301 if len(worked) == 1:
2302 url = worked[0]
2303 else:
2304 url = None
2306 # Update the list of subscribed podcasts
2307 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2308 self.update_podcasts_tab()
2310 # Offer to download new episodes
2311 self.offer_new_episodes(channels=[c for c in self.channels if c.url in worked])
2313 def thread_proc():
2314 # After the initial sorting and splitting, try all queued podcasts
2315 length = len(queued)
2316 for index, url in enumerate(queued):
2317 progress.on_progress(float(index)/float(length))
2318 progress.on_message(url)
2319 log('QUEUE RUNNER: %s', url, sender=self)
2320 try:
2321 # The URL is valid and does not exist already - subscribe!
2322 channel = PodcastChannel.load(self.db, url=url, create=True, \
2323 authentication_tokens=auth_tokens.get(url, None), \
2324 max_episodes=self.config.max_episodes_per_feed, \
2325 download_dir=self.config.download_dir, \
2326 allow_empty_feeds=self.config.allow_empty_feeds)
2328 try:
2329 username, password = util.username_password_from_url(url)
2330 except ValueError, ve:
2331 username, password = (None, None)
2333 if username is not None and channel.username is None and \
2334 password is not None and channel.password is None:
2335 channel.username = username
2336 channel.password = password
2337 channel.save()
2339 self._update_cover(channel)
2340 except feedcore.AuthenticationRequired:
2341 if url in auth_tokens:
2342 # Fail for wrong authentication data
2343 error_messages[url] = _('Authentication failed')
2344 failed.append(url)
2345 else:
2346 # Queue for login dialog later
2347 authreq.append(url)
2348 continue
2349 except feedcore.WifiLogin, error:
2350 redirections[url] = error.data
2351 failed.append(url)
2352 error_messages[url] = _('Redirection detected')
2353 continue
2354 except Exception, e:
2355 log('Subscription error: %s', e, traceback=True, sender=self)
2356 error_messages[url] = str(e)
2357 failed.append(url)
2358 continue
2360 assert channel is not None
2361 worked.append(channel.url)
2362 self.channels.append(channel)
2363 self.channel_list_changed = True
2364 util.idle_add(on_after_update)
2365 threading.Thread(target=thread_proc).start()
2367 def save_channels_opml(self):
2368 exporter = opml.Exporter(gpodder.subscription_file)
2369 return exporter.write(self.channels)
2371 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2372 self.db.commit()
2373 self.updating_feed_cache = False
2375 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2376 self.channel_list_changed = True
2377 self.update_podcast_list_model(select_url=select_url_afterwards)
2379 # Only search for new episodes in podcasts that have been
2380 # updated, not in other podcasts (for single-feed updates)
2381 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2383 if gpodder.ui.fremantle:
2384 self.button_subscribe.set_sensitive(True)
2385 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
2386 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
2387 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2388 self.update_podcasts_tab()
2389 if self.feed_cache_update_cancelled:
2390 return
2392 if episodes:
2393 if self.config.auto_download == 'always':
2394 count = len(episodes)
2395 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2396 self.show_message(title)
2397 self.download_episode_list(episodes)
2398 elif self.config.auto_download == 'queue':
2399 self.show_message(_('New episodes have been added to the download list.'))
2400 self.download_episode_list_paused(episodes)
2401 else:
2402 self.new_episodes_show(episodes)
2403 elif not self.config.auto_update_feeds:
2404 self.show_message(_('No new episodes. Please check for new episodes later.'))
2405 return
2407 if self.tray_icon:
2408 self.tray_icon.set_status()
2410 if self.feed_cache_update_cancelled:
2411 # The user decided to abort the feed update
2412 self.show_update_feeds_buttons()
2413 elif not episodes:
2414 # Nothing new here - but inform the user
2415 self.pbFeedUpdate.set_fraction(1.0)
2416 self.pbFeedUpdate.set_text(_('No new episodes'))
2417 self.feed_cache_update_cancelled = True
2418 self.btnCancelFeedUpdate.show()
2419 self.btnCancelFeedUpdate.set_sensitive(True)
2420 if gpodder.ui.maemo:
2421 # btnCancelFeedUpdate is a ToolButton on Maemo
2422 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2423 else:
2424 # btnCancelFeedUpdate is a normal gtk.Button
2425 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2426 else:
2427 count = len(episodes)
2428 # New episodes are available
2429 self.pbFeedUpdate.set_fraction(1.0)
2430 # Are we minimized and should we auto download?
2431 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2432 self.download_episode_list(episodes)
2433 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2434 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2435 self.show_update_feeds_buttons()
2436 elif self.config.auto_download == 'queue':
2437 self.download_episode_list_paused(episodes)
2438 title = N_('%d new episode added to download list.', '%d new episodes added to download list.', count) % count
2439 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2440 self.show_update_feeds_buttons()
2441 else:
2442 self.show_update_feeds_buttons()
2443 # New episodes are available and we are not minimized
2444 if not self.config.do_not_show_new_episodes_dialog:
2445 self.new_episodes_show(episodes, notification=True)
2446 else:
2447 message = N_('%d new episode available', '%d new episodes available', count) % count
2448 self.pbFeedUpdate.set_text(message)
2450 def _update_cover(self, channel):
2451 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2452 self.cover_downloader.request_cover(channel)
2454 def update_feed_cache_proc(self, channels, select_url_afterwards):
2455 total = len(channels)
2457 for updated, channel in enumerate(channels):
2458 if not self.feed_cache_update_cancelled:
2459 try:
2460 # Update if timeout is not reached or we update a single podcast or skipping is disabled
2461 if channel.query_automatic_update() or total == 1 or not self.config.feed_update_skipping:
2462 channel.update(max_episodes=self.config.max_episodes_per_feed)
2463 else:
2464 log('Skipping update of %s (see feed_update_skipping)', channel.title, sender=self)
2465 self._update_cover(channel)
2466 except Exception, e:
2467 d = {'url': saxutils.escape(channel.url), 'message': saxutils.escape(str(e))}
2468 if d['message']:
2469 message = _('Error while updating %(url)s: %(message)s')
2470 else:
2471 message = _('The feed at %(url)s could not be updated.')
2472 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2473 log('Error: %s', str(e), sender=self, traceback=True)
2475 if self.feed_cache_update_cancelled:
2476 break
2478 if gpodder.ui.fremantle:
2479 util.idle_add(self.button_refresh.set_title, \
2480 _('%(position)d/%(total)d updated') % {'position': updated, 'total': total})
2481 continue
2483 # By the time we get here the update may have already been cancelled
2484 if not self.feed_cache_update_cancelled:
2485 def update_progress():
2486 d = {'podcast': channel.title, 'position': updated, 'total': total}
2487 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2488 self.pbFeedUpdate.set_text(progression)
2489 if self.tray_icon:
2490 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2491 self.pbFeedUpdate.set_fraction(float(updated)/float(total))
2492 util.idle_add(update_progress)
2494 updated_urls = [c.url for c in channels]
2495 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2497 def show_update_feeds_buttons(self):
2498 # Make sure that the buttons for updating feeds
2499 # appear - this should happen after a feed update
2500 if gpodder.ui.maemo:
2501 self.btnUpdateSelectedFeed.show()
2502 self.toolFeedUpdateProgress.hide()
2503 self.btnCancelFeedUpdate.hide()
2504 self.btnCancelFeedUpdate.set_is_important(False)
2505 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2506 self.toolbarSpacer.set_expand(True)
2507 self.toolbarSpacer.set_draw(False)
2508 else:
2509 self.hboxUpdateFeeds.hide()
2510 self.btnUpdateFeeds.show()
2511 self.itemUpdate.set_sensitive(True)
2512 self.itemUpdateChannel.set_sensitive(True)
2514 def on_btnCancelFeedUpdate_clicked(self, widget):
2515 if not self.feed_cache_update_cancelled:
2516 self.pbFeedUpdate.set_text(_('Cancelling...'))
2517 self.feed_cache_update_cancelled = True
2518 self.btnCancelFeedUpdate.set_sensitive(False)
2519 else:
2520 self.show_update_feeds_buttons()
2522 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2523 if self.updating_feed_cache:
2524 if gpodder.ui.fremantle:
2525 self.feed_cache_update_cancelled = True
2526 return
2528 if not force_update:
2529 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2530 self.channel_list_changed = True
2531 self.update_podcast_list_model(select_url=select_url_afterwards)
2532 return
2534 # Fix URLs if mygpo has rewritten them
2535 self.rewrite_urls_mygpo()
2537 self.updating_feed_cache = True
2539 if channels is None:
2540 channels = self.channels
2542 if gpodder.ui.fremantle:
2543 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2544 self.button_refresh.set_title(_('Updating...'))
2545 self.button_subscribe.set_sensitive(False)
2546 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
2547 self.ICON_GENERAL_CLOSE, gtk.ICON_SIZE_BUTTON))
2548 self.feed_cache_update_cancelled = False
2549 else:
2550 self.itemUpdate.set_sensitive(False)
2551 self.itemUpdateChannel.set_sensitive(False)
2553 if self.tray_icon:
2554 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2556 if len(channels) == 1:
2557 text = _('Updating "%s"...') % channels[0].title
2558 else:
2559 count = len(channels)
2560 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
2561 self.pbFeedUpdate.set_text(text)
2562 self.pbFeedUpdate.set_fraction(0)
2564 self.feed_cache_update_cancelled = False
2565 self.btnCancelFeedUpdate.show()
2566 self.btnCancelFeedUpdate.set_sensitive(True)
2567 if gpodder.ui.maemo:
2568 self.toolbarSpacer.set_expand(False)
2569 self.toolbarSpacer.set_draw(True)
2570 self.btnUpdateSelectedFeed.hide()
2571 self.toolFeedUpdateProgress.show_all()
2572 else:
2573 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2574 self.hboxUpdateFeeds.show_all()
2575 self.btnUpdateFeeds.hide()
2577 args = (channels, select_url_afterwards)
2578 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
2580 def on_gPodder_delete_event(self, widget, *args):
2581 """Called when the GUI wants to close the window
2582 Displays a confirmation dialog (and closes/hides gPodder)
2585 downloading = self.download_status_model.are_downloads_in_progress()
2587 # Only iconify if we are using the window's "X" button,
2588 # but not when we are using "Quit" in the menu or toolbar
2589 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
2590 self.iconify_main_window()
2591 elif self.config.on_quit_ask or downloading:
2592 if gpodder.ui.fremantle:
2593 self.close_gpodder()
2594 elif gpodder.ui.diablo:
2595 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
2596 if result:
2597 self.close_gpodder()
2598 else:
2599 return True
2600 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2601 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2602 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
2604 title = _('Quit gPodder')
2605 if downloading:
2606 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2607 else:
2608 message = _('Do you really want to quit gPodder now?')
2610 dialog.set_title(title)
2611 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2612 if not downloading:
2613 cb_ask = gtk.CheckButton(_("Don't ask me again"))
2614 dialog.vbox.pack_start(cb_ask)
2615 cb_ask.show_all()
2617 quit_button.grab_focus()
2618 result = dialog.run()
2619 dialog.destroy()
2621 if result == gtk.RESPONSE_CLOSE:
2622 if not downloading and cb_ask.get_active() == True:
2623 self.config.on_quit_ask = False
2624 self.close_gpodder()
2625 else:
2626 self.close_gpodder()
2628 return True
2630 def close_gpodder(self):
2631 """ clean everything and exit properly
2633 if self.channels:
2634 if self.save_channels_opml():
2635 pass # FIXME: Add mygpo synchronization here
2636 else:
2637 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
2639 self.gPodder.hide()
2641 if self.tray_icon is not None:
2642 self.tray_icon.set_visible(False)
2644 # Notify all tasks to to carry out any clean-up actions
2645 self.download_status_model.tell_all_tasks_to_quit()
2647 while gtk.events_pending():
2648 gtk.main_iteration(False)
2650 self.db.close()
2652 self.quit()
2653 sys.exit(0)
2655 def get_expired_episodes(self):
2656 for channel in self.channels:
2657 for episode in channel.get_downloaded_episodes():
2658 # Never consider locked episodes as old
2659 if episode.is_locked:
2660 continue
2662 # Never consider fresh episodes as old
2663 if episode.age_in_days() < self.config.episode_old_age:
2664 continue
2666 # Do not delete played episodes (except if configured)
2667 if episode.is_played:
2668 if not self.config.auto_remove_played_episodes:
2669 continue
2671 # Do not delete unplayed episodes (except if configured)
2672 if not episode.is_played:
2673 if not self.config.auto_remove_unplayed_episodes:
2674 continue
2676 yield episode
2678 def delete_episode_list(self, episodes, confirm=True):
2679 if not episodes:
2680 return False
2682 count = len(episodes)
2684 if count == 1:
2685 episode = episodes[0]
2686 if episode.is_locked:
2687 title = _('%s is locked') % saxutils.escape(episode.title)
2688 message = _('You cannot delete this locked episode. You must unlock it before you can delete it.')
2689 self.notification(message, title, widget=self.treeAvailable)
2690 return False
2692 title = _('Remove %s?') % saxutils.escape(episode.title)
2693 message = _("If you remove this episode, it will be deleted from your computer. If you want to listen to this episode again, you will have to re-download it.")
2694 else:
2695 title = N_('Remove %d episode?', 'Remove %d episodes?', count) % count
2696 message = _('If you remove these episodes, they will be deleted from your computer. If you want to listen to any of these episodes again, you will have to re-download the episodes in question.')
2698 locked_count = sum(e.is_locked for e in episodes)
2700 if count == locked_count:
2701 title = _('Episodes are locked')
2702 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2703 self.notification(message, title, widget=self.treeAvailable)
2704 return False
2705 elif locked_count > 0:
2706 title = _('Remove %(unlocked)d out of %(selected)d episodes?') % {'unlocked': count-locked_count, 'selected': count}
2707 message = _('The selection contains locked episodes that will not be deleted. If you want to listen to the deleted episodes, you will have to re-download them.')
2709 if confirm and not self.show_confirmation(message, title):
2710 return False
2712 progress = ProgressIndicator(_('Removing episodes'), \
2713 _('Please wait while episodes are deleted'), \
2714 parent=self.main_window)
2716 def finish_deletion(episode_urls, channel_urls):
2717 progress.on_finished()
2719 # Episodes have been deleted - persist the database
2720 self.db.commit()
2722 self.update_episode_list_icons(episode_urls)
2723 self.update_podcast_list_model(channel_urls)
2724 self.play_or_download()
2726 def thread_proc():
2727 episode_urls = set()
2728 channel_urls = set()
2730 episodes_status_update = []
2731 for idx, episode in enumerate(episodes):
2732 progress.on_progress(float(idx)/float(len(episodes)))
2733 if episode.is_locked:
2734 log('Not deleting episode (is locked): %s', episode.title)
2735 else:
2736 log('Deleting episode: %s', episode.title)
2737 progress.on_message(_('Deleting: %s') % episode.title)
2738 episode.delete_from_disk()
2739 episode_urls.add(episode.url)
2740 channel_urls.add(episode.channel.url)
2741 episodes_status_update.append(episode)
2743 # Tell the shownotes window that we have removed the episode
2744 if self.episode_shownotes_window is not None and \
2745 self.episode_shownotes_window.episode is not None and \
2746 self.episode_shownotes_window.episode.url == episode.url:
2747 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
2749 # Notify the web service about the status update + upload
2750 self.mygpo_client.on_delete(episodes_status_update)
2751 self.mygpo_client.flush()
2753 util.idle_add(finish_deletion, episode_urls, channel_urls)
2755 threading.Thread(target=thread_proc).start()
2757 return True
2759 def on_itemRemoveOldEpisodes_activate( self, widget):
2760 if gpodder.ui.maemo:
2761 columns = (
2762 ('maemo_remove_markup', None, None, _('Episode')),
2764 else:
2765 columns = (
2766 ('title_markup', None, None, _('Episode')),
2767 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2768 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2769 ('played_prop', None, None, _('Status')),
2770 ('age_prop', None, None, _('Downloaded')),
2773 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
2774 selection_buttons = {
2775 _('Select played'): lambda episode: episode.is_played,
2776 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
2779 instructions = _('Select the episodes you want to delete:')
2781 episodes = []
2782 selected = []
2783 for channel in self.channels:
2784 for episode in channel.get_downloaded_episodes():
2785 # Disallow deletion of locked episodes that still exist
2786 if not episode.is_locked or not episode.file_exists():
2787 episodes.append(episode)
2788 # Automatically select played and file-less episodes
2789 selected.append(episode.is_played or \
2790 not episode.file_exists())
2792 gPodderEpisodeSelector(self.gPodder, title = _('Remove old episodes'), instructions = instructions, \
2793 episodes = episodes, selected = selected, columns = columns, \
2794 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
2795 selection_buttons = selection_buttons, _config=self.config)
2797 def on_selected_episodes_status_changed(self):
2798 self.update_episode_list_icons(selected=True)
2799 self.update_podcast_list_model(selected=True)
2800 self.db.commit()
2802 def mark_selected_episodes_new(self):
2803 for episode in self.get_selected_episodes():
2804 episode.mark_new()
2805 self.on_selected_episodes_status_changed()
2807 def mark_selected_episodes_old(self):
2808 for episode in self.get_selected_episodes():
2809 episode.mark_old()
2810 self.on_selected_episodes_status_changed()
2812 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
2813 for episode in self.get_selected_episodes():
2814 if toggle:
2815 episode.mark(is_played=not episode.is_played)
2816 else:
2817 episode.mark(is_played=new_value)
2818 self.on_selected_episodes_status_changed()
2820 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2821 for episode in self.get_selected_episodes():
2822 if toggle:
2823 episode.mark(is_locked=not episode.is_locked)
2824 else:
2825 episode.mark(is_locked=new_value)
2826 self.on_selected_episodes_status_changed()
2828 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2829 if self.active_channel is None:
2830 return
2832 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
2833 self.active_channel.update_channel_lock()
2835 for episode in self.active_channel.get_all_episodes():
2836 episode.mark(is_locked=self.active_channel.channel_is_locked)
2838 self.update_podcast_list_model(selected=True)
2839 self.update_episode_list_icons(all=True)
2841 def on_itemUpdateChannel_activate(self, widget=None):
2842 if self.active_channel is None:
2843 title = _('No podcast selected')
2844 message = _('Please select a podcast in the podcasts list to update.')
2845 self.show_message( message, title, widget=self.treeChannels)
2846 return
2848 self.update_feed_cache(channels=[self.active_channel])
2850 def on_itemUpdate_activate(self, widget=None):
2851 # Check if we have outstanding subscribe/unsubscribe actions
2852 if self.on_add_remove_podcasts_mygpo():
2853 log('Update cancelled (received server changes)', sender=self)
2854 return
2856 if self.channels:
2857 self.update_feed_cache()
2858 else:
2859 gPodderWelcome(self.gPodder,
2860 center_on_widget=self.gPodder,
2861 show_example_podcasts_callback=self.on_itemImportChannels_activate,
2862 setup_my_gpodder_callback=self.on_mygpo_settings_activate)
2864 def download_episode_list_paused(self, episodes):
2865 self.download_episode_list(episodes, True)
2867 def download_episode_list(self, episodes, add_paused=False):
2868 for episode in episodes:
2869 log('Downloading episode: %s', episode.title, sender = self)
2870 if not episode.was_downloaded(and_exists=True):
2871 task_exists = False
2872 for task in self.download_tasks_seen:
2873 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
2874 self.download_queue_manager.add_task(task)
2875 self.enable_download_list_update()
2876 task_exists = True
2877 continue
2879 if task_exists:
2880 continue
2882 try:
2883 task = download.DownloadTask(episode, self.config)
2884 except Exception, e:
2885 d = {'episode': episode.title, 'message': str(e)}
2886 message = _('Download error while downloading %(episode)s: %(message)s')
2887 self.show_message(message % d, _('Download error'), important=True)
2888 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
2889 continue
2891 if add_paused:
2892 task.status = task.PAUSED
2893 else:
2894 self.mygpo_client.on_download([task.episode])
2895 self.download_queue_manager.add_task(task)
2897 self.download_status_model.register_task(task)
2898 self.enable_download_list_update()
2900 # Flush updated episode status
2901 self.mygpo_client.flush()
2903 def cancel_task_list(self, tasks):
2904 if not tasks:
2905 return
2907 for task in tasks:
2908 if task.status in (task.QUEUED, task.DOWNLOADING):
2909 task.status = task.CANCELLED
2910 elif task.status == task.PAUSED:
2911 task.status = task.CANCELLED
2912 # Call run, so the partial file gets deleted
2913 task.run()
2915 self.update_episode_list_icons([task.url for task in tasks])
2916 self.play_or_download()
2918 # Update the tab title and downloads list
2919 self.update_downloads_list()
2921 def new_episodes_show(self, episodes, notification=False):
2922 if gpodder.ui.maemo:
2923 columns = (
2924 ('maemo_markup', None, None, _('Episode')),
2926 show_notification = notification
2927 else:
2928 columns = (
2929 ('title_markup', None, None, _('Episode')),
2930 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2931 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2933 show_notification = False
2935 instructions = _('Select the episodes you want to download:')
2937 if self.new_episodes_window is not None:
2938 self.new_episodes_window.main_window.destroy()
2939 self.new_episodes_window = None
2941 def download_episodes_callback(episodes):
2942 self.new_episodes_window = None
2943 self.download_episode_list(episodes)
2945 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
2946 title=_('New episodes available'), \
2947 instructions=instructions, \
2948 episodes=episodes, \
2949 columns=columns, \
2950 selected_default=True, \
2951 stock_ok_button = 'gpodder-download', \
2952 callback=download_episodes_callback, \
2953 remove_callback=lambda e: e.mark_old(), \
2954 remove_action=_('Mark as old'), \
2955 remove_finished=self.episode_new_status_changed, \
2956 _config=self.config, \
2957 show_notification=show_notification)
2959 def on_itemDownloadAllNew_activate(self, widget, *args):
2960 if not self.offer_new_episodes():
2961 self.show_message(_('Please check for new episodes later.'), \
2962 _('No new episodes available'), widget=self.btnUpdateFeeds)
2964 def get_new_episodes(self, channels=None):
2965 if channels is None:
2966 channels = self.channels
2967 episodes = []
2968 for channel in channels:
2969 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
2970 episodes.append(episode)
2972 return episodes
2974 def on_sync_to_ipod_activate(self, widget, episodes=None):
2975 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
2977 def commit_changes_to_database(self):
2978 """This will be called after the sync process is finished"""
2979 self.db.commit()
2981 def on_cleanup_ipod_activate(self, widget, *args):
2982 self.sync_ui.on_cleanup_device()
2984 def on_manage_device_playlist(self, widget):
2985 self.sync_ui.on_manage_device_playlist()
2987 def show_hide_tray_icon(self):
2988 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
2989 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
2990 elif not self.config.display_tray_icon and self.tray_icon is not None:
2991 self.tray_icon.set_visible(False)
2992 del self.tray_icon
2993 self.tray_icon = None
2995 if self.config.minimize_to_tray and self.tray_icon:
2996 self.tray_icon.set_visible(self.is_iconified())
2997 elif self.tray_icon:
2998 self.tray_icon.set_visible(True)
3000 def on_itemShowAllEpisodes_activate(self, widget):
3001 self.config.podcast_list_view_all = widget.get_active()
3003 def on_itemShowToolbar_activate(self, widget):
3004 self.config.show_toolbar = self.itemShowToolbar.get_active()
3006 def on_itemShowDescription_activate(self, widget):
3007 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3009 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3010 self.config.podcast_list_hide_boring = toggleaction.get_active()
3011 if self.config.podcast_list_hide_boring:
3012 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3013 else:
3014 self.podcast_list_model.set_view_mode(-1)
3016 def on_item_view_podcasts_changed(self, radioaction, current):
3017 # Only on Fremantle
3018 if current == self.item_view_podcasts_all:
3019 self.podcast_list_model.set_view_mode(-1)
3020 elif current == self.item_view_podcasts_downloaded:
3021 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3022 elif current == self.item_view_podcasts_unplayed:
3023 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3025 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3027 def on_item_view_episodes_changed(self, radioaction, current):
3028 if current == self.item_view_episodes_all:
3029 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
3030 elif current == self.item_view_episodes_undeleted:
3031 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
3032 elif current == self.item_view_episodes_downloaded:
3033 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3034 elif current == self.item_view_episodes_unplayed:
3035 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3037 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
3039 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3040 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3042 def update_item_device( self):
3043 if not gpodder.ui.fremantle:
3044 if self.config.device_type != 'none':
3045 self.itemDevice.set_visible(True)
3046 self.itemDevice.label = self.get_device_name()
3047 else:
3048 self.itemDevice.set_visible(False)
3050 def properties_closed( self):
3051 self.preferences_dialog = None
3052 self.show_hide_tray_icon()
3053 self.update_item_device()
3054 if gpodder.ui.maemo:
3055 selection = self.treeAvailable.get_selection()
3056 if self.config.maemo_enable_gestures or \
3057 self.config.enable_fingerscroll:
3058 selection.set_mode(gtk.SELECTION_SINGLE)
3059 else:
3060 selection.set_mode(gtk.SELECTION_MULTIPLE)
3062 def on_itemPreferences_activate(self, widget, *args):
3063 self.preferences_dialog = gPodderPreferences(self.main_window, \
3064 _config=self.config, \
3065 callback_finished=self.properties_closed, \
3066 user_apps_reader=self.user_apps_reader, \
3067 mygpo_login=self.on_mygpo_settings_activate, \
3068 on_itemAbout_activate=self.on_itemAbout_activate, \
3069 on_wiki_activate=self.on_wiki_activate, \
3070 parent_window=self.main_window)
3072 # Initial message to relayout window (in case it's opened in portrait mode
3073 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3075 def on_itemDependencies_activate(self, widget):
3076 gPodderDependencyManager(self.gPodder)
3078 def on_goto_mygpo(self, widget):
3079 self.mygpo_client.open_website()
3081 def on_mygpo_settings_activate(self, action=None):
3082 settings = MygPodderSettings(self.main_window, \
3083 config=self.config, \
3084 mygpo_client=self.mygpo_client, \
3085 on_send_full_subscriptions=self.on_send_full_subscriptions)
3087 def on_itemAddChannel_activate(self, widget=None):
3088 gPodderAddPodcast(self.gPodder, \
3089 add_urls_callback=self.add_podcast_list)
3091 def on_itemEditChannel_activate(self, widget, *args):
3092 if self.active_channel is None:
3093 title = _('No podcast selected')
3094 message = _('Please select a podcast in the podcasts list to edit.')
3095 self.show_message( message, title, widget=self.treeChannels)
3096 return
3098 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3099 gPodderChannel(self.main_window, \
3100 channel=self.active_channel, \
3101 callback_closed=callback_closed, \
3102 cover_downloader=self.cover_downloader)
3104 def on_itemMassUnsubscribe_activate(self, item=None):
3105 columns = (
3106 ('title', None, None, _('Podcast')),
3109 # We're abusing the Episode Selector for selecting Podcasts here,
3110 # but it works and looks good, so why not? -- thp
3111 gPodderEpisodeSelector(self.main_window, \
3112 title=_('Remove podcasts'), \
3113 instructions=_('Select the podcast you want to remove.'), \
3114 episodes=self.channels, \
3115 columns=columns, \
3116 size_attribute=None, \
3117 stock_ok_button=gtk.STOCK_DELETE, \
3118 callback=self.remove_podcast_list, \
3119 _config=self.config)
3121 def remove_podcast_list(self, channels, confirm=True):
3122 if not channels:
3123 log('No podcasts selected for deletion', sender=self)
3124 return
3126 if len(channels) == 1:
3127 title = _('Removing podcast')
3128 info = _('Please wait while the podcast is removed')
3129 message = _('Do you really want to remove this podcast and its episodes?')
3130 else:
3131 title = _('Removing podcasts')
3132 info = _('Please wait while the podcasts are removed')
3133 message = _('Do you really want to remove the selected podcasts and their episodes?')
3135 if confirm and not self.show_confirmation(message, title):
3136 return
3138 progress = ProgressIndicator(title, info, parent=self.main_window)
3140 def finish_deletion(select_url):
3141 # Upload subscription list changes to the web service
3142 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3144 # Re-load the channels and select the desired new channel
3145 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3146 progress.on_finished()
3147 self.update_podcasts_tab()
3149 def thread_proc():
3150 select_url = None
3152 for idx, channel in enumerate(channels):
3153 # Update the UI for correct status messages
3154 progress.on_progress(float(idx)/float(len(channels)))
3155 progress.on_message(_('Removing %s') % channel.title)
3157 # Delete downloaded episodes
3158 channel.remove_downloaded()
3160 # cancel any active downloads from this channel
3161 for episode in channel.get_all_episodes():
3162 util.idle_add(self.download_status_model.cancel_by_url,
3163 episode.url)
3165 if len(channels) == 1:
3166 # get the URL of the podcast we want to select next
3167 if channel in self.channels:
3168 position = self.channels.index(channel)
3169 else:
3170 position = -1
3172 if position == len(self.channels)-1:
3173 # this is the last podcast, so select the URL
3174 # of the item before this one (i.e. the "new last")
3175 select_url = self.channels[position-1].url
3176 else:
3177 # there is a podcast after the deleted one, so
3178 # we simply select the one that comes after it
3179 select_url = self.channels[position+1].url
3181 # Remove the channel and clean the database entries
3182 channel.delete(purge=True)
3183 self.channels.remove(channel)
3185 # Clean up downloads and download directories
3186 self.clean_up_downloads()
3188 self.channel_list_changed = True
3189 self.save_channels_opml()
3191 # The remaining stuff is to be done in the GTK main thread
3192 util.idle_add(finish_deletion, select_url)
3194 threading.Thread(target=thread_proc).start()
3196 def on_itemRemoveChannel_activate(self, widget, *args):
3197 if self.active_channel is None:
3198 title = _('No podcast selected')
3199 message = _('Please select a podcast in the podcasts list to remove.')
3200 self.show_message( message, title, widget=self.treeChannels)
3201 return
3203 self.remove_podcast_list([self.active_channel])
3205 def get_opml_filter(self):
3206 filter = gtk.FileFilter()
3207 filter.add_pattern('*.opml')
3208 filter.add_pattern('*.xml')
3209 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3210 return filter
3212 def on_item_import_from_file_activate(self, widget, filename=None):
3213 if filename is None:
3214 if gpodder.ui.desktop or gpodder.ui.fremantle:
3215 # FIXME: Hildonization on Fremantle
3216 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3217 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3218 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3219 elif gpodder.ui.diablo:
3220 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
3221 dlg.set_filter(self.get_opml_filter())
3222 response = dlg.run()
3223 filename = None
3224 if response == gtk.RESPONSE_OK:
3225 filename = dlg.get_filename()
3226 dlg.destroy()
3228 if filename is not None:
3229 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3230 custom_title=_('Import podcasts from OPML file'), \
3231 add_urls_callback=self.add_podcast_list, \
3232 hide_url_entry=True)
3233 dir.download_opml_file(filename)
3235 def on_itemExportChannels_activate(self, widget, *args):
3236 if not self.channels:
3237 title = _('Nothing to export')
3238 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3239 self.show_message(message, title, widget=self.treeChannels)
3240 return
3242 if gpodder.ui.desktop or gpodder.ui.fremantle:
3243 # FIXME: Hildonization on Fremantle
3244 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3245 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3246 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3247 elif gpodder.ui.diablo:
3248 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3249 dlg.set_filter(self.get_opml_filter())
3250 response = dlg.run()
3251 if response == gtk.RESPONSE_OK:
3252 filename = dlg.get_filename()
3253 dlg.destroy()
3254 exporter = opml.Exporter( filename)
3255 if exporter.write(self.channels):
3256 count = len(self.channels)
3257 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3258 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3259 else:
3260 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3261 else:
3262 dlg.destroy()
3264 def on_itemImportChannels_activate(self, widget, *args):
3265 if gpodder.ui.fremantle:
3266 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3267 self.config.toplist_url, \
3268 self.config.opml_url, \
3269 self.add_podcast_list, \
3270 self.on_itemAddChannel_activate, \
3271 self.on_mygpo_settings_activate, \
3272 self.show_text_edit_dialog)
3273 else:
3274 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3275 add_urls_callback=self.add_podcast_list)
3276 util.idle_add(dir.download_opml_file, self.config.opml_url)
3278 def on_homepage_activate(self, widget, *args):
3279 util.open_website(gpodder.__url__)
3281 def on_wiki_activate(self, widget, *args):
3282 util.open_website('http://gpodder.org/wiki/User_Manual')
3284 def on_bug_tracker_activate(self, widget, *args):
3285 if gpodder.ui.maemo:
3286 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3287 else:
3288 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder')
3290 def on_item_support_activate(self, widget):
3291 util.open_website('http://gpodder.org/donate')
3293 def on_itemAbout_activate(self, widget, *args):
3294 dlg = gtk.AboutDialog()
3295 dlg.set_transient_for(self.main_window)
3296 dlg.set_name('gPodder')
3297 dlg.set_version(gpodder.__version__)
3298 dlg.set_copyright(gpodder.__copyright__)
3299 dlg.set_comments(_('A podcast client with focus on usability'))
3300 if not gpodder.ui.fremantle:
3301 # Disable the URL label in Fremantle because of style issues
3302 dlg.set_website(gpodder.__url__)
3303 dlg.set_translator_credits( _('translator-credits'))
3304 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3306 if gpodder.ui.desktop:
3307 # For the "GUI" version, we add some more
3308 # items to the about dialog (credits and logo)
3309 app_authors = [
3310 _('Maintainer:'),
3311 'Thomas Perl <thpinfo.com>',
3314 if os.path.exists(gpodder.credits_file):
3315 credits = open(gpodder.credits_file).read().strip().split('\n')
3316 app_authors += ['', _('Patches, bug reports and donations by:')]
3317 app_authors += credits
3319 dlg.set_authors(app_authors)
3320 try:
3321 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3322 except:
3323 dlg.set_logo_icon_name('gpodder')
3324 elif gpodder.ui.fremantle:
3325 for parent in dlg.vbox.get_children():
3326 for child in parent.get_children():
3327 if isinstance(child, gtk.Label):
3328 child.set_selectable(False)
3329 child.set_alignment(0.0, 0.5)
3331 dlg.run()
3333 def on_wNotebook_switch_page(self, widget, *args):
3334 page_num = args[1]
3335 if gpodder.ui.maemo:
3336 self.tool_downloads.set_active(page_num == 1)
3337 page = self.wNotebook.get_nth_page(page_num)
3338 tab_label = self.wNotebook.get_tab_label(page).get_text()
3339 if page_num == 0 and self.active_channel is not None:
3340 self.set_title(self.active_channel.title)
3341 else:
3342 self.set_title(tab_label)
3343 if page_num == 0:
3344 self.play_or_download()
3345 self.menuChannels.set_sensitive(True)
3346 self.menuSubscriptions.set_sensitive(True)
3347 # The message area in the downloads tab should be hidden
3348 # when the user switches away from the downloads tab
3349 if self.message_area is not None:
3350 self.message_area.hide()
3351 self.message_area = None
3352 else:
3353 self.menuChannels.set_sensitive(False)
3354 self.menuSubscriptions.set_sensitive(False)
3355 if gpodder.ui.desktop:
3356 self.toolDownload.set_sensitive(False)
3357 self.toolPlay.set_sensitive(False)
3358 self.toolTransfer.set_sensitive(False)
3359 self.toolCancel.set_sensitive(False)
3361 def on_treeChannels_row_activated(self, widget, path, *args):
3362 # double-click action of the podcast list or enter
3363 self.treeChannels.set_cursor(path)
3365 def on_treeChannels_cursor_changed(self, widget, *args):
3366 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3368 if model is not None and iter is not None:
3369 old_active_channel = self.active_channel
3370 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3372 if self.active_channel == old_active_channel:
3373 return
3375 if gpodder.ui.maemo:
3376 self.set_title(self.active_channel.title)
3377 self.itemEditChannel.set_visible(True)
3378 self.itemRemoveChannel.set_visible(True)
3379 else:
3380 self.active_channel = None
3381 self.itemEditChannel.set_visible(False)
3382 self.itemRemoveChannel.set_visible(False)
3384 self.update_episode_list_model()
3386 def on_btnEditChannel_clicked(self, widget, *args):
3387 self.on_itemEditChannel_activate( widget, args)
3389 def get_podcast_urls_from_selected_episodes(self):
3390 """Get a set of podcast URLs based on the selected episodes"""
3391 return set(episode.channel.url for episode in \
3392 self.get_selected_episodes())
3394 def get_selected_episodes(self):
3395 """Get a list of selected episodes from treeAvailable"""
3396 selection = self.treeAvailable.get_selection()
3397 model, paths = selection.get_selected_rows()
3399 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3400 return episodes
3402 def on_transfer_selected_episodes(self, widget):
3403 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3405 def on_playback_selected_episodes(self, widget):
3406 self.playback_episodes(self.get_selected_episodes())
3408 def on_shownotes_selected_episodes(self, widget):
3409 episodes = self.get_selected_episodes()
3410 if episodes:
3411 episode = episodes.pop(0)
3412 self.show_episode_shownotes(episode)
3413 else:
3414 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3416 def on_download_selected_episodes(self, widget):
3417 episodes = self.get_selected_episodes()
3418 self.download_episode_list(episodes)
3419 self.update_episode_list_icons([episode.url for episode in episodes])
3420 self.play_or_download()
3422 def on_treeAvailable_row_activated(self, widget, path, view_column):
3423 """Double-click/enter action handler for treeAvailable"""
3424 # We should only have one one selected as it was double clicked!
3425 e = self.get_selected_episodes()[0]
3427 if (self.config.double_click_episode_action == 'download'):
3428 # If the episode has already been downloaded and exists then play it
3429 if e.was_downloaded(and_exists=True):
3430 self.playback_episodes(self.get_selected_episodes())
3431 # else download it if it is not already downloading
3432 elif not self.episode_is_downloading(e):
3433 self.download_episode_list([e])
3434 self.update_episode_list_icons([e.url])
3435 self.play_or_download()
3436 elif (self.config.double_click_episode_action == 'stream'):
3437 # If we happen to have downloaded this episode simple play it
3438 if e.was_downloaded(and_exists=True):
3439 self.playback_episodes(self.get_selected_episodes())
3440 # else if streaming is possible stream it
3441 elif self.streaming_possible():
3442 self.playback_episodes(self.get_selected_episodes())
3443 else:
3444 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3445 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3446 else:
3447 # default action is to display show notes
3448 self.on_shownotes_selected_episodes(widget)
3450 def show_episode_shownotes(self, episode):
3451 if self.episode_shownotes_window is None:
3452 log('First-time use of episode window --- creating', sender=self)
3453 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3454 _download_episode_list=self.download_episode_list, \
3455 _playback_episodes=self.playback_episodes, \
3456 _delete_episode_list=self.delete_episode_list, \
3457 _episode_list_status_changed=self.episode_list_status_changed, \
3458 _cancel_task_list=self.cancel_task_list, \
3459 _episode_is_downloading=self.episode_is_downloading, \
3460 _streaming_possible=self.streaming_possible())
3461 self.episode_shownotes_window.show(episode)
3462 if self.episode_is_downloading(episode):
3463 self.update_downloads_list()
3465 def restart_auto_update_timer(self):
3466 if self._auto_update_timer_source_id is not None:
3467 log('Removing existing auto update timer.', sender=self)
3468 gobject.source_remove(self._auto_update_timer_source_id)
3469 self._auto_update_timer_source_id = None
3471 if self.config.auto_update_feeds and \
3472 self.config.auto_update_frequency:
3473 interval = 60*1000*self.config.auto_update_frequency
3474 log('Setting up auto update timer with interval %d.', \
3475 self.config.auto_update_frequency, sender=self)
3476 self._auto_update_timer_source_id = gobject.timeout_add(\
3477 interval, self._on_auto_update_timer)
3479 def _on_auto_update_timer(self):
3480 log('Auto update timer fired.', sender=self)
3481 self.update_feed_cache(force_update=True)
3483 # Ask web service for sub changes (if enabled)
3484 self.mygpo_client.flush()
3486 return True
3488 def on_treeDownloads_row_activated(self, widget, *args):
3489 # Use the standard way of working on the treeview
3490 selection = self.treeDownloads.get_selection()
3491 (model, paths) = selection.get_selected_rows()
3492 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3494 for tree_row_reference, task in selected_tasks:
3495 if task.status in (task.DOWNLOADING, task.QUEUED):
3496 task.status = task.PAUSED
3497 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3498 self.download_queue_manager.add_task(task)
3499 self.enable_download_list_update()
3500 elif task.status == task.DONE:
3501 model.remove(model.get_iter(tree_row_reference.get_path()))
3503 self.play_or_download()
3505 # Update the tab title and downloads list
3506 self.update_downloads_list()
3508 def on_item_cancel_download_activate(self, widget):
3509 if self.wNotebook.get_current_page() == 0:
3510 selection = self.treeAvailable.get_selection()
3511 (model, paths) = selection.get_selected_rows()
3512 urls = [model.get_value(model.get_iter(path), \
3513 self.episode_list_model.C_URL) for path in paths]
3514 selected_tasks = [task for task in self.download_tasks_seen \
3515 if task.url in urls]
3516 else:
3517 selection = self.treeDownloads.get_selection()
3518 (model, paths) = selection.get_selected_rows()
3519 selected_tasks = [model.get_value(model.get_iter(path), \
3520 self.download_status_model.C_TASK) for path in paths]
3521 self.cancel_task_list(selected_tasks)
3523 def on_btnCancelAll_clicked(self, widget, *args):
3524 self.cancel_task_list(self.download_tasks_seen)
3526 def on_btnDownloadedDelete_clicked(self, widget, *args):
3527 if self.wNotebook.get_current_page() == 1:
3528 # Downloads tab visibile - skip (for now)
3529 return
3531 episodes = self.get_selected_episodes()
3532 self.delete_episode_list(episodes)
3534 def on_key_press(self, widget, event):
3535 # Allow tab switching with Ctrl + PgUp/PgDown
3536 if event.state & gtk.gdk.CONTROL_MASK:
3537 if event.keyval == gtk.keysyms.Page_Up:
3538 self.wNotebook.prev_page()
3539 return True
3540 elif event.keyval == gtk.keysyms.Page_Down:
3541 self.wNotebook.next_page()
3542 return True
3544 # After this code we only handle Maemo hardware keys,
3545 # so if we are not a Maemo app, we don't do anything
3546 if not gpodder.ui.maemo:
3547 return False
3549 diff = 0
3550 if event.keyval == gtk.keysyms.F7: #plus
3551 diff = 1
3552 elif event.keyval == gtk.keysyms.F8: #minus
3553 diff = -1
3555 if diff != 0 and not self.currently_updating:
3556 selection = self.treeChannels.get_selection()
3557 (model, iter) = selection.get_selected()
3558 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
3559 selection.select_path(new_path)
3560 self.treeChannels.set_cursor(new_path)
3561 return True
3563 return False
3565 def on_iconify(self):
3566 if self.tray_icon:
3567 self.gPodder.set_skip_taskbar_hint(True)
3568 if self.config.minimize_to_tray:
3569 self.tray_icon.set_visible(True)
3570 else:
3571 self.gPodder.set_skip_taskbar_hint(False)
3573 def on_uniconify(self):
3574 if self.tray_icon:
3575 self.gPodder.set_skip_taskbar_hint(False)
3576 if self.config.minimize_to_tray:
3577 self.tray_icon.set_visible(False)
3578 else:
3579 self.gPodder.set_skip_taskbar_hint(False)
3581 def uniconify_main_window(self):
3582 if self.is_iconified():
3583 self.gPodder.present()
3585 def iconify_main_window(self):
3586 if not self.is_iconified():
3587 self.gPodder.iconify()
3589 def update_podcasts_tab(self):
3590 if len(self.channels):
3591 if gpodder.ui.fremantle:
3592 self.button_refresh.set_title(_('Check for new episodes'))
3593 self.button_refresh.show()
3594 else:
3595 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
3596 else:
3597 if gpodder.ui.fremantle:
3598 self.button_refresh.hide()
3599 else:
3600 self.label2.set_text(_('Podcasts'))
3602 @dbus.service.method(gpodder.dbus_interface)
3603 def show_gui_window(self):
3604 self.gPodder.present()
3606 @dbus.service.method(gpodder.dbus_interface)
3607 def subscribe_to_url(self, url):
3608 gPodderAddPodcast(self.gPodder,
3609 add_urls_callback=self.add_podcast_list,
3610 preset_url=url)
3612 @dbus.service.method(gpodder.dbus_interface)
3613 def mark_episode_played(self, filename):
3614 if filename is None:
3615 return False
3617 for channel in self.channels:
3618 for episode in channel.get_all_episodes():
3619 fn = episode.local_filename(create=False, check_only=True)
3620 if fn == filename:
3621 episode.mark(is_played=True)
3622 self.db.commit()
3623 self.update_episode_list_icons([episode.url])
3624 self.update_podcast_list_model([episode.channel.url])
3625 return True
3627 return False
3630 def main(options=None):
3631 gobject.threads_init()
3632 gobject.set_application_name('gPodder')
3634 if gpodder.ui.maemo:
3635 # Try to enable the custom icon theme for gPodder on Maemo
3636 settings = gtk.settings_get_default()
3637 settings.set_string_property('gtk-icon-theme-name', \
3638 'gpodder', __file__)
3639 # Extend the search path for the optified icon theme (Maemo 5)
3640 icon_theme = gtk.icon_theme_get_default()
3641 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
3643 gtk.window_set_default_icon_name('gpodder')
3644 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
3646 try:
3647 session_bus = dbus.SessionBus(mainloop=dbus.glib.DBusGMainLoop())
3648 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=session_bus)
3649 except dbus.exceptions.DBusException, dbe:
3650 log('Warning: Cannot get "on the bus".', traceback=True)
3651 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
3652 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
3653 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
3654 dlg.set_title('gPodder')
3655 dlg.run()
3656 dlg.destroy()
3657 sys.exit(0)
3659 util.make_directory(gpodder.home)
3660 gpodder.load_plugins()
3662 config = UIConfig(gpodder.config_file)
3664 if gpodder.ui.diablo:
3665 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
3666 # folder exists there (allow moving "gpodder" between SD cards or USB)
3667 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
3668 if not os.path.exists(config.download_dir):
3669 log('Downloads might have been moved. Trying to locate them...')
3670 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
3671 dir = os.path.join(basedir, 'gpodder')
3672 if os.path.exists(dir):
3673 log('Downloads found in: %s', dir)
3674 config.download_dir = dir
3675 break
3676 else:
3677 log('Downloads NOT FOUND in %s', dir)
3679 if config.enable_fingerscroll:
3680 BuilderWidget.use_fingerscroll = True
3681 elif gpodder.ui.fremantle:
3682 config.on_quit_ask = False
3684 gp = gPodder(bus_name, config)
3686 # Handle options
3687 if options.subscribe:
3688 util.idle_add(gp.subscribe_to_url, options.subscribe)
3690 gp.run()