Optionally remove old episodes from iPod
[gpodder.git] / src / gpodder / gui.py
blobe8161762abcad565af3b0c6e3eb8546ab53ed02d
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 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 gtk
22 import gtk.gdk
23 import gobject
24 import pango
25 import sys
26 import shutil
27 import subprocess
28 import glob
29 import time
30 import urllib
31 import urllib2
32 import datetime
34 from xml.sax import saxutils
36 from threading import Event
37 from threading import Thread
38 from threading import Semaphore
39 from string import strip
41 import gpodder
42 from gpodder import util
43 from gpodder import opml
44 from gpodder import services
45 from gpodder import sync
46 from gpodder import download
47 from gpodder import SimpleGladeApp
48 from gpodder.liblogger import log
49 from gpodder.dbsqlite import db
50 from gpodder import resolver
52 try:
53 from gpodder import trayicon
54 have_trayicon = True
55 except Exception, exc:
56 log('Warning: Could not import gpodder.trayicon.', traceback=True)
57 log('Warning: This probably means your PyGTK installation is too old!')
58 have_trayicon = False
60 from libpodcasts import podcastChannel
61 from libpodcasts import LocalDBReader
62 from libpodcasts import podcastItem
63 from libpodcasts import channels_to_model
64 from libpodcasts import update_channel_model_by_iter
65 from libpodcasts import load_channels
66 from libpodcasts import update_channels
67 from libpodcasts import save_channels
68 from libpodcasts import can_restore_from_opml
69 from libpodcasts import HTTPAuthError
71 from gpodder.libgpodder import gl
73 from libplayers import UserAppsReader
75 from libtagupdate import tagging_supported
77 if gpodder.interface == gpodder.GUI:
78 WEB_BROWSER_ICON = 'web-browser'
79 elif gpodder.interface == gpodder.MAEMO:
80 import hildon
81 WEB_BROWSER_ICON = 'qgn_toolb_browser_web'
83 app_name = "gpodder"
84 app_version = "unknown" # will be set in main() call
85 app_authors = [
86 _('Current maintainer:'), 'Thomas Perl <thpinfo.com>',
87 '',
88 _('Patches, bug reports and donations by:'), 'Adrien Beaucreux',
89 'Alain Tauch', 'Alistair Sutton', 'Anders Kvist', 'Andy Busch',
90 'Antonio Roversi', 'Aravind Seshadri', 'Atte André Jensen', 'audioworld',
91 'Bastian Staeck', 'Bernd Schlapsi', 'Bill Barnard', 'Bill Peters', 'Bjørn Rasmussen', 'Camille Moncelier',
92 'Carlos Moffat', 'Chris', 'Chris Arnold', 'Clark Burbidge', 'Daniel Ramos',
93 'David Spreen', 'Doug Hellmann', 'FFranci72', 'Florian Richter', 'Frank Harper',
94 'Franz Seidl', 'FriedBunny', 'Gerrit Sangel', 'Götz Waschk',
95 'Haim Roitgrund', 'Heinz Erhard', 'Hex', 'Holger Bauer', 'Holger Leskien', 'Jens Thiele',
96 'Jérôme Chabod', 'Jerry Moss',
97 'Jessica Henline', 'João Trindade', 'Joel Calado', 'John Ferguson',
98 'José Luis Fustel', 'Joseph Bleau', 'Julio Acuña', 'Junio C Hamano',
99 'Jürgen Schinker', 'Justin Forest',
100 'Konstantin Ryabitsev', 'Leonid Ponomarev', 'Marcos Hernández', 'Mark Alford', 'Markus Golser', 'Michael Salim',
101 'Mika Leppinen', 'Mike Coulson', 'Mykola Nikishov', 'narf at inode.at',
102 'Nick L.', 'Nicolas Quienot', 'Ondrej Vesely',
103 'Ortwin Forster', 'Paul Elliot', 'Paul Rudkin',
104 'Pavel Mlčoch', 'Peter Hoffmann', 'PhilF', 'Philippe Gouaillier', 'Pieter de Decker',
105 'Preben Randhol', 'Rafael Proença', 'red26wings', 'Richard Voigt',
106 'Robert Young', 'Roel Groeneveld',
107 'Scott Wegner', 'Sebastian Krause', 'Seth Remington', 'Shane Donohoe', 'Silvio Sisto', 'SPGoetze',
108 'Stefan Lohmaier', 'Stephan Buys', 'Stylianos Papanastasiou', 'Teo Ramirez',
109 'Thomas Matthijs', 'Thomas Mills Hinkle', 'Thomas Nilsson',
110 'Tim Michelsen', 'Tim Preetz', 'Todd Zullinger', 'Tomas Matheson', 'VladDrac',
111 'Vladimir Zemlyakov', 'Wilfred van Rooijen',
113 'List may be incomplete - please contact me.'
115 app_copyright = '© 2005-2008 Thomas Perl and the gPodder Team'
116 app_website = 'http://www.gpodder.org/'
118 # these will be filled with pathnames in bin/gpodder
119 glade_dir = [ 'share', 'gpodder' ]
120 icon_dir = [ 'share', 'pixmaps', 'gpodder.png' ]
121 scalable_dir = [ 'share', 'icons', 'hicolor', 'scalable', 'apps', 'gpodder.svg' ]
124 class GladeWidget(SimpleGladeApp.SimpleGladeApp):
125 gpodder_main_window = None
126 finger_friendly_widgets = []
128 def __init__( self, **kwargs):
129 path = os.path.join( glade_dir, '%s.glade' % app_name)
130 root = self.__class__.__name__
131 domain = app_name
133 SimpleGladeApp.SimpleGladeApp.__init__( self, path, root, domain, **kwargs)
135 # Set widgets to finger-friendly mode if on Maemo
136 for widget_name in self.finger_friendly_widgets:
137 self.set_finger_friendly(getattr(self, widget_name))
139 if root == 'gPodder':
140 GladeWidget.gpodder_main_window = self.gPodder
141 else:
142 # If we have a child window, set it transient for our main window
143 getattr( self, root).set_transient_for( GladeWidget.gpodder_main_window)
145 if gpodder.interface == gpodder.GUI:
146 if hasattr( self, 'center_on_widget'):
147 ( x, y ) = self.gpodder_main_window.get_position()
148 a = self.center_on_widget.allocation
149 ( x, y ) = ( x + a.x, y + a.y )
150 ( w, h ) = ( a.width, a.height )
151 ( pw, ph ) = getattr( self, root).get_size()
152 getattr( self, root).move( x + w/2 - pw/2, y + h/2 - ph/2)
153 else:
154 getattr( self, root).set_position( gtk.WIN_POS_CENTER_ON_PARENT)
156 def notification(self, message, title=None):
157 util.idle_add(self.show_message, message, title)
159 def show_message( self, message, title = None):
160 if hasattr(self, 'tray_icon') and hasattr(self, 'minimized') and self.tray_icon and self.minimized:
161 if title is None:
162 title = 'gPodder'
163 self.tray_icon.send_notification(message, title)
164 return
166 if gpodder.interface == gpodder.GUI:
167 dlg = gtk.MessageDialog(GladeWidget.gpodder_main_window, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_OK)
168 if title:
169 dlg.set_title(str(title))
170 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
171 else:
172 dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (message))
173 elif gpodder.interface == gpodder.MAEMO:
174 dlg = hildon.Note('information', (GladeWidget.gpodder_main_window, message))
176 dlg.run()
177 dlg.destroy()
179 def set_finger_friendly(self, widget):
181 If we are on Maemo, we carry out the necessary
182 operations to turn a widget into a finger-friendly
183 one, depending on which type of widget it is (i.e.
184 buttons will have more padding, TreeViews a thick
185 scrollbar, etc..)
187 if gpodder.interface == gpodder.MAEMO:
188 if isinstance(widget, gtk.Misc):
189 widget.set_padding(0, 5)
190 elif isinstance(widget, gtk.Button):
191 for child in widget.get_children():
192 if isinstance(child, gtk.Alignment):
193 child.set_padding(10, 10, 5, 5)
194 else:
195 child.set_padding(10, 10)
196 elif isinstance(widget, gtk.TreeView) or isinstance(widget, gtk.TextView):
197 parent = widget.get_parent()
198 if isinstance(parent, gtk.ScrolledWindow):
199 hildon.hildon_helper_set_thumb_scrollbar(parent, True)
200 elif isinstance(widget, gtk.MenuItem):
201 for child in widget.get_children():
202 self.set_finger_friendly(child)
203 else:
204 log('Cannot set widget finger-friendly: %s', widget, sender=self)
206 return widget
208 def show_confirmation( self, message, title = None):
209 if gpodder.interface == gpodder.GUI:
210 affirmative = gtk.RESPONSE_YES
211 dlg = gtk.MessageDialog(GladeWidget.gpodder_main_window, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_YES_NO)
212 if title:
213 dlg.set_title(str(title))
214 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
215 else:
216 dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (message))
217 elif gpodder.interface == gpodder.MAEMO:
218 affirmative = gtk.RESPONSE_OK
219 dlg = hildon.Note('confirmation', (GladeWidget.gpodder_main_window, message))
221 response = dlg.run()
222 dlg.destroy()
224 return response == affirmative
226 def UsernamePasswordDialog( self, title, message ):
227 """ An authentication dialog based on
228 http://ardoris.wordpress.com/2008/07/05/pygtk-text-entry-dialog/ """
230 dialog = gtk.MessageDialog(
231 GladeWidget.gpodder_main_window,
232 gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
233 gtk.MESSAGE_QUESTION,
234 gtk.BUTTONS_OK_CANCEL )
236 dialog.set_markup('<span weight="bold" size="larger">' + title + '</span>')
237 dialog.set_title(title)
238 dialog.format_secondary_markup(message)
240 username_entry = gtk.Entry()
241 username_entry.set_width_chars(25)
242 password_entry = gtk.Entry()
243 password_entry.set_width_chars(25)
244 password_entry.set_visibility(False)
246 username_hbox = gtk.HBox()
247 username_label = gtk.Label()
248 username_label.set_markup('<b>' + _('Username:') + '</b>')
249 username_hbox.pack_start(username_label, False, 5, 5)
250 username_hbox.pack_end(username_entry, False)
252 password_hbox = gtk.HBox()
253 password_label = gtk.Label()
254 password_label.set_markup('<b>' + _('Password:') + '</b>')
255 password_hbox.pack_start(password_label, False, 5, 5)
256 password_hbox.pack_end(password_entry, False)
258 vbox = gtk.VBox(spacing=5)
259 vbox.pack_start(username_hbox)
260 vbox.pack_start(password_hbox)
262 dialog.vbox.pack_end(vbox, True, True, 0)
263 dialog.show_all()
264 response = dialog.run()
266 password_entry.set_visibility(True)
267 dialog.destroy()
269 return response == gtk.RESPONSE_OK, ( username_entry.get_text(), password_entry.get_text() )
271 def show_copy_dialog( self, src_filename, dst_filename = None, dst_directory = None, title = _('Select destination')):
272 if dst_filename is None:
273 dst_filename = src_filename
275 if dst_directory is None:
276 dst_directory = os.path.expanduser( '~')
278 ( base, extension ) = os.path.splitext( src_filename)
280 if not dst_filename.endswith( extension):
281 dst_filename += extension
283 if gpodder.interface == gpodder.GUI:
284 dlg = gtk.FileChooserDialog(title=title, parent=GladeWidget.gpodder_main_window, action=gtk.FILE_CHOOSER_ACTION_SAVE)
285 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
286 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
287 elif gpodder.interface == gpodder.MAEMO:
288 dlg = hildon.FileChooserDialog(GladeWidget.gpodder_main_window, gtk.FILE_CHOOSER_ACTION_SAVE)
290 dlg.set_do_overwrite_confirmation( True)
291 dlg.set_current_name( os.path.basename( dst_filename))
292 dlg.set_current_folder( dst_directory)
294 result = False
295 folder = dst_directory
296 if dlg.run() == gtk.RESPONSE_OK:
297 result = True
298 dst_filename = dlg.get_filename()
299 folder = dlg.get_current_folder()
300 if not dst_filename.endswith( extension):
301 dst_filename += extension
303 log( 'Copying %s => %s', src_filename, dst_filename, sender = self)
305 try:
306 shutil.copyfile( src_filename, dst_filename)
307 except:
308 log( 'Error copying file.', sender = self, traceback = True)
310 dlg.destroy()
311 return (result, folder)
314 class gPodder(GladeWidget):
315 finger_friendly_widgets = ['btnUpdateFeeds', 'btnCancelFeedUpdate', 'treeAvailable', 'label2', 'labelDownloads']
316 ENTER_URL_TEXT = _('Enter podcast URL...')
318 def new(self):
319 if gpodder.interface == gpodder.MAEMO:
320 # Maemo-specific changes to the UI
321 global scalable_dir
322 scalable_dir = scalable_dir.replace('.svg', '.png')
324 self.app = hildon.Program()
325 gtk.set_application_name('gPodder')
326 self.window = hildon.Window()
327 self.window.connect('delete-event', self.on_gPodder_delete_event)
328 self.window.connect('window-state-event', self.window_state_event)
330 self.itemUpdateChannel.show()
331 self.UpdateChannelSeparator.show()
333 # Give toolbar to the hildon window
334 self.toolbar.parent.remove(self.toolbar)
335 self.toolbar.set_style(gtk.TOOLBAR_ICONS)
336 self.window.add_toolbar(self.toolbar)
338 self.app.add_window(self.window)
339 self.vMain.reparent(self.window)
340 self.gPodder = self.window
342 # Reparent the main menu
343 menu = gtk.Menu()
344 for child in self.mainMenu.get_children():
345 child.reparent(menu)
346 self.itemQuit.reparent(menu)
347 self.window.set_menu(menu)
349 self.mainMenu.destroy()
350 self.window.show()
352 # do some widget hiding
353 self.toolbar.remove(self.toolTransfer)
354 self.itemTransferSelected.hide_all()
355 self.item_email_subscriptions.hide_all()
357 # Feed cache update button
358 self.label120.set_text(_('Update'))
360 # get screen real estate
361 self.hboxContainer.set_border_width(0)
363 self.gPodder.connect('key-press-event', self.on_key_press)
364 self.treeChannels.connect('size-allocate', self.on_tree_channels_resize)
366 if gl.config.show_url_entry_in_podcast_list:
367 self.hboxAddChannel.show()
369 if not gl.config.show_toolbar:
370 self.toolbar.hide_all()
372 gl.config.add_observer(self.on_config_changed)
373 self.default_entry_text_color = self.entryAddChannel.get_style().text[gtk.STATE_NORMAL]
374 self.entryAddChannel.connect('focus-in-event', self.entry_add_channel_focus)
375 self.entryAddChannel.connect('focus-out-event', self.entry_add_channel_unfocus)
376 self.entry_add_channel_unfocus(self.entryAddChannel, None)
378 self.uar = None
379 self.tray_icon = None
381 self.fullscreen = False
382 self.minimized = False
383 self.gPodder.connect('window-state-event', self.window_state_event)
385 self.already_notified_new_episodes = []
386 self.show_hide_tray_icon()
387 self.no_episode_selected.set_sensitive(False)
389 self.itemShowToolbar.set_active(gl.config.show_toolbar)
390 self.itemShowDescription.set_active(gl.config.episode_list_descriptions)
392 gl.config.connect_gtk_window( self.gPodder)
393 gl.config.connect_gtk_paned( 'paned_position', self.channelPaned)
395 gl.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
396 gl.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
397 gl.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
398 gl.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
400 # Make sure we free/close the download queue when we
401 # update the "max downloads" spin button
402 changed_cb = lambda spinbutton: services.download_status_manager.update_max_downloads()
403 self.spinMaxDownloads.connect('value-changed', changed_cb)
405 self.default_title = None
406 if app_version.rfind('git') != -1:
407 self.set_title('gPodder %s' % app_version)
408 else:
409 title = self.gPodder.get_title()
410 if title is not None:
411 self.set_title(title)
412 else:
413 self.set_title(_('gPodder'))
415 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
417 # cell renderers for channel tree
418 namecolumn = gtk.TreeViewColumn( _('Podcast'))
420 iconcell = gtk.CellRendererPixbuf()
421 namecolumn.pack_start( iconcell, False)
422 namecolumn.add_attribute( iconcell, 'pixbuf', 5)
423 self.cell_channel_icon = iconcell
425 namecell = gtk.CellRendererText()
426 namecell.set_property('foreground-set', True)
427 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
428 namecolumn.pack_start( namecell, True)
429 namecolumn.add_attribute( namecell, 'markup', 2)
430 namecolumn.add_attribute( namecell, 'foreground', 8)
432 iconcell = gtk.CellRendererPixbuf()
433 iconcell.set_property('xalign', 1.0)
434 namecolumn.pack_start( iconcell, False)
435 namecolumn.add_attribute( iconcell, 'pixbuf', 3)
436 namecolumn.add_attribute(iconcell, 'visible', 7)
437 self.cell_channel_pill = iconcell
439 self.treeChannels.append_column( namecolumn)
440 self.treeChannels.set_headers_visible(False)
442 # enable alternating colors hint
443 self.treeAvailable.set_rules_hint( True)
444 self.treeChannels.set_rules_hint( True)
446 # connect to tooltip signals
447 try:
448 self.treeChannels.set_property('has-tooltip', True)
449 self.treeChannels.connect('query-tooltip', self.treeview_channels_query_tooltip)
450 self.treeAvailable.set_property('has-tooltip', True)
451 self.treeAvailable.connect('query-tooltip', self.treeview_episodes_query_tooltip)
452 except:
453 log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender = self)
454 self.last_tooltip_channel = None
455 self.last_tooltip_episode = None
456 self.podcast_list_can_tooltip = True
457 self.episode_list_can_tooltip = True
459 # Add our context menu to treeAvailable
460 if gpodder.interface == gpodder.MAEMO:
461 self.treeAvailable.connect('button-release-event', self.treeview_button_pressed)
462 else:
463 self.treeAvailable.connect('button-press-event', self.treeview_button_pressed)
464 self.treeChannels.connect('button-press-event', self.treeview_channels_button_pressed)
466 iconcell = gtk.CellRendererPixbuf()
467 if gpodder.interface == gpodder.MAEMO:
468 status_column_label = ''
469 else:
470 status_column_label = _('Status')
471 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=4)
473 namecell = gtk.CellRendererText()
474 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
475 namecolumn = gtk.TreeViewColumn(_("Episode"), namecell, markup=6)
476 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
477 namecolumn.set_expand(True)
479 sizecell = gtk.CellRendererText()
480 sizecolumn = gtk.TreeViewColumn( _("Size"), sizecell, text=2)
482 releasecell = gtk.CellRendererText()
483 releasecolumn = gtk.TreeViewColumn( _("Released"), releasecell, text=5)
485 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
486 itemcolumn.set_reorderable(True)
487 self.treeAvailable.append_column(itemcolumn)
489 if gpodder.interface == gpodder.MAEMO:
490 # Due to screen space contraints, we
491 # hide these columns here by default
492 self.column_size = sizecolumn
493 self.column_released = releasecolumn
494 self.column_released.set_visible(False)
495 self.column_size.set_visible(False)
497 # enable search in treeavailable
498 self.treeAvailable.set_search_equal_func( self.treeAvailable_search_equal)
500 # enable multiple selection support
501 self.treeAvailable.get_selection().set_mode( gtk.SELECTION_MULTIPLE)
502 self.treeDownloads.get_selection().set_mode( gtk.SELECTION_MULTIPLE)
504 # columns and renderers for "download progress" tab
505 episodecell = gtk.CellRendererText()
506 episodecell.set_property('ellipsize', pango.ELLIPSIZE_END)
507 episodecolumn = gtk.TreeViewColumn( _("Episode"), episodecell, text=0)
508 episodecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
509 episodecolumn.set_expand(True)
511 speedcell = gtk.CellRendererText()
512 speedcolumn = gtk.TreeViewColumn( _("Speed"), speedcell, text=1)
514 progresscell = gtk.CellRendererProgress()
515 progresscolumn = gtk.TreeViewColumn( _("Progress"), progresscell, value=2)
516 progresscolumn.set_expand(True)
518 for itemcolumn in ( episodecolumn, speedcolumn, progresscolumn ):
519 self.treeDownloads.append_column( itemcolumn)
521 # After we've set up most of the window, show it :)
522 if not gpodder.interface == gpodder.MAEMO:
523 self.gPodder.show()
525 if self.tray_icon:
526 if gl.config.start_iconified:
527 self.iconify_main_window()
528 elif gl.config.minimize_to_tray:
529 self.tray_icon.set_visible(False)
531 services.download_status_manager.register( 'list-changed', self.download_status_updated)
532 services.download_status_manager.register( 'progress-changed', self.download_progress_updated)
533 services.cover_downloader.register('cover-available', self.cover_download_finished)
534 services.cover_downloader.register('cover-removed', self.cover_file_removed)
535 self.cover_cache = {}
537 self.treeDownloads.set_model( services.download_status_manager.tree_model)
539 #Add Drag and Drop Support
540 flags = gtk.DEST_DEFAULT_ALL
541 targets = [ ('text/plain', 0, 2), ('STRING', 0, 3), ('TEXT', 0, 4) ]
542 actions = gtk.gdk.ACTION_DEFAULT | gtk.gdk.ACTION_COPY
543 self.treeChannels.drag_dest_set( flags, targets, actions)
544 self.treeChannels.connect( 'drag_data_received', self.drag_data_received)
546 # Subscribed channels
547 self.active_channel = None
548 self.channels = load_channels()
549 self.update_podcasts_tab()
551 # load list of user applications for audio playback
552 self.user_apps_reader = UserAppsReader(['audio', 'video'])
553 Thread(target=self.read_apps).start()
555 # Clean up old, orphaned download files
556 gl.clean_up_downloads( delete_partial = True)
558 # Set the "Device" menu item for the first time
559 self.update_item_device()
561 # Last folder used for saving episodes
562 self.folder_for_saving_episodes = None
564 # Set up default channel colors
565 self.channel_colors = {
566 'default': None,
567 'updating': gl.config.color_updating_feeds,
568 'parse_error': '#ff0000',
571 # Now, update the feed cache, when everything's in place
572 self.btnUpdateFeeds.show_all()
573 self.updated_feeds = 0
574 self.updating_feed_cache = False
575 self.feed_cache_update_cancelled = False
576 self.update_feed_cache(force_update=gl.config.update_on_startup)
578 # Start the auto-update procedure
579 self.auto_update_procedure(first_run=True)
581 # Delete old episodes if the user wishes to
582 if gl.config.auto_remove_old_episodes:
583 old_episodes = self.get_old_episodes()
584 if len(old_episodes) > 0:
585 self.delete_episode_list(old_episodes, confirm=False)
586 self.updateComboBox()
588 # First-time users should be asked if they want to see the OPML
589 if len(self.channels) == 0:
590 util.idle_add(self.on_itemUpdate_activate, None)
592 def on_tree_channels_resize(self, widget, allocation):
593 if not gl.config.podcast_sidebar_save_space:
594 return
596 window_allocation = self.gPodder.get_allocation()
597 percentage = 100. * float(allocation.width) / float(window_allocation.width)
598 if hasattr(self, 'cell_channel_icon'):
599 self.cell_channel_icon.set_property('visible', bool(percentage > 22.))
600 if hasattr(self, 'cell_channel_pill'):
601 self.cell_channel_pill.set_property('visible', bool(percentage > 25.))
603 def entry_add_channel_focus(self, widget, event):
604 widget.modify_text(gtk.STATE_NORMAL, self.default_entry_text_color)
605 if widget.get_text() == self.ENTER_URL_TEXT:
606 widget.set_text('')
608 def entry_add_channel_unfocus(self, widget, event):
609 if widget.get_text() == '':
610 widget.set_text(self.ENTER_URL_TEXT)
611 widget.modify_text(gtk.STATE_NORMAL, gtk.gdk.color_parse('#aaaaaa'))
613 def on_config_changed(self, name, old_value, new_value):
614 if name == 'show_toolbar':
615 if new_value:
616 self.toolbar.show_all()
617 else:
618 self.toolbar.hide_all()
619 elif name == 'episode_list_descriptions':
620 self.updateTreeView()
621 elif name == 'show_url_entry_in_podcast_list':
622 if new_value:
623 self.hboxAddChannel.show()
624 else:
625 self.hboxAddChannel.hide()
627 def read_apps(self):
628 time.sleep(3) # give other parts of gpodder a chance to start up
629 self.user_apps_reader.read()
630 util.idle_add(self.user_apps_reader.get_applications_as_model, 'audio', False)
631 util.idle_add(self.user_apps_reader.get_applications_as_model, 'video', False)
633 def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
634 # With get_bin_window, we get the window that contains the rows without
635 # the header. The Y coordinate of this window will be the height of the
636 # treeview header. This is the amount we have to subtract from the
637 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
638 (x_bin, y_bin) = treeview.get_bin_window().get_position()
639 y -= x_bin
640 y -= y_bin
641 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
643 if not self.episode_list_can_tooltip:
644 self.last_tooltip_episode = None
645 return False
647 if path is not None:
648 model = treeview.get_model()
649 iter = model.get_iter(path)
650 url = model.get_value(iter, 0)
651 description = model.get_value(iter, 7)
652 if self.last_tooltip_episode is not None and self.last_tooltip_episode != url:
653 self.last_tooltip_episode = None
654 return False
655 self.last_tooltip_episode = url
657 tooltip.set_text(description)
658 return True
660 self.last_tooltip_episode = None
661 return False
663 def podcast_list_allow_tooltips(self):
664 self.podcast_list_can_tooltip = True
666 def episode_list_allow_tooltips(self):
667 self.episode_list_can_tooltip = True
669 def treeview_channels_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
670 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
672 if not self.podcast_list_can_tooltip:
673 self.last_tooltip_channel = None
674 return False
676 if path is not None:
677 model = treeview.get_model()
678 iter = model.get_iter(path)
679 url = model.get_value(iter, 0)
680 for channel in self.channels:
681 if channel.url == url:
682 if self.last_tooltip_channel is not None and self.last_tooltip_channel != channel:
683 self.last_tooltip_channel = None
684 return False
685 self.last_tooltip_channel = channel
686 channel.request_save_dir_size()
687 diskspace_str = gl.format_filesize(channel.save_dir_size, 0)
688 error_str = model.get_value(iter, 6)
689 if error_str:
690 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
691 error_str = '<span foreground="#ff0000">%s</span>' % error_str
692 table = gtk.Table(rows=3, columns=3)
693 table.set_row_spacings(5)
694 table.set_col_spacings(5)
695 table.set_border_width(5)
697 heading = gtk.Label()
698 heading.set_alignment(0, 1)
699 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
700 table.attach(heading, 0, 1, 0, 1)
701 size_info = gtk.Label()
702 size_info.set_alignment(1, 1)
703 size_info.set_justify(gtk.JUSTIFY_RIGHT)
704 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
705 table.attach(size_info, 2, 3, 0, 1)
707 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
709 if len(channel.description) < 500:
710 description = channel.description
711 else:
712 pos = channel.description.find('\n\n')
713 if pos == -1 or pos > 500:
714 description = channel.description[:498]+'[...]'
715 else:
716 description = channel.description[:pos]
718 description = gtk.Label(description)
719 if error_str:
720 description.set_markup(error_str)
721 description.set_alignment(0, 0)
722 description.set_line_wrap(True)
723 table.attach(description, 0, 3, 2, 3)
725 table.show_all()
726 tooltip.set_custom(table)
728 return True
730 self.last_tooltip_channel = None
731 return False
733 def update_m3u_playlist_clicked(self, widget):
734 self.active_channel.update_m3u_playlist()
735 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'))
737 def treeview_channels_button_pressed( self, treeview, event):
738 global WEB_BROWSER_ICON
740 if event.button == 3:
741 ( x, y ) = ( int(event.x), int(event.y) )
742 ( path, column, rx, ry ) = treeview.get_path_at_pos( x, y) or (None,)*4
744 paths = []
746 # Did the user right-click into a selection?
747 selection = treeview.get_selection()
748 if selection.count_selected_rows() and path:
749 ( model, paths ) = selection.get_selected_rows()
750 if path not in paths:
751 # We have right-clicked, but not into the
752 # selection, assume we don't want to operate
753 # on the selection
754 paths = []
756 # No selection or right click not in selection:
757 # Select the single item where we clicked
758 if not len( paths) and path:
759 treeview.grab_focus()
760 treeview.set_cursor( path, column, 0)
762 ( model, paths ) = ( treeview.get_model(), [ path ] )
764 # We did not find a selection, and the user didn't
765 # click on an item to select -- don't show the menu
766 if not len( paths):
767 return True
769 menu = gtk.Menu()
771 item = gtk.ImageMenuItem( _('Open download folder'))
772 item.set_image( gtk.image_new_from_icon_name( 'folder-open', gtk.ICON_SIZE_MENU))
773 item.connect('activate', lambda x: util.gui_open(self.active_channel.save_dir))
774 menu.append( item)
776 item = gtk.ImageMenuItem( _('Update Feed'))
777 item.set_image( gtk.image_new_from_icon_name( 'gtk-refresh', gtk.ICON_SIZE_MENU))
778 item.connect('activate', self.on_itemUpdateChannel_activate )
779 item.set_sensitive( not self.updating_feed_cache )
780 menu.append( item)
782 if gl.config.create_m3u_playlists:
783 item = gtk.ImageMenuItem(_('Update M3U playlist'))
784 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
785 item.connect('activate', self.update_m3u_playlist_clicked)
786 menu.append(item)
788 if self.active_channel.link:
789 item = gtk.ImageMenuItem(_('Visit website'))
790 item.set_image(gtk.image_new_from_icon_name(WEB_BROWSER_ICON, gtk.ICON_SIZE_MENU))
791 item.connect('activate', lambda w: util.open_website(self.active_channel.link))
792 menu.append(item)
794 menu.append( gtk.SeparatorMenuItem())
796 item = gtk.ImageMenuItem(gtk.STOCK_EDIT)
797 item.connect( 'activate', self.on_itemEditChannel_activate)
798 menu.append( item)
800 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
801 item.connect( 'activate', self.on_itemRemoveChannel_activate)
802 menu.append( item)
804 menu.show_all()
805 # Disable tooltips while we are showing the menu, so
806 # the tooltip will not appear over the menu
807 self.podcast_list_can_tooltip = False
808 menu.connect('deactivate', lambda menushell: self.podcast_list_allow_tooltips())
809 menu.popup( None, None, None, event.button, event.time)
811 return True
813 def on_itemClose_activate(self, widget):
814 if self.tray_icon is not None:
815 if gpodder.interface == gpodder.MAEMO:
816 self.gPodder.set_property('visible', False)
817 else:
818 self.iconify_main_window()
819 else:
820 self.on_gPodder_delete_event(widget)
822 def cover_file_removed(self, channel_url):
824 The Cover Downloader calls this when a previously-
825 available cover has been removed from the disk. We
826 have to update our cache to reflect this change.
828 (COLUMN_URL, COLUMN_PIXBUF) = (0, 5)
829 for row in self.treeChannels.get_model():
830 if row[COLUMN_URL] == channel_url:
831 row[COLUMN_PIXBUF] = None
832 key = (channel_url, gl.config.podcast_list_icon_size, \
833 gl.config.podcast_list_icon_size)
834 if key in self.cover_cache:
835 del self.cover_cache[key]
838 def cover_download_finished(self, channel_url, pixbuf):
840 The Cover Downloader calls this when it has finished
841 downloading (or registering, if already downloaded)
842 a new channel cover, which is ready for displaying.
844 if pixbuf is not None:
845 (COLUMN_URL, COLUMN_PIXBUF) = (0, 5)
846 for row in self.treeChannels.get_model():
847 if row[COLUMN_URL] == channel_url and row[COLUMN_PIXBUF] is None:
848 new_pixbuf = util.resize_pixbuf_keep_ratio(pixbuf, gl.config.podcast_list_icon_size, gl.config.podcast_list_icon_size, channel_url, self.cover_cache)
849 row[COLUMN_PIXBUF] = new_pixbuf or pixbuf
851 def save_episode_as_file( self, url, *args):
852 episode = self.active_channel.find_episode(url)
854 folder = self.folder_for_saving_episodes
855 (result, folder) = self.show_copy_dialog(src_filename=episode.local_filename(), dst_filename=episode.sync_filename(), dst_directory=folder)
856 self.folder_for_saving_episodes = folder
858 def copy_episode_bluetooth(self, url, *args):
859 episode = self.active_channel.find_episode(url)
860 filename = episode.local_filename()
862 if gl.config.bluetooth_ask_always:
863 device = None
864 else:
865 device = gl.config.bluetooth_device_address
867 destfile = os.path.join(gl.tempdir, util.sanitize_filename(episode.sync_filename()))
868 (base, ext) = os.path.splitext(filename)
869 if not destfile.endswith(ext):
870 destfile += ext
872 if gl.config.bluetooth_use_converter:
873 title = _('Converting file')
874 message = _('Please wait while gPodder converts your media file for bluetooth file transfer.')
875 dlg = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_NONE)
876 dlg.set_title(title)
877 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
878 dlg.show_all()
879 else:
880 dlg = None
882 def convert_and_send_thread(filename, destfile, device, dialog, notify):
883 if gl.config.bluetooth_use_converter:
884 p = subprocess.Popen([gl.config.bluetooth_converter, filename, destfile], stdout=sys.stdout, stderr=sys.stderr)
885 result = p.wait()
886 if dialog is not None:
887 dialog.destroy()
888 else:
889 try:
890 shutil.copyfile(filename, destfile)
891 result = 0
892 except:
893 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
894 result = 1
896 if result == 0 or not os.path.exists(destfile):
897 util.bluetooth_send_file(destfile, device)
898 else:
899 notify(_('Error converting file.'), _('Bluetooth file transfer'))
900 util.delete_file(destfile)
902 Thread(target=convert_and_send_thread, args=[filename, destfile, device, dlg, self.notification]).start()
904 def treeview_button_pressed( self, treeview, event):
905 global WEB_BROWSER_ICON
907 # Use right-click for the Desktop version and left-click for Maemo
908 if (event.button == 1 and gpodder.interface == gpodder.MAEMO) or \
909 (event.button == 3 and gpodder.interface == gpodder.GUI):
910 ( x, y ) = ( int(event.x), int(event.y) )
911 ( path, column, rx, ry ) = treeview.get_path_at_pos( x, y) or (None,)*4
913 paths = []
915 # Did the user right-click into a selection?
916 selection = self.treeAvailable.get_selection()
917 if selection.count_selected_rows() and path:
918 ( model, paths ) = selection.get_selected_rows()
919 if path not in paths:
920 # We have right-clicked, but not into the
921 # selection, assume we don't want to operate
922 # on the selection
923 paths = []
925 # No selection or right click not in selection:
926 # Select the single item where we clicked
927 if not len( paths) and path:
928 treeview.grab_focus()
929 treeview.set_cursor( path, column, 0)
931 ( model, paths ) = ( treeview.get_model(), [ path ] )
933 # We did not find a selection, and the user didn't
934 # click on an item to select -- don't show the menu
935 if not len( paths):
936 return True
938 first_url = model.get_value( model.get_iter( paths[0]), 0)
939 episode = db.load_episode(first_url)
941 menu = gtk.Menu()
943 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
945 if can_play:
946 if open_instead_of_play:
947 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
948 else:
949 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
950 item.connect( 'activate', lambda w: self.on_treeAvailable_row_activated( self.toolPlay))
951 menu.append(self.set_finger_friendly(item))
953 if not episode['is_locked'] and can_delete:
954 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
955 item.connect('activate', self.on_btnDownloadedDelete_clicked)
956 menu.append(self.set_finger_friendly(item))
958 if can_cancel:
959 item = gtk.ImageMenuItem( _('Cancel download'))
960 item.set_image( gtk.image_new_from_stock( gtk.STOCK_STOP, gtk.ICON_SIZE_MENU))
961 item.connect( 'activate', lambda w: self.on_treeDownloads_row_activated( self.toolCancel))
962 menu.append(self.set_finger_friendly(item))
964 if can_download:
965 item = gtk.ImageMenuItem(_('Download'))
966 item.set_image( gtk.image_new_from_stock( gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
967 item.connect( 'activate', lambda w: self.on_treeAvailable_row_activated( self.toolDownload))
968 menu.append(self.set_finger_friendly(item))
970 if episode['state'] == db.STATE_NORMAL and not episode['is_played']: # can_download:
971 item = gtk.ImageMenuItem(_('Do not download'))
972 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
973 item.connect('activate', lambda w: self.mark_selected_episodes_old())
974 menu.append(self.set_finger_friendly(item))
975 elif episode['state'] == db.STATE_NORMAL and can_download:
976 item = gtk.ImageMenuItem(_('Mark as new'))
977 item.set_image(gtk.image_new_from_stock(gtk.STOCK_ABOUT, gtk.ICON_SIZE_MENU))
978 item.connect('activate', lambda w: self.mark_selected_episodes_new())
979 menu.append(self.set_finger_friendly(item))
981 if can_play and not can_download:
982 menu.append( gtk.SeparatorMenuItem())
983 item = gtk.ImageMenuItem(_('Save to disk'))
984 item.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE_AS, gtk.ICON_SIZE_MENU))
985 item.connect( 'activate', lambda w: self.for_each_selected_episode_url(self.save_episode_as_file))
986 menu.append(self.set_finger_friendly(item))
987 if gl.config.bluetooth_enabled:
988 item = gtk.ImageMenuItem(_('Send via bluetooth'))
989 item.set_image(gtk.image_new_from_icon_name('bluetooth', gtk.ICON_SIZE_MENU))
990 item.connect('activate', lambda w: self.copy_episode_bluetooth(episode_url))
991 menu.append(self.set_finger_friendly(item))
992 if can_transfer:
993 item = gtk.ImageMenuItem(_('Transfer to %s') % gl.get_device_name())
994 item.set_image(gtk.image_new_from_icon_name('multimedia-player', gtk.ICON_SIZE_MENU))
995 item.connect('activate', lambda w: self.on_treeAvailable_row_activated(self.toolTransfer))
996 menu.append(self.set_finger_friendly(item))
998 if can_play:
999 menu.append( gtk.SeparatorMenuItem())
1000 is_played = episode['is_played']
1001 if is_played:
1002 item = gtk.ImageMenuItem(_('Mark as unplayed'))
1003 item.set_image( gtk.image_new_from_stock( gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU))
1004 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, False))
1005 menu.append(self.set_finger_friendly(item))
1006 else:
1007 item = gtk.ImageMenuItem(_('Mark as played'))
1008 item.set_image( gtk.image_new_from_stock( gtk.STOCK_APPLY, gtk.ICON_SIZE_MENU))
1009 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, True))
1010 menu.append(self.set_finger_friendly(item))
1012 is_locked = episode['is_locked']
1013 if is_locked:
1014 item = gtk.ImageMenuItem(_('Allow deletion'))
1015 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1016 item.connect('activate', self.on_item_toggle_lock_activate)
1017 menu.append(self.set_finger_friendly(item))
1018 else:
1019 item = gtk.ImageMenuItem(_('Prohibit deletion'))
1020 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1021 item.connect('activate', self.on_item_toggle_lock_activate)
1022 menu.append(self.set_finger_friendly(item))
1024 if len(paths) == 1:
1025 menu.append(gtk.SeparatorMenuItem())
1026 # Single item, add episode information menu item
1027 episode_url = model.get_value( model.get_iter( paths[0]), 0)
1028 item = gtk.ImageMenuItem(_('Episode details'))
1029 item.set_image( gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1030 item.connect( 'activate', lambda w: self.on_treeAvailable_row_activated( self.treeAvailable))
1031 menu.append(self.set_finger_friendly(item))
1032 episode = self.active_channel.find_episode(episode_url)
1033 # If we have it, also add episode website link
1034 if episode and episode.link and episode.link != episode.url:
1035 item = gtk.ImageMenuItem(_('Visit website'))
1036 item.set_image(gtk.image_new_from_icon_name(WEB_BROWSER_ICON, gtk.ICON_SIZE_MENU))
1037 item.connect('activate', lambda w: util.open_website(episode.link))
1038 menu.append(self.set_finger_friendly(item))
1040 if gpodder.interface == gpodder.MAEMO:
1041 # Because we open the popup on left-click for Maemo,
1042 # we also include a non-action to close the menu
1043 menu.append(gtk.SeparatorMenuItem())
1044 item = gtk.ImageMenuItem(_('Close this menu'))
1045 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1046 menu.append(self.set_finger_friendly(item))
1048 menu.show_all()
1049 # Disable tooltips while we are showing the menu, so
1050 # the tooltip will not appear over the menu
1051 self.episode_list_can_tooltip = False
1052 menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
1053 menu.popup( None, None, None, event.button, event.time)
1055 return True
1057 def set_title(self, new_title):
1058 self.default_title = new_title
1059 self.gPodder.set_title(new_title)
1061 def download_progress_updated( self, count, percentage):
1062 title = [ self.default_title ]
1064 total_speed = gl.format_filesize(services.download_status_manager.total_speed())
1066 if count == 1:
1067 title.append( _('downloading one file'))
1068 elif count > 1:
1069 title.append( _('downloading %d files') % count)
1071 if len(title) == 2:
1072 title[1] = ''.join( [ title[1], ' (%d%%, %s/s)' % (percentage, total_speed) ])
1074 self.gPodder.set_title( ' - '.join( title))
1076 # Have all the downloads completed?
1077 # If so execute user command if defined, else do nothing
1078 if count == 0:
1079 if len(gl.config.cmd_all_downloads_complete) > 0:
1080 Thread(target=gl.ext_command_thread, args=(self.notification,gl.config.cmd_all_downloads_complete)).start()
1082 def playback_episode(self, episode, stream=False):
1083 (success, application) = gl.playback_episode(episode, stream)
1084 if not success:
1085 self.show_message( _('The selected player application cannot be found. Please check your media player settings in the preferences dialog.'), _('Error opening player: %s') % ( saxutils.escape( application), ))
1086 self.updateComboBox(only_selected_channel=True)
1088 def treeAvailable_search_equal( self, model, column, key, iter, data = None):
1089 if model is None:
1090 return True
1092 key = key.lower()
1094 # columns, as defined in libpodcasts' get model method
1095 # 1 = episode title, 7 = description
1096 columns = (1, 7)
1098 for column in columns:
1099 value = model.get_value( iter, column).lower()
1100 if value.find( key) != -1:
1101 return False
1103 return True
1105 def change_menu_item(self, menuitem, icon=None, label=None):
1106 if icon is not None:
1107 menuitem.get_image().set_from_icon_name(icon, gtk.ICON_SIZE_MENU)
1108 if label is not None:
1109 label_widget = menuitem.get_child()
1110 label_widget.set_text(label)
1112 def play_or_download(self):
1113 if self.wNotebook.get_current_page() > 0:
1114 return
1116 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
1117 ( is_played, is_locked ) = (False,)*2
1119 open_instead_of_play = False
1121 selection = self.treeAvailable.get_selection()
1122 if selection.count_selected_rows() > 0:
1123 (model, paths) = selection.get_selected_rows()
1125 for path in paths:
1126 url = model.get_value( model.get_iter( path), 0)
1127 local_filename = model.get_value( model.get_iter( path), 8)
1129 episode = podcastItem.load(url, self.active_channel)
1131 if episode.file_type() not in ('audio', 'video'):
1132 open_instead_of_play = True
1134 if episode.was_downloaded():
1135 can_play = episode.was_downloaded(and_exists=True)
1136 can_delete = True
1137 is_played = episode.is_played
1138 is_locked = episode.is_locked
1139 if not can_play:
1140 can_download = True
1141 else:
1142 if services.download_status_manager.is_download_in_progress(url):
1143 can_cancel = True
1144 else:
1145 can_download = True
1147 can_download = can_download and not can_cancel
1148 can_play = gl.config.enable_streaming or (can_play and not can_cancel and not can_download)
1149 can_transfer = can_play and gl.config.device_type != 'none' and not can_cancel and not can_download
1151 if open_instead_of_play:
1152 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
1153 can_transfer = False
1154 else:
1155 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
1157 self.toolPlay.set_sensitive( can_play)
1158 self.toolDownload.set_sensitive( can_download)
1159 self.toolTransfer.set_sensitive( can_transfer)
1160 self.toolCancel.set_sensitive( can_cancel)
1162 if can_cancel:
1163 self.item_cancel_download.show_all()
1164 else:
1165 self.item_cancel_download.hide_all()
1166 if can_download:
1167 self.itemDownloadSelected.show_all()
1168 else:
1169 self.itemDownloadSelected.hide_all()
1170 if can_play:
1171 if open_instead_of_play:
1172 self.itemOpenSelected.show_all()
1173 self.itemPlaySelected.hide_all()
1174 else:
1175 self.itemPlaySelected.show_all()
1176 self.itemOpenSelected.hide_all()
1177 if not can_download:
1178 self.itemDeleteSelected.show_all()
1179 else:
1180 self.itemDeleteSelected.hide_all()
1181 self.item_toggle_played.show_all()
1182 self.item_toggle_lock.show_all()
1183 self.separator9.show_all()
1184 if is_played:
1185 self.change_menu_item(self.item_toggle_played, gtk.STOCK_CANCEL, _('Mark as unplayed'))
1186 else:
1187 self.change_menu_item(self.item_toggle_played, gtk.STOCK_APPLY, _('Mark as played'))
1188 if is_locked:
1189 self.change_menu_item(self.item_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Allow deletion'))
1190 else:
1191 self.change_menu_item(self.item_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Prohibit deletion'))
1192 else:
1193 self.itemPlaySelected.hide_all()
1194 self.itemOpenSelected.hide_all()
1195 self.itemDeleteSelected.hide_all()
1196 self.item_toggle_played.hide_all()
1197 self.item_toggle_lock.hide_all()
1198 self.separator9.hide_all()
1199 if can_play or can_download or can_cancel:
1200 self.item_episode_details.show_all()
1201 self.separator16.show_all()
1202 self.no_episode_selected.hide_all()
1203 else:
1204 self.item_episode_details.hide_all()
1205 self.separator16.hide_all()
1206 self.no_episode_selected.show_all()
1208 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
1210 def download_status_updated( self):
1211 count = services.download_status_manager.count()
1212 if count:
1213 self.labelDownloads.set_text( _('Downloads (%d)') % count)
1214 else:
1215 self.labelDownloads.set_text( _('Downloads'))
1217 self.updateComboBox()
1219 def on_cbMaxDownloads_toggled(self, widget, *args):
1220 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
1222 def on_cbLimitDownloads_toggled(self, widget, *args):
1223 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
1225 def episode_new_status_changed(self):
1226 self.updateComboBox()
1227 self.updateTreeView()
1229 def updateComboBox(self, selected_url=None, only_selected_channel=False):
1230 (model, iter) = self.treeChannels.get_selection().get_selected()
1232 if only_selected_channel:
1233 if iter and self.active_channel is not None:
1234 update_channel_model_by_iter( self.treeChannels.get_model(),
1235 iter, self.active_channel, self.channel_colors,
1236 self.cover_cache, *(gl.config.podcast_list_icon_size,)*2 )
1237 else:
1238 if model and iter and selected_url is None:
1239 # Get the URL of the currently-selected podcast
1240 selected_url = model.get_value(iter, 0)
1242 rect = self.treeChannels.get_visible_rect()
1243 self.treeChannels.set_model( channels_to_model( self.channels,
1244 self.channel_colors, self.cover_cache,
1245 *(gl.config.podcast_list_icon_size,)*2 ))
1246 util.idle_add(self.treeChannels.scroll_to_point, rect.x, rect.y)
1248 try:
1249 selected_path = (0,)
1250 # Find the previously-selected URL in the new
1251 # model if we have an URL (else select first)
1252 if selected_url is not None:
1253 model = self.treeChannels.get_model()
1254 pos = model.get_iter_first()
1255 while pos is not None:
1256 url = model.get_value(pos, 0)
1257 if url == selected_url:
1258 selected_path = model.get_path(pos)
1259 break
1260 pos = model.iter_next(pos)
1262 self.treeChannels.get_selection().select_path(selected_path)
1263 except:
1264 log( 'Cannot set selection on treeChannels', sender = self)
1265 self.on_treeChannels_cursor_changed( self.treeChannels)
1267 def updateTreeView( self):
1268 if self.channels and self.active_channel is not None:
1269 self.treeAvailable.set_model(self.active_channel.tree_model)
1270 self.treeAvailable.columns_autosize()
1271 self.play_or_download()
1272 else:
1273 if self.treeAvailable.get_model():
1274 self.treeAvailable.get_model().clear()
1276 def drag_data_received(self, widget, context, x, y, sel, ttype, time):
1277 (path, column, rx, ry) = self.treeChannels.get_path_at_pos( x, y) or (None,)*4
1279 dnd_channel = None
1280 if path is not None:
1281 model = self.treeChannels.get_model()
1282 iter = model.get_iter(path)
1283 url = model.get_value(iter, 0)
1284 for channel in self.channels:
1285 if channel.url == url:
1286 dnd_channel = channel
1287 break
1289 result = sel.data
1290 rl = result.strip().lower()
1291 if (rl.endswith('.jpg') or rl.endswith('.png') or rl.endswith('.gif') or rl.endswith('.svg')) and dnd_channel is not None:
1292 services.cover_downloader.replace_cover(dnd_channel, result)
1293 else:
1294 self.add_new_channel(result)
1296 def add_new_channel(self, result=None, ask_download_new=True, quiet=False, block=False, authentication_tokens=None):
1297 result = util.normalize_feed_url( result)
1299 waitdlg = gtk.MessageDialog(self.gPodder, 0, gtk.MESSAGE_INFO, gtk.BUTTONS_NONE)
1300 waitdlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
1301 waitdlg.set_title(_('Downloading episode list'))
1302 waitdlg.set_markup('<b><big>%s</big></b>' % waitdlg.get_title())
1303 waitdlg.format_secondary_text(_('Please wait while I am downloading episode information for %s') % result)
1304 waitpb = gtk.ProgressBar()
1305 if block:
1306 waitdlg.vbox.add(waitpb)
1307 waitdlg.show_all()
1308 waitdlg.set_response_sensitive(gtk.RESPONSE_CANCEL, False)
1310 if not result:
1311 title = _('URL scheme not supported')
1312 message = _('gPodder currently only supports URLs starting with <b>http://</b>, <b>feed://</b> or <b>ftp://</b>.')
1313 self.show_message( message, title)
1314 return
1316 for old_channel in self.channels:
1317 if old_channel.url == result:
1318 log( 'Channel already exists: %s', result)
1319 # Select the existing channel in combo box
1320 for i in range( len( self.channels)):
1321 if self.channels[i] == old_channel:
1322 self.treeChannels.get_selection().select_path( (i,))
1323 self.on_treeChannels_cursor_changed(self.treeChannels)
1324 break
1325 self.show_message( _('You have already subscribed to this podcast: %s') % (
1326 saxutils.escape( old_channel.title), ), _('Already added'))
1327 waitdlg.destroy()
1328 return
1330 self.entryAddChannel.set_text(_('Downloading feed...'))
1331 self.entryAddChannel.set_sensitive(False)
1332 self.btnAddChannel.set_sensitive(False)
1333 args = (result, self.add_new_channel_finish, authentication_tokens, ask_download_new, quiet, waitdlg)
1334 thread = Thread( target=self.add_new_channel_proc, args=args )
1335 thread.start()
1337 while block and thread.isAlive():
1338 while gtk.events_pending():
1339 gtk.main_iteration( False)
1340 waitpb.pulse()
1341 time.sleep(0.05)
1344 def add_new_channel_proc( self, url, callback, authentication_tokens, *callback_args):
1345 log( 'Adding new channel: %s', url)
1346 channel = error = None
1347 try:
1348 channel = podcastChannel.load(url=url, create=True, authentication_tokens=authentication_tokens)
1349 except HTTPAuthError, e:
1350 error = e
1351 except Exception, e:
1352 log('Error in podcastChannel.load(%s): %s', url, e, traceback=True, sender=self)
1354 util.idle_add( callback, channel, url, error, *callback_args )
1356 def add_new_channel_finish( self, channel, url, error, ask_download_new, quiet, waitdlg):
1357 if channel is not None:
1358 self.channels.append( channel)
1359 save_channels( self.channels)
1360 if not quiet:
1361 # download changed channels and select the new episode in the UI afterwards
1362 self.update_feed_cache(force_update=False, select_url_afterwards=channel.url)
1364 (username, password) = util.username_password_from_url( url)
1365 if username and self.show_confirmation( _('You have supplied <b>%s</b> as username and a password for this feed. Would you like to use the same authentication data for downloading episodes?') % ( saxutils.escape( username), ), _('Password authentication')):
1366 channel.username = username
1367 channel.password = password
1368 log('Saving authentication data for episode downloads..', sender = self)
1369 channel.save()
1370 # We need to update the channel list otherwise the authentication
1371 # data won't show up in the channel editor.
1372 # TODO: Only updated the newly added feed to save some cpu cycles
1373 self.channels = load_channels()
1375 if ask_download_new:
1376 new_episodes = channel.get_new_episodes()
1377 if len(new_episodes):
1378 self.new_episodes_show(new_episodes)
1380 elif isinstance( error, HTTPAuthError ):
1381 response, auth_tokens = self.UsernamePasswordDialog(
1382 _('Feed requires authentication'), _('Please enter your username and password.'))
1384 if response:
1385 self.add_new_channel( url, authentication_tokens=auth_tokens )
1387 else:
1388 # Ok, the URL is not a channel, or there is some other
1389 # error - let's see if it's a web page or OPML file...
1390 try:
1391 data = urllib2.urlopen(url).read().lower()
1392 if '</opml>' in data:
1393 # This looks like an OPML feed
1394 self.on_item_import_from_file_activate(None, url)
1396 elif '</html>' in data:
1397 # This looks like a web page
1398 title = _('The URL is a website')
1399 message = _('The URL you specified points to a web page. You need to find the "feed" URL of the podcast to add to gPodder. Do you want to visit this website now and look for the podcast feed URL?\n\n(Hint: Look for "XML feed", "RSS feed" or "Podcast feed" if you are unsure for what to look. If there is only an iTunes URL, try adding this one.)')
1400 if self.show_confirmation(message, title):
1401 util.open_website(url)
1403 except Exception, e:
1404 log('Error trying to handle the URL as OPML or web page: %s', e, sender=self)
1406 title = _('Error adding podcast')
1407 message = _('The podcast could not be added. Please check the spelling of the URL or try again later.')
1408 self.show_message( message, title)
1410 self.entryAddChannel.set_text(self.ENTER_URL_TEXT)
1411 self.entryAddChannel.set_sensitive(True)
1412 self.btnAddChannel.set_sensitive(True)
1413 self.update_podcasts_tab()
1414 waitdlg.destroy()
1417 def update_feed_cache_finish_callback(self, channels=None,
1418 notify_no_new_episodes=False, select_url_afterwards=None):
1420 db.commit()
1422 self.updating_feed_cache = False
1423 self.hboxUpdateFeeds.hide_all()
1424 self.btnUpdateFeeds.show_all()
1425 self.itemUpdate.set_sensitive(True)
1426 self.itemUpdateChannel.set_sensitive(True)
1428 # If we want to select a specific podcast (via its URL)
1429 # after the update, we give it to updateComboBox here to
1430 # select exactly this podcast after updating the view
1431 self.updateComboBox(selected_url=select_url_afterwards)
1433 if self.tray_icon:
1434 self.tray_icon.set_status(None)
1435 if self.minimized:
1436 new_episodes = []
1437 # look for new episodes to notify
1438 for channel in self.channels:
1439 for episode in channel.get_new_episodes():
1440 if not episode in self.already_notified_new_episodes:
1441 new_episodes.append(episode)
1442 self.already_notified_new_episodes.append(episode)
1443 # notify new episodes
1445 if len(new_episodes) == 0:
1446 if notify_no_new_episodes and self.tray_icon is not None:
1447 msg = _('No new episodes available for download')
1448 self.tray_icon.send_notification(msg)
1449 return
1450 elif len(new_episodes) == 1:
1451 title = _('gPodder has found %s') % (_('one new episode:'),)
1452 else:
1453 title = _('gPodder has found %s') % (_('%i new episodes:') % len(new_episodes))
1454 message = self.tray_icon.format_episode_list(new_episodes)
1456 #auto download new episodes
1457 if gl.config.auto_download_when_minimized:
1458 message += '\n<i>(%s...)</i>' % _('downloading')
1459 self.download_episode_list(new_episodes)
1460 self.tray_icon.send_notification(message, title)
1461 return
1463 # open the episodes selection dialog
1464 self.channels = load_channels()
1465 self.updateComboBox()
1466 if not self.feed_cache_update_cancelled:
1467 self.download_all_new(channels=channels)
1469 def update_feed_cache_callback(self, progressbar, title, position, count):
1470 progression = _('Updated %s (%d/%d)')%(title, position+1, count)
1471 progressbar.set_text(progression)
1472 if self.tray_icon:
1473 self.tray_icon.set_status(
1474 self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression )
1475 if count > 0:
1476 progressbar.set_fraction(float(position)/float(count))
1478 def update_feed_cache_proc( self, channel, total_channels, semaphore,
1479 callback_proc, finish_proc):
1481 semaphore.acquire()
1482 if not self.feed_cache_update_cancelled:
1483 try:
1484 channel.update()
1485 except:
1486 log('Darn SQLite LOCK!', sender=self, traceback=True)
1488 # By the time we get here the update may have already been cancelled
1489 if not self.feed_cache_update_cancelled:
1490 callback_proc(channel.title, self.updated_feeds, total_channels)
1492 self.updated_feeds += 1
1493 self.treeview_channel_set_color( channel, 'default' )
1494 channel.update_flag = False
1496 semaphore.release()
1497 if self.updated_feeds == total_channels:
1498 finish_proc()
1500 def on_btnCancelFeedUpdate_clicked(self, widget):
1501 self.pbFeedUpdate.set_text(_('Cancelling...'))
1502 self.feed_cache_update_cancelled = True
1504 def update_feed_cache(self, channels=None, force_update=True,
1505 notify_no_new_episodes=False, select_url_afterwards=None):
1507 if self.updating_feed_cache:
1508 return
1510 if not force_update:
1511 self.channels = load_channels()
1512 self.updateComboBox()
1513 return
1515 self.updating_feed_cache = True
1516 self.itemUpdate.set_sensitive(False)
1517 self.itemUpdateChannel.set_sensitive(False)
1519 if self.tray_icon:
1520 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
1522 if channels is None:
1523 channels = self.channels
1525 if len(channels) == 1:
1526 text = _('Updating %d feed.')
1527 else:
1528 text = _('Updating %d feeds.')
1529 self.pbFeedUpdate.set_text( text % len(channels))
1530 self.pbFeedUpdate.set_fraction(0)
1532 # let's get down to business..
1533 callback_proc = lambda title, pos, count: util.idle_add(
1534 self.update_feed_cache_callback, self.pbFeedUpdate, title, pos, count )
1535 finish_proc = lambda: util.idle_add( self.update_feed_cache_finish_callback,
1536 channels, notify_no_new_episodes, select_url_afterwards )
1538 self.updated_feeds = 0
1539 self.feed_cache_update_cancelled = False
1540 self.btnUpdateFeeds.hide_all()
1541 self.hboxUpdateFeeds.show_all()
1542 semaphore = Semaphore(gl.config.max_simulaneous_feeds_updating)
1544 for channel in channels:
1545 self.treeview_channel_set_color( channel, 'updating' )
1546 channel.update_flag = True
1547 args = (channel, len(channels), semaphore, callback_proc, finish_proc)
1548 thread = Thread( target = self.update_feed_cache_proc, args = args)
1549 thread.start()
1551 def treeview_channel_set_color( self, channel, color ):
1552 if self.treeChannels.get_model():
1553 if color in self.channel_colors:
1554 self.treeChannels.get_model().set(channel.iter, 8, self.channel_colors[color])
1555 else:
1556 self.treeChannels.get_model().set(channel.iter, 8, color)
1558 def on_gPodder_delete_event(self, widget, *args):
1559 """Called when the GUI wants to close the window
1560 Displays a confirmation dialog (and closes/hides gPodder)
1563 downloading = services.download_status_manager.has_items()
1565 # Only iconify if we are using the window's "X" button,
1566 # but not when we are using "Quit" in the menu or toolbar
1567 if not gl.config.on_quit_ask and gl.config.on_quit_systray and self.tray_icon and widget.name not in ('toolQuit', 'itemQuit'):
1568 self.iconify_main_window()
1569 elif gl.config.on_quit_ask or downloading:
1570 if gpodder.interface == gpodder.MAEMO:
1571 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
1572 if result:
1573 self.close_gpodder()
1574 else:
1575 return True
1576 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
1577 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
1578 dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
1580 title = _('Quit gPodder')
1581 if downloading:
1582 message = _('You are downloading episodes. If you close gPodder now, the downloads will be aborted.')
1583 else:
1584 message = _('Do you really want to quit gPodder now?')
1586 dialog.set_title(title)
1587 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
1588 if not downloading:
1589 cb_ask = gtk.CheckButton(_("Don't ask me again"))
1590 dialog.vbox.pack_start(cb_ask)
1591 cb_ask.show_all()
1593 result = dialog.run()
1594 dialog.destroy()
1596 if result == gtk.RESPONSE_CLOSE:
1597 if not downloading and cb_ask.get_active() == True:
1598 gl.config.on_quit_ask = False
1599 self.close_gpodder()
1600 else:
1601 self.close_gpodder()
1603 return True
1605 def close_gpodder(self):
1606 """ clean everything and exit properly
1608 if self.channels:
1609 if not save_channels(self.channels):
1610 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'))
1612 services.download_status_manager.cancel_all()
1613 db.commit()
1615 self.gtk_main_quit()
1616 sys.exit( 0)
1618 def get_old_episodes(self):
1619 episodes = []
1620 for channel in self.channels:
1621 for episode in channel.get_downloaded_episodes():
1622 if episode.is_old() and not episode.is_locked and episode.is_played:
1623 episodes.append(episode)
1624 return episodes
1626 def for_each_selected_episode_url( self, callback):
1627 ( model, paths ) = self.treeAvailable.get_selection().get_selected_rows()
1628 for path in paths:
1629 url = model.get_value( model.get_iter( path), 0)
1630 try:
1631 callback( url)
1632 except Exception, e:
1633 log( 'Warning: Error in for_each_selected_episode_url for URL %s: %s', url, e, sender = self)
1635 self.updateComboBox(only_selected_channel=True)
1637 def delete_episode_list( self, episodes, confirm = True):
1638 if len(episodes) == 0:
1639 return
1641 if len(episodes) == 1:
1642 message = _('Do you really want to delete this episode?')
1643 else:
1644 message = _('Do you really want to delete %d episodes?') % len(episodes)
1646 if confirm and self.show_confirmation( message, _('Delete episodes')) == False:
1647 return
1649 for episode in episodes:
1650 log('Deleting episode: %s', episode.title, sender = self)
1651 episode.delete_from_disk()
1653 self.download_status_updated()
1655 def on_itemRemoveOldEpisodes_activate( self, widget):
1656 columns = (
1657 ('title_and_description', None, None, _('Episode')),
1658 ('channel_prop', None, None, _('Podcast')),
1659 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
1660 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
1661 ('played_prop', None, None, _('Status')),
1662 ('age_prop', None, None, _('Downloaded')),
1665 selection_buttons = {
1666 _('Select played'): lambda episode: episode.is_played,
1667 _('Select older than %d days') % gl.config.episode_old_age: lambda episode: episode.is_old(),
1670 instructions = _('Select the episodes you want to delete from your hard disk.')
1672 episodes = []
1673 selected = []
1674 for channel in self.channels:
1675 for episode in channel.get_downloaded_episodes():
1676 if not episode.is_locked:
1677 episodes.append(episode)
1678 selected.append(episode.is_played)
1680 gPodderEpisodeSelector( title = _('Remove old episodes'), instructions = instructions, \
1681 episodes = episodes, selected = selected, columns = columns, \
1682 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
1683 selection_buttons = selection_buttons)
1685 def mark_selected_episodes_new(self):
1686 callback = lambda url: self.active_channel.find_episode(url).mark_new()
1687 self.for_each_selected_episode_url(callback)
1689 def mark_selected_episodes_old(self):
1690 callback = lambda url: self.active_channel.find_episode(url).mark_old()
1691 self.for_each_selected_episode_url(callback)
1693 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
1694 if toggle:
1695 callback = lambda url: db.mark_episode(url, is_played=True, toggle=True)
1696 else:
1697 callback = lambda url: db.mark_episode(url, is_played=new_value)
1699 self.for_each_selected_episode_url(callback)
1701 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
1702 if toggle:
1703 callback = lambda url: db.mark_episode(url, is_locked=True, toggle=True)
1704 else:
1705 callback = lambda url: db.mark_episode(url, is_locked=new_value)
1707 self.for_each_selected_episode_url(callback)
1709 def on_item_email_subscriptions_activate(self, widget):
1710 if not self.channels:
1711 self.show_message(_('Your subscription list is empty.'), _('Could not send list'))
1712 elif not gl.send_subscriptions():
1713 self.show_message(_('There was an error sending your subscription list via e-mail.'), _('Could not send list'))
1715 def on_itemUpdateChannel_activate(self, widget=None):
1716 self.update_feed_cache(channels=[self.active_channel,])
1718 def on_itemUpdate_activate(self, widget, notify_no_new_episodes=False):
1719 restore_from = can_restore_from_opml()
1721 if self.channels:
1722 self.update_feed_cache(notify_no_new_episodes=notify_no_new_episodes)
1723 elif restore_from is not None:
1724 title = _('Database upgrade required')
1725 message = _('gPodder is now using a new (much faster) database backend and needs to convert your current data. This can take some time. Start the conversion now?')
1726 if self.show_confirmation(message, title):
1727 add_callback = lambda url: self.add_new_channel(url, False, True)
1728 w = gtk.Dialog(_('Migrating to SQLite'), self.gPodder, 0, (gtk.STOCK_CLOSE, gtk.RESPONSE_ACCEPT))
1729 w.set_has_separator(False)
1730 w.set_response_sensitive(gtk.RESPONSE_ACCEPT, False)
1731 w.set_default_size(500, -1)
1732 pb = gtk.ProgressBar()
1733 l = gtk.Label()
1734 l.set_padding(6, 3)
1735 l.set_markup('<b><big>%s</big></b>' % _('SQLite migration'))
1736 l.set_alignment(0.0, 0.5)
1737 w.vbox.pack_start(l)
1738 l = gtk.Label()
1739 l.set_padding(6, 3)
1740 l.set_alignment(0.0, 0.5)
1741 l.set_text(_('Please wait while your settings are converted.'))
1742 w.vbox.pack_start(l)
1743 w.vbox.pack_start(pb)
1744 lb = gtk.Label()
1745 lb.set_ellipsize(pango.ELLIPSIZE_END)
1746 lb.set_alignment(0.0, 0.5)
1747 lb.set_padding(6, 6)
1748 w.vbox.pack_start(lb)
1750 def set_pb_status(pb, lb, fraction, text):
1751 pb.set_fraction(float(fraction)/100.0)
1752 pb.set_text('%.0f %%' % fraction)
1753 lb.set_markup('<i>%s</i>' % saxutils.escape(text))
1754 while gtk.events_pending():
1755 gtk.main_iteration(False)
1756 status_callback = lambda fraction, text: set_pb_status(pb, lb, fraction, text)
1757 get_localdb = lambda channel: LocalDBReader(channel.url).read(channel.index_file)
1758 w.show_all()
1759 start = datetime.datetime.now()
1760 gl.migrate_to_sqlite(add_callback, status_callback, load_channels, get_localdb)
1761 # Refresh the view with the updated episodes
1762 self.updateComboBox()
1763 time_taken = str(datetime.datetime.now()-start)
1764 status_callback(100.0, _('Migration finished in %s') % time_taken)
1765 w.set_response_sensitive(gtk.RESPONSE_ACCEPT, True)
1766 w.run()
1767 w.destroy()
1768 else:
1769 title = _('Import podcasts from the web')
1770 message = _('Your podcast list is empty. Do you want to see a list of example podcasts you can subscribe to?')
1771 if self.show_confirmation(message, title):
1772 self.on_itemImportChannels_activate(self, widget)
1774 def download_episode_list( self, episodes):
1775 services.download_status_manager.start_batch_mode()
1776 for episode in episodes:
1777 log('Downloading episode: %s', episode.title, sender = self)
1778 filename = episode.local_filename()
1779 if not episode.was_downloaded(and_exists=True) and not services.download_status_manager.is_download_in_progress( episode.url):
1780 download.DownloadThread( episode.channel, episode, self.notification).start()
1781 services.download_status_manager.end_batch_mode()
1783 def new_episodes_show(self, episodes):
1784 columns = (
1785 ('title_and_description', None, None, _('Episode')),
1786 ('channel_prop', None, None, _('Podcast')),
1787 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
1788 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
1791 if len(episodes) > 0:
1792 instructions = _('Select the episodes you want to download now.')
1794 gPodderEpisodeSelector(title=_('New episodes available'), instructions=instructions, \
1795 episodes=episodes, columns=columns, selected_default=True, \
1796 stock_ok_button = 'gpodder-download', \
1797 callback=self.download_episode_list, \
1798 remove_callback=lambda e: e.mark_old(), \
1799 remove_action=_('Never download'), \
1800 remove_finished=self.episode_new_status_changed)
1801 else:
1802 title = _('No new episodes')
1803 message = _('No new episodes to download.\nPlease check for new episodes later.')
1804 self.show_message(message, title)
1806 def on_itemDownloadAllNew_activate(self, widget, *args):
1807 self.download_all_new()
1809 def download_all_new(self, channels=None):
1810 if channels is None:
1811 channels = self.channels
1812 episodes = []
1813 for channel in channels:
1814 for episode in channel.get_new_episodes():
1815 episodes.append(episode)
1816 self.new_episodes_show(episodes)
1818 def get_all_episodes(self, exclude_nonsignificant=True ):
1819 """'exclude_nonsignificant' will exclude non-downloaded episodes
1820 and all episodes from channels that are set to skip when syncing"""
1821 episode_list = []
1822 for channel in self.channels:
1823 if not channel.sync_to_devices and exclude_nonsignificant:
1824 log('Skipping channel: %s', channel.title, sender=self)
1825 continue
1826 for episode in channel.get_all_episodes():
1827 if episode.was_downloaded(and_exists=True) or not exclude_nonsignificant:
1828 episode_list.append(episode)
1829 return episode_list
1831 def ipod_delete_played(self, device):
1832 all_episodes = self.get_all_episodes( exclude_nonsignificant=False )
1833 episodes_on_device = device.get_all_tracks()
1834 for local_episode in all_episodes:
1835 device_episode = device.episode_on_device(local_episode)
1836 if device_episode and ( local_episode.is_played and not local_episode.is_locked
1837 or local_episode.state == db.STATE_DELETED ):
1838 log("mp3_player_delete_played: removing %s" % device_episode.title)
1839 device.remove_track(device_episode)
1841 def on_sync_to_ipod_activate(self, widget, episodes=None):
1842 # make sure gpod is available before even trying to sync
1843 if gl.config.device_type == 'ipod' and not sync.gpod_available:
1844 title = _('Cannot Sync To iPod')
1845 message = _('Please install the libgpod python bindings (python-gpod) and restart gPodder to continue.')
1846 self.notification( message, title )
1847 return
1848 elif gl.config.device_type == 'mtp' and not sync.pymtp_available:
1849 title = _('Cannot sync to MTP device')
1850 message = _('Please install the libmtp python bindings (python-pymtp) and restart gPodder to continue.')
1851 self.notification( message, title )
1852 return
1854 device = sync.open_device()
1855 device.register( 'post-done', self.sync_to_ipod_completed )
1857 if device is None:
1858 title = _('No device configured')
1859 message = _('To use the synchronization feature, please configure your device in the preferences dialog first.')
1860 self.notification(message, title)
1861 return
1863 if not device.open():
1864 title = _('Cannot open device')
1865 message = _('There has been an error opening your device.')
1866 self.notification(message, title)
1867 return
1869 if gl.config.ipod_purge_old_episodes:
1870 device.purge()
1872 sync_all_episodes = not bool(episodes)
1874 if episodes is None:
1875 episodes = self.get_all_episodes()
1877 # make sure we have enough space on the device
1878 total_size = 0
1879 free_space = device.get_free_space()
1880 for episode in episodes:
1881 if not device.episode_on_device(episode) and not (sync_all_episodes and gl.config.only_sync_not_played and episode.is_played):
1882 total_size += util.calculate_size(str(episode.local_filename()))
1884 if total_size > free_space:
1885 # can be negative because of the 10 MiB for reserved for the iTunesDB
1886 free_space = max( free_space, 0 )
1887 log('(gpodder.sync) Not enough free space. Transfer size = %d, Free space = %d', total_size, free_space)
1888 title = _('Not enough space left on device.')
1889 message = _('%s remaining on device.\nPlease free up %s and try again.' % (
1890 util.format_filesize( free_space ), util.format_filesize( total_size - free_space )))
1891 self.notification(message, title)
1892 else:
1893 # start syncing!
1894 gPodderSync(device=device, gPodder=self)
1895 Thread(target=self.sync_to_ipod_thread, args=(widget, device, sync_all_episodes, episodes)).start()
1896 if self.tray_icon:
1897 self.tray_icon.set_synchronisation_device(device)
1899 def sync_to_ipod_completed(self, device, successful_sync):
1900 device.unregister( 'post-done', self.sync_to_ipod_completed )
1902 if self.tray_icon:
1903 self.tray_icon.release_synchronisation_device()
1905 if not successful_sync:
1906 title = _('Error closing device')
1907 message = _('There has been an error closing your device.')
1908 self.notification(message, title)
1910 # update model for played state updates after sync
1911 util.idle_add(self.updateComboBox)
1913 def sync_to_ipod_thread(self, widget, device, sync_all_episodes, episodes=None):
1914 if sync_all_episodes:
1915 device.add_tracks(episodes)
1916 # 'only_sync_not_played' must be used or else all the played
1917 # tracks will be copied then immediately deleted
1918 if gl.config.mp3_player_delete_played and gl.config.only_sync_not_played:
1919 self.ipod_delete_played(device)
1920 else:
1921 device.add_tracks(episodes, force_played=True)
1922 device.close()
1924 def ipod_cleanup_callback(self, device, tracks):
1925 title = _('Delete podcasts from device?')
1926 message = _('The selected episodes will be removed from your device. This cannot be undone. Files in your gPodder library will be unaffected. Do you really want to delete these episodes from your device?')
1927 if len(tracks) > 0 and self.show_confirmation(message, title):
1928 device.remove_tracks(tracks)
1930 if not device.close():
1931 title = _('Error closing device')
1932 message = _('There has been an error closing your device.')
1933 self.show_message(message, title)
1934 return
1936 def on_cleanup_ipod_activate(self, widget, *args):
1937 columns = (
1938 ('title', None, None, _('Episode')),
1939 ('podcast', None, None, _('Podcast')),
1940 ('filesize', None, None, _('Size')),
1941 ('modified', None, None, _('Copied')),
1942 ('playcount', None, None, _('Play count')),
1943 ('released', None, None, _('Released')),
1946 device = sync.open_device()
1948 if device is None:
1949 title = _('No device configured')
1950 message = _('To use the synchronization feature, please configure your device in the preferences dialog first.')
1951 self.show_message(message, title)
1952 return
1954 if not device.open():
1955 title = _('Cannot open device')
1956 message = _('There has been an error opening your device.')
1957 self.show_message(message, title)
1958 return
1960 gPodderSync(device=device, gPodder=self)
1962 tracks = device.get_all_tracks()
1963 if len(tracks) > 0:
1964 remove_tracks_callback = lambda tracks: self.ipod_cleanup_callback(device, tracks)
1965 wanted_columns = []
1966 for key, sort_name, sort_type, caption in columns:
1967 want_this_column = False
1968 for track in tracks:
1969 if getattr(track, key) is not None:
1970 want_this_column = True
1971 break
1973 if want_this_column:
1974 wanted_columns.append((key, sort_name, sort_type, caption))
1975 title = _('Remove podcasts from device')
1976 instructions = _('Select the podcast episodes you want to remove from your device.')
1977 gPodderEpisodeSelector(title=title, instructions=instructions, episodes=tracks, columns=wanted_columns, \
1978 stock_ok_button=gtk.STOCK_DELETE, callback=remove_tracks_callback, tooltip_attribute=None)
1979 else:
1980 title = _('No files on device')
1981 message = _('The devices contains no files to be removed.')
1982 self.show_message(message, title)
1983 device.close()
1985 def on_manage_device_playlist(self, widget):
1986 # make sure gpod is available before even trying to sync
1987 if gl.config.device_type == 'ipod' and not sync.gpod_available:
1988 title = _('Cannot manage iPod playlist')
1989 message = _('This feature is not available for iPods.')
1990 self.notification( message, title )
1991 return
1992 elif gl.config.device_type == 'mtp' and not sync.pymtp_available:
1993 title = _('Cannot manage MTP device playlist')
1994 message = _('This feature is not available for MTP devices.')
1995 self.notification( message, title )
1996 return
1998 device = sync.open_device()
2000 if device is None:
2001 title = _('No device configured')
2002 message = _('To use the playlist feature, please configure your Filesystem based MP3-Player in the preferences dialog first.')
2003 self.notification(message, title)
2004 return
2006 if not device.open():
2007 title = _('Cannot open device')
2008 message = _('There has been an error opening your device.')
2009 self.notification(message, title)
2010 return
2012 gPodderPlaylist(device=device, gPodder=self)
2013 device.close()
2015 def show_hide_tray_icon(self):
2016 if gl.config.display_tray_icon and have_trayicon and self.tray_icon is None:
2017 self.tray_icon = trayicon.GPodderStatusIcon(self, scalable_dir)
2018 elif not gl.config.display_tray_icon and self.tray_icon is not None:
2019 self.tray_icon.set_visible(False)
2020 del self.tray_icon
2021 self.tray_icon = None
2023 if gl.config.minimize_to_tray and self.tray_icon:
2024 self.tray_icon.set_visible(self.minimized)
2025 elif self.tray_icon:
2026 self.tray_icon.set_visible(True)
2028 def on_itemShowToolbar_activate(self, widget):
2029 gl.config.show_toolbar = self.itemShowToolbar.get_active()
2031 def on_itemShowDescription_activate(self, widget):
2032 gl.config.episode_list_descriptions = self.itemShowDescription.get_active()
2034 def update_item_device( self):
2035 if gl.config.device_type != 'none':
2036 self.itemDevice.show_all()
2037 (label,) = self.itemDevice.get_children()
2038 label.set_text(gl.get_device_name())
2039 else:
2040 self.itemDevice.hide_all()
2042 def properties_closed( self):
2043 self.show_hide_tray_icon()
2044 self.update_item_device()
2045 self.updateComboBox()
2047 def on_itemPreferences_activate(self, widget, *args):
2048 if gpodder.interface == gpodder.GUI:
2049 gPodderProperties(callback_finished=self.properties_closed, user_apps_reader=self.user_apps_reader)
2050 else:
2051 gPodderMaemoPreferences()
2053 def on_itemDependencies_activate(self, widget):
2054 gPodderDependencyManager()
2056 def on_add_new_google_search(self, widget, *args):
2057 def add_google_video_search(query):
2058 self.add_new_channel('http://video.google.com/videofeed?type=search&q='+urllib.quote(query)+'&so=1&num=250&output=rss')
2060 gPodderAddPodcastDialog(url_callback=add_google_video_search, custom_title=_('Add Google Video search'), custom_label=_('Search for:'))
2062 def on_itemAddChannel_activate(self, widget, *args):
2063 gPodderAddPodcastDialog(url_callback=self.add_new_channel)
2065 def on_itemEditChannel_activate(self, widget, *args):
2066 if self.active_channel is None:
2067 title = _('No podcast selected')
2068 message = _('Please select a podcast in the podcasts list to edit.')
2069 self.show_message( message, title)
2070 return
2072 gPodderChannel(channel=self.active_channel, callback_closed=lambda: self.updateComboBox(only_selected_channel=True), callback_change_url=self.change_channel_url)
2074 def change_channel_url(self, old_url, new_url):
2075 channel = None
2076 try:
2077 channel = podcastChannel.load(url=new_url, create=True)
2078 except:
2079 channel = None
2081 if channel is None:
2082 self.show_message(_('The specified URL is invalid. The old URL has been used instead.'), _('Invalid URL'))
2083 return
2085 for channel in self.channels:
2086 if channel.url == old_url:
2087 log('=> change channel url from %s to %s', old_url, new_url)
2088 old_save_dir = channel.save_dir
2089 channel.url = new_url
2090 new_save_dir = channel.save_dir
2091 log('old save dir=%s', old_save_dir, sender=self)
2092 log('new save dir=%s', new_save_dir, sender=self)
2093 files = glob.glob(os.path.join(old_save_dir, '*'))
2094 log('moving %d files to %s', len(files), new_save_dir, sender=self)
2095 for file in files:
2096 log('moving %s', file, sender=self)
2097 shutil.move(file, new_save_dir)
2098 try:
2099 os.rmdir(old_save_dir)
2100 except:
2101 log('Warning: cannot delete %s', old_save_dir, sender=self)
2103 save_channels(self.channels)
2104 # update feed cache and select the podcast with the new URL afterwards
2105 self.update_feed_cache(force_update=False, select_url_afterwards=new_url)
2107 def on_itemRemoveChannel_activate(self, widget, *args):
2108 try:
2109 if gpodder.interface == gpodder.GUI:
2110 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2111 dialog.add_button(gtk.STOCK_NO, gtk.RESPONSE_NO)
2112 dialog.add_button(gtk.STOCK_YES, gtk.RESPONSE_YES)
2114 title = _('Remove podcast and episodes?')
2115 message = _('Do you really want to remove <b>%s</b> and all downloaded episodes?') % saxutils.escape(self.active_channel.title)
2117 dialog.set_title(title)
2118 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2120 cb_ask = gtk.CheckButton(_('Do not delete my downloaded episodes'))
2121 dialog.vbox.pack_start(cb_ask)
2122 cb_ask.show_all()
2123 affirmative = gtk.RESPONSE_YES
2124 elif gpodder.interface == gpodder.MAEMO:
2125 cb_ask = gtk.CheckButton('') # dummy check button
2126 dialog = hildon.Note('confirmation', (self.gPodder, _('Do you really want to remove this podcast and all downloaded episodes?')))
2127 affirmative = gtk.RESPONSE_OK
2129 result = dialog.run()
2130 dialog.destroy()
2132 if result == affirmative:
2133 # delete downloaded episodes only if checkbox is unchecked
2134 if cb_ask.get_active() == False:
2135 self.active_channel.remove_downloaded()
2136 else:
2137 log('Not removing downloaded episodes', sender=self)
2139 # only delete partial files if we do not have any downloads in progress
2140 delete_partial = not services.download_status_manager.has_items()
2141 gl.clean_up_downloads(delete_partial)
2143 # cancel any active downloads from this channel
2144 if not delete_partial:
2145 for episode in self.active_channel.get_all_episodes():
2146 services.download_status_manager.cancel_by_url(episode.url)
2148 # get the URL of the podcast we want to select next
2149 position = self.channels.index(self.active_channel)
2150 if position == len(self.channels)-1:
2151 # this is the last podcast, so select the URL
2152 # of the item before this one (i.e. the "new last")
2153 select_url = self.channels[position-1].url
2154 else:
2155 # there is a podcast after the deleted one, so
2156 # we simply select the one that comes after it
2157 select_url = self.channels[position+1].url
2159 # Remove the channel
2160 self.active_channel.delete()
2161 self.channels.remove(self.active_channel)
2162 save_channels(self.channels)
2164 # Re-load the channels and select the desired new channel
2165 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
2166 except:
2167 log('There has been an error removing the channel.', traceback=True, sender=self)
2168 self.update_podcasts_tab()
2170 def get_opml_filter(self):
2171 filter = gtk.FileFilter()
2172 filter.add_pattern('*.opml')
2173 filter.add_pattern('*.xml')
2174 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
2175 return filter
2177 def on_item_import_from_file_activate(self, widget, filename=None):
2178 if filename is None:
2179 if gpodder.interface == gpodder.GUI:
2180 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
2181 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2182 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2183 elif gpodder.interface == gpodder.MAEMO:
2184 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
2185 dlg.set_filter(self.get_opml_filter())
2186 response = dlg.run()
2187 filename = None
2188 if response == gtk.RESPONSE_OK:
2189 filename = dlg.get_filename()
2190 dlg.destroy()
2192 if filename is not None:
2193 gPodderOpmlLister(custom_title=_('Import podcasts from OPML file'), hide_url_entry=True).get_channels_from_url(filename, lambda url: self.add_new_channel(url,False,block=True), lambda: self.on_itemDownloadAllNew_activate(self.gPodder))
2195 def on_itemExportChannels_activate(self, widget, *args):
2196 if not self.channels:
2197 title = _('Nothing to export')
2198 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
2199 self.show_message( message, title)
2200 return
2202 if gpodder.interface == gpodder.GUI:
2203 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
2204 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2205 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
2206 elif gpodder.interface == gpodder.MAEMO:
2207 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
2208 dlg.set_filter(self.get_opml_filter())
2209 response = dlg.run()
2210 if response == gtk.RESPONSE_OK:
2211 filename = dlg.get_filename()
2212 dlg.destroy()
2213 exporter = opml.Exporter( filename)
2214 if exporter.write(self.channels):
2215 if len(self.channels) == 1:
2216 title = _('One subscription exported')
2217 else:
2218 title = _('%d subscriptions exported') % len(self.channels)
2219 self.show_message(_('Your podcast list has been successfully exported.'), title)
2220 else:
2221 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'))
2222 else:
2223 dlg.destroy()
2225 def on_itemImportChannels_activate(self, widget, *args):
2226 gPodderOpmlLister().get_channels_from_url(gl.config.opml_url, lambda url: self.add_new_channel(url,False,block=True), lambda: self.on_itemDownloadAllNew_activate(self.gPodder))
2228 def on_homepage_activate(self, widget, *args):
2229 util.open_website(app_website)
2231 def on_wiki_activate(self, widget, *args):
2232 util.open_website('http://wiki.gpodder.org/')
2234 def on_bug_tracker_activate(self, widget, *args):
2235 util.open_website('http://bugs.gpodder.org/')
2237 def on_itemAbout_activate(self, widget, *args):
2238 dlg = gtk.AboutDialog()
2239 dlg.set_name(app_name.replace('p', 'P')) # gpodder->gPodder
2240 dlg.set_version( app_version)
2241 dlg.set_copyright( app_copyright)
2242 dlg.set_website( app_website)
2243 dlg.set_translator_credits( _('translator-credits'))
2244 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
2246 if gpodder.interface == gpodder.GUI:
2247 # For the "GUI" version, we add some more
2248 # items to the about dialog (credits and logo)
2249 dlg.set_authors(app_authors)
2250 try:
2251 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(scalable_dir))
2252 except:
2253 pass
2255 dlg.run()
2257 def on_wNotebook_switch_page(self, widget, *args):
2258 page_num = args[1]
2259 if gpodder.interface == gpodder.MAEMO:
2260 page = self.wNotebook.get_nth_page(page_num)
2261 tab_label = self.wNotebook.get_tab_label(page).get_text()
2262 if page_num == 0 and self.active_channel is not None:
2263 self.set_title(self.active_channel.title)
2264 else:
2265 self.set_title(tab_label)
2266 if page_num == 0:
2267 self.play_or_download()
2268 else:
2269 self.toolDownload.set_sensitive( False)
2270 self.toolPlay.set_sensitive( False)
2271 self.toolTransfer.set_sensitive( False)
2272 self.toolCancel.set_sensitive( services.download_status_manager.has_items())
2274 def on_treeChannels_row_activated(self, widget, *args):
2275 self.on_itemEditChannel_activate( self.treeChannels)
2277 def on_treeChannels_cursor_changed(self, widget, *args):
2278 ( model, iter ) = self.treeChannels.get_selection().get_selected()
2280 if model is not None and iter != None:
2281 id = model.get_path( iter)[0]
2282 self.active_channel = self.channels[id]
2284 if gpodder.interface == gpodder.MAEMO:
2285 self.set_title(self.active_channel.title)
2286 self.itemEditChannel.show_all()
2287 self.itemRemoveChannel.show_all()
2288 else:
2289 self.active_channel = None
2290 self.itemEditChannel.hide_all()
2291 self.itemRemoveChannel.hide_all()
2293 self.updateTreeView()
2295 def on_entryAddChannel_changed(self, widget, *args):
2296 active = self.entryAddChannel.get_text() not in ('', self.ENTER_URL_TEXT)
2297 self.btnAddChannel.set_sensitive( active)
2299 def on_btnAddChannel_clicked(self, widget, *args):
2300 url = self.entryAddChannel.get_text()
2301 self.entryAddChannel.set_text('')
2302 self.add_new_channel( url)
2304 def on_btnEditChannel_clicked(self, widget, *args):
2305 self.on_itemEditChannel_activate( widget, args)
2307 def on_treeAvailable_row_activated(self, widget, path=None, view_column=None):
2309 What this function does depends on from which widget it is called.
2310 It gets the selected episodes of the current podcast and runs one
2311 of the following actions on them:
2313 * Transfer (to MP3 player, iPod, etc..)
2314 * Playback/open files
2315 * Show the episode info dialog
2316 * Download episodes
2318 try:
2319 selection = self.treeAvailable.get_selection()
2320 (model, paths) = selection.get_selected_rows()
2322 wname = widget.get_name()
2323 do_transfer = (wname in ('itemTransferSelected', 'toolTransfer'))
2324 do_playback = (wname in ('itemPlaySelected', 'itemOpenSelected', 'toolPlay'))
2325 do_epdialog = (wname in ('treeAvailable', 'item_episode_details'))
2327 episodes = []
2328 for path in paths:
2329 it = model.get_iter(path)
2330 url = model.get_value(it, 0)
2331 episode = self.active_channel.find_episode(url)
2332 episodes.append(episode)
2334 if len(episodes) == 0:
2335 log('No episodes selected', sender=self)
2337 if do_transfer:
2338 self.on_sync_to_ipod_activate(widget, episodes)
2339 elif do_playback:
2340 for episode in episodes:
2341 # Make sure to mark the episode as downloaded
2342 if os.path.exists(episode.local_filename()):
2343 episode.channel.addDownloadedItem(episode)
2344 self.playback_episode(episode)
2345 elif gl.config.enable_streaming:
2346 self.playback_episode(episode, stream=True)
2347 elif do_epdialog:
2348 play_callback = lambda: self.playback_episode(episode)
2349 download_callback = lambda: self.download_episode_list([episode])
2350 gPodderEpisode(episode=episode, download_callback=download_callback, play_callback=play_callback)
2351 else:
2352 self.download_episode_list(episodes)
2353 except:
2354 log('Error in on_treeAvailable_row_activated', traceback=True, sender=self)
2356 def on_treeAvailable_button_release_event(self, widget, *args):
2357 self.play_or_download()
2359 def auto_update_procedure(self, first_run=False):
2360 log('auto_update_procedure() got called', sender=self)
2361 if not first_run and gl.config.auto_update_feeds and self.minimized:
2362 self.update_feed_cache(force_update=True)
2364 next_update = 60*1000*gl.config.auto_update_frequency
2365 gobject.timeout_add(next_update, self.auto_update_procedure)
2367 def on_treeDownloads_row_activated(self, widget, *args):
2368 cancel_urls = []
2370 if self.wNotebook.get_current_page() > 0:
2371 # Use the download list treeview + model
2372 ( tree, column ) = ( self.treeDownloads, 3 )
2373 else:
2374 # Use the available podcasts treeview + model
2375 ( tree, column ) = ( self.treeAvailable, 0 )
2377 selection = tree.get_selection()
2378 (model, paths) = selection.get_selected_rows()
2379 for path in paths:
2380 url = model.get_value( model.get_iter( path), column)
2381 cancel_urls.append( url)
2383 if len( cancel_urls) == 0:
2384 log('Nothing selected.', sender = self)
2385 return
2387 if len( cancel_urls) == 1:
2388 title = _('Cancel download?')
2389 message = _("Cancelling this download will remove the partially downloaded file and stop the download.")
2390 else:
2391 title = _('Cancel downloads?')
2392 message = _("Cancelling the download will stop the %d selected downloads and remove partially downloaded files.") % selection.count_selected_rows()
2394 if self.show_confirmation( message, title):
2395 services.download_status_manager.start_batch_mode()
2396 for url in cancel_urls:
2397 services.download_status_manager.cancel_by_url( url)
2398 services.download_status_manager.end_batch_mode()
2400 def on_btnCancelDownloadStatus_clicked(self, widget, *args):
2401 self.on_treeDownloads_row_activated( widget, None)
2403 def on_btnCancelAll_clicked(self, widget, *args):
2404 self.treeDownloads.get_selection().select_all()
2405 self.on_treeDownloads_row_activated( self.toolCancel, None)
2406 self.treeDownloads.get_selection().unselect_all()
2408 def on_btnDownloadedDelete_clicked(self, widget, *args):
2409 if self.active_channel is None:
2410 return
2412 channel_url = self.active_channel.url
2413 selection = self.treeAvailable.get_selection()
2414 ( model, paths ) = selection.get_selected_rows()
2416 if selection.count_selected_rows() == 0:
2417 log( 'Nothing selected - will not remove any downloaded episode.')
2418 return
2420 if selection.count_selected_rows() == 1:
2421 episode_title = saxutils.escape(model.get_value(model.get_iter(paths[0]), 1))
2423 episode = db.load_episode(model.get_value(model.get_iter(paths[0]), 0))
2424 if episode['is_locked']:
2425 title = _('%s is locked') % episode_title
2426 message = _('You cannot delete this locked episode. You must unlock it before you can delete it.')
2427 self.notification(message, title)
2428 return
2430 title = _('Remove %s?') % episode_title
2431 message = _("If you remove this episode, it will be deleted from your computer. If you want to listen to this episode again, you will have to re-download it.")
2432 else:
2433 title = _('Remove %d episodes?') % selection.count_selected_rows()
2434 message = _('If you remove these episodes, they will be deleted from your computer. If you want to listen to any of these episodes again, you will have to re-download the episodes in question.')
2436 locked_count = 0
2437 for path in paths:
2438 episode = db.load_episode(model.get_value(model.get_iter(path), 0))
2439 if episode['is_locked']:
2440 locked_count += 1
2442 if selection.count_selected_rows() == locked_count:
2443 title = _('Episodes are locked')
2444 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2445 self.notification(message, title)
2446 return
2447 elif locked_count > 0:
2448 title = _('Remove %d out of %d episodes?') % (selection.count_selected_rows() - locked_count, selection.count_selected_rows())
2449 message = _('The selection contains locked episodes. These will not be deleted. If you want to listen to any of these episodes again, then you will have to re-download them.')
2451 # if user confirms deletion, let's remove some stuff ;)
2452 if self.show_confirmation( message, title):
2453 try:
2454 # iterate over the selection, see also on_treeDownloads_row_activated
2455 for path in paths:
2456 url = model.get_value( model.get_iter( path), 0)
2457 self.active_channel.delete_episode_by_url( url)
2459 # now, clear local db cache so we can re-read it
2460 self.updateComboBox()
2461 except:
2462 log( 'Error while deleting (some) downloads.')
2464 # only delete partial files if we do not have any downloads in progress
2465 delete_partial = not services.download_status_manager.has_items()
2466 gl.clean_up_downloads(delete_partial)
2467 self.updateTreeView()
2469 def on_key_press(self, widget, event):
2470 # Allow tab switching with Ctrl + PgUp/PgDown
2471 if event.state & gtk.gdk.CONTROL_MASK:
2472 if event.keyval == gtk.keysyms.Page_Up:
2473 self.wNotebook.prev_page()
2474 return True
2475 elif event.keyval == gtk.keysyms.Page_Down:
2476 self.wNotebook.next_page()
2477 return True
2479 # After this code we only handle Maemo hardware keys,
2480 # so if we are not a Maemo app, we don't do anything
2481 if gpodder.interface != gpodder.MAEMO:
2482 return False
2484 if event.keyval == gtk.keysyms.F6:
2485 if self.fullscreen:
2486 self.window.unfullscreen()
2487 else:
2488 self.window.fullscreen()
2489 if event.keyval == gtk.keysyms.Escape:
2490 new_visibility = not self.vboxChannelNavigator.get_property('visible')
2491 self.vboxChannelNavigator.set_property('visible', new_visibility)
2492 self.column_size.set_visible(not new_visibility)
2493 self.column_released.set_visible(not new_visibility)
2495 diff = 0
2496 if event.keyval == gtk.keysyms.F7: #plus
2497 diff = 1
2498 elif event.keyval == gtk.keysyms.F8: #minus
2499 diff = -1
2501 if diff != 0:
2502 selection = self.treeChannels.get_selection()
2503 (model, iter) = selection.get_selected()
2504 selection.select_path(((model.get_path(iter)[0]+diff)%len(model),))
2505 self.on_treeChannels_cursor_changed(self.treeChannels)
2507 def window_state_event(self, widget, event):
2508 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
2509 self.fullscreen = True
2510 else:
2511 self.fullscreen = False
2513 old_minimized = self.minimized
2515 self.minimized = bool(event.new_window_state & gtk.gdk.WINDOW_STATE_ICONIFIED)
2516 if gpodder.interface == gpodder.MAEMO:
2517 self.minimized = bool(event.new_window_state & gtk.gdk.WINDOW_STATE_WITHDRAWN)
2519 if old_minimized != self.minimized and self.tray_icon:
2520 self.gPodder.set_skip_taskbar_hint(self.minimized)
2521 elif not self.tray_icon:
2522 self.gPodder.set_skip_taskbar_hint(False)
2524 if gl.config.minimize_to_tray and self.tray_icon:
2525 self.tray_icon.set_visible(self.minimized)
2527 def uniconify_main_window(self):
2528 if self.minimized:
2529 self.gPodder.present()
2531 def iconify_main_window(self):
2532 if not self.minimized:
2533 self.gPodder.iconify()
2535 def update_podcasts_tab(self):
2536 if len(self.channels):
2537 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
2538 else:
2539 self.label2.set_text(_('Podcasts'))
2541 class gPodderChannel(GladeWidget):
2542 finger_friendly_widgets = ['btn_website', 'btnOK', 'channel_description']
2544 def new(self):
2545 global WEB_BROWSER_ICON
2546 self.changed = False
2547 self.image3167.set_property('icon-name', WEB_BROWSER_ICON)
2548 self.gPodderChannel.set_title( self.channel.title)
2549 self.entryTitle.set_text( self.channel.title)
2550 self.entryURL.set_text( self.channel.url)
2552 self.LabelDownloadTo.set_text( self.channel.save_dir)
2553 self.LabelWebsite.set_text( self.channel.link)
2555 self.cbNoSync.set_active( not self.channel.sync_to_devices)
2556 self.musicPlaylist.set_text(self.channel.device_playlist_name)
2557 if self.channel.username:
2558 self.FeedUsername.set_text( self.channel.username)
2559 if self.channel.password:
2560 self.FeedPassword.set_text( self.channel.password)
2562 services.cover_downloader.register('cover-available', self.cover_download_finished)
2563 services.cover_downloader.request_cover(self.channel)
2565 # Hide the website button if we don't have a valid URL
2566 if not self.channel.link:
2567 self.btn_website.hide_all()
2569 b = gtk.TextBuffer()
2570 b.set_text( self.channel.description)
2571 self.channel_description.set_buffer( b)
2573 #Add Drag and Drop Support
2574 flags = gtk.DEST_DEFAULT_ALL
2575 targets = [ ('text/uri-list', 0, 2), ('text/plain', 0, 4) ]
2576 actions = gtk.gdk.ACTION_DEFAULT | gtk.gdk.ACTION_COPY
2577 self.vboxCoverEditor.drag_dest_set( flags, targets, actions)
2578 self.vboxCoverEditor.connect( 'drag_data_received', self.drag_data_received)
2580 def on_btn_website_clicked(self, widget):
2581 util.open_website(self.channel.link)
2583 def on_btnDownloadCover_clicked(self, widget):
2584 if gpodder.interface == gpodder.GUI:
2585 dlg = gtk.FileChooserDialog(title=_('Select new podcast cover artwork'), parent=self.gPodderChannel, action=gtk.FILE_CHOOSER_ACTION_OPEN)
2586 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2587 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2588 elif gpodder.interface == gpodder.MAEMO:
2589 dlg = hildon.FileChooserDialog(self.gPodderChannel, gtk.FILE_CHOOSER_ACTION_OPEN)
2591 if dlg.run() == gtk.RESPONSE_OK:
2592 url = dlg.get_uri()
2593 services.cover_downloader.replace_cover(self.channel, url)
2595 dlg.destroy()
2597 def on_btnClearCover_clicked(self, widget):
2598 services.cover_downloader.replace_cover(self.channel)
2600 def cover_download_finished(self, channel_url, pixbuf):
2601 if pixbuf is not None:
2602 self.imgCover.set_from_pixbuf(pixbuf)
2603 self.gPodderChannel.show()
2605 def drag_data_received( self, widget, content, x, y, sel, ttype, time):
2606 files = sel.data.strip().split('\n')
2607 if len(files) != 1:
2608 self.show_message( _('You can only drop a single image or URL here.'), _('Drag and drop'))
2609 return
2611 file = files[0]
2613 if file.startswith('file://') or file.startswith('http://'):
2614 services.cover_downloader.replace_cover(self.channel, file)
2615 return
2617 self.show_message( _('You can only drop local files and http:// URLs here.'), _('Drag and drop'))
2619 def on_gPodderChannel_destroy(self, widget, *args):
2620 services.cover_downloader.unregister('cover-available', self.cover_download_finished)
2622 def on_btnOK_clicked(self, widget, *args):
2623 entered_url = self.entryURL.get_text()
2624 channel_url = self.channel.url
2626 if entered_url != channel_url:
2627 if self.show_confirmation(_('Do you really want to move this podcast to <b>%s</b>?') % (saxutils.escape(entered_url),), _('Really change URL?')):
2628 if hasattr(self, 'callback_change_url'):
2629 self.gPodderChannel.hide_all()
2630 self.callback_change_url(channel_url, entered_url)
2632 self.channel.sync_to_devices = not self.cbNoSync.get_active()
2633 self.channel.device_playlist_name = self.musicPlaylist.get_text()
2634 self.channel.set_custom_title( self.entryTitle.get_text())
2635 self.channel.username = self.FeedUsername.get_text().strip()
2636 self.channel.password = self.FeedPassword.get_text()
2637 self.channel.save()
2639 self.gPodderChannel.destroy()
2640 self.callback_closed()
2642 class gPodderAddPodcastDialog(GladeWidget):
2643 finger_friendly_widgets = ['btn_close', 'btn_add']
2645 def new(self):
2646 if not hasattr(self, 'url_callback'):
2647 log('No url callback set', sender=self)
2648 self.url_callback = None
2649 if hasattr(self, 'custom_label'):
2650 self.label_add.set_text(self.custom_label)
2651 if hasattr(self, 'custom_title'):
2652 self.gPodderAddPodcastDialog.set_title(self.custom_title)
2654 def on_btn_close_clicked(self, widget):
2655 self.gPodderAddPodcastDialog.destroy()
2657 def on_entry_url_changed(self, widget):
2658 self.btn_add.set_sensitive(self.entry_url.get_text().strip() != '')
2660 def on_btn_add_clicked(self, widget):
2661 url = self.entry_url.get_text()
2662 self.on_btn_close_clicked(widget)
2663 if self.url_callback is not None:
2664 self.url_callback(url)
2667 class gPodderMaemoPreferences(GladeWidget):
2668 finger_friendly_widgets = ['btn_close', 'label128', 'label129', 'btn_advanced']
2670 def new(self):
2671 gl.config.connect_gtk_togglebutton('update_on_startup', self.update_on_startup)
2672 gl.config.connect_gtk_togglebutton('display_tray_icon', self.show_tray_icon)
2673 gl.config.connect_gtk_togglebutton('enable_notifications', self.show_notifications)
2674 gl.config.connect_gtk_togglebutton('on_quit_ask', self.on_quit_ask)
2676 self.restart_required = False
2677 self.show_tray_icon.connect('clicked', self.on_restart_required)
2678 self.show_notifications.connect('clicked', self.on_restart_required)
2680 def on_restart_required(self, widget):
2681 self.restart_required = True
2683 def on_btn_advanced_clicked(self, widget):
2684 self.gPodderMaemoPreferences.destroy()
2685 gPodderConfigEditor()
2687 def on_btn_close_clicked(self, widget):
2688 self.gPodderMaemoPreferences.destroy()
2689 if self.restart_required:
2690 self.show_message(_('Please restart gPodder for the changes to take effect.'))
2693 class gPodderProperties(GladeWidget):
2694 def new(self):
2695 if not hasattr( self, 'callback_finished'):
2696 self.callback_finished = None
2698 if gpodder.interface == gpodder.MAEMO:
2699 self.table13.hide_all() # bluetooth
2700 self.table5.hide_all() # player
2701 self.gPodderProperties.fullscreen()
2703 gl.config.connect_gtk_editable( 'http_proxy', self.httpProxy)
2704 gl.config.connect_gtk_editable( 'ftp_proxy', self.ftpProxy)
2705 gl.config.connect_gtk_editable( 'player', self.openApp)
2706 gl.config.connect_gtk_editable('videoplayer', self.openVideoApp)
2707 gl.config.connect_gtk_editable( 'custom_sync_name', self.entryCustomSyncName)
2708 gl.config.connect_gtk_togglebutton( 'custom_sync_name_enabled', self.cbCustomSyncName)
2709 gl.config.connect_gtk_togglebutton( 'auto_download_when_minimized', self.downloadnew)
2710 gl.config.connect_gtk_togglebutton( 'update_on_startup', self.updateonstartup)
2711 gl.config.connect_gtk_togglebutton( 'only_sync_not_played', self.only_sync_not_played)
2712 gl.config.connect_gtk_togglebutton( 'fssync_channel_subfolders', self.cbChannelSubfolder)
2713 gl.config.connect_gtk_togglebutton( 'on_sync_mark_played', self.on_sync_mark_played)
2714 gl.config.connect_gtk_togglebutton( 'on_sync_delete', self.on_sync_delete)
2715 gl.config.connect_gtk_togglebutton( 'proxy_use_environment', self.cbEnvironmentVariables)
2716 gl.config.connect_gtk_spinbutton('episode_old_age', self.episode_old_age)
2717 gl.config.connect_gtk_togglebutton('auto_remove_old_episodes', self.auto_remove_old_episodes)
2718 gl.config.connect_gtk_togglebutton('auto_update_feeds', self.auto_update_feeds)
2719 gl.config.connect_gtk_spinbutton('auto_update_frequency', self.auto_update_frequency)
2720 gl.config.connect_gtk_togglebutton('display_tray_icon', self.display_tray_icon)
2721 gl.config.connect_gtk_togglebutton('minimize_to_tray', self.minimize_to_tray)
2722 gl.config.connect_gtk_togglebutton('enable_notifications', self.enable_notifications)
2723 gl.config.connect_gtk_togglebutton('start_iconified', self.start_iconified)
2724 gl.config.connect_gtk_togglebutton('bluetooth_enabled', self.bluetooth_enabled)
2725 gl.config.connect_gtk_togglebutton('bluetooth_ask_always', self.bluetooth_ask_always)
2726 gl.config.connect_gtk_togglebutton('bluetooth_ask_never', self.bluetooth_ask_never)
2727 gl.config.connect_gtk_togglebutton('bluetooth_use_converter', self.bluetooth_use_converter)
2728 gl.config.connect_gtk_filechooser( 'bluetooth_converter', self.bluetooth_converter, is_for_files=True)
2729 gl.config.connect_gtk_togglebutton('ipod_write_gtkpod_extended', self.ipod_write_gtkpod_extended)
2730 gl.config.connect_gtk_togglebutton('mp3_player_delete_played', self.delete_episodes_marked_played)
2732 self.enable_notifications.set_sensitive(self.display_tray_icon.get_active())
2733 self.minimize_to_tray.set_sensitive(self.display_tray_icon.get_active())
2735 self.entryCustomSyncName.set_sensitive( self.cbCustomSyncName.get_active())
2737 self.iPodMountpoint.set_label( gl.config.ipod_mount)
2738 self.filesystemMountpoint.set_label( gl.config.mp3_player_folder)
2739 self.bluetooth_device_name.set_markup('<b>%s</b>'%gl.config.bluetooth_device_name)
2740 self.chooserDownloadTo.set_current_folder(gl.downloaddir)
2742 self.on_sync_delete.set_sensitive(not self.delete_episodes_marked_played.get_active())
2743 self.on_sync_mark_played.set_sensitive(not self.delete_episodes_marked_played.get_active())
2745 if tagging_supported():
2746 gl.config.connect_gtk_togglebutton( 'update_tags', self.updatetags)
2747 else:
2748 self.updatetags.set_sensitive( False)
2749 new_label = '%s (%s)' % ( self.updatetags.get_label(), _('needs python-eyed3') )
2750 self.updatetags.set_label( new_label)
2752 # device type
2753 self.comboboxDeviceType.set_active( 0)
2754 if gl.config.device_type == 'ipod':
2755 self.comboboxDeviceType.set_active( 1)
2756 elif gl.config.device_type == 'filesystem':
2757 self.comboboxDeviceType.set_active( 2)
2758 elif gl.config.device_type == 'mtp':
2759 self.comboboxDeviceType.set_active( 3)
2761 # setup cell renderers
2762 cellrenderer = gtk.CellRendererPixbuf()
2763 self.comboAudioPlayerApp.pack_start(cellrenderer, False)
2764 self.comboAudioPlayerApp.add_attribute(cellrenderer, 'pixbuf', 2)
2765 cellrenderer = gtk.CellRendererText()
2766 self.comboAudioPlayerApp.pack_start(cellrenderer, True)
2767 self.comboAudioPlayerApp.add_attribute(cellrenderer, 'markup', 0)
2769 cellrenderer = gtk.CellRendererPixbuf()
2770 self.comboVideoPlayerApp.pack_start(cellrenderer, False)
2771 self.comboVideoPlayerApp.add_attribute(cellrenderer, 'pixbuf', 2)
2772 cellrenderer = gtk.CellRendererText()
2773 self.comboVideoPlayerApp.pack_start(cellrenderer, True)
2774 self.comboVideoPlayerApp.add_attribute(cellrenderer, 'markup', 0)
2776 if not hasattr(self, 'user_apps_reader'):
2777 self.user_apps_reader = UserAppsReader(['audio', 'video'])
2779 self.comboAudioPlayerApp.set_row_separator_func(self.is_row_separator)
2780 self.comboVideoPlayerApp.set_row_separator_func(self.is_row_separator)
2782 if gpodder.interface == gpodder.GUI:
2783 self.user_apps_reader.read()
2785 self.comboAudioPlayerApp.set_model(self.user_apps_reader.get_applications_as_model('audio'))
2786 index = self.find_active_audio_app()
2787 self.comboAudioPlayerApp.set_active(index)
2788 self.comboVideoPlayerApp.set_model(self.user_apps_reader.get_applications_as_model('video'))
2789 index = self.find_active_video_app()
2790 self.comboVideoPlayerApp.set_active(index)
2792 self.ipodIcon.set_from_icon_name( 'gnome-dev-ipod', gtk.ICON_SIZE_BUTTON)
2794 def is_row_separator(self, model, iter):
2795 return model.get_value(iter, 0) == ''
2797 def update_mountpoint( self, ipod):
2798 if ipod is None or ipod.mount_point is None:
2799 self.iPodMountpoint.set_label( '')
2800 else:
2801 self.iPodMountpoint.set_label( ipod.mount_point)
2803 def on_bluetooth_select_device_clicked(self, widget):
2804 # Stupid GTK doesn't provide us with a method to directly
2805 # edit the text of a gtk.Button without "destroying" the
2806 # image on it, so we dig into the button's widget tree and
2807 # get the gtk.Image and gtk.Label and edit the label directly.
2808 alignment = self.bluetooth_select_device.get_child()
2809 hbox = alignment.get_child()
2810 (image, label) = hbox.get_children()
2812 old_text = label.get_text()
2813 label.set_text(_('Searching...'))
2814 self.bluetooth_select_device.set_sensitive(False)
2815 while gtk.events_pending():
2816 gtk.main_iteration(False)
2818 # FIXME: Make bluetooth device discovery threaded, so
2819 # the GUI doesn't freeze while we are searching for devices
2820 found = False
2821 for name, address in util.discover_bluetooth_devices():
2822 if self.show_confirmation('Use this device as your bluetooth device?', name):
2823 gl.config.bluetooth_device_name = name
2824 gl.config.bluetooth_device_address = address
2825 self.bluetooth_device_name.set_markup('<b>%s</b>'%gl.config.bluetooth_device_name)
2826 found = True
2827 break
2828 if not found:
2829 self.show_message('No more devices found', 'Scan finished')
2830 self.bluetooth_select_device.set_sensitive(True)
2831 label.set_text(old_text)
2833 def find_active_audio_app(self):
2834 model = self.comboAudioPlayerApp.get_model()
2835 iter = model.get_iter_first()
2836 index = 0
2837 while iter is not None:
2838 command = model.get_value(iter, 1)
2839 if command == self.openApp.get_text():
2840 return index
2841 iter = model.iter_next(iter)
2842 index += 1
2843 # return last item = custom command
2844 return index-1
2846 def find_active_video_app( self):
2847 model = self.comboVideoPlayerApp.get_model()
2848 iter = model.get_iter_first()
2849 index = 0
2850 while iter is not None:
2851 command = model.get_value(iter, 1)
2852 if command == self.openVideoApp.get_text():
2853 return index
2854 iter = model.iter_next(iter)
2855 index += 1
2856 # return last item = custom command
2857 return index-1
2859 def set_download_dir( self, new_download_dir, event = None):
2860 gl.downloaddir = self.chooserDownloadTo.get_filename()
2861 if gl.downloaddir != self.chooserDownloadTo.get_filename():
2862 self.notification(_('There has been an error moving your downloads to the specified location. The old download directory will be used instead.'), _('Error moving downloads'))
2864 if event:
2865 event.set()
2867 def on_auto_update_feeds_toggled( self, widget, *args):
2868 self.auto_update_frequency.set_sensitive(widget.get_active())
2870 def on_display_tray_icon_toggled( self, widget, *args):
2871 self.enable_notifications.set_sensitive(widget.get_active())
2872 self.minimize_to_tray.set_sensitive(widget.get_active())
2874 def on_cbCustomSyncName_toggled( self, widget, *args):
2875 self.entryCustomSyncName.set_sensitive( widget.get_active())
2877 def on_only_sync_not_played_toggled( self, widget, *args):
2878 self.delete_episodes_marked_played.set_sensitive( widget.get_active())
2879 if not widget.get_active():
2880 self.delete_episodes_marked_played.set_active(False)
2882 def on_delete_episodes_marked_played_toggled( self, widget, *args):
2883 if widget.get_active() and self.only_sync_not_played.get_active():
2884 self.on_sync_leave.set_active(True)
2885 self.on_sync_delete.set_sensitive(not widget.get_active())
2886 self.on_sync_mark_played.set_sensitive(not widget.get_active())
2888 def on_btnCustomSyncNameHelp_clicked( self, widget):
2889 examples = [
2890 '<i>{episode.title}</i> -&gt; <b>Interview with RMS</b>',
2891 '<i>{episode.basename}</i> -&gt; <b>70908-interview-rms</b>',
2892 '<i>{episode.published}</i> -&gt; <b>20070908</b>'
2895 info = [
2896 _('You can specify a custom format string for the file names on your MP3 player here.'),
2897 _('The format string will be used to generate a file name on your device. The file extension (e.g. ".mp3") will be added automatically.'),
2898 '\n'.join( [ ' %s' % s for s in examples ])
2901 self.show_message( '\n\n'.join( info), _('Custom format strings'))
2903 def on_gPodderProperties_destroy(self, widget, *args):
2904 self.on_btnOK_clicked( widget, *args)
2906 def on_btnConfigEditor_clicked(self, widget, *args):
2907 self.on_btnOK_clicked(widget, *args)
2908 gPodderConfigEditor()
2910 def on_comboAudioPlayerApp_changed(self, widget, *args):
2911 # find out which one
2912 iter = self.comboAudioPlayerApp.get_active_iter()
2913 model = self.comboAudioPlayerApp.get_model()
2914 command = model.get_value( iter, 1)
2915 if command == '':
2916 if self.openApp.get_text() == 'default':
2917 self.openApp.set_text('')
2918 self.openApp.set_sensitive( True)
2919 self.openApp.show()
2920 self.labelCustomCommand.show()
2921 else:
2922 self.openApp.set_text( command)
2923 self.openApp.set_sensitive( False)
2924 self.openApp.hide()
2925 self.labelCustomCommand.hide()
2927 def on_comboVideoPlayerApp_changed(self, widget, *args):
2928 # find out which one
2929 iter = self.comboVideoPlayerApp.get_active_iter()
2930 model = self.comboVideoPlayerApp.get_model()
2931 command = model.get_value(iter, 1)
2932 if command == '':
2933 if self.openVideoApp.get_text() == 'default':
2934 self.openVideoApp.set_text('')
2935 self.openVideoApp.set_sensitive(True)
2936 self.openVideoApp.show()
2937 self.labelCustomVideoCommand.show()
2938 else:
2939 self.openVideoApp.set_text(command)
2940 self.openVideoApp.set_sensitive(False)
2941 self.openVideoApp.hide()
2942 self.labelCustomVideoCommand.hide()
2944 def on_cbEnvironmentVariables_toggled(self, widget, *args):
2945 sens = not self.cbEnvironmentVariables.get_active()
2946 self.httpProxy.set_sensitive( sens)
2947 self.ftpProxy.set_sensitive( sens)
2949 def on_comboboxDeviceType_changed(self, widget, *args):
2950 active_item = self.comboboxDeviceType.get_active()
2952 # None
2953 sync_widgets = ( self.only_sync_not_played, self.labelSyncOptions,
2954 self.imageSyncOptions, self. separatorSyncOptions,
2955 self.on_sync_mark_played, self.on_sync_delete,
2956 self.on_sync_leave, self.label_after_sync, self.delete_episodes_marked_played)
2957 for widget in sync_widgets:
2958 if active_item == 0:
2959 widget.hide_all()
2960 else:
2961 widget.show_all()
2963 # iPod
2964 ipod_widgets = (self.ipodLabel, self.btn_iPodMountpoint,
2965 self.ipod_write_gtkpod_extended)
2966 for widget in ipod_widgets:
2967 if active_item == 1:
2968 widget.show_all()
2969 else:
2970 widget.hide_all()
2972 # filesystem-based MP3 player
2973 fs_widgets = ( self.filesystemLabel, self.btn_filesystemMountpoint,
2974 self.cbChannelSubfolder, self.cbCustomSyncName,
2975 self.entryCustomSyncName, self.btnCustomSyncNameHelp )
2976 for widget in fs_widgets:
2977 if active_item == 2:
2978 widget.show_all()
2979 else:
2980 widget.hide_all()
2982 def on_btn_iPodMountpoint_clicked(self, widget, *args):
2983 fs = gtk.FileChooserDialog( title = _('Select iPod mountpoint'), action = gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
2984 fs.add_button( gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2985 fs.add_button( gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2986 fs.set_current_folder(self.iPodMountpoint.get_label())
2987 if fs.run() == gtk.RESPONSE_OK:
2988 self.iPodMountpoint.set_label( fs.get_filename())
2989 fs.destroy()
2991 def on_btn_FilesystemMountpoint_clicked(self, widget, *args):
2992 fs = gtk.FileChooserDialog( title = _('Select folder for MP3 player'), action = gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
2993 fs.add_button( gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2994 fs.add_button( gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2995 fs.set_current_folder(self.filesystemMountpoint.get_label())
2996 if fs.run() == gtk.RESPONSE_OK:
2997 self.filesystemMountpoint.set_label( fs.get_filename())
2998 fs.destroy()
3000 def on_btnOK_clicked(self, widget, *args):
3001 gl.config.ipod_mount = self.iPodMountpoint.get_label()
3002 gl.config.mp3_player_folder = self.filesystemMountpoint.get_label()
3004 if gl.downloaddir != self.chooserDownloadTo.get_filename():
3005 new_download_dir = self.chooserDownloadTo.get_filename()
3006 download_dir_size = util.calculate_size( gl.downloaddir)
3007 download_dir_size_string = gl.format_filesize( download_dir_size)
3008 event = Event()
3010 dlg = gtk.Dialog( _('Moving downloads folder'), self.gPodderProperties)
3011 dlg.vbox.set_spacing( 5)
3012 dlg.set_border_width( 5)
3014 label = gtk.Label()
3015 label.set_line_wrap( True)
3016 label.set_markup( _('Moving downloads from <b>%s</b> to <b>%s</b>...') % ( saxutils.escape( gl.downloaddir), saxutils.escape( new_download_dir), ))
3017 myprogressbar = gtk.ProgressBar()
3019 # put it all together
3020 dlg.vbox.pack_start( label)
3021 dlg.vbox.pack_end( myprogressbar)
3023 # switch windows
3024 dlg.show_all()
3025 self.gPodderProperties.hide_all()
3027 # hide action area and separator line
3028 dlg.action_area.hide()
3029 dlg.set_has_separator( False)
3031 args = ( new_download_dir, event, )
3033 thread = Thread( target = self.set_download_dir, args = args)
3034 thread.start()
3036 while not event.isSet():
3037 try:
3038 new_download_dir_size = util.calculate_size( new_download_dir)
3039 except:
3040 new_download_dir_size = 0
3041 if download_dir_size > 0:
3042 fract = (1.00*new_download_dir_size) / (1.00*download_dir_size)
3043 else:
3044 fract = 0.0
3045 if fract < 0.99:
3046 myprogressbar.set_text( _('%s of %s') % ( gl.format_filesize( new_download_dir_size), download_dir_size_string, ))
3047 else:
3048 myprogressbar.set_text( _('Finishing... please wait.'))
3049 myprogressbar.set_fraction(max(0.0,min(1.0,fract)))
3050 event.wait( 0.1)
3051 while gtk.events_pending():
3052 gtk.main_iteration( False)
3054 dlg.destroy()
3056 device_type = self.comboboxDeviceType.get_active()
3057 if device_type == 0:
3058 gl.config.device_type = 'none'
3059 elif device_type == 1:
3060 gl.config.device_type = 'ipod'
3061 elif device_type == 2:
3062 gl.config.device_type = 'filesystem'
3063 elif device_type == 3:
3064 gl.config.device_type = 'mtp'
3065 self.gPodderProperties.destroy()
3066 if self.callback_finished:
3067 self.callback_finished()
3070 class gPodderEpisode(GladeWidget):
3071 finger_friendly_widgets = ['episode_description', 'btnCloseWindow', 'btnDownload',
3072 'btnCancel', 'btnPlay', 'btn_website']
3074 def new(self):
3075 global WEB_BROWSER_ICON
3076 self.image3166.set_property('icon-name', WEB_BROWSER_ICON)
3077 services.download_status_manager.register( 'list-changed', self.on_download_status_changed)
3078 services.download_status_manager.register( 'progress-detail', self.on_download_status_progress)
3080 self.episode_title.set_markup( '<span weight="bold" size="larger">%s</span>' % saxutils.escape( self.episode.title))
3082 if gpodder.interface == gpodder.MAEMO:
3083 # Hide the advanced prefs expander
3084 self.expander1.hide_all()
3086 try:
3087 import gtkhtml2
3088 document = gtkhtml2.Document()
3089 document.connect('link-clicked', lambda d, url: util.open_website(url))
3090 def request_url(document, url, stream):
3091 stream.write(urllib2.urlopen(url).read())
3092 stream.close()
3093 document.connect('request-url', request_url)
3094 document.clear()
3095 document.open_stream('text/html')
3096 document.write_stream('<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"/></head><body>')
3097 document.write_stream('<h2>%s</h2><small><em>from <a href="%s">%s</a>, released %s</em></small><br><hr style="border: 1px #eeeeee solid;">' % (saxutils.escape(self.episode.title), self.episode.link, saxutils.escape(self.episode.channel.title), self.episode.cute_pubdate()))
3098 document.write_stream(self.episode.description)
3099 document.write_stream('<br><hr style="border: 1px #eeeeee solid;"><p style="font-size: 8px;">%s</p>' % self.episode.link)
3100 document.write_stream('</body></html>')
3101 document.close_stream()
3102 self.gPodderEpisode.resize(500, 500)
3104 self.episode_title.hide_all()
3105 self.channel_title.hide_all()
3106 self.btn_website.hide_all()
3107 self.expander1.hide_all()
3109 view = gtkhtml2.View()
3110 view.set_document(document)
3111 self.scrolledwindow4.remove(self.scrolledwindow4.get_child())
3112 self.scrolledwindow4.add(view)
3113 view.show()
3114 except ImportError, ie:
3115 b = gtk.TextBuffer()
3116 b.set_text(strip(util.remove_html_tags(self.episode.description)))
3117 self.episode_description.set_buffer( b)
3119 self.gPodderEpisode.set_title( self.episode.title)
3120 self.LabelDownloadLink.set_text( self.episode.url)
3121 self.LabelWebsiteLink.set_text( self.episode.link)
3122 self.labelPubDate.set_text(self.episode.cute_pubdate())
3124 # Hide the "Go to website" button if we don't have a valid URL
3125 if self.episode.link == self.episode.url or not self.episode.link:
3126 self.btn_website.hide_all()
3128 self.channel_title.set_markup(_('<i>from %s</i>') % saxutils.escape(self.episode.channel.title))
3130 self.hide_show_widgets()
3131 services.download_status_manager.request_progress_detail( self.episode.url)
3133 def on_btnCancel_clicked( self, widget):
3134 services.download_status_manager.cancel_by_url( self.episode.url)
3136 def on_gPodderEpisode_destroy( self, widget):
3137 services.download_status_manager.unregister( 'list-changed', self.on_download_status_changed)
3138 services.download_status_manager.unregister( 'progress-detail', self.on_download_status_progress)
3140 def on_download_status_changed( self):
3141 self.hide_show_widgets()
3143 def on_btn_website_clicked(self, widget):
3144 util.open_website(self.episode.link)
3146 def on_download_status_progress( self, url, progress, speed):
3147 if url == self.episode.url:
3148 progress = float(min(100.0,max(0.0,progress)))
3149 self.progress_bar.set_fraction(progress/100.0)
3150 self.progress_bar.set_text( 'Downloading: %d%% (%s)' % ( progress, speed, ))
3152 def hide_show_widgets( self):
3153 is_downloading = services.download_status_manager.is_download_in_progress( self.episode.url)
3154 if is_downloading:
3155 self.progress_bar.show_all()
3156 self.btnCancel.show_all()
3157 self.btnPlay.hide_all()
3158 self.btnDownload.hide_all()
3159 else:
3160 self.progress_bar.hide_all()
3161 self.btnCancel.hide_all()
3162 if os.path.exists( self.episode.local_filename()):
3163 if self.episode.file_type() in ('audio', 'video'):
3164 self.btnPlay.set_label(gtk.STOCK_MEDIA_PLAY)
3165 else:
3166 self.btnPlay.set_label(gtk.STOCK_OPEN)
3167 self.btnPlay.set_use_stock(True)
3168 self.btnPlay.show_all()
3169 self.btnDownload.hide_all()
3170 else:
3171 self.btnPlay.hide_all()
3172 self.btnDownload.show_all()
3174 def on_btnCloseWindow_clicked(self, widget, *args):
3175 self.gPodderEpisode.destroy()
3177 def on_btnDownload_clicked(self, widget, *args):
3178 if self.download_callback:
3179 self.download_callback()
3181 def on_btnPlay_clicked(self, widget, *args):
3182 if self.play_callback:
3183 self.play_callback()
3185 self.gPodderEpisode.destroy()
3188 class gPodderSync(GladeWidget):
3189 def new(self):
3190 util.idle_add(self.imageSync.set_from_icon_name, 'gnome-dev-ipod', gtk.ICON_SIZE_DIALOG)
3192 self.device.register('progress', self.on_progress)
3193 self.device.register('sub-progress', self.on_sub_progress)
3194 self.device.register('status', self.on_status)
3195 self.device.register('done', self.on_done)
3197 def on_progress(self, pos, max, text=None):
3198 if text is None:
3199 text = _('%d of %d done') % (pos, max)
3200 util.idle_add(self.progressbar.set_fraction, float(pos)/float(max))
3201 util.idle_add(self.progressbar.set_text, text)
3203 def on_sub_progress(self, percentage):
3204 util.idle_add(self.progressbar.set_text, _('Processing (%d%%)') % (percentage))
3206 def on_status(self, status):
3207 util.idle_add(self.status_label.set_markup, '<i>%s</i>' % saxutils.escape(status))
3209 def on_done(self):
3210 util.idle_add(self.gPodderSync.destroy)
3211 if not self.gPodder.minimized:
3212 util.idle_add(self.notification, _('Your device has been updated by gPodder.'), _('Operation finished'))
3214 def on_gPodderSync_destroy(self, widget, *args):
3215 self.device.unregister('progress', self.on_progress)
3216 self.device.unregister('sub-progress', self.on_sub_progress)
3217 self.device.unregister('status', self.on_status)
3218 self.device.unregister('done', self.on_done)
3219 self.device.cancel()
3221 def on_cancel_button_clicked(self, widget, *args):
3222 self.device.cancel()
3225 class gPodderOpmlLister(GladeWidget):
3226 finger_friendly_widgets = ['btnDownloadOpml', 'btnCancel', 'btnOK', 'treeviewChannelChooser']
3228 def new(self):
3229 # initiate channels list
3230 self.channels = []
3231 self.callback_for_channel = None
3232 self.callback_finished = None
3234 if hasattr(self, 'custom_title'):
3235 self.gPodderOpmlLister.set_title(self.custom_title)
3236 if hasattr(self, 'hide_url_entry'):
3237 self.hbox25.hide_all()
3239 self.setup_treeview(self.treeviewChannelChooser)
3240 self.setup_treeview(self.treeviewPodcastAlleyChooser)
3241 self.setup_treeview(self.treeviewYouTubeChooser)
3243 self.notebookChannelAdder.connect('switch-page', lambda a, b, c: self.on_change_tab(c))
3245 def setup_treeview(self, tv):
3246 togglecell = gtk.CellRendererToggle()
3247 togglecell.set_property( 'activatable', True)
3248 togglecell.connect( 'toggled', self.callback_edited)
3249 togglecolumn = gtk.TreeViewColumn( '', togglecell, active=0)
3251 titlecell = gtk.CellRendererText()
3252 titlecell.set_property('ellipsize', pango.ELLIPSIZE_END)
3253 titlecolumn = gtk.TreeViewColumn(_('Podcast'), titlecell, markup=1)
3255 for itemcolumn in ( togglecolumn, titlecolumn ):
3256 tv.append_column(itemcolumn)
3258 def callback_edited( self, cell, path):
3259 model = self.get_treeview().get_model()
3261 url = model[path][2]
3263 model[path][0] = not model[path][0]
3264 if model[path][0]:
3265 self.channels.append( url)
3266 else:
3267 self.channels.remove( url)
3269 self.btnOK.set_sensitive( bool(len(self.get_selected_channels())))
3271 def get_selected_channels(self, tab=None):
3272 channels = []
3274 model = self.get_treeview(tab).get_model()
3275 if model is not None:
3276 for row in model:
3277 if row[0]:
3278 channels.append(row[2])
3280 return channels
3282 def on_change_tab(self, tab):
3283 self.btnOK.set_sensitive( bool(len(self.get_selected_channels(tab))))
3285 def thread_finished(self, model, tab=0):
3286 if tab == 1:
3287 tv = self.treeviewPodcastAlleyChooser
3288 elif tab == 2:
3289 tv = self.treeviewYouTubeChooser
3290 self.entryYoutubeSearch.set_sensitive(True)
3291 self.btnSearchYouTube.set_sensitive(True)
3292 self.btnOK.set_sensitive(False)
3293 else:
3294 tv = self.treeviewChannelChooser
3295 self.btnDownloadOpml.set_sensitive(True)
3296 self.entryURL.set_sensitive(True)
3297 self.channels = []
3299 tv.set_model(model)
3300 tv.set_sensitive(True)
3302 def thread_func(self, tab=0):
3303 if tab == 1:
3304 model = opml.Importer('http://podcastalley.com/feeds/PodcastAlleyTop50.opml').get_model()
3305 if len(model) == 0:
3306 self.notification(_('Something is wrong with PodcastAlley.com'), _('Could not get top 50 channels'))
3307 elif tab == 2:
3308 model = resolver.find_youtube_channels(self.entryYoutubeSearch.get_text())
3309 if len(model) == 0:
3310 self.notification(_('There are no YouTube channels that would match this query.'), _('No channels found'))
3311 else:
3312 model = opml.Importer(self.entryURL.get_text()).get_model()
3313 if len(model) == 0:
3314 self.notification(_('The specified URL does not provide any valid OPML podcast items.'), _('No feeds found'))
3316 util.idle_add(self.thread_finished, model, tab)
3318 def get_channels_from_url( self, url, callback_for_channel = None, callback_finished = None):
3319 if callback_for_channel:
3320 self.callback_for_channel = callback_for_channel
3321 if callback_finished:
3322 self.callback_finished = callback_finished
3323 self.entryURL.set_text( url)
3324 self.btnDownloadOpml.set_sensitive( False)
3325 self.entryURL.set_sensitive( False)
3326 self.btnOK.set_sensitive( False)
3327 self.treeviewChannelChooser.set_sensitive( False)
3328 Thread( target = self.thread_func).start()
3329 Thread( target = lambda: self.thread_func(1)).start()
3331 def select_all( self, value ):
3332 enabled = False
3333 model = self.get_treeview().get_model()
3334 if model is not None:
3335 for row in model:
3336 row[0] = value
3337 if value:
3338 enabled = True
3339 self.btnOK.set_sensitive(enabled)
3341 def on_gPodderOpmlLister_destroy(self, widget, *args):
3342 pass
3344 def on_btnDownloadOpml_clicked(self, widget, *args):
3345 self.get_channels_from_url( self.entryURL.get_text())
3347 def on_btnSearchYouTube_clicked(self, widget, *args):
3348 self.entryYoutubeSearch.set_sensitive(False)
3349 self.treeviewYouTubeChooser.set_sensitive(False)
3350 self.btnSearchYouTube.set_sensitive(False)
3351 Thread(target = lambda: self.thread_func(2)).start()
3353 def on_btnSelectAll_clicked(self, widget, *args):
3354 self.select_all(True)
3356 def on_btnSelectNone_clicked(self, widget, *args):
3357 self.select_all(False)
3359 def on_btnOK_clicked(self, widget, *args):
3360 self.channels = self.get_selected_channels()
3361 self.gPodderOpmlLister.destroy()
3363 # add channels that have been selected
3364 for url in self.channels:
3365 if self.callback_for_channel:
3366 self.callback_for_channel( url)
3368 if self.callback_finished:
3369 util.idle_add(self.callback_finished)
3371 def on_btnCancel_clicked(self, widget, *args):
3372 self.gPodderOpmlLister.destroy()
3374 def on_entryYoutubeSearch_key_press_event(self, widget, event):
3375 if event.keyval == gtk.keysyms.Return:
3376 self.on_btnSearchYouTube_clicked(widget)
3378 def get_treeview(self, tab=None):
3379 if tab is None:
3380 tab = self.notebookChannelAdder.get_current_page()
3382 if tab == 0:
3383 return self.treeviewChannelChooser
3384 elif tab == 1:
3385 return self.treeviewPodcastAlleyChooser
3386 else:
3387 return self.treeviewYouTubeChooser
3389 class gPodderEpisodeSelector( GladeWidget):
3390 """Episode selection dialog
3392 Optional keyword arguments that modify the behaviour of this dialog:
3394 - callback: Function that takes 1 parameter which is a list of
3395 the selected episodes (or empty list when none selected)
3396 - remove_callback: Function that takes 1 parameter which is a list
3397 of episodes that should be "removed" (see below)
3398 (default is None, which means remove not possible)
3399 - remove_action: Label for the "remove" action (default is "Remove")
3400 - remove_finished: Callback after all remove callbacks have finished
3401 (default is None, also depends on remove_callback)
3402 - episodes: List of episodes that are presented for selection
3403 - selected: (optional) List of boolean variables that define the
3404 default checked state for the given episodes
3405 - selected_default: (optional) The default boolean value for the
3406 checked state if no other value is set
3407 (default is False)
3408 - columns: List of (name, sort_name, sort_type, caption) pairs for the
3409 columns, the name is the attribute name of the episode to be
3410 read from each episode object. The sort name is the
3411 attribute name of the episode to be used to sort this column.
3412 If the sort_name is None it will use the attribute name for
3413 sorting. The sort type is the type of the sort column.
3414 The caption attribute is the text that appear as column caption
3415 (default is [('title_and_description', None, None, 'Episode'),])
3416 - title: (optional) The title of the window + heading
3417 - instructions: (optional) A one-line text describing what the
3418 user should select / what the selection is for
3419 - stock_ok_button: (optional) Will replace the "OK" button with
3420 another GTK+ stock item to be used for the
3421 affirmative button of the dialog (e.g. can
3422 be gtk.STOCK_DELETE when the episodes to be
3423 selected will be deleted after closing the
3424 dialog)
3425 - selection_buttons: (optional) A dictionary with labels as
3426 keys and callbacks as values; for each
3427 key a button will be generated, and when
3428 the button is clicked, the callback will
3429 be called for each episode and the return
3430 value of the callback (True or False) will
3431 be the new selected state of the episode
3432 - size_attribute: (optional) The name of an attribute of the
3433 supplied episode objects that can be used to
3434 calculate the size of an episode; set this to
3435 None if no total size calculation should be
3436 done (in cases where total size is useless)
3437 (default is 'length')
3438 - tooltip_attribute: (optional) The name of an attribute of
3439 the supplied episode objects that holds
3440 the text for the tooltips when hovering
3441 over an episode (default is 'description')
3444 finger_friendly_widgets = ['btnCancel', 'btnOK', 'btnCheckAll', 'btnCheckNone', 'treeviewEpisodes']
3446 COLUMN_INDEX = 0
3447 COLUMN_TOOLTIP = 1
3448 COLUMN_TOGGLE = 2
3449 COLUMN_ADDITIONAL = 3
3451 def new( self):
3452 if not hasattr( self, 'callback'):
3453 self.callback = None
3455 if not hasattr(self, 'remove_callback'):
3456 self.remove_callback = None
3458 if not hasattr(self, 'remove_action'):
3459 self.remove_action = _('Remove')
3461 if not hasattr(self, 'remove_finished'):
3462 self.remove_finished = None
3464 if not hasattr( self, 'episodes'):
3465 self.episodes = []
3467 if not hasattr( self, 'size_attribute'):
3468 self.size_attribute = 'length'
3470 if not hasattr(self, 'tooltip_attribute'):
3471 self.tooltip_attribute = 'description'
3473 if not hasattr( self, 'selection_buttons'):
3474 self.selection_buttons = {}
3476 if not hasattr( self, 'selected_default'):
3477 self.selected_default = False
3479 if not hasattr( self, 'selected'):
3480 self.selected = [self.selected_default]*len(self.episodes)
3482 if len(self.selected) < len(self.episodes):
3483 self.selected += [self.selected_default]*(len(self.episodes)-len(self.selected))
3485 if not hasattr( self, 'columns'):
3486 self.columns = (('title_and_description', None, None, _('Episode')),)
3488 if hasattr( self, 'title'):
3489 self.gPodderEpisodeSelector.set_title( self.title)
3490 self.labelHeading.set_markup( '<b><big>%s</big></b>' % saxutils.escape( self.title))
3492 if gpodder.interface == gpodder.MAEMO:
3493 self.labelHeading.hide()
3495 if hasattr( self, 'instructions'):
3496 self.labelInstructions.set_text( self.instructions)
3497 self.labelInstructions.show_all()
3499 if hasattr(self, 'stock_ok_button'):
3500 if self.stock_ok_button == 'gpodder-download':
3501 self.btnOK.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_BUTTON))
3502 self.btnOK.set_label(_('Download'))
3503 else:
3504 self.btnOK.set_label(self.stock_ok_button)
3505 self.btnOK.set_use_stock(True)
3507 # check/uncheck column
3508 toggle_cell = gtk.CellRendererToggle()
3509 toggle_cell.connect( 'toggled', self.toggle_cell_handler)
3510 self.treeviewEpisodes.append_column( gtk.TreeViewColumn( '', toggle_cell, active=self.COLUMN_TOGGLE))
3512 next_column = self.COLUMN_ADDITIONAL
3513 for name, sort_name, sort_type, caption in self.columns:
3514 renderer = gtk.CellRendererText()
3515 renderer.set_property( 'ellipsize', pango.ELLIPSIZE_END)
3516 column = gtk.TreeViewColumn(caption, renderer, markup=next_column)
3517 column.set_resizable( True)
3518 # Only set "expand" on the first column (so more text is displayed there)
3519 column.set_expand(next_column == self.COLUMN_ADDITIONAL)
3520 if sort_name is not None:
3521 column.set_sort_column_id(next_column+1)
3522 else:
3523 column.set_sort_column_id(next_column)
3524 self.treeviewEpisodes.append_column( column)
3525 next_column += 1
3527 if sort_name is not None:
3528 # add the sort column
3529 column = gtk.TreeViewColumn()
3530 column.set_visible(False)
3531 self.treeviewEpisodes.append_column( column)
3532 next_column += 1
3534 column_types = [ gobject.TYPE_INT, gobject.TYPE_STRING, gobject.TYPE_BOOLEAN ]
3535 # add string column type plus sort column type if it exists
3536 for name, sort_name, sort_type, caption in self.columns:
3537 column_types.append(gobject.TYPE_STRING)
3538 if sort_name is not None:
3539 column_types.append(sort_type)
3540 self.model = gtk.ListStore( *column_types)
3542 tooltip = None
3543 for index, episode in enumerate( self.episodes):
3544 if self.tooltip_attribute is not None:
3545 try:
3546 tooltip = getattr(episode, self.tooltip_attribute)
3547 except:
3548 log('Episode object %s does not have tooltip attribute: "%s"', episode, self.tooltip_attribute, sender=self)
3549 tooltip = None
3550 row = [ index, tooltip, self.selected[index] ]
3551 for name, sort_name, sort_type, caption in self.columns:
3552 if not hasattr(episode, name):
3553 log('Warning: Missing attribute "%s"', name, sender=self)
3554 row.append(None)
3555 else:
3556 row.append(getattr( episode, name))
3558 if sort_name is not None:
3559 if not hasattr(episode, sort_name):
3560 log('Warning: Missing attribute "%s"', sort_name, sender=self)
3561 row.append(None)
3562 else:
3563 row.append(getattr( episode, sort_name))
3564 self.model.append( row)
3566 if self.remove_callback is not None:
3567 self.btnRemoveAction.show()
3568 self.btnRemoveAction.set_label(self.remove_action)
3570 # connect to tooltip signals
3571 if self.tooltip_attribute is not None:
3572 try:
3573 self.treeviewEpisodes.set_property('has-tooltip', True)
3574 self.treeviewEpisodes.connect('query-tooltip', self.treeview_episodes_query_tooltip)
3575 except:
3576 log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender=self)
3577 self.last_tooltip_episode = None
3578 self.episode_list_can_tooltip = True
3580 self.treeviewEpisodes.connect('button-press-event', self.treeview_episodes_button_pressed)
3581 self.treeviewEpisodes.set_rules_hint( True)
3582 self.treeviewEpisodes.set_model( self.model)
3583 self.treeviewEpisodes.columns_autosize()
3584 self.calculate_total_size()
3586 def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
3587 # With get_bin_window, we get the window that contains the rows without
3588 # the header. The Y coordinate of this window will be the height of the
3589 # treeview header. This is the amount we have to subtract from the
3590 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
3591 (x_bin, y_bin) = treeview.get_bin_window().get_position()
3592 y -= x_bin
3593 y -= y_bin
3594 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
3596 if not self.episode_list_can_tooltip:
3597 self.last_tooltip_episode = None
3598 return False
3600 if path is not None:
3601 model = treeview.get_model()
3602 iter = model.get_iter(path)
3603 index = model.get_value(iter, self.COLUMN_INDEX)
3604 description = model.get_value(iter, self.COLUMN_TOOLTIP)
3605 if self.last_tooltip_episode is not None and self.last_tooltip_episode != index:
3606 self.last_tooltip_episode = None
3607 return False
3608 self.last_tooltip_episode = index
3610 if description is not None:
3611 tooltip.set_text(description)
3612 return True
3613 else:
3614 return False
3616 self.last_tooltip_episode = None
3617 return False
3619 def treeview_episodes_button_pressed(self, treeview, event):
3620 if event.button == 3:
3621 menu = gtk.Menu()
3623 if len(self.selection_buttons):
3624 for label in self.selection_buttons:
3625 item = gtk.MenuItem(label)
3626 item.connect('activate', self.custom_selection_button_clicked, label)
3627 menu.append(item)
3628 menu.append(gtk.SeparatorMenuItem())
3630 item = gtk.MenuItem(_('Select all'))
3631 item.connect('activate', self.on_btnCheckAll_clicked)
3632 menu.append(item)
3634 item = gtk.MenuItem(_('Select none'))
3635 item.connect('activate', self.on_btnCheckNone_clicked)
3636 menu.append(item)
3638 menu.show_all()
3639 # Disable tooltips while we are showing the menu, so
3640 # the tooltip will not appear over the menu
3641 self.episode_list_can_tooltip = False
3642 menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
3643 menu.popup(None, None, None, event.button, event.time)
3645 return True
3647 def episode_list_allow_tooltips(self):
3648 self.episode_list_can_tooltip = True
3650 def calculate_total_size( self):
3651 if self.size_attribute is not None:
3652 (total_size, count) = (0, 0)
3653 for episode in self.get_selected_episodes():
3654 try:
3655 total_size += int(getattr( episode, self.size_attribute))
3656 count += 1
3657 except:
3658 log( 'Cannot get size for %s', episode.title, sender = self)
3660 text = []
3661 if count == 0:
3662 text.append(_('Nothing selected'))
3663 elif count == 1:
3664 text.append(_('One episode selected'))
3665 else:
3666 text.append(_('%d episodes selected') % count)
3667 if total_size > 0:
3668 text.append(_('total size: %s') % gl.format_filesize(total_size))
3669 self.labelTotalSize.set_text(', '.join(text))
3670 self.btnOK.set_sensitive(count>0)
3671 self.btnRemoveAction.set_sensitive(count>0)
3672 if count > 0:
3673 self.btnCancel.set_label(gtk.STOCK_CANCEL)
3674 else:
3675 self.btnCancel.set_label(gtk.STOCK_CLOSE)
3676 else:
3677 self.btnOK.set_sensitive(False)
3678 self.btnRemoveAction.set_sensitive(False)
3679 for index, row in enumerate(self.model):
3680 if self.model.get_value(row.iter, self.COLUMN_TOGGLE) == True:
3681 self.btnOK.set_sensitive(True)
3682 self.btnRemoveAction.set_sensitive(True)
3683 break
3684 self.labelTotalSize.set_text('')
3686 def toggle_cell_handler( self, cell, path):
3687 model = self.treeviewEpisodes.get_model()
3688 model[path][self.COLUMN_TOGGLE] = not model[path][self.COLUMN_TOGGLE]
3690 self.calculate_total_size()
3692 def custom_selection_button_clicked(self, button, label):
3693 callback = self.selection_buttons[label]
3695 for index, row in enumerate( self.model):
3696 new_value = callback( self.episodes[index])
3697 self.model.set_value( row.iter, self.COLUMN_TOGGLE, new_value)
3699 self.calculate_total_size()
3701 def on_btnCheckAll_clicked( self, widget):
3702 for row in self.model:
3703 self.model.set_value( row.iter, self.COLUMN_TOGGLE, True)
3705 self.calculate_total_size()
3707 def on_btnCheckNone_clicked( self, widget):
3708 for row in self.model:
3709 self.model.set_value( row.iter, self.COLUMN_TOGGLE, False)
3711 self.calculate_total_size()
3713 def on_remove_action_activate(self, widget):
3714 episodes = self.get_selected_episodes(remove_episodes=True)
3716 for episode in episodes:
3717 self.remove_callback(episode)
3719 if self.remove_finished is not None:
3720 self.remove_finished()
3721 self.calculate_total_size()
3723 def get_selected_episodes( self, remove_episodes=False):
3724 selected_episodes = []
3726 for index, row in enumerate( self.model):
3727 if self.model.get_value( row.iter, self.COLUMN_TOGGLE) == True:
3728 selected_episodes.append( self.episodes[self.model.get_value( row.iter, self.COLUMN_INDEX)])
3730 if remove_episodes:
3731 for episode in selected_episodes:
3732 index = self.episodes.index(episode)
3733 iter = self.model.get_iter_first()
3734 while iter is not None:
3735 if self.model.get_value(iter, self.COLUMN_INDEX) == index:
3736 self.model.remove(iter)
3737 break
3738 iter = self.model.iter_next(iter)
3740 return selected_episodes
3742 def on_btnOK_clicked( self, widget):
3743 self.gPodderEpisodeSelector.destroy()
3744 if self.callback is not None:
3745 self.callback( self.get_selected_episodes())
3747 def on_btnCancel_clicked( self, widget):
3748 self.gPodderEpisodeSelector.destroy()
3749 if self.callback is not None:
3750 self.callback([])
3752 class gPodderConfigEditor(GladeWidget):
3753 finger_friendly_widgets = ['btnShowAll', 'btnClose', 'configeditor']
3755 def new(self):
3756 name_column = gtk.TreeViewColumn(_('Setting'))
3757 name_renderer = gtk.CellRendererText()
3758 name_column.pack_start(name_renderer)
3759 name_column.add_attribute(name_renderer, 'text', 0)
3760 name_column.add_attribute(name_renderer, 'style', 5)
3761 self.configeditor.append_column(name_column)
3763 value_column = gtk.TreeViewColumn(_('Set to'))
3764 value_check_renderer = gtk.CellRendererToggle()
3765 value_column.pack_start(value_check_renderer, expand=False)
3766 value_column.add_attribute(value_check_renderer, 'active', 7)
3767 value_column.add_attribute(value_check_renderer, 'visible', 6)
3768 value_column.add_attribute(value_check_renderer, 'activatable', 6)
3769 value_check_renderer.connect('toggled', self.value_toggled)
3771 value_renderer = gtk.CellRendererText()
3772 value_column.pack_start(value_renderer)
3773 value_column.add_attribute(value_renderer, 'text', 2)
3774 value_column.add_attribute(value_renderer, 'visible', 4)
3775 value_column.add_attribute(value_renderer, 'editable', 4)
3776 value_column.add_attribute(value_renderer, 'style', 5)
3777 value_renderer.connect('edited', self.value_edited)
3778 self.configeditor.append_column(value_column)
3780 self.model = gl.config.model()
3781 self.filter = self.model.filter_new()
3782 self.filter.set_visible_func(self.visible_func)
3784 self.configeditor.set_model(self.filter)
3785 self.configeditor.set_rules_hint(True)
3787 def visible_func(self, model, iter, user_data=None):
3788 text = self.entryFilter.get_text().lower()
3789 if text == '':
3790 return True
3791 else:
3792 # either the variable name or its value
3793 return (text in model.get_value(iter, 0).lower() or
3794 text in model.get_value(iter, 2).lower())
3796 def value_edited(self, renderer, path, new_text):
3797 model = self.configeditor.get_model()
3798 iter = model.get_iter(path)
3799 name = model.get_value(iter, 0)
3800 type_cute = model.get_value(iter, 1)
3802 if not gl.config.update_field(name, new_text):
3803 self.notification(_('Cannot set value of <b>%s</b> to <i>%s</i>.\n\nNeeded data type: %s') % (saxutils.escape(name), saxutils.escape(new_text), saxutils.escape(type_cute)), _('Error updating %s') % saxutils.escape(name))
3805 def value_toggled(self, renderer, path):
3806 model = self.configeditor.get_model()
3807 iter = model.get_iter(path)
3808 field_name = model.get_value(iter, 0)
3809 field_type = model.get_value(iter, 3)
3811 # Flip the boolean config flag
3812 if field_type == bool:
3813 gl.config.toggle_flag(field_name)
3815 def on_entryFilter_changed(self, widget):
3816 self.filter.refilter()
3818 def on_btnShowAll_clicked(self, widget):
3819 self.entryFilter.set_text('')
3820 self.entryFilter.grab_focus()
3822 def on_btnClose_clicked(self, widget):
3823 self.gPodderConfigEditor.destroy()
3825 class gPodderPlaylist(GladeWidget):
3826 finger_friendly_widgets = ['btnCancelPlaylist', 'btnSavePlaylist', 'treeviewPlaylist']
3828 def new(self):
3829 self.m3u_header = '#EXTM3U\n'
3830 self.mountpoint = util.find_mount_point(gl.config.mp3_player_folder)
3831 if self.mountpoint == '/':
3832 self.mountpoint = gl.config.mp3_player_folder
3833 log('Warning: MP3 player resides on / - using %s as MP3 player root', self.mountpoint, sender=self)
3834 self.playlist_file = os.path.join(self.mountpoint,
3835 gl.config.mp3_player_playlist_file)
3836 icon_theme = gtk.icon_theme_get_default()
3837 self.icon_new = icon_theme.load_icon(gtk.STOCK_NEW, 16, 0)
3839 # add column two
3840 check_cell = gtk.CellRendererToggle()
3841 check_cell.set_property('activatable', True)
3842 check_cell.connect('toggled', self.cell_toggled)
3843 check_column = gtk.TreeViewColumn(_('Use'), check_cell, active=1)
3844 self.treeviewPlaylist.append_column(check_column)
3846 # add column three
3847 column = gtk.TreeViewColumn(_('Filename'))
3848 icon_cell = gtk.CellRendererPixbuf()
3849 column.pack_start(icon_cell, False)
3850 column.add_attribute(icon_cell, 'pixbuf', 0)
3851 filename_cell = gtk.CellRendererText()
3852 column.pack_start(filename_cell, True)
3853 column.add_attribute(filename_cell, 'text', 2)
3855 column.set_resizable(True)
3856 self.treeviewPlaylist.append_column(column)
3858 # Make treeview reorderable
3859 self.treeviewPlaylist.set_reorderable(True)
3861 # init liststore
3862 self.playlist = gtk.ListStore(gtk.gdk.Pixbuf, bool, str)
3863 self.treeviewPlaylist.set_model(self.playlist)
3865 # read device and playlist and fill the TreeView
3866 self.m3u = self.read_m3u()
3867 self.device = self.read_device()
3868 self.write2gui()
3870 def cell_toggled(self, cellrenderertoggle, path):
3871 (treeview, liststore) = (self.treeviewPlaylist, self.playlist)
3872 it = liststore.get_iter(path)
3873 liststore.set_value(it, 1, not liststore.get_value(it, 1))
3875 def on_btnCancelPlaylist_clicked(self, widget):
3876 self.gPodderPlaylist.destroy()
3878 def on_btnSavePlaylist_clicked(self, widget):
3879 self.write_m3u()
3880 self.gPodderPlaylist.destroy()
3882 def read_m3u(self):
3884 read all files from the existing playlist
3886 tracks = []
3887 if os.path.exists(self.playlist_file):
3888 for line in open(self.playlist_file, 'r'):
3889 if line != self.m3u_header:
3890 if line.startswith('#'):
3891 tracks.append([False, line[1:].strip()])
3892 else:
3893 tracks.append([True, line.strip()])
3894 return tracks
3896 def write_m3u(self):
3898 write the list into the playlist on the device
3900 playlist_folder = os.path.split(self.playlist_file)[0]
3901 if not util.make_directory(playlist_folder):
3902 self.show_message(_('Folder %s could not be created.') % playlist_folder, _('Error writing playlist'))
3903 else:
3904 try:
3905 fp = open(self.playlist_file, 'w')
3906 fp.write(self.m3u_header)
3907 for icon, checked, filename in self.playlist:
3908 if not checked:
3909 fp.write('#')
3910 fp.write(filename)
3911 fp.write('\n')
3912 fp.close()
3913 self.show_message(_('The playlist on your MP3 player has been updated.'), _('Update successful'))
3914 except IOError, ioe:
3915 self.show_message(str(ioe), _('Error writing playlist file'))
3917 def read_device(self):
3919 read all files from the device
3921 tracks = []
3922 for root, dirs, files in os.walk(gl.config.mp3_player_folder):
3923 for file in files:
3924 filename = os.path.join(root, file)
3926 if filename == self.playlist_file:
3927 # We don't want to have our playlist file as
3928 # an entry in our file list, so skip it!
3929 break
3931 if not gl.config.mp3_player_playlist_absolute_path:
3932 filename = filename[len(self.mountpoint):]
3934 if gl.config.mp3_player_playlist_win_path:
3935 filename = filename.replace( '/', '\\')
3937 tracks.append(filename)
3938 return tracks
3940 def write2gui(self):
3941 # add the files from the device to the list only when
3942 # they are not yet in the playlist
3943 # mark this files as NEW
3944 for filename in self.device[:]:
3945 m3ulist = [file[1] for file in self.m3u]
3946 if filename not in m3ulist:
3947 self.playlist.append([self.icon_new, False, filename])
3949 # add the files from the playlist to the list only when
3950 # they are on the device
3951 for checked, filename in self.m3u[:]:
3952 if filename in self.device:
3953 self.playlist.append([None, checked, filename])
3955 class gPodderDependencyManager(GladeWidget):
3956 def new(self):
3957 col_name = gtk.TreeViewColumn(_('Feature'), gtk.CellRendererText(), text=0)
3958 self.treeview_components.append_column(col_name)
3959 col_installed = gtk.TreeViewColumn(_('Status'), gtk.CellRendererText(), text=2)
3960 self.treeview_components.append_column(col_installed)
3961 self.treeview_components.set_model(services.dependency_manager.get_model())
3962 self.btn_about.set_sensitive(False)
3964 def on_btn_about_clicked(self, widget):
3965 selection = self.treeview_components.get_selection()
3966 model, iter = selection.get_selected()
3967 if iter is not None:
3968 title = model.get_value(iter, 0)
3969 description = model.get_value(iter, 1)
3970 available = model.get_value(iter, 3)
3971 missing = model.get_value(iter, 4)
3973 if not available:
3974 description += '\n\n'+_('Missing components:')+'\n\n'+missing
3976 self.show_message(description, title)
3978 def on_btn_install_clicked(self, widget):
3979 # TODO: Implement package manager integration
3980 pass
3982 def on_treeview_components_cursor_changed(self, treeview):
3983 self.btn_about.set_sensitive(treeview.get_selection().count_selected_rows() > 0)
3984 # TODO: If installing is possible, enable btn_install
3986 def on_gPodderDependencyManager_response(self, dialog, response_id):
3987 self.gPodderDependencyManager.destroy()
3989 def main():
3990 gobject.threads_init()
3991 gtk.window_set_default_icon_name( 'gpodder')
3993 gPodder().run()