Refactor libpodcasts; fix episode dialog
[gpodder.git] / src / gpodder / gui.py
blobebb807ccff0685fbe535969c643e4804c1752be7
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 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
33 import dbus
34 import dbus.service
35 import dbus.mainloop
36 import dbus.glib
38 from xml.sax import saxutils
40 from threading import Event
41 from threading import Thread
42 from threading import Semaphore
43 from string import strip
45 import gpodder
46 from gpodder import libtagupdate
47 from gpodder import util
48 from gpodder import opml
49 from gpodder import services
50 from gpodder import sync
51 from gpodder import download
52 from gpodder import SimpleGladeApp
53 from gpodder import my
54 from gpodder.liblogger import log
55 from gpodder.dbsqlite import db
56 from gpodder import resolver
58 try:
59 from gpodder import trayicon
60 have_trayicon = True
61 except Exception, exc:
62 log('Warning: Could not import gpodder.trayicon.', traceback=True)
63 log('Warning: This probably means your PyGTK installation is too old!')
64 have_trayicon = False
66 from libpodcasts import PodcastChannel
67 from libpodcasts import LocalDBReader
68 from libpodcasts import channels_to_model
69 from libpodcasts import update_channel_model_by_iter
70 from libpodcasts import load_channels
71 from libpodcasts import update_channels
72 from libpodcasts import save_channels
73 from libpodcasts import can_restore_from_opml
74 from libpodcasts import HTTPAuthError
76 from gpodder.libgpodder import gl
78 from libplayers import UserAppsReader
80 from libtagupdate import tagging_supported
82 if gpodder.interface == gpodder.GUI:
83 WEB_BROWSER_ICON = 'web-browser'
84 elif gpodder.interface == gpodder.MAEMO:
85 import hildon
86 WEB_BROWSER_ICON = 'qgn_toolb_browser_web'
88 app_name = "gpodder"
89 app_version = "unknown" # will be set in main() call
90 app_authors = [
91 _('Current maintainer:'), 'Thomas Perl <thpinfo.com>',
92 '',
93 _('Patches, bug reports and donations by:'), 'Adrien Beaucreux',
94 'Alain Tauch', 'Alex Ghitza', 'Alistair Sutton', 'Anders Kvist', 'Andrei Dolganov', 'Andrew Bennett', 'Andy Busch',
95 'Antonio Roversi', 'Aravind Seshadri', 'Atte André Jensen', 'audioworld',
96 'Bastian Staeck', 'Bernd Schlapsi', 'Bill Barnard', 'Bill Peters', 'Bjørn Rasmussen', 'Camille Moncelier', 'Casey Watson',
97 'Carlos Moffat', 'Chris Arnold', 'Chris Moffitt', 'Clark Burbidge', 'Cory Albrecht', 'daggpod', 'Daniel Ramos',
98 'David Spreen', 'Doug Hellmann', 'Edouard Pellerin', 'FFranci72', 'Florian Richter', 'Frank Harper',
99 'Franz Seidl', 'FriedBunny', 'Gerrit Sangel', 'Gilles Lehoux', 'Götz Waschk',
100 'Haim Roitgrund', 'Heinz Erhard', 'Hex', 'Holger Bauer', 'Holger Leskien', 'Iwan van der Kleijn', 'Jens Thiele',
101 'Jérôme Chabod', 'Jerry Moss',
102 'Jessica Henline', 'João Trindade', 'Joel Calado', 'John Ferguson',
103 'José Luis Fustel', 'Joseph Bleau', 'Julio Acuña', 'Junio C Hamano',
104 'Jürgen Schinker', 'Justin Forest',
105 'Konstantin Ryabitsev', 'Leonid Ponomarev', 'Marcos Hernández', 'Mark Alford', 'Markus Golser', 'Mehmet Nur Olcay', 'Michael Salim',
106 'Mika Leppinen', 'Mike Coulson', 'Mikolaj Laczynski', 'Morten Juhl-Johansen Zölde-Fejér', 'Mykola Nikishov', 'narf',
107 'Nick L.', 'Nicolas Quienot', 'Ondrej Vesely',
108 'Ortwin Forster', 'Paul Elliot', 'Paul Rudkin',
109 'Pavel Mlčoch', 'Peter Hoffmann', 'PhilF', 'Philippe Gouaillier', 'Pieter de Decker',
110 'Preben Randhol', 'Rafael Proença', 'R.Bell', 'red26wings', 'Richard Voigt',
111 'Robert Young', 'Roel Groeneveld', 'Romain Janvier',
112 'Scott Wegner', 'Sebastian Krause', 'Seth Remington', 'Shane Donohoe', 'Silvio Sisto', 'SPGoetze',
113 'S. Rust',
114 'Stefan Lohmaier', 'Stephan Buys', 'Steve McCarthy', 'Stylianos Papanastasiou', 'Teo Ramirez',
115 'Thomas Matthijs', 'Thomas Mills Hinkle', 'Thomas Nilsson',
116 'Tim Michelsen', 'Tim Preetz', 'Todd Zullinger', 'Tomas Matheson', 'Ville-Pekka Vainio', 'Vitaliy Bondar', 'VladDrac',
117 'Vladimir Zemlyakov', 'Wilfred van Rooijen',
119 'List may be incomplete - please contact me.'
121 app_copyright = '© 2005-2009 Thomas Perl and the gPodder Team'
122 app_website = 'http://www.gpodder.org/'
124 # these will be filled with pathnames in bin/gpodder
125 glade_dir = [ 'share', 'gpodder' ]
126 icon_dir = [ 'share', 'pixmaps', 'gpodder.png' ]
127 scalable_dir = [ 'share', 'icons', 'hicolor', 'scalable', 'apps', 'gpodder.svg' ]
130 class GladeWidget(SimpleGladeApp.SimpleGladeApp):
131 gpodder_main_window = None
132 finger_friendly_widgets = []
134 def __init__( self, **kwargs):
135 path = os.path.join( glade_dir, '%s.glade' % app_name)
136 root = self.__class__.__name__
137 domain = app_name
139 SimpleGladeApp.SimpleGladeApp.__init__( self, path, root, domain, **kwargs)
141 # Set widgets to finger-friendly mode if on Maemo
142 for widget_name in self.finger_friendly_widgets:
143 if hasattr(self, widget_name):
144 self.set_finger_friendly(getattr(self, widget_name))
145 else:
146 log('Finger-friendly widget not found: %s', widget_name, sender=self)
148 if root == 'gPodder':
149 GladeWidget.gpodder_main_window = self.gPodder
150 else:
151 # If we have a child window, set it transient for our main window
152 getattr( self, root).set_transient_for( GladeWidget.gpodder_main_window)
154 if gpodder.interface == gpodder.GUI:
155 if hasattr( self, 'center_on_widget'):
156 ( x, y ) = self.gpodder_main_window.get_position()
157 a = self.center_on_widget.allocation
158 ( x, y ) = ( x + a.x, y + a.y )
159 ( w, h ) = ( a.width, a.height )
160 ( pw, ph ) = getattr( self, root).get_size()
161 getattr( self, root).move( x + w/2 - pw/2, y + h/2 - ph/2)
162 else:
163 getattr( self, root).set_position( gtk.WIN_POS_CENTER_ON_PARENT)
165 def notification(self, message, title=None):
166 util.idle_add(self.show_message, message, title)
168 def show_message( self, message, title = None):
169 if hasattr(self, 'tray_icon') and hasattr(self, 'minimized') and self.tray_icon and self.minimized:
170 if title is None:
171 title = 'gPodder'
172 self.tray_icon.send_notification(message, title)
173 return
175 if gpodder.interface == gpodder.GUI:
176 dlg = gtk.MessageDialog(GladeWidget.gpodder_main_window, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_OK)
177 if title:
178 dlg.set_title(str(title))
179 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
180 else:
181 dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (message))
182 elif gpodder.interface == gpodder.MAEMO:
183 dlg = hildon.Note('information', (GladeWidget.gpodder_main_window, message))
185 dlg.run()
186 dlg.destroy()
188 def set_finger_friendly(self, widget):
190 If we are on Maemo, we carry out the necessary
191 operations to turn a widget into a finger-friendly
192 one, depending on which type of widget it is (i.e.
193 buttons will have more padding, TreeViews a thick
194 scrollbar, etc..)
196 if gpodder.interface == gpodder.MAEMO:
197 if isinstance(widget, gtk.Misc):
198 widget.set_padding(0, 5)
199 elif isinstance(widget, gtk.Button):
200 for child in widget.get_children():
201 if isinstance(child, gtk.Alignment):
202 child.set_padding(5, 5, 5, 5)
203 else:
204 child.set_padding(5, 5)
205 elif isinstance(widget, gtk.TreeView) or isinstance(widget, gtk.TextView):
206 parent = widget.get_parent()
207 if isinstance(parent, gtk.ScrolledWindow):
208 hildon.hildon_helper_set_thumb_scrollbar(parent, True)
209 elif isinstance(widget, gtk.MenuItem):
210 for child in widget.get_children():
211 self.set_finger_friendly(child)
212 submenu = widget.get_submenu()
213 if submenu is not None:
214 for child in submenu.get_children():
215 self.set_finger_friendly(child)
216 elif isinstance(widget, gtk.Menu):
217 for child in widget.get_children():
218 self.set_finger_friendly(child)
219 else:
220 log('Cannot set widget finger-friendly: %s', widget, sender=self)
222 return widget
224 def show_confirmation( self, message, title = None):
225 if gpodder.interface == gpodder.GUI:
226 affirmative = gtk.RESPONSE_YES
227 dlg = gtk.MessageDialog(GladeWidget.gpodder_main_window, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_YES_NO)
228 if title:
229 dlg.set_title(str(title))
230 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
231 else:
232 dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (message))
233 elif gpodder.interface == gpodder.MAEMO:
234 affirmative = gtk.RESPONSE_OK
235 dlg = hildon.Note('confirmation', (GladeWidget.gpodder_main_window, message))
237 response = dlg.run()
238 dlg.destroy()
240 return response == affirmative
242 def UsernamePasswordDialog( self, title, message, username=None, password=None, username_prompt=_('Username'), register_callback=None):
243 """ An authentication dialog based on
244 http://ardoris.wordpress.com/2008/07/05/pygtk-text-entry-dialog/ """
246 dialog = gtk.MessageDialog(
247 GladeWidget.gpodder_main_window,
248 gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
249 gtk.MESSAGE_QUESTION,
250 gtk.BUTTONS_OK_CANCEL )
252 dialog.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_DIALOG))
254 dialog.set_markup('<span weight="bold" size="larger">' + title + '</span>')
255 dialog.set_title(_('Authentication required'))
256 dialog.format_secondary_markup(message)
257 dialog.set_default_response(gtk.RESPONSE_OK)
259 if register_callback is not None:
260 dialog.add_button(_('New user'), gtk.RESPONSE_HELP)
262 username_entry = gtk.Entry()
263 password_entry = gtk.Entry()
265 username_entry.connect('activate', lambda w: password_entry.grab_focus())
266 password_entry.set_visibility(False)
267 password_entry.set_activates_default(True)
269 if username is not None:
270 username_entry.set_text(username)
271 if password is not None:
272 password_entry.set_text(password)
274 table = gtk.Table(2, 2)
275 table.set_row_spacings(6)
276 table.set_col_spacings(6)
278 username_label = gtk.Label()
279 username_label.set_markup('<b>' + username_prompt + ':</b>')
280 username_label.set_alignment(0.0, 0.5)
281 table.attach(username_label, 0, 1, 0, 1, gtk.FILL, 0)
282 table.attach(username_entry, 1, 2, 0, 1)
284 password_label = gtk.Label()
285 password_label.set_markup('<b>' + _('Password') + ':</b>')
286 password_label.set_alignment(0.0, 0.5)
287 table.attach(password_label, 0, 1, 1, 2, gtk.FILL, 0)
288 table.attach(password_entry, 1, 2, 1, 2)
290 dialog.vbox.pack_end(table, True, True, 0)
291 dialog.show_all()
292 response = dialog.run()
294 while response == gtk.RESPONSE_HELP:
295 register_callback()
296 response = dialog.run()
298 password_entry.set_visibility(True)
299 dialog.destroy()
301 return response == gtk.RESPONSE_OK, ( username_entry.get_text(), password_entry.get_text() )
303 def show_copy_dialog( self, src_filename, dst_filename = None, dst_directory = None, title = _('Select destination')):
304 if dst_filename is None:
305 dst_filename = src_filename
307 if dst_directory is None:
308 dst_directory = os.path.expanduser( '~')
310 ( base, extension ) = os.path.splitext( src_filename)
312 if not dst_filename.endswith( extension):
313 dst_filename += extension
315 if gpodder.interface == gpodder.GUI:
316 dlg = gtk.FileChooserDialog(title=title, parent=GladeWidget.gpodder_main_window, action=gtk.FILE_CHOOSER_ACTION_SAVE)
317 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
318 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
319 elif gpodder.interface == gpodder.MAEMO:
320 dlg = hildon.FileChooserDialog(GladeWidget.gpodder_main_window, gtk.FILE_CHOOSER_ACTION_SAVE)
322 dlg.set_do_overwrite_confirmation( True)
323 dlg.set_current_name( os.path.basename( dst_filename))
324 dlg.set_current_folder( dst_directory)
326 result = False
327 folder = dst_directory
328 if dlg.run() == gtk.RESPONSE_OK:
329 result = True
330 dst_filename = dlg.get_filename()
331 folder = dlg.get_current_folder()
332 if not dst_filename.endswith( extension):
333 dst_filename += extension
335 log( 'Copying %s => %s', src_filename, dst_filename, sender = self)
337 try:
338 shutil.copyfile( src_filename, dst_filename)
339 except:
340 log( 'Error copying file.', sender = self, traceback = True)
342 dlg.destroy()
343 return (result, folder)
346 class gPodder(GladeWidget, dbus.service.Object):
347 finger_friendly_widgets = ['btnCancelFeedUpdate', 'label2', 'labelDownloads', 'itemQuit', 'menuPodcasts', 'advanced1', 'menuChannels', 'menuHelp']
348 ENTER_URL_TEXT = _('Enter podcast URL...')
350 def __init__(self, bus_name):
351 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
352 GladeWidget.__init__(self)
354 def new(self):
355 if gpodder.interface == gpodder.MAEMO:
356 # Maemo-specific changes to the UI
357 global scalable_dir
358 scalable_dir = scalable_dir.replace('.svg', '.png')
360 self.app = hildon.Program()
361 gtk.set_application_name('gPodder')
362 self.window = hildon.Window()
363 self.window.connect('delete-event', self.on_gPodder_delete_event)
364 self.window.connect('window-state-event', self.window_state_event)
366 self.itemUpdateChannel.show()
367 self.UpdateChannelSeparator.show()
369 # Remove old toolbar from its parent widget
370 self.toolbar.get_parent().remove(self.toolbar)
372 toolbar = gtk.Toolbar()
373 toolbar.set_style(gtk.TOOLBAR_BOTH_HORIZ)
375 self.btnUpdateFeeds.get_parent().remove(self.btnUpdateFeeds)
377 self.btnUpdateFeeds = gtk.ToolButton(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_SMALL_TOOLBAR), _('Update all'))
378 self.btnUpdateFeeds.set_is_important(True)
379 self.btnUpdateFeeds.connect('clicked', self.on_itemUpdate_activate)
380 toolbar.insert(self.btnUpdateFeeds, -1)
381 self.btnUpdateFeeds.show_all()
383 self.btnUpdateSelectedFeed = gtk.ToolButton(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_SMALL_TOOLBAR), _('Update selected'))
384 self.btnUpdateSelectedFeed.set_is_important(True)
385 self.btnUpdateSelectedFeed.connect('clicked', self.on_itemUpdateChannel_activate)
386 toolbar.insert(self.btnUpdateSelectedFeed, -1)
387 self.btnUpdateSelectedFeed.show_all()
389 self.toolFeedUpdateProgress = gtk.ToolItem()
390 self.pbFeedUpdate.reparent(self.toolFeedUpdateProgress)
391 self.toolFeedUpdateProgress.set_expand(True)
392 toolbar.insert(self.toolFeedUpdateProgress, -1)
393 self.toolFeedUpdateProgress.hide()
395 self.btnCancelFeedUpdate = gtk.ToolButton(gtk.STOCK_CLOSE)
396 self.btnCancelFeedUpdate.connect('clicked', self.on_btnCancelFeedUpdate_clicked)
397 toolbar.insert(self.btnCancelFeedUpdate, -1)
398 self.btnCancelFeedUpdate.hide()
400 self.toolbarSpacer = gtk.SeparatorToolItem()
401 self.toolbarSpacer.set_draw(False)
402 self.toolbarSpacer.set_expand(True)
403 toolbar.insert(self.toolbarSpacer, -1)
404 self.toolbarSpacer.show()
406 self.toolPreferences = gtk.ToolButton(gtk.STOCK_PREFERENCES)
407 self.toolPreferences.connect('clicked', self.on_itemPreferences_activate)
408 toolbar.insert(self.toolPreferences, -1)
409 self.toolPreferences.show()
411 self.toolQuit = gtk.ToolButton(gtk.STOCK_QUIT)
412 self.toolQuit.connect('clicked', self.on_gPodder_delete_event)
413 toolbar.insert(self.toolQuit, -1)
414 self.toolQuit.show()
416 # Add and replace toolbar with our new one
417 toolbar.show()
418 self.window.add_toolbar(toolbar)
419 self.toolbar = toolbar
421 self.app.add_window(self.window)
422 self.vMain.reparent(self.window)
423 self.gPodder = self.window
425 # Reparent the main menu
426 menu = gtk.Menu()
427 for child in self.mainMenu.get_children():
428 child.reparent(menu)
429 self.itemQuit.reparent(menu)
430 self.window.set_menu(menu)
432 self.mainMenu.destroy()
433 self.window.show()
435 # do some widget hiding
436 self.itemTransferSelected.hide_all()
437 self.item_email_subscriptions.hide_all()
438 self.menuView.hide()
440 # get screen real estate
441 self.hboxContainer.set_border_width(0)
443 # Offer importing of videocenter podcasts
444 if os.path.exists(os.path.expanduser('~/videocenter')):
445 self.item_upgrade_from_videocenter.show()
446 self.upgrade_from_videocenter_separator.show()
448 self.gPodder.connect('key-press-event', self.on_key_press)
449 self.treeChannels.connect('size-allocate', self.on_tree_channels_resize)
451 if gl.config.show_url_entry_in_podcast_list:
452 self.hboxAddChannel.show()
454 if not gpodder.interface == gpodder.MAEMO and not gl.config.show_toolbar:
455 self.toolbar.hide()
457 gl.config.add_observer(self.on_config_changed)
458 self.default_entry_text_color = self.entryAddChannel.get_style().text[gtk.STATE_NORMAL]
459 self.entryAddChannel.connect('focus-in-event', self.entry_add_channel_focus)
460 self.entryAddChannel.connect('focus-out-event', self.entry_add_channel_unfocus)
461 self.entry_add_channel_unfocus(self.entryAddChannel, None)
463 self.uar = None
464 self.tray_icon = None
465 self.gpodder_episode_window = None
467 self.fullscreen = False
468 self.minimized = False
469 self.gPodder.connect('window-state-event', self.window_state_event)
471 self.already_notified_new_episodes = []
472 self.show_hide_tray_icon()
473 self.no_episode_selected.set_sensitive(False)
475 self.itemShowToolbar.set_active(gl.config.show_toolbar)
476 self.itemShowDescription.set_active(gl.config.episode_list_descriptions)
478 gl.config.connect_gtk_window(self.gPodder, 'main_window')
479 gl.config.connect_gtk_paned( 'paned_position', self.channelPaned)
481 gl.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
482 gl.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
483 gl.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
484 gl.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
486 # Make sure we free/close the download queue when we
487 # update the "max downloads" spin button
488 changed_cb = lambda spinbutton: services.download_status_manager.update_max_downloads()
489 self.spinMaxDownloads.connect('value-changed', changed_cb)
491 self.default_title = None
492 if app_version.rfind('git') != -1:
493 self.set_title('gPodder %s' % app_version)
494 else:
495 title = self.gPodder.get_title()
496 if title is not None:
497 self.set_title(title)
498 else:
499 self.set_title(_('gPodder'))
501 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
503 # cell renderers for channel tree
504 iconcolumn = gtk.TreeViewColumn('')
506 iconcell = gtk.CellRendererPixbuf()
507 iconcolumn.pack_start( iconcell, False)
508 iconcolumn.add_attribute( iconcell, 'pixbuf', 5)
509 self.cell_channel_icon = iconcell
511 namecolumn = gtk.TreeViewColumn('')
512 namecell = gtk.CellRendererText()
513 namecell.set_property('foreground-set', True)
514 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
515 namecolumn.pack_start( namecell, True)
516 namecolumn.add_attribute( namecell, 'markup', 2)
517 namecolumn.add_attribute( namecell, 'foreground', 8)
519 iconcell = gtk.CellRendererPixbuf()
520 iconcell.set_property('xalign', 1.0)
521 namecolumn.pack_start( iconcell, False)
522 namecolumn.add_attribute( iconcell, 'pixbuf', 3)
523 namecolumn.add_attribute(iconcell, 'visible', 7)
524 self.cell_channel_pill = iconcell
526 self.treeChannels.append_column(iconcolumn)
527 self.treeChannels.append_column(namecolumn)
528 self.treeChannels.set_headers_visible(False)
530 # enable alternating colors hint
531 self.treeAvailable.set_rules_hint( True)
532 self.treeChannels.set_rules_hint( True)
534 # connect to tooltip signals
535 try:
536 self.treeChannels.set_property('has-tooltip', True)
537 self.treeChannels.connect('query-tooltip', self.treeview_channels_query_tooltip)
538 self.treeAvailable.set_property('has-tooltip', True)
539 self.treeAvailable.connect('query-tooltip', self.treeview_episodes_query_tooltip)
540 except:
541 log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender = self)
542 self.last_tooltip_channel = None
543 self.last_tooltip_episode = None
544 self.podcast_list_can_tooltip = True
545 self.episode_list_can_tooltip = True
547 self.currently_updating = False
549 # Add our context menu to treeAvailable
550 if gpodder.interface == gpodder.MAEMO:
551 self.treeview_available_buttonpress = (0, 0)
552 self.treeAvailable.connect('button-press-event', self.treeview_button_savepos)
553 self.treeAvailable.connect('button-release-event', self.treeview_button_pressed)
555 self.treeview_channels_buttonpress = (0, 0)
556 self.treeChannels.connect('button-press-event', self.treeview_channels_button_pressed)
557 self.treeChannels.connect('button-release-event', self.treeview_channels_button_released)
559 import mokoui
560 fs = mokoui.FingerScroll()
561 fs.set_property('spring-speed', 0)
562 self.treeAvailable.reparent(fs)
563 self.channelPaned.remove(self.scrollAvailable)
564 self.channelPaned.pack2(fs)
565 fs.show()
566 fsc = mokoui.FingerScroll()
567 fsc.set_property('spring-speed', 0)
568 self.treeChannels.reparent(fsc)
569 self.vboxChannelNavigator.remove(self.scrolledwindow6)
570 self.vboxChannelNavigator.pack_start(fsc, expand=True, fill=True)
571 self.vboxChannelNavigator.reorder_child(fsc, 0)
572 fsc.show()
573 else:
574 self.treeAvailable.connect('button-press-event', self.treeview_button_pressed)
575 self.treeChannels.connect('button-press-event', self.treeview_channels_button_pressed)
577 iconcell = gtk.CellRendererPixbuf()
578 if gpodder.interface == gpodder.MAEMO:
579 iconcell.set_fixed_size(-1, 52)
580 status_column_label = ''
581 else:
582 status_column_label = _('Status')
583 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=4)
585 namecell = gtk.CellRendererText()
586 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
587 namecolumn = gtk.TreeViewColumn(_("Episode"), namecell, markup=6)
588 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
589 namecolumn.set_expand(True)
591 sizecell = gtk.CellRendererText()
592 sizecolumn = gtk.TreeViewColumn( _("Size"), sizecell, text=2)
594 releasecell = gtk.CellRendererText()
595 releasecolumn = gtk.TreeViewColumn( _("Released"), releasecell, text=5)
597 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
598 itemcolumn.set_reorderable(gpodder.interface != gpodder.MAEMO)
599 self.treeAvailable.append_column(itemcolumn)
601 if gpodder.interface == gpodder.MAEMO:
602 # Due to screen space contraints, we
603 # hide these columns here by default
604 self.column_size = sizecolumn
605 self.column_released = releasecolumn
606 self.column_released.set_visible(False)
607 self.column_size.set_visible(False)
609 # enable search in treeavailable
610 self.treeAvailable.set_search_equal_func( self.treeAvailable_search_equal)
612 # enable multiple selection support
613 if gpodder.interface == gpodder.MAEMO:
614 self.treeAvailable.get_selection().set_mode(gtk.SELECTION_SINGLE)
615 else:
616 self.treeAvailable.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
617 self.treeDownloads.get_selection().set_mode( gtk.SELECTION_MULTIPLE)
619 # columns and renderers for "download progress" tab
620 episodecell = gtk.CellRendererText()
621 episodecell.set_property('ellipsize', pango.ELLIPSIZE_END)
622 episodecolumn = gtk.TreeViewColumn( _("Episode"), episodecell, text=0)
623 episodecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
624 episodecolumn.set_expand(True)
626 speedcell = gtk.CellRendererText()
627 speedcolumn = gtk.TreeViewColumn( _("Speed"), speedcell, text=1)
629 progresscell = gtk.CellRendererProgress()
630 progresscolumn = gtk.TreeViewColumn( _("Progress"), progresscell, value=2)
631 progresscolumn.set_expand(True)
633 for itemcolumn in ( episodecolumn, speedcolumn, progresscolumn ):
634 self.treeDownloads.append_column( itemcolumn)
636 # After we've set up most of the window, show it :)
637 if not gpodder.interface == gpodder.MAEMO:
638 self.gPodder.show()
640 if self.tray_icon:
641 if gl.config.start_iconified:
642 self.iconify_main_window()
643 elif gl.config.minimize_to_tray:
644 self.tray_icon.set_visible(False)
646 # a dictionary that maps episode URLs to the current
647 # treeAvailable row numbers to generate tree paths
648 self.url_path_mapping = {}
650 # a dictionary that maps channel URLs to the current
651 # treeChannels row numbers to generate tree paths
652 self.channel_url_path_mapping = {}
654 services.download_status_manager.register( 'list-changed', self.download_status_updated)
655 services.download_status_manager.register( 'progress-changed', self.download_progress_updated)
656 services.cover_downloader.register('cover-available', self.cover_download_finished)
657 services.cover_downloader.register('cover-removed', self.cover_file_removed)
658 self.cover_cache = {}
660 self.treeDownloads.set_model( services.download_status_manager.tree_model)
662 #Add Drag and Drop Support
663 flags = gtk.DEST_DEFAULT_ALL
664 targets = [ ('text/plain', 0, 2), ('STRING', 0, 3), ('TEXT', 0, 4) ]
665 actions = gtk.gdk.ACTION_DEFAULT | gtk.gdk.ACTION_COPY
666 self.treeChannels.drag_dest_set( flags, targets, actions)
667 self.treeChannels.connect( 'drag_data_received', self.drag_data_received)
669 # Subscribed channels
670 self.active_channel = None
671 self.channels = load_channels()
672 self.channel_list_changed = True
673 self.update_podcasts_tab()
675 # load list of user applications for audio playback
676 self.user_apps_reader = UserAppsReader(['audio', 'video'])
677 Thread(target=self.read_apps).start()
679 # Set the "Device" menu item for the first time
680 self.update_item_device()
682 # Last folder used for saving episodes
683 self.folder_for_saving_episodes = None
685 # Set up default channel colors
686 self.channel_colors = {
687 'default': None,
688 'updating': gl.config.color_updating_feeds,
689 'parse_error': '#ff0000',
692 # Now, update the feed cache, when everything's in place
693 self.btnUpdateFeeds.show()
694 self.updated_feeds = 0
695 self.updating_feed_cache = False
696 self.feed_cache_update_cancelled = False
697 self.update_feed_cache(force_update=gl.config.update_on_startup)
699 # Clean up old, orphaned download files
700 partial_files = gl.find_partial_files()
702 resumable_episodes = []
703 if len(partial_files) > 0:
704 for f in partial_files:
705 correct_name = os.path.basename(f)[:-len('.partial')] # strip ".partial"
706 log('Searching episode for file: %s', correct_name, sender=self)
707 found_episode = False
708 for c in self.channels:
709 for e in c.get_all_episodes():
710 if e.filename == correct_name:
711 log('Found episode: %s', e.title, sender=self)
712 resumable_episodes.append(e)
713 found_episode = True
714 if found_episode:
715 break
716 if found_episode:
717 break
719 def remove_partial_file(episode):
720 fn = episode.local_filename(create=False)
721 if fn is not None:
722 util.delete_file(fn+'.partial')
724 if len(resumable_episodes):
725 if gl.config.resume_ask_every_episode:
726 gPodderEpisodeSelector(title = _('Resume downloads'), instructions = _('There are unfinished downloads from your last session. Pick the ones you want to resume.'), \
727 episodes = resumable_episodes, \
728 stock_ok_button = 'gpodder-download', callback = self.download_episode_list, remove_callback=remove_partial_file)
729 else:
730 if len(resumable_episodes) == 0:
731 question = _('There is one partially downloaded episode. Do you want to continue downloading it?')
732 else:
733 question = _('There are %d partially downloaded episodes. Do you want to continue downloading them?') % (len(resumable_episodes))
735 if self.show_confirmation(question, _('Resume downloads from last session')):
736 self.download_episode_list(resumable_episodes)
737 else:
738 for episode in resumable_episodes:
739 remove_partial_file(episode)
742 gl.clean_up_downloads(delete_partial=False)
743 else:
744 gl.clean_up_downloads(delete_partial=True)
746 # Start the auto-update procedure
747 self.auto_update_procedure(first_run=True)
749 # Delete old episodes if the user wishes to
750 if gl.config.auto_remove_old_episodes:
751 old_episodes = self.get_old_episodes()
752 if len(old_episodes) > 0:
753 self.delete_episode_list(old_episodes, confirm=False)
754 self.updateComboBox()
756 # First-time users should be asked if they want to see the OPML
757 if len(self.channels) == 0:
758 util.idle_add(self.on_itemUpdate_activate, None)
760 def on_tree_channels_resize(self, widget, allocation):
761 if not gl.config.podcast_sidebar_save_space:
762 return
764 window_allocation = self.gPodder.get_allocation()
765 percentage = 100. * float(allocation.width) / float(window_allocation.width)
766 if hasattr(self, 'cell_channel_icon'):
767 self.cell_channel_icon.set_property('visible', bool(percentage > 22.))
768 if hasattr(self, 'cell_channel_pill'):
769 self.cell_channel_pill.set_property('visible', bool(percentage > 25.))
771 def entry_add_channel_focus(self, widget, event):
772 widget.modify_text(gtk.STATE_NORMAL, self.default_entry_text_color)
773 if widget.get_text() == self.ENTER_URL_TEXT:
774 widget.set_text('')
776 def entry_add_channel_unfocus(self, widget, event):
777 if widget.get_text() == '':
778 widget.set_text(self.ENTER_URL_TEXT)
779 widget.modify_text(gtk.STATE_NORMAL, gtk.gdk.color_parse('#aaaaaa'))
781 def on_config_changed(self, name, old_value, new_value):
782 if name == 'show_toolbar' and gpodder.interface != gpodder.MAEMO:
783 if new_value:
784 self.toolbar.show()
785 else:
786 self.toolbar.hide()
787 elif name == 'episode_list_descriptions' and gpodder.interface != gpodder.MAEMO:
788 self.updateTreeView()
789 elif name == 'show_url_entry_in_podcast_list':
790 if new_value:
791 self.hboxAddChannel.show()
792 else:
793 self.hboxAddChannel.hide()
795 def read_apps(self):
796 time.sleep(3) # give other parts of gpodder a chance to start up
797 self.user_apps_reader.read()
798 util.idle_add(self.user_apps_reader.get_applications_as_model, 'audio', False)
799 util.idle_add(self.user_apps_reader.get_applications_as_model, 'video', False)
801 def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
802 # With get_bin_window, we get the window that contains the rows without
803 # the header. The Y coordinate of this window will be the height of the
804 # treeview header. This is the amount we have to subtract from the
805 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
806 (x_bin, y_bin) = treeview.get_bin_window().get_position()
807 y -= x_bin
808 y -= y_bin
809 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
811 if not self.episode_list_can_tooltip or (column is not None and column != treeview.get_columns()[0]):
812 self.last_tooltip_episode = None
813 return False
815 if path is not None:
816 model = treeview.get_model()
817 iter = model.get_iter(path)
818 url = model.get_value(iter, 0)
819 description = model.get_value(iter, 7)
820 if self.last_tooltip_episode is not None and self.last_tooltip_episode != url:
821 self.last_tooltip_episode = None
822 return False
823 self.last_tooltip_episode = url
825 if len(description) > 400:
826 description = description[:398]+'[...]'
828 tooltip.set_text(description)
829 return True
831 self.last_tooltip_episode = None
832 return False
834 def podcast_list_allow_tooltips(self):
835 self.podcast_list_can_tooltip = True
837 def episode_list_allow_tooltips(self):
838 self.episode_list_can_tooltip = True
840 def treeview_channels_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
841 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
843 if not self.podcast_list_can_tooltip or (column is not None and column != treeview.get_columns()[0]):
844 self.last_tooltip_channel = None
845 return False
847 if path is not None:
848 model = treeview.get_model()
849 iter = model.get_iter(path)
850 url = model.get_value(iter, 0)
851 for channel in self.channels:
852 if channel.url == url:
853 if self.last_tooltip_channel is not None and self.last_tooltip_channel != channel:
854 self.last_tooltip_channel = None
855 return False
856 self.last_tooltip_channel = channel
857 channel.request_save_dir_size()
858 diskspace_str = gl.format_filesize(channel.save_dir_size, 0)
859 error_str = model.get_value(iter, 6)
860 if error_str:
861 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
862 error_str = '<span foreground="#ff0000">%s</span>' % error_str
863 table = gtk.Table(rows=3, columns=3)
864 table.set_row_spacings(5)
865 table.set_col_spacings(5)
866 table.set_border_width(5)
868 heading = gtk.Label()
869 heading.set_alignment(0, 1)
870 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
871 table.attach(heading, 0, 1, 0, 1)
872 size_info = gtk.Label()
873 size_info.set_alignment(1, 1)
874 size_info.set_justify(gtk.JUSTIFY_RIGHT)
875 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
876 table.attach(size_info, 2, 3, 0, 1)
878 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
880 if len(channel.description) < 500:
881 description = channel.description
882 else:
883 pos = channel.description.find('\n\n')
884 if pos == -1 or pos > 500:
885 description = channel.description[:498]+'[...]'
886 else:
887 description = channel.description[:pos]
889 description = gtk.Label(description)
890 if error_str:
891 description.set_markup(error_str)
892 description.set_alignment(0, 0)
893 description.set_line_wrap(True)
894 table.attach(description, 0, 3, 2, 3)
896 table.show_all()
897 tooltip.set_custom(table)
899 return True
901 self.last_tooltip_channel = None
902 return False
904 def update_m3u_playlist_clicked(self, widget):
905 self.active_channel.update_m3u_playlist()
906 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'))
908 def treeview_channels_button_pressed( self, treeview, event):
909 global WEB_BROWSER_ICON
911 if gpodder.interface == gpodder.MAEMO:
912 self.treeview_channels_buttonpress = (event.x, event.y)
913 return True
915 if event.button == 3:
916 ( x, y ) = ( int(event.x), int(event.y) )
917 ( path, column, rx, ry ) = treeview.get_path_at_pos( x, y) or (None,)*4
919 paths = []
921 # Did the user right-click into a selection?
922 selection = treeview.get_selection()
923 if selection.count_selected_rows() and path:
924 ( model, paths ) = selection.get_selected_rows()
925 if path not in paths:
926 # We have right-clicked, but not into the
927 # selection, assume we don't want to operate
928 # on the selection
929 paths = []
931 # No selection or right click not in selection:
932 # Select the single item where we clicked
933 if not len( paths) and path:
934 treeview.grab_focus()
935 treeview.set_cursor( path, column, 0)
937 ( model, paths ) = ( treeview.get_model(), [ path ] )
939 # We did not find a selection, and the user didn't
940 # click on an item to select -- don't show the menu
941 if not len( paths):
942 return True
944 menu = gtk.Menu()
946 item = gtk.ImageMenuItem( _('Open download folder'))
947 item.set_image( gtk.image_new_from_icon_name( 'folder-open', gtk.ICON_SIZE_MENU))
948 item.connect('activate', lambda x: util.gui_open(self.active_channel.save_dir))
949 menu.append( item)
951 item = gtk.ImageMenuItem( _('Update Feed'))
952 item.set_image( gtk.image_new_from_icon_name( 'gtk-refresh', gtk.ICON_SIZE_MENU))
953 item.connect('activate', self.on_itemUpdateChannel_activate )
954 item.set_sensitive( not self.updating_feed_cache )
955 menu.append( item)
957 if gl.config.create_m3u_playlists:
958 item = gtk.ImageMenuItem(_('Update M3U playlist'))
959 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
960 item.connect('activate', self.update_m3u_playlist_clicked)
961 menu.append(item)
963 if self.active_channel.link:
964 item = gtk.ImageMenuItem(_('Visit website'))
965 item.set_image(gtk.image_new_from_icon_name(WEB_BROWSER_ICON, gtk.ICON_SIZE_MENU))
966 item.connect('activate', lambda w: util.open_website(self.active_channel.link))
967 menu.append(item)
969 if self.active_channel.channel_is_locked:
970 item = gtk.ImageMenuItem(_('Allow deletion of all episodes'))
971 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
972 item.connect('activate', self.on_channel_toggle_lock_activate)
973 menu.append(self.set_finger_friendly(item))
974 else:
975 item = gtk.ImageMenuItem(_('Prohibit deletion of all episodes'))
976 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
977 item.connect('activate', self.on_channel_toggle_lock_activate)
978 menu.append(self.set_finger_friendly(item))
981 menu.append( gtk.SeparatorMenuItem())
983 item = gtk.ImageMenuItem(gtk.STOCK_EDIT)
984 item.connect( 'activate', self.on_itemEditChannel_activate)
985 menu.append( item)
987 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
988 item.connect( 'activate', self.on_itemRemoveChannel_activate)
989 menu.append( item)
991 menu.show_all()
992 # Disable tooltips while we are showing the menu, so
993 # the tooltip will not appear over the menu
994 self.podcast_list_can_tooltip = False
995 menu.connect('deactivate', lambda menushell: self.podcast_list_allow_tooltips())
996 menu.popup( None, None, None, event.button, event.time)
998 return True
1000 def on_itemClose_activate(self, widget):
1001 if self.tray_icon is not None:
1002 if gpodder.interface == gpodder.MAEMO:
1003 self.gPodder.set_property('visible', False)
1004 else:
1005 self.iconify_main_window()
1006 else:
1007 self.on_gPodder_delete_event(widget)
1009 def cover_file_removed(self, channel_url):
1011 The Cover Downloader calls this when a previously-
1012 available cover has been removed from the disk. We
1013 have to update our cache to reflect this change.
1015 (COLUMN_URL, COLUMN_PIXBUF) = (0, 5)
1016 for row in self.treeChannels.get_model():
1017 if row[COLUMN_URL] == channel_url:
1018 row[COLUMN_PIXBUF] = None
1019 key = (channel_url, gl.config.podcast_list_icon_size, \
1020 gl.config.podcast_list_icon_size)
1021 if key in self.cover_cache:
1022 del self.cover_cache[key]
1025 def cover_download_finished(self, channel_url, pixbuf):
1027 The Cover Downloader calls this when it has finished
1028 downloading (or registering, if already downloaded)
1029 a new channel cover, which is ready for displaying.
1031 if pixbuf is not None:
1032 (COLUMN_URL, COLUMN_PIXBUF) = (0, 5)
1033 for row in self.treeChannels.get_model():
1034 if row[COLUMN_URL] == channel_url and row[COLUMN_PIXBUF] is None:
1035 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)
1036 row[COLUMN_PIXBUF] = new_pixbuf or pixbuf
1038 def save_episode_as_file( self, url, *args):
1039 episode = self.active_channel.find_episode(url)
1041 if episode.was_downloaded(and_exists=True):
1042 folder = self.folder_for_saving_episodes
1043 copy_from = episode.local_filename(create=False)
1044 assert copy_from is not None
1045 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=episode.sync_filename(), dst_directory=folder)
1046 self.folder_for_saving_episodes = folder
1048 def copy_episode_bluetooth(self, url, *args):
1049 episode = self.active_channel.find_episode(url)
1051 if not episode.was_downloaded(and_exists=True):
1052 log('Cannot copy episode via bluetooth (does not exist!)', sender=self)
1054 filename = episode.local_filename(create=False)
1055 assert filename is not None
1057 if gl.config.bluetooth_use_device_address:
1058 device = gl.config.bluetooth_device_address
1059 else:
1060 device = None
1062 destfile = os.path.join(gl.tempdir, util.sanitize_filename(episode.sync_filename()))
1063 (base, ext) = os.path.splitext(filename)
1064 if not destfile.endswith(ext):
1065 destfile += ext
1067 if gl.config.bluetooth_use_converter:
1068 title = _('Converting file')
1069 message = _('Please wait while gPodder converts your media file for bluetooth file transfer.')
1070 dlg = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_NONE)
1071 dlg.set_title(title)
1072 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
1073 dlg.show_all()
1074 else:
1075 dlg = None
1077 def convert_and_send_thread(filename, destfile, device, dialog, notify):
1078 if gl.config.bluetooth_use_converter:
1079 p = subprocess.Popen([gl.config.bluetooth_converter, filename, destfile], stdout=sys.stdout, stderr=sys.stderr)
1080 result = p.wait()
1081 if dialog is not None:
1082 dialog.destroy()
1083 else:
1084 try:
1085 shutil.copyfile(filename, destfile)
1086 result = 0
1087 except:
1088 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1089 result = 1
1091 if result == 0 or not os.path.exists(destfile):
1092 util.bluetooth_send_file(destfile, device)
1093 else:
1094 notify(_('Error converting file.'), _('Bluetooth file transfer'))
1095 util.delete_file(destfile)
1097 Thread(target=convert_and_send_thread, args=[filename, destfile, device, dlg, self.notification]).start()
1099 def treeview_button_savepos(self, treeview, event):
1100 if gpodder.interface == gpodder.MAEMO and event.button == 1:
1101 self.treeview_available_buttonpress = (event.x, event.y)
1102 return True
1104 def treeview_channels_button_released(self, treeview, event):
1105 if gpodder.interface == gpodder.MAEMO and event.button == 1:
1106 selection = self.treeChannels.get_selection()
1107 pathatpos = self.treeChannels.get_path_at_pos(int(event.x), int(event.y))
1108 if self.currently_updating:
1109 log('do not handle press while updating', sender=self)
1110 return True
1111 if pathatpos is None:
1112 return False
1113 else:
1114 ydistance = int(abs(event.y-self.treeview_channels_buttonpress[1]))
1115 xdistance = int(event.x-self.treeview_channels_buttonpress[0])
1116 if ydistance < 30:
1117 (path, column, x, y) = pathatpos
1118 selection.select_path(path)
1119 self.treeChannels.set_cursor(path)
1120 self.treeChannels.grab_focus()
1121 return True
1123 def treeview_button_pressed( self, treeview, event):
1124 global WEB_BROWSER_ICON
1126 if gpodder.interface == gpodder.MAEMO:
1127 ydistance = int(abs(event.y-self.treeview_available_buttonpress[1]))
1128 xdistance = int(event.x-self.treeview_available_buttonpress[0])
1130 selection = self.treeAvailable.get_selection()
1131 pathatpos = self.treeAvailable.get_path_at_pos(int(event.x), int(event.y))
1132 if pathatpos is None:
1133 # No item at the current cursor position
1134 return False
1135 elif ydistance < 30:
1136 # Item under the cursor, and no scrolling done
1137 (path, column, x, y) = pathatpos
1138 selection.select_path(path)
1139 self.treeAvailable.set_cursor(path)
1140 self.treeAvailable.grab_focus()
1141 if gl.config.maemo_enable_gestures and xdistance > 70:
1142 self.on_treeAvailable_row_activated(self.itemPlaySelected)
1143 return True
1144 elif gl.config.maemo_enable_gestures and xdistance < -70:
1145 self.on_treeAvailable_row_activated(self.treeAvailable)
1146 return True
1147 else:
1148 # Scrolling has been done
1149 return True
1151 # Use right-click for the Desktop version and left-click for Maemo
1152 if (event.button == 1 and gpodder.interface == gpodder.MAEMO) or \
1153 (event.button == 3 and gpodder.interface == gpodder.GUI):
1154 ( x, y ) = ( int(event.x), int(event.y) )
1155 ( path, column, rx, ry ) = treeview.get_path_at_pos( x, y) or (None,)*4
1157 paths = []
1159 # Did the user right-click into a selection?
1160 selection = self.treeAvailable.get_selection()
1161 if selection.count_selected_rows() and path:
1162 ( model, paths ) = selection.get_selected_rows()
1163 if path not in paths:
1164 # We have right-clicked, but not into the
1165 # selection, assume we don't want to operate
1166 # on the selection
1167 paths = []
1169 # No selection or right click not in selection:
1170 # Select the single item where we clicked
1171 if not len( paths) and path:
1172 treeview.grab_focus()
1173 treeview.set_cursor( path, column, 0)
1175 ( model, paths ) = ( treeview.get_model(), [ path ] )
1177 # We did not find a selection, and the user didn't
1178 # click on an item to select -- don't show the menu
1179 if not len( paths):
1180 return True
1182 first_url = model.get_value( model.get_iter( paths[0]), 0)
1183 episode = db.load_episode(first_url)
1185 menu = gtk.Menu()
1187 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1189 if can_play:
1190 if open_instead_of_play:
1191 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1192 else:
1193 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1194 item.connect( 'activate', lambda w: self.on_treeAvailable_row_activated( self.toolPlay))
1195 menu.append(self.set_finger_friendly(item))
1197 if not episode['is_locked'] and can_delete:
1198 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1199 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1200 menu.append(self.set_finger_friendly(item))
1202 if can_cancel:
1203 item = gtk.ImageMenuItem( _('Cancel download'))
1204 item.set_image( gtk.image_new_from_stock( gtk.STOCK_STOP, gtk.ICON_SIZE_MENU))
1205 item.connect( 'activate', lambda w: self.on_treeDownloads_row_activated( self.toolCancel))
1206 menu.append(self.set_finger_friendly(item))
1208 if can_download:
1209 item = gtk.ImageMenuItem(_('Download'))
1210 item.set_image( gtk.image_new_from_stock( gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1211 item.connect( 'activate', lambda w: self.on_treeAvailable_row_activated( self.toolDownload))
1212 menu.append(self.set_finger_friendly(item))
1214 if episode['state'] == db.STATE_NORMAL and not episode['is_played']: # can_download:
1215 item = gtk.ImageMenuItem(_('Do not download'))
1216 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1217 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1218 menu.append(self.set_finger_friendly(item))
1219 elif episode['state'] == db.STATE_NORMAL and can_download:
1220 item = gtk.ImageMenuItem(_('Mark as new'))
1221 item.set_image(gtk.image_new_from_stock(gtk.STOCK_ABOUT, gtk.ICON_SIZE_MENU))
1222 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1223 menu.append(self.set_finger_friendly(item))
1225 if can_play and not can_download:
1226 menu.append( gtk.SeparatorMenuItem())
1227 item = gtk.ImageMenuItem(_('Save to disk'))
1228 item.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE_AS, gtk.ICON_SIZE_MENU))
1229 item.connect( 'activate', lambda w: self.for_each_selected_episode_url(self.save_episode_as_file))
1230 menu.append(self.set_finger_friendly(item))
1231 if gl.bluetooth_available:
1232 item = gtk.ImageMenuItem(_('Send via bluetooth'))
1233 item.set_image(gtk.image_new_from_icon_name('bluetooth', gtk.ICON_SIZE_MENU))
1234 item.connect('activate', lambda w: self.copy_episode_bluetooth(episode_url))
1235 menu.append(self.set_finger_friendly(item))
1236 if can_transfer:
1237 item = gtk.ImageMenuItem(_('Transfer to %s') % gl.get_device_name())
1238 item.set_image(gtk.image_new_from_icon_name('multimedia-player', gtk.ICON_SIZE_MENU))
1239 item.connect('activate', lambda w: self.on_treeAvailable_row_activated(self.toolTransfer))
1240 menu.append(self.set_finger_friendly(item))
1242 if can_play:
1243 menu.append( gtk.SeparatorMenuItem())
1244 is_played = episode['is_played']
1245 if is_played:
1246 item = gtk.ImageMenuItem(_('Mark as unplayed'))
1247 item.set_image( gtk.image_new_from_stock( gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU))
1248 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, False))
1249 menu.append(self.set_finger_friendly(item))
1250 else:
1251 item = gtk.ImageMenuItem(_('Mark as played'))
1252 item.set_image( gtk.image_new_from_stock( gtk.STOCK_APPLY, gtk.ICON_SIZE_MENU))
1253 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, True))
1254 menu.append(self.set_finger_friendly(item))
1256 is_locked = episode['is_locked']
1257 if is_locked:
1258 item = gtk.ImageMenuItem(_('Allow deletion'))
1259 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1260 item.connect('activate', self.on_item_toggle_lock_activate)
1261 menu.append(self.set_finger_friendly(item))
1262 else:
1263 item = gtk.ImageMenuItem(_('Prohibit deletion'))
1264 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1265 item.connect('activate', self.on_item_toggle_lock_activate)
1266 menu.append(self.set_finger_friendly(item))
1268 if len(paths) == 1:
1269 menu.append(gtk.SeparatorMenuItem())
1270 # Single item, add episode information menu item
1271 episode_url = model.get_value( model.get_iter( paths[0]), 0)
1272 item = gtk.ImageMenuItem(_('Episode details'))
1273 item.set_image( gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1274 item.connect( 'activate', lambda w: self.on_treeAvailable_row_activated( self.treeAvailable))
1275 menu.append(self.set_finger_friendly(item))
1276 episode = self.active_channel.find_episode(episode_url)
1277 # If we have it, also add episode website link
1278 if episode and episode.link and episode.link != episode.url:
1279 item = gtk.ImageMenuItem(_('Visit website'))
1280 item.set_image(gtk.image_new_from_icon_name(WEB_BROWSER_ICON, gtk.ICON_SIZE_MENU))
1281 item.connect('activate', lambda w: util.open_website(episode.link))
1282 menu.append(self.set_finger_friendly(item))
1284 if gpodder.interface == gpodder.MAEMO:
1285 # Because we open the popup on left-click for Maemo,
1286 # we also include a non-action to close the menu
1287 menu.append(gtk.SeparatorMenuItem())
1288 item = gtk.ImageMenuItem(_('Close this menu'))
1289 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1290 menu.append(self.set_finger_friendly(item))
1292 menu.show_all()
1293 # Disable tooltips while we are showing the menu, so
1294 # the tooltip will not appear over the menu
1295 self.episode_list_can_tooltip = False
1296 menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
1297 menu.popup( None, None, None, event.button, event.time)
1299 return True
1301 def set_title(self, new_title):
1302 self.default_title = new_title
1303 self.gPodder.set_title(new_title)
1305 def download_progress_updated( self, count, percentage):
1306 title = [ self.default_title ]
1308 total_speed = gl.format_filesize(services.download_status_manager.total_speed())
1310 if count == 1:
1311 title.append( _('downloading one file'))
1312 elif count > 1:
1313 title.append( _('downloading %d files') % count)
1315 if len(title) == 2:
1316 title[1] = ''.join( [ title[1], ' (%d%%, %s/s)' % (percentage, total_speed) ])
1318 self.gPodder.set_title( ' - '.join( title))
1320 # Have all the downloads completed?
1321 # If so execute user command if defined, else do nothing
1322 if count == 0:
1323 if len(gl.config.cmd_all_downloads_complete) > 0:
1324 Thread(target=gl.ext_command_thread, args=(self.notification,gl.config.cmd_all_downloads_complete)).start()
1326 def update_selected_episode_list_icons(self):
1328 Updates the status icons in the episode list
1330 selection = self.treeAvailable.get_selection()
1331 (model, paths) = selection.get_selected_rows()
1332 for path in paths:
1333 iter = model.get_iter(path)
1334 self.active_channel.iter_set_downloading_columns(model, iter)
1336 def update_episode_list_icons(self, urls):
1338 Updates the status icons in the episode list
1339 Only update the episodes that have an URL in
1340 the "urls" iterable object (e.g. a list of URLs)
1342 if self.active_channel is None:
1343 return
1345 model = self.treeAvailable.get_model()
1346 if model is None:
1347 return
1349 for url in urls:
1350 if url in self.url_path_mapping:
1351 path = (self.url_path_mapping[url],)
1352 self.active_channel.iter_set_downloading_columns(model, model.get_iter(path))
1354 def playback_episode(self, episode, stream=False):
1355 if gpodder.interface == gpodder.MAEMO:
1356 banner = hildon.hildon_banner_show_animation(self.gPodder, None, _('Opening %s') % saxutils.escape(episode.title))
1357 def destroy_banner_later(banner):
1358 banner.destroy()
1359 return False
1360 gobject.timeout_add(5000, destroy_banner_later, banner)
1361 (success, application) = gl.playback_episode(episode, stream)
1362 if not success:
1363 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), ))
1364 self.update_selected_episode_list_icons()
1365 self.updateComboBox(only_selected_channel=True)
1367 def treeAvailable_search_equal( self, model, column, key, iter, data = None):
1368 if model is None:
1369 return True
1371 key = key.lower()
1373 # columns, as defined in libpodcasts' get model method
1374 # 1 = episode title, 7 = description
1375 columns = (1, 7)
1377 for column in columns:
1378 value = model.get_value( iter, column).lower()
1379 if value.find( key) != -1:
1380 return False
1382 return True
1384 def change_menu_item(self, menuitem, icon=None, label=None):
1385 if icon is not None:
1386 menuitem.get_image().set_from_icon_name(icon, gtk.ICON_SIZE_MENU)
1387 if label is not None:
1388 label_widget = menuitem.get_child()
1389 label_widget.set_text(label)
1391 def play_or_download(self):
1392 if self.wNotebook.get_current_page() > 0:
1393 return
1395 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
1396 ( is_played, is_locked ) = (False,)*2
1398 open_instead_of_play = False
1400 selection = self.treeAvailable.get_selection()
1401 if selection.count_selected_rows() > 0:
1402 (model, paths) = selection.get_selected_rows()
1404 for path in paths:
1405 url = model.get_value( model.get_iter( path), 0)
1407 episode = self.active_channel.find_episode(url)
1409 if episode.file_type() not in ('audio', 'video'):
1410 open_instead_of_play = True
1412 if episode.was_downloaded():
1413 can_play = episode.was_downloaded(and_exists=True)
1414 can_delete = True
1415 is_played = episode.is_played
1416 is_locked = episode.is_locked
1417 if not can_play:
1418 can_download = True
1419 else:
1420 if services.download_status_manager.is_download_in_progress(url):
1421 can_cancel = True
1422 else:
1423 can_download = True
1425 can_download = can_download and not can_cancel
1426 can_play = gl.config.enable_streaming or (can_play and not can_cancel and not can_download)
1427 can_transfer = can_play and gl.config.device_type != 'none' and not can_cancel and not can_download
1429 if open_instead_of_play:
1430 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
1431 can_transfer = False
1432 else:
1433 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
1435 self.toolPlay.set_sensitive( can_play)
1436 self.toolDownload.set_sensitive( can_download)
1437 self.toolTransfer.set_sensitive( can_transfer)
1438 self.toolCancel.set_sensitive( can_cancel)
1440 if can_cancel:
1441 self.item_cancel_download.show_all()
1442 else:
1443 self.item_cancel_download.hide_all()
1444 if can_download:
1445 self.itemDownloadSelected.show_all()
1446 else:
1447 self.itemDownloadSelected.hide_all()
1448 if can_play:
1449 if open_instead_of_play:
1450 self.itemOpenSelected.show_all()
1451 self.itemPlaySelected.hide_all()
1452 else:
1453 self.itemPlaySelected.show_all()
1454 self.itemOpenSelected.hide_all()
1455 if not can_download:
1456 self.itemDeleteSelected.show_all()
1457 else:
1458 self.itemDeleteSelected.hide_all()
1459 self.item_toggle_played.show_all()
1460 self.item_toggle_lock.show_all()
1461 self.separator9.show_all()
1462 if is_played:
1463 self.change_menu_item(self.item_toggle_played, gtk.STOCK_CANCEL, _('Mark as unplayed'))
1464 else:
1465 self.change_menu_item(self.item_toggle_played, gtk.STOCK_APPLY, _('Mark as played'))
1466 if is_locked:
1467 self.change_menu_item(self.item_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Allow deletion'))
1468 else:
1469 self.change_menu_item(self.item_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Prohibit deletion'))
1470 else:
1471 self.itemPlaySelected.hide_all()
1472 self.itemOpenSelected.hide_all()
1473 self.itemDeleteSelected.hide_all()
1474 self.item_toggle_played.hide_all()
1475 self.item_toggle_lock.hide_all()
1476 self.separator9.hide_all()
1477 if can_play or can_download or can_cancel:
1478 self.item_episode_details.show_all()
1479 self.separator16.show_all()
1480 self.no_episode_selected.hide_all()
1481 else:
1482 self.item_episode_details.hide_all()
1483 self.separator16.hide_all()
1484 self.no_episode_selected.show_all()
1486 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
1488 def download_status_updated(self, episode_urls, channel_urls):
1489 count = services.download_status_manager.count()
1490 if count:
1491 self.labelDownloads.set_text( _('Downloads (%d)') % count)
1492 else:
1493 self.labelDownloads.set_text( _('Downloads'))
1495 self.update_episode_list_icons(episode_urls)
1496 self.updateComboBox(only_these_urls=channel_urls)
1498 def on_cbMaxDownloads_toggled(self, widget, *args):
1499 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
1501 def on_cbLimitDownloads_toggled(self, widget, *args):
1502 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
1504 def episode_new_status_changed(self, urls):
1505 self.updateComboBox()
1506 self.update_episode_list_icons(urls)
1508 def updateComboBox(self, selected_url=None, only_selected_channel=False, only_these_urls=None):
1509 selection = self.treeChannels.get_selection()
1510 (model, iter) = selection.get_selected()
1512 if only_selected_channel:
1513 # very cheap! only update selected channel
1514 if iter and self.active_channel is not None:
1515 update_channel_model_by_iter(model, iter,
1516 self.active_channel, self.channel_colors,
1517 self.cover_cache,
1518 gl.config.podcast_list_icon_size,
1519 gl.config.podcast_list_icon_size)
1520 elif not self.channel_list_changed:
1521 # we can keep the model, but have to update some
1522 if only_these_urls is None:
1523 # still cheaper than reloading the whole list
1524 iter = model.get_iter_first()
1525 while iter is not None:
1526 (index,) = model.get_path(iter)
1527 update_channel_model_by_iter(model, iter,
1528 self.channels[index], self.channel_colors,
1529 self.cover_cache,
1530 gl.config.podcast_list_icon_size,
1531 gl.config.podcast_list_icon_size)
1532 iter = model.iter_next(iter)
1533 else:
1534 # ok, we got a bunch of urls to update
1535 for url in only_these_urls:
1536 if url in self.channel_url_path_mapping:
1537 index = self.channel_url_path_mapping[url]
1538 path = (index,)
1539 iter = model.get_iter(path)
1540 update_channel_model_by_iter(model, iter,
1541 self.channels[index], self.channel_colors,
1542 self.cover_cache,
1543 gl.config.podcast_list_icon_size,
1544 gl.config.podcast_list_icon_size)
1545 else:
1546 if model and iter and selected_url is None:
1547 # Get the URL of the currently-selected podcast
1548 selected_url = model.get_value(iter, 0)
1550 (model, urls) = channels_to_model(self.channels,
1551 self.channel_colors, self.cover_cache,
1552 gl.config.podcast_list_icon_size,
1553 gl.config.podcast_list_icon_size)
1555 self.channel_url_path_mapping = dict(zip(urls, range(len(urls))))
1556 self.treeChannels.set_model(model)
1558 try:
1559 selected_path = (0,)
1560 # Find the previously-selected URL in the new
1561 # model if we have an URL (else select first)
1562 if selected_url is not None:
1563 pos = model.get_iter_first()
1564 while pos is not None:
1565 url = model.get_value(pos, 0)
1566 if url == selected_url:
1567 selected_path = model.get_path(pos)
1568 break
1569 pos = model.iter_next(pos)
1571 self.treeChannels.get_selection().select_path(selected_path)
1572 except:
1573 log( 'Cannot set selection on treeChannels', sender = self)
1574 self.on_treeChannels_cursor_changed( self.treeChannels)
1575 self.channel_list_changed = False
1577 def updateTreeView(self):
1578 if self.channels and self.active_channel is not None:
1579 if gpodder.interface == gpodder.MAEMO:
1580 banner = hildon.hildon_banner_show_animation(self.gPodder, None, _('Loading episodes for %s') % saxutils.escape(self.active_channel.title))
1581 else:
1582 banner = None
1583 def thread_func(self, banner, active_channel):
1584 (model, urls) = self.active_channel.get_tree_model()
1585 mapping = dict(zip(urls, range(len(urls))))
1586 def update_gui_with_new_model(self, channel, model, urls, mapping, banner):
1587 if self.active_channel is not None and channel is not None:
1588 log('%s <=> %s', self.active_channel.title, channel.title, sender=self)
1589 if self.active_channel == channel:
1590 self.treeAvailable.set_model(model)
1591 self.url_path_mapping = mapping
1592 self.treeAvailable.columns_autosize()
1593 self.play_or_download()
1594 if banner is not None:
1595 banner.destroy()
1596 self.currently_updating = False
1597 return False
1598 gobject.idle_add(lambda: update_gui_with_new_model(self, active_channel, model, urls, mapping, banner))
1599 self.currently_updating = True
1600 Thread(target=thread_func, args=[self, banner, self.active_channel]).start()
1601 else:
1602 model = self.treeAvailable.get_model()
1603 if model is not None:
1604 model.clear()
1606 def drag_data_received(self, widget, context, x, y, sel, ttype, time):
1607 (path, column, rx, ry) = self.treeChannels.get_path_at_pos( x, y) or (None,)*4
1609 dnd_channel = None
1610 if path is not None:
1611 model = self.treeChannels.get_model()
1612 iter = model.get_iter(path)
1613 url = model.get_value(iter, 0)
1614 for channel in self.channels:
1615 if channel.url == url:
1616 dnd_channel = channel
1617 break
1619 result = sel.data
1620 rl = result.strip().lower()
1621 if (rl.endswith('.jpg') or rl.endswith('.png') or rl.endswith('.gif') or rl.endswith('.svg')) and dnd_channel is not None:
1622 services.cover_downloader.replace_cover(dnd_channel, result)
1623 else:
1624 self.add_new_channel(result)
1626 def add_new_channel(self, result=None, ask_download_new=True, quiet=False, block=False, authentication_tokens=None):
1627 (scheme, rest) = result.split('://', 1)
1628 result = util.normalize_feed_url(result)
1630 if not result:
1631 cute_scheme = saxutils.escape(scheme)+'://'
1632 title = _('%s URLs are not supported') % cute_scheme
1633 message = _('gPodder does not understand the URL you supplied.')
1634 self.show_message( message, title)
1635 return
1637 for old_channel in self.channels:
1638 if old_channel.url == result:
1639 log( 'Channel already exists: %s', result)
1640 # Select the existing channel in combo box
1641 for i in range( len( self.channels)):
1642 if self.channels[i] == old_channel:
1643 self.treeChannels.get_selection().select_path( (i,))
1644 self.on_treeChannels_cursor_changed(self.treeChannels)
1645 break
1646 self.show_message( _('You have already subscribed to this podcast: %s') % (
1647 saxutils.escape( old_channel.title), ), _('Already added'))
1648 return
1650 waitdlg = gtk.MessageDialog(self.gPodder, 0, gtk.MESSAGE_INFO, gtk.BUTTONS_NONE)
1651 waitdlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
1652 waitdlg.set_title(_('Downloading episode list'))
1653 waitdlg.set_markup('<b><big>%s</big></b>' % waitdlg.get_title())
1654 waitdlg.format_secondary_text(_('Please wait while I am downloading episode information for %s') % result)
1655 waitpb = gtk.ProgressBar()
1656 if block:
1657 waitdlg.vbox.add(waitpb)
1658 waitdlg.show_all()
1659 waitdlg.set_response_sensitive(gtk.RESPONSE_CANCEL, False)
1661 self.entryAddChannel.set_text(_('Downloading feed...'))
1662 self.entryAddChannel.set_sensitive(False)
1663 self.btnAddChannel.set_sensitive(False)
1664 args = (result, self.add_new_channel_finish, authentication_tokens, ask_download_new, quiet, waitdlg)
1665 thread = Thread( target=self.add_new_channel_proc, args=args )
1666 thread.start()
1668 while block and thread.isAlive():
1669 while gtk.events_pending():
1670 gtk.main_iteration( False)
1671 waitpb.pulse()
1672 time.sleep(0.1)
1675 def add_new_channel_proc( self, url, callback, authentication_tokens, *callback_args):
1676 log( 'Adding new channel: %s', url)
1677 channel = error = None
1678 try:
1679 channel = PodcastChannel.load(url=url, create=True, authentication_tokens=authentication_tokens)
1680 except HTTPAuthError, e:
1681 error = e
1682 except Exception, e:
1683 log('Error in PodcastChannel.load(%s): %s', url, e, traceback=True, sender=self)
1685 util.idle_add( callback, channel, url, error, *callback_args )
1687 def add_new_channel_finish( self, channel, url, error, ask_download_new, quiet, waitdlg):
1688 if channel is not None:
1689 self.channels.append( channel)
1690 self.channel_list_changed = True
1691 save_channels( self.channels)
1692 if not quiet:
1693 # download changed channels and select the new episode in the UI afterwards
1694 self.update_feed_cache(force_update=False, select_url_afterwards=channel.url)
1696 (username, password) = util.username_password_from_url( url)
1697 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')):
1698 channel.username = username
1699 channel.password = password
1700 log('Saving authentication data for episode downloads..', sender = self)
1701 channel.save()
1702 # We need to update the channel list otherwise the authentication
1703 # data won't show up in the channel editor.
1704 # TODO: Only updated the newly added feed to save some cpu cycles
1705 self.channels = load_channels()
1706 self.channel_list_changed = True
1708 if ask_download_new:
1709 new_episodes = channel.get_new_episodes()
1710 if len(new_episodes):
1711 self.new_episodes_show(new_episodes)
1713 elif isinstance( error, HTTPAuthError ):
1714 response, auth_tokens = self.UsernamePasswordDialog(
1715 _('Feed requires authentication'), _('Please enter your username and password.'))
1717 if response:
1718 self.add_new_channel( url, authentication_tokens=auth_tokens )
1720 else:
1721 # Ok, the URL is not a channel, or there is some other
1722 # error - let's see if it's a web page or OPML file...
1723 try:
1724 data = urllib2.urlopen(url).read().lower()
1725 if '</opml>' in data:
1726 # This looks like an OPML feed
1727 self.on_item_import_from_file_activate(None, url)
1729 elif '</html>' in data:
1730 # This looks like a web page
1731 title = _('The URL is a website')
1732 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.)')
1733 if self.show_confirmation(message, title):
1734 util.open_website(url)
1736 except Exception, e:
1737 log('Error trying to handle the URL as OPML or web page: %s', e, sender=self)
1739 title = _('Error adding podcast')
1740 message = _('The podcast could not be added. Please check the spelling of the URL or try again later.')
1741 self.show_message( message, title)
1743 self.entryAddChannel.set_text(self.ENTER_URL_TEXT)
1744 self.entryAddChannel.set_sensitive(True)
1745 self.btnAddChannel.set_sensitive(True)
1746 self.update_podcasts_tab()
1747 waitdlg.destroy()
1750 def update_feed_cache_finish_callback(self, channels=None,
1751 notify_no_new_episodes=False, select_url_afterwards=None):
1753 db.commit()
1755 self.updating_feed_cache = False
1756 if gpodder.interface == gpodder.MAEMO:
1757 self.btnCancelFeedUpdate.show()
1758 self.itemUpdate.set_sensitive(True)
1759 self.itemUpdateChannel.set_sensitive(True)
1761 # If we want to select a specific podcast (via its URL)
1762 # after the update, we give it to updateComboBox here to
1763 # select exactly this podcast after updating the view
1764 self.updateComboBox(selected_url=select_url_afterwards)
1766 if self.tray_icon:
1767 self.tray_icon.set_status(None)
1768 if self.minimized:
1769 new_episodes = []
1770 # look for new episodes to notify
1771 for channel in self.channels:
1772 for episode in channel.get_new_episodes():
1773 if not episode in self.already_notified_new_episodes:
1774 new_episodes.append(episode)
1775 self.already_notified_new_episodes.append(episode)
1776 # notify new episodes
1778 if len(new_episodes) == 0:
1779 if notify_no_new_episodes and self.tray_icon is not None:
1780 msg = _('No new episodes available for download')
1781 self.tray_icon.send_notification(msg)
1782 return
1783 elif len(new_episodes) == 1:
1784 title = _('gPodder has found %s') % (_('one new episode:'),)
1785 else:
1786 title = _('gPodder has found %s') % (_('%i new episodes:') % len(new_episodes))
1787 message = self.tray_icon.format_episode_list(new_episodes)
1789 #auto download new episodes
1790 if gl.config.auto_download_when_minimized:
1791 message += '\n<i>(%s...)</i>' % _('downloading')
1792 self.download_episode_list(new_episodes)
1793 self.tray_icon.send_notification(message, title)
1794 return
1796 # open the episodes selection dialog
1797 self.channels = load_channels()
1798 self.channel_list_changed = True
1799 self.updateComboBox()
1800 self.download_all_new(channels=channels)
1802 def update_feed_cache_callback(self, progressbar, title, position, count):
1803 progression = _('Updated %s (%d/%d)')%(title, position+1, count)
1804 progressbar.set_text(progression)
1805 if self.tray_icon:
1806 self.tray_icon.set_status(
1807 self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression )
1808 if count > 0:
1809 progressbar.set_fraction(float(position)/float(count))
1811 def update_feed_cache_proc( self, channel, total_channels, semaphore,
1812 callback_proc, finish_proc):
1814 semaphore.acquire()
1815 if not self.feed_cache_update_cancelled:
1816 try:
1817 channel.update()
1818 except:
1819 log('Darn SQLite LOCK!', sender=self, traceback=True)
1821 # By the time we get here the update may have already been cancelled
1822 if not self.feed_cache_update_cancelled:
1823 callback_proc(channel.title, self.updated_feeds, total_channels)
1825 self.updated_feeds += 1
1826 self.treeview_channel_set_color( channel, 'default' )
1827 channel.update_flag = False
1829 semaphore.release()
1830 if self.updated_feeds == total_channels:
1831 finish_proc()
1833 def on_btnCancelFeedUpdate_clicked(self, widget):
1834 if self.feed_cache_update_cancelled:
1835 if gpodder.interface == gpodder.MAEMO:
1836 self.btnUpdateSelectedFeed.show()
1837 self.toolFeedUpdateProgress.hide()
1838 self.btnCancelFeedUpdate.hide()
1839 self.btnCancelFeedUpdate.set_is_important(False)
1840 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
1841 self.toolbarSpacer.set_expand(True)
1842 self.toolbarSpacer.set_draw(False)
1843 else:
1844 self.hboxUpdateFeeds.hide()
1845 self.btnUpdateFeeds.show()
1846 else:
1847 self.pbFeedUpdate.set_text(_('Cancelling, please wait...'))
1848 self.feed_cache_update_cancelled = True
1849 self.btnCancelFeedUpdate.set_sensitive(False)
1851 def update_feed_cache(self, channels=None, force_update=True,
1852 notify_no_new_episodes=False, select_url_afterwards=None):
1854 if self.updating_feed_cache:
1855 return
1857 if not force_update:
1858 self.channels = load_channels()
1859 self.channel_list_changed = True
1860 self.updateComboBox()
1861 return
1863 self.updating_feed_cache = True
1864 self.itemUpdate.set_sensitive(False)
1865 self.itemUpdateChannel.set_sensitive(False)
1867 if self.tray_icon:
1868 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
1870 if channels is None:
1871 channels = self.channels
1873 if len(channels) == 1:
1874 text = _('Updating "%s"...') % channels[0].title
1875 else:
1876 text = _('Updating %d feeds...') % len(channels)
1877 self.pbFeedUpdate.set_text(text)
1878 self.pbFeedUpdate.set_fraction(0)
1880 # let's get down to business..
1881 callback_proc = lambda title, pos, count: util.idle_add(
1882 self.update_feed_cache_callback, self.pbFeedUpdate, title, pos, count )
1883 finish_proc = lambda: util.idle_add( self.update_feed_cache_finish_callback,
1884 channels, notify_no_new_episodes, select_url_afterwards )
1886 self.updated_feeds = 0
1887 self.feed_cache_update_cancelled = False
1888 self.btnCancelFeedUpdate.show()
1889 self.btnCancelFeedUpdate.set_sensitive(True)
1890 if gpodder.interface == gpodder.MAEMO:
1891 self.toolbarSpacer.set_expand(False)
1892 self.toolbarSpacer.set_draw(True)
1893 self.btnUpdateSelectedFeed.hide()
1894 self.toolFeedUpdateProgress.show_all()
1895 else:
1896 self.hboxUpdateFeeds.show_all()
1897 self.btnUpdateFeeds.hide()
1898 semaphore = Semaphore(gl.config.max_simulaneous_feeds_updating)
1900 for channel in channels:
1901 self.treeview_channel_set_color( channel, 'updating' )
1902 channel.update_flag = True
1903 args = (channel, len(channels), semaphore, callback_proc, finish_proc)
1904 thread = Thread( target = self.update_feed_cache_proc, args = args)
1905 thread.start()
1907 def treeview_channel_set_color( self, channel, color ):
1908 if self.treeChannels.get_model():
1909 if color in self.channel_colors:
1910 self.treeChannels.get_model().set(channel.iter, 8, self.channel_colors[color])
1911 else:
1912 self.treeChannels.get_model().set(channel.iter, 8, color)
1914 def on_gPodder_delete_event(self, widget, *args):
1915 """Called when the GUI wants to close the window
1916 Displays a confirmation dialog (and closes/hides gPodder)
1919 downloading = services.download_status_manager.has_items()
1921 # Only iconify if we are using the window's "X" button,
1922 # but not when we are using "Quit" in the menu or toolbar
1923 if not gl.config.on_quit_ask and gl.config.on_quit_systray and self.tray_icon and widget.name not in ('toolQuit', 'itemQuit'):
1924 self.iconify_main_window()
1925 elif gl.config.on_quit_ask or downloading:
1926 if gpodder.interface == gpodder.MAEMO:
1927 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
1928 if result:
1929 self.close_gpodder()
1930 else:
1931 return True
1932 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
1933 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
1934 dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
1936 title = _('Quit gPodder')
1937 if downloading:
1938 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
1939 else:
1940 message = _('Do you really want to quit gPodder now?')
1942 dialog.set_title(title)
1943 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
1944 if not downloading:
1945 cb_ask = gtk.CheckButton(_("Don't ask me again"))
1946 dialog.vbox.pack_start(cb_ask)
1947 cb_ask.show_all()
1949 result = dialog.run()
1950 dialog.destroy()
1952 if result == gtk.RESPONSE_CLOSE:
1953 if not downloading and cb_ask.get_active() == True:
1954 gl.config.on_quit_ask = False
1955 self.close_gpodder()
1956 else:
1957 self.close_gpodder()
1959 return True
1961 def close_gpodder(self):
1962 """ clean everything and exit properly
1964 if self.channels:
1965 if save_channels(self.channels):
1966 if gl.config.my_gpodder_autoupload:
1967 log('Uploading to my.gpodder.org on close', sender=self)
1968 util.idle_add(self.on_upload_to_mygpo, None)
1969 else:
1970 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'))
1972 services.download_status_manager.cancel_all(keep_files=True)
1973 self.gPodder.hide()
1974 while gtk.events_pending():
1975 gtk.main_iteration(False)
1977 db.close()
1979 self.gtk_main_quit()
1980 sys.exit( 0)
1982 def get_old_episodes(self):
1983 episodes = []
1984 for channel in self.channels:
1985 for episode in channel.get_downloaded_episodes():
1986 if episode.is_old() and not episode.is_locked and episode.is_played:
1987 episodes.append(episode)
1988 return episodes
1990 def for_each_selected_episode_url( self, callback):
1991 ( model, paths ) = self.treeAvailable.get_selection().get_selected_rows()
1992 for path in paths:
1993 url = model.get_value( model.get_iter( path), 0)
1994 try:
1995 callback( url)
1996 except Exception, e:
1997 log( 'Warning: Error in for_each_selected_episode_url for URL %s: %s', url, e, sender = self)
1999 self.update_selected_episode_list_icons()
2000 self.updateComboBox(only_selected_channel=True)
2001 db.commit()
2003 def delete_episode_list( self, episodes, confirm = True):
2004 if len(episodes) == 0:
2005 return
2007 if len(episodes) == 1:
2008 message = _('Do you really want to delete this episode?')
2009 else:
2010 message = _('Do you really want to delete %d episodes?') % len(episodes)
2012 if confirm and self.show_confirmation( message, _('Delete episodes')) == False:
2013 return
2015 episode_urls = set()
2016 channel_urls = set()
2017 for episode in episodes:
2018 log('Deleting episode: %s', episode.title, sender = self)
2019 episode.delete_from_disk()
2020 episode_urls.add(episode.url)
2021 channel_urls.add(episode.channel.url)
2023 self.download_status_updated(episode_urls, channel_urls)
2025 def on_itemRemoveOldEpisodes_activate( self, widget):
2026 columns = (
2027 ('title_and_description', None, None, _('Episode')),
2028 ('channel_prop', None, None, _('Podcast')),
2029 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2030 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2031 ('played_prop', None, None, _('Status')),
2032 ('age_prop', None, None, _('Downloaded')),
2035 selection_buttons = {
2036 _('Select played'): lambda episode: episode.is_played,
2037 _('Select older than %d days') % gl.config.episode_old_age: lambda episode: episode.is_old(),
2040 instructions = _('Select the episodes you want to delete from your hard disk.')
2042 episodes = []
2043 selected = []
2044 for channel in self.channels:
2045 for episode in channel.get_downloaded_episodes():
2046 if not episode.is_locked:
2047 episodes.append(episode)
2048 selected.append(episode.is_played)
2050 gPodderEpisodeSelector( title = _('Remove old episodes'), instructions = instructions, \
2051 episodes = episodes, selected = selected, columns = columns, \
2052 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
2053 selection_buttons = selection_buttons)
2055 def mark_selected_episodes_new(self):
2056 callback = lambda url: self.active_channel.find_episode(url).mark_new()
2057 self.for_each_selected_episode_url(callback)
2059 def mark_selected_episodes_old(self):
2060 callback = lambda url: self.active_channel.find_episode(url).mark_old()
2061 self.for_each_selected_episode_url(callback)
2063 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
2064 if toggle:
2065 callback = lambda url: db.mark_episode(url, is_played=True, toggle=True)
2066 else:
2067 callback = lambda url: db.mark_episode(url, is_played=new_value)
2069 self.for_each_selected_episode_url(callback)
2071 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2072 if toggle:
2073 callback = lambda url: db.mark_episode(url, is_locked=True, toggle=True)
2074 else:
2075 callback = lambda url: db.mark_episode(url, is_locked=new_value)
2077 self.for_each_selected_episode_url(callback)
2079 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2080 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
2081 db.update_channel_lock(self.active_channel)
2083 if self.active_channel.channel_is_locked:
2084 self.change_menu_item(self.channel_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Allow deletion of all episodes'))
2085 else:
2086 self.change_menu_item(self.channel_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Prohibit deletion of all episodes'))
2088 for episode in self.active_channel.get_all_episodes():
2089 db.mark_episode(episode.url, is_locked=self.active_channel.channel_is_locked)
2091 self.updateComboBox(only_selected_channel=True)
2093 def on_item_email_subscriptions_activate(self, widget):
2094 if not self.channels:
2095 self.show_message(_('Your subscription list is empty.'), _('Could not send list'))
2096 elif not gl.send_subscriptions():
2097 self.show_message(_('There was an error sending your subscription list via e-mail.'), _('Could not send list'))
2099 def on_itemUpdateChannel_activate(self, widget=None):
2100 self.update_feed_cache(channels=[self.active_channel,])
2102 def on_itemUpdate_activate(self, widget, notify_no_new_episodes=False):
2103 restore_from = can_restore_from_opml()
2105 if self.channels:
2106 self.update_feed_cache(notify_no_new_episodes=notify_no_new_episodes)
2107 elif restore_from is not None:
2108 title = _('Database upgrade required')
2109 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?')
2110 if self.show_confirmation(message, title):
2111 add_callback = lambda url: self.add_new_channel(url, False, True)
2112 w = gtk.Dialog(_('Migrating to SQLite'), self.gPodder, 0, (gtk.STOCK_CLOSE, gtk.RESPONSE_ACCEPT))
2113 w.set_has_separator(False)
2114 w.set_response_sensitive(gtk.RESPONSE_ACCEPT, False)
2115 w.set_default_size(500, -1)
2116 pb = gtk.ProgressBar()
2117 l = gtk.Label()
2118 l.set_padding(6, 3)
2119 l.set_markup('<b><big>%s</big></b>' % _('SQLite migration'))
2120 l.set_alignment(0.0, 0.5)
2121 w.vbox.pack_start(l)
2122 l = gtk.Label()
2123 l.set_padding(6, 3)
2124 l.set_alignment(0.0, 0.5)
2125 l.set_text(_('Please wait while your settings are converted.'))
2126 w.vbox.pack_start(l)
2127 w.vbox.pack_start(pb)
2128 lb = gtk.Label()
2129 lb.set_ellipsize(pango.ELLIPSIZE_END)
2130 lb.set_alignment(0.0, 0.5)
2131 lb.set_padding(6, 6)
2132 w.vbox.pack_start(lb)
2134 def set_pb_status(pb, lb, fraction, text):
2135 pb.set_fraction(float(fraction)/100.0)
2136 pb.set_text('%.0f %%' % fraction)
2137 lb.set_markup('<i>%s</i>' % saxutils.escape(text))
2138 while gtk.events_pending():
2139 gtk.main_iteration(False)
2140 status_callback = lambda fraction, text: set_pb_status(pb, lb, fraction, text)
2141 get_localdb = lambda channel: LocalDBReader(channel.url).read(channel.index_file)
2142 w.show_all()
2143 start = datetime.datetime.now()
2144 gl.migrate_to_sqlite(add_callback, status_callback, load_channels, get_localdb)
2145 # Refresh the view with the updated episodes
2146 self.updateComboBox()
2147 time_taken = str(datetime.datetime.now()-start)
2148 status_callback(100.0, _('Migration finished in %s') % time_taken)
2149 w.set_response_sensitive(gtk.RESPONSE_ACCEPT, True)
2150 w.run()
2151 w.destroy()
2152 else:
2153 gPodderWelcome(center_on_widget=self.gPodder, show_example_podcasts_callback=self.on_itemImportChannels_activate, setup_my_gpodder_callback=self.on_download_from_mygpo)
2155 def download_episode_list(self, episodes):
2156 services.download_status_manager.start_batch_mode()
2157 for episode in episodes:
2158 log('Downloading episode: %s', episode.title, sender = self)
2159 if not episode.was_downloaded(and_exists=True) and not services.download_status_manager.is_download_in_progress( episode.url):
2160 download.DownloadThread(episode.channel, episode, self.notification).start()
2161 services.download_status_manager.end_batch_mode()
2163 def new_episodes_show(self, episodes):
2164 columns = (
2165 ('title_and_description', None, None, _('Episode')),
2166 ('channel_prop', None, None, _('Podcast')),
2167 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2168 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2171 if len(episodes) > 0 and not self.feed_cache_update_cancelled:
2172 instructions = _('Select the episodes you want to download now.')
2174 self.feed_cache_update_cancelled = True
2175 self.on_btnCancelFeedUpdate_clicked(self.btnCancelFeedUpdate)
2177 gPodderEpisodeSelector(title=_('New episodes available'), instructions=instructions, \
2178 episodes=episodes, columns=columns, selected_default=True, \
2179 stock_ok_button = 'gpodder-download', \
2180 callback=self.download_episode_list, \
2181 remove_callback=lambda e: e.mark_old(), \
2182 remove_action=_('Never download'), \
2183 remove_finished=self.episode_new_status_changed)
2184 else:
2185 if gpodder.interface == gpodder.MAEMO:
2186 self.pbFeedUpdate.set_fraction(1.0)
2187 if self.feed_cache_update_cancelled:
2188 self.pbFeedUpdate.set_text(_('Update has been cancelled'))
2189 else:
2190 self.pbFeedUpdate.set_text(_('No new episodes'))
2191 self.feed_cache_update_cancelled = True
2192 self.btnCancelFeedUpdate.show()
2193 self.btnCancelFeedUpdate.set_sensitive(True)
2194 #self.btnCancelFeedUpdate.set_is_important(True)
2195 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2196 else:
2197 self.hboxUpdateFeeds.hide()
2198 self.btnUpdateFeeds.show()
2199 title = _('No new episodes')
2200 message = _('No new episodes to download.\nPlease check for new episodes later.')
2201 self.show_message(message, title)
2203 def on_itemDownloadAllNew_activate(self, widget, *args):
2204 self.download_all_new()
2206 def download_all_new(self, channels=None):
2207 if channels is None:
2208 channels = self.channels
2209 episodes = []
2210 for channel in channels:
2211 for episode in channel.get_new_episodes():
2212 episodes.append(episode)
2213 self.new_episodes_show(episodes)
2215 def get_all_episodes(self, exclude_nonsignificant=True ):
2216 """'exclude_nonsignificant' will exclude non-downloaded episodes
2217 and all episodes from channels that are set to skip when syncing"""
2218 episode_list = []
2219 for channel in self.channels:
2220 if not channel.sync_to_devices and exclude_nonsignificant:
2221 log('Skipping channel: %s', channel.title, sender=self)
2222 continue
2223 for episode in channel.get_all_episodes():
2224 if episode.was_downloaded(and_exists=True) or not exclude_nonsignificant:
2225 episode_list.append(episode)
2226 return episode_list
2228 def ipod_delete_played(self, device):
2229 all_episodes = self.get_all_episodes( exclude_nonsignificant=False )
2230 episodes_on_device = device.get_all_tracks()
2231 for local_episode in all_episodes:
2232 device_episode = device.episode_on_device(local_episode)
2233 if device_episode and ( local_episode.is_played and not local_episode.is_locked
2234 or local_episode.state == db.STATE_DELETED ):
2235 log("mp3_player_delete_played: removing %s" % device_episode.title)
2236 device.remove_track(device_episode)
2238 def on_sync_to_ipod_activate(self, widget, episodes=None):
2239 # make sure gpod is available before even trying to sync
2240 if gl.config.device_type == 'ipod' and not sync.gpod_available:
2241 title = _('Cannot Sync To iPod')
2242 message = _('Please install the libgpod python bindings (python-gpod) and restart gPodder to continue.')
2243 self.notification( message, title )
2244 return
2245 elif gl.config.device_type == 'mtp' and not sync.pymtp_available:
2246 title = _('Cannot sync to MTP device')
2247 message = _('Please install the libmtp python bindings (python-pymtp) and restart gPodder to continue.')
2248 self.notification( message, title )
2249 return
2251 device = sync.open_device()
2252 device.register( 'post-done', self.sync_to_ipod_completed )
2254 if device is None:
2255 title = _('No device configured')
2256 message = _('To use the synchronization feature, please configure your device in the preferences dialog first.')
2257 self.notification(message, title)
2258 return
2260 if not device.open():
2261 title = _('Cannot open device')
2262 message = _('There has been an error opening your device.')
2263 self.notification(message, title)
2264 return
2266 if gl.config.ipod_purge_old_episodes:
2267 device.purge()
2269 sync_all_episodes = not bool(episodes)
2271 if episodes is None:
2272 episodes = self.get_all_episodes()
2274 # make sure we have enough space on the device
2275 total_size = 0
2276 free_space = device.get_free_space()
2277 for episode in episodes:
2278 if not device.episode_on_device(episode) and not (sync_all_episodes and gl.config.only_sync_not_played and episode.is_played):
2279 filename = episode.local_filename(create=False)
2280 if filename is not None:
2281 total_size += util.calculate_size(str(filename))
2283 if total_size > free_space:
2284 # can be negative because of the 10 MiB for reserved for the iTunesDB
2285 free_space = max( free_space, 0 )
2286 log('(gpodder.sync) Not enough free space. Transfer size = %d, Free space = %d', total_size, free_space)
2287 title = _('Not enough space left on device.')
2288 message = _('%s remaining on device.\nPlease free up %s and try again.' % (
2289 util.format_filesize( free_space ), util.format_filesize( total_size - free_space )))
2290 self.notification(message, title)
2291 device.close()
2292 else:
2293 # start syncing!
2294 gPodderSync(device=device, gPodder=self)
2295 Thread(target=self.sync_to_ipod_thread, args=(widget, device, sync_all_episodes, episodes)).start()
2296 if self.tray_icon:
2297 self.tray_icon.set_synchronisation_device(device)
2299 def sync_to_ipod_completed(self, device, successful_sync):
2300 device.unregister( 'post-done', self.sync_to_ipod_completed )
2302 if self.tray_icon:
2303 self.tray_icon.release_synchronisation_device()
2305 if not successful_sync:
2306 title = _('Error closing device')
2307 message = _('There has been an error closing your device.')
2308 self.notification(message, title)
2310 # update model for played state updates after sync
2311 util.idle_add(self.updateComboBox)
2313 def sync_to_ipod_thread(self, widget, device, sync_all_episodes, episodes=None):
2314 if sync_all_episodes:
2315 device.add_tracks(episodes)
2316 # 'only_sync_not_played' must be used or else all the played
2317 # tracks will be copied then immediately deleted
2318 if gl.config.mp3_player_delete_played and gl.config.only_sync_not_played:
2319 self.ipod_delete_played(device)
2320 else:
2321 device.add_tracks(episodes, force_played=True)
2322 device.close()
2324 def ipod_cleanup_callback(self, device, tracks):
2325 title = _('Delete podcasts from device?')
2326 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?')
2327 if len(tracks) > 0 and self.show_confirmation(message, title):
2328 gPodderSync(device=device, gPodder=self)
2329 Thread(target=self.ipod_cleanup_thread, args=[device, tracks]).start()
2331 def ipod_cleanup_thread(self, device, tracks):
2332 device.remove_tracks(tracks)
2334 if not device.close():
2335 title = _('Error closing device')
2336 message = _('There has been an error closing your device.')
2337 gobject.idle_add(self.show_message, message, title)
2339 def on_cleanup_ipod_activate(self, widget, *args):
2340 columns = (
2341 ('title', None, None, _('Episode')),
2342 ('podcast', None, None, _('Podcast')),
2343 ('filesize', None, None, _('Size')),
2344 ('modified', None, None, _('Copied')),
2345 ('playcount', None, None, _('Play count')),
2346 ('released', None, None, _('Released')),
2349 device = sync.open_device()
2351 if device is None:
2352 title = _('No device configured')
2353 message = _('To use the synchronization feature, please configure your device in the preferences dialog first.')
2354 self.show_message(message, title)
2355 return
2357 if not device.open():
2358 title = _('Cannot open device')
2359 message = _('There has been an error opening your device.')
2360 self.show_message(message, title)
2361 return
2363 tracks = device.get_all_tracks()
2364 if len(tracks) > 0:
2365 remove_tracks_callback = lambda tracks: self.ipod_cleanup_callback(device, tracks)
2366 wanted_columns = []
2367 for key, sort_name, sort_type, caption in columns:
2368 want_this_column = False
2369 for track in tracks:
2370 if getattr(track, key) is not None:
2371 want_this_column = True
2372 break
2374 if want_this_column:
2375 wanted_columns.append((key, sort_name, sort_type, caption))
2376 title = _('Remove podcasts from device')
2377 instructions = _('Select the podcast episodes you want to remove from your device.')
2378 gPodderEpisodeSelector(title=title, instructions=instructions, episodes=tracks, columns=wanted_columns, \
2379 stock_ok_button=gtk.STOCK_DELETE, callback=remove_tracks_callback, tooltip_attribute=None)
2380 else:
2381 title = _('No files on device')
2382 message = _('The devices contains no files to be removed.')
2383 self.show_message(message, title)
2384 device.close()
2386 def on_manage_device_playlist(self, widget):
2387 # make sure gpod is available before even trying to sync
2388 if gl.config.device_type == 'ipod' and not sync.gpod_available:
2389 title = _('Cannot manage iPod playlist')
2390 message = _('This feature is not available for iPods.')
2391 self.notification( message, title )
2392 return
2393 elif gl.config.device_type == 'mtp' and not sync.pymtp_available:
2394 title = _('Cannot manage MTP device playlist')
2395 message = _('This feature is not available for MTP devices.')
2396 self.notification( message, title )
2397 return
2399 device = sync.open_device()
2401 if device is None:
2402 title = _('No device configured')
2403 message = _('To use the playlist feature, please configure your Filesystem based MP3-Player in the preferences dialog first.')
2404 self.notification(message, title)
2405 return
2407 if not device.open():
2408 title = _('Cannot open device')
2409 message = _('There has been an error opening your device.')
2410 self.notification(message, title)
2411 return
2413 gPodderPlaylist(device=device, gPodder=self)
2414 device.close()
2416 def show_hide_tray_icon(self):
2417 if gl.config.display_tray_icon and have_trayicon and self.tray_icon is None:
2418 self.tray_icon = trayicon.GPodderStatusIcon(self, scalable_dir)
2419 elif not gl.config.display_tray_icon and self.tray_icon is not None:
2420 self.tray_icon.set_visible(False)
2421 del self.tray_icon
2422 self.tray_icon = None
2424 if gl.config.minimize_to_tray and self.tray_icon:
2425 self.tray_icon.set_visible(self.minimized)
2426 elif self.tray_icon:
2427 self.tray_icon.set_visible(True)
2429 def on_itemShowToolbar_activate(self, widget):
2430 gl.config.show_toolbar = self.itemShowToolbar.get_active()
2432 def on_itemShowDescription_activate(self, widget):
2433 gl.config.episode_list_descriptions = self.itemShowDescription.get_active()
2435 def update_item_device( self):
2436 if gl.config.device_type != 'none':
2437 self.itemDevice.show_all()
2438 (label,) = self.itemDevice.get_children()
2439 label.set_text(gl.get_device_name())
2440 else:
2441 self.itemDevice.hide_all()
2443 def properties_closed( self):
2444 self.show_hide_tray_icon()
2445 self.update_item_device()
2446 self.updateComboBox()
2448 def on_itemPreferences_activate(self, widget, *args):
2449 if gpodder.interface == gpodder.GUI:
2450 gPodderProperties(callback_finished=self.properties_closed, user_apps_reader=self.user_apps_reader)
2451 else:
2452 gPodderMaemoPreferences()
2454 def on_itemDependencies_activate(self, widget):
2455 gPodderDependencyManager()
2457 def on_add_new_google_search(self, widget, *args):
2458 def add_google_video_search(query):
2459 self.add_new_channel('http://video.google.com/videofeed?type=search&q='+urllib.quote(query)+'&so=1&num=250&output=rss')
2461 gPodderAddPodcastDialog(url_callback=add_google_video_search, custom_title=_('Add Google Video search'), custom_label=_('Search for:'))
2463 def on_upgrade_from_videocenter(self, widget):
2464 from gpodder import nokiavideocenter
2465 vc = nokiavideocenter.UpgradeFromVideocenter()
2466 if vc.db2opml():
2467 gPodderOpmlLister(custom_title=_('Import podcasts from Video Center'), hide_url_entry=True).get_channels_from_url(vc.opmlfile, lambda url: self.add_new_channel(url,False,block=True), lambda: self.on_itemDownloadAllNew_activate(self.gPodder))
2468 else:
2469 self.show_message(_('Have you installed Video Center on your tablet?'), _('Cannot find Video Center subscriptions'))
2471 def require_my_gpodder_authentication(self):
2472 if not gl.config.my_gpodder_username or not gl.config.my_gpodder_password:
2473 success, authentication = self.UsernamePasswordDialog(_('Login to my.gpodder.org'), _('Please enter your e-mail address and your password.'), username=gl.config.my_gpodder_username, password=gl.config.my_gpodder_password, username_prompt=_('E-Mail Address'), register_callback=lambda: util.open_website('http://my.gpodder.org/register'))
2474 if success and authentication[0] and authentication[1]:
2475 gl.config.my_gpodder_username, gl.config.my_gpodder_password = authentication
2476 return True
2477 else:
2478 return False
2480 return True
2482 def my_gpodder_offer_autoupload(self):
2483 if not gl.config.my_gpodder_autoupload:
2484 if self.show_confirmation(_('gPodder can automatically upload your subscription list to my.gpodder.org when you close it. Do you want to enable this feature?'), _('Upload subscriptions on quit')):
2485 gl.config.my_gpodder_autoupload = True
2487 def on_download_from_mygpo(self, widget):
2488 if self.require_my_gpodder_authentication():
2489 client = my.MygPodderClient(gl.config.my_gpodder_username, gl.config.my_gpodder_password)
2490 opml_data = client.download_subscriptions()
2491 if len(opml_data) > 0:
2492 fp = open(gl.channel_opml_file, 'w')
2493 fp.write(opml_data)
2494 fp.close()
2495 (added, skipped) = (0, 0)
2496 i = opml.Importer(gl.channel_opml_file)
2497 for item in i.items:
2498 url = item['url']
2499 if url not in (c.url for c in self.channels):
2500 self.add_new_channel(url, ask_download_new=False, block=True)
2501 added += 1
2502 else:
2503 log('Already added: %s', url, sender=self)
2504 skipped += 1
2505 self.updateComboBox()
2506 if added > 0:
2507 self.show_message(_('Added %d new subscriptions and skipped %d existing ones.') % (added, skipped), _('Result of subscription download'))
2508 elif widget is not None:
2509 self.show_message(_('Your local subscription list is up to date.'), _('Result of subscription download'))
2510 self.my_gpodder_offer_autoupload()
2511 else:
2512 gl.config.my_gpodder_password = ''
2513 self.on_download_from_mygpo(widget)
2514 else:
2515 self.show_message(_('Please set up your username and password first.'), _('Username and password needed'))
2517 def on_upload_to_mygpo(self, widget):
2518 if self.require_my_gpodder_authentication():
2519 client = my.MygPodderClient(gl.config.my_gpodder_username, gl.config.my_gpodder_password)
2520 save_channels(self.channels)
2521 success, messages = client.upload_subscriptions(gl.channel_opml_file)
2522 if widget is not None:
2523 self.show_message('\n'.join(messages), _('Results of upload'))
2524 if not success:
2525 gl.config.my_gpodder_password = ''
2526 self.on_upload_to_mygpo(widget)
2527 else:
2528 self.my_gpodder_offer_autoupload()
2529 elif not success:
2530 log('Upload to my.gpodder.org failed, but widget is None!', sender=self)
2531 elif widget is not None:
2532 self.show_message(_('Please set up your username and password first.'), _('Username and password needed'))
2534 def on_itemAddChannel_activate(self, widget, *args):
2535 gPodderAddPodcastDialog(url_callback=self.add_new_channel)
2537 def on_itemEditChannel_activate(self, widget, *args):
2538 if self.active_channel is None:
2539 title = _('No podcast selected')
2540 message = _('Please select a podcast in the podcasts list to edit.')
2541 self.show_message( message, title)
2542 return
2544 gPodderChannel(channel=self.active_channel, callback_closed=lambda: self.updateComboBox(only_selected_channel=True), callback_change_url=self.change_channel_url)
2546 def change_channel_url(self, channel, new_url):
2547 old_url = channel.url
2548 log('=> change channel url from %s to %s', old_url, new_url)
2549 channel.url = new_url
2550 # remove etag and last_modified to force an update
2551 channel.etag = ''
2552 channel.last_modified = ''
2553 (success, error) = channel.update()
2554 if not success:
2555 self.show_message(_('The specified URL is invalid. The old URL has been used instead.'), _('Invalid URL'))
2556 channel.url = old_url
2558 # Remove old episodes which haven't been downloaded.
2559 db.delete_empty_episodes(channel.id);
2561 # Update the OPML file.
2562 save_channels(self.channels)
2564 # update feed cache and select the podcast with the new URL afterwards
2565 self.update_feed_cache(force_update=False, select_url_afterwards=new_url)
2567 def on_itemRemoveChannel_activate(self, widget, *args):
2568 try:
2569 if gpodder.interface == gpodder.GUI:
2570 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2571 dialog.add_button(gtk.STOCK_NO, gtk.RESPONSE_NO)
2572 dialog.add_button(gtk.STOCK_YES, gtk.RESPONSE_YES)
2574 title = _('Remove podcast and episodes?')
2575 message = _('Do you really want to remove <b>%s</b> and all downloaded episodes?') % saxutils.escape(self.active_channel.title)
2577 dialog.set_title(title)
2578 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2580 cb_ask = gtk.CheckButton(_('Do not delete my downloaded episodes'))
2581 dialog.vbox.pack_start(cb_ask)
2582 cb_ask.show_all()
2583 affirmative = gtk.RESPONSE_YES
2584 elif gpodder.interface == gpodder.MAEMO:
2585 cb_ask = gtk.CheckButton('') # dummy check button
2586 dialog = hildon.Note('confirmation', (self.gPodder, _('Do you really want to remove this podcast and all downloaded episodes?')))
2587 affirmative = gtk.RESPONSE_OK
2589 result = dialog.run()
2590 dialog.destroy()
2592 if result == affirmative:
2593 # delete downloaded episodes only if checkbox is unchecked
2594 if cb_ask.get_active() == False:
2595 self.active_channel.remove_downloaded()
2596 else:
2597 log('Not removing downloaded episodes', sender=self)
2599 # only delete partial files if we do not have any downloads in progress
2600 delete_partial = not services.download_status_manager.has_items()
2601 gl.clean_up_downloads(delete_partial)
2603 # cancel any active downloads from this channel
2604 if not delete_partial:
2605 for episode in self.active_channel.get_all_episodes():
2606 services.download_status_manager.cancel_by_url(episode.url)
2608 # get the URL of the podcast we want to select next
2609 position = self.channels.index(self.active_channel)
2610 if position == len(self.channels)-1:
2611 # this is the last podcast, so select the URL
2612 # of the item before this one (i.e. the "new last")
2613 select_url = self.channels[position-1].url
2614 else:
2615 # there is a podcast after the deleted one, so
2616 # we simply select the one that comes after it
2617 select_url = self.channels[position+1].url
2619 # Remove the channel
2620 self.active_channel.delete()
2621 self.channels.remove(self.active_channel)
2622 self.channel_list_changed = True
2623 save_channels(self.channels)
2625 # Re-load the channels and select the desired new channel
2626 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
2627 except:
2628 log('There has been an error removing the channel.', traceback=True, sender=self)
2629 self.update_podcasts_tab()
2631 def get_opml_filter(self):
2632 filter = gtk.FileFilter()
2633 filter.add_pattern('*.opml')
2634 filter.add_pattern('*.xml')
2635 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
2636 return filter
2638 def on_item_import_from_file_activate(self, widget, filename=None):
2639 if filename is None:
2640 if gpodder.interface == gpodder.GUI:
2641 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
2642 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2643 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2644 elif gpodder.interface == gpodder.MAEMO:
2645 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
2646 dlg.set_filter(self.get_opml_filter())
2647 response = dlg.run()
2648 filename = None
2649 if response == gtk.RESPONSE_OK:
2650 filename = dlg.get_filename()
2651 dlg.destroy()
2653 if filename is not None:
2654 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))
2656 def on_itemExportChannels_activate(self, widget, *args):
2657 if not self.channels:
2658 title = _('Nothing to export')
2659 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
2660 self.show_message( message, title)
2661 return
2663 if gpodder.interface == gpodder.GUI:
2664 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
2665 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2666 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
2667 elif gpodder.interface == gpodder.MAEMO:
2668 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
2669 dlg.set_filter(self.get_opml_filter())
2670 response = dlg.run()
2671 if response == gtk.RESPONSE_OK:
2672 filename = dlg.get_filename()
2673 dlg.destroy()
2674 exporter = opml.Exporter( filename)
2675 if exporter.write(self.channels):
2676 if len(self.channels) == 1:
2677 title = _('One subscription exported')
2678 else:
2679 title = _('%d subscriptions exported') % len(self.channels)
2680 self.show_message(_('Your podcast list has been successfully exported.'), title)
2681 else:
2682 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'))
2683 else:
2684 dlg.destroy()
2686 def on_itemImportChannels_activate(self, widget, *args):
2687 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))
2689 def on_homepage_activate(self, widget, *args):
2690 util.open_website(app_website)
2692 def on_wiki_activate(self, widget, *args):
2693 util.open_website('http://wiki.gpodder.org/')
2695 def on_bug_tracker_activate(self, widget, *args):
2696 util.open_website('http://bugs.gpodder.org/')
2698 def on_itemAbout_activate(self, widget, *args):
2699 dlg = gtk.AboutDialog()
2700 dlg.set_name(app_name.replace('p', 'P')) # gpodder->gPodder
2701 dlg.set_version( app_version)
2702 dlg.set_copyright( app_copyright)
2703 dlg.set_website( app_website)
2704 dlg.set_translator_credits( _('translator-credits'))
2705 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
2707 if gpodder.interface == gpodder.GUI:
2708 # For the "GUI" version, we add some more
2709 # items to the about dialog (credits and logo)
2710 dlg.set_authors(app_authors)
2711 try:
2712 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(scalable_dir))
2713 except:
2714 pass
2716 dlg.run()
2718 def on_wNotebook_switch_page(self, widget, *args):
2719 page_num = args[1]
2720 if gpodder.interface == gpodder.MAEMO:
2721 page = self.wNotebook.get_nth_page(page_num)
2722 tab_label = self.wNotebook.get_tab_label(page).get_text()
2723 if page_num == 0 and self.active_channel is not None:
2724 self.set_title(self.active_channel.title)
2725 else:
2726 self.set_title(tab_label)
2727 if page_num == 0:
2728 self.play_or_download()
2729 else:
2730 self.toolDownload.set_sensitive( False)
2731 self.toolPlay.set_sensitive( False)
2732 self.toolTransfer.set_sensitive( False)
2733 self.toolCancel.set_sensitive( services.download_status_manager.has_items())
2735 def on_treeChannels_row_activated(self, widget, *args):
2736 # double-click action of the podcast list
2737 pass
2739 def on_treeChannels_cursor_changed(self, widget, *args):
2740 ( model, iter ) = self.treeChannels.get_selection().get_selected()
2742 if model is not None and iter is not None:
2743 old_active_channel = self.active_channel
2744 (id,) = model.get_path(iter)
2745 self.active_channel = self.channels[id]
2747 if self.active_channel == old_active_channel:
2748 return
2750 if gpodder.interface == gpodder.MAEMO:
2751 self.set_title(self.active_channel.title)
2752 self.itemEditChannel.show_all()
2753 self.itemRemoveChannel.show_all()
2754 self.channel_toggle_lock.show_all()
2755 if self.active_channel.channel_is_locked:
2756 self.change_menu_item(self.channel_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Allow deletion of all episodes'))
2757 else:
2758 self.change_menu_item(self.channel_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Prohibit deletion of all episodes'))
2760 else:
2761 self.active_channel = None
2762 self.itemEditChannel.hide_all()
2763 self.itemRemoveChannel.hide_all()
2764 self.channel_toggle_lock.hide_all()
2766 self.updateTreeView()
2768 def on_entryAddChannel_changed(self, widget, *args):
2769 active = self.entryAddChannel.get_text() not in ('', self.ENTER_URL_TEXT)
2770 self.btnAddChannel.set_sensitive( active)
2772 def on_btnAddChannel_clicked(self, widget, *args):
2773 url = self.entryAddChannel.get_text()
2774 self.entryAddChannel.set_text('')
2775 self.add_new_channel( url)
2777 def on_btnEditChannel_clicked(self, widget, *args):
2778 self.on_itemEditChannel_activate( widget, args)
2780 def on_treeAvailable_row_activated(self, widget, path=None, view_column=None):
2782 What this function does depends on from which widget it is called.
2783 It gets the selected episodes of the current podcast and runs one
2784 of the following actions on them:
2786 * Transfer (to MP3 player, iPod, etc..)
2787 * Playback/open files
2788 * Show the episode info dialog
2789 * Download episodes
2791 try:
2792 selection = self.treeAvailable.get_selection()
2793 (model, paths) = selection.get_selected_rows()
2795 if len(paths) == 0:
2796 log('Nothing selected', sender=self)
2797 return
2799 wname = widget.get_name()
2800 do_transfer = (wname in ('itemTransferSelected', 'toolTransfer'))
2801 do_playback = (wname in ('itemPlaySelected', 'itemOpenSelected', 'toolPlay'))
2802 do_epdialog = (wname in ('treeAvailable', 'item_episode_details'))
2804 episodes = []
2805 for path in paths:
2806 it = model.get_iter(path)
2807 url = model.get_value(it, 0)
2808 episode = self.active_channel.find_episode(url)
2809 episodes.append(episode)
2811 if len(episodes) == 0:
2812 log('No episodes selected', sender=self)
2814 if do_transfer:
2815 self.on_sync_to_ipod_activate(widget, episodes)
2816 elif do_playback:
2817 for episode in episodes:
2818 if episode.was_downloaded(and_exists=True):
2819 self.playback_episode(episode)
2820 elif gl.config.enable_streaming:
2821 self.playback_episode(episode, stream=True)
2822 elif do_epdialog:
2823 play_callback = lambda: self.playback_episode(episode)
2824 def download_callback():
2825 self.download_episode_list([episode])
2826 self.play_or_download()
2827 if self.gpodder_episode_window is None:
2828 log('First-time use of episode window --- creating', sender=self)
2829 self.gpodder_episode_window = gPodderEpisode()
2830 self.gpodder_episode_window.show(episode=episode, download_callback=download_callback, play_callback=play_callback)
2831 else:
2832 self.download_episode_list(episodes)
2833 self.play_or_download()
2834 except:
2835 log('Error in on_treeAvailable_row_activated', traceback=True, sender=self)
2837 def on_treeAvailable_button_release_event(self, widget, *args):
2838 self.play_or_download()
2840 def auto_update_procedure(self, first_run=False):
2841 log('auto_update_procedure() got called', sender=self)
2842 if not first_run and gl.config.auto_update_feeds and self.minimized:
2843 self.update_feed_cache(force_update=True)
2845 next_update = 60*1000*gl.config.auto_update_frequency
2846 gobject.timeout_add(next_update, self.auto_update_procedure)
2848 def on_treeDownloads_row_activated(self, widget, *args):
2849 cancel_urls = []
2851 if self.wNotebook.get_current_page() > 0:
2852 # Use the download list treeview + model
2853 ( tree, column ) = ( self.treeDownloads, 3 )
2854 else:
2855 # Use the available podcasts treeview + model
2856 ( tree, column ) = ( self.treeAvailable, 0 )
2858 selection = tree.get_selection()
2859 (model, paths) = selection.get_selected_rows()
2860 for path in paths:
2861 url = model.get_value( model.get_iter( path), column)
2862 cancel_urls.append( url)
2864 if len( cancel_urls) == 0:
2865 log('Nothing selected.', sender = self)
2866 return
2868 if len( cancel_urls) == 1:
2869 title = _('Cancel download?')
2870 message = _("Cancelling this download will remove the partially downloaded file and stop the download.")
2871 else:
2872 title = _('Cancel downloads?')
2873 message = _("Cancelling the download will stop the %d selected downloads and remove partially downloaded files.") % selection.count_selected_rows()
2875 if self.show_confirmation( message, title):
2876 services.download_status_manager.start_batch_mode()
2877 for url in cancel_urls:
2878 services.download_status_manager.cancel_by_url( url)
2879 services.download_status_manager.end_batch_mode()
2880 self.play_or_download()
2882 def on_btnCancelDownloadStatus_clicked(self, widget, *args):
2883 self.on_treeDownloads_row_activated( widget, None)
2885 def on_btnCancelAll_clicked(self, widget, *args):
2886 self.treeDownloads.get_selection().select_all()
2887 self.on_treeDownloads_row_activated( self.toolCancel, None)
2888 self.treeDownloads.get_selection().unselect_all()
2890 def on_btnDownloadedDelete_clicked(self, widget, *args):
2891 if self.active_channel is None:
2892 return
2894 channel_url = self.active_channel.url
2895 selection = self.treeAvailable.get_selection()
2896 ( model, paths ) = selection.get_selected_rows()
2898 if selection.count_selected_rows() == 0:
2899 log( 'Nothing selected - will not remove any downloaded episode.')
2900 return
2902 if selection.count_selected_rows() == 1:
2903 episode_title = saxutils.escape(model.get_value(model.get_iter(paths[0]), 1))
2905 episode = db.load_episode(model.get_value(model.get_iter(paths[0]), 0))
2906 if episode['is_locked']:
2907 title = _('%s is locked') % episode_title
2908 message = _('You cannot delete this locked episode. You must unlock it before you can delete it.')
2909 self.notification(message, title)
2910 return
2912 title = _('Remove %s?') % episode_title
2913 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.")
2914 else:
2915 title = _('Remove %d episodes?') % selection.count_selected_rows()
2916 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.')
2918 locked_count = 0
2919 for path in paths:
2920 episode = db.load_episode(model.get_value(model.get_iter(path), 0))
2921 if episode['is_locked']:
2922 locked_count += 1
2924 if selection.count_selected_rows() == locked_count:
2925 title = _('Episodes are locked')
2926 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2927 self.notification(message, title)
2928 return
2929 elif locked_count > 0:
2930 title = _('Remove %d out of %d episodes?') % (selection.count_selected_rows() - locked_count, selection.count_selected_rows())
2931 message = _('The selection contains locked episodes that will not be deleted. If you want to listen to the deleted episodes, you will have to re-download them.')
2933 # if user confirms deletion, let's remove some stuff ;)
2934 if self.show_confirmation( message, title):
2935 try:
2936 # iterate over the selection, see also on_treeDownloads_row_activated
2937 for path in paths:
2938 url = model.get_value( model.get_iter( path), 0)
2939 self.active_channel.delete_episode_by_url( url)
2941 # now, clear local db cache so we can re-read it
2942 self.updateComboBox()
2943 except:
2944 log( 'Error while deleting (some) downloads.', traceback=True, sender=self)
2946 # only delete partial files if we do not have any downloads in progress
2947 delete_partial = not services.download_status_manager.has_items()
2948 gl.clean_up_downloads(delete_partial)
2949 self.update_selected_episode_list_icons()
2950 self.play_or_download()
2952 def on_key_press(self, widget, event):
2953 # Allow tab switching with Ctrl + PgUp/PgDown
2954 if event.state & gtk.gdk.CONTROL_MASK:
2955 if event.keyval == gtk.keysyms.Page_Up:
2956 self.wNotebook.prev_page()
2957 return True
2958 elif event.keyval == gtk.keysyms.Page_Down:
2959 self.wNotebook.next_page()
2960 return True
2962 # After this code we only handle Maemo hardware keys,
2963 # so if we are not a Maemo app, we don't do anything
2964 if gpodder.interface != gpodder.MAEMO:
2965 return False
2967 if event.keyval == gtk.keysyms.F6:
2968 if self.fullscreen:
2969 self.window.unfullscreen()
2970 else:
2971 self.window.fullscreen()
2972 if event.keyval == gtk.keysyms.Escape:
2973 new_visibility = not self.vboxChannelNavigator.get_property('visible')
2974 self.vboxChannelNavigator.set_property('visible', new_visibility)
2975 self.column_size.set_visible(not new_visibility)
2976 self.column_released.set_visible(not new_visibility)
2978 diff = 0
2979 if event.keyval == gtk.keysyms.F7: #plus
2980 diff = 1
2981 elif event.keyval == gtk.keysyms.F8: #minus
2982 diff = -1
2984 if diff != 0 and not self.currently_updating:
2985 selection = self.treeChannels.get_selection()
2986 (model, iter) = selection.get_selected()
2987 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
2988 selection.select_path(new_path)
2989 self.treeChannels.set_cursor(new_path)
2990 return True
2992 return False
2994 def window_state_event(self, widget, event):
2995 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
2996 self.fullscreen = True
2997 else:
2998 self.fullscreen = False
3000 old_minimized = self.minimized
3002 self.minimized = bool(event.new_window_state & gtk.gdk.WINDOW_STATE_ICONIFIED)
3003 if gpodder.interface == gpodder.MAEMO:
3004 self.minimized = bool(event.new_window_state & gtk.gdk.WINDOW_STATE_WITHDRAWN)
3006 if old_minimized != self.minimized and self.tray_icon:
3007 self.gPodder.set_skip_taskbar_hint(self.minimized)
3008 elif not self.tray_icon:
3009 self.gPodder.set_skip_taskbar_hint(False)
3011 if gl.config.minimize_to_tray and self.tray_icon:
3012 self.tray_icon.set_visible(self.minimized)
3014 def uniconify_main_window(self):
3015 if self.minimized:
3016 self.gPodder.present()
3018 def iconify_main_window(self):
3019 if not self.minimized:
3020 self.gPodder.iconify()
3022 def update_podcasts_tab(self):
3023 if len(self.channels):
3024 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
3025 else:
3026 self.label2.set_text(_('Podcasts'))
3028 @dbus.service.method(gpodder.dbus_interface)
3029 def show_gui_window(self):
3030 self.gPodder.present()
3032 class gPodderChannel(GladeWidget):
3033 finger_friendly_widgets = ['btn_website', 'btnOK', 'channel_description', 'label19', 'label37', 'label31']
3035 def new(self):
3036 global WEB_BROWSER_ICON
3037 self.changed = False
3038 self.image3167.set_property('icon-name', WEB_BROWSER_ICON)
3039 self.gPodderChannel.set_title( self.channel.title)
3040 self.entryTitle.set_text( self.channel.title)
3041 self.entryURL.set_text( self.channel.url)
3043 self.LabelDownloadTo.set_text( self.channel.save_dir)
3044 self.LabelWebsite.set_text( self.channel.link)
3046 self.cbNoSync.set_active( not self.channel.sync_to_devices)
3047 self.musicPlaylist.set_text(self.channel.device_playlist_name)
3048 if self.channel.username:
3049 self.FeedUsername.set_text( self.channel.username)
3050 if self.channel.password:
3051 self.FeedPassword.set_text( self.channel.password)
3053 services.cover_downloader.register('cover-available', self.cover_download_finished)
3054 services.cover_downloader.request_cover(self.channel)
3056 # Hide the website button if we don't have a valid URL
3057 if not self.channel.link:
3058 self.btn_website.hide_all()
3060 b = gtk.TextBuffer()
3061 b.set_text( self.channel.description)
3062 self.channel_description.set_buffer( b)
3064 #Add Drag and Drop Support
3065 flags = gtk.DEST_DEFAULT_ALL
3066 targets = [ ('text/uri-list', 0, 2), ('text/plain', 0, 4) ]
3067 actions = gtk.gdk.ACTION_DEFAULT | gtk.gdk.ACTION_COPY
3068 self.vboxCoverEditor.drag_dest_set( flags, targets, actions)
3069 self.vboxCoverEditor.connect( 'drag_data_received', self.drag_data_received)
3071 def on_btn_website_clicked(self, widget):
3072 util.open_website(self.channel.link)
3074 def on_btnDownloadCover_clicked(self, widget):
3075 if gpodder.interface == gpodder.GUI:
3076 dlg = gtk.FileChooserDialog(title=_('Select new podcast cover artwork'), parent=self.gPodderChannel, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3077 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3078 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3079 elif gpodder.interface == gpodder.MAEMO:
3080 dlg = hildon.FileChooserDialog(self.gPodderChannel, gtk.FILE_CHOOSER_ACTION_OPEN)
3082 if dlg.run() == gtk.RESPONSE_OK:
3083 url = dlg.get_uri()
3084 services.cover_downloader.replace_cover(self.channel, url)
3086 dlg.destroy()
3088 def on_btnClearCover_clicked(self, widget):
3089 services.cover_downloader.replace_cover(self.channel)
3091 def cover_download_finished(self, channel_url, pixbuf):
3092 if pixbuf is not None:
3093 self.imgCover.set_from_pixbuf(pixbuf)
3094 self.gPodderChannel.show()
3096 def drag_data_received( self, widget, content, x, y, sel, ttype, time):
3097 files = sel.data.strip().split('\n')
3098 if len(files) != 1:
3099 self.show_message( _('You can only drop a single image or URL here.'), _('Drag and drop'))
3100 return
3102 file = files[0]
3104 if file.startswith('file://') or file.startswith('http://'):
3105 services.cover_downloader.replace_cover(self.channel, file)
3106 return
3108 self.show_message( _('You can only drop local files and http:// URLs here.'), _('Drag and drop'))
3110 def on_gPodderChannel_destroy(self, widget, *args):
3111 services.cover_downloader.unregister('cover-available', self.cover_download_finished)
3113 def on_btnOK_clicked(self, widget, *args):
3114 entered_url = self.entryURL.get_text()
3115 channel_url = self.channel.url
3117 if entered_url != channel_url:
3118 if self.show_confirmation(_('Do you really want to move this podcast to <b>%s</b>?') % (saxutils.escape(entered_url),), _('Really change URL?')):
3119 if hasattr(self, 'callback_change_url'):
3120 self.gPodderChannel.hide_all()
3121 self.callback_change_url(self.channel, entered_url)
3123 self.channel.sync_to_devices = not self.cbNoSync.get_active()
3124 self.channel.device_playlist_name = self.musicPlaylist.get_text()
3125 self.channel.set_custom_title( self.entryTitle.get_text())
3126 self.channel.username = self.FeedUsername.get_text().strip()
3127 self.channel.password = self.FeedPassword.get_text()
3128 self.channel.save()
3130 self.gPodderChannel.destroy()
3131 self.callback_closed()
3133 class gPodderAddPodcastDialog(GladeWidget):
3134 finger_friendly_widgets = ['btn_close', 'btn_add']
3136 def new(self):
3137 if not hasattr(self, 'url_callback'):
3138 log('No url callback set', sender=self)
3139 self.url_callback = None
3140 if hasattr(self, 'custom_label'):
3141 self.label_add.set_text(self.custom_label)
3142 if hasattr(self, 'custom_title'):
3143 self.gPodderAddPodcastDialog.set_title(self.custom_title)
3144 if gpodder.interface == gpodder.MAEMO:
3145 self.entry_url.set_text('http://')
3147 def on_btn_close_clicked(self, widget):
3148 self.gPodderAddPodcastDialog.destroy()
3150 def on_entry_url_changed(self, widget):
3151 self.btn_add.set_sensitive(self.entry_url.get_text().strip() != '')
3153 def on_btn_add_clicked(self, widget):
3154 url = self.entry_url.get_text()
3155 self.on_btn_close_clicked(widget)
3156 if self.url_callback is not None:
3157 self.url_callback(url)
3160 class gPodderMaemoPreferences(GladeWidget):
3161 finger_friendly_widgets = ['btn_close', 'label128', 'label129', 'btn_advanced']
3163 def new(self):
3164 gl.config.connect_gtk_togglebutton('update_on_startup', self.update_on_startup)
3165 gl.config.connect_gtk_togglebutton('display_tray_icon', self.show_tray_icon)
3166 gl.config.connect_gtk_togglebutton('enable_notifications', self.show_notifications)
3167 gl.config.connect_gtk_togglebutton('on_quit_ask', self.on_quit_ask)
3169 self.restart_required = False
3170 self.show_tray_icon.connect('clicked', self.on_restart_required)
3171 self.show_notifications.connect('clicked', self.on_restart_required)
3173 def on_restart_required(self, widget):
3174 self.restart_required = True
3176 def on_btn_advanced_clicked(self, widget):
3177 self.gPodderMaemoPreferences.destroy()
3178 gPodderConfigEditor()
3180 def on_btn_close_clicked(self, widget):
3181 self.gPodderMaemoPreferences.destroy()
3182 if self.restart_required:
3183 self.show_message(_('Please restart gPodder for the changes to take effect.'))
3186 class gPodderProperties(GladeWidget):
3187 def new(self):
3188 if not hasattr( self, 'callback_finished'):
3189 self.callback_finished = None
3191 if gpodder.interface == gpodder.MAEMO:
3192 self.table5.hide_all() # player
3193 self.gPodderProperties.fullscreen()
3195 gl.config.connect_gtk_editable( 'http_proxy', self.httpProxy)
3196 gl.config.connect_gtk_editable( 'ftp_proxy', self.ftpProxy)
3197 gl.config.connect_gtk_editable( 'player', self.openApp)
3198 gl.config.connect_gtk_editable('videoplayer', self.openVideoApp)
3199 gl.config.connect_gtk_editable( 'custom_sync_name', self.entryCustomSyncName)
3200 gl.config.connect_gtk_togglebutton( 'custom_sync_name_enabled', self.cbCustomSyncName)
3201 gl.config.connect_gtk_togglebutton( 'auto_download_when_minimized', self.downloadnew)
3202 gl.config.connect_gtk_togglebutton( 'update_on_startup', self.updateonstartup)
3203 gl.config.connect_gtk_togglebutton( 'only_sync_not_played', self.only_sync_not_played)
3204 gl.config.connect_gtk_togglebutton( 'fssync_channel_subfolders', self.cbChannelSubfolder)
3205 gl.config.connect_gtk_togglebutton( 'on_sync_mark_played', self.on_sync_mark_played)
3206 gl.config.connect_gtk_togglebutton( 'on_sync_delete', self.on_sync_delete)
3207 gl.config.connect_gtk_togglebutton( 'proxy_use_environment', self.cbEnvironmentVariables)
3208 gl.config.connect_gtk_spinbutton('episode_old_age', self.episode_old_age)
3209 gl.config.connect_gtk_togglebutton('auto_remove_old_episodes', self.auto_remove_old_episodes)
3210 gl.config.connect_gtk_togglebutton('auto_update_feeds', self.auto_update_feeds)
3211 gl.config.connect_gtk_spinbutton('auto_update_frequency', self.auto_update_frequency)
3212 gl.config.connect_gtk_togglebutton('display_tray_icon', self.display_tray_icon)
3213 gl.config.connect_gtk_togglebutton('minimize_to_tray', self.minimize_to_tray)
3214 gl.config.connect_gtk_togglebutton('enable_notifications', self.enable_notifications)
3215 gl.config.connect_gtk_togglebutton('start_iconified', self.start_iconified)
3216 gl.config.connect_gtk_togglebutton('ipod_write_gtkpod_extended', self.ipod_write_gtkpod_extended)
3217 gl.config.connect_gtk_togglebutton('mp3_player_delete_played', self.delete_episodes_marked_played)
3218 gl.config.connect_gtk_togglebutton('disable_pre_sync_conversion', self.player_supports_ogg)
3220 self.enable_notifications.set_sensitive(self.display_tray_icon.get_active())
3221 self.minimize_to_tray.set_sensitive(self.display_tray_icon.get_active())
3223 self.entryCustomSyncName.set_sensitive( self.cbCustomSyncName.get_active())
3225 self.iPodMountpoint.set_label( gl.config.ipod_mount)
3226 self.filesystemMountpoint.set_label( gl.config.mp3_player_folder)
3227 self.chooserDownloadTo.set_current_folder(gl.downloaddir)
3229 self.on_sync_delete.set_sensitive(not self.delete_episodes_marked_played.get_active())
3230 self.on_sync_mark_played.set_sensitive(not self.delete_episodes_marked_played.get_active())
3232 if tagging_supported():
3233 gl.config.connect_gtk_togglebutton( 'update_tags', self.updatetags)
3234 else:
3235 self.updatetags.set_sensitive( False)
3236 new_label = '%s (%s)' % ( self.updatetags.get_label(), _('needs python-eyed3') )
3237 self.updatetags.set_label( new_label)
3239 # device type
3240 self.comboboxDeviceType.set_active( 0)
3241 if gl.config.device_type == 'ipod':
3242 self.comboboxDeviceType.set_active( 1)
3243 elif gl.config.device_type == 'filesystem':
3244 self.comboboxDeviceType.set_active( 2)
3245 elif gl.config.device_type == 'mtp':
3246 self.comboboxDeviceType.set_active( 3)
3248 # setup cell renderers
3249 cellrenderer = gtk.CellRendererPixbuf()
3250 self.comboAudioPlayerApp.pack_start(cellrenderer, False)
3251 self.comboAudioPlayerApp.add_attribute(cellrenderer, 'pixbuf', 2)
3252 cellrenderer = gtk.CellRendererText()
3253 self.comboAudioPlayerApp.pack_start(cellrenderer, True)
3254 self.comboAudioPlayerApp.add_attribute(cellrenderer, 'markup', 0)
3256 cellrenderer = gtk.CellRendererPixbuf()
3257 self.comboVideoPlayerApp.pack_start(cellrenderer, False)
3258 self.comboVideoPlayerApp.add_attribute(cellrenderer, 'pixbuf', 2)
3259 cellrenderer = gtk.CellRendererText()
3260 self.comboVideoPlayerApp.pack_start(cellrenderer, True)
3261 self.comboVideoPlayerApp.add_attribute(cellrenderer, 'markup', 0)
3263 if not hasattr(self, 'user_apps_reader'):
3264 self.user_apps_reader = UserAppsReader(['audio', 'video'])
3266 self.comboAudioPlayerApp.set_row_separator_func(self.is_row_separator)
3267 self.comboVideoPlayerApp.set_row_separator_func(self.is_row_separator)
3269 if gpodder.interface == gpodder.GUI:
3270 self.user_apps_reader.read()
3272 self.comboAudioPlayerApp.set_model(self.user_apps_reader.get_applications_as_model('audio'))
3273 index = self.find_active_audio_app()
3274 self.comboAudioPlayerApp.set_active(index)
3275 self.comboVideoPlayerApp.set_model(self.user_apps_reader.get_applications_as_model('video'))
3276 index = self.find_active_video_app()
3277 self.comboVideoPlayerApp.set_active(index)
3279 self.ipodIcon.set_from_icon_name( 'gnome-dev-ipod', gtk.ICON_SIZE_BUTTON)
3281 def is_row_separator(self, model, iter):
3282 return model.get_value(iter, 0) == ''
3284 def update_mountpoint( self, ipod):
3285 if ipod is None or ipod.mount_point is None:
3286 self.iPodMountpoint.set_label( '')
3287 else:
3288 self.iPodMountpoint.set_label( ipod.mount_point)
3290 def find_active_audio_app(self):
3291 index_custom = -1
3292 model = self.comboAudioPlayerApp.get_model()
3293 iter = model.get_iter_first()
3294 index = 0
3295 while iter is not None:
3296 command = model.get_value(iter, 1)
3297 if command == self.openApp.get_text():
3298 return index
3299 if index_custom < 0 and command == '':
3300 index_custom = index
3301 iter = model.iter_next(iter)
3302 index += 1
3303 # return index of custom command or first item
3304 return max(0, index_custom)
3306 def find_active_video_app( self):
3307 index_custom = -1
3308 model = self.comboVideoPlayerApp.get_model()
3309 iter = model.get_iter_first()
3310 index = 0
3311 while iter is not None:
3312 command = model.get_value(iter, 1)
3313 if command == self.openVideoApp.get_text():
3314 return index
3315 if index_custom < 0 and command == '':
3316 index_custom = index
3317 iter = model.iter_next(iter)
3318 index += 1
3319 # return index of custom command or first item
3320 return max(0, index_custom)
3322 def set_download_dir( self, new_download_dir, event = None):
3323 gl.downloaddir = self.chooserDownloadTo.get_filename()
3324 if gl.downloaddir != self.chooserDownloadTo.get_filename():
3325 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'))
3327 if event:
3328 event.set()
3330 def on_auto_update_feeds_toggled( self, widget, *args):
3331 self.auto_update_frequency.set_sensitive(widget.get_active())
3333 def on_display_tray_icon_toggled( self, widget, *args):
3334 self.enable_notifications.set_sensitive(widget.get_active())
3335 self.minimize_to_tray.set_sensitive(widget.get_active())
3337 def on_cbCustomSyncName_toggled( self, widget, *args):
3338 self.entryCustomSyncName.set_sensitive( widget.get_active())
3340 def on_only_sync_not_played_toggled( self, widget, *args):
3341 self.delete_episodes_marked_played.set_sensitive( widget.get_active())
3342 if not widget.get_active():
3343 self.delete_episodes_marked_played.set_active(False)
3345 def on_delete_episodes_marked_played_toggled( self, widget, *args):
3346 if widget.get_active() and self.only_sync_not_played.get_active():
3347 self.on_sync_leave.set_active(True)
3348 self.on_sync_delete.set_sensitive(not widget.get_active())
3349 self.on_sync_mark_played.set_sensitive(not widget.get_active())
3351 def on_btnCustomSyncNameHelp_clicked( self, widget):
3352 examples = [
3353 '<i>{episode.title}</i> -&gt; <b>Interview with RMS</b>',
3354 '<i>{episode.basename}</i> -&gt; <b>70908-interview-rms</b>',
3355 '<i>{episode.published}</i> -&gt; <b>20070908</b> (for 08.09.2007)',
3356 '<i>{episode.pubtime}</i> -&gt; <b>1344</b> (for 13:44)',
3357 '<i>{podcast.title}</i> -&gt; <b>The Interview Podcast</b>'
3360 info = [
3361 _('You can specify a custom format string for the file names on your MP3 player here.'),
3362 _('The format string will be used to generate a file name on your device. The file extension (e.g. ".mp3") will be added automatically.'),
3363 '\n'.join( [ ' %s' % s for s in examples ])
3366 self.show_message( '\n\n'.join( info), _('Custom format strings'))
3368 def on_gPodderProperties_destroy(self, widget, *args):
3369 self.on_btnOK_clicked( widget, *args)
3371 def on_btnConfigEditor_clicked(self, widget, *args):
3372 self.on_btnOK_clicked(widget, *args)
3373 gPodderConfigEditor()
3375 def on_comboAudioPlayerApp_changed(self, widget, *args):
3376 # find out which one
3377 iter = self.comboAudioPlayerApp.get_active_iter()
3378 model = self.comboAudioPlayerApp.get_model()
3379 command = model.get_value( iter, 1)
3380 if command == '':
3381 if self.openApp.get_text() == 'default':
3382 self.openApp.set_text('')
3383 self.openApp.set_sensitive( True)
3384 self.openApp.show()
3385 self.labelCustomCommand.show()
3386 else:
3387 self.openApp.set_text( command)
3388 self.openApp.set_sensitive( False)
3389 self.openApp.hide()
3390 self.labelCustomCommand.hide()
3392 def on_comboVideoPlayerApp_changed(self, widget, *args):
3393 # find out which one
3394 iter = self.comboVideoPlayerApp.get_active_iter()
3395 model = self.comboVideoPlayerApp.get_model()
3396 command = model.get_value(iter, 1)
3397 if command == '':
3398 if self.openVideoApp.get_text() == 'default':
3399 self.openVideoApp.set_text('')
3400 self.openVideoApp.set_sensitive(True)
3401 self.openVideoApp.show()
3402 self.labelCustomVideoCommand.show()
3403 else:
3404 self.openVideoApp.set_text(command)
3405 self.openVideoApp.set_sensitive(False)
3406 self.openVideoApp.hide()
3407 self.labelCustomVideoCommand.hide()
3409 def on_cbEnvironmentVariables_toggled(self, widget, *args):
3410 sens = not self.cbEnvironmentVariables.get_active()
3411 self.httpProxy.set_sensitive( sens)
3412 self.ftpProxy.set_sensitive( sens)
3414 def on_comboboxDeviceType_changed(self, widget, *args):
3415 active_item = self.comboboxDeviceType.get_active()
3417 # None
3418 sync_widgets = ( self.only_sync_not_played, self.labelSyncOptions,
3419 self.imageSyncOptions, self. separatorSyncOptions,
3420 self.on_sync_mark_played, self.on_sync_delete,
3421 self.on_sync_leave, self.label_after_sync,
3422 self.delete_episodes_marked_played,
3423 self.player_supports_ogg )
3425 for widget in sync_widgets:
3426 if active_item == 0:
3427 widget.hide_all()
3428 else:
3429 widget.show_all()
3431 # iPod
3432 ipod_widgets = (self.ipodLabel, self.btn_iPodMountpoint,
3433 self.ipod_write_gtkpod_extended)
3435 for widget in ipod_widgets:
3436 if active_item == 1:
3437 widget.show_all()
3438 else:
3439 widget.hide_all()
3441 # filesystem-based MP3 player
3442 fs_widgets = ( self.filesystemLabel, self.btn_filesystemMountpoint,
3443 self.cbChannelSubfolder, self.cbCustomSyncName,
3444 self.entryCustomSyncName, self.btnCustomSyncNameHelp,
3445 self.player_supports_ogg )
3447 for widget in fs_widgets:
3448 if active_item == 2:
3449 widget.show_all()
3450 else:
3451 widget.hide_all()
3453 def on_btn_iPodMountpoint_clicked(self, widget, *args):
3454 fs = gtk.FileChooserDialog( title = _('Select iPod mountpoint'), action = gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
3455 fs.add_button( gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3456 fs.add_button( gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3457 fs.set_current_folder(self.iPodMountpoint.get_label())
3458 if fs.run() == gtk.RESPONSE_OK:
3459 self.iPodMountpoint.set_label( fs.get_filename())
3460 fs.destroy()
3462 def on_btn_FilesystemMountpoint_clicked(self, widget, *args):
3463 fs = gtk.FileChooserDialog( title = _('Select folder for MP3 player'), action = gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
3464 fs.add_button( gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3465 fs.add_button( gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3466 fs.set_current_folder(self.filesystemMountpoint.get_label())
3467 if fs.run() == gtk.RESPONSE_OK:
3468 self.filesystemMountpoint.set_label( fs.get_filename())
3469 fs.destroy()
3471 def on_btnOK_clicked(self, widget, *args):
3472 gl.config.ipod_mount = self.iPodMountpoint.get_label()
3473 gl.config.mp3_player_folder = self.filesystemMountpoint.get_label()
3475 if gl.downloaddir != self.chooserDownloadTo.get_filename():
3476 new_download_dir = self.chooserDownloadTo.get_filename()
3477 download_dir_size = util.calculate_size( gl.downloaddir)
3478 download_dir_size_string = gl.format_filesize( download_dir_size)
3479 event = Event()
3481 dlg = gtk.Dialog( _('Moving downloads folder'), self.gPodderProperties)
3482 dlg.vbox.set_spacing( 5)
3483 dlg.set_border_width( 5)
3485 label = gtk.Label()
3486 label.set_line_wrap( True)
3487 label.set_markup( _('Moving downloads from <b>%s</b> to <b>%s</b>...') % ( saxutils.escape( gl.downloaddir), saxutils.escape( new_download_dir), ))
3488 myprogressbar = gtk.ProgressBar()
3490 # put it all together
3491 dlg.vbox.pack_start( label)
3492 dlg.vbox.pack_end( myprogressbar)
3494 # switch windows
3495 dlg.show_all()
3496 self.gPodderProperties.hide_all()
3498 # hide action area and separator line
3499 dlg.action_area.hide()
3500 dlg.set_has_separator( False)
3502 args = ( new_download_dir, event, )
3504 thread = Thread( target = self.set_download_dir, args = args)
3505 thread.start()
3507 while not event.isSet():
3508 try:
3509 new_download_dir_size = util.calculate_size( new_download_dir)
3510 except:
3511 new_download_dir_size = 0
3512 if download_dir_size > 0:
3513 fract = (1.00*new_download_dir_size) / (1.00*download_dir_size)
3514 else:
3515 fract = 0.0
3516 if fract < 0.99:
3517 myprogressbar.set_text( _('%s of %s') % ( gl.format_filesize( new_download_dir_size), download_dir_size_string, ))
3518 else:
3519 myprogressbar.set_text( _('Finishing... please wait.'))
3520 myprogressbar.set_fraction(max(0.0,min(1.0,fract)))
3521 event.wait( 0.1)
3522 while gtk.events_pending():
3523 gtk.main_iteration( False)
3525 dlg.destroy()
3527 device_type = self.comboboxDeviceType.get_active()
3528 if device_type == 0:
3529 gl.config.device_type = 'none'
3530 elif device_type == 1:
3531 gl.config.device_type = 'ipod'
3532 elif device_type == 2:
3533 gl.config.device_type = 'filesystem'
3534 elif device_type == 3:
3535 gl.config.device_type = 'mtp'
3536 self.gPodderProperties.destroy()
3537 if self.callback_finished:
3538 self.callback_finished()
3541 class gPodderEpisode(GladeWidget):
3542 finger_friendly_widgets = ['btnPlay', 'btnDownload', 'btnCancel', 'btnClose', 'textview']
3544 def new(self):
3545 setattr(self, 'episode', None)
3546 setattr(self, 'download_callback', None)
3547 setattr(self, 'play_callback', None)
3548 self.gPodderEpisode.connect('delete-event', self.on_delete_event)
3549 gl.config.connect_gtk_window(self.gPodderEpisode, 'episode_window', True)
3550 services.download_status_manager.register('list-changed', self.on_download_status_changed)
3551 services.download_status_manager.register('progress-detail', self.on_download_status_progress)
3552 self.textview.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse('#ffffff'))
3553 if gl.config.enable_html_shownotes:
3554 try:
3555 import gtkhtml2
3556 setattr(self, 'have_gtkhtml2', True)
3557 # Generate a HTML view and remove the textview
3558 setattr(self, 'htmlview', gtkhtml2.View())
3559 self.scrolled_window.remove(self.scrolled_window.get_child())
3560 self.scrolled_window.add(self.htmlview)
3561 self.textview = None
3562 self.htmlview.set_document(gtkhtml2.Document())
3563 self.htmlview.show()
3564 except ImportError:
3565 log('Install gtkhtml2 if you want HTML shownotes', sender=self)
3566 setattr(self, 'have_gtkhtml2', False)
3567 else:
3568 setattr(self, 'have_gtkhtml2', False)
3570 def show(self, episode, download_callback, play_callback):
3571 self.episode = episode
3572 self.download_callback = download_callback
3573 self.play_callback = play_callback
3575 self.gPodderEpisode.set_title(self.episode.title)
3577 if self.have_gtkhtml2:
3578 import gtkhtml2
3579 d = gtkhtml2.Document()
3580 d.open_stream('text/html')
3581 d.write_stream('<html><head></head><body><em>%s</em></body></html>' % _('Loading shownotes...'))
3582 d.close_stream()
3583 self.htmlview.set_document(d)
3584 else:
3585 b = gtk.TextBuffer()
3586 self.textview.set_buffer(b)
3588 self.hide_show_widgets()
3589 self.gPodderEpisode.show()
3591 # Make sure the window comes up right now:
3592 while gtk.events_pending():
3593 gtk.main_iteration(False)
3595 # Now do the stuff that takes a bit longer...
3596 heading = self.episode.title
3597 subheading = 'from %s' % (self.episode.channel.title)
3598 description = self.episode.description
3599 footer = []
3601 if self.have_gtkhtml2:
3602 import gtkhtml2
3603 d.connect('link-clicked', lambda d, url: util.open_website(url))
3604 def request_url(document, url, stream):
3605 def opendata(url, stream):
3606 fp = urllib2.urlopen(url)
3607 data = fp.read(1024*10)
3608 while data != '':
3609 stream.write(data)
3610 data = fp.read(1024*10)
3611 stream.close()
3612 Thread(target=opendata, args=[url, stream]).start()
3613 d.connect('request-url', request_url)
3614 d.clear()
3615 d.open_stream('text/html')
3616 d.write_stream('<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"/></head><body>')
3617 d.write_stream('<span style="font-size: big; font-weight: bold;">%s</span><br><span style="font-size: small;">%s</span><hr style="border: 1px #eeeeee solid;"><p>' % (saxutils.escape(heading), saxutils.escape(subheading)))
3618 d.write_stream(self.episode.description)
3619 if len(footer):
3620 d.write_stream('<hr style="border: 1px #eeeeee solid;">')
3621 d.write_stream('<span style="font-size: small;">%s</span>' % ('<br>'.join(((saxutils.escape(f) for f in footer))),))
3622 d.write_stream('</p></body></html>')
3623 d.close_stream()
3624 else:
3625 b.create_tag('heading', scale=pango.SCALE_LARGE, weight=pango.WEIGHT_BOLD)
3626 b.create_tag('subheading', scale=pango.SCALE_SMALL)
3627 b.create_tag('footer', scale=pango.SCALE_SMALL)
3629 b.insert_with_tags_by_name(b.get_end_iter(), heading, 'heading')
3630 b.insert_at_cursor('\n')
3631 b.insert_with_tags_by_name(b.get_end_iter(), subheading, 'subheading')
3632 b.insert_at_cursor('\n\n')
3633 b.insert(b.get_end_iter(), util.remove_html_tags(description))
3634 if len(footer):
3635 b.insert_at_cursor('\n\n')
3636 b.insert_with_tags_by_name(b.get_end_iter(), '\n'.join(footer), 'footer')
3637 b.place_cursor(b.get_start_iter())
3639 services.download_status_manager.request_progress_detail(self.episode.url)
3641 def on_cancel(self, widget):
3642 services.download_status_manager.cancel_by_url(self.episode.url)
3644 def on_delete_event(self, widget, event):
3645 # Avoid destroying the dialog, simply hide
3646 self.on_close(widget)
3647 return True
3649 def on_close(self, widget):
3650 self.episode = None
3651 if self.have_gtkhtml2:
3652 import gtkhtml2
3653 self.htmlview.set_document(gtkhtml2.Document())
3654 else:
3655 self.textview.get_buffer().set_text('')
3656 self.gPodderEpisode.hide()
3658 def on_download_status_changed(self, episode_urls, channel_urls):
3659 if self.gPodderEpisode.get_property('visible'):
3660 # Reload the episode from the database, so a newly-set local_filename
3661 # as a result of a download gets updated in the episode object
3662 self.episode.reload_from_db()
3663 self.hide_show_widgets()
3664 else:
3665 log('download status changed, but not visible', sender=self)
3667 def on_download_status_progress(self, url, progress, speed):
3668 if self.episode is None:
3669 return
3671 if url == self.episode.url:
3672 progress = float(min(100.0,max(0.0,progress)))
3673 self.download_progress.set_fraction(progress/100.0)
3674 self.download_progress.set_text('Downloading: %d%% (%s)' % (progress, speed))
3676 def hide_show_widgets(self):
3677 is_downloading = services.download_status_manager.is_download_in_progress(self.episode.url)
3678 if is_downloading:
3679 self.download_progress.show_all()
3680 self.btnCancel.show_all()
3681 self.btnPlay.hide_all()
3682 self.btnDownload.hide_all()
3683 else:
3684 self.download_progress.hide_all()
3685 self.btnCancel.hide_all()
3686 if self.episode.was_downloaded(and_exists=True):
3687 if self.episode.file_type() in ('audio', 'video'):
3688 self.btnPlay.set_label(gtk.STOCK_MEDIA_PLAY)
3689 else:
3690 self.btnPlay.set_label(gtk.STOCK_OPEN)
3691 self.btnPlay.set_use_stock(True)
3692 self.btnPlay.show_all()
3693 self.btnDownload.hide_all()
3694 else:
3695 self.btnPlay.hide_all()
3696 self.btnDownload.show_all()
3698 def on_download(self, widget):
3699 if self.download_callback:
3700 self.download_callback()
3702 def on_playback(self, widget):
3703 if self.play_callback:
3704 self.play_callback()
3705 self.on_close(widget)
3707 class gPodderSync(GladeWidget):
3708 def new(self):
3709 util.idle_add(self.imageSync.set_from_icon_name, 'gnome-dev-ipod', gtk.ICON_SIZE_DIALOG)
3711 self.device.register('progress', self.on_progress)
3712 self.device.register('sub-progress', self.on_sub_progress)
3713 self.device.register('status', self.on_status)
3714 self.device.register('done', self.on_done)
3716 def on_progress(self, pos, max, text=None):
3717 if text is None:
3718 text = _('%d of %d done') % (pos, max)
3719 util.idle_add(self.progressbar.set_fraction, float(pos)/float(max))
3720 util.idle_add(self.progressbar.set_text, text)
3722 def on_sub_progress(self, percentage):
3723 util.idle_add(self.progressbar.set_text, _('Processing (%d%%)') % (percentage))
3725 def on_status(self, status):
3726 util.idle_add(self.status_label.set_markup, '<i>%s</i>' % saxutils.escape(status))
3728 def on_done(self):
3729 util.idle_add(self.gPodderSync.destroy)
3730 if not self.gPodder.minimized:
3731 util.idle_add(self.notification, _('Your device has been updated by gPodder.'), _('Operation finished'))
3733 def on_gPodderSync_destroy(self, widget, *args):
3734 self.device.unregister('progress', self.on_progress)
3735 self.device.unregister('sub-progress', self.on_sub_progress)
3736 self.device.unregister('status', self.on_status)
3737 self.device.unregister('done', self.on_done)
3738 self.device.cancel()
3740 def on_cancel_button_clicked(self, widget, *args):
3741 self.device.cancel()
3744 class gPodderOpmlLister(GladeWidget):
3745 finger_friendly_widgets = ['btnDownloadOpml', 'btnCancel', 'btnOK', 'treeviewChannelChooser']
3747 def new(self):
3748 # initiate channels list
3749 self.channels = []
3750 self.callback_for_channel = None
3751 self.callback_finished = None
3753 if hasattr(self, 'custom_title'):
3754 self.gPodderOpmlLister.set_title(self.custom_title)
3755 if hasattr(self, 'hide_url_entry'):
3756 self.hboxOpmlUrlEntry.hide_all()
3757 new_parent = self.notebookChannelAdder.get_parent()
3758 new_parent.remove(self.notebookChannelAdder)
3759 self.vboxOpmlImport.reparent(new_parent)
3761 self.setup_treeview(self.treeviewChannelChooser)
3762 self.setup_treeview(self.treeviewTopPodcastsChooser)
3763 self.setup_treeview(self.treeviewYouTubeChooser)
3765 self.notebookChannelAdder.connect('switch-page', lambda a, b, c: self.on_change_tab(c))
3767 def setup_treeview(self, tv):
3768 togglecell = gtk.CellRendererToggle()
3769 togglecell.set_property( 'activatable', True)
3770 togglecell.connect( 'toggled', self.callback_edited)
3771 togglecolumn = gtk.TreeViewColumn( '', togglecell, active=0)
3773 titlecell = gtk.CellRendererText()
3774 titlecell.set_property('ellipsize', pango.ELLIPSIZE_END)
3775 titlecolumn = gtk.TreeViewColumn(_('Podcast'), titlecell, markup=1)
3777 for itemcolumn in ( togglecolumn, titlecolumn ):
3778 tv.append_column(itemcolumn)
3780 def callback_edited( self, cell, path):
3781 model = self.get_treeview().get_model()
3783 url = model[path][2]
3785 model[path][0] = not model[path][0]
3786 if model[path][0]:
3787 self.channels.append( url)
3788 else:
3789 self.channels.remove( url)
3791 self.btnOK.set_sensitive( bool(len(self.get_selected_channels())))
3793 def get_selected_channels(self, tab=None):
3794 channels = []
3796 model = self.get_treeview(tab).get_model()
3797 if model is not None:
3798 for row in model:
3799 if row[0]:
3800 channels.append(row[2])
3802 return channels
3804 def on_change_tab(self, tab):
3805 self.btnOK.set_sensitive( bool(len(self.get_selected_channels(tab))))
3807 def thread_finished(self, model, tab=0):
3808 if tab == 1:
3809 tv = self.treeviewTopPodcastsChooser
3810 elif tab == 2:
3811 tv = self.treeviewYouTubeChooser
3812 self.entryYoutubeSearch.set_sensitive(True)
3813 self.btnSearchYouTube.set_sensitive(True)
3814 self.btnOK.set_sensitive(False)
3815 else:
3816 tv = self.treeviewChannelChooser
3817 self.btnDownloadOpml.set_sensitive(True)
3818 self.entryURL.set_sensitive(True)
3819 self.channels = []
3821 tv.set_model(model)
3822 tv.set_sensitive(True)
3824 def thread_func(self, tab=0):
3825 if tab == 1:
3826 model = opml.Importer(gl.config.toplist_url).get_model()
3827 if len(model) == 0:
3828 self.notification(_('The specified URL does not provide any valid OPML podcast items.'), _('No feeds found'))
3829 elif tab == 2:
3830 model = resolver.find_youtube_channels(self.entryYoutubeSearch.get_text())
3831 if len(model) == 0:
3832 self.notification(_('There are no YouTube channels that would match this query.'), _('No channels found'))
3833 else:
3834 model = opml.Importer(self.entryURL.get_text()).get_model()
3835 if len(model) == 0:
3836 self.notification(_('The specified URL does not provide any valid OPML podcast items.'), _('No feeds found'))
3838 util.idle_add(self.thread_finished, model, tab)
3840 def get_channels_from_url( self, url, callback_for_channel = None, callback_finished = None):
3841 if callback_for_channel:
3842 self.callback_for_channel = callback_for_channel
3843 if callback_finished:
3844 self.callback_finished = callback_finished
3845 self.entryURL.set_text( url)
3846 self.btnDownloadOpml.set_sensitive( False)
3847 self.entryURL.set_sensitive( False)
3848 self.btnOK.set_sensitive( False)
3849 self.treeviewChannelChooser.set_sensitive( False)
3850 Thread( target = self.thread_func).start()
3851 Thread( target = lambda: self.thread_func(1)).start()
3853 def select_all( self, value ):
3854 enabled = False
3855 model = self.get_treeview().get_model()
3856 if model is not None:
3857 for row in model:
3858 row[0] = value
3859 if value:
3860 enabled = True
3861 self.btnOK.set_sensitive(enabled)
3863 def on_gPodderOpmlLister_destroy(self, widget, *args):
3864 pass
3866 def on_btnDownloadOpml_clicked(self, widget, *args):
3867 self.get_channels_from_url( self.entryURL.get_text())
3869 def on_btnSearchYouTube_clicked(self, widget, *args):
3870 self.entryYoutubeSearch.set_sensitive(False)
3871 self.treeviewYouTubeChooser.set_sensitive(False)
3872 self.btnSearchYouTube.set_sensitive(False)
3873 Thread(target = lambda: self.thread_func(2)).start()
3875 def on_btnSelectAll_clicked(self, widget, *args):
3876 self.select_all(True)
3878 def on_btnSelectNone_clicked(self, widget, *args):
3879 self.select_all(False)
3881 def on_btnOK_clicked(self, widget, *args):
3882 self.channels = self.get_selected_channels()
3883 self.gPodderOpmlLister.destroy()
3885 # add channels that have been selected
3886 for url in self.channels:
3887 if self.callback_for_channel:
3888 self.callback_for_channel( url)
3890 if self.callback_finished:
3891 util.idle_add(self.callback_finished)
3893 def on_btnCancel_clicked(self, widget, *args):
3894 self.gPodderOpmlLister.destroy()
3896 def on_entryYoutubeSearch_key_press_event(self, widget, event):
3897 if event.keyval == gtk.keysyms.Return:
3898 self.on_btnSearchYouTube_clicked(widget)
3900 def get_treeview(self, tab=None):
3901 if tab is None:
3902 tab = self.notebookChannelAdder.get_current_page()
3904 if tab == 0:
3905 return self.treeviewChannelChooser
3906 elif tab == 1:
3907 return self.treeviewTopPodcastsChooser
3908 else:
3909 return self.treeviewYouTubeChooser
3911 class gPodderEpisodeSelector( GladeWidget):
3912 """Episode selection dialog
3914 Optional keyword arguments that modify the behaviour of this dialog:
3916 - callback: Function that takes 1 parameter which is a list of
3917 the selected episodes (or empty list when none selected)
3918 - remove_callback: Function that takes 1 parameter which is a list
3919 of episodes that should be "removed" (see below)
3920 (default is None, which means remove not possible)
3921 - remove_action: Label for the "remove" action (default is "Remove")
3922 - remove_finished: Callback after all remove callbacks have finished
3923 (default is None, also depends on remove_callback)
3924 It will get a list of episode URLs that have been
3925 removed, so the main UI can update those
3926 - episodes: List of episodes that are presented for selection
3927 - selected: (optional) List of boolean variables that define the
3928 default checked state for the given episodes
3929 - selected_default: (optional) The default boolean value for the
3930 checked state if no other value is set
3931 (default is False)
3932 - columns: List of (name, sort_name, sort_type, caption) pairs for the
3933 columns, the name is the attribute name of the episode to be
3934 read from each episode object. The sort name is the
3935 attribute name of the episode to be used to sort this column.
3936 If the sort_name is None it will use the attribute name for
3937 sorting. The sort type is the type of the sort column.
3938 The caption attribute is the text that appear as column caption
3939 (default is [('title_and_description', None, None, 'Episode'),])
3940 - title: (optional) The title of the window + heading
3941 - instructions: (optional) A one-line text describing what the
3942 user should select / what the selection is for
3943 - stock_ok_button: (optional) Will replace the "OK" button with
3944 another GTK+ stock item to be used for the
3945 affirmative button of the dialog (e.g. can
3946 be gtk.STOCK_DELETE when the episodes to be
3947 selected will be deleted after closing the
3948 dialog)
3949 - selection_buttons: (optional) A dictionary with labels as
3950 keys and callbacks as values; for each
3951 key a button will be generated, and when
3952 the button is clicked, the callback will
3953 be called for each episode and the return
3954 value of the callback (True or False) will
3955 be the new selected state of the episode
3956 - size_attribute: (optional) The name of an attribute of the
3957 supplied episode objects that can be used to
3958 calculate the size of an episode; set this to
3959 None if no total size calculation should be
3960 done (in cases where total size is useless)
3961 (default is 'length')
3962 - tooltip_attribute: (optional) The name of an attribute of
3963 the supplied episode objects that holds
3964 the text for the tooltips when hovering
3965 over an episode (default is 'description')
3968 finger_friendly_widgets = ['btnCancel', 'btnOK', 'btnCheckAll', 'btnCheckNone', 'treeviewEpisodes']
3970 COLUMN_INDEX = 0
3971 COLUMN_TOOLTIP = 1
3972 COLUMN_TOGGLE = 2
3973 COLUMN_ADDITIONAL = 3
3975 def new( self):
3976 gl.config.connect_gtk_window(self.gPodderEpisodeSelector, 'episode_selector', True)
3977 if not hasattr( self, 'callback'):
3978 self.callback = None
3980 if not hasattr(self, 'remove_callback'):
3981 self.remove_callback = None
3983 if not hasattr(self, 'remove_action'):
3984 self.remove_action = _('Remove')
3986 if not hasattr(self, 'remove_finished'):
3987 self.remove_finished = None
3989 if not hasattr( self, 'episodes'):
3990 self.episodes = []
3992 if not hasattr( self, 'size_attribute'):
3993 self.size_attribute = 'length'
3995 if not hasattr(self, 'tooltip_attribute'):
3996 self.tooltip_attribute = 'description'
3998 if not hasattr( self, 'selection_buttons'):
3999 self.selection_buttons = {}
4001 if not hasattr( self, 'selected_default'):
4002 self.selected_default = False
4004 if not hasattr( self, 'selected'):
4005 self.selected = [self.selected_default]*len(self.episodes)
4007 if len(self.selected) < len(self.episodes):
4008 self.selected += [self.selected_default]*(len(self.episodes)-len(self.selected))
4010 if not hasattr( self, 'columns'):
4011 self.columns = (('title_and_description', None, None, _('Episode')),)
4013 if hasattr( self, 'title'):
4014 self.gPodderEpisodeSelector.set_title( self.title)
4015 self.labelHeading.set_markup( '<b><big>%s</big></b>' % saxutils.escape( self.title))
4017 if gpodder.interface == gpodder.MAEMO:
4018 self.labelHeading.hide()
4020 if hasattr( self, 'instructions'):
4021 self.labelInstructions.set_text( self.instructions)
4022 self.labelInstructions.show_all()
4024 if hasattr(self, 'stock_ok_button'):
4025 if self.stock_ok_button == 'gpodder-download':
4026 self.btnOK.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_BUTTON))
4027 self.btnOK.set_label(_('Download'))
4028 else:
4029 self.btnOK.set_label(self.stock_ok_button)
4030 self.btnOK.set_use_stock(True)
4032 # check/uncheck column
4033 toggle_cell = gtk.CellRendererToggle()
4034 toggle_cell.connect( 'toggled', self.toggle_cell_handler)
4035 self.treeviewEpisodes.append_column( gtk.TreeViewColumn( '', toggle_cell, active=self.COLUMN_TOGGLE))
4037 next_column = self.COLUMN_ADDITIONAL
4038 for name, sort_name, sort_type, caption in self.columns:
4039 renderer = gtk.CellRendererText()
4040 renderer.set_property( 'ellipsize', pango.ELLIPSIZE_END)
4041 column = gtk.TreeViewColumn(caption, renderer, markup=next_column)
4042 column.set_resizable( True)
4043 # Only set "expand" on the first column (so more text is displayed there)
4044 column.set_expand(next_column == self.COLUMN_ADDITIONAL)
4045 if sort_name is not None:
4046 column.set_sort_column_id(next_column+1)
4047 else:
4048 column.set_sort_column_id(next_column)
4049 self.treeviewEpisodes.append_column( column)
4050 next_column += 1
4052 if sort_name is not None:
4053 # add the sort column
4054 column = gtk.TreeViewColumn()
4055 column.set_visible(False)
4056 self.treeviewEpisodes.append_column( column)
4057 next_column += 1
4059 column_types = [ gobject.TYPE_INT, gobject.TYPE_STRING, gobject.TYPE_BOOLEAN ]
4060 # add string column type plus sort column type if it exists
4061 for name, sort_name, sort_type, caption in self.columns:
4062 column_types.append(gobject.TYPE_STRING)
4063 if sort_name is not None:
4064 column_types.append(sort_type)
4065 self.model = gtk.ListStore( *column_types)
4067 tooltip = None
4068 for index, episode in enumerate( self.episodes):
4069 if self.tooltip_attribute is not None:
4070 try:
4071 tooltip = getattr(episode, self.tooltip_attribute)
4072 except:
4073 log('Episode object %s does not have tooltip attribute: "%s"', episode, self.tooltip_attribute, sender=self)
4074 tooltip = None
4075 row = [ index, tooltip, self.selected[index] ]
4076 for name, sort_name, sort_type, caption in self.columns:
4077 if not hasattr(episode, name):
4078 log('Warning: Missing attribute "%s"', name, sender=self)
4079 row.append(None)
4080 else:
4081 row.append(getattr( episode, name))
4083 if sort_name is not None:
4084 if not hasattr(episode, sort_name):
4085 log('Warning: Missing attribute "%s"', sort_name, sender=self)
4086 row.append(None)
4087 else:
4088 row.append(getattr( episode, sort_name))
4089 self.model.append( row)
4091 if self.remove_callback is not None:
4092 self.btnRemoveAction.show()
4093 self.btnRemoveAction.set_label(self.remove_action)
4095 # connect to tooltip signals
4096 if self.tooltip_attribute is not None:
4097 try:
4098 self.treeviewEpisodes.set_property('has-tooltip', True)
4099 self.treeviewEpisodes.connect('query-tooltip', self.treeview_episodes_query_tooltip)
4100 except:
4101 log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender=self)
4102 self.last_tooltip_episode = None
4103 self.episode_list_can_tooltip = True
4105 self.treeviewEpisodes.connect('button-press-event', self.treeview_episodes_button_pressed)
4106 self.treeviewEpisodes.set_rules_hint( True)
4107 self.treeviewEpisodes.set_model( self.model)
4108 self.treeviewEpisodes.columns_autosize()
4109 self.calculate_total_size()
4111 def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
4112 # With get_bin_window, we get the window that contains the rows without
4113 # the header. The Y coordinate of this window will be the height of the
4114 # treeview header. This is the amount we have to subtract from the
4115 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
4116 (x_bin, y_bin) = treeview.get_bin_window().get_position()
4117 y -= x_bin
4118 y -= y_bin
4119 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
4121 if not self.episode_list_can_tooltip:
4122 self.last_tooltip_episode = None
4123 return False
4125 if path is not None:
4126 model = treeview.get_model()
4127 iter = model.get_iter(path)
4128 index = model.get_value(iter, self.COLUMN_INDEX)
4129 description = model.get_value(iter, self.COLUMN_TOOLTIP)
4130 if self.last_tooltip_episode is not None and self.last_tooltip_episode != index:
4131 self.last_tooltip_episode = None
4132 return False
4133 self.last_tooltip_episode = index
4135 if description is not None:
4136 tooltip.set_text(description)
4137 return True
4138 else:
4139 return False
4141 self.last_tooltip_episode = None
4142 return False
4144 def treeview_episodes_button_pressed(self, treeview, event):
4145 if event.button == 3:
4146 menu = gtk.Menu()
4148 if len(self.selection_buttons):
4149 for label in self.selection_buttons:
4150 item = gtk.MenuItem(label)
4151 item.connect('activate', self.custom_selection_button_clicked, label)
4152 menu.append(item)
4153 menu.append(gtk.SeparatorMenuItem())
4155 item = gtk.MenuItem(_('Select all'))
4156 item.connect('activate', self.on_btnCheckAll_clicked)
4157 menu.append(item)
4159 item = gtk.MenuItem(_('Select none'))
4160 item.connect('activate', self.on_btnCheckNone_clicked)
4161 menu.append(item)
4163 menu.show_all()
4164 # Disable tooltips while we are showing the menu, so
4165 # the tooltip will not appear over the menu
4166 self.episode_list_can_tooltip = False
4167 menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
4168 menu.popup(None, None, None, event.button, event.time)
4170 return True
4172 def episode_list_allow_tooltips(self):
4173 self.episode_list_can_tooltip = True
4175 def calculate_total_size( self):
4176 if self.size_attribute is not None:
4177 (total_size, count) = (0, 0)
4178 for episode in self.get_selected_episodes():
4179 try:
4180 total_size += int(getattr( episode, self.size_attribute))
4181 count += 1
4182 except:
4183 log( 'Cannot get size for %s', episode.title, sender = self)
4185 text = []
4186 if count == 0:
4187 text.append(_('Nothing selected'))
4188 elif count == 1:
4189 text.append(_('One episode selected'))
4190 else:
4191 text.append(_('%d episodes selected') % count)
4192 if total_size > 0:
4193 text.append(_('total size: %s') % gl.format_filesize(total_size))
4194 self.labelTotalSize.set_text(', '.join(text))
4195 self.btnOK.set_sensitive(count>0)
4196 self.btnRemoveAction.set_sensitive(count>0)
4197 if count > 0:
4198 self.btnCancel.set_label(gtk.STOCK_CANCEL)
4199 else:
4200 self.btnCancel.set_label(gtk.STOCK_CLOSE)
4201 else:
4202 self.btnOK.set_sensitive(False)
4203 self.btnRemoveAction.set_sensitive(False)
4204 for index, row in enumerate(self.model):
4205 if self.model.get_value(row.iter, self.COLUMN_TOGGLE) == True:
4206 self.btnOK.set_sensitive(True)
4207 self.btnRemoveAction.set_sensitive(True)
4208 break
4209 self.labelTotalSize.set_text('')
4211 def toggle_cell_handler( self, cell, path):
4212 model = self.treeviewEpisodes.get_model()
4213 model[path][self.COLUMN_TOGGLE] = not model[path][self.COLUMN_TOGGLE]
4215 self.calculate_total_size()
4217 def custom_selection_button_clicked(self, button, label):
4218 callback = self.selection_buttons[label]
4220 for index, row in enumerate( self.model):
4221 new_value = callback( self.episodes[index])
4222 self.model.set_value( row.iter, self.COLUMN_TOGGLE, new_value)
4224 self.calculate_total_size()
4226 def on_btnCheckAll_clicked( self, widget):
4227 for row in self.model:
4228 self.model.set_value( row.iter, self.COLUMN_TOGGLE, True)
4230 self.calculate_total_size()
4232 def on_btnCheckNone_clicked( self, widget):
4233 for row in self.model:
4234 self.model.set_value( row.iter, self.COLUMN_TOGGLE, False)
4236 self.calculate_total_size()
4238 def on_remove_action_activate(self, widget):
4239 episodes = self.get_selected_episodes(remove_episodes=True)
4241 urls = []
4242 for episode in episodes:
4243 urls.append(episode.url)
4244 self.remove_callback(episode)
4246 if self.remove_finished is not None:
4247 self.remove_finished(urls)
4248 self.calculate_total_size()
4250 def get_selected_episodes( self, remove_episodes=False):
4251 selected_episodes = []
4253 for index, row in enumerate( self.model):
4254 if self.model.get_value( row.iter, self.COLUMN_TOGGLE) == True:
4255 selected_episodes.append( self.episodes[self.model.get_value( row.iter, self.COLUMN_INDEX)])
4257 if remove_episodes:
4258 for episode in selected_episodes:
4259 index = self.episodes.index(episode)
4260 iter = self.model.get_iter_first()
4261 while iter is not None:
4262 if self.model.get_value(iter, self.COLUMN_INDEX) == index:
4263 self.model.remove(iter)
4264 break
4265 iter = self.model.iter_next(iter)
4267 return selected_episodes
4269 def on_btnOK_clicked( self, widget):
4270 self.gPodderEpisodeSelector.destroy()
4271 if self.callback is not None:
4272 self.callback( self.get_selected_episodes())
4274 def on_btnCancel_clicked( self, widget):
4275 self.gPodderEpisodeSelector.destroy()
4276 if self.callback is not None:
4277 self.callback([])
4279 class gPodderConfigEditor(GladeWidget):
4280 finger_friendly_widgets = ['btnShowAll', 'btnClose', 'configeditor']
4282 def new(self):
4283 name_column = gtk.TreeViewColumn(_('Setting'))
4284 name_renderer = gtk.CellRendererText()
4285 name_column.pack_start(name_renderer)
4286 name_column.add_attribute(name_renderer, 'text', 0)
4287 name_column.add_attribute(name_renderer, 'style', 5)
4288 self.configeditor.append_column(name_column)
4290 value_column = gtk.TreeViewColumn(_('Set to'))
4291 value_check_renderer = gtk.CellRendererToggle()
4292 value_column.pack_start(value_check_renderer, expand=False)
4293 value_column.add_attribute(value_check_renderer, 'active', 7)
4294 value_column.add_attribute(value_check_renderer, 'visible', 6)
4295 value_column.add_attribute(value_check_renderer, 'activatable', 6)
4296 value_check_renderer.connect('toggled', self.value_toggled)
4298 value_renderer = gtk.CellRendererText()
4299 value_column.pack_start(value_renderer)
4300 value_column.add_attribute(value_renderer, 'text', 2)
4301 value_column.add_attribute(value_renderer, 'visible', 4)
4302 value_column.add_attribute(value_renderer, 'editable', 4)
4303 value_column.add_attribute(value_renderer, 'style', 5)
4304 value_renderer.connect('edited', self.value_edited)
4305 self.configeditor.append_column(value_column)
4307 self.model = gl.config.model()
4308 self.filter = self.model.filter_new()
4309 self.filter.set_visible_func(self.visible_func)
4311 self.configeditor.set_model(self.filter)
4312 self.configeditor.set_rules_hint(True)
4314 def visible_func(self, model, iter, user_data=None):
4315 text = self.entryFilter.get_text().lower()
4316 if text == '':
4317 return True
4318 else:
4319 # either the variable name or its value
4320 return (text in model.get_value(iter, 0).lower() or
4321 text in model.get_value(iter, 2).lower())
4323 def value_edited(self, renderer, path, new_text):
4324 model = self.configeditor.get_model()
4325 iter = model.get_iter(path)
4326 name = model.get_value(iter, 0)
4327 type_cute = model.get_value(iter, 1)
4329 if not gl.config.update_field(name, new_text):
4330 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))
4332 def value_toggled(self, renderer, path):
4333 model = self.configeditor.get_model()
4334 iter = model.get_iter(path)
4335 field_name = model.get_value(iter, 0)
4336 field_type = model.get_value(iter, 3)
4338 # Flip the boolean config flag
4339 if field_type == bool:
4340 gl.config.toggle_flag(field_name)
4342 def on_entryFilter_changed(self, widget):
4343 self.filter.refilter()
4345 def on_btnShowAll_clicked(self, widget):
4346 self.entryFilter.set_text('')
4347 self.entryFilter.grab_focus()
4349 def on_btnClose_clicked(self, widget):
4350 self.gPodderConfigEditor.destroy()
4352 class gPodderPlaylist(GladeWidget):
4353 finger_friendly_widgets = ['btnCancelPlaylist', 'btnSavePlaylist', 'treeviewPlaylist']
4355 def new(self):
4356 self.linebreak = '\n'
4357 if gl.config.mp3_player_playlist_win_path:
4358 self.linebreak = '\r\n'
4359 self.mountpoint = util.find_mount_point(gl.config.mp3_player_folder)
4360 if self.mountpoint == '/':
4361 self.mountpoint = gl.config.mp3_player_folder
4362 log('Warning: MP3 player resides on / - using %s as MP3 player root', self.mountpoint, sender=self)
4363 self.playlist_file = os.path.join(self.mountpoint,
4364 gl.config.mp3_player_playlist_file)
4365 icon_theme = gtk.icon_theme_get_default()
4366 self.icon_new = icon_theme.load_icon(gtk.STOCK_NEW, 16, 0)
4368 # add column two
4369 check_cell = gtk.CellRendererToggle()
4370 check_cell.set_property('activatable', True)
4371 check_cell.connect('toggled', self.cell_toggled)
4372 check_column = gtk.TreeViewColumn(_('Use'), check_cell, active=1)
4373 self.treeviewPlaylist.append_column(check_column)
4375 # add column three
4376 column = gtk.TreeViewColumn(_('Filename'))
4377 icon_cell = gtk.CellRendererPixbuf()
4378 column.pack_start(icon_cell, False)
4379 column.add_attribute(icon_cell, 'pixbuf', 0)
4380 filename_cell = gtk.CellRendererText()
4381 column.pack_start(filename_cell, True)
4382 column.add_attribute(filename_cell, 'text', 2)
4384 column.set_resizable(True)
4385 self.treeviewPlaylist.append_column(column)
4387 # Make treeview reorderable
4388 self.treeviewPlaylist.set_reorderable(True)
4390 # init liststore
4391 self.playlist = gtk.ListStore(gtk.gdk.Pixbuf, bool, str)
4392 self.treeviewPlaylist.set_model(self.playlist)
4394 # read device and playlist and fill the TreeView
4395 title = _('Reading files from %s') % gl.config.mp3_player_folder
4396 message = _('Please wait while gPodder reads your media file list from device.')
4397 dlg = gtk.MessageDialog(GladeWidget.gpodder_main_window, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_NONE)
4398 dlg.set_title(title)
4399 dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
4400 dlg.show_all()
4401 Thread(target=self.process_device, args=[dlg]).start()
4403 def process_device(self, dlg):
4404 self.m3u = self.read_m3u()
4405 self.device = self.read_device()
4406 util.idle_add(self.write2gui, dlg)
4408 def cell_toggled(self, cellrenderertoggle, path):
4409 (treeview, liststore) = (self.treeviewPlaylist, self.playlist)
4410 it = liststore.get_iter(path)
4411 liststore.set_value(it, 1, not liststore.get_value(it, 1))
4413 def on_btnCancelPlaylist_clicked(self, widget):
4414 self.gPodderPlaylist.destroy()
4416 def on_btnSavePlaylist_clicked(self, widget):
4417 self.write_m3u()
4418 self.gPodderPlaylist.destroy()
4420 def read_m3u(self):
4422 read all files from the existing playlist
4424 tracks = []
4425 log("Read data from the playlistfile %s" % self.playlist_file)
4426 if os.path.exists(self.playlist_file):
4427 for line in open(self.playlist_file, 'r'):
4428 if not line.startswith('#EXT'):
4429 if line.startswith('#'):
4430 tracks.append([False, line[1:].strip()])
4431 else:
4432 tracks.append([True, line.strip()])
4433 return tracks
4435 def build_extinf(self, filename):
4436 if gl.config.mp3_player_playlist_win_path:
4437 filename = filename.replace('\\', os.sep)
4439 # rebuild the whole filename including the mountpoint
4440 if gl.config.mp3_player_playlist_absolute_path:
4441 absfile = self.mountpoint + filename
4442 else:
4443 absfile = util.rel2abs(filename, os.path.dirname(self.playlist_file))
4445 # read the title from the mp3/ogg tag
4446 metadata = libtagupdate.get_tags_from_file(absfile)
4447 if 'title' in metadata and metadata['title']:
4448 title = metadata['title']
4449 else:
4450 # fallback: use the basename of the file
4451 (title, extension) = os.path.splitext(os.path.basename(filename))
4453 return "#EXTINF:0,%s%s" % (title.strip(), self.linebreak)
4455 def write_m3u(self):
4457 write the list into the playlist on the device
4459 log('Writing playlist file: %s', self.playlist_file, sender=self)
4460 playlist_folder = os.path.split(self.playlist_file)[0]
4461 if not util.make_directory(playlist_folder):
4462 self.show_message(_('Folder %s could not be created.') % playlist_folder, _('Error writing playlist'))
4463 else:
4464 try:
4465 fp = open(self.playlist_file, 'w')
4466 fp.write('#EXTM3U%s' % self.linebreak)
4467 for icon, checked, filename in self.playlist:
4468 fp.write(self.build_extinf(filename))
4469 if not checked:
4470 fp.write('#')
4471 fp.write(filename)
4472 fp.write(self.linebreak)
4473 fp.close()
4474 self.show_message(_('The playlist on your MP3 player has been updated.'), _('Update successful'))
4475 except IOError, ioe:
4476 self.show_message(str(ioe), _('Error writing playlist file'))
4478 def read_device(self):
4480 read all files from the device
4482 log('Reading files from %s', gl.config.mp3_player_folder, sender=self)
4483 tracks = []
4484 for root, dirs, files in os.walk(gl.config.mp3_player_folder):
4485 for file in files:
4486 filename = os.path.join(root, file)
4488 if filename == self.playlist_file:
4489 # We don't want to have our playlist file as
4490 # an entry in our file list, so skip it!
4491 break
4493 if gl.config.mp3_player_playlist_absolute_path:
4494 filename = filename[len(self.mountpoint):]
4495 else:
4496 filename = util.relpath(os.path.dirname(self.playlist_file),
4497 os.path.dirname(filename)) + \
4498 os.sep + os.path.basename(filename)
4500 if gl.config.mp3_player_playlist_win_path:
4501 filename = filename.replace(os.sep, '\\')
4503 tracks.append(filename)
4504 return tracks
4506 def write2gui(self, dlg):
4507 # add the files from the device to the list only when
4508 # they are not yet in the playlist
4509 # mark this files as NEW
4510 for filename in self.device[:]:
4511 m3ulist = [file[1] for file in self.m3u]
4512 if filename not in m3ulist:
4513 self.playlist.append([self.icon_new, False, filename])
4515 # add the files from the playlist to the list only when
4516 # they are on the device
4517 for checked, filename in self.m3u[:]:
4518 if filename in self.device:
4519 self.playlist.append([None, checked, filename])
4521 dlg.destroy()
4522 return False
4524 class gPodderDependencyManager(GladeWidget):
4525 def new(self):
4526 col_name = gtk.TreeViewColumn(_('Feature'), gtk.CellRendererText(), text=0)
4527 self.treeview_components.append_column(col_name)
4528 col_installed = gtk.TreeViewColumn(_('Status'), gtk.CellRendererText(), text=2)
4529 self.treeview_components.append_column(col_installed)
4530 self.treeview_components.set_model(services.dependency_manager.get_model())
4531 self.btn_about.set_sensitive(False)
4533 def on_btn_about_clicked(self, widget):
4534 selection = self.treeview_components.get_selection()
4535 model, iter = selection.get_selected()
4536 if iter is not None:
4537 title = model.get_value(iter, 0)
4538 description = model.get_value(iter, 1)
4539 available = model.get_value(iter, 3)
4540 missing = model.get_value(iter, 4)
4542 if not available:
4543 description += '\n\n'+_('Missing components:')+'\n\n'+missing
4545 self.show_message(description, title)
4547 def on_btn_install_clicked(self, widget):
4548 # TODO: Implement package manager integration
4549 pass
4551 def on_treeview_components_cursor_changed(self, treeview):
4552 self.btn_about.set_sensitive(treeview.get_selection().count_selected_rows() > 0)
4553 # TODO: If installing is possible, enable btn_install
4555 def on_gPodderDependencyManager_response(self, dialog, response_id):
4556 self.gPodderDependencyManager.destroy()
4558 class gPodderWelcome(GladeWidget):
4559 finger_friendly_widgets = ['btnOPML', 'btnMygPodder', 'btnCancel']
4561 def new(self):
4562 pass
4564 def on_show_example_podcasts(self, button):
4565 self.gPodderWelcome.destroy()
4566 self.show_example_podcasts_callback(None)
4568 def on_setup_my_gpodder(self, gpodder):
4569 self.gPodderWelcome.destroy()
4570 self.setup_my_gpodder_callback(None)
4572 def on_btnCancel_clicked(self, button):
4573 self.gPodderWelcome.destroy()
4575 def main():
4576 gobject.threads_init()
4577 gtk.window_set_default_icon_name( 'gpodder')
4579 session_bus = dbus.SessionBus(mainloop=dbus.glib.DBusGMainLoop())
4580 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=session_bus)
4581 gp = gPodder(bus_name)
4582 gp.run()