Flattr: Flattr-on-play for QML UI, fixes for Gtk UI
[gpodder.git] / src / gpodder / qmlui / __init__.py
blob972cf2b6bd933c52a9e77cc6b2cf9977730cc65a
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2012 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 # gpodder.qmlui - gPodder's QML interface
21 # Thomas Perl <thp@gpodder.org>; 2011-02-06
24 from PySide.QtGui import QApplication
25 from PySide.QtCore import Qt, QObject, Signal, Slot, Property, QUrl
26 from PySide.QtCore import QAbstractListModel, QModelIndex
27 from PySide.QtDeclarative import QDeclarativeView
29 import os
30 import signal
31 import functools
32 import subprocess
34 import dbus
35 import dbus.service
37 from dbus.mainloop.glib import DBusGMainLoop
40 import gpodder
42 _ = gpodder.gettext
43 N_ = gpodder.ngettext
45 from gpodder import core
46 from gpodder import util
47 from gpodder import my
48 from gpodder import query
50 from gpodder.model import Model
52 from gpodder.qmlui import model
53 from gpodder.qmlui import helper
54 from gpodder.qmlui import images
56 import logging
57 logger = logging.getLogger("qmlui")
60 EPISODE_LIST_FILTERS = [
61 # (UI label, EQL expression)
62 (_('All'), None),
63 (_('Hide deleted'), 'not deleted'),
64 (_('New'), 'new or downloading'),
65 (_('Downloaded'), 'downloaded or downloading'),
66 (_('Deleted'), 'deleted'),
67 (_('Finished'), 'finished'),
68 (_('Archived'), 'downloaded and archive'),
69 (_('Videos'), 'video'),
73 class ConfigProxy(QObject):
74 def __init__(self, config):
75 QObject.__init__(self)
76 self._config = config
78 config.add_observer(self._on_config_changed)
80 def _on_config_changed(self, name, old_value, new_value):
81 if name == 'ui.qml.autorotate':
82 self.autorotateChanged.emit()
83 elif name == 'flattr.token':
84 self.flattrTokenChanged.emit()
85 elif name == 'flattr.flattr_on_play':
86 self.flattrOnPlayChanged.emit()
88 def get_autorotate(self):
89 return self._config.ui.qml.autorotate
91 def set_autorotate(self, autorotate):
92 self._config.ui.qml.autorotate = autorotate
94 autorotateChanged = Signal()
96 autorotate = Property(bool, get_autorotate, set_autorotate,
97 notify=autorotateChanged)
99 def get_flattr_token(self):
100 return self._config.flattr.token
102 def set_flattr_token(self, flattr_token):
103 self._config.flattr.token = flattr_token
105 flattrTokenChanged = Signal()
107 flattrToken = Property(unicode, get_flattr_token, set_flattr_token,
108 notify=flattrTokenChanged)
110 def get_flattr_on_play(self):
111 return self._config.flattr.flattr_on_play
113 def set_flattr_on_play(self, flattr_on_play):
114 self._config.flattr.flattr_on_play = flattr_on_play
116 flattrOnPlayChanged = Signal()
118 flattrOnPlay = Property(bool, get_flattr_on_play, set_flattr_on_play,
119 notify=flattrOnPlayChanged)
122 class Controller(QObject):
123 def __init__(self, root):
124 QObject.__init__(self)
125 self.root = root
126 self.context_menu_actions = []
127 self.episode_list_title = u''
128 self.current_input_dialog = None
129 self.root.config.add_observer(self.on_config_changed)
130 self._flattr = self.root.core.flattr
131 self.flattr_button_text = u''
133 def on_config_changed(self, name, old_value, new_value):
134 logger.info('Config changed: %s (%s -> %s)', name,
135 old_value, new_value)
136 if name == 'mygpo.enabled':
137 self.myGpoEnabledChanged.emit()
138 elif name == 'mygpo.username':
139 self.myGpoUsernameChanged.emit()
140 elif name == 'mygpo.password':
141 self.myGpoPasswordChanged.emit()
142 elif name == 'mygpo.device.caption':
143 self.myGpoDeviceCaptionChanged.emit()
145 episodeListTitleChanged = Signal()
147 def setEpisodeListTitle(self, title):
148 if self.episode_list_title != title:
149 self.episode_list_title = title
150 self.episodeListTitleChanged.emit()
152 def getEpisodeListTitle(self):
153 return self.episode_list_title
155 episodeListTitle = Property(unicode, getEpisodeListTitle, \
156 setEpisodeListTitle, notify=episodeListTitleChanged)
158 flattrButtonTextChanged = Signal()
160 def setFlattrButtonText(self, flattr_button_text):
161 if self.flattr_button_text != flattr_button_text:
162 self.flattr_button_text = flattr_button_text
163 self.flattrButtonTextChanged.emit()
165 def getFlattrButtonText(self):
166 return self.flattr_button_text
168 flattrButtonText = Property(unicode, getFlattrButtonText,
169 setFlattrButtonText, notify=flattrButtonTextChanged)
171 @Slot(QObject)
172 def onPlayback(self, qepisode):
173 if (qepisode.payment_url and self.root.config.flattr.token and
174 self.root.config.flattr.flattr_on_play):
175 success, message = self._flattr.flattr_url(qepisode.payment_url)
176 if not success:
177 logger.warn('Flattr message on playback action: %s', message)
179 @Slot(QObject)
180 def updateFlattrButtonText(self, qepisode):
181 self.setFlattrButtonText('')
183 if qepisode is None:
184 return
186 episode = qepisode._episode
188 if not episode.payment_url:
189 return
190 if not self._flattr.has_token():
191 self.setFlattrButtonText(_('Sign in'))
192 return
194 @util.run_in_background
195 def get_flattr_info():
196 flattrs, flattred = self._flattr.get_thing_info(episode.payment_url)
198 if flattred:
199 self.setFlattrButtonText(_('Flattred (%(count)d)') % {
200 'count': flattrs
202 else:
203 self.setFlattrButtonText(_('Flattr this (%(count)d)') % {
204 'count': flattrs
207 @Slot(QObject)
208 def flattrEpisode(self, qepisode):
209 if not qepisode:
210 return
212 episode = qepisode._episode
214 if not episode.payment_url:
215 return
216 if not self._flattr.has_token():
217 self.root.show_message(_('Sign in to Flattr in the settings.'))
218 return
220 self.root.start_progress(_('Flattring episode...'))
222 @util.run_in_background
223 def flattr_episode():
224 try:
225 success, message = self._flattr.flattr_url(episode.payment_url)
226 if success:
227 self.updateFlattrButtonText(qepisode)
228 else:
229 self.root.show_message(message)
230 finally:
231 self.root.end_progress()
233 @Slot(result=str)
234 def getFlattrLoginURL(self):
235 return self._flattr.get_auth_url()
237 @Slot(result=str)
238 def getFlattrCallbackURL(self):
239 return self._flattr.CALLBACK
241 @Slot(str)
242 def processFlattrCode(self, url):
243 if not self._flattr.process_retrieved_code(url):
244 self.root.show_message(_('Could not log in to Flattr.'))
246 @Slot(result='QStringList')
247 def getEpisodeListFilterNames(self):
248 return [caption for caption, eql in EPISODE_LIST_FILTERS]
250 @Slot('QVariant', str)
251 def multiEpisodeAction(self, selected, action):
252 if not selected:
253 return
255 count = len(selected)
256 episodes = map(self.root.episode_model.get_object_by_index, selected)
258 def delete():
259 for episode in episodes:
260 if not episode.qarchive:
261 episode.delete_episode()
262 self.update_subset_stats()
263 self.root.mygpo_client.on_delete(episodes)
264 self.root.mygpo_client.flush()
265 for episode in episodes:
266 self.root.on_episode_deleted(episode)
267 self.root.episode_model.sort()
269 if action == 'delete':
270 msg = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?', count) % {'count':count}
271 self.confirm_action(msg, _('Delete'), delete)
272 elif action == 'download':
273 for episode in episodes:
274 if episode.qdownloaded:
275 print ' XXX already downloaded'
276 continue
277 episode.qdownload(self.root.config, self.update_subset_stats)
278 self.root.mygpo_client.on_download(episodes)
279 self.root.mygpo_client.flush()
280 elif action == 'play':
281 for episode in episodes:
282 self.root.enqueue_episode(episode)
284 @Slot(str, result=str)
285 def translate(self, x):
286 return _(x)
288 @Slot(str, str, int, result=str)
289 def ntranslate(self, singular, plural, count):
290 return N_(singular, plural, count)
292 @Slot(str, int, result=str)
293 def formatCount(self, template, count):
294 return template % {'count': count}
296 @Slot(result=str)
297 def getVersion(self):
298 return gpodder.__version__
300 @Slot(result=str)
301 def getReleased(self):
302 return gpodder.__date__
304 @Slot(result=unicode)
305 def getCredits(self):
306 credits_file = os.path.join(gpodder.prefix, 'share', 'gpodder', 'credits.txt')
307 return util.convert_bytes(open(credits_file).read())
309 @Slot(result=unicode)
310 def getCopyright(self):
311 return util.convert_bytes(gpodder.__copyright__)
313 @Slot(result=str)
314 def getLicense(self):
315 return gpodder.__license__
317 @Slot(result=str)
318 def getURL(self):
319 return gpodder.__url__
321 @Slot()
322 def loadLastEpisode(self):
323 self.root.load_last_episode()
325 @Slot(QObject, int, int)
326 def storePlaybackAction(self, episode, start, end):
327 if end - 5 < start:
328 logger.info('Ignoring too short playback action.')
329 return
330 total = episode.qduration
331 self.root.mygpo_client.on_playback_full(episode, start, end, total)
332 self.root.mygpo_client.flush()
334 @Slot(QObject)
335 def playVideo(self, episode):
336 """Video Playback on MeeGo 1.2 Harmattan"""
337 if episode.qnew:
338 episode.toggle_new()
339 self.update_subset_stats()
341 url = episode.get_playback_url()
342 if gpodder.ui.harmattan:
343 subprocess.Popen(['video-suite', url])
344 else:
345 util.gui_open(url)
347 self.root.mygpo_client.on_playback([episode])
348 self.root.mygpo_client.flush()
350 @Slot(QObject)
351 def podcastSelected(self, podcast):
352 self.setEpisodeListTitle(podcast.qtitle)
353 self.root.select_podcast(podcast)
355 windowTitleChanged = Signal()
357 def getWindowTitle(self):
358 return self.root.view.windowTitle()
360 def setWindowTitle(self, windowTitle):
361 if gpodder.ui.fremantle:
362 self.root.view.setWindowTitle(windowTitle)
364 windowTitle = Property(unicode, getWindowTitle,
365 setWindowTitle, notify=windowTitleChanged)
367 @Slot()
368 def myGpoUploadList(self):
369 def upload_proc(self):
370 self.root.start_progress(_('Uploading subscriptions...'))
372 try:
373 try:
374 self.root.mygpo_client.set_subscriptions([podcast.url
375 for podcast in self.root.podcast_model.get_podcasts()])
376 except Exception, e:
377 self.root.show_message('\n'.join((_('Error on upload:'), unicode(e))))
378 finally:
379 self.root.end_progress()
381 util.run_in_background(lambda: upload_proc(self))
383 @Slot()
384 def saveMyGpoSettings(self):
385 # Update the device settings and upload changes
386 self.root.mygpo_client.create_device()
387 self.root.mygpo_client.flush(now=True)
389 myGpoEnabledChanged = Signal()
391 def getMyGpoEnabled(self):
392 return self.root.config.mygpo.enabled
394 def setMyGpoEnabled(self, enabled):
395 self.root.config.mygpo.enabled = enabled
397 myGpoEnabled = Property(bool, getMyGpoEnabled,
398 setMyGpoEnabled, notify=myGpoEnabledChanged)
400 myGpoUsernameChanged = Signal()
402 def getMyGpoUsername(self):
403 return model.convert(self.root.config.mygpo.username)
405 def setMyGpoUsername(self, username):
406 self.root.config.mygpo.username = username
408 myGpoUsername = Property(unicode, getMyGpoUsername,
409 setMyGpoUsername, notify=myGpoUsernameChanged)
411 myGpoPasswordChanged = Signal()
413 def getMyGpoPassword(self):
414 return model.convert(self.root.config.mygpo.password)
416 def setMyGpoPassword(self, password):
417 self.root.config.mygpo.password = password
419 myGpoPassword = Property(unicode, getMyGpoPassword,
420 setMyGpoPassword, notify=myGpoPasswordChanged)
422 myGpoDeviceCaptionChanged = Signal()
424 def getMyGpoDeviceCaption(self):
425 return model.convert(self.root.config.mygpo.device.caption)
427 def setMyGpoDeviceCaption(self, caption):
428 self.root.config.mygpo.device.caption = caption
430 myGpoDeviceCaption = Property(unicode, getMyGpoDeviceCaption,
431 setMyGpoDeviceCaption, notify=myGpoDeviceCaptionChanged)
433 @Slot(QObject)
434 def podcastContextMenu(self, podcast):
435 menu = []
437 if isinstance(podcast, model.EpisodeSubsetView):
438 menu.append(helper.Action(_('Update all'), 'update-all', podcast))
439 else:
440 menu.append(helper.Action(_('Update'), 'update', podcast))
441 menu.append(helper.Action(_('Mark episodes as old'), 'mark-as-read', podcast))
442 menu.append(helper.Action(_('Rename'), 'rename-podcast', podcast))
443 menu.append(helper.Action(_('Change section'), 'change-section', podcast))
444 menu.append(helper.Action(_('Unsubscribe'), 'unsubscribe', podcast))
446 #menu.append(helper.Action('Force update all', 'force-update-all', podcast))
447 #menu.append(helper.Action('Force update', 'force-update', podcast))
449 self.show_context_menu(menu)
451 def show_context_menu(self, actions):
452 if gpodder.ui.harmattan:
453 actions = filter(lambda a: a.caption != '', actions)
454 self.context_menu_actions = actions
455 self.root.open_context_menu(self.context_menu_actions)
457 def update_subset_stats(self):
458 # This should be called when an episode changes state,
459 # so that all subset views (e.g. "All episodes") can
460 # update its status (i.e. download/new counts, etc..)
461 for podcast in self.root.podcast_model.get_objects():
462 if isinstance(podcast, model.EpisodeSubsetView):
463 podcast.qupdate()
465 def find_episode(self, podcast_url, episode_url):
466 for podcast in self.root.podcast_model.get_podcasts():
467 if podcast.url == podcast_url:
468 for episode in podcast.get_all_episodes():
469 if episode.url == episode_url:
470 return episode
471 return None
473 @Slot()
474 def updateAllPodcasts(self):
475 # Process episode actions received from gpodder.net
476 def merge_proc(self):
477 self.root.start_progress(_('Merging episode actions...'))
479 def find_episode(podcast_url, episode_url, counter):
480 counter['x'] += 1
481 self.root.start_progress(_('Merging episode actions (%d)')
482 % counter['x'])
483 return self.find_episode(podcast_url, episode_url)
485 try:
486 d = {'x': 0} # Used to "remember" the counter inside find_episode
487 self.root.mygpo_client.process_episode_actions(lambda x, y:
488 find_episode(x, y, d))
489 finally:
490 self.root.end_progress()
492 util.run_in_background(lambda: merge_proc(self))
494 for podcast in self.root.podcast_model.get_objects():
495 podcast.qupdate(finished_callback=self.update_subset_stats)
497 @Slot(int)
498 def contextMenuResponse(self, index):
499 assert index < len(self.context_menu_actions)
500 action = self.context_menu_actions[index]
501 if action.action == 'update':
502 action.target.qupdate(finished_callback=self.update_subset_stats)
503 elif action.action == 'force-update':
504 action.target.qupdate(force=True, \
505 finished_callback=self.update_subset_stats)
506 elif action.action == 'update-all':
507 self.updateAllPodcasts()
508 elif action.action == 'force-update-all':
509 for podcast in self.root.podcast_model.get_objects():
510 podcast.qupdate(force=True, finished_callback=self.update_subset_stats)
511 if action.action == 'unsubscribe':
512 def unsubscribe():
513 action.target.remove_downloaded()
514 action.target.delete()
515 self.root.remove_podcast(action.target)
517 self.confirm_action(_('Remove this podcast and episodes?'),
518 _('Unsubscribe'), unsubscribe)
519 elif action.action == 'episode-toggle-new':
520 action.target.toggle_new()
521 self.update_subset_stats()
522 elif action.action == 'episode-toggle-archive':
523 action.target.toggle_archive()
524 self.update_subset_stats()
525 elif action.action == 'episode-delete':
526 self.deleteEpisode(action.target)
527 elif action.action == 'episode-enqueue':
528 self.root.enqueue_episode(action.target)
529 elif action.action == 'mark-as-read':
530 for episode in action.target.get_all_episodes():
531 if not episode.was_downloaded(and_exists=True):
532 episode.mark(is_played=True)
533 action.target.changed.emit()
534 self.update_subset_stats()
535 elif action.action == 'change-section':
536 def section_changer(podcast):
537 section = yield (_('New section name:'), podcast.section,
538 _('Rename'))
539 if section and section != podcast.section:
540 podcast.set_section(section)
541 self.root.resort_podcast_list()
543 self.start_input_dialog(section_changer(action.target))
544 elif action.action == 'rename-podcast':
545 def title_changer(podcast):
546 title = yield (_('New name:'), podcast.title,
547 _('Rename'))
548 if title and title != podcast.title:
549 podcast.rename(title)
550 self.root.resort_podcast_list()
552 self.start_input_dialog(title_changer(action.target))
554 def confirm_action(self, message, affirmative, callback):
555 def confirm(message, affirmative, callback):
556 args = (message, '', affirmative, _('Cancel'), False)
557 if (yield args):
558 callback()
560 self.start_input_dialog(confirm(message, affirmative, callback))
562 def start_input_dialog(self, generator):
563 """Carry out an input dialog with the UI
565 This function takes a generator function as argument
566 which should yield a tuple of arguments for the
567 "show_input_dialog" function (i.e. message, default
568 value, accept and reject message - only the message
569 is mandatory, the other arguments have default values).
571 The generator will receive the user's response as a
572 result of the yield expression. If the user accepted
573 the dialog, a string is returned (the value that has
574 been input), otherwise None is returned.
576 Example usage:
578 def some_function():
579 result = yield ('A simple message', 'default value')
580 if result is None:
581 # user has rejected the dialog
582 else:
583 # user has accepted, new value in "result"
585 start_input_dialog(some_function())
587 assert self.current_input_dialog is None
588 self.current_input_dialog = generator
589 args = generator.next()
590 self.root.show_input_dialog(*args)
592 @Slot(bool, str, bool)
593 def inputDialogResponse(self, accepted, value, is_text):
594 if not is_text:
595 value = accepted
596 elif not accepted:
597 value = None
599 try:
600 self.current_input_dialog.send(value)
601 except StopIteration:
602 # This is expected, as the generator
603 # should only have one yield statement
604 pass
606 self.current_input_dialog = None
608 @Slot(QObject)
609 def downloadEpisode(self, episode):
610 episode.qdownload(self.root.config, self.update_subset_stats)
611 self.root.mygpo_client.on_download([episode])
612 self.root.mygpo_client.flush()
614 @Slot(QObject)
615 def cancelDownload(self, episode):
616 episode.download_task.cancel()
617 episode.download_task.removed_from_list()
619 @Slot(QObject)
620 def deleteEpisode(self, episode):
621 def delete():
622 episode.delete_episode()
623 self.update_subset_stats()
624 self.root.mygpo_client.on_delete([episode])
625 self.root.mygpo_client.flush()
626 self.root.on_episode_deleted(episode)
627 self.root.episode_model.sort()
629 self.confirm_action(_('Delete this episode?'), _('Delete'), delete)
631 @Slot(QObject)
632 def acquireEpisode(self, episode):
633 self.root.add_active_episode(episode)
635 @Slot(QObject)
636 def releaseEpisode(self, episode):
637 self.root.remove_active_episode(episode)
639 @Slot()
640 def contextMenuClosed(self):
641 self.context_menu_actions = []
643 @Slot(QObject)
644 def episodeContextMenu(self, episode):
645 menu = []
647 toggle_new = _('Mark as old') if episode.is_new else _('Mark as new')
648 menu.append(helper.Action(toggle_new, 'episode-toggle-new', episode))
650 toggle_archive = _('Allow deletion') if episode.archive else _('Archive')
651 menu.append(helper.Action(toggle_archive, 'episode-toggle-archive', episode))
653 if episode.state != gpodder.STATE_DELETED:
654 menu.append(helper.Action(_('Delete'), 'episode-delete', episode))
656 menu.append(helper.Action(_('Add to play queue'), 'episode-enqueue', episode))
658 self.show_context_menu(menu)
660 @Slot('QVariant')
661 def addSubscriptions(self, urls):
662 def not_yet_subscribed(url):
663 for podcast in self.root.podcast_model.get_objects():
664 if isinstance(podcast, model.EpisodeSubsetView):
665 continue
667 if podcast.url == url:
668 logger.info('Already subscribed: %s', url)
669 return False
671 return True
673 urls = map(util.normalize_feed_url, urls)
674 urls = filter(not_yet_subscribed, urls)
676 def subscribe_proc(self, urls):
677 self.root.start_progress(_('Adding podcasts...'))
678 try:
679 for idx, url in enumerate(urls):
680 print idx, url
681 self.root.start_progress(_('Adding podcasts...') + ' (%d/%d)' % (idx, len(urls)))
682 try:
683 podcast = self.root.model.load_podcast(url=url, create=True,
684 max_episodes=self.root.config.max_episodes_per_feed)
685 podcast.save()
686 self.root.insert_podcast(model.QPodcast(podcast))
687 except Exception, e:
688 logger.warn('Cannot add pocast: %s', e)
689 # XXX: Visual feedback in the QML UI
690 finally:
691 self.root.end_progress()
693 util.run_in_background(lambda: subscribe_proc(self, urls))
695 @Slot()
696 def currentEpisodeChanging(self):
697 self.root.save_pending_data()
699 @Slot()
700 def quit(self):
701 self.root.quit.emit()
703 @Slot()
704 def switcher(self):
705 if gpodder.ui.harmattan:
706 self.root.view.showMinimized()
707 elif gpodder.ui.fremantle:
708 os.system('dbus-send /com/nokia/hildon_desktop '+
709 'com.nokia.hildon_desktop.exit_app_view')
710 else:
711 self.root.view.showMinimized()
714 class gPodderListModel(QAbstractListModel):
715 def __init__(self, objects=None):
716 QAbstractListModel.__init__(self)
717 if objects is None:
718 objects = []
719 self._objects = objects
720 self.setRoleNames({0: 'modelData', 1: 'section'})
722 def sort(self):
723 # Unimplemented for the generic list model
724 self.reset()
726 def insert_object(self, o):
727 self._objects.append(o)
728 self.sort()
730 def remove_object(self, o):
731 self._objects.remove(o)
732 self.reset()
734 def set_objects(self, objects):
735 self._objects = objects
736 self.sort()
738 def get_objects(self):
739 return self._objects
741 def get_object(self, index):
742 return self._objects[index.row()]
744 def rowCount(self, parent=QModelIndex()):
745 return len(self.get_objects())
747 def data(self, index, role):
748 if index.isValid():
749 if role == 0:
750 return self.get_object(index)
751 elif role == 1:
752 return self.get_object(index).qsection
753 return None
755 class gPodderPodcastListModel(gPodderListModel):
756 def set_podcasts(self, db, podcasts):
757 views = [
758 model.EpisodeSubsetView(db, self, _('All episodes'), ''),
760 self.set_objects(views + podcasts)
762 def get_podcasts(self):
763 return filter(lambda podcast: isinstance(podcast, model.QPodcast),
764 self.get_objects())
766 def sort(self):
767 self._objects = sorted(self._objects, key=model.QPodcast.sort_key)
768 self.reset()
770 class gPodderEpisodeListModel(gPodderListModel):
771 def __init__(self, config):
772 gPodderListModel.__init__(self)
773 self._filter = config.ui.qml.state.episode_list_filter
774 self._filtered = []
775 self._is_subset_view = False
777 self._config = config
778 config.add_observer(self._on_config_changed)
780 is_subset_view_changed = Signal()
782 def get_is_subset_view(self):
783 return self._is_subset_view
785 def set_is_subset_view(self, is_subset_view):
786 if is_subset_view != self.is_subset_view:
787 self._is_subset_view = is_subset_view
788 self.is_subset_view_changed.emit()
790 is_subset_view = Property(bool, get_is_subset_view,
791 set_is_subset_view, notify=is_subset_view_changed)
793 def _on_config_changed(self, name, old_value, new_value):
794 if name == 'ui.qml.state.episode_list_filter':
795 self._filter = new_value
796 self.sort()
798 def sort(self):
799 caption, eql = EPISODE_LIST_FILTERS[self._filter]
801 if eql is None:
802 self._filtered = self._objects
803 else:
804 eql = query.EQL(eql)
805 match = lambda episode: eql.match(episode._episode)
806 self._filtered = filter(match, self._objects)
808 self.reset()
810 def get_objects(self):
811 return self._filtered
813 def get_object(self, index):
814 return self._filtered[index.row()]
816 @Slot(int, result=QObject)
817 def get_object_by_index(self, index):
818 return self._filtered[int(index)]
820 @Slot(result=int)
821 def getFilter(self):
822 return self._filter
824 @Slot(int)
825 def setFilter(self, filter_index):
826 self._config.ui.qml.state.episode_list_filter = filter_index
829 def QML(filename):
830 for folder in gpodder.ui_folders:
831 filename = os.path.join(folder, filename)
832 if os.path.exists(filename):
833 return filename
835 class DeclarativeView(QDeclarativeView):
836 def __init__(self):
837 QDeclarativeView.__init__(self)
838 self.setAttribute(Qt.WA_OpaquePaintEvent)
839 self.setAttribute(Qt.WA_NoSystemBackground)
840 self.viewport().setAttribute(Qt.WA_OpaquePaintEvent)
841 self.viewport().setAttribute(Qt.WA_NoSystemBackground)
843 closing = Signal()
845 def closeEvent(self, event):
846 self.closing.emit()
847 event.ignore()
849 class qtPodder(QObject):
850 def __init__(self, args, gpodder_core, dbus_bus_name):
851 QObject.__init__(self)
853 self.dbus_bus_name = dbus_bus_name
854 # TODO: Expose the same D-Bus API as the Gtk UI D-Bus object (/gui)
855 # TODO: Create a gpodder.dbusproxy.DBusPodcastsProxy object (/podcasts)
857 # Enable OpenGL rendering without requiring QtOpenGL
858 # On Harmattan we let the system choose the best graphicssystem
859 if '-graphicssystem' not in args and not gpodder.ui.harmattan:
860 if gpodder.ui.fremantle:
861 args += ['-graphicssystem', 'opengl']
862 elif not gpodder.win32:
863 args += ['-graphicssystem', 'raster']
865 self.app = QApplication(args)
866 signal.signal(signal.SIGINT, signal.SIG_DFL)
867 self.quit.connect(self.on_quit)
869 self.core = gpodder_core
870 self.config = self.core.config
871 self.db = self.core.db
872 self.model = self.core.model
874 self.config_proxy = ConfigProxy(self.config)
876 # Initialize the gpodder.net client
877 self.mygpo_client = my.MygPoClient(self.config)
879 gpodder.user_extensions.on_ui_initialized(self.model,
880 self.extensions_podcast_update_cb,
881 self.extensions_episode_download_cb)
883 self.view = DeclarativeView()
884 self.view.closing.connect(self.on_quit)
885 self.view.setResizeMode(QDeclarativeView.SizeRootObjectToView)
887 self.controller = Controller(self)
888 self.media_buttons_handler = helper.MediaButtonsHandler()
889 self.tracker_miner_config = helper.TrackerMinerConfig()
890 self.podcast_model = gPodderPodcastListModel()
891 self.episode_model = gPodderEpisodeListModel(self.config)
892 self.last_episode = None
894 # A dictionary of episodes that are currently active
895 # in some way (i.e. playing back or downloading)
896 self.active_episode_wrappers = {}
898 engine = self.view.engine()
900 # Maemo 5: Experimental Qt Mobility packages are installed in /opt
901 if gpodder.ui.fremantle:
902 for path in ('/opt/qtm11/imports', '/opt/qtm12/imports'):
903 engine.addImportPath(path)
904 elif gpodder.win32:
905 for path in (r'C:\QtSDK\Desktop\Qt\4.7.4\msvc2008\imports',):
906 engine.addImportPath(path)
908 # Add the cover art image provider
909 self.cover_provider = images.LocalCachedImageProvider()
910 engine.addImageProvider('cover', self.cover_provider)
912 root_context = self.view.rootContext()
913 root_context.setContextProperty('controller', self.controller)
914 root_context.setContextProperty('configProxy', self.config_proxy)
915 root_context.setContextProperty('mediaButtonsHandler',
916 self.media_buttons_handler)
917 root_context.setContextProperty('trackerMinerConfig',
918 self.tracker_miner_config)
920 # Load the QML UI (this could take a while...)
921 self.view.setSource(QUrl.fromLocalFile(QML('main_default.qml')))
923 # Proxy to the "main" QML object for direct access to Qt Properties
924 self.main = helper.QObjectProxy(self.view.rootObject().property('main'))
926 self.main.podcastModel = self.podcast_model
927 self.main.episodeModel = self.episode_model
929 self.view.setWindowTitle('gPodder')
931 if gpodder.ui.harmattan:
932 self.view.showFullScreen()
933 elif gpodder.ui.fremantle:
934 self.view.setAttribute(Qt.WA_Maemo5AutoOrientation, True)
935 self.view.showFullScreen()
936 else:
937 # On the Desktop, scale to fit my small laptop screen..
938 desktop = self.app.desktop()
939 if desktop.height() < 1000:
940 FACTOR = .8
941 self.view.scale(FACTOR, FACTOR)
942 size = self.view.size()
943 size *= FACTOR
944 self.view.resize(size)
945 self.view.show()
947 self.do_start_progress.connect(self.on_start_progress)
948 self.do_end_progress.connect(self.on_end_progress)
949 self.do_show_message.connect(self.on_show_message)
951 self.load_podcasts()
953 def add_active_episode(self, episode):
954 self.active_episode_wrappers[episode.id] = episode
955 episode.episode_wrapper_refcount += 1
957 def remove_active_episode(self, episode):
958 episode.episode_wrapper_refcount -= 1
959 if episode.episode_wrapper_refcount == 0:
960 del self.active_episode_wrappers[episode.id]
962 def load_last_episode(self):
963 last_episode = None
964 last_podcast = None
965 for podcast in self.podcast_model.get_podcasts():
966 for episode in podcast.get_all_episodes():
967 if not episode.last_playback:
968 continue
969 if last_episode is None or \
970 episode.last_playback > last_episode.last_playback:
971 last_episode = episode
972 last_podcast = podcast
974 if last_episode is not None:
975 self.last_episode = self.wrap_episode(last_podcast, last_episode)
976 # FIXME: Send last episode to player
977 #self.select_episode(self.last_episode)
979 def on_episode_deleted(self, episode):
980 # Remove episode from play queue (if it's in there)
981 self.main.removeQueuedEpisode(episode)
983 # If the episode that has been deleted is currently
984 # being played back (or paused), stop playback now.
985 if self.main.currentEpisode == episode:
986 self.main.togglePlayback(None)
988 def enqueue_episode(self, episode):
989 self.main.enqueueEpisode(episode)
991 def run(self):
992 return self.app.exec_()
994 quit = Signal()
996 def on_quit(self):
997 # Make sure the audio playback is stopped immediately
998 self.main.togglePlayback(None)
999 self.save_pending_data()
1000 self.view.hide()
1001 self.core.shutdown()
1002 self.app.quit()
1004 do_show_message = Signal(unicode)
1006 @Slot(unicode)
1007 def on_show_message(self, message):
1008 self.main.showMessage(message)
1010 def show_message(self, message):
1011 self.do_show_message.emit(message)
1013 def show_input_dialog(self, message, value='', accept=_('OK'),
1014 reject=_('Cancel'), is_text=True):
1015 self.main.showInputDialog(message, value, accept, reject, is_text)
1017 def open_context_menu(self, items):
1018 self.main.openContextMenu(items)
1020 do_start_progress = Signal(str)
1022 @Slot(str)
1023 def on_start_progress(self, text):
1024 self.main.startProgress(text)
1026 def start_progress(self, text=_('Please wait...')):
1027 self.do_start_progress.emit(text)
1029 do_end_progress = Signal()
1031 @Slot()
1032 def on_end_progress(self):
1033 self.main.endProgress()
1035 def end_progress(self):
1036 self.do_end_progress.emit()
1038 def resort_podcast_list(self):
1039 self.podcast_model.sort()
1041 def insert_podcast(self, podcast):
1042 self.podcast_model.insert_object(podcast)
1043 self.mygpo_client.on_subscribe([podcast.url])
1044 self.mygpo_client.flush()
1046 def remove_podcast(self, podcast):
1047 # Remove queued episodes for this specific podcast
1048 self.main.removeQueuedEpisodesForPodcast(podcast)
1050 if self.main.currentEpisode is not None:
1051 # If the currently-playing episode is in the podcast
1052 # that is to be deleted, stop playback immediately.
1053 if self.main.currentEpisode.qpodcast == podcast:
1054 self.main.togglePlayback(None)
1055 self.podcast_model.remove_object(podcast)
1056 self.mygpo_client.on_unsubscribe([podcast.url])
1057 self.mygpo_client.flush()
1059 def load_podcasts(self):
1060 podcasts = map(model.QPodcast, self.model.get_podcasts())
1061 self.podcast_model.set_podcasts(self.db, podcasts)
1063 def wrap_episode(self, podcast, episode):
1064 try:
1065 return self.active_episode_wrappers[episode.id]
1066 except KeyError:
1067 return model.QEpisode(self, podcast, episode)
1069 def select_podcast(self, podcast):
1070 if isinstance(podcast, model.QPodcast):
1071 # Normal QPodcast instance
1072 wrap = functools.partial(self.wrap_episode, podcast)
1073 objects = podcast.get_all_episodes()
1074 self.episode_model.set_is_subset_view(False)
1075 else:
1076 # EpisodeSubsetView
1077 wrap = lambda args: self.wrap_episode(*args)
1078 objects = podcast.get_all_episodes_with_podcast()
1079 self.episode_model.set_is_subset_view(True)
1081 self.episode_model.set_objects(map(wrap, objects))
1082 self.main.state = 'episodes'
1084 def save_pending_data(self):
1085 current_ep = self.main.currentEpisode
1086 if isinstance(current_ep, model.QEpisode):
1087 current_ep.save()
1089 def podcast_to_qpodcast(self, podcast):
1090 podcasts = filter(lambda p: p._podcast == podcast,
1091 self.podcast_model.get_podcasts())
1092 assert len(podcasts) <= 1
1093 if podcasts:
1094 return podcasts[0]
1095 return None
1097 def extensions_podcast_update_cb(self, podcast):
1098 logger.debug('extensions_podcast_update_cb(%s)', podcast)
1099 try:
1100 qpodcast = self.podcast_to_qpodcast(podcast)
1101 if qpodcast is not None:
1102 qpodcast.qupdate(
1103 finished_callback=self.controller.update_subset_stats)
1104 except Exception, e:
1105 logger.exception('extensions_podcast_update_cb(%s): %s', podcast, e)
1107 def extensions_episode_download_cb(self, episode):
1108 logger.debug('extensions_episode_download_cb(%s)', episode)
1109 try:
1110 qpodcast = self.podcast_to_qpodcast(episode.channel)
1111 qepisode = self.wrap_episode(qpodcast, episode)
1112 self.controller.downloadEpisode(qepisode)
1113 except Exception, e:
1114 logger.exception('extensions_episode_download_cb(%s): %s', episode, e)
1116 def main(args):
1117 try:
1118 dbus_main_loop = DBusGMainLoop(set_as_default=True)
1119 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
1121 bus_name = dbus.service.BusName(gpodder.dbus_bus_name,
1122 bus=gpodder.dbus_session_bus)
1123 except dbus.exceptions.DBusException, dbe:
1124 logger.warn('Cannot get "on the bus".', exc_info=True)
1125 bus_name = None
1127 gui = qtPodder(args, core.Core(), bus_name)
1128 return gui.run()