Fixed height mode; episode list optimization
[gpodder.git] / src / gpodder / gui.py
blob47a0b985215510af3a4807ea11a2d89322c40b56
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 platform
22 import gtk
23 import gtk.gdk
24 import gobject
25 import pango
26 import random
27 import sys
28 import shutil
29 import subprocess
30 import glob
31 import time
32 import tempfile
33 import collections
34 import threading
36 from xml.sax import saxutils
38 import gpodder
40 try:
41 import dbus
42 import dbus.service
43 import dbus.mainloop
44 import dbus.glib
45 except ImportError:
46 # Mock the required D-Bus interfaces with no-ops (ugly? maybe.)
47 class dbus:
48 class SessionBus:
49 def __init__(self, *args, **kwargs):
50 pass
51 def add_signal_receiver(self, *args, **kwargs):
52 pass
53 class glib:
54 class DBusGMainLoop:
55 def __init__(self, *args, **kwargs):
56 pass
57 class service:
58 @staticmethod
59 def method(*args, **kwargs):
60 return lambda x: x
61 class BusName:
62 def __init__(self, *args, **kwargs):
63 pass
64 class Object:
65 def __init__(self, *args, **kwargs):
66 pass
69 from gpodder import feedcore
70 from gpodder import util
71 from gpodder import opml
72 from gpodder import download
73 from gpodder import my
74 from gpodder import youtube
75 from gpodder import player
76 from gpodder.liblogger import log
78 _ = gpodder.gettext
79 N_ = gpodder.ngettext
81 from gpodder.model import PodcastChannel
82 from gpodder.model import PodcastEpisode
83 from gpodder.dbsqlite import Database
85 from gpodder.gtkui.model import PodcastListModel
86 from gpodder.gtkui.model import EpisodeListModel
87 from gpodder.gtkui.config import UIConfig
88 from gpodder.gtkui.services import CoverDownloader
89 from gpodder.gtkui.widgets import SimpleMessageArea
90 from gpodder.gtkui.desktopfile import UserAppsReader
92 from gpodder.gtkui.draw import draw_text_box_centered
94 from gpodder.gtkui.interface.common import BuilderWidget
95 from gpodder.gtkui.interface.common import TreeViewHelper
96 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
98 if gpodder.ui.desktop:
99 from gpodder.gtkui.download import DownloadStatusModel
101 from gpodder.gtkui.desktop.sync import gPodderSyncUI
103 from gpodder.gtkui.desktop.channel import gPodderChannel
104 from gpodder.gtkui.desktop.preferences import gPodderPreferences
105 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
106 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
107 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
108 from gpodder.gtkui.desktop.dependencymanager import gPodderDependencyManager
109 from gpodder.gtkui.interface.progress import ProgressIndicator
110 try:
111 from gpodder.gtkui.desktop.trayicon import GPodderStatusIcon
112 have_trayicon = True
113 except Exception, exc:
114 log('Warning: Could not import gpodder.trayicon.', traceback=True)
115 log('Warning: This probably means your PyGTK installation is too old!')
116 have_trayicon = False
117 elif gpodder.ui.diablo:
118 from gpodder.gtkui.download import DownloadStatusModel
120 from gpodder.gtkui.maemo.channel import gPodderChannel
121 from gpodder.gtkui.maemo.preferences import gPodderPreferences
122 from gpodder.gtkui.maemo.shownotes import gPodderShownotes
123 from gpodder.gtkui.maemo.episodeselector import gPodderEpisodeSelector
124 from gpodder.gtkui.maemo.podcastdirectory import gPodderPodcastDirectory
125 from gpodder.gtkui.maemo.mygpodder import MygPodderSettings
126 from gpodder.gtkui.interface.progress import ProgressIndicator
127 have_trayicon = False
128 elif gpodder.ui.fremantle:
129 from gpodder.gtkui.frmntl.model import DownloadStatusModel
130 from gpodder.gtkui.frmntl.model import EpisodeListModel
131 from gpodder.gtkui.frmntl.model import PodcastListModel
133 from gpodder.gtkui.maemo.channel import gPodderChannel
134 from gpodder.gtkui.frmntl.preferences import gPodderPreferences
135 from gpodder.gtkui.frmntl.shownotes import gPodderShownotes
136 from gpodder.gtkui.frmntl.episodeselector import gPodderEpisodeSelector
137 from gpodder.gtkui.frmntl.podcastdirectory import gPodderPodcastDirectory
138 from gpodder.gtkui.frmntl.episodes import gPodderEpisodes
139 from gpodder.gtkui.frmntl.downloads import gPodderDownloads
140 from gpodder.gtkui.frmntl.progress import ProgressIndicator
141 from gpodder.gtkui.frmntl.widgets import FancyProgressBar
142 have_trayicon = False
144 from gpodder.gtkui.frmntl.portrait import FremantleRotation
145 from gpodder.gtkui.frmntl.mafw import MafwPlaybackMonitor
146 from gpodder.gtkui.frmntl.hints import HINT_STRINGS
148 from gpodder.gtkui.interface.common import Orientation
150 from gpodder.gtkui.interface.welcome import gPodderWelcome
152 if gpodder.ui.maemo:
153 import hildon
155 from gpodder.dbusproxy import DBusPodcastsProxy
156 from gpodder import hooks
158 class gPodder(BuilderWidget, dbus.service.Object):
159 finger_friendly_widgets = ['btnCleanUpDownloads', 'button_search_episodes_clear', 'label2', 'labelDownloads', 'btnUpdateFeeds']
161 ICON_GENERAL_ADD = 'general_add'
162 ICON_GENERAL_REFRESH = 'general_refresh'
164 def __init__(self, bus_name, config):
165 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
166 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels, \
167 self.on_itemUpdate_activate, \
168 self.playback_episodes, \
169 self.download_episode_list, \
170 self.episode_object_by_uri, \
171 bus_name)
172 self.db = Database(gpodder.database_file)
173 self.config = config
174 BuilderWidget.__init__(self, None)
176 def new(self):
177 if gpodder.ui.diablo:
178 import hildon
179 self.app = hildon.Program()
180 self.app.add_window(self.main_window)
181 self.main_window.add_toolbar(self.toolbar)
182 menu = gtk.Menu()
183 for child in self.main_menu.get_children():
184 child.reparent(menu)
185 self.main_window.set_menu(self.set_finger_friendly(menu))
186 self._last_orientation = Orientation.LANDSCAPE
187 elif gpodder.ui.fremantle:
188 import hildon
189 self.app = hildon.Program()
190 self.app.add_window(self.main_window)
192 appmenu = hildon.AppMenu()
194 for filter in (self.item_view_podcasts_all, \
195 self.item_view_podcasts_downloaded, \
196 self.item_view_podcasts_unplayed):
197 button = gtk.ToggleButton()
198 filter.connect_proxy(button)
199 appmenu.add_filter(button)
201 for action in (self.itemPreferences, \
202 self.item_downloads, \
203 self.itemRemoveOldEpisodes, \
204 self.item_unsubscribe, \
205 self.itemAbout):
206 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
207 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
208 action.connect_proxy(button)
209 if action == self.item_downloads:
210 button.set_title(_('Downloads'))
211 button.set_value(_('Idle'))
212 self.button_downloads = button
213 appmenu.append(button)
215 def show_hint(button):
216 self.show_message(random.choice(HINT_STRINGS), important=True)
218 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
219 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
220 button.set_title(_('Hint of the day'))
221 button.connect('clicked', show_hint)
222 appmenu.append(button)
224 appmenu.show_all()
225 self.main_window.set_app_menu(appmenu)
227 # Initialize portrait mode / rotation manager
228 self._fremantle_rotation = FremantleRotation('gPodder', \
229 self.main_window, \
230 gpodder.__version__, \
231 self.config.rotation_mode)
233 if self.config.rotation_mode == FremantleRotation.ALWAYS:
234 util.idle_add(self.on_window_orientation_changed, \
235 Orientation.PORTRAIT)
236 self._last_orientation = Orientation.PORTRAIT
237 else:
238 self._last_orientation = Orientation.LANDSCAPE
240 # Flag set when a notification is being shown (Maemo bug 11235)
241 self._fremantle_notification_visible = False
242 else:
243 self._last_orientation = Orientation.LANDSCAPE
244 self.toolbar.set_property('visible', self.config.show_toolbar)
246 self.bluetooth_available = util.bluetooth_available()
248 self.config.connect_gtk_window(self.gPodder, 'main_window')
249 if not gpodder.ui.fremantle:
250 self.config.connect_gtk_paned('paned_position', self.channelPaned)
251 self.main_window.show()
253 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
255 if gpodder.ui.fremantle:
256 # Create a D-Bus monitoring object that takes care of
257 # tracking MAFW (Nokia Media Player) playback events
258 # and sends episode playback status events via D-Bus
259 self.mafw_monitor = MafwPlaybackMonitor(gpodder.dbus_session_bus)
261 self.gPodder.connect('key-press-event', self.on_key_press)
263 self.preferences_dialog = None
264 self.config.add_observer(self.on_config_changed)
266 self.tray_icon = None
267 self.episode_shownotes_window = None
268 self.new_episodes_window = None
270 if gpodder.ui.desktop:
271 # Mac OS X-specific UI tweaks: Native main menu integration
272 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
273 if getattr(gtk.gdk, 'WINDOWING', 'x11') == 'quartz':
274 try:
275 import igemacintegration as igemi
277 # Move the menu bar from the window to the Mac menu bar
278 self.mainMenu.hide()
279 igemi.ige_mac_menu_set_menu_bar(self.mainMenu)
281 # Reparent some items to the "Application" menu
282 for widget in ('/mainMenu/menuHelp/itemAbout', \
283 '/mainMenu/menuPodcasts/itemPreferences'):
284 item = self.uimanager1.get_widget(widget)
285 group = igemi.ige_mac_menu_add_app_menu_group()
286 igemi.ige_mac_menu_add_app_menu_item(group, item, None)
288 quit_widget = '/mainMenu/menuPodcasts/itemQuit'
289 quit_item = self.uimanager1.get_widget(quit_widget)
290 igemi.ige_mac_menu_set_quit_menu_item(quit_item)
291 except ImportError:
292 print >>sys.stderr, """
293 Warning: ige-mac-integration not found - no native menus.
296 self.sync_ui = gPodderSyncUI(self.config, self.notification, \
297 self.main_window, self.show_confirmation, \
298 self.update_episode_list_icons, \
299 self.update_podcast_list_model, self.toolPreferences, \
300 gPodderEpisodeSelector, \
301 self.commit_changes_to_database)
302 else:
303 self.sync_ui = None
305 self.download_status_model = DownloadStatusModel()
306 self.download_queue_manager = download.DownloadQueueManager(self.config)
308 if gpodder.ui.desktop:
309 self.show_hide_tray_icon()
310 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
311 self.itemShowToolbar.set_active(self.config.show_toolbar)
312 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
314 if not gpodder.ui.fremantle:
315 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
316 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
317 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
318 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
320 # When the amount of maximum downloads changes, notify the queue manager
321 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
322 self.spinMaxDownloads.connect('value-changed', changed_cb)
324 self.default_title = 'gPodder'
325 if gpodder.__version__.rfind('git') != -1:
326 self.set_title('gPodder %s' % gpodder.__version__)
327 else:
328 title = self.gPodder.get_title()
329 if title is not None:
330 self.set_title(title)
331 else:
332 self.set_title(_('gPodder'))
334 self.cover_downloader = CoverDownloader()
336 # Generate list models for podcasts and their episodes
337 self.podcast_list_model = PodcastListModel(self.cover_downloader)
339 self.cover_downloader.register('cover-available', self.cover_download_finished)
340 self.cover_downloader.register('cover-removed', self.cover_file_removed)
342 if gpodder.ui.fremantle:
343 # Work around Maemo bug #4718
344 self.button_refresh.set_name('HildonButton-finger')
345 self.button_subscribe.set_name('HildonButton-finger')
347 self.button_refresh.set_sensitive(False)
348 self.button_subscribe.set_sensitive(False)
350 self.button_subscribe.set_image(gtk.image_new_from_icon_name(\
351 self.ICON_GENERAL_ADD, gtk.ICON_SIZE_BUTTON))
352 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
353 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
355 # Make the button scroll together with the TreeView contents
356 action_area_box = self.treeChannels.get_action_area_box()
357 for child in self.buttonbox:
358 child.reparent(action_area_box)
359 self.vbox.remove(self.buttonbox)
360 action_area_box.set_spacing(2)
361 action_area_box.set_border_width(3)
362 self.treeChannels.set_action_area_visible(True)
364 # Set up a very nice progress bar setup
365 self.fancy_progress_bar = FancyProgressBar(self.main_window, \
366 self.on_btnCancelFeedUpdate_clicked)
367 self.pbFeedUpdate = self.fancy_progress_bar.progress_bar
368 self.pbFeedUpdate.set_ellipsize(pango.ELLIPSIZE_MIDDLE)
369 self.vbox.pack_start(self.fancy_progress_bar.event_box, False)
371 from gpodder.gtkui.frmntl import style
372 sub_font = style.get_font_desc('SmallSystemFont')
373 sub_color = style.get_color('SecondaryTextColor')
374 sub = (sub_font.to_string(), sub_color.to_string())
375 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
376 self.label_footer.set_markup(sub % gpodder.__copyright__)
378 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
379 while gtk.events_pending():
380 gtk.main_iteration(False)
382 try:
383 # Try to get the real package version from dpkg
384 p = subprocess.Popen(['dpkg-query', '-W', '-f=${Version}', 'gpodder'], stdout=subprocess.PIPE)
385 version, _stderr = p.communicate()
386 del _stderr
387 del p
388 except:
389 version = gpodder.__version__
390 self.label_footer.set_markup(sub % ('v %s' % version))
391 self.label_footer.hide()
393 self.episodes_window = gPodderEpisodes(self.main_window, \
394 on_treeview_expose_event=self.on_treeview_expose_event, \
395 show_episode_shownotes=self.show_episode_shownotes, \
396 update_podcast_list_model=self.update_podcast_list_model, \
397 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
398 item_view_episodes_all=self.item_view_episodes_all, \
399 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
400 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
401 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
402 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
403 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
404 hide_episode_search=self.hide_episode_search, \
405 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
406 playback_episodes=self.playback_episodes, \
407 delete_episode_list=self.delete_episode_list, \
408 episode_list_status_changed=self.episode_list_status_changed, \
409 download_episode_list=self.download_episode_list, \
410 episode_is_downloading=self.episode_is_downloading, \
411 show_episode_in_download_manager=self.show_episode_in_download_manager, \
412 add_download_task_monitor=self.add_download_task_monitor, \
413 remove_download_task_monitor=self.remove_download_task_monitor, \
414 for_each_episode_set_task_status=self.for_each_episode_set_task_status, \
415 on_delete_episodes_button_clicked=self.on_itemRemoveOldEpisodes_activate, \
416 on_itemUpdate_activate=self.on_itemUpdate_activate)
418 # Expose objects for episode list type-ahead find
419 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
420 self.entry_search_episodes = self.episodes_window.entry_search_episodes
421 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
423 self.downloads_window = gPodderDownloads(self.main_window, \
424 on_treeview_expose_event=self.on_treeview_expose_event, \
425 cleanup_downloads=self.cleanup_downloads, \
426 _for_each_task_set_status=self._for_each_task_set_status, \
427 downloads_list_get_selection=self.downloads_list_get_selection, \
428 _config=self.config)
430 self.treeAvailable = self.episodes_window.treeview
431 self.treeDownloads = self.downloads_window.treeview
433 # Init the treeviews that we use
434 self.init_podcast_list_treeview()
435 self.init_episode_list_treeview()
436 self.init_download_list_treeview()
438 if self.config.podcast_list_hide_boring:
439 self.item_view_hide_boring_podcasts.set_active(True)
441 self.currently_updating = False
443 if gpodder.ui.maemo or self.config.enable_fingerscroll:
444 self.context_menu_mouse_button = 1
445 else:
446 self.context_menu_mouse_button = 3
448 if self.config.start_iconified:
449 self.iconify_main_window()
451 self.download_tasks_seen = set()
452 self.download_list_update_enabled = False
453 self.download_task_monitors = set()
455 # Subscribed channels
456 self.active_channel = None
457 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
458 self.channel_list_changed = True
459 self.update_podcasts_tab()
461 # load list of user applications for audio playback
462 self.user_apps_reader = UserAppsReader(['audio', 'video'])
463 threading.Thread(target=self.user_apps_reader.read).start()
465 # Set the "Device" menu item for the first time
466 if gpodder.ui.desktop:
467 self.update_item_device()
469 # Set up the first instance of MygPoClient
470 self.mygpo_client = my.MygPoClient(self.config)
472 # Now, update the feed cache, when everything's in place
473 if not gpodder.ui.fremantle:
474 self.btnUpdateFeeds.show()
475 self.updating_feed_cache = False
476 self.feed_cache_update_cancelled = False
477 self.update_feed_cache(force_update=self.config.update_on_startup)
479 self.message_area = None
481 def find_partial_downloads():
482 # Look for partial file downloads
483 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
484 count = len(partial_files)
485 resumable_episodes = []
486 if count:
487 if not gpodder.ui.fremantle:
488 util.idle_add(self.wNotebook.set_current_page, 1)
489 indicator = ProgressIndicator(_('Loading incomplete downloads'), \
490 _('Some episodes have not finished downloading in a previous session.'), \
491 False, self.get_dialog_parent())
492 indicator.on_message(N_('%d partial file', '%d partial files', count) % count)
494 candidates = [f[:-len('.partial')] for f in partial_files]
495 found = 0
497 for c in self.channels:
498 for e in c.get_all_episodes():
499 filename = e.local_filename(create=False, check_only=True)
500 if filename in candidates:
501 log('Found episode: %s', e.title, sender=self)
502 found += 1
503 indicator.on_message(e.title)
504 indicator.on_progress(float(found)/count)
505 candidates.remove(filename)
506 partial_files.remove(filename+'.partial')
507 resumable_episodes.append(e)
509 if not candidates:
510 break
512 if not candidates:
513 break
515 for f in partial_files:
516 log('Partial file without episode: %s', f, sender=self)
517 util.delete_file(f)
519 util.idle_add(indicator.on_finished)
521 if len(resumable_episodes):
522 def offer_resuming():
523 self.download_episode_list_paused(resumable_episodes)
524 if not gpodder.ui.fremantle:
525 resume_all = gtk.Button(_('Resume all'))
526 #resume_all.set_border_width(0)
527 def on_resume_all(button):
528 selection = self.treeDownloads.get_selection()
529 selection.select_all()
530 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
531 selection.unselect_all()
532 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
533 self.message_area.hide()
534 resume_all.connect('clicked', on_resume_all)
536 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
537 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
538 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
539 self.message_area.show_all()
540 self.clean_up_downloads(delete_partial=False)
541 util.idle_add(offer_resuming)
542 elif not gpodder.ui.fremantle:
543 util.idle_add(self.wNotebook.set_current_page, 0)
544 else:
545 util.idle_add(self.clean_up_downloads, True)
546 threading.Thread(target=find_partial_downloads).start()
548 # Start the auto-update procedure
549 self._auto_update_timer_source_id = None
550 if self.config.auto_update_feeds:
551 self.restart_auto_update_timer()
553 # Delete old episodes if the user wishes to
554 if self.config.auto_remove_played_episodes and \
555 self.config.episode_old_age > 0:
556 old_episodes = list(self.get_expired_episodes())
557 if len(old_episodes) > 0:
558 self.delete_episode_list(old_episodes, confirm=False)
559 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
561 if gpodder.ui.fremantle:
562 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
563 self.button_refresh.set_sensitive(True)
564 self.button_subscribe.set_sensitive(True)
565 self.main_window.set_title(_('gPodder'))
566 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
568 # Do the initial sync with the web service
569 util.idle_add(self.mygpo_client.flush, True)
571 # First-time users should be asked if they want to see the OPML
572 if not self.channels and not gpodder.ui.fremantle:
573 util.idle_add(self.on_itemUpdate_activate)
575 def episode_object_by_uri(self, uri):
576 """Get an episode object given a local or remote URI
578 This can be used to quickly access an episode object
579 when all we have is its download filename or episode
580 URL (e.g. from external D-Bus calls / signals, etc..)
582 if uri.startswith('/'):
583 uri = 'file://' + uri
585 prefix = 'file://' + self.config.download_dir
587 if uri.startswith(prefix):
588 # File is on the local filesystem in the download folder
589 filename = uri[len(prefix):]
590 file_parts = [x for x in filename.split(os.sep) if x]
592 if len(file_parts) == 2:
593 dir_name, filename = file_parts
594 channels = [c for c in self.channels if c.foldername == dir_name]
595 if len(channels) == 1:
596 channel = channels[0]
597 return channel.get_episode_by_filename(filename)
598 else:
599 # Possibly remote file - search the database for a podcast
600 channel_id = self.db.get_channel_id_from_episode_url(uri)
602 if channel_id is not None:
603 channels = [c for c in self.channels if c.id == channel_id]
604 if len(channels) == 1:
605 channel = channels[0]
606 return channel.get_episode_by_url(uri)
608 return None
610 def on_played(self, start, end, total, file_uri):
611 """Handle the "played" signal from a media player"""
612 if start == 0 and end == 0 and total == 0:
613 # Ignore bogus play event
614 return
615 elif end < start + 5:
616 # Ignore "less than five seconds" segments,
617 # as they can happen with seeking, etc...
618 return
620 log('Received play action: %s (%d, %d, %d)', file_uri, start, end, total, sender=self)
621 episode = self.episode_object_by_uri(file_uri)
623 if episode is not None:
624 file_type = episode.file_type()
625 # Automatically enable D-Bus played status mode
626 if file_type == 'audio':
627 self.config.audio_played_dbus = True
628 elif file_type == 'video':
629 self.config.video_played_dbus = True
631 now = time.time()
632 if total > 0:
633 episode.total_time = total
634 elif total == 0:
635 # Assume the episode's total time for the action
636 total = episode.total_time
637 if episode.current_position_updated is None or \
638 now > episode.current_position_updated:
639 episode.current_position = end
640 episode.current_position_updated = now
641 episode.mark(is_played=True)
642 episode.save()
643 self.db.commit()
644 self.update_episode_list_icons([episode.url])
645 self.update_podcast_list_model([episode.channel.url])
647 # Submit this action to the webservice
648 self.mygpo_client.on_playback_full(episode, \
649 start, end, total)
651 def on_add_remove_podcasts_mygpo(self):
652 actions = self.mygpo_client.get_received_actions()
653 if not actions:
654 return False
656 existing_urls = [c.url for c in self.channels]
658 # Columns for the episode selector window - just one...
659 columns = (
660 ('description', None, None, _('Action')),
663 # A list of actions that have to be chosen from
664 changes = []
666 # Actions that are ignored (already carried out)
667 ignored = []
669 for action in actions:
670 if action.is_add and action.url not in existing_urls:
671 changes.append(my.Change(action))
672 elif action.is_remove and action.url in existing_urls:
673 podcast_object = None
674 for podcast in self.channels:
675 if podcast.url == action.url:
676 podcast_object = podcast
677 break
678 changes.append(my.Change(action, podcast_object))
679 else:
680 log('Ignoring action: %s', action, sender=self)
681 ignored.append(action)
683 # Confirm all ignored changes
684 self.mygpo_client.confirm_received_actions(ignored)
686 def execute_podcast_actions(selected):
687 add_list = [c.action.url for c in selected if c.action.is_add]
688 remove_list = [c.podcast for c in selected if c.action.is_remove]
690 # Apply the accepted changes locally
691 self.add_podcast_list(add_list)
692 self.remove_podcast_list(remove_list, confirm=False)
694 # All selected items are now confirmed
695 self.mygpo_client.confirm_received_actions(c.action for c in selected)
697 # Revert the changes on the server
698 rejected = [c.action for c in changes if c not in selected]
699 self.mygpo_client.reject_received_actions(rejected)
701 def ask():
702 # We're abusing the Episode Selector again ;) -- thp
703 gPodderEpisodeSelector(self.main_window, \
704 title=_('Confirm changes from gpodder.net'), \
705 instructions=_('Select the actions you want to carry out.'), \
706 episodes=changes, \
707 columns=columns, \
708 size_attribute=None, \
709 stock_ok_button=gtk.STOCK_APPLY, \
710 callback=execute_podcast_actions, \
711 _config=self.config)
713 # There are some actions that need the user's attention
714 if changes:
715 util.idle_add(ask)
716 return True
718 # We have no remaining actions - no selection happens
719 return False
721 def rewrite_urls_mygpo(self):
722 # Check if we have to rewrite URLs since the last add
723 rewritten_urls = self.mygpo_client.get_rewritten_urls()
725 for rewritten_url in rewritten_urls:
726 if not rewritten_url.new_url:
727 continue
729 for channel in self.channels:
730 if channel.url == rewritten_url.old_url:
731 log('Updating URL of %s to %s', channel, \
732 rewritten_url.new_url, sender=self)
733 channel.url = rewritten_url.new_url
734 channel.save()
735 self.channel_list_changed = True
736 util.idle_add(self.update_episode_list_model)
737 break
739 def on_send_full_subscriptions(self):
740 # Send the full subscription list to the gpodder.net client
741 # (this will overwrite the subscription list on the server)
742 indicator = ProgressIndicator(_('Uploading subscriptions'), \
743 _('Your subscriptions are being uploaded to the server.'), \
744 False, self.get_dialog_parent())
746 try:
747 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
748 util.idle_add(self.show_message, _('List uploaded successfully.'))
749 except Exception, e:
750 def show_error(e):
751 message = str(e)
752 if not message:
753 message = e.__class__.__name__
754 self.show_message(message, \
755 _('Error while uploading'), \
756 important=True)
757 util.idle_add(show_error, e)
759 util.idle_add(indicator.on_finished)
761 def on_podcast_selected(self, treeview, path, column):
762 # for Maemo 5's UI
763 model = treeview.get_model()
764 channel = model.get_value(model.get_iter(path), \
765 PodcastListModel.C_CHANNEL)
766 self.active_channel = channel
767 self.update_episode_list_model()
768 self.episodes_window.channel = self.active_channel
769 self.episodes_window.show()
771 def on_button_subscribe_clicked(self, button):
772 self.on_itemImportChannels_activate(button)
774 def on_button_downloads_clicked(self, widget):
775 self.downloads_window.show()
777 def show_episode_in_download_manager(self, episode):
778 self.downloads_window.show()
779 model = self.treeDownloads.get_model()
780 selection = self.treeDownloads.get_selection()
781 selection.unselect_all()
782 it = model.get_iter_first()
783 while it is not None:
784 task = model.get_value(it, DownloadStatusModel.C_TASK)
785 if task.episode.url == episode.url:
786 selection.select_iter(it)
787 # FIXME: Scroll to selection in pannable area
788 break
789 it = model.iter_next(it)
791 def for_each_episode_set_task_status(self, episodes, status):
792 episode_urls = set(episode.url for episode in episodes)
793 model = self.treeDownloads.get_model()
794 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
795 model.get_value(row.iter, \
796 DownloadStatusModel.C_TASK)) for row in model \
797 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
798 in episode_urls]
799 self._for_each_task_set_status(selected_tasks, status)
801 def on_window_orientation_changed(self, orientation):
802 self._last_orientation = orientation
803 if self.preferences_dialog is not None:
804 self.preferences_dialog.on_window_orientation_changed(orientation)
806 treeview = self.treeChannels
807 if orientation == Orientation.PORTRAIT:
808 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
809 # Work around Maemo bug #4718
810 self.button_subscribe.set_name('HildonButton-thumb')
811 self.button_refresh.set_name('HildonButton-thumb')
812 else:
813 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
814 # Work around Maemo bug #4718
815 self.button_subscribe.set_name('HildonButton-finger')
816 self.button_refresh.set_name('HildonButton-finger')
818 if gpodder.ui.fremantle:
819 self.fancy_progress_bar.relayout()
821 def on_treeview_podcasts_selection_changed(self, selection):
822 model, iter = selection.get_selected()
823 if iter is None:
824 self.active_channel = None
825 self.episode_list_model.clear()
827 def on_treeview_button_pressed(self, treeview, event):
828 if event.window != treeview.get_bin_window():
829 return False
831 TreeViewHelper.save_button_press_event(treeview, event)
833 if getattr(treeview, TreeViewHelper.ROLE) == \
834 TreeViewHelper.ROLE_PODCASTS:
835 return self.currently_updating
837 return event.button == self.context_menu_mouse_button and \
838 gpodder.ui.desktop
840 def on_treeview_podcasts_button_released(self, treeview, event):
841 if event.window != treeview.get_bin_window():
842 return False
844 if gpodder.ui.maemo:
845 return self.treeview_channels_handle_gestures(treeview, event)
846 return self.treeview_channels_show_context_menu(treeview, event)
848 def on_treeview_episodes_button_released(self, treeview, event):
849 if event.window != treeview.get_bin_window():
850 return False
852 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
853 return self.treeview_available_handle_gestures(treeview, event)
855 return self.treeview_available_show_context_menu(treeview, event)
857 def on_treeview_downloads_button_released(self, treeview, event):
858 if event.window != treeview.get_bin_window():
859 return False
861 return self.treeview_downloads_show_context_menu(treeview, event)
863 def on_entry_search_podcasts_changed(self, editable):
864 if self.hbox_search_podcasts.get_property('visible'):
865 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
867 def on_entry_search_podcasts_key_press(self, editable, event):
868 if event.keyval == gtk.keysyms.Escape:
869 self.hide_podcast_search()
870 return True
872 def hide_podcast_search(self, *args):
873 self.hbox_search_podcasts.hide()
874 self.entry_search_podcasts.set_text('')
875 self.podcast_list_model.set_search_term(None)
876 self.treeChannels.grab_focus()
878 def show_podcast_search(self, input_char):
879 self.hbox_search_podcasts.show()
880 self.entry_search_podcasts.insert_text(input_char, -1)
881 self.entry_search_podcasts.grab_focus()
882 self.entry_search_podcasts.set_position(-1)
884 def init_podcast_list_treeview(self):
885 # Set up podcast channel tree view widget
886 if gpodder.ui.fremantle:
887 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
888 self.item_view_podcasts_downloaded.set_active(True)
889 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
890 self.item_view_podcasts_unplayed.set_active(True)
891 else:
892 self.item_view_podcasts_all.set_active(True)
893 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
895 iconcolumn = gtk.TreeViewColumn('')
896 iconcell = gtk.CellRendererPixbuf()
897 iconcolumn.pack_start(iconcell, False)
898 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
899 self.treeChannels.append_column(iconcolumn)
901 namecolumn = gtk.TreeViewColumn('')
902 namecell = gtk.CellRendererText()
903 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
904 namecolumn.pack_start(namecell, True)
905 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
907 if gpodder.ui.fremantle:
908 countcell = gtk.CellRendererText()
909 from gpodder.gtkui.frmntl import style
910 countcell.set_property('font-desc', style.get_font_desc('EmpSystemFont'))
911 countcell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
912 countcell.set_property('alignment', pango.ALIGN_RIGHT)
913 countcell.set_property('xalign', 1.)
914 countcell.set_property('xpad', 5)
915 namecolumn.pack_start(countcell, False)
916 namecolumn.add_attribute(countcell, 'text', PodcastListModel.C_DOWNLOADS)
917 namecolumn.add_attribute(countcell, 'visible', PodcastListModel.C_DOWNLOADS)
918 else:
919 iconcell = gtk.CellRendererPixbuf()
920 iconcell.set_property('xalign', 1.0)
921 namecolumn.pack_start(iconcell, False)
922 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
923 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
925 self.treeChannels.append_column(namecolumn)
927 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
929 # When no podcast is selected, clear the episode list model
930 selection = self.treeChannels.get_selection()
931 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
933 # Set up type-ahead find for the podcast list
934 def on_key_press(treeview, event):
935 if event.keyval == gtk.keysyms.Escape:
936 self.hide_podcast_search()
937 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
938 self.hide_podcast_search()
939 elif event.state & gtk.gdk.CONTROL_MASK:
940 # Don't handle type-ahead when control is pressed (so shortcuts
941 # with the Ctrl key still work, e.g. Ctrl+A, ...)
942 return True
943 else:
944 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
945 if unicode_char_id == 0:
946 return False
947 input_char = unichr(unicode_char_id)
948 self.show_podcast_search(input_char)
949 return True
950 self.treeChannels.connect('key-press-event', on_key_press)
952 # Enable separators to the podcast list to separate special podcasts
953 # from others (this is used for the "all episodes" view)
954 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
956 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
958 def on_entry_search_episodes_changed(self, editable):
959 if self.hbox_search_episodes.get_property('visible'):
960 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
962 def on_entry_search_episodes_key_press(self, editable, event):
963 if event.keyval == gtk.keysyms.Escape:
964 self.hide_episode_search()
965 return True
967 def hide_episode_search(self, *args):
968 self.hbox_search_episodes.hide()
969 self.entry_search_episodes.set_text('')
970 self.episode_list_model.set_search_term(None)
971 self.treeAvailable.grab_focus()
973 def show_episode_search(self, input_char):
974 self.hbox_search_episodes.show()
975 self.entry_search_episodes.insert_text(input_char, -1)
976 self.entry_search_episodes.grab_focus()
977 self.entry_search_episodes.set_position(-1)
979 def init_episode_list_treeview(self):
980 # For loading the list model
981 self.episode_list_model = EpisodeListModel()
983 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
984 self.item_view_episodes_undeleted.set_active(True)
985 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
986 self.item_view_episodes_downloaded.set_active(True)
987 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
988 self.item_view_episodes_unplayed.set_active(True)
989 else:
990 self.item_view_episodes_all.set_active(True)
992 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
994 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
996 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
998 iconcell = gtk.CellRendererPixbuf()
999 iconcell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
1000 if gpodder.ui.maemo:
1001 iconcell.set_fixed_size(50, 50)
1002 else:
1003 iconcell.set_fixed_size(40, -1)
1005 namecell = gtk.CellRendererText()
1006 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
1007 namecolumn = gtk.TreeViewColumn(_('Episode'))
1008 namecolumn.pack_start(iconcell, False)
1009 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
1010 namecolumn.pack_start(namecell, True)
1011 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
1012 if gpodder.ui.fremantle:
1013 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
1014 else:
1015 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
1016 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1017 namecolumn.set_resizable(True)
1018 namecolumn.set_expand(True)
1020 if gpodder.ui.fremantle:
1021 from gpodder.gtkui.frmntl import style
1022 timecell = gtk.CellRendererText()
1023 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1024 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1025 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1026 timecell.set_property('xalign', 1.)
1027 timecell.set_property('xpad', 5)
1028 namecolumn.pack_start(timecell, False)
1029 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1030 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME1_VISIBLE)
1032 # Add another cell renderer to fix a sizing issue (one renderer
1033 # only renders short text and the other one longer text to avoid
1034 # having titles of episodes unnecessarily cut off)
1035 timecell = gtk.CellRendererText()
1036 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1037 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1038 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1039 timecell.set_property('xalign', 1.)
1040 timecell.set_property('xpad', 5)
1041 namecolumn.pack_start(timecell, False)
1042 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1043 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME2_VISIBLE)
1045 lockcell = gtk.CellRendererPixbuf()
1046 lockcell.set_property('stock-size', gtk.ICON_SIZE_MENU)
1047 if gpodder.ui.fremantle:
1048 lockcell.set_property('icon-name', 'general_locked')
1049 else:
1050 lockcell.set_property('icon-name', 'emblem-readonly')
1052 namecolumn.pack_start(lockcell, False)
1053 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
1055 sizecell = gtk.CellRendererText()
1056 sizecell.set_property('xalign', 1)
1057 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1058 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1060 releasecell = gtk.CellRendererText()
1061 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
1062 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1064 namecolumn.set_reorderable(True)
1065 self.treeAvailable.append_column(namecolumn)
1067 if not gpodder.ui.maemo:
1068 for itemcolumn in (sizecolumn, releasecolumn):
1069 itemcolumn.set_reorderable(True)
1070 self.treeAvailable.append_column(itemcolumn)
1072 # Set up type-ahead find for the episode list
1073 def on_key_press(treeview, event):
1074 if event.keyval == gtk.keysyms.Escape:
1075 self.hide_episode_search()
1076 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
1077 self.hide_episode_search()
1078 elif event.state & gtk.gdk.CONTROL_MASK:
1079 # Don't handle type-ahead when control is pressed (so shortcuts
1080 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1081 return False
1082 else:
1083 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
1084 if unicode_char_id == 0:
1085 return False
1086 input_char = unichr(unicode_char_id)
1087 self.show_episode_search(input_char)
1088 return True
1089 self.treeAvailable.connect('key-press-event', on_key_press)
1091 if gpodder.ui.desktop and not self.config.enable_fingerscroll:
1092 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
1093 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
1094 def drag_data_get(tree, context, selection_data, info, timestamp):
1095 if self.config.on_drag_mark_played:
1096 for episode in self.get_selected_episodes():
1097 episode.mark(is_played=True)
1098 self.on_selected_episodes_status_changed()
1099 uris = ['file://'+e.local_filename(create=False) \
1100 for e in self.get_selected_episodes() \
1101 if e.was_downloaded(and_exists=True)]
1102 uris.append('') # for the trailing '\r\n'
1103 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
1104 self.treeAvailable.connect('drag-data-get', drag_data_get)
1106 selection = self.treeAvailable.get_selection()
1107 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
1108 selection.set_mode(gtk.SELECTION_SINGLE)
1109 elif gpodder.ui.fremantle:
1110 selection.set_mode(gtk.SELECTION_SINGLE)
1111 else:
1112 selection.set_mode(gtk.SELECTION_MULTIPLE)
1113 # Update the sensitivity of the toolbar buttons on the Desktop
1114 selection.connect('changed', lambda s: self.play_or_download())
1116 if gpodder.ui.diablo:
1117 # Set up the tap-and-hold context menu for podcasts
1118 menu = gtk.Menu()
1119 menu.append(self.itemUpdateChannel.create_menu_item())
1120 menu.append(self.itemEditChannel.create_menu_item())
1121 menu.append(gtk.SeparatorMenuItem())
1122 menu.append(self.itemRemoveChannel.create_menu_item())
1123 menu.append(gtk.SeparatorMenuItem())
1124 item = gtk.ImageMenuItem(_('Close this menu'))
1125 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
1126 gtk.ICON_SIZE_MENU))
1127 menu.append(item)
1128 menu.show_all()
1129 menu = self.set_finger_friendly(menu)
1130 self.treeChannels.tap_and_hold_setup(menu)
1133 def init_download_list_treeview(self):
1134 # enable multiple selection support
1135 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
1136 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1138 # columns and renderers for "download progress" tab
1139 # First column: [ICON] Episodename
1140 column = gtk.TreeViewColumn(_('Episode'))
1142 cell = gtk.CellRendererPixbuf()
1143 if gpodder.ui.maemo:
1144 cell.set_fixed_size(50, 50)
1145 cell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
1146 column.pack_start(cell, expand=False)
1147 column.add_attribute(cell, 'icon-name', \
1148 DownloadStatusModel.C_ICON_NAME)
1150 cell = gtk.CellRendererText()
1151 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
1152 column.pack_start(cell, expand=True)
1153 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1154 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1155 column.set_expand(True)
1156 self.treeDownloads.append_column(column)
1158 # Second column: Progress
1159 cell = gtk.CellRendererProgress()
1160 cell.set_property('yalign', .5)
1161 cell.set_property('ypad', 6)
1162 column = gtk.TreeViewColumn(_('Progress'), cell,
1163 value=DownloadStatusModel.C_PROGRESS, \
1164 text=DownloadStatusModel.C_PROGRESS_TEXT)
1165 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1166 column.set_expand(False)
1167 self.treeDownloads.append_column(column)
1168 if gpodder.ui.maemo:
1169 column.set_property('min-width', 200)
1170 column.set_property('max-width', 200)
1171 else:
1172 column.set_property('min-width', 150)
1173 column.set_property('max-width', 150)
1175 self.treeDownloads.set_model(self.download_status_model)
1176 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1178 def on_treeview_expose_event(self, treeview, event):
1179 if event.window == treeview.get_bin_window():
1180 model = treeview.get_model()
1181 if (model is not None and model.get_iter_first() is not None):
1182 return False
1184 role = getattr(treeview, TreeViewHelper.ROLE, None)
1185 if role is None:
1186 return False
1188 ctx = event.window.cairo_create()
1189 ctx.rectangle(event.area.x, event.area.y,
1190 event.area.width, event.area.height)
1191 ctx.clip()
1193 x, y, width, height, depth = event.window.get_geometry()
1194 progress = None
1196 if role == TreeViewHelper.ROLE_EPISODES:
1197 if self.currently_updating:
1198 text = _('Loading episodes')
1199 elif self.config.episode_list_view_mode != \
1200 EpisodeListModel.VIEW_ALL:
1201 text = _('No episodes in current view')
1202 else:
1203 text = _('No episodes available')
1204 elif role == TreeViewHelper.ROLE_PODCASTS:
1205 if self.config.episode_list_view_mode != \
1206 EpisodeListModel.VIEW_ALL and \
1207 self.config.podcast_list_hide_boring and \
1208 len(self.channels) > 0:
1209 text = _('No podcasts in this view')
1210 else:
1211 text = _('No subscriptions')
1212 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1213 text = _('No active downloads')
1214 else:
1215 raise Exception('on_treeview_expose_event: unknown role')
1217 if gpodder.ui.fremantle:
1218 from gpodder.gtkui.frmntl import style
1219 font_desc = style.get_font_desc('LargeSystemFont')
1220 else:
1221 font_desc = None
1223 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1225 if role == TreeViewHelper.ROLE_EPISODES and \
1226 self.currently_updating:
1227 return True
1229 return False
1231 def enable_download_list_update(self):
1232 if not self.download_list_update_enabled:
1233 self.update_downloads_list()
1234 gobject.timeout_add(1500, self.update_downloads_list)
1235 self.download_list_update_enabled = True
1237 def cleanup_downloads(self):
1238 model = self.download_status_model
1240 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1241 changed_episode_urls = set()
1242 for row_reference, task in all_tasks:
1243 if task.status in (task.DONE, task.CANCELLED):
1244 model.remove(model.get_iter(row_reference.get_path()))
1245 try:
1246 # We don't "see" this task anymore - remove it;
1247 # this is needed, so update_episode_list_icons()
1248 # below gets the correct list of "seen" tasks
1249 self.download_tasks_seen.remove(task)
1250 except KeyError, key_error:
1251 log('Cannot remove task from "seen" list: %s', task, sender=self)
1252 changed_episode_urls.add(task.url)
1253 # Tell the task that it has been removed (so it can clean up)
1254 task.removed_from_list()
1256 # Tell the podcasts tab to update icons for our removed podcasts
1257 self.update_episode_list_icons(changed_episode_urls)
1259 # Tell the shownotes window that we have removed the episode
1260 if self.episode_shownotes_window is not None and \
1261 self.episode_shownotes_window.episode is not None and \
1262 self.episode_shownotes_window.episode.url in changed_episode_urls:
1263 self.episode_shownotes_window._download_status_changed(None)
1265 # Update the downloads list one more time
1266 self.update_downloads_list(can_call_cleanup=False)
1268 def on_tool_downloads_toggled(self, toolbutton):
1269 if toolbutton.get_active():
1270 self.wNotebook.set_current_page(1)
1271 else:
1272 self.wNotebook.set_current_page(0)
1274 def add_download_task_monitor(self, monitor):
1275 self.download_task_monitors.add(monitor)
1276 model = self.download_status_model
1277 if model is None:
1278 model = ()
1279 for row in model:
1280 task = row[self.download_status_model.C_TASK]
1281 monitor.task_updated(task)
1283 def remove_download_task_monitor(self, monitor):
1284 self.download_task_monitors.remove(monitor)
1286 def update_downloads_list(self, can_call_cleanup=True):
1287 try:
1288 model = self.download_status_model
1290 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1291 total_speed, total_size, done_size = 0, 0, 0
1293 # Keep a list of all download tasks that we've seen
1294 download_tasks_seen = set()
1296 # Remember the DownloadTask object for the episode that
1297 # has been opened in the episode shownotes dialog (if any)
1298 if self.episode_shownotes_window is not None:
1299 shownotes_episode = self.episode_shownotes_window.episode
1300 shownotes_task = None
1301 else:
1302 shownotes_episode = None
1303 shownotes_task = None
1305 # Do not go through the list of the model is not (yet) available
1306 if model is None:
1307 model = ()
1309 failed_downloads = []
1310 for row in model:
1311 self.download_status_model.request_update(row.iter)
1313 task = row[self.download_status_model.C_TASK]
1314 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1316 # Let the download task monitors know of changes
1317 for monitor in self.download_task_monitors:
1318 monitor.task_updated(task)
1320 total_size += size
1321 done_size += size*progress
1323 if shownotes_episode is not None and \
1324 shownotes_episode.url == task.episode.url:
1325 shownotes_task = task
1327 download_tasks_seen.add(task)
1329 if status == download.DownloadTask.DOWNLOADING:
1330 downloading += 1
1331 total_speed += speed
1332 elif status == download.DownloadTask.FAILED:
1333 failed_downloads.append(task)
1334 failed += 1
1335 elif status == download.DownloadTask.DONE:
1336 finished += 1
1337 elif status == download.DownloadTask.QUEUED:
1338 queued += 1
1339 elif status == download.DownloadTask.PAUSED:
1340 paused += 1
1341 else:
1342 others += 1
1344 # Remember which tasks we have seen after this run
1345 self.download_tasks_seen = download_tasks_seen
1347 if gpodder.ui.desktop:
1348 text = [_('Downloads')]
1349 if downloading + failed + queued > 0:
1350 s = []
1351 if downloading > 0:
1352 s.append(N_('%d active', '%d active', downloading) % downloading)
1353 if failed > 0:
1354 s.append(N_('%d failed', '%d failed', failed) % failed)
1355 if queued > 0:
1356 s.append(N_('%d queued', '%d queued', queued) % queued)
1357 text.append(' (' + ', '.join(s)+')')
1358 self.labelDownloads.set_text(''.join(text))
1359 elif gpodder.ui.diablo:
1360 sum = downloading + failed + finished + queued + paused + others
1361 if sum:
1362 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
1363 else:
1364 self.tool_downloads.set_label(_('Downloads'))
1365 elif gpodder.ui.fremantle:
1366 if downloading + queued > 0:
1367 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1368 elif failed > 0:
1369 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1370 elif paused > 0:
1371 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1372 else:
1373 self.button_downloads.set_value(_('Idle'))
1375 title = [self.default_title]
1377 # We have to update all episodes/channels for which the status has
1378 # changed. Accessing task.status_changed has the side effect of
1379 # re-setting the changed flag, so we need to get the "changed" list
1380 # of tuples first and split it into two lists afterwards
1381 changed = [(task.url, task.podcast_url) for task in \
1382 self.download_tasks_seen if task.status_changed]
1383 episode_urls = [episode_url for episode_url, channel_url in changed]
1384 channel_urls = [channel_url for episode_url, channel_url in changed]
1386 count = downloading + queued
1387 if count > 0:
1388 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1390 if total_size > 0:
1391 percentage = 100.0*done_size/total_size
1392 else:
1393 percentage = 0.0
1394 total_speed = util.format_filesize(total_speed)
1395 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1396 if self.tray_icon is not None:
1397 # Update the tray icon status and progress bar
1398 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1399 self.tray_icon.draw_progress_bar(percentage/100.)
1400 else:
1401 if self.tray_icon is not None:
1402 # Update the tray icon status
1403 self.tray_icon.set_status()
1404 if gpodder.ui.desktop:
1405 self.downloads_finished(self.download_tasks_seen)
1406 if gpodder.ui.diablo:
1407 hildon.hildon_banner_show_information(self.gPodder, '', 'gPodder: %s' % _('All downloads finished'))
1408 log('All downloads have finished.', sender=self)
1409 if self.config.cmd_all_downloads_complete:
1410 util.run_external_command(self.config.cmd_all_downloads_complete)
1412 if gpodder.ui.fremantle and failed:
1413 message = '\n'.join(['%s: %s' % (str(task), \
1414 task.error_message) for task in failed_downloads])
1415 self.show_message(message, _('Downloads failed'), important=True)
1417 # Remove finished episodes
1418 if self.config.auto_cleanup_downloads and can_call_cleanup:
1419 self.cleanup_downloads()
1421 # Stop updating the download list here
1422 self.download_list_update_enabled = False
1424 if not gpodder.ui.fremantle:
1425 self.gPodder.set_title(' - '.join(title))
1427 self.update_episode_list_icons(episode_urls)
1428 if self.episode_shownotes_window is not None:
1429 if (shownotes_task and shownotes_task.url in episode_urls) or \
1430 shownotes_task != self.episode_shownotes_window.task:
1431 self.episode_shownotes_window._download_status_changed(shownotes_task)
1432 self.episode_shownotes_window._download_status_progress()
1433 self.play_or_download()
1434 if channel_urls:
1435 self.update_podcast_list_model(channel_urls)
1437 return self.download_list_update_enabled
1438 except Exception, e:
1439 log('Exception happened while updating download list.', sender=self, traceback=True)
1440 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1441 # We return False here, so the update loop won't be called again,
1442 # that's why we require the restart of gPodder in the message.
1443 return False
1445 def on_config_changed(self, *args):
1446 util.idle_add(self._on_config_changed, *args)
1448 def _on_config_changed(self, name, old_value, new_value):
1449 if name == 'show_toolbar' and gpodder.ui.desktop:
1450 self.toolbar.set_property('visible', new_value)
1451 elif name == 'videoplayer':
1452 self.config.video_played_dbus = False
1453 elif name == 'player':
1454 self.config.audio_played_dbus = False
1455 elif name == 'episode_list_descriptions':
1456 self.update_episode_list_model()
1457 elif name == 'episode_list_thumbnails':
1458 self.update_episode_list_icons(all=True)
1459 elif name == 'rotation_mode':
1460 self._fremantle_rotation.set_mode(new_value)
1461 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1462 self.restart_auto_update_timer()
1463 elif name == 'podcast_list_view_all':
1464 # Force a update of the podcast list model
1465 self.channel_list_changed = True
1466 if gpodder.ui.fremantle:
1467 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
1468 while gtk.events_pending():
1469 gtk.main_iteration(False)
1470 self.update_podcast_list_model()
1471 if gpodder.ui.fremantle:
1472 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
1474 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1475 # With get_bin_window, we get the window that contains the rows without
1476 # the header. The Y coordinate of this window will be the height of the
1477 # treeview header. This is the amount we have to subtract from the
1478 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1479 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1480 y -= x_bin
1481 y -= y_bin
1482 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1484 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1485 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1486 return False
1488 if path is not None:
1489 model = treeview.get_model()
1490 iter = model.get_iter(path)
1491 role = getattr(treeview, TreeViewHelper.ROLE)
1493 if role == TreeViewHelper.ROLE_EPISODES:
1494 id = model.get_value(iter, EpisodeListModel.C_URL)
1495 elif role == TreeViewHelper.ROLE_PODCASTS:
1496 id = model.get_value(iter, PodcastListModel.C_URL)
1498 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1499 if last_tooltip is not None and last_tooltip != id:
1500 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1501 return False
1502 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1504 if role == TreeViewHelper.ROLE_EPISODES:
1505 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1506 if description:
1507 tooltip.set_text(description)
1508 else:
1509 return False
1510 elif role == TreeViewHelper.ROLE_PODCASTS:
1511 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1512 if channel is None:
1513 return False
1514 channel.request_save_dir_size()
1515 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1516 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1517 if error_str:
1518 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1519 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1520 table = gtk.Table(rows=3, columns=3)
1521 table.set_row_spacings(5)
1522 table.set_col_spacings(5)
1523 table.set_border_width(5)
1525 heading = gtk.Label()
1526 heading.set_alignment(0, 1)
1527 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1528 table.attach(heading, 0, 1, 0, 1)
1529 size_info = gtk.Label()
1530 size_info.set_alignment(1, 1)
1531 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1532 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1533 table.attach(size_info, 2, 3, 0, 1)
1535 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1537 if len(channel.description) < 500:
1538 description = channel.description
1539 else:
1540 pos = channel.description.find('\n\n')
1541 if pos == -1 or pos > 500:
1542 description = channel.description[:498]+'[...]'
1543 else:
1544 description = channel.description[:pos]
1546 description = gtk.Label(description)
1547 if error_str:
1548 description.set_markup(error_str)
1549 description.set_alignment(0, 0)
1550 description.set_line_wrap(True)
1551 table.attach(description, 0, 3, 2, 3)
1553 table.show_all()
1554 tooltip.set_custom(table)
1556 return True
1558 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1559 return False
1561 def treeview_allow_tooltips(self, treeview, allow):
1562 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1564 def update_m3u_playlist_clicked(self, widget):
1565 if self.active_channel is not None:
1566 self.active_channel.update_m3u_playlist()
1567 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1569 def treeview_handle_context_menu_click(self, treeview, event):
1570 x, y = int(event.x), int(event.y)
1571 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1573 selection = treeview.get_selection()
1574 model, paths = selection.get_selected_rows()
1576 if path is None or (path not in paths and \
1577 event.button == self.context_menu_mouse_button):
1578 # We have right-clicked, but not into the selection,
1579 # assume we don't want to operate on the selection
1580 paths = []
1582 if path is not None and not paths and \
1583 event.button == self.context_menu_mouse_button:
1584 # No selection or clicked outside selection;
1585 # select the single item where we clicked
1586 treeview.grab_focus()
1587 treeview.set_cursor(path, column, 0)
1588 paths = [path]
1590 if not paths:
1591 # Unselect any remaining items (clicked elsewhere)
1592 if hasattr(treeview, 'is_rubber_banding_active'):
1593 if not treeview.is_rubber_banding_active():
1594 selection.unselect_all()
1595 else:
1596 selection.unselect_all()
1598 return model, paths
1600 def downloads_list_get_selection(self, model=None, paths=None):
1601 if model is None and paths is None:
1602 selection = self.treeDownloads.get_selection()
1603 model, paths = selection.get_selected_rows()
1605 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1606 selected_tasks = [(gtk.TreeRowReference(model, path), \
1607 model.get_value(model.get_iter(path), \
1608 DownloadStatusModel.C_TASK)) for path in paths]
1610 for row_reference, task in selected_tasks:
1611 if task.status != download.DownloadTask.QUEUED:
1612 can_force = False
1613 if task.status not in (download.DownloadTask.PAUSED, \
1614 download.DownloadTask.FAILED, \
1615 download.DownloadTask.CANCELLED):
1616 can_queue = False
1617 if task.status not in (download.DownloadTask.PAUSED, \
1618 download.DownloadTask.QUEUED, \
1619 download.DownloadTask.DOWNLOADING, \
1620 download.DownloadTask.FAILED):
1621 can_cancel = False
1622 if task.status not in (download.DownloadTask.QUEUED, \
1623 download.DownloadTask.DOWNLOADING):
1624 can_pause = False
1625 if task.status not in (download.DownloadTask.CANCELLED, \
1626 download.DownloadTask.FAILED, \
1627 download.DownloadTask.DONE):
1628 can_remove = False
1630 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1632 def downloads_finished(self, download_tasks_seen):
1633 # FIXME: Filter all tasks that have already been reported
1634 finished_downloads = [str(task) for task in download_tasks_seen if task.status == task.DONE]
1635 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.status == task.FAILED]
1637 if finished_downloads and failed_downloads:
1638 message = self.format_episode_list(finished_downloads, 5)
1639 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1640 message += self.format_episode_list(failed_downloads, 5)
1641 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1642 elif finished_downloads:
1643 message = self.format_episode_list(finished_downloads)
1644 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1645 elif failed_downloads:
1646 message = self.format_episode_list(failed_downloads)
1647 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1649 # Open torrent files right after download (bug 1029)
1650 if self.config.open_torrent_after_download:
1651 for task in download_tasks_seen:
1652 if task.status != task.DONE:
1653 continue
1655 episode = task.episode
1656 if episode.mimetype != 'application/x-bittorrent':
1657 continue
1659 self.playback_episodes([episode])
1662 def format_episode_list(self, episode_list, max_episodes=10):
1664 Format a list of episode names for notifications
1666 Will truncate long episode names and limit the amount of
1667 episodes displayed (max_episodes=10).
1669 The episode_list parameter should be a list of strings.
1671 MAX_TITLE_LENGTH = 100
1673 result = []
1674 for title in episode_list[:min(len(episode_list), max_episodes)]:
1675 if len(title) > MAX_TITLE_LENGTH:
1676 middle = (MAX_TITLE_LENGTH/2)-2
1677 title = '%s...%s' % (title[0:middle], title[-middle:])
1678 result.append(saxutils.escape(title))
1679 result.append('\n')
1681 more_episodes = len(episode_list) - max_episodes
1682 if more_episodes > 0:
1683 result.append('(...')
1684 result.append(N_('%d more episode', '%d more episodes', more_episodes) % more_episodes)
1685 result.append('...)')
1687 return (''.join(result)).strip()
1689 def _for_each_task_set_status(self, tasks, status, force_start=False):
1690 episode_urls = set()
1691 model = self.treeDownloads.get_model()
1692 for row_reference, task in tasks:
1693 if status == download.DownloadTask.QUEUED:
1694 # Only queue task when its paused/failed/cancelled (or forced)
1695 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1696 self.download_queue_manager.add_task(task, force_start)
1697 self.enable_download_list_update()
1698 elif status == download.DownloadTask.CANCELLED:
1699 # Cancelling a download allowed when downloading/queued
1700 if task.status in (task.QUEUED, task.DOWNLOADING):
1701 task.status = status
1702 # Cancelling paused/failed downloads requires a call to .run()
1703 elif task.status in (task.PAUSED, task.FAILED):
1704 task.status = status
1705 # Call run, so the partial file gets deleted
1706 task.run()
1707 elif status == download.DownloadTask.PAUSED:
1708 # Pausing a download only when queued/downloading
1709 if task.status in (task.DOWNLOADING, task.QUEUED):
1710 task.status = status
1711 elif status is None:
1712 # Remove the selected task - cancel downloading/queued tasks
1713 if task.status in (task.QUEUED, task.DOWNLOADING):
1714 task.status = task.CANCELLED
1715 model.remove(model.get_iter(row_reference.get_path()))
1716 # Remember the URL, so we can tell the UI to update
1717 try:
1718 # We don't "see" this task anymore - remove it;
1719 # this is needed, so update_episode_list_icons()
1720 # below gets the correct list of "seen" tasks
1721 self.download_tasks_seen.remove(task)
1722 except KeyError, key_error:
1723 log('Cannot remove task from "seen" list: %s', task, sender=self)
1724 episode_urls.add(task.url)
1725 # Tell the task that it has been removed (so it can clean up)
1726 task.removed_from_list()
1727 else:
1728 # We can (hopefully) simply set the task status here
1729 task.status = status
1730 # Tell the podcasts tab to update icons for our removed podcasts
1731 self.update_episode_list_icons(episode_urls)
1732 # Update the tab title and downloads list
1733 self.update_downloads_list()
1735 def treeview_downloads_show_context_menu(self, treeview, event):
1736 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1737 if not paths:
1738 if not hasattr(treeview, 'is_rubber_banding_active'):
1739 return True
1740 else:
1741 return not treeview.is_rubber_banding_active()
1743 if event.button == self.context_menu_mouse_button:
1744 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1745 self.downloads_list_get_selection(model, paths)
1747 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1748 # This creates a menu item for selection-wide actions
1749 item = gtk.ImageMenuItem(label)
1750 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1751 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1752 item.set_sensitive(sensitive)
1753 return self.set_finger_friendly(item)
1755 menu = gtk.Menu()
1757 item = gtk.ImageMenuItem(_('Episode details'))
1758 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1759 if len(selected_tasks) == 1:
1760 row_reference, task = selected_tasks[0]
1761 episode = task.episode
1762 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1763 else:
1764 item.set_sensitive(False)
1765 menu.append(self.set_finger_friendly(item))
1766 menu.append(gtk.SeparatorMenuItem())
1767 if can_force:
1768 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1769 else:
1770 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1771 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1772 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1773 menu.append(gtk.SeparatorMenuItem())
1774 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1776 if gpodder.ui.maemo or self.config.enable_fingerscroll:
1777 # Because we open the popup on left-click for Maemo,
1778 # we also include a non-action to close the menu
1779 menu.append(gtk.SeparatorMenuItem())
1780 item = gtk.ImageMenuItem(_('Close this menu'))
1781 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1783 menu.append(self.set_finger_friendly(item))
1785 menu.show_all()
1786 menu.popup(None, None, None, event.button, event.time)
1787 return True
1789 def treeview_channels_show_context_menu(self, treeview, event):
1790 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1791 if not paths:
1792 return True
1794 # Check for valid channel id, if there's no id then
1795 # assume that it is a proxy channel or equivalent
1796 # and cannot be operated with right click
1797 if self.active_channel.id is None:
1798 return True
1800 if event.button == 3:
1801 menu = gtk.Menu()
1803 ICON = lambda x: x
1805 item = gtk.ImageMenuItem( _('Update podcast'))
1806 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1807 item.connect('activate', self.on_itemUpdateChannel_activate)
1808 item.set_sensitive(not self.updating_feed_cache)
1809 menu.append(item)
1811 menu.append(gtk.SeparatorMenuItem())
1813 item = gtk.CheckMenuItem(_('Keep episodes'))
1814 item.set_active(self.active_channel.channel_is_locked)
1815 item.connect('activate', self.on_channel_toggle_lock_activate)
1816 menu.append(self.set_finger_friendly(item))
1818 item = gtk.ImageMenuItem(_('Remove podcast'))
1819 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1820 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1821 menu.append( item)
1823 if self.config.device_type != 'none':
1824 item = gtk.MenuItem(_('Synchronize to device'))
1825 item.connect('activate', lambda item: self.on_sync_to_ipod_activate(item, self.active_channel.get_downloaded_episodes()))
1826 menu.append(item)
1828 menu.append( gtk.SeparatorMenuItem())
1830 item = gtk.ImageMenuItem(_('Podcast details'))
1831 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1832 item.connect('activate', self.on_itemEditChannel_activate)
1833 menu.append(item)
1835 menu.show_all()
1836 # Disable tooltips while we are showing the menu, so
1837 # the tooltip will not appear over the menu
1838 self.treeview_allow_tooltips(self.treeChannels, False)
1839 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1840 menu.popup( None, None, None, event.button, event.time)
1842 return True
1844 def on_itemClose_activate(self, widget):
1845 if self.tray_icon is not None:
1846 self.iconify_main_window()
1847 else:
1848 self.on_gPodder_delete_event(widget)
1850 def cover_file_removed(self, channel_url):
1852 The Cover Downloader calls this when a previously-
1853 available cover has been removed from the disk. We
1854 have to update our model to reflect this change.
1856 self.podcast_list_model.delete_cover_by_url(channel_url)
1858 def cover_download_finished(self, channel, pixbuf):
1860 The Cover Downloader calls this when it has finished
1861 downloading (or registering, if already downloaded)
1862 a new channel cover, which is ready for displaying.
1864 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1866 def save_episodes_as_file(self, episodes):
1867 for episode in episodes:
1868 self.save_episode_as_file(episode)
1870 def save_episode_as_file(self, episode):
1871 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1872 if episode.was_downloaded(and_exists=True):
1873 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1874 copy_from = episode.local_filename(create=False)
1875 assert copy_from is not None
1876 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1877 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1878 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1880 def copy_episodes_bluetooth(self, episodes):
1881 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1883 if gpodder.ui.maemo:
1884 util.bluetooth_send_files_maemo([e.local_filename(create=False) \
1885 for e in episodes_to_copy])
1886 return True
1888 def convert_and_send_thread(episode):
1889 for episode in episodes:
1890 filename = episode.local_filename(create=False)
1891 assert filename is not None
1892 destfile = os.path.join(tempfile.gettempdir(), \
1893 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1894 (base, ext) = os.path.splitext(filename)
1895 if not destfile.endswith(ext):
1896 destfile += ext
1898 try:
1899 shutil.copyfile(filename, destfile)
1900 util.bluetooth_send_file(destfile)
1901 except:
1902 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1903 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1905 util.delete_file(destfile)
1907 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1909 def get_device_name(self):
1910 if self.config.device_type == 'ipod':
1911 return _('iPod')
1912 elif self.config.device_type in ('filesystem', 'mtp'):
1913 return _('MP3 player')
1914 else:
1915 return '(unknown device)'
1917 def _treeview_button_released(self, treeview, event):
1918 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1919 dy = int(abs(event.y-ypos))
1920 dx = int(event.x-xpos)
1922 selection = treeview.get_selection()
1923 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1924 if path is None or dy > 30:
1925 return (False, dx, dy)
1927 path, column, x, y = path
1928 selection.select_path(path)
1929 treeview.set_cursor(path)
1930 treeview.grab_focus()
1932 return (True, dx, dy)
1934 def treeview_channels_handle_gestures(self, treeview, event):
1935 if self.currently_updating:
1936 return False
1938 selected, dx, dy = self._treeview_button_released(treeview, event)
1940 if selected:
1941 if self.config.maemo_enable_gestures:
1942 if dx > 70:
1943 self.on_itemUpdateChannel_activate()
1944 elif dx < -70:
1945 self.on_itemEditChannel_activate(treeview)
1947 return False
1949 def treeview_available_handle_gestures(self, treeview, event):
1950 selected, dx, dy = self._treeview_button_released(treeview, event)
1952 if selected:
1953 if self.config.maemo_enable_gestures:
1954 if dx > 70:
1955 self.on_playback_selected_episodes(None)
1956 return True
1957 elif dx < -70:
1958 self.on_shownotes_selected_episodes(None)
1959 return True
1961 # Pass the event to the context menu handler for treeAvailable
1962 self.treeview_available_show_context_menu(treeview, event)
1964 return True
1966 def treeview_available_show_context_menu(self, treeview, event):
1967 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1968 if not paths:
1969 if not hasattr(treeview, 'is_rubber_banding_active'):
1970 return True
1971 else:
1972 return not treeview.is_rubber_banding_active()
1974 if event.button == self.context_menu_mouse_button:
1975 episodes = self.get_selected_episodes()
1976 any_locked = any(e.is_locked for e in episodes)
1977 any_played = any(e.is_played for e in episodes)
1978 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1979 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1980 downloading = any(self.episode_is_downloading(e) for e in episodes)
1982 menu = gtk.Menu()
1984 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1986 if open_instead_of_play:
1987 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1988 elif downloaded:
1989 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1990 else:
1991 item = gtk.ImageMenuItem(_('Stream'))
1992 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1994 item.set_sensitive(can_play and not downloading)
1995 item.connect('activate', self.on_playback_selected_episodes)
1996 menu.append(self.set_finger_friendly(item))
1998 if not can_cancel:
1999 item = gtk.ImageMenuItem(_('Download'))
2000 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
2001 item.set_sensitive(can_download)
2002 item.connect('activate', self.on_download_selected_episodes)
2003 menu.append(self.set_finger_friendly(item))
2004 else:
2005 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
2006 item.connect('activate', self.on_item_cancel_download_activate)
2007 menu.append(self.set_finger_friendly(item))
2009 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
2010 item.set_sensitive(can_delete)
2011 item.connect('activate', self.on_btnDownloadedDelete_clicked)
2012 menu.append(self.set_finger_friendly(item))
2014 ICON = lambda x: x
2016 # Ok, this probably makes sense to only display for downloaded files
2017 if downloaded:
2018 menu.append(gtk.SeparatorMenuItem())
2019 share_item = gtk.MenuItem(_('Send to'))
2020 menu.append(self.set_finger_friendly(share_item))
2021 share_menu = gtk.Menu()
2023 item = gtk.ImageMenuItem(_('Local folder'))
2024 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
2025 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
2026 share_menu.append(self.set_finger_friendly(item))
2027 if self.bluetooth_available:
2028 item = gtk.ImageMenuItem(_('Bluetooth device'))
2029 if gpodder.ui.maemo:
2030 icon_name = ICON('qgn_list_filesys_bluetooth')
2031 else:
2032 icon_name = ICON('bluetooth')
2033 item.set_image(gtk.image_new_from_icon_name(icon_name, gtk.ICON_SIZE_MENU))
2034 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
2035 share_menu.append(self.set_finger_friendly(item))
2036 if can_transfer:
2037 item = gtk.ImageMenuItem(self.get_device_name())
2038 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
2039 item.connect('button-press-event', lambda w, ee: self.on_sync_to_ipod_activate(w, episodes))
2040 share_menu.append(self.set_finger_friendly(item))
2042 share_item.set_submenu(share_menu)
2044 if (downloaded or one_is_new or can_download) and not downloading:
2045 menu.append(gtk.SeparatorMenuItem())
2046 if one_is_new:
2047 item = gtk.CheckMenuItem(_('New'))
2048 item.set_active(True)
2049 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2050 menu.append(self.set_finger_friendly(item))
2051 elif can_download:
2052 item = gtk.CheckMenuItem(_('New'))
2053 item.set_active(False)
2054 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2055 menu.append(self.set_finger_friendly(item))
2057 if downloaded:
2058 item = gtk.CheckMenuItem(_('Played'))
2059 item.set_active(any_played)
2060 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, not any_played))
2061 menu.append(self.set_finger_friendly(item))
2063 item = gtk.CheckMenuItem(_('Keep episode'))
2064 item.set_active(any_locked)
2065 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
2066 menu.append(self.set_finger_friendly(item))
2068 menu.append(gtk.SeparatorMenuItem())
2069 # Single item, add episode information menu item
2070 item = gtk.ImageMenuItem(_('Episode details'))
2071 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
2072 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
2073 menu.append(self.set_finger_friendly(item))
2075 if gpodder.ui.maemo or self.config.enable_fingerscroll:
2076 # Because we open the popup on left-click for Maemo,
2077 # we also include a non-action to close the menu
2078 menu.append(gtk.SeparatorMenuItem())
2079 item = gtk.ImageMenuItem(_('Close this menu'))
2080 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
2081 menu.append(self.set_finger_friendly(item))
2083 menu.show_all()
2084 # Disable tooltips while we are showing the menu, so
2085 # the tooltip will not appear over the menu
2086 self.treeview_allow_tooltips(self.treeAvailable, False)
2087 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2088 menu.popup( None, None, None, event.button, event.time)
2090 return True
2092 def set_title(self, new_title):
2093 if not gpodder.ui.fremantle:
2094 self.default_title = new_title
2095 self.gPodder.set_title(new_title)
2097 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2099 Updates the status icons in the episode list.
2101 If urls is given, it should be a list of URLs
2102 of episodes that should be updated.
2104 If urls is None, set ONE OF selected, all to
2105 True (the former updates just the selected
2106 episodes and the latter updates all episodes).
2108 additional_args = (self.episode_is_downloading, \
2109 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2110 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2112 if urls is not None:
2113 # We have a list of URLs to walk through
2114 self.episode_list_model.update_by_urls(urls, *additional_args)
2115 elif selected and not all:
2116 # We should update all selected episodes
2117 selection = self.treeAvailable.get_selection()
2118 model, paths = selection.get_selected_rows()
2119 for path in reversed(paths):
2120 iter = model.get_iter(path)
2121 self.episode_list_model.update_by_filter_iter(iter, \
2122 *additional_args)
2123 elif all and not selected:
2124 # We update all (even the filter-hidden) episodes
2125 self.episode_list_model.update_all(*additional_args)
2126 else:
2127 # Wrong/invalid call - have to specify at least one parameter
2128 raise ValueError('Invalid call to update_episode_list_icons')
2130 def episode_list_status_changed(self, episodes):
2131 self.update_episode_list_icons(set(e.url for e in episodes))
2132 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2133 self.db.commit()
2135 def clean_up_downloads(self, delete_partial=False):
2136 # Clean up temporary files left behind by old gPodder versions
2137 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
2139 if delete_partial:
2140 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
2142 for tempfile in temporary_files:
2143 util.delete_file(tempfile)
2145 # Clean up empty download folders and abandoned download folders
2146 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
2147 for ddir in download_dirs:
2148 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
2149 globr = glob.glob(os.path.join(ddir, '*'))
2150 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
2151 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
2152 shutil.rmtree(ddir, ignore_errors=True)
2154 def streaming_possible(self):
2155 if gpodder.ui.desktop:
2156 # User has to have a media player set on the Desktop, or else we
2157 # would probably open the browser when giving a URL to xdg-open..
2158 return (self.config.player and self.config.player != 'default')
2159 elif gpodder.ui.maemo:
2160 # On Maemo, the default is to use the Nokia Media Player, which is
2161 # already able to deal with HTTP URLs the right way, so we
2162 # unconditionally enable streaming always on Maemo
2163 return True
2165 return False
2167 def playback_episodes_for_real(self, episodes):
2168 groups = collections.defaultdict(list)
2169 for episode in episodes:
2170 file_type = episode.file_type()
2171 if file_type == 'video' and self.config.videoplayer and \
2172 self.config.videoplayer != 'default':
2173 player = self.config.videoplayer
2174 if gpodder.ui.diablo:
2175 # Use the wrapper script if it's installed to crop 3GP YouTube
2176 # videos to fit the screen (looks much nicer than w/ black border)
2177 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
2178 player = 'gpodder-mplayer'
2179 elif gpodder.ui.fremantle and player == 'mplayer':
2180 player = 'mplayer -fs %F'
2181 elif file_type == 'audio' and self.config.player and \
2182 self.config.player != 'default':
2183 player = self.config.player
2184 else:
2185 player = 'default'
2187 if file_type not in ('audio', 'video') or \
2188 (file_type == 'audio' and not self.config.audio_played_dbus) or \
2189 (file_type == 'video' and not self.config.video_played_dbus):
2190 # Mark episode as played in the database
2191 episode.mark(is_played=True)
2192 self.mygpo_client.on_playback([episode])
2194 filename = episode.local_filename(create=False)
2195 if filename is None or not os.path.exists(filename):
2196 filename = episode.url
2197 if youtube.is_video_link(filename):
2198 fmt_id = self.config.youtube_preferred_fmt_id
2199 if gpodder.ui.fremantle:
2200 fmt_id = 5
2201 filename = youtube.get_real_download_url(filename, fmt_id)
2203 # Determine the playback resume position - if the file
2204 # was played 100%, we simply start from the beginning
2205 resume_position = episode.current_position
2206 if resume_position == episode.total_time:
2207 resume_position = 0
2209 if gpodder.ui.fremantle:
2210 self.mafw_monitor.set_resume_point(filename, resume_position)
2212 # If Panucci is configured, use D-Bus on Maemo to call it
2213 if player == 'panucci':
2214 try:
2215 PANUCCI_NAME = 'org.panucci.panucciInterface'
2216 PANUCCI_PATH = '/panucciInterface'
2217 PANUCCI_INTF = 'org.panucci.panucciInterface'
2218 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2219 i = dbus.Interface(o, PANUCCI_INTF)
2221 def on_reply(*args):
2222 pass
2224 def error_handler(filename, err):
2225 log('Exception in D-Bus call: %s', str(err), \
2226 sender=self)
2228 # Fallback: use the command line client
2229 for command in util.format_desktop_command('panucci', \
2230 [filename]):
2231 log('Executing: %s', repr(command), sender=self)
2232 subprocess.Popen(command)
2234 on_error = lambda err: error_handler(filename, err)
2236 # This method only exists in Panucci > 0.9 ('new Panucci')
2237 i.playback_from(filename, resume_position, \
2238 reply_handler=on_reply, error_handler=on_error)
2240 continue # This file was handled by the D-Bus call
2241 except Exception, e:
2242 log('Error calling Panucci using D-Bus', sender=self, traceback=True)
2243 elif player == 'MediaBox' and gpodder.ui.maemo:
2244 try:
2245 MEDIABOX_NAME = 'de.pycage.mediabox'
2246 MEDIABOX_PATH = '/de/pycage/mediabox/control'
2247 MEDIABOX_INTF = 'de.pycage.mediabox.control'
2248 o = gpodder.dbus_session_bus.get_object(MEDIABOX_NAME, MEDIABOX_PATH)
2249 i = dbus.Interface(o, MEDIABOX_INTF)
2251 def on_reply(*args):
2252 pass
2254 def on_error(err):
2255 log('Exception in D-Bus call: %s', str(err), \
2256 sender=self)
2258 i.load(filename, '%s/x-unknown' % file_type, \
2259 reply_handler=on_reply, error_handler=on_error)
2261 continue # This file was handled by the D-Bus call
2262 except Exception, e:
2263 log('Error calling MediaBox using D-Bus', sender=self, traceback=True)
2265 groups[player].append(filename)
2267 # Open episodes with system default player
2268 if 'default' in groups:
2269 if gpodder.ui.maemo and len(groups['default']) > 1:
2270 # The Nokia Media Player app does not support receiving multiple
2271 # file names via D-Bus, so we simply place all file names into a
2272 # temporary M3U playlist and open that with the Media Player.
2273 m3u_filename = os.path.join(gpodder.home, 'gpodder_open_with.m3u')
2274 util.write_m3u_playlist(m3u_filename, groups['default'], extm3u=False)
2275 util.gui_open(m3u_filename)
2276 else:
2277 for filename in groups['default']:
2278 log('Opening with system default: %s', filename, sender=self)
2279 util.gui_open(filename)
2280 del groups['default']
2281 elif gpodder.ui.maemo and groups:
2282 # When on Maemo and not opening with default, show a notification
2283 # (no startup notification for Panucci / MPlayer yet...)
2284 if len(episodes) == 1:
2285 text = _('Opening %s') % episodes[0].title
2286 else:
2287 count = len(episodes)
2288 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
2290 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
2292 def destroy_banner_later(banner):
2293 banner.destroy()
2294 return False
2295 gobject.timeout_add(5000, destroy_banner_later, banner)
2297 # For each type now, go and create play commands
2298 for group in groups:
2299 for command in util.format_desktop_command(group, groups[group]):
2300 log('Executing: %s', repr(command), sender=self)
2301 subprocess.Popen(command)
2303 # Persist episode status changes to the database
2304 self.db.commit()
2306 # Flush updated episode status
2307 self.mygpo_client.flush()
2309 def playback_episodes(self, episodes):
2310 # We need to create a list, because we run through it more than once
2311 episodes = list(PodcastEpisode.sort_by_pubdate(e for e in episodes if \
2312 e.was_downloaded(and_exists=True) or self.streaming_possible()))
2314 try:
2315 self.playback_episodes_for_real(episodes)
2316 except Exception, e:
2317 log('Error in playback!', sender=self, traceback=True)
2318 if gpodder.ui.desktop:
2319 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2320 _('Error opening player'), widget=self.toolPreferences)
2321 else:
2322 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2324 channel_urls = set()
2325 episode_urls = set()
2326 for episode in episodes:
2327 channel_urls.add(episode.channel.url)
2328 episode_urls.add(episode.url)
2329 self.update_episode_list_icons(episode_urls)
2330 self.update_podcast_list_model(channel_urls)
2332 def play_or_download(self):
2333 if not gpodder.ui.fremantle:
2334 if self.wNotebook.get_current_page() > 0:
2335 if gpodder.ui.desktop:
2336 self.toolCancel.set_sensitive(True)
2337 return
2339 if self.currently_updating:
2340 return (False, False, False, False, False, False)
2342 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
2343 ( is_played, is_locked ) = (False,)*2
2345 open_instead_of_play = False
2347 selection = self.treeAvailable.get_selection()
2348 if selection.count_selected_rows() > 0:
2349 (model, paths) = selection.get_selected_rows()
2351 for path in paths:
2352 try:
2353 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2354 except TypeError, te:
2355 log('Invalid episode at path %s', str(path), sender=self)
2356 continue
2358 if episode.file_type() not in ('audio', 'video'):
2359 open_instead_of_play = True
2361 if episode.was_downloaded():
2362 can_play = episode.was_downloaded(and_exists=True)
2363 is_played = episode.is_played
2364 is_locked = episode.is_locked
2365 if not can_play:
2366 can_download = True
2367 else:
2368 if self.episode_is_downloading(episode):
2369 can_cancel = True
2370 else:
2371 can_download = True
2373 can_download = can_download and not can_cancel
2374 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2375 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
2376 can_delete = not can_cancel
2378 if gpodder.ui.desktop:
2379 if open_instead_of_play:
2380 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2381 else:
2382 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2383 self.toolPlay.set_sensitive( can_play)
2384 self.toolDownload.set_sensitive( can_download)
2385 self.toolTransfer.set_sensitive( can_transfer)
2386 self.toolCancel.set_sensitive( can_cancel)
2388 if not gpodder.ui.fremantle:
2389 self.item_cancel_download.set_sensitive(can_cancel)
2390 self.itemDownloadSelected.set_sensitive(can_download)
2391 self.itemOpenSelected.set_sensitive(can_play)
2392 self.itemPlaySelected.set_sensitive(can_play)
2393 self.itemDeleteSelected.set_sensitive(can_delete)
2394 self.item_toggle_played.set_sensitive(can_play)
2395 self.item_toggle_lock.set_sensitive(can_play)
2396 self.itemOpenSelected.set_visible(open_instead_of_play)
2397 self.itemPlaySelected.set_visible(not open_instead_of_play)
2399 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
2401 def on_cbMaxDownloads_toggled(self, widget, *args):
2402 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2404 def on_cbLimitDownloads_toggled(self, widget, *args):
2405 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2407 def episode_new_status_changed(self, urls):
2408 self.update_podcast_list_model()
2409 self.update_episode_list_icons(urls)
2411 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2412 """Update the podcast list treeview model
2414 If urls is given, it should list the URLs of each
2415 podcast that has to be updated in the list.
2417 If selected is True, only update the model contents
2418 for the currently-selected podcast - nothing more.
2420 The caller can optionally specify "select_url",
2421 which is the URL of the podcast that is to be
2422 selected in the list after the update is complete.
2423 This only works if the podcast list has to be
2424 reloaded; i.e. something has been added or removed
2425 since the last update of the podcast list).
2427 selection = self.treeChannels.get_selection()
2428 model, iter = selection.get_selected()
2430 if self.config.podcast_list_view_all and not self.channel_list_changed:
2431 # Update "all episodes" view in any case (if enabled)
2432 self.podcast_list_model.update_first_row()
2434 if selected:
2435 # very cheap! only update selected channel
2436 if iter is not None:
2437 # If we have selected the "all episodes" view, we have
2438 # to update all channels for selected episodes:
2439 if self.config.podcast_list_view_all and \
2440 self.podcast_list_model.iter_is_first_row(iter):
2441 urls = self.get_podcast_urls_from_selected_episodes()
2442 self.podcast_list_model.update_by_urls(urls)
2443 else:
2444 # Otherwise just update the selected row (a podcast)
2445 self.podcast_list_model.update_by_filter_iter(iter)
2446 elif not self.channel_list_changed:
2447 # we can keep the model, but have to update some
2448 if urls is None:
2449 # still cheaper than reloading the whole list
2450 self.podcast_list_model.update_all()
2451 else:
2452 # ok, we got a bunch of urls to update
2453 self.podcast_list_model.update_by_urls(urls)
2454 else:
2455 if model and iter and select_url is None:
2456 # Get the URL of the currently-selected podcast
2457 select_url = model.get_value(iter, PodcastListModel.C_URL)
2459 # Update the podcast list model with new channels
2460 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2462 try:
2463 selected_iter = model.get_iter_first()
2464 # Find the previously-selected URL in the new
2465 # model if we have an URL (else select first)
2466 if select_url is not None:
2467 pos = model.get_iter_first()
2468 while pos is not None:
2469 url = model.get_value(pos, PodcastListModel.C_URL)
2470 if url == select_url:
2471 selected_iter = pos
2472 break
2473 pos = model.iter_next(pos)
2475 if not gpodder.ui.fremantle:
2476 if selected_iter is not None:
2477 selection.select_iter(selected_iter)
2478 self.on_treeChannels_cursor_changed(self.treeChannels)
2479 except:
2480 log('Cannot select podcast in list', traceback=True, sender=self)
2481 self.channel_list_changed = False
2483 def episode_is_downloading(self, episode):
2484 """Returns True if the given episode is being downloaded at the moment"""
2485 if episode is None:
2486 return False
2488 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2490 def update_episode_list_model(self):
2491 if self.channels and self.active_channel is not None:
2492 if gpodder.ui.fremantle:
2493 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2495 self.currently_updating = True
2496 self.treeAvailable.hide()
2498 def update():
2499 additional_args = (self.episode_is_downloading, \
2500 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2501 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2502 self.episode_list_model.replace_from_channel(self.active_channel, *additional_args)
2504 self.treeAvailable.get_selection().unselect_all()
2505 self.treeAvailable.show()
2506 util.idle_add(self.treeAvailable.scroll_to_point, 0, 0)
2507 self.currently_updating = False
2508 self.play_or_download()
2510 if gpodder.ui.fremantle:
2511 util.idle_add(hildon.hildon_gtk_window_set_progress_indicator,
2512 self.episodes_window.main_window, False)
2514 util.idle_add(update)
2515 else:
2516 self.episode_list_model.clear()
2518 @dbus.service.method(gpodder.dbus_interface)
2519 def offer_new_episodes(self, channels=None):
2520 if gpodder.ui.fremantle:
2521 # Assume that when this function is called that the
2522 # notification is not shown anymore (Maemo bug 11345)
2523 self._fremantle_notification_visible = False
2525 new_episodes = self.get_new_episodes(channels)
2526 if new_episodes:
2527 self.new_episodes_show(new_episodes)
2528 return True
2529 return False
2531 def add_podcast_list(self, urls, auth_tokens=None):
2532 """Subscribe to a list of podcast given their URLs
2534 If auth_tokens is given, it should be a dictionary
2535 mapping URLs to (username, password) tuples."""
2537 if auth_tokens is None:
2538 auth_tokens = {}
2540 # Sort and split the URL list into five buckets
2541 queued, failed, existing, worked, authreq = [], [], [], [], []
2542 for input_url in urls:
2543 url = util.normalize_feed_url(input_url)
2544 if url is None:
2545 # Fail this one because the URL is not valid
2546 failed.append(input_url)
2547 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2548 # A podcast already exists in the list for this URL
2549 existing.append(url)
2550 else:
2551 # This URL has survived the first round - queue for add
2552 queued.append(url)
2553 if url != input_url and input_url in auth_tokens:
2554 auth_tokens[url] = auth_tokens[input_url]
2556 error_messages = {}
2557 redirections = {}
2559 progress = ProgressIndicator(_('Adding podcasts'), \
2560 _('Please wait while episode information is downloaded.'), \
2561 parent=self.get_dialog_parent())
2563 def on_after_update():
2564 progress.on_finished()
2565 # Report already-existing subscriptions to the user
2566 if existing:
2567 title = _('Existing subscriptions skipped')
2568 message = _('You are already subscribed to these podcasts:') \
2569 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2570 self.show_message(message, title, widget=self.treeChannels)
2572 # Report subscriptions that require authentication
2573 if authreq:
2574 retry_podcasts = {}
2575 for url in authreq:
2576 title = _('Podcast requires authentication')
2577 message = _('Please login to %s:') % (saxutils.escape(url),)
2578 success, auth_tokens = self.show_login_dialog(title, message)
2579 if success:
2580 retry_podcasts[url] = auth_tokens
2581 else:
2582 # Stop asking the user for more login data
2583 retry_podcasts = {}
2584 for url in authreq:
2585 error_messages[url] = _('Authentication failed')
2586 failed.append(url)
2587 break
2589 # If we have authentication data to retry, do so here
2590 if retry_podcasts:
2591 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2593 # Report website redirections
2594 for url in redirections:
2595 title = _('Website redirection detected')
2596 message = _('The URL %(url)s redirects to %(target)s.') \
2597 + '\n\n' + _('Do you want to visit the website now?')
2598 message = message % {'url': url, 'target': redirections[url]}
2599 if self.show_confirmation(message, title):
2600 util.open_website(url)
2601 else:
2602 break
2604 # Report failed subscriptions to the user
2605 if failed:
2606 title = _('Could not add some podcasts')
2607 message = _('Some podcasts could not be added to your list:') \
2608 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2609 error_messages.get(url, _('Unknown')))) for url in failed)
2610 self.show_message(message, title, important=True)
2612 # Upload subscription changes to gpodder.net
2613 self.mygpo_client.on_subscribe(worked)
2615 # If at least one podcast has been added, save and update all
2616 if self.channel_list_changed:
2617 # Fix URLs if mygpo has rewritten them
2618 self.rewrite_urls_mygpo()
2620 self.save_channels_opml()
2622 # If only one podcast was added, select it after the update
2623 if len(worked) == 1:
2624 url = worked[0]
2625 else:
2626 url = None
2628 # Update the list of subscribed podcasts
2629 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2630 self.update_podcasts_tab()
2632 # Offer to download new episodes
2633 episodes = []
2634 for podcast in self.channels:
2635 if podcast.url in worked:
2636 episodes.extend(podcast.get_all_episodes())
2638 if episodes:
2639 episodes = list(PodcastEpisode.sort_by_pubdate(episodes, \
2640 reverse=True))
2641 self.new_episodes_show(episodes, \
2642 selected=[e.check_is_new() for e in episodes])
2645 def thread_proc():
2646 # After the initial sorting and splitting, try all queued podcasts
2647 length = len(queued)
2648 for index, url in enumerate(queued):
2649 progress.on_progress(float(index)/float(length))
2650 progress.on_message(url)
2651 log('QUEUE RUNNER: %s', url, sender=self)
2652 try:
2653 # The URL is valid and does not exist already - subscribe!
2654 channel = PodcastChannel.load(self.db, url=url, create=True, \
2655 authentication_tokens=auth_tokens.get(url, None), \
2656 max_episodes=self.config.max_episodes_per_feed, \
2657 download_dir=self.config.download_dir, \
2658 allow_empty_feeds=self.config.allow_empty_feeds, \
2659 mimetype_prefs=self.config.mimetype_prefs)
2661 try:
2662 username, password = util.username_password_from_url(url)
2663 except ValueError, ve:
2664 username, password = (None, None)
2666 if username is not None and channel.username is None and \
2667 password is not None and channel.password is None:
2668 channel.username = username
2669 channel.password = password
2670 channel.save()
2672 self._update_cover(channel)
2673 except feedcore.AuthenticationRequired:
2674 if url in auth_tokens:
2675 # Fail for wrong authentication data
2676 error_messages[url] = _('Authentication failed')
2677 failed.append(url)
2678 else:
2679 # Queue for login dialog later
2680 authreq.append(url)
2681 continue
2682 except feedcore.WifiLogin, error:
2683 redirections[url] = error.data
2684 failed.append(url)
2685 error_messages[url] = _('Redirection detected')
2686 continue
2687 except Exception, e:
2688 log('Subscription error: %s', e, traceback=True, sender=self)
2689 error_messages[url] = str(e)
2690 failed.append(url)
2691 continue
2693 assert channel is not None
2694 worked.append(channel.url)
2695 self.channels.append(channel)
2696 self.channel_list_changed = True
2697 util.idle_add(on_after_update)
2698 threading.Thread(target=thread_proc).start()
2700 def save_channels_opml(self):
2701 exporter = opml.Exporter(gpodder.subscription_file)
2702 return exporter.write(self.channels)
2704 def find_episode(self, podcast_url, episode_url):
2705 """Find an episode given its podcast and episode URL
2707 The function will return a PodcastEpisode object if
2708 the episode is found, or None if it's not found.
2710 for podcast in self.channels:
2711 if podcast_url == podcast.url:
2712 for episode in podcast.get_all_episodes():
2713 if episode_url == episode.url:
2714 return episode
2716 return None
2718 def process_received_episode_actions(self, updated_urls):
2719 """Process/merge episode actions from gpodder.net
2721 This function will merge all changes received from
2722 the server to the local database and update the
2723 status of the affected episodes as necessary.
2725 indicator = ProgressIndicator(_('Merging episode actions'), \
2726 _('Episode actions from gpodder.net are merged.'), \
2727 False, self.get_dialog_parent())
2729 for idx, action in enumerate(self.mygpo_client.get_episode_actions(updated_urls)):
2730 if action.action == 'play':
2731 episode = self.find_episode(action.podcast_url, \
2732 action.episode_url)
2734 if episode is not None:
2735 log('Play action for %s', episode.url, sender=self)
2736 episode.mark(is_played=True)
2738 if action.timestamp > episode.current_position_updated and \
2739 action.position is not None:
2740 log('Updating position for %s', episode.url, sender=self)
2741 episode.current_position = action.position
2742 episode.current_position_updated = action.timestamp
2744 if action.total:
2745 log('Updating total time for %s', episode.url, sender=self)
2746 episode.total_time = action.total
2748 episode.save()
2749 elif action.action == 'delete':
2750 episode = self.find_episode(action.podcast_url, \
2751 action.episode_url)
2753 if episode is not None:
2754 if not episode.was_downloaded(and_exists=True):
2755 # Set the episode to a "deleted" state
2756 log('Marking as deleted: %s', episode.url, sender=self)
2757 episode.delete_from_disk()
2758 episode.save()
2760 indicator.on_message(N_('%d action processed', '%d actions processed', idx) % idx)
2761 gtk.main_iteration(False)
2763 indicator.on_finished()
2764 self.db.commit()
2767 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2768 self.db.commit()
2769 self.updating_feed_cache = False
2771 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2773 # Process received episode actions for all updated URLs
2774 self.process_received_episode_actions(updated_urls)
2776 self.channel_list_changed = True
2777 self.update_podcast_list_model(select_url=select_url_afterwards)
2779 # Only search for new episodes in podcasts that have been
2780 # updated, not in other podcasts (for single-feed updates)
2781 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2783 if gpodder.ui.fremantle:
2784 self.fancy_progress_bar.hide()
2785 self.button_subscribe.set_sensitive(True)
2786 self.button_refresh.set_sensitive(True)
2787 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2788 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2789 self.update_podcasts_tab()
2790 self.update_episode_list_model()
2791 if self.feed_cache_update_cancelled:
2792 return
2794 def application_in_foreground():
2795 try:
2796 return any(w.get_property('is-topmost') for w in hildon.WindowStack.get_default().get_windows())
2797 except Exception, e:
2798 log('Could not determine is-topmost', traceback=True)
2799 # When in doubt, assume not in foreground
2800 return False
2802 if episodes:
2803 if self.config.auto_download == 'quiet' and not self.config.auto_update_feeds:
2804 # New episodes found, but we should do nothing
2805 self.show_message(_('New episodes are available.'))
2806 elif self.config.auto_download == 'always':
2807 count = len(episodes)
2808 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2809 self.show_message(title)
2810 self.download_episode_list(episodes)
2811 elif self.config.auto_download == 'queue':
2812 self.show_message(_('New episodes have been added to the download list.'))
2813 self.download_episode_list_paused(episodes)
2814 elif application_in_foreground():
2815 if not self._fremantle_notification_visible:
2816 self.new_episodes_show(episodes)
2817 elif not self._fremantle_notification_visible:
2818 try:
2819 import pynotify
2820 pynotify.init('gPodder')
2821 n = pynotify.Notification('gPodder', _('New episodes available'), 'gpodder')
2822 n.set_urgency(pynotify.URGENCY_CRITICAL)
2823 n.set_hint('dbus-callback-default', ' '.join([
2824 gpodder.dbus_bus_name,
2825 gpodder.dbus_gui_object_path,
2826 gpodder.dbus_interface,
2827 'offer_new_episodes',
2829 n.set_category('gpodder-new-episodes')
2830 n.show()
2831 self._fremantle_notification_visible = True
2832 except Exception, e:
2833 log('Error: %s', str(e), sender=self, traceback=True)
2834 self.new_episodes_show(episodes)
2835 self._fremantle_notification_visible = False
2836 elif not self.config.auto_update_feeds:
2837 self.show_message(_('No new episodes. Please check for new episodes later.'))
2838 return
2840 if self.tray_icon:
2841 self.tray_icon.set_status()
2843 if self.feed_cache_update_cancelled:
2844 # The user decided to abort the feed update
2845 self.show_update_feeds_buttons()
2846 elif not episodes:
2847 # Nothing new here - but inform the user
2848 self.pbFeedUpdate.set_fraction(1.0)
2849 self.pbFeedUpdate.set_text(_('No new episodes'))
2850 self.feed_cache_update_cancelled = True
2851 self.btnCancelFeedUpdate.show()
2852 self.btnCancelFeedUpdate.set_sensitive(True)
2853 self.itemUpdate.set_sensitive(True)
2854 if gpodder.ui.maemo:
2855 # btnCancelFeedUpdate is a ToolButton on Maemo
2856 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2857 else:
2858 # btnCancelFeedUpdate is a normal gtk.Button
2859 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2860 else:
2861 count = len(episodes)
2862 # New episodes are available
2863 self.pbFeedUpdate.set_fraction(1.0)
2864 # Are we minimized and should we auto download?
2865 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2866 self.download_episode_list(episodes)
2867 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2868 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2869 self.show_update_feeds_buttons()
2870 elif self.config.auto_download == 'queue':
2871 self.download_episode_list_paused(episodes)
2872 title = N_('%d new episode added to download list.', '%d new episodes added to download list.', count) % count
2873 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2874 self.show_update_feeds_buttons()
2875 else:
2876 self.show_update_feeds_buttons()
2877 # New episodes are available and we are not minimized
2878 if not self.config.do_not_show_new_episodes_dialog:
2879 self.new_episodes_show(episodes, notification=True)
2880 else:
2881 message = N_('%d new episode available', '%d new episodes available', count) % count
2882 self.pbFeedUpdate.set_text(message)
2884 def _update_cover(self, channel):
2885 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2886 self.cover_downloader.request_cover(channel)
2888 def update_feed_cache_proc(self, channels, select_url_afterwards):
2889 total = len(channels)
2891 for updated, channel in enumerate(channels):
2892 if not self.feed_cache_update_cancelled:
2893 try:
2894 channel.update(max_episodes=self.config.max_episodes_per_feed, \
2895 mimetype_prefs=self.config.mimetype_prefs)
2896 self._update_cover(channel)
2897 except Exception, e:
2898 d = {'url': saxutils.escape(channel.url), 'message': saxutils.escape(str(e))}
2899 if d['message']:
2900 message = _('Error while updating %(url)s: %(message)s')
2901 else:
2902 message = _('The feed at %(url)s could not be updated.')
2903 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2904 log('Error: %s', str(e), sender=self, traceback=True)
2906 if self.feed_cache_update_cancelled:
2907 break
2909 # By the time we get here the update may have already been cancelled
2910 if not self.feed_cache_update_cancelled:
2911 def update_progress():
2912 d = {'podcast': channel.title, 'position': updated+1, 'total': total}
2913 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2914 self.pbFeedUpdate.set_text(progression)
2915 if self.tray_icon:
2916 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2917 self.pbFeedUpdate.set_fraction(float(updated+1)/float(total))
2918 util.idle_add(update_progress)
2920 updated_urls = [c.url for c in channels]
2921 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2923 def show_update_feeds_buttons(self):
2924 # Make sure that the buttons for updating feeds
2925 # appear - this should happen after a feed update
2926 if gpodder.ui.maemo:
2927 self.btnUpdateSelectedFeed.show()
2928 self.toolFeedUpdateProgress.hide()
2929 self.btnCancelFeedUpdate.hide()
2930 self.btnCancelFeedUpdate.set_is_important(False)
2931 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2932 self.toolbarSpacer.set_expand(True)
2933 self.toolbarSpacer.set_draw(False)
2934 else:
2935 self.hboxUpdateFeeds.hide()
2936 self.btnUpdateFeeds.show()
2937 self.itemUpdate.set_sensitive(True)
2938 self.itemUpdateChannel.set_sensitive(True)
2940 def on_btnCancelFeedUpdate_clicked(self, widget):
2941 if not self.feed_cache_update_cancelled:
2942 self.pbFeedUpdate.set_text(_('Cancelling...'))
2943 self.feed_cache_update_cancelled = True
2944 if not gpodder.ui.fremantle:
2945 self.btnCancelFeedUpdate.set_sensitive(False)
2946 elif not gpodder.ui.fremantle:
2947 self.show_update_feeds_buttons()
2949 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2950 if self.updating_feed_cache:
2951 if gpodder.ui.fremantle:
2952 self.feed_cache_update_cancelled = True
2953 return
2955 if not force_update:
2956 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2957 self.channel_list_changed = True
2958 self.update_podcast_list_model(select_url=select_url_afterwards)
2959 return
2961 # Fix URLs if mygpo has rewritten them
2962 self.rewrite_urls_mygpo()
2964 self.updating_feed_cache = True
2966 if channels is None:
2967 # Only update podcasts for which updates are enabled
2968 channels = [c for c in self.channels if c.feed_update_enabled]
2970 if gpodder.ui.fremantle:
2971 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2972 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2973 self.fancy_progress_bar.show()
2974 self.button_subscribe.set_sensitive(False)
2975 self.button_refresh.set_sensitive(False)
2976 self.feed_cache_update_cancelled = False
2977 else:
2978 self.itemUpdate.set_sensitive(False)
2979 self.itemUpdateChannel.set_sensitive(False)
2981 if self.tray_icon:
2982 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2984 self.feed_cache_update_cancelled = False
2985 self.btnCancelFeedUpdate.show()
2986 self.btnCancelFeedUpdate.set_sensitive(True)
2987 if gpodder.ui.maemo:
2988 self.toolbarSpacer.set_expand(False)
2989 self.toolbarSpacer.set_draw(True)
2990 self.btnUpdateSelectedFeed.hide()
2991 self.toolFeedUpdateProgress.show_all()
2992 else:
2993 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2994 self.hboxUpdateFeeds.show_all()
2995 self.btnUpdateFeeds.hide()
2997 if len(channels) == 1:
2998 text = _('Updating "%s"...') % channels[0].title
2999 else:
3000 count = len(channels)
3001 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
3002 self.pbFeedUpdate.set_text(text)
3003 self.pbFeedUpdate.set_fraction(0)
3005 args = (channels, select_url_afterwards)
3006 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
3008 def on_gPodder_delete_event(self, widget, *args):
3009 """Called when the GUI wants to close the window
3010 Displays a confirmation dialog (and closes/hides gPodder)
3013 downloading = self.download_status_model.are_downloads_in_progress()
3015 # Only iconify if we are using the window's "X" button,
3016 # but not when we are using "Quit" in the menu or toolbar
3017 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
3018 self.iconify_main_window()
3019 elif self.config.on_quit_ask or downloading:
3020 if gpodder.ui.fremantle:
3021 self.close_gpodder()
3022 elif gpodder.ui.diablo:
3023 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
3024 if result:
3025 self.close_gpodder()
3026 else:
3027 return True
3028 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
3029 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3030 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
3032 title = _('Quit gPodder')
3033 if downloading:
3034 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
3035 else:
3036 message = _('Do you really want to quit gPodder now?')
3038 dialog.set_title(title)
3039 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
3040 if not downloading:
3041 cb_ask = gtk.CheckButton(_("Don't ask me again"))
3042 dialog.vbox.pack_start(cb_ask)
3043 cb_ask.show_all()
3045 quit_button.grab_focus()
3046 result = dialog.run()
3047 dialog.destroy()
3049 if result == gtk.RESPONSE_CLOSE:
3050 if not downloading and cb_ask.get_active() == True:
3051 self.config.on_quit_ask = False
3052 self.close_gpodder()
3053 else:
3054 self.close_gpodder()
3056 return True
3058 def close_gpodder(self):
3059 """ clean everything and exit properly
3061 if self.channels:
3062 if self.save_channels_opml():
3063 pass # FIXME: Add mygpo synchronization here
3064 else:
3065 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
3067 self.gPodder.hide()
3069 if self.tray_icon is not None:
3070 self.tray_icon.set_visible(False)
3072 # Notify all tasks to to carry out any clean-up actions
3073 self.download_status_model.tell_all_tasks_to_quit()
3075 while gtk.events_pending():
3076 gtk.main_iteration(False)
3078 self.db.close()
3080 self.quit()
3081 sys.exit(0)
3083 def get_expired_episodes(self):
3084 for channel in self.channels:
3085 for episode in channel.get_downloaded_episodes():
3086 # Never consider locked episodes as old
3087 if episode.is_locked:
3088 continue
3090 # Never consider fresh episodes as old
3091 if episode.age_in_days() < self.config.episode_old_age:
3092 continue
3094 # Do not delete played episodes (except if configured)
3095 if episode.is_played:
3096 if not self.config.auto_remove_played_episodes:
3097 continue
3099 # Do not delete unplayed episodes (except if configured)
3100 if not episode.is_played:
3101 if not self.config.auto_remove_unplayed_episodes:
3102 continue
3104 yield episode
3106 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
3107 if not episodes:
3108 return False
3110 if skip_locked:
3111 episodes = [e for e in episodes if not e.is_locked]
3113 if not episodes:
3114 title = _('Episodes are locked')
3115 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
3116 self.notification(message, title, widget=self.treeAvailable)
3117 return False
3119 count = len(episodes)
3120 title = N_('Delete %d episode?', 'Delete %d episodes?', count) % count
3121 message = _('Deleting episodes removes downloaded files.')
3123 if gpodder.ui.fremantle:
3124 message = '\n'.join([title, message])
3126 if confirm and not self.show_confirmation(message, title):
3127 return False
3129 progress = ProgressIndicator(_('Deleting episodes'), \
3130 _('Please wait while episodes are deleted'), \
3131 parent=self.get_dialog_parent())
3133 def finish_deletion(episode_urls, channel_urls):
3134 progress.on_finished()
3136 # Episodes have been deleted - persist the database
3137 self.db.commit()
3139 self.update_episode_list_icons(episode_urls)
3140 self.update_podcast_list_model(channel_urls)
3141 self.play_or_download()
3143 def thread_proc():
3144 episode_urls = set()
3145 channel_urls = set()
3147 episodes_status_update = []
3148 for idx, episode in enumerate(episodes):
3149 progress.on_progress(float(idx)/float(len(episodes)))
3150 if episode.is_locked and skip_locked:
3151 log('Not deleting episode (is locked): %s', episode.title)
3152 else:
3153 log('Deleting episode: %s', episode.title)
3154 progress.on_message(episode.title)
3155 episode.delete_from_disk()
3156 episode_urls.add(episode.url)
3157 channel_urls.add(episode.channel.url)
3158 episodes_status_update.append(episode)
3160 # Tell the shownotes window that we have removed the episode
3161 if self.episode_shownotes_window is not None and \
3162 self.episode_shownotes_window.episode is not None and \
3163 self.episode_shownotes_window.episode.url == episode.url:
3164 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
3166 # Notify the web service about the status update + upload
3167 self.mygpo_client.on_delete(episodes_status_update)
3168 self.mygpo_client.flush()
3170 util.idle_add(finish_deletion, episode_urls, channel_urls)
3172 threading.Thread(target=thread_proc).start()
3174 return True
3176 def on_itemRemoveOldEpisodes_activate( self, widget):
3177 if gpodder.ui.maemo:
3178 columns = (
3179 ('maemo_remove_markup', None, None, _('Episode')),
3181 else:
3182 columns = (
3183 ('title_markup', None, None, _('Episode')),
3184 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3185 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3186 ('played_prop', None, None, _('Status')),
3187 ('age_prop', 'age_int_prop', gobject.TYPE_INT, _('Downloaded')),
3190 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
3191 selection_buttons = {
3192 _('Select played'): lambda episode: episode.is_played,
3193 _('Select finished'): lambda episode: episode.is_finished(),
3194 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3197 instructions = _('Select the episodes you want to delete:')
3199 episodes = []
3200 selected = []
3201 for channel in self.channels:
3202 for episode in channel.get_downloaded_episodes():
3203 # Disallow deletion of locked episodes that still exist
3204 if not episode.is_locked or not episode.file_exists():
3205 episodes.append(episode)
3206 # Automatically select played and file-less episodes
3207 selected.append(episode.is_played or \
3208 not episode.file_exists())
3210 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
3211 episodes = episodes, selected = selected, columns = columns, \
3212 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
3213 selection_buttons = selection_buttons, _config=self.config, \
3214 show_episode_shownotes=self.show_episode_shownotes)
3216 def on_selected_episodes_status_changed(self):
3217 # The order of the updates here is important! When "All episodes" is
3218 # selected, the update of the podcast list model depends on the episode
3219 # list selection to determine which podcasts are affected. Updating
3220 # the episode list could remove the selection if a filter is active.
3221 self.update_podcast_list_model(selected=True)
3222 self.update_episode_list_icons(selected=True)
3223 self.db.commit()
3225 def mark_selected_episodes_new(self):
3226 for episode in self.get_selected_episodes():
3227 episode.mark_new()
3228 self.on_selected_episodes_status_changed()
3230 def mark_selected_episodes_old(self):
3231 for episode in self.get_selected_episodes():
3232 episode.mark_old()
3233 self.on_selected_episodes_status_changed()
3235 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
3236 for episode in self.get_selected_episodes():
3237 if toggle:
3238 episode.mark(is_played=not episode.is_played)
3239 else:
3240 episode.mark(is_played=new_value)
3241 self.on_selected_episodes_status_changed()
3243 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3244 for episode in self.get_selected_episodes():
3245 if toggle:
3246 episode.mark(is_locked=not episode.is_locked)
3247 else:
3248 episode.mark(is_locked=new_value)
3249 self.on_selected_episodes_status_changed()
3251 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3252 if self.active_channel is None:
3253 return
3255 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
3256 self.active_channel.update_channel_lock()
3258 for episode in self.active_channel.get_all_episodes():
3259 episode.mark(is_locked=self.active_channel.channel_is_locked)
3261 self.update_podcast_list_model(selected=True)
3262 self.update_episode_list_icons(all=True)
3264 def on_itemUpdateChannel_activate(self, widget=None):
3265 if self.active_channel is None:
3266 title = _('No podcast selected')
3267 message = _('Please select a podcast in the podcasts list to update.')
3268 self.show_message( message, title, widget=self.treeChannels)
3269 return
3271 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3272 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3273 self.update_feed_cache()
3274 else:
3275 self.update_feed_cache(channels=[self.active_channel])
3277 def on_itemUpdate_activate(self, widget=None):
3278 # Check if we have outstanding subscribe/unsubscribe actions
3279 if self.on_add_remove_podcasts_mygpo():
3280 log('Update cancelled (received server changes)', sender=self)
3281 return
3283 if self.channels:
3284 self.update_feed_cache()
3285 else:
3286 gPodderWelcome(self.gPodder,
3287 center_on_widget=self.gPodder,
3288 show_example_podcasts_callback=self.on_itemImportChannels_activate,
3289 setup_my_gpodder_callback=self.on_download_subscriptions_from_mygpo)
3291 def download_episode_list_paused(self, episodes):
3292 self.download_episode_list(episodes, True)
3294 def download_episode_list(self, episodes, add_paused=False, force_start=False):
3295 enable_update = False
3297 for episode in episodes:
3298 log('Downloading episode: %s', episode.title, sender = self)
3299 if not episode.was_downloaded(and_exists=True):
3300 task_exists = False
3301 for task in self.download_tasks_seen:
3302 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
3303 self.download_queue_manager.add_task(task, force_start)
3304 enable_update = True
3305 task_exists = True
3306 continue
3308 if task_exists:
3309 continue
3311 try:
3312 task = download.DownloadTask(episode, self.config)
3313 except Exception, e:
3314 d = {'episode': episode.title, 'message': str(e)}
3315 message = _('Download error while downloading %(episode)s: %(message)s')
3316 self.show_message(message % d, _('Download error'), important=True)
3317 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
3318 continue
3320 if add_paused:
3321 task.status = task.PAUSED
3322 else:
3323 self.mygpo_client.on_download([task.episode])
3324 self.download_queue_manager.add_task(task, force_start)
3326 self.download_status_model.register_task(task)
3327 enable_update = True
3329 if enable_update:
3330 self.enable_download_list_update()
3332 # Flush updated episode status
3333 self.mygpo_client.flush()
3335 def cancel_task_list(self, tasks):
3336 if not tasks:
3337 return
3339 for task in tasks:
3340 if task.status in (task.QUEUED, task.DOWNLOADING):
3341 task.status = task.CANCELLED
3342 elif task.status == task.PAUSED:
3343 task.status = task.CANCELLED
3344 # Call run, so the partial file gets deleted
3345 task.run()
3347 self.update_episode_list_icons([task.url for task in tasks])
3348 self.play_or_download()
3350 # Update the tab title and downloads list
3351 self.update_downloads_list()
3353 def new_episodes_show(self, episodes, notification=False, selected=None):
3354 if gpodder.ui.maemo:
3355 columns = (
3356 ('maemo_markup', None, None, _('Episode')),
3358 show_notification = notification
3359 else:
3360 columns = (
3361 ('title_markup', None, None, _('Episode')),
3362 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3363 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3365 show_notification = False
3367 instructions = _('Select the episodes you want to download:')
3369 if self.new_episodes_window is not None:
3370 self.new_episodes_window.main_window.destroy()
3371 self.new_episodes_window = None
3373 def download_episodes_callback(episodes):
3374 self.new_episodes_window = None
3375 self.download_episode_list(episodes)
3377 if selected is None:
3378 # Select all by default
3379 selected = [True]*len(episodes)
3381 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
3382 title=_('New episodes available'), \
3383 instructions=instructions, \
3384 episodes=episodes, \
3385 columns=columns, \
3386 selected=selected, \
3387 stock_ok_button = 'gpodder-download', \
3388 callback=download_episodes_callback, \
3389 remove_callback=lambda e: e.mark_old(), \
3390 remove_action=_('Mark as old'), \
3391 remove_finished=self.episode_new_status_changed, \
3392 _config=self.config, \
3393 show_notification=show_notification, \
3394 show_episode_shownotes=self.show_episode_shownotes)
3396 def on_itemDownloadAllNew_activate(self, widget, *args):
3397 if not self.offer_new_episodes():
3398 self.show_message(_('Please check for new episodes later.'), \
3399 _('No new episodes available'), widget=self.btnUpdateFeeds)
3401 def get_new_episodes(self, channels=None):
3402 if channels is None:
3403 channels = self.channels
3404 episodes = []
3405 for channel in channels:
3406 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
3407 episodes.append(episode)
3409 return episodes
3411 @dbus.service.method(gpodder.dbus_interface)
3412 def start_device_synchronization(self):
3413 """Public D-Bus API for starting Device sync (Desktop only)
3415 This method can be called to initiate a synchronization with
3416 a configured protable media player. This only works for the
3417 Desktop version of gPodder and does nothing on Maemo.
3419 if gpodder.ui.desktop:
3420 self.on_sync_to_ipod_activate(None)
3421 return True
3423 return False
3425 def on_sync_to_ipod_activate(self, widget, episodes=None):
3426 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
3428 def commit_changes_to_database(self):
3429 """This will be called after the sync process is finished"""
3430 self.db.commit()
3432 def on_cleanup_ipod_activate(self, widget, *args):
3433 self.sync_ui.on_cleanup_device()
3435 def on_manage_device_playlist(self, widget):
3436 self.sync_ui.on_manage_device_playlist()
3438 def show_hide_tray_icon(self):
3439 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
3440 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
3441 elif not self.config.display_tray_icon and self.tray_icon is not None:
3442 self.tray_icon.set_visible(False)
3443 del self.tray_icon
3444 self.tray_icon = None
3446 if self.config.minimize_to_tray and self.tray_icon:
3447 self.tray_icon.set_visible(self.is_iconified())
3448 elif self.tray_icon:
3449 self.tray_icon.set_visible(True)
3451 def on_itemShowAllEpisodes_activate(self, widget):
3452 self.config.podcast_list_view_all = widget.get_active()
3454 def on_itemShowToolbar_activate(self, widget):
3455 self.config.show_toolbar = self.itemShowToolbar.get_active()
3457 def on_itemShowDescription_activate(self, widget):
3458 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3460 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3461 self.config.podcast_list_hide_boring = toggleaction.get_active()
3462 if self.config.podcast_list_hide_boring:
3463 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3464 else:
3465 self.podcast_list_model.set_view_mode(-1)
3467 def on_item_view_podcasts_changed(self, radioaction, current):
3468 # Only on Fremantle
3469 if current == self.item_view_podcasts_all:
3470 self.podcast_list_model.set_view_mode(-1)
3471 elif current == self.item_view_podcasts_downloaded:
3472 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3473 elif current == self.item_view_podcasts_unplayed:
3474 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3476 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3478 def on_item_view_episodes_changed(self, radioaction, current):
3479 if current == self.item_view_episodes_all:
3480 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
3481 elif current == self.item_view_episodes_undeleted:
3482 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
3483 elif current == self.item_view_episodes_downloaded:
3484 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3485 elif current == self.item_view_episodes_unplayed:
3486 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3488 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
3490 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3491 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3493 def update_item_device( self):
3494 if not gpodder.ui.fremantle:
3495 if self.config.device_type != 'none':
3496 self.itemDevice.set_visible(True)
3497 self.itemDevice.label = self.get_device_name()
3498 else:
3499 self.itemDevice.set_visible(False)
3501 def properties_closed( self):
3502 self.preferences_dialog = None
3503 self.show_hide_tray_icon()
3504 self.update_item_device()
3505 if gpodder.ui.maemo:
3506 selection = self.treeAvailable.get_selection()
3507 if self.config.maemo_enable_gestures or \
3508 self.config.enable_fingerscroll:
3509 selection.set_mode(gtk.SELECTION_SINGLE)
3510 else:
3511 selection.set_mode(gtk.SELECTION_MULTIPLE)
3513 def on_itemPreferences_activate(self, widget, *args):
3514 self.preferences_dialog = gPodderPreferences(self.main_window, \
3515 _config=self.config, \
3516 callback_finished=self.properties_closed, \
3517 user_apps_reader=self.user_apps_reader, \
3518 parent_window=self.main_window, \
3519 mygpo_client=self.mygpo_client, \
3520 on_send_full_subscriptions=self.on_send_full_subscriptions)
3522 # Initial message to relayout window (in case it's opened in portrait mode
3523 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3525 def on_itemDependencies_activate(self, widget):
3526 gPodderDependencyManager(self.gPodder)
3528 def on_goto_mygpo(self, widget):
3529 self.mygpo_client.open_website()
3531 def on_download_subscriptions_from_mygpo(self, action=None):
3532 title = _('Login to gpodder.net')
3533 message = _('Please login to download your subscriptions.')
3534 success, (username, password) = self.show_login_dialog(title, message, \
3535 self.config.mygpo_username, self.config.mygpo_password)
3536 if not success:
3537 return
3539 self.config.mygpo_username = username
3540 self.config.mygpo_password = password
3542 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3543 custom_title=_('Subscriptions on gpodder.net'), \
3544 add_urls_callback=self.add_podcast_list, \
3545 hide_url_entry=True)
3547 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
3548 # we do not have to hardcode the URL here
3549 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo_username
3550 url = util.url_add_authentication(OPML_URL, \
3551 self.config.mygpo_username, \
3552 self.config.mygpo_password)
3553 dir.download_opml_file(url)
3555 def on_mygpo_settings_activate(self, action=None):
3556 # This dialog is only used for Maemo 4
3557 if not gpodder.ui.diablo:
3558 return
3560 settings = MygPodderSettings(self.main_window, \
3561 config=self.config, \
3562 mygpo_client=self.mygpo_client, \
3563 on_send_full_subscriptions=self.on_send_full_subscriptions)
3565 def on_itemAddChannel_activate(self, widget=None):
3566 gPodderAddPodcast(self.gPodder, \
3567 add_urls_callback=self.add_podcast_list)
3569 def on_itemEditChannel_activate(self, widget, *args):
3570 if self.active_channel is None:
3571 title = _('No podcast selected')
3572 message = _('Please select a podcast in the podcasts list to edit.')
3573 self.show_message( message, title, widget=self.treeChannels)
3574 return
3576 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3577 gPodderChannel(self.main_window, \
3578 channel=self.active_channel, \
3579 callback_closed=callback_closed, \
3580 cover_downloader=self.cover_downloader)
3582 def on_itemMassUnsubscribe_activate(self, item=None):
3583 columns = (
3584 ('title', None, None, _('Podcast')),
3587 # We're abusing the Episode Selector for selecting Podcasts here,
3588 # but it works and looks good, so why not? -- thp
3589 gPodderEpisodeSelector(self.main_window, \
3590 title=_('Remove podcasts'), \
3591 instructions=_('Select the podcast you want to remove.'), \
3592 episodes=self.channels, \
3593 columns=columns, \
3594 size_attribute=None, \
3595 stock_ok_button=_('Remove'), \
3596 callback=self.remove_podcast_list, \
3597 _config=self.config)
3599 def remove_podcast_list(self, channels, confirm=True):
3600 if not channels:
3601 log('No podcasts selected for deletion', sender=self)
3602 return
3604 if len(channels) == 1:
3605 title = _('Removing podcast')
3606 info = _('Please wait while the podcast is removed')
3607 message = _('Do you really want to remove this podcast and its episodes?')
3608 else:
3609 title = _('Removing podcasts')
3610 info = _('Please wait while the podcasts are removed')
3611 message = _('Do you really want to remove the selected podcasts and their episodes?')
3613 if confirm and not self.show_confirmation(message, title):
3614 return
3616 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3618 def finish_deletion(select_url):
3619 # Upload subscription list changes to the web service
3620 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3622 # Re-load the channels and select the desired new channel
3623 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3624 progress.on_finished()
3625 self.update_podcasts_tab()
3627 def thread_proc():
3628 select_url = None
3630 for idx, channel in enumerate(channels):
3631 # Update the UI for correct status messages
3632 progress.on_progress(float(idx)/float(len(channels)))
3633 progress.on_message(channel.title)
3635 # Delete downloaded episodes
3636 channel.remove_downloaded()
3638 # cancel any active downloads from this channel
3639 for episode in channel.get_all_episodes():
3640 util.idle_add(self.download_status_model.cancel_by_url,
3641 episode.url)
3643 if len(channels) == 1:
3644 # get the URL of the podcast we want to select next
3645 if channel in self.channels:
3646 position = self.channels.index(channel)
3647 else:
3648 position = -1
3650 if position == len(self.channels)-1:
3651 # this is the last podcast, so select the URL
3652 # of the item before this one (i.e. the "new last")
3653 select_url = self.channels[position-1].url
3654 else:
3655 # there is a podcast after the deleted one, so
3656 # we simply select the one that comes after it
3657 select_url = self.channels[position+1].url
3659 # Remove the channel and clean the database entries
3660 channel.delete()
3661 self.channels.remove(channel)
3663 # Clean up downloads and download directories
3664 self.clean_up_downloads()
3666 self.channel_list_changed = True
3667 self.save_channels_opml()
3669 # The remaining stuff is to be done in the GTK main thread
3670 util.idle_add(finish_deletion, select_url)
3672 threading.Thread(target=thread_proc).start()
3674 def on_itemRemoveChannel_activate(self, widget, *args):
3675 if self.active_channel is None:
3676 title = _('No podcast selected')
3677 message = _('Please select a podcast in the podcasts list to remove.')
3678 self.show_message( message, title, widget=self.treeChannels)
3679 return
3681 self.remove_podcast_list([self.active_channel])
3683 def get_opml_filter(self):
3684 filter = gtk.FileFilter()
3685 filter.add_pattern('*.opml')
3686 filter.add_pattern('*.xml')
3687 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3688 return filter
3690 def on_item_import_from_file_activate(self, widget, filename=None):
3691 if filename is None:
3692 if gpodder.ui.desktop or gpodder.ui.fremantle:
3693 # FIXME: Hildonization on Fremantle
3694 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3695 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3696 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3697 elif gpodder.ui.diablo:
3698 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
3699 dlg.set_filter(self.get_opml_filter())
3700 response = dlg.run()
3701 filename = None
3702 if response == gtk.RESPONSE_OK:
3703 filename = dlg.get_filename()
3704 dlg.destroy()
3706 if filename is not None:
3707 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3708 custom_title=_('Import podcasts from OPML file'), \
3709 add_urls_callback=self.add_podcast_list, \
3710 hide_url_entry=True)
3711 dir.download_opml_file(filename)
3713 def on_itemExportChannels_activate(self, widget, *args):
3714 if not self.channels:
3715 title = _('Nothing to export')
3716 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3717 self.show_message(message, title, widget=self.treeChannels)
3718 return
3720 if gpodder.ui.desktop or gpodder.ui.fremantle:
3721 # FIXME: Hildonization on Fremantle
3722 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3723 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3724 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3725 elif gpodder.ui.diablo:
3726 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3727 dlg.set_filter(self.get_opml_filter())
3728 response = dlg.run()
3729 if response == gtk.RESPONSE_OK:
3730 filename = dlg.get_filename()
3731 dlg.destroy()
3732 exporter = opml.Exporter( filename)
3733 if exporter.write(self.channels):
3734 count = len(self.channels)
3735 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3736 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3737 else:
3738 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3739 else:
3740 dlg.destroy()
3742 def on_itemImportChannels_activate(self, widget, *args):
3743 if gpodder.ui.fremantle:
3744 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3745 self.config.toplist_url, \
3746 self.config.opml_url, \
3747 self.add_podcast_list, \
3748 self.on_itemAddChannel_activate, \
3749 self.on_download_subscriptions_from_mygpo, \
3750 self.show_text_edit_dialog)
3751 else:
3752 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3753 add_urls_callback=self.add_podcast_list)
3754 util.idle_add(dir.download_opml_file, self.config.opml_url)
3756 def on_homepage_activate(self, widget, *args):
3757 util.open_website(gpodder.__url__)
3759 def on_wiki_activate(self, widget, *args):
3760 util.open_website('http://gpodder.org/wiki/User_Manual')
3762 def on_bug_tracker_activate(self, widget, *args):
3763 if gpodder.ui.maemo:
3764 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3765 else:
3766 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder')
3768 def on_item_support_activate(self, widget):
3769 util.open_website('http://gpodder.org/donate')
3771 def on_itemAbout_activate(self, widget, *args):
3772 if gpodder.ui.fremantle:
3773 from gpodder.gtkui.frmntl.about import HeAboutDialog
3774 HeAboutDialog.present(self.main_window,
3775 'gPodder',
3776 'gpodder',
3777 gpodder.__version__,
3778 _('A podcast client with focus on usability'),
3779 gpodder.__copyright__,
3780 gpodder.__url__,
3781 'http://bugs.maemo.org/enter_bug.cgi?product=gPodder',
3782 'http://gpodder.org/donate')
3783 return
3785 dlg = gtk.AboutDialog()
3786 dlg.set_transient_for(self.main_window)
3787 dlg.set_name('gPodder')
3788 dlg.set_version(gpodder.__version__)
3789 dlg.set_copyright(gpodder.__copyright__)
3790 dlg.set_comments(_('A podcast client with focus on usability'))
3791 dlg.set_website(gpodder.__url__)
3792 dlg.set_translator_credits( _('translator-credits'))
3793 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3795 if gpodder.ui.desktop:
3796 # For the "GUI" version, we add some more
3797 # items to the about dialog (credits and logo)
3798 app_authors = [
3799 _('Maintainer:'),
3800 'Thomas Perl <thpinfo.com>',
3803 if os.path.exists(gpodder.credits_file):
3804 credits = open(gpodder.credits_file).read().strip().split('\n')
3805 app_authors += ['', _('Patches, bug reports and donations by:')]
3806 app_authors += credits
3808 dlg.set_authors(app_authors)
3809 try:
3810 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3811 except:
3812 dlg.set_logo_icon_name('gpodder')
3814 dlg.run()
3816 def on_wNotebook_switch_page(self, widget, *args):
3817 page_num = args[1]
3818 if gpodder.ui.maemo:
3819 self.tool_downloads.set_active(page_num == 1)
3820 page = self.wNotebook.get_nth_page(page_num)
3821 tab_label = self.wNotebook.get_tab_label(page).get_text()
3822 if page_num == 0 and self.active_channel is not None:
3823 self.set_title(self.active_channel.title)
3824 else:
3825 self.set_title(tab_label)
3826 if page_num == 0:
3827 self.play_or_download()
3828 self.menuChannels.set_sensitive(True)
3829 self.menuSubscriptions.set_sensitive(True)
3830 # The message area in the downloads tab should be hidden
3831 # when the user switches away from the downloads tab
3832 if self.message_area is not None:
3833 self.message_area.hide()
3834 self.message_area = None
3835 else:
3836 self.menuChannels.set_sensitive(False)
3837 self.menuSubscriptions.set_sensitive(False)
3838 if gpodder.ui.desktop:
3839 self.toolDownload.set_sensitive(False)
3840 self.toolPlay.set_sensitive(False)
3841 self.toolTransfer.set_sensitive(False)
3842 self.toolCancel.set_sensitive(False)
3844 def on_treeChannels_row_activated(self, widget, path, *args):
3845 # double-click action of the podcast list or enter
3846 self.treeChannels.set_cursor(path)
3848 def on_treeChannels_cursor_changed(self, widget, *args):
3849 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3851 if model is not None and iter is not None:
3852 old_active_channel = self.active_channel
3853 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3855 if self.active_channel == old_active_channel:
3856 return
3858 if gpodder.ui.maemo:
3859 self.set_title(self.active_channel.title)
3861 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3862 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3863 self.itemEditChannel.set_visible(False)
3864 self.itemRemoveChannel.set_visible(False)
3865 else:
3866 self.itemEditChannel.set_visible(True)
3867 self.itemRemoveChannel.set_visible(True)
3868 else:
3869 self.active_channel = None
3870 self.itemEditChannel.set_visible(False)
3871 self.itemRemoveChannel.set_visible(False)
3873 self.update_episode_list_model()
3875 def on_btnEditChannel_clicked(self, widget, *args):
3876 self.on_itemEditChannel_activate( widget, args)
3878 def get_podcast_urls_from_selected_episodes(self):
3879 """Get a set of podcast URLs based on the selected episodes"""
3880 return set(episode.channel.url for episode in \
3881 self.get_selected_episodes())
3883 def get_selected_episodes(self):
3884 """Get a list of selected episodes from treeAvailable"""
3885 selection = self.treeAvailable.get_selection()
3886 model, paths = selection.get_selected_rows()
3888 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3889 return episodes
3891 def on_transfer_selected_episodes(self, widget):
3892 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3894 def on_playback_selected_episodes(self, widget):
3895 self.playback_episodes(self.get_selected_episodes())
3897 def on_shownotes_selected_episodes(self, widget):
3898 episodes = self.get_selected_episodes()
3899 if episodes:
3900 episode = episodes.pop(0)
3901 self.show_episode_shownotes(episode)
3902 else:
3903 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3905 def on_download_selected_episodes(self, widget):
3906 episodes = self.get_selected_episodes()
3907 self.download_episode_list(episodes)
3908 self.update_episode_list_icons([episode.url for episode in episodes])
3909 self.play_or_download()
3911 def on_treeAvailable_row_activated(self, widget, path, view_column):
3912 """Double-click/enter action handler for treeAvailable"""
3913 # We should only have one one selected as it was double clicked!
3914 e = self.get_selected_episodes()[0]
3916 if (self.config.double_click_episode_action == 'download'):
3917 # If the episode has already been downloaded and exists then play it
3918 if e.was_downloaded(and_exists=True):
3919 self.playback_episodes(self.get_selected_episodes())
3920 # else download it if it is not already downloading
3921 elif not self.episode_is_downloading(e):
3922 self.download_episode_list([e])
3923 self.update_episode_list_icons([e.url])
3924 self.play_or_download()
3925 elif (self.config.double_click_episode_action == 'stream'):
3926 # If we happen to have downloaded this episode simple play it
3927 if e.was_downloaded(and_exists=True):
3928 self.playback_episodes(self.get_selected_episodes())
3929 # else if streaming is possible stream it
3930 elif self.streaming_possible():
3931 self.playback_episodes(self.get_selected_episodes())
3932 else:
3933 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3934 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3935 else:
3936 # default action is to display show notes
3937 self.on_shownotes_selected_episodes(widget)
3939 def show_episode_shownotes(self, episode):
3940 if self.episode_shownotes_window is None:
3941 log('First-time use of episode window --- creating', sender=self)
3942 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3943 _download_episode_list=self.download_episode_list, \
3944 _playback_episodes=self.playback_episodes, \
3945 _delete_episode_list=self.delete_episode_list, \
3946 _episode_list_status_changed=self.episode_list_status_changed, \
3947 _cancel_task_list=self.cancel_task_list, \
3948 _episode_is_downloading=self.episode_is_downloading, \
3949 _streaming_possible=self.streaming_possible())
3950 self.episode_shownotes_window.show(episode)
3951 if self.episode_is_downloading(episode):
3952 self.update_downloads_list()
3954 def restart_auto_update_timer(self):
3955 if self._auto_update_timer_source_id is not None:
3956 log('Removing existing auto update timer.', sender=self)
3957 gobject.source_remove(self._auto_update_timer_source_id)
3958 self._auto_update_timer_source_id = None
3960 if self.config.auto_update_feeds and \
3961 self.config.auto_update_frequency:
3962 interval = 60*1000*self.config.auto_update_frequency
3963 log('Setting up auto update timer with interval %d.', \
3964 self.config.auto_update_frequency, sender=self)
3965 self._auto_update_timer_source_id = gobject.timeout_add(\
3966 interval, self._on_auto_update_timer)
3968 def _on_auto_update_timer(self):
3969 log('Auto update timer fired.', sender=self)
3970 self.update_feed_cache(force_update=True)
3972 # Ask web service for sub changes (if enabled)
3973 self.mygpo_client.flush()
3975 return True
3977 def on_treeDownloads_row_activated(self, widget, *args):
3978 # Use the standard way of working on the treeview
3979 selection = self.treeDownloads.get_selection()
3980 (model, paths) = selection.get_selected_rows()
3981 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3983 for tree_row_reference, task in selected_tasks:
3984 if task.status in (task.DOWNLOADING, task.QUEUED):
3985 task.status = task.PAUSED
3986 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3987 self.download_queue_manager.add_task(task)
3988 self.enable_download_list_update()
3989 elif task.status == task.DONE:
3990 model.remove(model.get_iter(tree_row_reference.get_path()))
3992 self.play_or_download()
3994 # Update the tab title and downloads list
3995 self.update_downloads_list()
3997 def on_item_cancel_download_activate(self, widget):
3998 if self.wNotebook.get_current_page() == 0:
3999 selection = self.treeAvailable.get_selection()
4000 (model, paths) = selection.get_selected_rows()
4001 urls = [model.get_value(model.get_iter(path), \
4002 self.episode_list_model.C_URL) for path in paths]
4003 selected_tasks = [task for task in self.download_tasks_seen \
4004 if task.url in urls]
4005 else:
4006 selection = self.treeDownloads.get_selection()
4007 (model, paths) = selection.get_selected_rows()
4008 selected_tasks = [model.get_value(model.get_iter(path), \
4009 self.download_status_model.C_TASK) for path in paths]
4010 self.cancel_task_list(selected_tasks)
4012 def on_btnCancelAll_clicked(self, widget, *args):
4013 self.cancel_task_list(self.download_tasks_seen)
4015 def on_btnDownloadedDelete_clicked(self, widget, *args):
4016 episodes = self.get_selected_episodes()
4017 if len(episodes) == 1:
4018 self.delete_episode_list(episodes, skip_locked=False)
4019 else:
4020 self.delete_episode_list(episodes)
4022 def on_key_press(self, widget, event):
4023 # Allow tab switching with Ctrl + PgUp/PgDown
4024 if event.state & gtk.gdk.CONTROL_MASK:
4025 if event.keyval == gtk.keysyms.Page_Up:
4026 self.wNotebook.prev_page()
4027 return True
4028 elif event.keyval == gtk.keysyms.Page_Down:
4029 self.wNotebook.next_page()
4030 return True
4032 # After this code we only handle Maemo hardware keys,
4033 # so if we are not a Maemo app, we don't do anything
4034 if not gpodder.ui.maemo:
4035 return False
4037 diff = 0
4038 if event.keyval == gtk.keysyms.F7: #plus
4039 diff = 1
4040 elif event.keyval == gtk.keysyms.F8: #minus
4041 diff = -1
4043 if diff != 0 and not self.currently_updating:
4044 selection = self.treeChannels.get_selection()
4045 (model, iter) = selection.get_selected()
4046 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
4047 selection.select_path(new_path)
4048 self.treeChannels.set_cursor(new_path)
4049 return True
4051 return False
4053 def on_iconify(self):
4054 if self.tray_icon:
4055 self.gPodder.set_skip_taskbar_hint(True)
4056 if self.config.minimize_to_tray:
4057 self.tray_icon.set_visible(True)
4058 else:
4059 self.gPodder.set_skip_taskbar_hint(False)
4061 def on_uniconify(self):
4062 if self.tray_icon:
4063 self.gPodder.set_skip_taskbar_hint(False)
4064 if self.config.minimize_to_tray:
4065 self.tray_icon.set_visible(False)
4066 else:
4067 self.gPodder.set_skip_taskbar_hint(False)
4069 def uniconify_main_window(self):
4070 if self.is_iconified():
4071 # We need to hide and then show the window in WMs like Metacity
4072 # or KWin4 to move the window to the active workspace
4073 # (see http://gpodder.org/bug/1125)
4074 self.gPodder.hide()
4075 self.gPodder.show()
4076 self.gPodder.present()
4078 def iconify_main_window(self):
4079 if not self.is_iconified():
4080 self.gPodder.iconify()
4082 def update_podcasts_tab(self):
4083 if len(self.channels):
4084 if gpodder.ui.fremantle:
4085 self.button_refresh.set_title(_('Check for new episodes'))
4086 self.button_refresh.show()
4087 else:
4088 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
4089 else:
4090 if gpodder.ui.fremantle:
4091 self.button_refresh.hide()
4092 else:
4093 self.label2.set_text(_('Podcasts'))
4095 @dbus.service.method(gpodder.dbus_interface)
4096 def show_gui_window(self):
4097 parent = self.get_dialog_parent()
4098 parent.present()
4100 @dbus.service.method(gpodder.dbus_interface)
4101 def subscribe_to_url(self, url):
4102 gPodderAddPodcast(self.gPodder,
4103 add_urls_callback=self.add_podcast_list,
4104 preset_url=url)
4106 @dbus.service.method(gpodder.dbus_interface)
4107 def mark_episode_played(self, filename):
4108 if filename is None:
4109 return False
4111 for channel in self.channels:
4112 for episode in channel.get_all_episodes():
4113 fn = episode.local_filename(create=False, check_only=True)
4114 if fn == filename:
4115 episode.mark(is_played=True)
4116 self.db.commit()
4117 self.update_episode_list_icons([episode.url])
4118 self.update_podcast_list_model([episode.channel.url])
4119 return True
4121 return False
4124 def main(options=None):
4125 gobject.threads_init()
4126 gobject.set_application_name('gPodder')
4128 if gpodder.ui.maemo:
4129 # Try to enable the custom icon theme for gPodder on Maemo
4130 settings = gtk.settings_get_default()
4131 settings.set_string_property('gtk-icon-theme-name', \
4132 'gpodder', __file__)
4133 # Extend the search path for the optified icon theme (Maemo 5)
4134 icon_theme = gtk.icon_theme_get_default()
4135 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
4137 gtk.window_set_default_icon_name('gpodder')
4138 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
4140 try:
4141 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
4142 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
4144 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
4145 except dbus.exceptions.DBusException, dbe:
4146 log('Warning: Cannot get "on the bus".', traceback=True)
4147 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
4148 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
4149 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
4150 dlg.set_title('gPodder')
4151 dlg.run()
4152 dlg.destroy()
4153 sys.exit(0)
4155 util.make_directory(gpodder.home)
4156 gpodder.load_plugins()
4158 config = UIConfig(gpodder.config_file)
4160 # Load hook modules and install the hook manager globally
4161 # if modules have been found an instantiated by the manager
4162 user_hooks = hooks.HookManager()
4163 if user_hooks.has_modules():
4164 gpodder.user_hooks = user_hooks
4166 if gpodder.ui.diablo:
4167 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
4168 # folder exists there (allow moving "gpodder" between SD cards or USB)
4169 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
4170 if not os.path.exists(config.download_dir):
4171 log('Downloads might have been moved. Trying to locate them...')
4172 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
4173 dir = os.path.join(basedir, 'gpodder')
4174 if os.path.exists(dir):
4175 log('Downloads found in: %s', dir)
4176 config.download_dir = dir
4177 break
4178 else:
4179 log('Downloads NOT FOUND in %s', dir)
4180 elif gpodder.ui.fremantle:
4181 config.on_quit_ask = False
4183 if config.enable_fingerscroll:
4184 BuilderWidget.use_fingerscroll = True
4186 config.mygpo_device_type = util.detect_device_type()
4188 gp = gPodder(bus_name, config)
4190 # Handle options
4191 if options.subscribe:
4192 util.idle_add(gp.subscribe_to_url, options.subscribe)
4194 # mac OS X stuff :
4195 # handle "subscribe to podcast" events from firefox
4196 if platform.system() == 'Darwin':
4197 from gpodder import gpodderosx
4198 gpodderosx.register_handlers(gp)
4199 # end mac OS X stuff
4201 gp.run()