Add episode context menu hook, refactor playback code
[gpodder.git] / src / gpodder / gui.py
blobeb743a03d8562416b7b08c3fdd9c89f026b2cd65
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2011 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
35 import urllib
36 import cgi
39 import gpodder
41 try:
42 import dbus
43 import dbus.service
44 import dbus.mainloop
45 import dbus.glib
46 except ImportError:
47 # Mock the required D-Bus interfaces with no-ops (ugly? maybe.)
48 class dbus:
49 class SessionBus:
50 def __init__(self, *args, **kwargs):
51 pass
52 def add_signal_receiver(self, *args, **kwargs):
53 pass
54 class glib:
55 class DBusGMainLoop:
56 def __init__(self, *args, **kwargs):
57 pass
58 class service:
59 @staticmethod
60 def method(*args, **kwargs):
61 return lambda x: x
62 class BusName:
63 def __init__(self, *args, **kwargs):
64 pass
65 class Object:
66 def __init__(self, *args, **kwargs):
67 pass
69 from gpodder import core
70 from gpodder import feedcore
71 from gpodder import util
72 from gpodder import opml
73 from gpodder import download
74 from gpodder import my
75 from gpodder import youtube
76 from gpodder import player
77 from gpodder.liblogger import log
79 _ = gpodder.gettext
80 N_ = gpodder.ngettext
82 from gpodder.gtkui.model import Model
83 from gpodder.gtkui.model import PodcastListModel
84 from gpodder.gtkui.model import EpisodeListModel
85 from gpodder.gtkui.config import UIConfig
86 from gpodder.gtkui.services import CoverDownloader
87 from gpodder.gtkui.widgets import SimpleMessageArea
88 from gpodder.gtkui.desktopfile import UserAppsReader
90 from gpodder.gtkui.draw import draw_text_box_centered
92 from gpodder.gtkui.interface.common import BuilderWidget
93 from gpodder.gtkui.interface.common import TreeViewHelper
94 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
96 if gpodder.ui.desktop:
97 from gpodder.gtkui.download import DownloadStatusModel
99 from gpodder.gtkui.desktop.welcome import gPodderWelcome
100 from gpodder.gtkui.desktop.channel import gPodderChannel
101 from gpodder.gtkui.desktop.preferences import gPodderPreferences
102 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
103 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
104 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
105 from gpodder.gtkui.interface.progress import ProgressIndicator
106 elif gpodder.ui.fremantle:
107 from gpodder.gtkui.frmntl.model import DownloadStatusModel
108 from gpodder.gtkui.frmntl.model import EpisodeListModel
109 from gpodder.gtkui.frmntl.model import PodcastListModel
111 from gpodder.gtkui.frmntl.preferences import gPodderPreferences
112 from gpodder.gtkui.frmntl.shownotes import gPodderShownotes
113 from gpodder.gtkui.frmntl.episodeselector import gPodderEpisodeSelector
114 from gpodder.gtkui.frmntl.podcastdirectory import gPodderPodcastDirectory
115 from gpodder.gtkui.frmntl.episodes import gPodderEpisodes
116 from gpodder.gtkui.frmntl.downloads import gPodderDownloads
117 from gpodder.gtkui.frmntl.progress import ProgressIndicator
118 from gpodder.gtkui.frmntl.widgets import FancyProgressBar
120 from gpodder.gtkui.frmntl.portrait import FremantleRotation
121 from gpodder.gtkui.frmntl.mafw import MafwPlaybackMonitor
122 from gpodder.gtkui.frmntl.hints import HINT_STRINGS
123 from gpodder.gtkui.frmntl.network import NetworkManager
125 from gpodder.gtkui.interface.common import Orientation
127 if gpodder.ui.fremantle:
128 import hildon
130 from gpodder.dbusproxy import DBusPodcastsProxy
131 from gpodder import hooks
133 class gPodder(BuilderWidget, dbus.service.Object):
134 ICON_GENERAL_ADD = 'general_add'
135 ICON_GENERAL_REFRESH = 'general_refresh'
137 # Delay until live search is started after typing stop
138 LIVE_SEARCH_DELAY = 500
140 def __init__(self, bus_name, gpodder_core):
141 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
142 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels, \
143 self.on_itemUpdate_activate, \
144 self.playback_episodes, \
145 self.download_episode_list, \
146 self.episode_object_by_uri, \
147 bus_name)
148 self.core = gpodder_core
149 self.config = self.core.config
150 self.db = self.core.db
151 BuilderWidget.__init__(self, None)
153 def new(self):
154 if gpodder.ui.fremantle:
155 import hildon
156 self.app = hildon.Program()
157 self.app.add_window(self.main_window)
159 appmenu = hildon.AppMenu()
161 for filter in (self.item_view_podcasts_all, \
162 self.item_view_podcasts_downloaded, \
163 self.item_view_podcasts_unplayed):
164 button = gtk.ToggleButton()
165 filter.connect_proxy(button)
166 appmenu.add_filter(button)
168 for action in (self.itemPreferences, \
169 self.item_downloads, \
170 self.itemRemoveOldEpisodes, \
171 self.item_unsubscribe, \
172 self.itemAbout):
173 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
174 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
175 action.connect_proxy(button)
176 if action == self.item_downloads:
177 button.set_title(_('Downloads'))
178 button.set_value(_('Idle'))
179 self.button_downloads = button
180 appmenu.append(button)
182 def show_hint(button):
183 self.show_message(random.choice(HINT_STRINGS), important=True)
185 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
186 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
187 button.set_title(_('Hint of the day'))
188 button.connect('clicked', show_hint)
189 appmenu.append(button)
191 appmenu.show_all()
192 self.main_window.set_app_menu(appmenu)
194 # Initialize portrait mode / rotation manager
195 self._fremantle_rotation = FremantleRotation('gPodder', \
196 self.main_window, \
197 gpodder.__version__, \
198 self.config.rotation_mode)
200 # Initialize the Fremantle network manager
201 self.network_manager = NetworkManager()
203 if self.config.rotation_mode == FremantleRotation.ALWAYS:
204 util.idle_add(self.on_window_orientation_changed, \
205 Orientation.PORTRAIT)
206 self._last_orientation = Orientation.PORTRAIT
207 else:
208 self._last_orientation = Orientation.LANDSCAPE
210 # Flag set when a notification is being shown (Maemo bug 11235)
211 self._fremantle_notification_visible = False
212 else:
213 self._last_orientation = Orientation.LANDSCAPE
214 self.toolbar.set_property('visible', self.config.show_toolbar)
216 self.bluetooth_available = util.bluetooth_available()
218 if not gpodder.ui.fremantle:
219 self.config.connect_gtk_window(self.gPodder, '_main_window')
221 # Default/last paned position for sidebar toggling
222 self._last_paned_position = 200
223 self._last_paned_position_toggling = False
224 self.item_sidebar.set_active(self.config._paned_position > 0)
226 self.config.connect_gtk_paned('_paned_position', self.channelPaned)
228 self.main_window.show()
230 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
232 if gpodder.ui.fremantle:
233 # Create a D-Bus monitoring object that takes care of
234 # tracking MAFW (Nokia Media Player) playback events
235 # and sends episode playback status events via D-Bus
236 self.mafw_monitor = MafwPlaybackMonitor(gpodder.dbus_session_bus)
238 self.gPodder.connect('key-press-event', self.on_key_press)
240 self.preferences_dialog = None
241 self.episode_columns_menu = None
242 self.config.add_observer(self.on_config_changed)
244 self.episode_shownotes_window = None
245 self.new_episodes_window = None
247 if gpodder.ui.desktop:
248 # Mac OS X-specific UI tweaks: Native main menu integration
249 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
250 if getattr(gtk.gdk, 'WINDOWING', 'x11') == 'quartz':
251 try:
252 import igemacintegration as igemi
254 # Move the menu bar from the window to the Mac menu bar
255 self.mainMenu.hide()
256 igemi.ige_mac_menu_set_menu_bar(self.mainMenu)
258 # Reparent some items to the "Application" menu
259 for widget in ('/mainMenu/menuHelp/itemAbout', \
260 '/mainMenu/menuPodcasts/itemPreferences'):
261 item = self.uimanager1.get_widget(widget)
262 group = igemi.ige_mac_menu_add_app_menu_group()
263 igemi.ige_mac_menu_add_app_menu_item(group, item, None)
265 quit_widget = '/mainMenu/menuPodcasts/itemQuit'
266 quit_item = self.uimanager1.get_widget(quit_widget)
267 igemi.ige_mac_menu_set_quit_menu_item(quit_item)
268 except ImportError:
269 print >>sys.stderr, """
270 Warning: ige-mac-integration not found - no native menus.
273 self.download_status_model = DownloadStatusModel()
274 self.download_queue_manager = download.DownloadQueueManager(self.config)
276 if gpodder.ui.desktop:
277 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
278 self.itemShowToolbar.set_active(self.config.show_toolbar)
279 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
281 if not gpodder.ui.fremantle:
282 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
283 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
284 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
285 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
287 # When the amount of maximum downloads changes, notify the queue manager
288 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
289 self.spinMaxDownloads.connect('value-changed', changed_cb)
291 self.default_title = 'gPodder'
292 if gpodder.__version__.rfind('git') != -1:
293 self.set_title('gPodder %s' % gpodder.__version__)
294 else:
295 title = self.gPodder.get_title()
296 if title is not None:
297 self.set_title(title)
298 else:
299 self.set_title(_('gPodder'))
301 self.cover_downloader = CoverDownloader()
303 # Generate list models for podcasts and their episodes
304 self.podcast_list_model = PodcastListModel(self.cover_downloader)
306 self.cover_downloader.register('cover-available', self.cover_download_finished)
307 self.cover_downloader.register('cover-removed', self.cover_file_removed)
309 if gpodder.ui.fremantle:
310 # Work around Maemo bug #4718
311 self.button_refresh.set_name('HildonButton-finger')
312 self.button_subscribe.set_name('HildonButton-finger')
314 self.button_refresh.set_sensitive(False)
315 self.button_subscribe.set_sensitive(False)
317 self.button_subscribe.set_image(gtk.image_new_from_icon_name(\
318 self.ICON_GENERAL_ADD, gtk.ICON_SIZE_BUTTON))
319 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
320 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
322 # Make the button scroll together with the TreeView contents
323 action_area_box = self.treeChannels.get_action_area_box()
324 for child in self.buttonbox:
325 child.reparent(action_area_box)
326 self.vbox.remove(self.buttonbox)
327 self.treeChannels.set_action_area_visible(True)
329 # Set up a very nice progress bar setup
330 self.fancy_progress_bar = FancyProgressBar(self.main_window, \
331 self.on_btnCancelFeedUpdate_clicked)
332 self.pbFeedUpdate = self.fancy_progress_bar.progress_bar
333 self.pbFeedUpdate.set_ellipsize(pango.ELLIPSIZE_MIDDLE)
334 self.vbox.pack_start(self.fancy_progress_bar.event_box, False)
336 from gpodder.gtkui.frmntl import style
337 sub_font = style.get_font_desc('SmallSystemFont')
338 sub_color = style.get_color('SecondaryTextColor')
339 sub = (sub_font.to_string(), sub_color.to_string())
340 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
341 self.label_footer.set_markup(sub % gpodder.__copyright__)
343 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
344 while gtk.events_pending():
345 gtk.main_iteration(False)
347 self.episodes_window = gPodderEpisodes(self.main_window, \
348 on_treeview_expose_event=self.on_treeview_expose_event, \
349 show_episode_shownotes=self.show_episode_shownotes, \
350 update_podcast_list_model=self.update_podcast_list_model, \
351 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
352 item_view_episodes_all=self.item_view_episodes_all, \
353 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
354 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
355 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
356 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
357 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
358 hide_episode_search=self.hide_episode_search, \
359 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
360 playback_episodes=self.playback_episodes, \
361 delete_episode_list=self.delete_episode_list, \
362 episode_list_status_changed=self.episode_list_status_changed, \
363 download_episode_list=self.download_episode_list, \
364 episode_is_downloading=self.episode_is_downloading, \
365 show_episode_in_download_manager=self.show_episode_in_download_manager, \
366 add_download_task_monitor=self.add_download_task_monitor, \
367 remove_download_task_monitor=self.remove_download_task_monitor, \
368 for_each_episode_set_task_status=self.for_each_episode_set_task_status, \
369 on_itemUpdate_activate=self.on_itemUpdate_activate, \
370 show_delete_episodes_window=self.show_delete_episodes_window, \
371 cover_downloader=self.cover_downloader)
373 # Expose objects for episode list type-ahead find
374 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
375 self.entry_search_episodes = self.episodes_window.entry_search_episodes
376 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
378 self.downloads_window = gPodderDownloads(self.main_window, \
379 on_treeview_expose_event=self.on_treeview_expose_event, \
380 cleanup_downloads=self.cleanup_downloads, \
381 _for_each_task_set_status=self._for_each_task_set_status, \
382 downloads_list_get_selection=self.downloads_list_get_selection, \
383 _config=self.config)
385 self.treeAvailable = self.episodes_window.treeview
386 self.treeDownloads = self.downloads_window.treeview
388 # Source IDs for timeouts for search-as-you-type
389 self._podcast_list_search_timeout = None
390 self._episode_list_search_timeout = None
392 # Init the treeviews that we use
393 self.init_podcast_list_treeview()
394 self.init_episode_list_treeview()
395 self.init_download_list_treeview()
397 if self.config.podcast_list_hide_boring:
398 self.item_view_hide_boring_podcasts.set_active(True)
400 self.currently_updating = False
402 self.context_menu_mouse_button = 3
404 self.download_tasks_seen = set()
405 self.download_list_update_enabled = False
406 self.download_task_monitors = set()
408 # Subscribed channels
409 self.active_channel = None
410 self.channels = Model.get_podcasts(self.db)
412 # Check if the user has downloaded any podcast with an external program
413 # and mark episodes as downloaded / move them away (bug 902)
414 for podcast in self.channels:
415 podcast.import_external_files()
417 self.channel_list_changed = True
419 # load list of user applications for audio playback
420 self.user_apps_reader = UserAppsReader(['audio', 'video'])
421 threading.Thread(target=self.user_apps_reader.read).start()
423 # Set up the first instance of MygPoClient
424 self.mygpo_client = my.MygPoClient(self.config)
426 # Now, update the feed cache, when everything's in place
427 if not gpodder.ui.fremantle:
428 self.btnUpdateFeeds.show()
429 self.updating_feed_cache = False
430 self.feed_cache_update_cancelled = False
431 self.update_feed_cache(force_update=False)
433 self.message_area = None
435 def find_partial_downloads():
436 # Look for partial file downloads
437 partial_files = glob.glob(os.path.join(gpodder.downloads, '*', '*.partial'))
438 count = len(partial_files)
439 resumable_episodes = []
440 if count:
441 if not gpodder.ui.fremantle:
442 util.idle_add(self.wNotebook.set_current_page, 1)
443 indicator = ProgressIndicator(_('Loading incomplete downloads'), \
444 _('Some episodes have not finished downloading in a previous session.'), \
445 False, self.get_dialog_parent())
446 indicator.on_message(N_('%(count)d partial file', '%(count)d partial files', count) % {'count':count})
448 candidates = [f[:-len('.partial')] for f in partial_files]
449 found = 0
451 for c in self.channels:
452 for e in c.get_all_episodes():
453 filename = e.local_filename(create=False, check_only=True)
454 if filename in candidates:
455 log('Found episode: %s', e.title, sender=self)
456 found += 1
457 indicator.on_message(e.title)
458 indicator.on_progress(float(found)/count)
459 candidates.remove(filename)
460 partial_files.remove(filename+'.partial')
462 if os.path.exists(filename):
463 # The file has already been downloaded;
464 # remove the leftover partial file
465 util.delete_file(filename+'.partial')
466 else:
467 resumable_episodes.append(e)
469 if not candidates:
470 break
472 if not candidates:
473 break
475 for f in partial_files:
476 log('Partial file without episode: %s', f, sender=self)
477 util.delete_file(f)
479 util.idle_add(indicator.on_finished)
481 if len(resumable_episodes):
482 def offer_resuming():
483 self.download_episode_list_paused(resumable_episodes)
484 if not gpodder.ui.fremantle:
485 resume_all = gtk.Button(_('Resume all'))
486 #resume_all.set_border_width(0)
487 def on_resume_all(button):
488 selection = self.treeDownloads.get_selection()
489 selection.select_all()
490 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
491 selection.unselect_all()
492 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
493 self.message_area.hide()
494 resume_all.connect('clicked', on_resume_all)
496 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
497 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
498 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
499 self.message_area.show_all()
500 self.clean_up_downloads(delete_partial=False)
501 util.idle_add(offer_resuming)
502 elif not gpodder.ui.fremantle:
503 util.idle_add(self.wNotebook.set_current_page, 0)
504 else:
505 util.idle_add(self.clean_up_downloads, True)
506 threading.Thread(target=find_partial_downloads).start()
508 # Start the auto-update procedure
509 self._auto_update_timer_source_id = None
510 if self.config.auto_update_feeds:
511 self.restart_auto_update_timer()
513 # Delete old episodes if the user wishes to
514 if self.config.auto_remove_played_episodes and \
515 self.config.episode_old_age > 0:
516 old_episodes = list(self.get_expired_episodes())
517 if len(old_episodes) > 0:
518 self.delete_episode_list(old_episodes, confirm=False)
519 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
521 if gpodder.ui.fremantle:
522 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
523 self.button_refresh.set_sensitive(True)
524 self.button_subscribe.set_sensitive(True)
525 self.main_window.set_title(_('gPodder'))
526 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
528 # Do the initial sync with the web service
529 util.idle_add(self.mygpo_client.flush, True)
531 # First-time users should be asked if they want to see the OPML
532 if not self.channels and not gpodder.ui.fremantle:
533 util.idle_add(self.on_itemUpdate_activate)
535 def on_view_sidebar_toggled(self, menu_item):
536 self.channelPaned.child_set_property(self.vboxChannelNavigator, \
537 'shrink', not menu_item.get_active())
539 if self._last_paned_position_toggling:
540 return
542 active = menu_item.get_active()
543 if active:
544 if self._last_paned_position == 0:
545 self._last_paned_position = 200
546 self.channelPaned.set_position(self._last_paned_position)
547 else:
548 current_position = self.channelPaned.get_position()
549 if current_position > 0:
550 self._last_paned_position = current_position
551 self.channelPaned.set_position(0)
553 def episode_object_by_uri(self, uri):
554 """Get an episode object given a local or remote URI
556 This can be used to quickly access an episode object
557 when all we have is its download filename or episode
558 URL (e.g. from external D-Bus calls / signals, etc..)
560 if uri.startswith('/'):
561 uri = 'file://' + urllib.quote(uri)
563 prefix = 'file://' + urllib.quote(gpodder.downloads)
565 if uri.startswith(prefix):
566 # File is on the local filesystem in the download folder
567 filename = urllib.unquote(uri[len(prefix):])
568 file_parts = [x for x in filename.split(os.sep) if x]
570 if len(file_parts) == 2:
571 dir_name, filename = file_parts
572 channels = [c for c in self.channels if c.download_folder == dir_name]
573 if len(channels) == 1:
574 channel = channels[0]
575 return channel.get_episode_by_filename(filename)
576 else:
577 # Possibly remote file - search the database for a podcast
578 channel_id = self.db.get_podcast_id_from_episode_url(uri)
580 if channel_id is not None:
581 channels = [c for c in self.channels if c.id == channel_id]
582 if len(channels) == 1:
583 channel = channels[0]
584 return channel.get_episode_by_url(uri)
586 return None
588 def on_played(self, start, end, total, file_uri):
589 """Handle the "played" signal from a media player"""
590 if start == 0 and end == 0 and total == 0:
591 # Ignore bogus play event
592 return
593 elif end < start + 5:
594 # Ignore "less than five seconds" segments,
595 # as they can happen with seeking, etc...
596 return
598 log('Received play action: %s (%d, %d, %d)', file_uri, start, end, total, sender=self)
599 episode = self.episode_object_by_uri(file_uri)
601 if episode is not None:
602 file_type = episode.file_type()
604 now = time.time()
605 if total > 0:
606 episode.total_time = total
607 elif total == 0:
608 # Assume the episode's total time for the action
609 total = episode.total_time
610 if episode.current_position_updated is None or \
611 now > episode.current_position_updated:
612 episode.current_position = end
613 episode.current_position_updated = now
614 episode.mark(is_played=True)
615 episode.save()
616 self.db.commit()
617 self.update_episode_list_icons([episode.url])
618 self.update_podcast_list_model([episode.channel.url])
620 # Submit this action to the webservice
621 self.mygpo_client.on_playback_full(episode, \
622 start, end, total)
624 def on_add_remove_podcasts_mygpo(self):
625 actions = self.mygpo_client.get_received_actions()
626 if not actions:
627 return False
629 existing_urls = [c.url for c in self.channels]
631 # Columns for the episode selector window - just one...
632 columns = (
633 ('description', None, None, _('Action')),
636 # A list of actions that have to be chosen from
637 changes = []
639 # Actions that are ignored (already carried out)
640 ignored = []
642 for action in actions:
643 if action.is_add and action.url not in existing_urls:
644 changes.append(my.Change(action))
645 elif action.is_remove and action.url in existing_urls:
646 podcast_object = None
647 for podcast in self.channels:
648 if podcast.url == action.url:
649 podcast_object = podcast
650 break
651 changes.append(my.Change(action, podcast_object))
652 else:
653 log('Ignoring action: %s', action, sender=self)
654 ignored.append(action)
656 # Confirm all ignored changes
657 self.mygpo_client.confirm_received_actions(ignored)
659 def execute_podcast_actions(selected):
660 add_list = [c.action.url for c in selected if c.action.is_add]
661 remove_list = [c.podcast for c in selected if c.action.is_remove]
663 # Apply the accepted changes locally
664 self.add_podcast_list(add_list)
665 self.remove_podcast_list(remove_list, confirm=False)
667 # All selected items are now confirmed
668 self.mygpo_client.confirm_received_actions(c.action for c in selected)
670 # Revert the changes on the server
671 rejected = [c.action for c in changes if c not in selected]
672 self.mygpo_client.reject_received_actions(rejected)
674 def ask():
675 # We're abusing the Episode Selector again ;) -- thp
676 gPodderEpisodeSelector(self.main_window, \
677 title=_('Confirm changes from gpodder.net'), \
678 instructions=_('Select the actions you want to carry out.'), \
679 episodes=changes, \
680 columns=columns, \
681 size_attribute=None, \
682 stock_ok_button=gtk.STOCK_APPLY, \
683 callback=execute_podcast_actions, \
684 _config=self.config)
686 # There are some actions that need the user's attention
687 if changes:
688 util.idle_add(ask)
689 return True
691 # We have no remaining actions - no selection happens
692 return False
694 def rewrite_urls_mygpo(self):
695 # Check if we have to rewrite URLs since the last add
696 rewritten_urls = self.mygpo_client.get_rewritten_urls()
698 for rewritten_url in rewritten_urls:
699 if not rewritten_url.new_url:
700 continue
702 for channel in self.channels:
703 if channel.url == rewritten_url.old_url:
704 log('Updating URL of %s to %s', channel, \
705 rewritten_url.new_url, sender=self)
706 channel.url = rewritten_url.new_url
707 channel.save()
708 self.channel_list_changed = True
709 util.idle_add(self.update_episode_list_model)
710 break
712 def on_send_full_subscriptions(self):
713 # Send the full subscription list to the gpodder.net client
714 # (this will overwrite the subscription list on the server)
715 indicator = ProgressIndicator(_('Uploading subscriptions'), \
716 _('Your subscriptions are being uploaded to the server.'), \
717 False, self.get_dialog_parent())
719 try:
720 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
721 util.idle_add(self.show_message, _('List uploaded successfully.'))
722 except Exception, e:
723 def show_error(e):
724 message = str(e)
725 if not message:
726 message = e.__class__.__name__
727 self.show_message(message, \
728 _('Error while uploading'), \
729 important=True)
730 util.idle_add(show_error, e)
732 util.idle_add(indicator.on_finished)
734 def on_podcast_selected(self, treeview, path, column):
735 # for Maemo 5's UI
736 model = treeview.get_model()
737 channel = model.get_value(model.get_iter(path), \
738 PodcastListModel.C_CHANNEL)
739 self.active_channel = channel
740 self.update_episode_list_model()
741 self.episodes_window.channel = self.active_channel
742 self.episodes_window.show()
744 def on_button_subscribe_clicked(self, button):
745 self.on_itemImportChannels_activate(button)
747 def on_button_downloads_clicked(self, widget):
748 self.downloads_window.show()
750 def show_episode_in_download_manager(self, episode):
751 self.downloads_window.show()
752 model = self.treeDownloads.get_model()
753 selection = self.treeDownloads.get_selection()
754 selection.unselect_all()
755 it = model.get_iter_first()
756 while it is not None:
757 task = model.get_value(it, DownloadStatusModel.C_TASK)
758 if task.episode.url == episode.url:
759 selection.select_iter(it)
760 # FIXME: Scroll to selection in pannable area
761 break
762 it = model.iter_next(it)
764 def for_each_episode_set_task_status(self, episodes, status):
765 episode_urls = set(episode.url for episode in episodes)
766 model = self.treeDownloads.get_model()
767 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
768 model.get_value(row.iter, \
769 DownloadStatusModel.C_TASK)) for row in model \
770 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
771 in episode_urls]
772 self._for_each_task_set_status(selected_tasks, status)
774 def on_window_orientation_changed(self, orientation):
775 self._last_orientation = orientation
776 if self.preferences_dialog is not None:
777 self.preferences_dialog.on_window_orientation_changed(orientation)
779 treeview = self.treeChannels
780 if orientation == Orientation.PORTRAIT:
781 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
782 # Work around Maemo bug #4718
783 self.button_subscribe.set_name('HildonButton-thumb')
784 self.button_refresh.set_name('HildonButton-thumb')
785 else:
786 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
787 # Work around Maemo bug #4718
788 self.button_subscribe.set_name('HildonButton-finger')
789 self.button_refresh.set_name('HildonButton-finger')
791 if gpodder.ui.fremantle:
792 self.fancy_progress_bar.relayout()
794 def on_treeview_podcasts_selection_changed(self, selection):
795 model, iter = selection.get_selected()
796 if iter is None:
797 self.active_channel = None
798 self.episode_list_model.clear()
800 def on_treeview_button_pressed(self, treeview, event):
801 if event.window != treeview.get_bin_window():
802 return False
804 TreeViewHelper.save_button_press_event(treeview, event)
806 if getattr(treeview, TreeViewHelper.ROLE) == \
807 TreeViewHelper.ROLE_PODCASTS:
808 return self.currently_updating
810 return event.button == self.context_menu_mouse_button and \
811 gpodder.ui.desktop
813 def on_treeview_podcasts_button_released(self, treeview, event):
814 if event.window != treeview.get_bin_window():
815 return False
817 return self.treeview_channels_show_context_menu(treeview, event)
819 def on_treeview_episodes_button_released(self, treeview, event):
820 if event.window != treeview.get_bin_window():
821 return False
823 return self.treeview_available_show_context_menu(treeview, event)
825 def on_treeview_downloads_button_released(self, treeview, event):
826 if event.window != treeview.get_bin_window():
827 return False
829 return self.treeview_downloads_show_context_menu(treeview, event)
831 def on_entry_search_podcasts_changed(self, editable):
832 if self.hbox_search_podcasts.get_property('visible'):
833 def set_search_term(self, text):
834 self.podcast_list_model.set_search_term(text)
835 self._podcast_list_search_timeout = None
836 return False
838 if self._podcast_list_search_timeout is not None:
839 gobject.source_remove(self._podcast_list_search_timeout)
840 self._podcast_list_search_timeout = gobject.timeout_add(\
841 self.LIVE_SEARCH_DELAY, \
842 set_search_term, self, editable.get_chars(0, -1))
844 def on_entry_search_podcasts_key_press(self, editable, event):
845 if event.keyval == gtk.keysyms.Escape:
846 self.hide_podcast_search()
847 return True
849 def hide_podcast_search(self, *args):
850 if self._podcast_list_search_timeout is not None:
851 gobject.source_remove(self._podcast_list_search_timeout)
852 self._podcast_list_search_timeout = None
853 self.hbox_search_podcasts.hide()
854 self.entry_search_podcasts.set_text('')
855 self.podcast_list_model.set_search_term(None)
856 self.treeChannels.grab_focus()
858 def show_podcast_search(self, input_char):
859 self.hbox_search_podcasts.show()
860 self.entry_search_podcasts.insert_text(input_char, -1)
861 self.entry_search_podcasts.grab_focus()
862 self.entry_search_podcasts.set_position(-1)
864 def init_podcast_list_treeview(self):
865 # Set up podcast channel tree view widget
866 if gpodder.ui.fremantle:
867 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
868 self.item_view_podcasts_downloaded.set_active(True)
869 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
870 self.item_view_podcasts_unplayed.set_active(True)
871 else:
872 self.item_view_podcasts_all.set_active(True)
873 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
875 iconcolumn = gtk.TreeViewColumn('')
876 iconcell = gtk.CellRendererPixbuf()
877 iconcolumn.pack_start(iconcell, False)
878 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
879 self.treeChannels.append_column(iconcolumn)
881 namecolumn = gtk.TreeViewColumn('')
882 namecell = gtk.CellRendererText()
883 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
884 namecolumn.pack_start(namecell, True)
885 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
887 if gpodder.ui.fremantle:
888 countcell = gtk.CellRendererText()
889 from gpodder.gtkui.frmntl import style
890 countcell.set_property('font-desc', style.get_font_desc('EmpSystemFont'))
891 countcell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
892 countcell.set_property('alignment', pango.ALIGN_RIGHT)
893 countcell.set_property('xalign', 1.)
894 countcell.set_property('xpad', 5)
895 namecolumn.pack_start(countcell, False)
896 namecolumn.add_attribute(countcell, 'text', PodcastListModel.C_DOWNLOADS)
897 namecolumn.add_attribute(countcell, 'visible', PodcastListModel.C_DOWNLOADS)
898 else:
899 iconcell = gtk.CellRendererPixbuf()
900 iconcell.set_property('xalign', 1.0)
901 namecolumn.pack_start(iconcell, False)
902 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
903 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
905 self.treeChannels.append_column(namecolumn)
907 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
909 # When no podcast is selected, clear the episode list model
910 selection = self.treeChannels.get_selection()
911 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
913 # Set up type-ahead find for the podcast list
914 def on_key_press(treeview, event):
915 if gpodder.ui.desktop and event.keyval == gtk.keysyms.Right:
916 self.treeAvailable.grab_focus()
917 elif event.keyval == gtk.keysyms.Escape:
918 self.hide_podcast_search()
919 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
920 self.hide_podcast_search()
921 elif event.state & gtk.gdk.CONTROL_MASK:
922 # Don't handle type-ahead when control is pressed (so shortcuts
923 # with the Ctrl key still work, e.g. Ctrl+A, ...)
924 return True
925 else:
926 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
927 if unicode_char_id == 0:
928 return False
929 input_char = unichr(unicode_char_id)
930 self.show_podcast_search(input_char)
931 return True
932 self.treeChannels.connect('key-press-event', on_key_press)
934 self.treeChannels.connect('popup-menu', self.treeview_channels_show_context_menu)
936 # Enable separators to the podcast list to separate special podcasts
937 # from others (this is used for the "all episodes" view)
938 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
940 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
942 def on_entry_search_episodes_changed(self, editable):
943 if self.hbox_search_episodes.get_property('visible'):
944 def set_search_term(self, text):
945 self.episode_list_model.set_search_term(text)
946 self._episode_list_search_timeout = None
947 return False
949 if self._episode_list_search_timeout is not None:
950 gobject.source_remove(self._episode_list_search_timeout)
951 self._episode_list_search_timeout = gobject.timeout_add(\
952 self.LIVE_SEARCH_DELAY, \
953 set_search_term, self, editable.get_chars(0, -1))
955 def on_entry_search_episodes_key_press(self, editable, event):
956 if event.keyval == gtk.keysyms.Escape:
957 self.hide_episode_search()
958 return True
960 def hide_episode_search(self, *args):
961 if self._episode_list_search_timeout is not None:
962 gobject.source_remove(self._episode_list_search_timeout)
963 self._episode_list_search_timeout = None
964 self.hbox_search_episodes.hide()
965 self.entry_search_episodes.set_text('')
966 self.episode_list_model.set_search_term(None)
967 self.treeAvailable.grab_focus()
969 def show_episode_search(self, input_char):
970 self.hbox_search_episodes.show()
971 self.entry_search_episodes.insert_text(input_char, -1)
972 self.entry_search_episodes.grab_focus()
973 self.entry_search_episodes.set_position(-1)
975 def set_episode_list_column(self, index, new_value):
976 mask = (1 << index)
977 if new_value:
978 self.config.episode_list_columns |= mask
979 else:
980 self.config.episode_list_columns &= ~mask
982 def update_episode_list_columns_visibility(self):
983 if gpodder.ui.fremantle:
984 return
986 columns = TreeViewHelper.get_columns(self.treeAvailable)
987 for index, column in enumerate(columns):
988 visible = bool(self.config.episode_list_columns & (1 << index))
989 column.set_visible(visible)
990 self.treeAvailable.columns_autosize()
992 if self.episode_columns_menu is not None:
993 children = self.episode_columns_menu.get_children()
994 for index, child in enumerate(children):
995 active = bool(self.config.episode_list_columns & (1 << index))
996 child.set_active(active)
998 def on_episode_list_header_clicked(self, button, event):
999 if event.button != 3:
1000 return False
1002 if self.episode_columns_menu is not None:
1003 self.episode_columns_menu.popup(None, None, None, event.button, \
1004 event.time, None)
1006 return False
1008 def init_episode_list_treeview(self):
1009 # For loading the list model
1010 self.episode_list_model = EpisodeListModel(self.on_episode_list_filter_changed)
1012 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
1013 self.item_view_episodes_undeleted.set_active(True)
1014 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
1015 self.item_view_episodes_downloaded.set_active(True)
1016 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
1017 self.item_view_episodes_unplayed.set_active(True)
1018 else:
1019 self.item_view_episodes_all.set_active(True)
1021 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
1023 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
1025 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
1027 iconcell = gtk.CellRendererPixbuf()
1028 iconcell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
1029 if gpodder.ui.fremantle:
1030 iconcell.set_fixed_size(50, 50)
1031 else:
1032 iconcell.set_fixed_size(40, -1)
1034 namecell = gtk.CellRendererText()
1035 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
1036 namecolumn = gtk.TreeViewColumn(_('Episode'))
1037 namecolumn.pack_start(iconcell, False)
1038 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
1039 namecolumn.pack_start(namecell, True)
1040 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
1041 if gpodder.ui.fremantle:
1042 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
1043 else:
1044 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
1045 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1046 namecolumn.set_resizable(True)
1047 namecolumn.set_expand(True)
1049 if gpodder.ui.fremantle:
1050 from gpodder.gtkui.frmntl import style
1051 timecell = gtk.CellRendererText()
1052 timecell.set_property('font-desc', style.get_font_desc('SmallSystemFont'))
1053 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1054 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1055 timecell.set_property('xalign', 1.)
1056 timecell.set_property('xpad', 5)
1057 timecell.set_property('yalign', .85)
1058 namecolumn.pack_start(timecell, False)
1059 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1060 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME_VISIBLE)
1061 else:
1062 lockcell = gtk.CellRendererPixbuf()
1063 lockcell.set_fixed_size(40, -1)
1064 lockcell.set_property('stock-size', gtk.ICON_SIZE_MENU)
1065 lockcell.set_property('icon-name', 'emblem-readonly')
1066 namecolumn.pack_start(lockcell, False)
1067 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
1069 sizecell = gtk.CellRendererText()
1070 sizecell.set_property('xalign', 1)
1071 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1072 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1074 timecell = gtk.CellRendererText()
1075 timecell.set_property('xalign', 1)
1076 timecolumn = gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
1077 timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
1079 releasecell = gtk.CellRendererText()
1080 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
1081 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1083 namecolumn.set_reorderable(True)
1084 self.treeAvailable.append_column(namecolumn)
1086 if gpodder.ui.desktop:
1087 for itemcolumn in (sizecolumn, timecolumn, releasecolumn):
1088 itemcolumn.set_reorderable(True)
1089 self.treeAvailable.append_column(itemcolumn)
1090 TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
1092 # Add context menu to all tree view column headers
1093 for column in self.treeAvailable.get_columns():
1094 label = gtk.Label(column.get_title())
1095 label.show_all()
1096 column.set_widget(label)
1098 w = column.get_widget()
1099 while w is not None and not isinstance(w, gtk.Button):
1100 w = w.get_parent()
1102 w.connect('button-release-event', self.on_episode_list_header_clicked)
1104 # Create a new menu for the visible episode list columns
1105 for child in self.mainMenu.get_children():
1106 if child.get_name() == 'menuView':
1107 submenu = child.get_submenu()
1108 item = gtk.MenuItem(_('Visible columns'))
1109 submenu.append(gtk.SeparatorMenuItem())
1110 submenu.append(item)
1111 submenu.show_all()
1113 self.episode_columns_menu = gtk.Menu()
1114 item.set_submenu(self.episode_columns_menu)
1115 break
1117 # For each column that can be shown/hidden, add a menu item
1118 columns = TreeViewHelper.get_columns(self.treeAvailable)
1119 for index, column in enumerate(columns):
1120 item = gtk.CheckMenuItem(column.get_title())
1121 self.episode_columns_menu.append(item)
1122 def on_item_toggled(item, index):
1123 self.set_episode_list_column(index, item.get_active())
1124 item.connect('toggled', on_item_toggled, index)
1125 self.episode_columns_menu.show_all()
1127 # Update the visibility of the columns and the check menu items
1128 self.update_episode_list_columns_visibility()
1130 # Set up type-ahead find for the episode list
1131 def on_key_press(treeview, event):
1132 if gpodder.ui.desktop and event.keyval == gtk.keysyms.Left:
1133 self.treeChannels.grab_focus()
1134 elif event.keyval == gtk.keysyms.Escape:
1135 self.hide_episode_search()
1136 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
1137 self.hide_episode_search()
1138 elif event.state & gtk.gdk.CONTROL_MASK:
1139 # Don't handle type-ahead when control is pressed (so shortcuts
1140 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1141 return False
1142 else:
1143 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
1144 if unicode_char_id == 0:
1145 return False
1146 input_char = unichr(unicode_char_id)
1147 self.show_episode_search(input_char)
1148 return True
1149 self.treeAvailable.connect('key-press-event', on_key_press)
1151 self.treeAvailable.connect('popup-menu', self.treeview_available_show_context_menu)
1153 if gpodder.ui.desktop:
1154 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
1155 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
1156 def drag_data_get(tree, context, selection_data, info, timestamp):
1157 uris = ['file://'+e.local_filename(create=False) \
1158 for e in self.get_selected_episodes() \
1159 if e.was_downloaded(and_exists=True)]
1160 uris.append('') # for the trailing '\r\n'
1161 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
1162 self.treeAvailable.connect('drag-data-get', drag_data_get)
1164 selection = self.treeAvailable.get_selection()
1165 if gpodder.ui.fremantle:
1166 selection.set_mode(gtk.SELECTION_SINGLE)
1167 else:
1168 selection.set_mode(gtk.SELECTION_MULTIPLE)
1169 # Update the sensitivity of the toolbar buttons on the Desktop
1170 selection.connect('changed', lambda s: self.play_or_download())
1172 def init_download_list_treeview(self):
1173 # enable multiple selection support
1174 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
1175 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1177 # columns and renderers for "download progress" tab
1178 # First column: [ICON] Episodename
1179 column = gtk.TreeViewColumn(_('Episode'))
1181 cell = gtk.CellRendererPixbuf()
1182 if gpodder.ui.fremantle:
1183 cell.set_fixed_size(50, 50)
1184 cell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
1185 column.pack_start(cell, expand=False)
1186 column.add_attribute(cell, 'icon-name', \
1187 DownloadStatusModel.C_ICON_NAME)
1189 cell = gtk.CellRendererText()
1190 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
1191 column.pack_start(cell, expand=True)
1192 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1193 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1194 column.set_expand(True)
1195 self.treeDownloads.append_column(column)
1197 # Second column: Progress
1198 cell = gtk.CellRendererProgress()
1199 cell.set_property('yalign', .5)
1200 cell.set_property('ypad', 6)
1201 column = gtk.TreeViewColumn(_('Progress'), cell,
1202 value=DownloadStatusModel.C_PROGRESS, \
1203 text=DownloadStatusModel.C_PROGRESS_TEXT)
1204 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1205 column.set_expand(False)
1206 self.treeDownloads.append_column(column)
1207 if gpodder.ui.fremantle:
1208 column.set_property('min-width', 200)
1209 column.set_property('max-width', 200)
1210 else:
1211 column.set_property('min-width', 150)
1212 column.set_property('max-width', 150)
1214 self.treeDownloads.set_model(self.download_status_model)
1215 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1217 self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu)
1219 def on_treeview_expose_event(self, treeview, event):
1220 if event.window == treeview.get_bin_window():
1221 model = treeview.get_model()
1222 if (model is not None and model.get_iter_first() is not None):
1223 return False
1225 role = getattr(treeview, TreeViewHelper.ROLE, None)
1226 if role is None:
1227 return False
1229 ctx = event.window.cairo_create()
1230 ctx.rectangle(event.area.x, event.area.y,
1231 event.area.width, event.area.height)
1232 ctx.clip()
1234 x, y, width, height, depth = event.window.get_geometry()
1235 progress = None
1237 if role == TreeViewHelper.ROLE_EPISODES:
1238 if self.currently_updating:
1239 text = _('Loading episodes')
1240 elif self.config.episode_list_view_mode != \
1241 EpisodeListModel.VIEW_ALL:
1242 text = _('No episodes in current view')
1243 else:
1244 text = _('No episodes available')
1245 elif role == TreeViewHelper.ROLE_PODCASTS:
1246 if self.config.episode_list_view_mode != \
1247 EpisodeListModel.VIEW_ALL and \
1248 self.config.podcast_list_hide_boring and \
1249 len(self.channels) > 0:
1250 text = _('No podcasts in this view')
1251 else:
1252 text = _('No subscriptions')
1253 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1254 text = _('No active downloads')
1255 else:
1256 raise Exception('on_treeview_expose_event: unknown role')
1258 if gpodder.ui.fremantle:
1259 from gpodder.gtkui.frmntl import style
1260 font_desc = style.get_font_desc('LargeSystemFont')
1261 else:
1262 font_desc = None
1264 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1266 return False
1268 def enable_download_list_update(self):
1269 if not self.download_list_update_enabled:
1270 self.update_downloads_list()
1271 gobject.timeout_add(1500, self.update_downloads_list)
1272 self.download_list_update_enabled = True
1274 def cleanup_downloads(self):
1275 model = self.download_status_model
1277 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1278 changed_episode_urls = set()
1279 for row_reference, task in all_tasks:
1280 if task.status in (task.DONE, task.CANCELLED):
1281 model.remove(model.get_iter(row_reference.get_path()))
1282 try:
1283 # We don't "see" this task anymore - remove it;
1284 # this is needed, so update_episode_list_icons()
1285 # below gets the correct list of "seen" tasks
1286 self.download_tasks_seen.remove(task)
1287 except KeyError, key_error:
1288 log('Cannot remove task from "seen" list: %s', task, sender=self)
1289 changed_episode_urls.add(task.url)
1290 # Tell the task that it has been removed (so it can clean up)
1291 task.removed_from_list()
1293 # Tell the podcasts tab to update icons for our removed podcasts
1294 self.update_episode_list_icons(changed_episode_urls)
1296 # Tell the shownotes window that we have removed the episode
1297 if self.episode_shownotes_window is not None and \
1298 self.episode_shownotes_window.episode is not None and \
1299 self.episode_shownotes_window.episode.url in changed_episode_urls:
1300 self.episode_shownotes_window._download_status_changed(None)
1302 # Update the downloads list one more time
1303 self.update_downloads_list(can_call_cleanup=False)
1305 def on_tool_downloads_toggled(self, toolbutton):
1306 if toolbutton.get_active():
1307 self.wNotebook.set_current_page(1)
1308 else:
1309 self.wNotebook.set_current_page(0)
1311 def add_download_task_monitor(self, monitor):
1312 self.download_task_monitors.add(monitor)
1313 model = self.download_status_model
1314 if model is None:
1315 model = ()
1316 for row in model:
1317 task = row[self.download_status_model.C_TASK]
1318 monitor.task_updated(task)
1320 def remove_download_task_monitor(self, monitor):
1321 self.download_task_monitors.remove(monitor)
1323 def update_downloads_list(self, can_call_cleanup=True):
1324 try:
1325 model = self.download_status_model
1327 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1328 total_speed, total_size, done_size = 0, 0, 0
1330 # Keep a list of all download tasks that we've seen
1331 download_tasks_seen = set()
1333 # Remember the DownloadTask object for the episode that
1334 # has been opened in the episode shownotes dialog (if any)
1335 if self.episode_shownotes_window is not None:
1336 shownotes_episode = self.episode_shownotes_window.episode
1337 shownotes_task = None
1338 else:
1339 shownotes_episode = None
1340 shownotes_task = None
1342 # Do not go through the list of the model is not (yet) available
1343 if model is None:
1344 model = ()
1346 for row in model:
1347 self.download_status_model.request_update(row.iter)
1349 task = row[self.download_status_model.C_TASK]
1350 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1352 # Let the download task monitors know of changes
1353 for monitor in self.download_task_monitors:
1354 monitor.task_updated(task)
1356 total_size += size
1357 done_size += size*progress
1359 if shownotes_episode is not None and \
1360 shownotes_episode.url == task.episode.url:
1361 shownotes_task = task
1363 download_tasks_seen.add(task)
1365 if status == download.DownloadTask.DOWNLOADING:
1366 downloading += 1
1367 total_speed += speed
1368 elif status == download.DownloadTask.FAILED:
1369 failed += 1
1370 elif status == download.DownloadTask.DONE:
1371 finished += 1
1372 elif status == download.DownloadTask.QUEUED:
1373 queued += 1
1374 elif status == download.DownloadTask.PAUSED:
1375 paused += 1
1376 else:
1377 others += 1
1379 # Remember which tasks we have seen after this run
1380 self.download_tasks_seen = download_tasks_seen
1382 if gpodder.ui.desktop:
1383 text = [_('Downloads')]
1384 if downloading + failed + queued > 0:
1385 s = []
1386 if downloading > 0:
1387 s.append(N_('%(count)d active', '%(count)d active', downloading) % {'count':downloading})
1388 if failed > 0:
1389 s.append(N_('%(count)d failed', '%(count)d failed', failed) % {'count':failed})
1390 if queued > 0:
1391 s.append(N_('%(count)d queued', '%(count)d queued', queued) % {'count':queued})
1392 text.append(' (' + ', '.join(s)+')')
1393 self.labelDownloads.set_text(''.join(text))
1394 if gpodder.ui.fremantle:
1395 if downloading + queued > 0:
1396 self.button_downloads.set_value(N_('%(count)d active', '%(count)d active', downloading+queued) % {'count':(downloading+queued)})
1397 elif failed > 0:
1398 self.button_downloads.set_value(N_('%(count)d failed', '%(count)d failed', failed) % {'count':failed})
1399 elif paused > 0:
1400 self.button_downloads.set_value(N_('%(count)d paused', '%(count)d paused', paused) % {'count':paused})
1401 else:
1402 self.button_downloads.set_value(_('Idle'))
1404 title = [self.default_title]
1406 # We have to update all episodes/channels for which the status has
1407 # changed. Accessing task.status_changed has the side effect of
1408 # re-setting the changed flag, so we need to get the "changed" list
1409 # of tuples first and split it into two lists afterwards
1410 changed = [(task.url, task.podcast_url) for task in \
1411 self.download_tasks_seen if task.status_changed]
1412 episode_urls = [episode_url for episode_url, channel_url in changed]
1413 channel_urls = [channel_url for episode_url, channel_url in changed]
1415 count = downloading + queued
1416 if count > 0:
1417 title.append(N_('downloading %(count)d file', 'downloading %(count)d files', count) % {'count':count})
1419 if total_size > 0:
1420 percentage = 100.0*done_size/total_size
1421 else:
1422 percentage = 0.0
1423 total_speed = util.format_filesize(total_speed)
1424 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1425 else:
1426 if gpodder.ui.desktop:
1427 self.downloads_finished(self.download_tasks_seen)
1428 log('All downloads have finished.', sender=self)
1430 if gpodder.ui.fremantle:
1431 message = '\n'.join(['%s: %s' % (str(task), \
1432 task.error_message) for task in self.download_tasks_seen if task.notify_as_failed()])
1433 if message:
1434 self.show_message(message, _('Downloads failed'), important=True)
1436 # Remove finished episodes
1437 if self.config.auto_cleanup_downloads and can_call_cleanup:
1438 self.cleanup_downloads()
1440 # Stop updating the download list here
1441 self.download_list_update_enabled = False
1443 if not gpodder.ui.fremantle:
1444 self.gPodder.set_title(' - '.join(title))
1446 self.update_episode_list_icons(episode_urls)
1447 if self.episode_shownotes_window is not None:
1448 if (shownotes_task and shownotes_task.url in episode_urls) or \
1449 shownotes_task != self.episode_shownotes_window.task:
1450 self.episode_shownotes_window._download_status_changed(shownotes_task)
1451 self.episode_shownotes_window._download_status_progress()
1452 self.play_or_download()
1453 if channel_urls:
1454 self.update_podcast_list_model(channel_urls)
1456 return self.download_list_update_enabled
1457 except Exception, e:
1458 log('Exception happened while updating download list.', sender=self, traceback=True)
1459 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1460 # We return False here, so the update loop won't be called again,
1461 # that's why we require the restart of gPodder in the message.
1462 return False
1464 def on_config_changed(self, *args):
1465 util.idle_add(self._on_config_changed, *args)
1467 def _on_config_changed(self, name, old_value, new_value):
1468 if name == 'show_toolbar' and gpodder.ui.desktop:
1469 self.toolbar.set_property('visible', new_value)
1470 elif name == 'episode_list_descriptions':
1471 self.update_episode_list_model()
1472 elif name == 'rotation_mode':
1473 self._fremantle_rotation.set_mode(new_value)
1474 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1475 self.restart_auto_update_timer()
1476 elif name == 'podcast_list_view_all':
1477 # Force a update of the podcast list model
1478 self.channel_list_changed = True
1479 if gpodder.ui.fremantle:
1480 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
1481 while gtk.events_pending():
1482 gtk.main_iteration(False)
1483 self.update_podcast_list_model()
1484 if gpodder.ui.fremantle:
1485 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
1486 elif name == 'episode_list_columns':
1487 self.update_episode_list_columns_visibility()
1488 elif name == '_paned_position':
1489 self._last_paned_position_toggling = True
1490 self.item_sidebar.set_active(new_value > 0)
1491 self._last_paned_position_toggling = False
1493 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1494 # With get_bin_window, we get the window that contains the rows without
1495 # the header. The Y coordinate of this window will be the height of the
1496 # treeview header. This is the amount we have to subtract from the
1497 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1498 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1499 y -= x_bin
1500 y -= y_bin
1501 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1503 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or x > 50 or (column is not None and column != treeview.get_columns()[0]):
1504 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1505 return False
1507 if path is not None:
1508 model = treeview.get_model()
1509 iter = model.get_iter(path)
1510 role = getattr(treeview, TreeViewHelper.ROLE)
1512 if role == TreeViewHelper.ROLE_EPISODES:
1513 id = model.get_value(iter, EpisodeListModel.C_URL)
1514 elif role == TreeViewHelper.ROLE_PODCASTS:
1515 id = model.get_value(iter, PodcastListModel.C_URL)
1517 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1518 if last_tooltip is not None and last_tooltip != id:
1519 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1520 return False
1521 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1523 if role == TreeViewHelper.ROLE_EPISODES:
1524 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1525 if description:
1526 tooltip.set_text(description)
1527 else:
1528 return False
1529 elif role == TreeViewHelper.ROLE_PODCASTS:
1530 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1531 if channel is None:
1532 return False
1533 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1534 if error_str:
1535 error_str = _('Feedparser error: %s') % cgi.escape(error_str.strip())
1536 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1537 table = gtk.Table(rows=3, columns=3)
1538 table.set_row_spacings(5)
1539 table.set_col_spacings(5)
1540 table.set_border_width(5)
1542 heading = gtk.Label()
1543 heading.set_alignment(0, 1)
1544 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (cgi.escape(channel.title), cgi.escape(channel.url)))
1545 table.attach(heading, 0, 1, 0, 1)
1547 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1549 if len(channel.description) < 500:
1550 description = channel.description
1551 else:
1552 pos = channel.description.find('\n\n')
1553 if pos == -1 or pos > 500:
1554 description = channel.description[:498]+'[...]'
1555 else:
1556 description = channel.description[:pos]
1558 description = gtk.Label(description)
1559 if error_str:
1560 description.set_markup(error_str)
1561 description.set_alignment(0, 0)
1562 description.set_line_wrap(True)
1563 table.attach(description, 0, 3, 2, 3)
1565 table.show_all()
1566 tooltip.set_custom(table)
1568 return True
1570 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1571 return False
1573 def treeview_allow_tooltips(self, treeview, allow):
1574 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1576 def treeview_handle_context_menu_click(self, treeview, event):
1577 if event is None:
1578 selection = treeview.get_selection()
1579 return selection.get_selected_rows()
1581 x, y = int(event.x), int(event.y)
1582 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1584 selection = treeview.get_selection()
1585 model, paths = selection.get_selected_rows()
1587 if path is None or (path not in paths and \
1588 event.button == self.context_menu_mouse_button):
1589 # We have right-clicked, but not into the selection,
1590 # assume we don't want to operate on the selection
1591 paths = []
1593 if path is not None and not paths and \
1594 event.button == self.context_menu_mouse_button:
1595 # No selection or clicked outside selection;
1596 # select the single item where we clicked
1597 treeview.grab_focus()
1598 treeview.set_cursor(path, column, 0)
1599 paths = [path]
1601 if not paths:
1602 # Unselect any remaining items (clicked elsewhere)
1603 if hasattr(treeview, 'is_rubber_banding_active'):
1604 if not treeview.is_rubber_banding_active():
1605 selection.unselect_all()
1606 else:
1607 selection.unselect_all()
1609 return model, paths
1611 def downloads_list_get_selection(self, model=None, paths=None):
1612 if model is None and paths is None:
1613 selection = self.treeDownloads.get_selection()
1614 model, paths = selection.get_selected_rows()
1616 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1617 selected_tasks = [(gtk.TreeRowReference(model, path), \
1618 model.get_value(model.get_iter(path), \
1619 DownloadStatusModel.C_TASK)) for path in paths]
1621 for row_reference, task in selected_tasks:
1622 if task.status != download.DownloadTask.QUEUED:
1623 can_force = False
1624 if task.status not in (download.DownloadTask.PAUSED, \
1625 download.DownloadTask.FAILED, \
1626 download.DownloadTask.CANCELLED):
1627 can_queue = False
1628 if task.status not in (download.DownloadTask.PAUSED, \
1629 download.DownloadTask.QUEUED, \
1630 download.DownloadTask.DOWNLOADING, \
1631 download.DownloadTask.FAILED):
1632 can_cancel = False
1633 if task.status not in (download.DownloadTask.QUEUED, \
1634 download.DownloadTask.DOWNLOADING):
1635 can_pause = False
1636 if task.status not in (download.DownloadTask.CANCELLED, \
1637 download.DownloadTask.FAILED, \
1638 download.DownloadTask.DONE):
1639 can_remove = False
1641 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1643 def downloads_finished(self, download_tasks_seen):
1644 finished_downloads = [str(task) for task in download_tasks_seen if task.notify_as_finished()]
1645 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.notify_as_failed()]
1647 if finished_downloads and failed_downloads:
1648 message = self.format_episode_list(finished_downloads, 5)
1649 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1650 message += self.format_episode_list(failed_downloads, 5)
1651 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1652 elif finished_downloads:
1653 message = self.format_episode_list(finished_downloads)
1654 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1655 elif failed_downloads:
1656 message = self.format_episode_list(failed_downloads)
1657 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1660 def format_episode_list(self, episode_list, max_episodes=10):
1662 Format a list of episode names for notifications
1664 Will truncate long episode names and limit the amount of
1665 episodes displayed (max_episodes=10).
1667 The episode_list parameter should be a list of strings.
1669 MAX_TITLE_LENGTH = 100
1671 result = []
1672 for title in episode_list[:min(len(episode_list), max_episodes)]:
1673 if len(title) > MAX_TITLE_LENGTH:
1674 middle = (MAX_TITLE_LENGTH/2)-2
1675 title = '%s...%s' % (title[0:middle], title[-middle:])
1676 result.append(cgi.escape(title))
1677 result.append('\n')
1679 more_episodes = len(episode_list) - max_episodes
1680 if more_episodes > 0:
1681 result.append('(...')
1682 result.append(N_('%(count)d more episode', '%(count)d more episodes', more_episodes) % {'count':more_episodes})
1683 result.append('...)')
1685 return (''.join(result)).strip()
1687 def _for_each_task_set_status(self, tasks, status, force_start=False):
1688 episode_urls = set()
1689 model = self.treeDownloads.get_model()
1690 for row_reference, task in tasks:
1691 if status == download.DownloadTask.QUEUED:
1692 # Only queue task when its paused/failed/cancelled (or forced)
1693 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1694 self.download_queue_manager.add_task(task, force_start)
1695 self.enable_download_list_update()
1696 elif status == download.DownloadTask.CANCELLED:
1697 # Cancelling a download allowed when downloading/queued
1698 if task.status in (task.QUEUED, task.DOWNLOADING):
1699 task.status = status
1700 # Cancelling paused/failed downloads requires a call to .run()
1701 elif task.status in (task.PAUSED, task.FAILED):
1702 task.status = status
1703 # Call run, so the partial file gets deleted
1704 task.run()
1705 elif status == download.DownloadTask.PAUSED:
1706 # Pausing a download only when queued/downloading
1707 if task.status in (task.DOWNLOADING, task.QUEUED):
1708 task.status = status
1709 elif status is None:
1710 # Remove the selected task - cancel downloading/queued tasks
1711 if task.status in (task.QUEUED, task.DOWNLOADING):
1712 task.status = task.CANCELLED
1713 model.remove(model.get_iter(row_reference.get_path()))
1714 # Remember the URL, so we can tell the UI to update
1715 try:
1716 # We don't "see" this task anymore - remove it;
1717 # this is needed, so update_episode_list_icons()
1718 # below gets the correct list of "seen" tasks
1719 self.download_tasks_seen.remove(task)
1720 except KeyError, key_error:
1721 log('Cannot remove task from "seen" list: %s', task, sender=self)
1722 episode_urls.add(task.url)
1723 # Tell the task that it has been removed (so it can clean up)
1724 task.removed_from_list()
1725 else:
1726 # We can (hopefully) simply set the task status here
1727 task.status = status
1728 # Tell the podcasts tab to update icons for our removed podcasts
1729 self.update_episode_list_icons(episode_urls)
1730 # Update the tab title and downloads list
1731 self.update_downloads_list()
1733 def treeview_downloads_show_context_menu(self, treeview, event=None):
1734 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1735 if not paths:
1736 if not hasattr(treeview, 'is_rubber_banding_active'):
1737 return True
1738 else:
1739 return not treeview.is_rubber_banding_active()
1741 if event is None or event.button == self.context_menu_mouse_button:
1742 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1743 self.downloads_list_get_selection(model, paths)
1745 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1746 # This creates a menu item for selection-wide actions
1747 item = gtk.ImageMenuItem(label)
1748 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1749 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1750 item.set_sensitive(sensitive)
1751 return item
1753 menu = gtk.Menu()
1755 item = gtk.ImageMenuItem(_('Episode details'))
1756 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1757 if len(selected_tasks) == 1:
1758 row_reference, task = selected_tasks[0]
1759 episode = task.episode
1760 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1761 else:
1762 item.set_sensitive(False)
1763 menu.append(item)
1764 menu.append(gtk.SeparatorMenuItem())
1765 if can_force:
1766 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1767 else:
1768 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1769 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1770 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1771 menu.append(gtk.SeparatorMenuItem())
1772 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1774 menu.show_all()
1776 if event is None:
1777 func = TreeViewHelper.make_popup_position_func(treeview)
1778 menu.popup(None, None, func, self.context_menu_mouse_button, 0)
1779 else:
1780 menu.popup(None, None, None, event.button, event.time)
1781 return True
1783 def treeview_channels_show_context_menu(self, treeview, event=None):
1784 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1785 if not paths:
1786 return True
1788 # Check for valid channel id, if there's no id then
1789 # assume that it is a proxy channel or equivalent
1790 # and cannot be operated with right click
1791 if self.active_channel.id is None:
1792 return True
1794 if event is None or event.button == self.context_menu_mouse_button:
1795 menu = gtk.Menu()
1797 ICON = lambda x: x
1799 item = gtk.ImageMenuItem( _('Update podcast'))
1800 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1801 item.connect('activate', self.on_itemUpdateChannel_activate)
1802 item.set_sensitive(not self.updating_feed_cache)
1803 menu.append(item)
1805 menu.append(gtk.SeparatorMenuItem())
1807 item = gtk.CheckMenuItem(_('Archive'))
1808 item.set_active(self.active_channel.auto_archive_episodes)
1809 item.connect('activate', self.on_channel_toggle_lock_activate)
1810 menu.append(item)
1812 item = gtk.ImageMenuItem(_('Remove podcast'))
1813 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1814 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1815 menu.append( item)
1817 menu.append( gtk.SeparatorMenuItem())
1819 item = gtk.ImageMenuItem(_('Podcast details'))
1820 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1821 item.connect('activate', self.on_itemEditChannel_activate)
1822 menu.append(item)
1824 menu.show_all()
1825 # Disable tooltips while we are showing the menu, so
1826 # the tooltip will not appear over the menu
1827 self.treeview_allow_tooltips(self.treeChannels, False)
1828 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1830 if event is None:
1831 func = TreeViewHelper.make_popup_position_func(treeview)
1832 menu.popup(None, None, func, self.context_menu_mouse_button, 0)
1833 else:
1834 menu.popup(None, None, None, event.button, event.time)
1836 return True
1838 def cover_file_removed(self, channel_url):
1840 The Cover Downloader calls this when a previously-
1841 available cover has been removed from the disk. We
1842 have to update our model to reflect this change.
1844 self.podcast_list_model.delete_cover_by_url(channel_url)
1846 def cover_download_finished(self, channel, pixbuf):
1848 The Cover Downloader calls this when it has finished
1849 downloading (or registering, if already downloaded)
1850 a new channel cover, which is ready for displaying.
1852 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1854 def save_episodes_as_file(self, episodes):
1855 for episode in episodes:
1856 self.save_episode_as_file(episode)
1858 def save_episode_as_file(self, episode):
1859 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1860 if episode.was_downloaded(and_exists=True):
1861 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1862 copy_from = episode.local_filename(create=False)
1863 assert copy_from is not None
1864 copy_to = util.sanitize_filename(episode.sync_filename())
1865 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1866 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1868 def copy_episodes_bluetooth(self, episodes):
1869 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1871 def convert_and_send_thread(episode):
1872 for episode in episodes:
1873 filename = episode.local_filename(create=False)
1874 assert filename is not None
1875 destfile = os.path.join(tempfile.gettempdir(), \
1876 util.sanitize_filename(episode.sync_filename()))
1877 (base, ext) = os.path.splitext(filename)
1878 if not destfile.endswith(ext):
1879 destfile += ext
1881 try:
1882 shutil.copyfile(filename, destfile)
1883 util.bluetooth_send_file(destfile)
1884 except:
1885 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1886 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1888 util.delete_file(destfile)
1890 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1892 def _treeview_button_released(self, treeview, event):
1893 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1894 dy = int(abs(event.y-ypos))
1895 dx = int(event.x-xpos)
1897 selection = treeview.get_selection()
1898 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1899 if path is None or dy > 30:
1900 return (False, dx, dy)
1902 path, column, x, y = path
1903 selection.select_path(path)
1904 treeview.set_cursor(path)
1905 treeview.grab_focus()
1907 return (True, dx, dy)
1909 def treeview_available_show_context_menu(self, treeview, event=None):
1910 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1911 if not paths:
1912 if not hasattr(treeview, 'is_rubber_banding_active'):
1913 return True
1914 else:
1915 return not treeview.is_rubber_banding_active()
1917 if event is None or event.button == self.context_menu_mouse_button:
1918 episodes = self.get_selected_episodes()
1919 any_locked = any(e.archive for e in episodes)
1920 any_played = any(not e.is_new for e in episodes)
1921 one_is_new = any(e.state == gpodder.STATE_NORMAL and e.is_new for e in episodes)
1922 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1923 downloading = any(self.episode_is_downloading(e) for e in episodes)
1925 menu = gtk.Menu()
1927 (can_play, can_download, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1929 if open_instead_of_play:
1930 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1931 elif downloaded:
1932 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1933 else:
1934 item = gtk.ImageMenuItem(_('Stream'))
1935 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1937 item.set_sensitive(can_play and not downloading)
1938 item.connect('activate', self.on_playback_selected_episodes)
1939 menu.append(item)
1941 if not can_cancel:
1942 item = gtk.ImageMenuItem(_('Download'))
1943 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1944 item.set_sensitive(can_download)
1945 item.connect('activate', self.on_download_selected_episodes)
1946 menu.append(item)
1947 else:
1948 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1949 item.connect('activate', self.on_item_cancel_download_activate)
1950 menu.append(item)
1952 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1953 item.set_sensitive(can_delete)
1954 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1955 menu.append(item)
1957 result = gpodder.user_hooks.on_episodes_context_menu(episodes)
1958 if result:
1959 menu.append(gtk.SeparatorMenuItem())
1960 for label, callback in result:
1961 item = gtk.MenuItem(label)
1962 item.connect('activate', lambda item: callback(episodes))
1963 menu.append(item)
1965 ICON = lambda x: x
1967 # Ok, this probably makes sense to only display for downloaded files
1968 if downloaded:
1969 menu.append(gtk.SeparatorMenuItem())
1970 share_item = gtk.MenuItem(_('Send to'))
1971 menu.append(share_item)
1972 share_menu = gtk.Menu()
1974 item = gtk.ImageMenuItem(_('Local folder'))
1975 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
1976 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
1977 share_menu.append(item)
1978 if self.bluetooth_available:
1979 item = gtk.ImageMenuItem(_('Bluetooth device'))
1980 item.set_image(gtk.image_new_from_icon_name(ICON('bluetooth'), gtk.ICON_SIZE_MENU))
1981 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
1982 share_menu.append(item)
1984 share_item.set_submenu(share_menu)
1986 if (downloaded or one_is_new or can_download) and not downloading:
1987 menu.append(gtk.SeparatorMenuItem())
1988 if one_is_new:
1989 item = gtk.CheckMenuItem(_('New'))
1990 item.set_active(True)
1991 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1992 menu.append(item)
1993 elif can_download:
1994 item = gtk.CheckMenuItem(_('New'))
1995 item.set_active(False)
1996 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1997 menu.append(item)
1999 if downloaded:
2000 item = gtk.CheckMenuItem(_('Played'))
2001 item.set_active(any_played)
2002 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, not any_played))
2003 menu.append(item)
2005 item = gtk.CheckMenuItem(_('Archive'))
2006 item.set_active(any_locked)
2007 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
2008 menu.append(item)
2010 menu.append(gtk.SeparatorMenuItem())
2011 # Single item, add episode information menu item
2012 item = gtk.ImageMenuItem(_('Episode details'))
2013 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
2014 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
2015 menu.append(item)
2017 menu.show_all()
2018 # Disable tooltips while we are showing the menu, so
2019 # the tooltip will not appear over the menu
2020 self.treeview_allow_tooltips(self.treeAvailable, False)
2021 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2022 if event is None:
2023 func = TreeViewHelper.make_popup_position_func(treeview)
2024 menu.popup(None, None, func, self.context_menu_mouse_button, 0)
2025 else:
2026 menu.popup(None, None, None, event.button, event.time)
2028 return True
2030 def set_title(self, new_title):
2031 if not gpodder.ui.fremantle:
2032 self.default_title = new_title
2033 self.gPodder.set_title(new_title)
2035 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2037 Updates the status icons in the episode list.
2039 If urls is given, it should be a list of URLs
2040 of episodes that should be updated.
2042 If urls is None, set ONE OF selected, all to
2043 True (the former updates just the selected
2044 episodes and the latter updates all episodes).
2046 additional_args = (self.episode_is_downloading, \
2047 self.config.episode_list_descriptions and gpodder.ui.desktop)
2049 if urls is not None:
2050 # We have a list of URLs to walk through
2051 self.episode_list_model.update_by_urls(urls, *additional_args)
2052 elif selected and not all:
2053 # We should update all selected episodes
2054 selection = self.treeAvailable.get_selection()
2055 model, paths = selection.get_selected_rows()
2056 for path in reversed(paths):
2057 iter = model.get_iter(path)
2058 self.episode_list_model.update_by_filter_iter(iter, \
2059 *additional_args)
2060 elif all and not selected:
2061 # We update all (even the filter-hidden) episodes
2062 self.episode_list_model.update_all(*additional_args)
2063 else:
2064 # Wrong/invalid call - have to specify at least one parameter
2065 raise ValueError('Invalid call to update_episode_list_icons')
2067 def episode_list_status_changed(self, episodes):
2068 self.update_episode_list_icons(set(e.url for e in episodes))
2069 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2070 self.db.commit()
2072 def clean_up_downloads(self, delete_partial=False):
2073 # Clean up temporary files left behind by old gPodder versions
2074 temporary_files = glob.glob('%s/*/.tmp-*' % gpodder.downloads)
2076 if delete_partial:
2077 temporary_files += glob.glob('%s/*/*.partial' % gpodder.downloads)
2079 for tempfile in temporary_files:
2080 util.delete_file(tempfile)
2083 def streaming_possible(self):
2084 if gpodder.ui.desktop:
2085 # User has to have a media player set on the Desktop, or else we
2086 # would probably open the browser when giving a URL to xdg-open..
2087 return (self.config.player and self.config.player != 'default')
2088 elif gpodder.ui.fremantle:
2089 # On Maemo, the default is to use the Nokia Media Player, which is
2090 # already able to deal with HTTP URLs the right way, so we
2091 # unconditionally enable streaming always on Maemo
2092 return True
2094 return False
2096 def playback_episodes_for_real(self, episodes):
2097 groups = collections.defaultdict(list)
2098 for episode in episodes:
2099 file_type = episode.file_type()
2100 if file_type == 'video' and self.config.videoplayer and \
2101 self.config.videoplayer != 'default':
2102 player = self.config.videoplayer
2103 if gpodder.ui.fremantle and player == 'mplayer':
2104 player = 'mplayer -fs %F'
2105 elif file_type == 'audio' and self.config.player and \
2106 self.config.player != 'default':
2107 player = self.config.player
2108 else:
2109 player = 'default'
2111 # Mark episode as played in the database
2112 episode.playback_mark()
2113 self.mygpo_client.on_playback([episode])
2115 fmt_id = self.config.youtube_preferred_fmt_id
2116 filename = episode.get_playback_url(fmt_id)
2118 # Determine the playback resume position - if the file
2119 # was played 100%, we simply start from the beginning
2120 resume_position = episode.current_position
2121 if resume_position == episode.total_time:
2122 resume_position = 0
2124 # Only on Maemo 5, and only if the episode isn't finished yet
2125 if gpodder.ui.fremantle and not episode.is_finished():
2126 self.mafw_monitor.set_resume_point(filename, resume_position)
2128 # If Panucci is configured, use D-Bus on Maemo to call it
2129 if player == 'panucci':
2130 try:
2131 PANUCCI_NAME = 'org.panucci.panucciInterface'
2132 PANUCCI_PATH = '/panucciInterface'
2133 PANUCCI_INTF = 'org.panucci.panucciInterface'
2134 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2135 i = dbus.Interface(o, PANUCCI_INTF)
2137 def on_reply(*args):
2138 pass
2140 def error_handler(filename, err):
2141 log('Exception in D-Bus call: %s', str(err), \
2142 sender=self)
2144 # Fallback: use the command line client
2145 for command in util.format_desktop_command('panucci', \
2146 [filename]):
2147 log('Executing: %s', repr(command), sender=self)
2148 subprocess.Popen(command)
2150 on_error = lambda err: error_handler(filename, err)
2152 # This method only exists in Panucci > 0.9 ('new Panucci')
2153 i.playback_from(filename, resume_position, \
2154 reply_handler=on_reply, error_handler=on_error)
2156 continue # This file was handled by the D-Bus call
2157 except Exception, e:
2158 log('Error calling Panucci using D-Bus', sender=self, traceback=True)
2159 elif player == 'MediaBox' and gpodder.ui.fremantle:
2160 try:
2161 MEDIABOX_NAME = 'de.pycage.mediabox'
2162 MEDIABOX_PATH = '/de/pycage/mediabox/control'
2163 MEDIABOX_INTF = 'de.pycage.mediabox.control'
2164 o = gpodder.dbus_session_bus.get_object(MEDIABOX_NAME, MEDIABOX_PATH)
2165 i = dbus.Interface(o, MEDIABOX_INTF)
2167 def on_reply(*args):
2168 pass
2170 def on_error(err):
2171 log('Exception in D-Bus call: %s', str(err), \
2172 sender=self)
2174 i.load(filename, '%s/x-unknown' % file_type, \
2175 reply_handler=on_reply, error_handler=on_error)
2177 continue # This file was handled by the D-Bus call
2178 except Exception, e:
2179 log('Error calling MediaBox using D-Bus', sender=self, traceback=True)
2181 groups[player].append(filename)
2183 # Open episodes with system default player
2184 if 'default' in groups:
2185 # Special-casing for a single episode when the object is a PDF
2186 # file - this is needed on Maemo 5, so we only use gui_open()
2187 # for single PDF files, but still use the built-in media player
2188 # with an M3U file for single audio/video files. (The Maemo 5
2189 # media player behaves differently when opening a single-file
2190 # M3U playlist compared to opening the single file directly.)
2191 if len(groups['default']) == 1:
2192 fn = groups['default'][0]
2193 # The list of extensions is taken from gui_open in util.py
2194 # where all special-cases of Maemo apps are listed
2195 for extension in ('.pdf', '.jpg', '.jpeg', '.png'):
2196 if fn.lower().endswith(extension):
2197 util.gui_open(fn)
2198 groups['default'] = []
2199 break
2201 if gpodder.ui.fremantle and groups['default']:
2202 # The Nokia Media Player app does not support receiving multiple
2203 # file names via D-Bus, so we simply place all file names into a
2204 # temporary M3U playlist and open that with the Media Player.
2205 m3u_filename = os.path.join(gpodder.home, 'gpodder_open_with.m3u')
2207 def to_url(x):
2208 if '://' not in x:
2209 return 'file://' + urllib.quote(os.path.abspath(x))
2210 return x
2212 util.write_m3u_playlist(m3u_filename, \
2213 map(to_url, groups['default']), \
2214 extm3u=False)
2215 util.gui_open(m3u_filename)
2216 else:
2217 for filename in groups['default']:
2218 log('Opening with system default: %s', filename, sender=self)
2219 util.gui_open(filename)
2220 del groups['default']
2221 elif gpodder.ui.fremantle and groups:
2222 # When on Maemo and not opening with default, show a notification
2223 # (no startup notification for Panucci / MPlayer yet...)
2224 if len(episodes) == 1:
2225 text = _('Opening %s') % episodes[0].title
2226 else:
2227 count = len(episodes)
2228 text = N_('Opening %(count)d episode', 'Opening %(count)d episodes', count) % {'count':count}
2230 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
2232 def destroy_banner_later(banner):
2233 banner.destroy()
2234 return False
2235 gobject.timeout_add(5000, destroy_banner_later, banner)
2237 # For each type now, go and create play commands
2238 for group in groups:
2239 for command in util.format_desktop_command(group, groups[group]):
2240 log('Executing: %s', repr(command), sender=self)
2241 subprocess.Popen(command)
2243 # Persist episode status changes to the database
2244 self.db.commit()
2246 # Flush updated episode status
2247 self.mygpo_client.flush()
2249 def playback_episodes(self, episodes):
2250 # We need to create a list, because we run through it more than once
2251 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if \
2252 e.was_downloaded(and_exists=True) or self.streaming_possible()))
2254 try:
2255 self.playback_episodes_for_real(episodes)
2256 except Exception, e:
2257 log('Error in playback!', sender=self, traceback=True)
2258 if gpodder.ui.desktop:
2259 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2260 _('Error opening player'), widget=self.toolPreferences)
2261 else:
2262 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2264 channel_urls = set()
2265 episode_urls = set()
2266 for episode in episodes:
2267 channel_urls.add(episode.channel.url)
2268 episode_urls.add(episode.url)
2269 self.update_episode_list_icons(episode_urls)
2270 self.update_podcast_list_model(channel_urls)
2272 def play_or_download(self):
2273 if not gpodder.ui.fremantle:
2274 if self.wNotebook.get_current_page() > 0:
2275 if gpodder.ui.desktop:
2276 self.toolCancel.set_sensitive(True)
2277 return
2279 if self.currently_updating:
2280 return (False, False, False, False, False, False)
2282 ( can_play, can_download, can_cancel, can_delete ) = (False,)*4
2283 ( is_played, is_locked ) = (False,)*2
2285 open_instead_of_play = False
2287 selection = self.treeAvailable.get_selection()
2288 if selection.count_selected_rows() > 0:
2289 (model, paths) = selection.get_selected_rows()
2291 for path in paths:
2292 try:
2293 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2294 except TypeError, te:
2295 log('Invalid episode at path %s', str(path), sender=self)
2296 continue
2298 if episode.file_type() not in ('audio', 'video'):
2299 open_instead_of_play = True
2301 if episode.was_downloaded():
2302 can_play = episode.was_downloaded(and_exists=True)
2303 is_played = not episode.is_new
2304 is_locked = episode.archive
2305 if not can_play:
2306 can_download = True
2307 else:
2308 if self.episode_is_downloading(episode):
2309 can_cancel = True
2310 else:
2311 can_download = True
2313 can_download = can_download and not can_cancel
2314 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2315 can_delete = not can_cancel
2317 if gpodder.ui.desktop:
2318 if open_instead_of_play:
2319 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2320 else:
2321 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2322 self.toolPlay.set_sensitive( can_play)
2323 self.toolDownload.set_sensitive( can_download)
2324 self.toolCancel.set_sensitive( can_cancel)
2326 if not gpodder.ui.fremantle:
2327 self.item_cancel_download.set_sensitive(can_cancel)
2328 self.itemDownloadSelected.set_sensitive(can_download)
2329 self.itemOpenSelected.set_sensitive(can_play)
2330 self.itemPlaySelected.set_sensitive(can_play)
2331 self.itemDeleteSelected.set_sensitive(can_delete)
2332 self.item_toggle_played.set_sensitive(can_play)
2333 self.item_toggle_lock.set_sensitive(can_play)
2334 self.itemOpenSelected.set_visible(open_instead_of_play)
2335 self.itemPlaySelected.set_visible(not open_instead_of_play)
2337 return (can_play, can_download, can_cancel, can_delete, open_instead_of_play)
2339 def on_cbMaxDownloads_toggled(self, widget, *args):
2340 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2342 def on_cbLimitDownloads_toggled(self, widget, *args):
2343 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2345 def episode_new_status_changed(self, urls):
2346 self.update_podcast_list_model()
2347 self.update_episode_list_icons(urls)
2349 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2350 """Update the podcast list treeview model
2352 If urls is given, it should list the URLs of each
2353 podcast that has to be updated in the list.
2355 If selected is True, only update the model contents
2356 for the currently-selected podcast - nothing more.
2358 The caller can optionally specify "select_url",
2359 which is the URL of the podcast that is to be
2360 selected in the list after the update is complete.
2361 This only works if the podcast list has to be
2362 reloaded; i.e. something has been added or removed
2363 since the last update of the podcast list).
2365 selection = self.treeChannels.get_selection()
2366 model, iter = selection.get_selected()
2368 if self.config.podcast_list_view_all and not self.channel_list_changed:
2369 # Update "all episodes" view in any case (if enabled)
2370 self.podcast_list_model.update_first_row()
2372 if selected:
2373 # very cheap! only update selected channel
2374 if iter is not None:
2375 # If we have selected the "all episodes" view, we have
2376 # to update all channels for selected episodes:
2377 if self.config.podcast_list_view_all and \
2378 self.podcast_list_model.iter_is_first_row(iter):
2379 urls = self.get_podcast_urls_from_selected_episodes()
2380 self.podcast_list_model.update_by_urls(urls)
2381 else:
2382 # Otherwise just update the selected row (a podcast)
2383 self.podcast_list_model.update_by_filter_iter(iter)
2384 elif not self.channel_list_changed:
2385 # we can keep the model, but have to update some
2386 if urls is None:
2387 # still cheaper than reloading the whole list
2388 self.podcast_list_model.update_all()
2389 else:
2390 # ok, we got a bunch of urls to update
2391 self.podcast_list_model.update_by_urls(urls)
2392 else:
2393 if model and iter and select_url is None:
2394 # Get the URL of the currently-selected podcast
2395 select_url = model.get_value(iter, PodcastListModel.C_URL)
2397 # Update the podcast list model with new channels
2398 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2400 try:
2401 selected_iter = model.get_iter_first()
2402 # Find the previously-selected URL in the new
2403 # model if we have an URL (else select first)
2404 if select_url is not None:
2405 pos = model.get_iter_first()
2406 while pos is not None:
2407 url = model.get_value(pos, PodcastListModel.C_URL)
2408 if url == select_url:
2409 selected_iter = pos
2410 break
2411 pos = model.iter_next(pos)
2413 if not gpodder.ui.fremantle:
2414 if selected_iter is not None:
2415 selection.select_iter(selected_iter)
2416 self.on_treeChannels_cursor_changed(self.treeChannels)
2417 except:
2418 log('Cannot select podcast in list', traceback=True, sender=self)
2419 self.channel_list_changed = False
2421 def episode_is_downloading(self, episode):
2422 """Returns True if the given episode is being downloaded at the moment"""
2423 if episode is None:
2424 return False
2426 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2428 def on_episode_list_filter_changed(self, has_episodes):
2429 if gpodder.ui.fremantle:
2430 if has_episodes:
2431 self.episodes_window.empty_label.hide()
2432 self.episodes_window.pannablearea.show()
2433 else:
2434 if self.config.episode_list_view_mode != \
2435 EpisodeListModel.VIEW_ALL:
2436 text = _('No episodes in current view')
2437 else:
2438 text = _('No episodes available')
2439 self.episodes_window.empty_label.set_text(text)
2440 self.episodes_window.pannablearea.hide()
2441 self.episodes_window.empty_label.show()
2443 def update_episode_list_model(self):
2444 if self.channels and self.active_channel is not None:
2445 if gpodder.ui.fremantle:
2446 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2448 self.currently_updating = True
2449 self.episode_list_model.clear()
2450 if gpodder.ui.fremantle:
2451 self.episodes_window.pannablearea.hide()
2452 self.episodes_window.empty_label.set_text(_('Loading episodes'))
2453 self.episodes_window.empty_label.show()
2455 def update():
2456 additional_args = (self.episode_is_downloading, \
2457 self.config.episode_list_descriptions and gpodder.ui.desktop)
2458 self.episode_list_model.replace_from_channel(self.active_channel, *additional_args)
2460 self.treeAvailable.get_selection().unselect_all()
2461 self.treeAvailable.scroll_to_point(0, 0)
2463 self.currently_updating = False
2464 self.play_or_download()
2466 if gpodder.ui.fremantle:
2467 hildon.hildon_gtk_window_set_progress_indicator(\
2468 self.episodes_window.main_window, False)
2470 util.idle_add(update)
2471 else:
2472 self.episode_list_model.clear()
2474 @dbus.service.method(gpodder.dbus_interface)
2475 def offer_new_episodes(self, channels=None):
2476 if gpodder.ui.fremantle:
2477 # Assume that when this function is called that the
2478 # notification is not shown anymore (Maemo bug 11345)
2479 self._fremantle_notification_visible = False
2481 new_episodes = self.get_new_episodes(channels)
2482 if new_episodes:
2483 self.new_episodes_show(new_episodes)
2484 return True
2485 return False
2487 def add_podcast_list(self, urls, auth_tokens=None):
2488 """Subscribe to a list of podcast given their URLs
2490 If auth_tokens is given, it should be a dictionary
2491 mapping URLs to (username, password) tuples."""
2493 if auth_tokens is None:
2494 auth_tokens = {}
2496 # Sort and split the URL list into five buckets
2497 queued, failed, existing, worked, authreq = [], [], [], [], []
2498 for input_url in urls:
2499 url = util.normalize_feed_url(input_url)
2500 if url is None:
2501 # Fail this one because the URL is not valid
2502 failed.append(input_url)
2503 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2504 # A podcast already exists in the list for this URL
2505 existing.append(url)
2506 else:
2507 # This URL has survived the first round - queue for add
2508 queued.append(url)
2509 if url != input_url and input_url in auth_tokens:
2510 auth_tokens[url] = auth_tokens[input_url]
2512 error_messages = {}
2513 redirections = {}
2515 progress = ProgressIndicator(_('Adding podcasts'), \
2516 _('Please wait while episode information is downloaded.'), \
2517 parent=self.get_dialog_parent())
2519 def on_after_update():
2520 progress.on_finished()
2521 # Report already-existing subscriptions to the user
2522 if existing:
2523 title = _('Existing subscriptions skipped')
2524 message = _('You are already subscribed to these podcasts:') \
2525 + '\n\n' + '\n'.join(cgi.escape(url) for url in existing)
2526 self.show_message(message, title, widget=self.treeChannels)
2528 # Report subscriptions that require authentication
2529 if authreq:
2530 retry_podcasts = {}
2531 for url in authreq:
2532 title = _('Podcast requires authentication')
2533 message = _('Please login to %s:') % (cgi.escape(url),)
2534 success, auth_tokens = self.show_login_dialog(title, message)
2535 if success:
2536 retry_podcasts[url] = auth_tokens
2537 else:
2538 # Stop asking the user for more login data
2539 retry_podcasts = {}
2540 for url in authreq:
2541 error_messages[url] = _('Authentication failed')
2542 failed.append(url)
2543 break
2545 # If we have authentication data to retry, do so here
2546 if retry_podcasts:
2547 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2549 # Report website redirections
2550 for url in redirections:
2551 title = _('Website redirection detected')
2552 message = _('The URL %(url)s redirects to %(target)s.') \
2553 + '\n\n' + _('Do you want to visit the website now?')
2554 message = message % {'url': url, 'target': redirections[url]}
2555 if self.show_confirmation(message, title):
2556 util.open_website(url)
2557 else:
2558 break
2560 # Report failed subscriptions to the user
2561 if failed:
2562 title = _('Could not add some podcasts')
2563 message = _('Some podcasts could not be added to your list:') \
2564 + '\n\n' + '\n'.join(cgi.escape('%s: %s' % (url, \
2565 error_messages.get(url, _('Unknown')))) for url in failed)
2566 self.show_message(message, title, important=True)
2568 # Upload subscription changes to gpodder.net
2569 self.mygpo_client.on_subscribe(worked)
2571 # If at least one podcast has been added, save and update all
2572 if self.channel_list_changed:
2573 # Fix URLs if mygpo has rewritten them
2574 self.rewrite_urls_mygpo()
2576 # If only one podcast was added, select it after the update
2577 if len(worked) == 1:
2578 url = worked[0]
2579 else:
2580 url = None
2582 # Update the list of subscribed podcasts
2583 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2585 # Offer to download new episodes
2586 episodes = []
2587 for podcast in self.channels:
2588 if podcast.url in worked:
2589 episodes.extend(podcast.get_all_episodes())
2591 if episodes:
2592 episodes = list(Model.sort_episodes_by_pubdate(episodes, \
2593 reverse=True))
2594 self.new_episodes_show(episodes, \
2595 selected=[e.check_is_new() for e in episodes])
2598 def thread_proc():
2599 # After the initial sorting and splitting, try all queued podcasts
2600 length = len(queued)
2601 for index, url in enumerate(queued):
2602 progress.on_progress(float(index)/float(length))
2603 progress.on_message(url)
2604 log('QUEUE RUNNER: %s', url, sender=self)
2605 try:
2606 # The URL is valid and does not exist already - subscribe!
2607 channel = Model.load_podcast(self.db, url=url, create=True, \
2608 authentication_tokens=auth_tokens.get(url, None), \
2609 max_episodes=self.config.max_episodes_per_feed, \
2610 mimetype_prefs=self.config.mimetype_prefs)
2612 try:
2613 username, password = util.username_password_from_url(url)
2614 except ValueError, ve:
2615 username, password = (None, None)
2617 if username is not None and channel.auth_username is None and \
2618 password is not None and channel.auth_password is None:
2619 channel.auth_username = username
2620 channel.auth_password = password
2621 channel.save()
2623 self._update_cover(channel)
2624 except feedcore.AuthenticationRequired:
2625 if url in auth_tokens:
2626 # Fail for wrong authentication data
2627 error_messages[url] = _('Authentication failed')
2628 failed.append(url)
2629 else:
2630 # Queue for login dialog later
2631 authreq.append(url)
2632 continue
2633 except feedcore.WifiLogin, error:
2634 redirections[url] = error.data
2635 failed.append(url)
2636 error_messages[url] = _('Redirection detected')
2637 continue
2638 except Exception, e:
2639 log('Subscription error: %s', e, traceback=True, sender=self)
2640 error_messages[url] = str(e)
2641 failed.append(url)
2642 continue
2644 assert channel is not None
2645 worked.append(channel.url)
2646 self.channels.append(channel)
2647 self.channel_list_changed = True
2648 util.idle_add(on_after_update)
2649 threading.Thread(target=thread_proc).start()
2651 def find_episode(self, podcast_url, episode_url):
2652 """Find an episode given its podcast and episode URL
2654 The function will return a PodcastEpisode object if
2655 the episode is found, or None if it's not found.
2657 for podcast in self.channels:
2658 if podcast_url == podcast.url:
2659 for episode in podcast.get_all_episodes():
2660 if episode_url == episode.url:
2661 return episode
2663 return None
2665 def process_received_episode_actions(self, updated_urls):
2666 """Process/merge episode actions from gpodder.net
2668 This function will merge all changes received from
2669 the server to the local database and update the
2670 status of the affected episodes as necessary.
2672 indicator = ProgressIndicator(_('Merging episode actions'), \
2673 _('Episode actions from gpodder.net are merged.'), \
2674 False, self.get_dialog_parent())
2676 for idx, action in enumerate(self.mygpo_client.get_episode_actions(updated_urls)):
2677 if action.action == 'play':
2678 episode = self.find_episode(action.podcast_url, \
2679 action.episode_url)
2681 if episode is not None:
2682 log('Play action for %s', episode.url, sender=self)
2683 episode.mark(is_played=True)
2685 if action.timestamp > episode.current_position_updated and \
2686 action.position is not None:
2687 log('Updating position for %s', episode.url, sender=self)
2688 episode.current_position = action.position
2689 episode.current_position_updated = action.timestamp
2691 if action.total:
2692 log('Updating total time for %s', episode.url, sender=self)
2693 episode.total_time = action.total
2695 episode.save()
2696 elif action.action == 'delete':
2697 episode = self.find_episode(action.podcast_url, \
2698 action.episode_url)
2700 if episode is not None:
2701 if not episode.was_downloaded(and_exists=True):
2702 # Set the episode to a "deleted" state
2703 log('Marking as deleted: %s', episode.url, sender=self)
2704 episode.delete_from_disk()
2705 episode.save()
2707 indicator.on_message(N_('%(count)d action processed', '%(count)d actions processed', idx) % {'count':idx})
2708 gtk.main_iteration(False)
2710 indicator.on_finished()
2711 self.db.commit()
2714 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2715 self.db.commit()
2716 self.updating_feed_cache = False
2718 self.channels = Model.get_podcasts(self.db)
2720 # Process received episode actions for all updated URLs
2721 self.process_received_episode_actions(updated_urls)
2723 self.channel_list_changed = True
2724 self.update_podcast_list_model(select_url=select_url_afterwards)
2726 # Only search for new episodes in podcasts that have been
2727 # updated, not in other podcasts (for single-feed updates)
2728 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2730 if gpodder.ui.fremantle:
2731 self.fancy_progress_bar.hide()
2732 self.button_subscribe.set_sensitive(True)
2733 self.button_refresh.set_sensitive(True)
2734 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2735 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2736 self.update_episode_list_model()
2737 if self.feed_cache_update_cancelled:
2738 return
2740 def application_in_foreground():
2741 try:
2742 return any(w.get_property('is-topmost') for w in hildon.WindowStack.get_default().get_windows())
2743 except Exception, e:
2744 log('Could not determine is-topmost', traceback=True)
2745 # When in doubt, assume not in foreground
2746 return False
2748 if episodes:
2749 if self.config.auto_download == 'quiet' and not self.config.auto_update_feeds:
2750 # New episodes found, but we should do nothing
2751 self.show_message(_('New episodes are available.'))
2752 elif self.config.auto_download == 'always' or \
2753 (self.config.auto_download == 'wifi' and \
2754 self.network_manager.connection_is_wlan()):
2755 count = len(episodes)
2756 title = N_('Downloading %(count)d new episode.', 'Downloading %(count)d new episodes.', count) % {'count':count}
2757 self.show_message(title)
2758 self.download_episode_list(episodes)
2759 elif self.config.auto_download == 'queue':
2760 self.show_message(_('New episodes have been added to the download list.'))
2761 self.download_episode_list_paused(episodes)
2762 elif application_in_foreground():
2763 if not self._fremantle_notification_visible:
2764 self.new_episodes_show(episodes)
2765 elif not self._fremantle_notification_visible:
2766 try:
2767 import pynotify
2768 pynotify.init('gPodder')
2769 n = pynotify.Notification('gPodder', _('New episodes available'), 'gpodder')
2770 n.set_urgency(pynotify.URGENCY_CRITICAL)
2771 n.set_hint('dbus-callback-default', ' '.join([
2772 gpodder.dbus_bus_name,
2773 gpodder.dbus_gui_object_path,
2774 gpodder.dbus_interface,
2775 'offer_new_episodes',
2777 n.set_category('gpodder-new-episodes')
2778 n.show()
2779 self._fremantle_notification_visible = True
2780 except Exception, e:
2781 log('Error: %s', str(e), sender=self, traceback=True)
2782 self.new_episodes_show(episodes)
2783 self._fremantle_notification_visible = False
2784 elif not self.config.auto_update_feeds:
2785 self.show_message(_('No new episodes. Please check for new episodes later.'))
2786 return
2788 if self.feed_cache_update_cancelled:
2789 # The user decided to abort the feed update
2790 self.show_update_feeds_buttons()
2791 elif not episodes:
2792 # Nothing new here - but inform the user
2793 self.pbFeedUpdate.set_fraction(1.0)
2794 self.pbFeedUpdate.set_text(_('No new episodes'))
2795 self.feed_cache_update_cancelled = True
2796 self.btnCancelFeedUpdate.show()
2797 self.btnCancelFeedUpdate.set_sensitive(True)
2798 self.itemUpdate.set_sensitive(True)
2799 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2800 else:
2801 count = len(episodes)
2802 # New episodes are available
2803 self.pbFeedUpdate.set_fraction(1.0)
2804 # Are we minimized and should we auto download?
2805 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2806 self.download_episode_list(episodes)
2807 title = N_('Downloading %(count)d new episode.', 'Downloading %(count)d new episodes.', count) % {'count':count}
2808 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2809 self.show_update_feeds_buttons()
2810 elif self.config.auto_download == 'queue':
2811 self.download_episode_list_paused(episodes)
2812 title = N_('%(count)d new episode added to download list.', '%(count)d new episodes added to download list.', count) % {'count':count}
2813 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2814 self.show_update_feeds_buttons()
2815 else:
2816 self.show_update_feeds_buttons()
2817 # New episodes are available and we are not minimized
2818 if not self.config.do_not_show_new_episodes_dialog:
2819 self.new_episodes_show(episodes, notification=True)
2820 else:
2821 message = N_('%(count)d new episode available', '%(count)d new episodes available', count) % {'count':count}
2822 self.pbFeedUpdate.set_text(message)
2824 def _update_cover(self, channel):
2825 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2826 self.cover_downloader.request_cover(channel)
2828 def update_feed_cache_proc(self, channels, select_url_afterwards):
2829 total = len(channels)
2831 for updated, channel in enumerate(channels):
2832 if not self.feed_cache_update_cancelled:
2833 try:
2834 channel.update(max_episodes=self.config.max_episodes_per_feed, \
2835 mimetype_prefs=self.config.mimetype_prefs)
2836 self._update_cover(channel)
2837 except Exception, e:
2838 d = {'url': cgi.escape(channel.url), 'message': cgi.escape(str(e))}
2839 if d['message']:
2840 message = _('Error while updating %(url)s: %(message)s')
2841 else:
2842 message = _('The feed at %(url)s could not be updated.')
2843 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2844 log('Error: %s', str(e), sender=self, traceback=True)
2846 if self.feed_cache_update_cancelled:
2847 break
2849 # By the time we get here the update may have already been cancelled
2850 if not self.feed_cache_update_cancelled:
2851 def update_progress():
2852 d = {'podcast': channel.title, 'position': updated+1, 'total': total}
2853 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2854 self.pbFeedUpdate.set_text(progression)
2855 self.pbFeedUpdate.set_fraction(float(updated+1)/float(total))
2856 util.idle_add(update_progress)
2858 updated_urls = [c.url for c in channels]
2859 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2861 def show_update_feeds_buttons(self):
2862 # Make sure that the buttons for updating feeds
2863 # appear - this should happen after a feed update
2864 self.hboxUpdateFeeds.hide()
2865 self.btnUpdateFeeds.show()
2866 self.itemUpdate.set_sensitive(True)
2867 self.itemUpdateChannel.set_sensitive(True)
2869 def on_btnCancelFeedUpdate_clicked(self, widget):
2870 if not self.feed_cache_update_cancelled:
2871 self.pbFeedUpdate.set_text(_('Cancelling...'))
2872 self.feed_cache_update_cancelled = True
2873 if not gpodder.ui.fremantle:
2874 self.btnCancelFeedUpdate.set_sensitive(False)
2875 elif not gpodder.ui.fremantle:
2876 self.show_update_feeds_buttons()
2878 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2879 if self.updating_feed_cache:
2880 if gpodder.ui.fremantle:
2881 self.feed_cache_update_cancelled = True
2882 return
2884 if not force_update:
2885 self.channels = Model.get_podcasts(self.db)
2886 self.channel_list_changed = True
2887 self.update_podcast_list_model(select_url=select_url_afterwards)
2888 return
2890 # Fix URLs if mygpo has rewritten them
2891 self.rewrite_urls_mygpo()
2893 self.updating_feed_cache = True
2895 if channels is None:
2896 # Only update podcasts for which updates are enabled
2897 channels = [c for c in self.channels if not c.pause_subscription]
2899 if gpodder.ui.fremantle:
2900 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2901 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2902 self.fancy_progress_bar.show()
2903 self.button_subscribe.set_sensitive(False)
2904 self.button_refresh.set_sensitive(False)
2905 self.feed_cache_update_cancelled = False
2906 else:
2907 self.itemUpdate.set_sensitive(False)
2908 self.itemUpdateChannel.set_sensitive(False)
2910 self.feed_cache_update_cancelled = False
2911 self.btnCancelFeedUpdate.show()
2912 self.btnCancelFeedUpdate.set_sensitive(True)
2913 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2914 self.hboxUpdateFeeds.show_all()
2915 self.btnUpdateFeeds.hide()
2917 if len(channels) == 1:
2918 text = _('Updating "%s"...') % channels[0].title
2919 else:
2920 count = len(channels)
2921 text = N_('Updating %(count)d feed...', 'Updating %(count)d feeds...', count) % {'count':count}
2922 self.pbFeedUpdate.set_text(text)
2923 self.pbFeedUpdate.set_fraction(0)
2925 args = (channels, select_url_afterwards)
2926 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
2928 def on_gPodder_delete_event(self, widget, *args):
2929 """Called when the GUI wants to close the window
2930 Displays a confirmation dialog (and closes/hides gPodder)
2933 downloading = self.download_status_model.are_downloads_in_progress()
2935 if downloading:
2936 if gpodder.ui.fremantle:
2937 self.close_gpodder()
2938 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2939 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2940 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
2942 title = _('Quit gPodder')
2943 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2945 dialog.set_title(title)
2946 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2948 quit_button.grab_focus()
2949 result = dialog.run()
2950 dialog.destroy()
2952 if result == gtk.RESPONSE_CLOSE:
2953 self.close_gpodder()
2954 else:
2955 self.close_gpodder()
2957 return True
2959 def close_gpodder(self):
2960 """ clean everything and exit properly
2962 self.gPodder.hide()
2964 # Notify all tasks to to carry out any clean-up actions
2965 self.download_status_model.tell_all_tasks_to_quit()
2967 while gtk.events_pending():
2968 gtk.main_iteration(False)
2970 self.core.shutdown()
2972 self.quit()
2973 sys.exit(0)
2975 def get_expired_episodes(self):
2976 for channel in self.channels:
2977 for episode in channel.get_downloaded_episodes():
2978 # Never consider archived episodes as old
2979 if episode.archive:
2980 continue
2982 # Never consider fresh episodes as old
2983 if episode.age_in_days() < self.config.episode_old_age:
2984 continue
2986 # Do not delete played episodes (except if configured)
2987 if not episode.is_new:
2988 if not self.config.auto_remove_played_episodes:
2989 continue
2991 # Do not delete unplayed episodes (except if configured)
2992 if episode.is_new:
2993 if not self.config.auto_remove_unplayed_episodes:
2994 continue
2996 yield episode
2998 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
2999 if not episodes:
3000 return False
3002 if skip_locked:
3003 episodes = [e for e in episodes if not e.archive]
3005 if not episodes:
3006 title = _('Episodes are locked')
3007 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
3008 self.notification(message, title, widget=self.treeAvailable)
3009 return False
3011 count = len(episodes)
3012 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?', count) % {'count':count}
3013 message = _('Deleting episodes removes downloaded files.')
3015 if gpodder.ui.fremantle:
3016 message = '\n'.join([title, message])
3018 if confirm and not self.show_confirmation(message, title):
3019 return False
3021 progress = ProgressIndicator(_('Deleting episodes'), \
3022 _('Please wait while episodes are deleted'), \
3023 parent=self.get_dialog_parent())
3025 def finish_deletion(episode_urls, channel_urls):
3026 progress.on_finished()
3028 # Episodes have been deleted - persist the database
3029 self.db.commit()
3031 self.update_episode_list_icons(episode_urls)
3032 self.update_podcast_list_model(channel_urls)
3033 self.play_or_download()
3035 def thread_proc():
3036 episode_urls = set()
3037 channel_urls = set()
3039 episodes_status_update = []
3040 for idx, episode in enumerate(episodes):
3041 progress.on_progress(float(idx)/float(len(episodes)))
3042 if episode.archive and skip_locked:
3043 log('Not deleting episode (is locked): %s', episode.title)
3044 else:
3045 log('Deleting episode: %s', episode.title)
3046 progress.on_message(episode.title)
3047 episode.delete_from_disk()
3048 episode_urls.add(episode.url)
3049 channel_urls.add(episode.channel.url)
3050 episodes_status_update.append(episode)
3052 # Tell the shownotes window that we have removed the episode
3053 if self.episode_shownotes_window is not None and \
3054 self.episode_shownotes_window.episode is not None and \
3055 self.episode_shownotes_window.episode.url == episode.url:
3056 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
3058 # Notify the web service about the status update + upload
3059 self.mygpo_client.on_delete(episodes_status_update)
3060 self.mygpo_client.flush()
3062 util.idle_add(finish_deletion, episode_urls, channel_urls)
3064 threading.Thread(target=thread_proc).start()
3066 return True
3068 def on_itemRemoveOldEpisodes_activate(self, widget):
3069 self.show_delete_episodes_window()
3071 def show_delete_episodes_window(self, channel=None):
3072 """Offer deletion of episodes
3074 If channel is None, offer deletion of all episodes.
3075 Otherwise only offer deletion of episodes in the channel.
3077 columns = (
3078 ('markup_delete_episodes', None, None, _('Episode')),
3081 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.episode_old_age)
3082 selection_buttons = {
3083 _('Select played'): lambda episode: not episode.is_new,
3084 _('Select finished'): lambda episode: episode.is_finished(),
3085 msg_older_than % {'count':self.config.episode_old_age}: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3088 instructions = _('Select the episodes you want to delete:')
3090 if channel is None:
3091 channels = self.channels
3092 else:
3093 channels = [channel]
3095 episodes = []
3096 for channel in channels:
3097 for episode in channel.get_downloaded_episodes():
3098 # Disallow deletion of locked episodes that still exist
3099 if not episode.archive or not episode.file_exists():
3100 episodes.append(episode)
3102 selected = [not e.is_new or not e.file_exists() for e in episodes]
3104 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
3105 episodes = episodes, selected = selected, columns = columns, \
3106 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
3107 selection_buttons = selection_buttons, _config=self.config, \
3108 show_episode_shownotes=self.show_episode_shownotes)
3110 def on_selected_episodes_status_changed(self):
3111 # The order of the updates here is important! When "All episodes" is
3112 # selected, the update of the podcast list model depends on the episode
3113 # list selection to determine which podcasts are affected. Updating
3114 # the episode list could remove the selection if a filter is active.
3115 self.update_podcast_list_model(selected=True)
3116 self.update_episode_list_icons(selected=True)
3117 self.db.commit()
3119 def mark_selected_episodes_new(self):
3120 for episode in self.get_selected_episodes():
3121 episode.mark_new()
3122 self.on_selected_episodes_status_changed()
3124 def mark_selected_episodes_old(self):
3125 for episode in self.get_selected_episodes():
3126 episode.mark_old()
3127 self.on_selected_episodes_status_changed()
3129 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
3130 for episode in self.get_selected_episodes():
3131 if toggle:
3132 episode.mark(is_played=episode.is_new)
3133 else:
3134 episode.mark(is_played=new_value)
3135 self.on_selected_episodes_status_changed()
3137 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3138 for episode in self.get_selected_episodes():
3139 if toggle:
3140 episode.mark(is_locked=not episode.archive)
3141 else:
3142 episode.mark(is_locked=new_value)
3143 self.on_selected_episodes_status_changed()
3145 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3146 if self.active_channel is None:
3147 return
3149 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
3150 self.active_channel.save()
3152 for episode in self.active_channel.get_all_episodes():
3153 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
3155 self.update_podcast_list_model(selected=True)
3156 self.update_episode_list_icons(all=True)
3158 def on_itemUpdateChannel_activate(self, widget=None):
3159 if self.active_channel is None:
3160 title = _('No podcast selected')
3161 message = _('Please select a podcast in the podcasts list to update.')
3162 self.show_message( message, title, widget=self.treeChannels)
3163 return
3165 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3166 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3167 self.update_feed_cache()
3168 else:
3169 self.update_feed_cache(channels=[self.active_channel])
3171 def on_itemUpdate_activate(self, widget=None):
3172 # Check if we have outstanding subscribe/unsubscribe actions
3173 if self.on_add_remove_podcasts_mygpo():
3174 log('Update cancelled (received server changes)', sender=self)
3175 return
3177 if self.channels:
3178 self.update_feed_cache()
3179 else:
3180 gPodderWelcome(self.gPodder,
3181 center_on_widget=self.gPodder,
3182 show_example_podcasts_callback=self.on_itemImportChannels_activate,
3183 setup_my_gpodder_callback=self.on_download_subscriptions_from_mygpo)
3185 def download_episode_list_paused(self, episodes):
3186 self.download_episode_list(episodes, True)
3188 def download_episode_list(self, episodes, add_paused=False, force_start=False):
3189 enable_update = False
3191 for episode in episodes:
3192 log('Downloading episode: %s', episode.title, sender = self)
3193 if not episode.was_downloaded(and_exists=True):
3194 task_exists = False
3195 for task in self.download_tasks_seen:
3196 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
3197 self.download_queue_manager.add_task(task, force_start)
3198 enable_update = True
3199 task_exists = True
3200 continue
3202 if task_exists:
3203 continue
3205 try:
3206 task = download.DownloadTask(episode, self.config)
3207 except Exception, e:
3208 d = {'episode': episode.title, 'message': str(e)}
3209 message = _('Download error while downloading %(episode)s: %(message)s')
3210 self.show_message(message % d, _('Download error'), important=True)
3211 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
3212 continue
3214 if add_paused:
3215 task.status = task.PAUSED
3216 else:
3217 self.mygpo_client.on_download([task.episode])
3218 self.download_queue_manager.add_task(task, force_start)
3220 self.download_status_model.register_task(task)
3221 enable_update = True
3223 if enable_update:
3224 self.enable_download_list_update()
3226 # Flush updated episode status
3227 self.mygpo_client.flush()
3229 def cancel_task_list(self, tasks):
3230 if not tasks:
3231 return
3233 for task in tasks:
3234 if task.status in (task.QUEUED, task.DOWNLOADING):
3235 task.status = task.CANCELLED
3236 elif task.status == task.PAUSED:
3237 task.status = task.CANCELLED
3238 # Call run, so the partial file gets deleted
3239 task.run()
3241 self.update_episode_list_icons([task.url for task in tasks])
3242 self.play_or_download()
3244 # Update the tab title and downloads list
3245 self.update_downloads_list()
3247 def new_episodes_show(self, episodes, notification=False, selected=None):
3248 columns = (
3249 ('markup_new_episodes', None, None, _('Episode')),
3251 show_notification = notification and gpodder.ui.fremantle
3253 instructions = _('Select the episodes you want to download:')
3255 if self.new_episodes_window is not None:
3256 self.new_episodes_window.main_window.destroy()
3257 self.new_episodes_window = None
3259 def download_episodes_callback(episodes):
3260 self.new_episodes_window = None
3261 self.download_episode_list(episodes)
3263 if selected is None:
3264 # Select all by default
3265 selected = [True]*len(episodes)
3267 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
3268 title=_('New episodes available'), \
3269 instructions=instructions, \
3270 episodes=episodes, \
3271 columns=columns, \
3272 selected=selected, \
3273 stock_ok_button = 'gpodder-download', \
3274 callback=download_episodes_callback, \
3275 remove_callback=lambda e: e.mark_old(), \
3276 remove_action=_('Mark as old'), \
3277 remove_finished=self.episode_new_status_changed, \
3278 _config=self.config, \
3279 show_notification=show_notification, \
3280 show_episode_shownotes=self.show_episode_shownotes)
3282 def on_itemDownloadAllNew_activate(self, widget, *args):
3283 if not self.offer_new_episodes():
3284 self.show_message(_('Please check for new episodes later.'), \
3285 _('No new episodes available'), widget=self.btnUpdateFeeds)
3287 def get_new_episodes(self, channels=None):
3288 if channels is None:
3289 channels = self.channels
3290 episodes = []
3291 for channel in channels:
3292 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
3293 episodes.append(episode)
3295 return episodes
3297 def commit_changes_to_database(self):
3298 """This will be called after the sync process is finished"""
3299 self.db.commit()
3301 def on_itemShowAllEpisodes_activate(self, widget):
3302 self.config.podcast_list_view_all = widget.get_active()
3304 def on_itemShowToolbar_activate(self, widget):
3305 self.config.show_toolbar = self.itemShowToolbar.get_active()
3307 def on_itemShowDescription_activate(self, widget):
3308 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3310 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3311 self.config.podcast_list_hide_boring = toggleaction.get_active()
3312 if self.config.podcast_list_hide_boring:
3313 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3314 else:
3315 self.podcast_list_model.set_view_mode(-1)
3317 def on_item_view_podcasts_changed(self, radioaction, current):
3318 # Only on Fremantle
3319 if current == self.item_view_podcasts_all:
3320 self.podcast_list_model.set_view_mode(-1)
3321 elif current == self.item_view_podcasts_downloaded:
3322 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3323 elif current == self.item_view_podcasts_unplayed:
3324 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3326 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3328 def on_item_view_episodes_changed(self, radioaction, current):
3329 if current == self.item_view_episodes_all:
3330 self.config.episode_list_view_mode = EpisodeListModel.VIEW_ALL
3331 elif current == self.item_view_episodes_undeleted:
3332 self.config.episode_list_view_mode = EpisodeListModel.VIEW_UNDELETED
3333 elif current == self.item_view_episodes_downloaded:
3334 self.config.episode_list_view_mode = EpisodeListModel.VIEW_DOWNLOADED
3335 elif current == self.item_view_episodes_unplayed:
3336 self.config.episode_list_view_mode = EpisodeListModel.VIEW_UNPLAYED
3338 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
3340 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3341 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3343 def properties_closed(self):
3344 self.preferences_dialog = None
3346 def on_itemPreferences_activate(self, widget, *args):
3347 self.preferences_dialog = gPodderPreferences(self.main_window, \
3348 _config=self.config, \
3349 callback_finished=self.properties_closed, \
3350 user_apps_reader=self.user_apps_reader, \
3351 parent_window=self.main_window, \
3352 mygpo_client=self.mygpo_client, \
3353 on_send_full_subscriptions=self.on_send_full_subscriptions, \
3354 on_itemExportChannels_activate=self.on_itemExportChannels_activate)
3356 # Initial message to relayout window (in case it's opened in portrait mode
3357 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3359 def on_goto_mygpo(self, widget):
3360 self.mygpo_client.open_website()
3362 def on_download_subscriptions_from_mygpo(self, action=None):
3363 title = _('Login to gpodder.net')
3364 message = _('Please login to download your subscriptions.')
3365 success, (username, password) = self.show_login_dialog(title, message, \
3366 self.config.mygpo_username, self.config.mygpo_password)
3367 if not success:
3368 return
3370 self.config.mygpo_username = username
3371 self.config.mygpo_password = password
3373 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3374 custom_title=_('Subscriptions on gpodder.net'), \
3375 add_urls_callback=self.add_podcast_list, \
3376 hide_url_entry=True)
3378 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
3379 # we do not have to hardcode the URL here
3380 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo_username
3381 url = util.url_add_authentication(OPML_URL, \
3382 self.config.mygpo_username, \
3383 self.config.mygpo_password)
3384 dir.download_opml_file(url)
3386 def on_itemAddChannel_activate(self, widget=None):
3387 gPodderAddPodcast(self.gPodder, \
3388 add_urls_callback=self.add_podcast_list)
3390 def on_itemEditChannel_activate(self, widget, *args):
3391 if self.active_channel is None:
3392 title = _('No podcast selected')
3393 message = _('Please select a podcast in the podcasts list to edit.')
3394 self.show_message( message, title, widget=self.treeChannels)
3395 return
3397 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3398 gPodderChannel(self.main_window, \
3399 channel=self.active_channel, \
3400 callback_closed=callback_closed, \
3401 cover_downloader=self.cover_downloader)
3403 def on_itemMassUnsubscribe_activate(self, item=None):
3404 columns = (
3405 ('title', None, None, _('Podcast')),
3408 # We're abusing the Episode Selector for selecting Podcasts here,
3409 # but it works and looks good, so why not? -- thp
3410 gPodderEpisodeSelector(self.main_window, \
3411 title=_('Remove podcasts'), \
3412 instructions=_('Select the podcast you want to remove.'), \
3413 episodes=self.channels, \
3414 columns=columns, \
3415 size_attribute=None, \
3416 stock_ok_button=_('Remove'), \
3417 callback=self.remove_podcast_list, \
3418 _config=self.config)
3420 def remove_podcast_list(self, channels, confirm=True):
3421 if not channels:
3422 log('No podcasts selected for deletion', sender=self)
3423 return
3425 if len(channels) == 1:
3426 title = _('Removing podcast')
3427 info = _('Please wait while the podcast is removed')
3428 message = _('Do you really want to remove this podcast and its episodes?')
3429 else:
3430 title = _('Removing podcasts')
3431 info = _('Please wait while the podcasts are removed')
3432 message = _('Do you really want to remove the selected podcasts and their episodes?')
3434 if confirm and not self.show_confirmation(message, title):
3435 return
3437 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3439 def finish_deletion(select_url):
3440 # Upload subscription list changes to the web service
3441 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3443 # Re-load the channels and select the desired new channel
3444 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3445 progress.on_finished()
3447 def thread_proc():
3448 select_url = None
3450 for idx, channel in enumerate(channels):
3451 # Update the UI for correct status messages
3452 progress.on_progress(float(idx)/float(len(channels)))
3453 progress.on_message(channel.title)
3455 # Delete downloaded episodes
3456 channel.remove_downloaded()
3458 # cancel any active downloads from this channel
3459 for episode in channel.get_all_episodes():
3460 util.idle_add(self.download_status_model.cancel_by_url,
3461 episode.url)
3463 if len(channels) == 1:
3464 # get the URL of the podcast we want to select next
3465 if channel in self.channels:
3466 position = self.channels.index(channel)
3467 else:
3468 position = -1
3470 if position == len(self.channels)-1:
3471 # this is the last podcast, so select the URL
3472 # of the item before this one (i.e. the "new last")
3473 select_url = self.channels[position-1].url
3474 else:
3475 # there is a podcast after the deleted one, so
3476 # we simply select the one that comes after it
3477 select_url = self.channels[position+1].url
3479 # Remove the channel and clean the database entries
3480 channel.delete()
3481 self.channels.remove(channel)
3483 # Clean up downloads and download directories
3484 self.clean_up_downloads()
3486 self.channel_list_changed = True
3488 # The remaining stuff is to be done in the GTK main thread
3489 util.idle_add(finish_deletion, select_url)
3491 threading.Thread(target=thread_proc).start()
3493 def on_itemRemoveChannel_activate(self, widget, *args):
3494 if self.active_channel is None:
3495 title = _('No podcast selected')
3496 message = _('Please select a podcast in the podcasts list to remove.')
3497 self.show_message( message, title, widget=self.treeChannels)
3498 return
3500 self.remove_podcast_list([self.active_channel])
3502 def get_opml_filter(self):
3503 filter = gtk.FileFilter()
3504 filter.add_pattern('*.opml')
3505 filter.add_pattern('*.xml')
3506 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3507 return filter
3509 def on_item_import_from_file_activate(self, widget, filename=None):
3510 if filename is None:
3511 if gpodder.ui.desktop or gpodder.ui.fremantle:
3512 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), \
3513 parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3514 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3515 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3516 dlg.set_filter(self.get_opml_filter())
3517 response = dlg.run()
3518 filename = None
3519 if response == gtk.RESPONSE_OK:
3520 filename = dlg.get_filename()
3521 dlg.destroy()
3523 if filename is not None:
3524 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3525 custom_title=_('Import podcasts from OPML file'), \
3526 add_urls_callback=self.add_podcast_list, \
3527 hide_url_entry=True)
3528 dir.download_opml_file(filename)
3530 def on_itemExportChannels_activate(self, widget, *args):
3531 if not self.channels:
3532 title = _('Nothing to export')
3533 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3534 self.show_message(message, title, widget=self.treeChannels)
3535 return
3537 if gpodder.ui.desktop:
3538 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3539 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3540 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3541 elif gpodder.ui.fremantle:
3542 dlg = gobject.new(hildon.FileChooserDialog, \
3543 action=gtk.FILE_CHOOSER_ACTION_SAVE)
3544 dlg.set_title(_('Export to OPML'))
3545 dlg.set_filter(self.get_opml_filter())
3546 response = dlg.run()
3547 if response == gtk.RESPONSE_OK:
3548 filename = dlg.get_filename()
3549 dlg.destroy()
3550 exporter = opml.Exporter( filename)
3551 if filename is not None and exporter.write(self.channels):
3552 count = len(self.channels)
3553 title = N_('%(count)d subscription exported', '%(count)d subscriptions exported', count) % {'count':count}
3554 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3555 else:
3556 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3557 else:
3558 dlg.destroy()
3560 def on_itemImportChannels_activate(self, widget, *args):
3561 if gpodder.ui.fremantle:
3562 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3563 self.config.toplist_opml, \
3564 self.config.example_opml, \
3565 self.add_podcast_list, \
3566 self.on_itemAddChannel_activate, \
3567 self.on_download_subscriptions_from_mygpo, \
3568 self.show_text_edit_dialog)
3569 else:
3570 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3571 add_urls_callback=self.add_podcast_list)
3572 util.idle_add(dir.download_opml_file, self.config.example_opml)
3574 def on_homepage_activate(self, widget, *args):
3575 util.open_website(gpodder.__url__)
3577 def on_wiki_activate(self, widget, *args):
3578 util.open_website('http://gpodder.org/wiki/User_Manual')
3580 def on_bug_tracker_activate(self, widget, *args):
3581 if gpodder.ui.fremantle:
3582 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3583 else:
3584 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder&component=Application&version=%s' % gpodder.__version__)
3586 def on_item_support_activate(self, widget):
3587 util.open_website('http://gpodder.org/donate')
3589 def on_itemAbout_activate(self, widget, *args):
3590 if gpodder.ui.fremantle:
3591 from gpodder.gtkui.frmntl.about import HeAboutDialog
3592 HeAboutDialog.present(self.main_window,
3593 'gPodder',
3594 'gpodder',
3595 gpodder.__version__,
3596 _('A podcast client with focus on usability'),
3597 gpodder.__copyright__,
3598 gpodder.__url__,
3599 'http://bugs.maemo.org/enter_bug.cgi?product=gPodder',
3600 'http://gpodder.org/donate')
3601 return
3603 dlg = gtk.Dialog(_('About gPodder'), self.main_window, \
3604 gtk.DIALOG_MODAL)
3605 dlg.set_resizable(False)
3607 bg = gtk.HBox(spacing=10)
3608 bg.pack_start(gtk.image_new_from_file(gpodder.icon_file), expand=False)
3609 vb = gtk.VBox()
3610 label = gtk.Label()
3611 label.set_alignment(0, 1)
3612 label.set_markup('<b><big>gPodder</big> %s</b>' % gpodder.__version__)
3613 vb.pack_start(label)
3614 label = gtk.Label()
3615 label.set_alignment(0, 0)
3616 label.set_markup('<small>%s</small>' % \
3617 cgi.escape(_('A podcast client with focus on usability')))
3618 vb.pack_start(label)
3619 label = gtk.Label()
3620 label.set_alignment(0, 0)
3621 label.set_markup('<small><a href="%s">%s</a></small>' % \
3622 ((cgi.escape(gpodder.__url__),)*2))
3623 vb.pack_start(label)
3624 bg.pack_start(vb)
3626 out = gtk.VBox(spacing=10)
3627 out.set_border_width(12)
3628 out.pack_start(bg, expand=False)
3629 out.pack_start(gtk.HSeparator())
3630 out.pack_start(gtk.Label(gpodder.__copyright__))
3632 button_box = gtk.HButtonBox()
3633 button = gtk.Button(_('Donate / Wishlist'))
3634 button.connect('clicked', self.on_item_support_activate)
3635 button_box.pack_start(button)
3636 button = gtk.Button(_('Report a problem'))
3637 button.connect('clicked', self.on_bug_tracker_activate)
3638 button_box.pack_start(button)
3639 out.pack_start(button_box, expand=False)
3641 credits = gtk.TextView()
3642 credits.set_left_margin(5)
3643 credits.set_right_margin(5)
3644 credits.set_pixels_above_lines(5)
3645 credits.set_pixels_below_lines(5)
3646 credits.set_editable(False)
3647 credits.set_cursor_visible(False)
3648 sw = gtk.ScrolledWindow()
3649 sw.set_shadow_type(gtk.SHADOW_IN)
3650 sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
3651 sw.add(credits)
3652 credits.set_size_request(-1, 160)
3653 out.pack_start(sw, expand=True, fill=True)
3655 dlg.vbox.pack_start(out, expand=False)
3656 dlg.connect('response', lambda dlg, response: dlg.destroy())
3658 dlg.vbox.show_all()
3660 if os.path.exists(gpodder.credits_file):
3661 credits_txt = open(gpodder.credits_file).read().strip().split('\n')
3662 translator_credits = _('translator-credits')
3663 if translator_credits != 'translator-credits':
3664 app_authors = [_('Translation by:'), translator_credits, '']
3665 else:
3666 app_authors = []
3668 app_authors += [_('Thanks to:')]
3669 app_authors += credits_txt
3671 buffer = gtk.TextBuffer()
3672 buffer.set_text('\n'.join(app_authors))
3673 credits.set_buffer(buffer)
3674 else:
3675 sw.hide()
3677 credits.grab_focus()
3678 dlg.action_area.hide()
3679 dlg.run()
3681 def on_wNotebook_switch_page(self, notebook, page, page_num):
3682 if page_num == 0:
3683 self.play_or_download()
3684 # The message area in the downloads tab should be hidden
3685 # when the user switches away from the downloads tab
3686 if self.message_area is not None:
3687 self.message_area.hide()
3688 self.message_area = None
3689 elif gpodder.ui.desktop:
3690 self.toolDownload.set_sensitive(False)
3691 self.toolPlay.set_sensitive(False)
3692 self.toolCancel.set_sensitive(False)
3694 def on_treeChannels_row_activated(self, widget, path, *args):
3695 # double-click action of the podcast list or enter
3696 self.treeChannels.set_cursor(path)
3698 def on_treeChannels_cursor_changed(self, widget, *args):
3699 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3701 if model is not None and iter is not None:
3702 old_active_channel = self.active_channel
3703 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3705 if self.active_channel == old_active_channel:
3706 return
3708 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3709 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3710 self.itemEditChannel.set_visible(False)
3711 self.itemRemoveChannel.set_visible(False)
3712 else:
3713 self.itemEditChannel.set_visible(True)
3714 self.itemRemoveChannel.set_visible(True)
3715 else:
3716 self.active_channel = None
3717 self.itemEditChannel.set_visible(False)
3718 self.itemRemoveChannel.set_visible(False)
3720 self.update_episode_list_model()
3722 def on_btnEditChannel_clicked(self, widget, *args):
3723 self.on_itemEditChannel_activate( widget, args)
3725 def get_podcast_urls_from_selected_episodes(self):
3726 """Get a set of podcast URLs based on the selected episodes"""
3727 return set(episode.channel.url for episode in \
3728 self.get_selected_episodes())
3730 def get_selected_episodes(self):
3731 """Get a list of selected episodes from treeAvailable"""
3732 selection = self.treeAvailable.get_selection()
3733 model, paths = selection.get_selected_rows()
3735 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3736 return episodes
3738 def on_playback_selected_episodes(self, widget):
3739 self.playback_episodes(self.get_selected_episodes())
3741 def on_shownotes_selected_episodes(self, widget):
3742 episodes = self.get_selected_episodes()
3743 if episodes:
3744 episode = episodes.pop(0)
3745 self.show_episode_shownotes(episode)
3746 else:
3747 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3749 def on_download_selected_episodes(self, widget):
3750 episodes = self.get_selected_episodes()
3751 self.download_episode_list(episodes)
3752 self.update_episode_list_icons([episode.url for episode in episodes])
3753 self.play_or_download()
3755 def on_treeAvailable_row_activated(self, widget, path, view_column):
3756 """Double-click/enter action handler for treeAvailable"""
3757 self.on_shownotes_selected_episodes(widget)
3759 def show_episode_shownotes(self, episode):
3760 if self.episode_shownotes_window is None:
3761 log('First-time use of episode window --- creating', sender=self)
3762 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3763 _download_episode_list=self.download_episode_list, \
3764 _playback_episodes=self.playback_episodes, \
3765 _delete_episode_list=self.delete_episode_list, \
3766 _episode_list_status_changed=self.episode_list_status_changed, \
3767 _cancel_task_list=self.cancel_task_list, \
3768 _episode_is_downloading=self.episode_is_downloading, \
3769 _streaming_possible=self.streaming_possible())
3770 self.episode_shownotes_window.show(episode)
3771 if self.episode_is_downloading(episode):
3772 self.update_downloads_list()
3774 def restart_auto_update_timer(self):
3775 if self._auto_update_timer_source_id is not None:
3776 log('Removing existing auto update timer.', sender=self)
3777 gobject.source_remove(self._auto_update_timer_source_id)
3778 self._auto_update_timer_source_id = None
3780 if self.config.auto_update_feeds and \
3781 self.config.auto_update_frequency:
3782 interval = 60*1000*self.config.auto_update_frequency
3783 log('Setting up auto update timer with interval %d.', \
3784 self.config.auto_update_frequency, sender=self)
3785 self._auto_update_timer_source_id = gobject.timeout_add(\
3786 interval, self._on_auto_update_timer)
3788 def _on_auto_update_timer(self):
3789 log('Auto update timer fired.', sender=self)
3790 self.update_feed_cache(force_update=True)
3792 # Ask web service for sub changes (if enabled)
3793 self.mygpo_client.flush()
3795 return True
3797 def on_treeDownloads_row_activated(self, widget, *args):
3798 # Use the standard way of working on the treeview
3799 selection = self.treeDownloads.get_selection()
3800 (model, paths) = selection.get_selected_rows()
3801 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3803 for tree_row_reference, task in selected_tasks:
3804 if task.status in (task.DOWNLOADING, task.QUEUED):
3805 task.status = task.PAUSED
3806 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3807 self.download_queue_manager.add_task(task)
3808 self.enable_download_list_update()
3809 elif task.status == task.DONE:
3810 model.remove(model.get_iter(tree_row_reference.get_path()))
3812 self.play_or_download()
3814 # Update the tab title and downloads list
3815 self.update_downloads_list()
3817 def on_item_cancel_download_activate(self, widget):
3818 if self.wNotebook.get_current_page() == 0:
3819 selection = self.treeAvailable.get_selection()
3820 (model, paths) = selection.get_selected_rows()
3821 urls = [model.get_value(model.get_iter(path), \
3822 self.episode_list_model.C_URL) for path in paths]
3823 selected_tasks = [task for task in self.download_tasks_seen \
3824 if task.url in urls]
3825 else:
3826 selection = self.treeDownloads.get_selection()
3827 (model, paths) = selection.get_selected_rows()
3828 selected_tasks = [model.get_value(model.get_iter(path), \
3829 self.download_status_model.C_TASK) for path in paths]
3830 self.cancel_task_list(selected_tasks)
3832 def on_btnCancelAll_clicked(self, widget, *args):
3833 self.cancel_task_list(self.download_tasks_seen)
3835 def on_btnDownloadedDelete_clicked(self, widget, *args):
3836 episodes = self.get_selected_episodes()
3837 if len(episodes) == 1:
3838 self.delete_episode_list(episodes, skip_locked=False)
3839 else:
3840 self.delete_episode_list(episodes)
3842 def on_key_press(self, widget, event):
3843 # Allow tab switching with Ctrl + PgUp/PgDown
3844 if event.state & gtk.gdk.CONTROL_MASK:
3845 if event.keyval == gtk.keysyms.Page_Up:
3846 self.wNotebook.prev_page()
3847 return True
3848 elif event.keyval == gtk.keysyms.Page_Down:
3849 self.wNotebook.next_page()
3850 return True
3852 return False
3854 def uniconify_main_window(self):
3855 if self.is_iconified():
3856 # We need to hide and then show the window in WMs like Metacity
3857 # or KWin4 to move the window to the active workspace
3858 # (see http://gpodder.org/bug/1125)
3859 self.gPodder.hide()
3860 self.gPodder.show()
3861 self.gPodder.present()
3863 def iconify_main_window(self):
3864 if not self.is_iconified():
3865 self.gPodder.iconify()
3867 @dbus.service.method(gpodder.dbus_interface)
3868 def show_gui_window(self):
3869 parent = self.get_dialog_parent()
3870 parent.present()
3872 @dbus.service.method(gpodder.dbus_interface)
3873 def subscribe_to_url(self, url):
3874 gPodderAddPodcast(self.gPodder,
3875 add_urls_callback=self.add_podcast_list,
3876 preset_url=url)
3878 @dbus.service.method(gpodder.dbus_interface)
3879 def mark_episode_played(self, filename):
3880 if filename is None:
3881 return False
3883 for channel in self.channels:
3884 for episode in channel.get_all_episodes():
3885 fn = episode.local_filename(create=False, check_only=True)
3886 if fn == filename:
3887 episode.mark(is_played=True)
3888 self.db.commit()
3889 self.update_episode_list_icons([episode.url])
3890 self.update_podcast_list_model([episode.channel.url])
3891 return True
3893 return False
3896 def main(options=None):
3897 gobject.threads_init()
3898 gobject.set_application_name('gPodder')
3900 if gpodder.ui.fremantle:
3901 # Add custom icons for the new Maemo 5 look :)
3902 for id in ('audio', 'video', 'download', 'audio-locked', 'video-locked'):
3903 filename = os.path.join(gpodder.images_folder, '%s.png' % id)
3904 pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
3905 gtk.icon_theme_add_builtin_icon('gpodder-%s' % id, 40, pixbuf)
3907 gtk.window_set_default_icon_name('gpodder')
3908 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
3910 try:
3911 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
3912 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
3914 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
3915 except dbus.exceptions.DBusException, dbe:
3916 log('Warning: Cannot get "on the bus".', traceback=True)
3917 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
3918 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
3919 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
3920 dlg.set_title('gPodder')
3921 dlg.run()
3922 dlg.destroy()
3923 sys.exit(0)
3925 gp = gPodder(bus_name, core.Core(UIConfig))
3927 # Handle options
3928 if options.subscribe:
3929 util.idle_add(gp.subscribe_to_url, options.subscribe)
3931 # mac OS X stuff :
3932 # handle "subscribe to podcast" events from firefox
3933 if platform.system() == 'Darwin':
3934 from gpodder import gpodderosx
3935 gpodderosx.register_handlers(gp)
3936 # end mac OS X stuff
3938 gp.run()