1
# Miro - an RSS based video player application
2
# Copyright (C) 2005-2010 Participatory Culture Foundation
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18
# In addition, as a special exception, the copyright holders give
19
# permission to link the code of portions of this program with the OpenSSL
22
# You must obey the GNU General Public License in all respects for all of
23
# the code used other than OpenSSL. If you modify file(s) with this
24
# exception, you may extend this exception to your version of the file(s),
25
# but you are not obligated to do so. If you do not wish to do so, delete
26
# this exception statement from your version. If you delete this exception
27
# statement from all source files in the program, then also delete it here.
29
"""``miro.frontends.widgets.application`` -- The application module
30
holds :class:`Application` and the portable code to handle the high
31
level running of the Miro application.
35
* :class:`InfoUpdater` -- tracks channel/item updates from the backend
36
and sends the information to the frontend
37
* :class:`InfoUpdaterCallbackList` -- tracks the list of callbacks for
39
* :class:`WidgetsMessageHandler` -- frontend message handler
40
* :class:`FrontendStatesStore` -- stores state of the frontend
49
from miro import config
50
from miro import crashreport
51
from miro import prefs
53
from miro import startup
54
from miro import signals
55
from miro import messages
56
from miro import eventloop
57
from miro import videoconversion
58
from miro.gtcache import gettext as _
59
from miro.gtcache import ngettext
60
from miro.frontends.widgets import dialogs
61
from miro.frontends.widgets import newsearchfeed
62
from miro.frontends.widgets import newfeed
63
from miro.frontends.widgets import newfolder
64
from miro.frontends.widgets import itemedit
65
from miro.frontends.widgets import addtoplaylistdialog
66
from miro.frontends.widgets import removefeeds
67
from miro.frontends.widgets import diagnostics
68
from miro.frontends.widgets import crashdialog
69
from miro.frontends.widgets import itemlist
70
from miro.frontends.widgets import itemlistcontroller
71
from miro.frontends.widgets import prefpanel
72
from miro.frontends.widgets import displays
73
from miro.frontends.widgets import menus
74
from miro.frontends.widgets import tablistmanager
75
from miro.frontends.widgets import playback
76
from miro.frontends.widgets import search
77
from miro.frontends.widgets import rundialog
78
from miro.frontends.widgets import watchedfolders
79
from miro.frontends.widgets import quitconfirmation
80
from miro.frontends.widgets import firsttimedialog
81
from miro.frontends.widgets.widgetconst import MAX_VOLUME
82
from miro.frontends.widgets.window import MiroWindow
83
from miro.plat.frontends.widgets.threads import call_on_ui_thread
84
from miro.plat.frontends.widgets.widgetset import Rect
85
from miro.plat.utils import FilenameType
86
from miro import fileutil
89
"""This class holds the portable application code. Each platform
90
extends this class with a platform-specific version.
94
self.ignore_errors = False
95
self.message_handler = WidgetsMessageHandler()
96
self.default_guide_info = None
98
self.ui_initialized = False
99
messages.FrontendMessage.install_handler(self.message_handler)
100
app.info_updater = InfoUpdater()
101
app.watched_folder_manager = watchedfolders.WatchedFolderManager()
102
self.download_count = 0
103
self.paused_count = 0
104
self.unwatched_count = 0
106
def exception_handler(self, type, value, traceback):
107
report = crashreport.format_crash_report("in frontend thread",
108
exc_info=(type, value, traceback), details=None)
109
self.handle_crash_report(report)
112
"""Connects to signals, installs handlers, and calls :meth:`startup`
113
from the :mod:`miro.startup` module.
115
self.connect_to_signals()
116
startup.install_movies_directory_gone_handler(self.handle_movies_directory_gone)
117
startup.install_first_time_handler(self.handle_first_time)
120
def startup_ui(self):
121
"""Starts up the widget ui by sending a bunch of messages
122
requesting data from the backend. Also sets up managers,
123
initializes the ui, and displays the :class:`MiroWindow`.
125
# Send a couple messages to the backend, when we get responses,
126
# WidgetsMessageHandler() will call build_window()
127
messages.TrackGuides().send_to_backend()
128
messages.QuerySearchInfo().send_to_backend()
129
messages.TrackWatchedFolders().send_to_backend()
130
messages.QueryFrontendState().send_to_backend()
131
messages.TrackChannels().send_to_backend()
133
app.item_list_controller_manager = \
134
itemlistcontroller.ItemListControllerManager()
135
app.display_manager = displays.DisplayManager()
136
app.menu_manager = menus.MenuStateManager()
137
app.playback_manager = playback.PlaybackManager()
138
app.search_manager = search.SearchManager()
139
app.inline_search_memory = search.InlineSearchMemory()
140
app.tab_list_manager = tablistmanager.TabListManager()
141
self.ui_initialized = True
143
self.window = MiroWindow(config.get(prefs.LONG_APP_NAME),
144
self.get_main_window_dimensions())
145
self.window.connect_weak('key-press', self.on_key_press)
146
self._window_show_callback = self.window.connect_weak('show',
149
def on_window_show(self, window):
150
m = messages.FrontendStarted()
151
# Use call_on_ui_thread to introduce a bit of a delay. On GTK it uses
152
# gobject.add_idle(), so it won't run until the GUI processing is
153
# idle. I'm (BDK) not sure what happens on Cocoa, but it's worth a
155
call_on_ui_thread(m.send_to_backend)
156
self.window.disconnect(self._window_show_callback)
157
del self._window_show_callback
159
def on_key_press(self, window, key, mods):
160
if (app.playback_manager.is_playing and
161
app.playback_manager.detached_window is None):
162
return playback.handle_key_press(key, mods)
164
def handle_movies_directory_gone(self, continue_callback):
165
call_on_ui_thread(self._handle_movies_directory_gone, continue_callback)
167
def _handle_movies_directory_gone(self, continue_callback):
168
title = _("Movies directory gone")
170
"%(shortappname)s can't use the primary video directory "
173
"%(moviedirectory)s\n"
175
"This may be because it is located on an external drive that "
176
"is not connected, is a directory that %(shortappname)s does "
177
"not have write permission to, or there is something that is "
178
"not a directory at that path.\n"
180
"If you continue, the primary video directory will be reset "
181
"to a location on this drive. If you had videos downloaded "
182
"this will cause %(shortappname)s to lose details about those "
185
"If you quit, then you can connect the drive or otherwise "
186
"fix the problem and relaunch %(shortappname)s.",
187
{"shortappname": config.get(prefs.SHORT_APP_NAME),
188
"moviedirectory": config.get(prefs.MOVIES_DIRECTORY)}
190
ret = dialogs.show_choice_dialog(title, description,
191
[dialogs.BUTTON_CONTINUE, dialogs.BUTTON_QUIT])
193
if ret == dialogs.BUTTON_QUIT:
199
def handle_first_time(self, continue_callback):
200
call_on_ui_thread(lambda: self._handle_first_time(continue_callback))
202
def _handle_first_time(self, continue_callback):
203
startup.mark_first_time()
204
firsttimedialog.FirstTimeDialog(continue_callback).run()
206
def build_window(self):
207
app.tab_list_manager.populate_tab_list()
208
for info in self.message_handler.initial_guides:
209
app.tab_list_manager.site_list.add(info)
210
app.tab_list_manager.site_list.model_changed()
211
app.tab_list_manager.handle_startup_selection()
212
videobox = self.window.videobox
213
videobox.volume_slider.set_value(config.get(prefs.VOLUME_LEVEL))
214
videobox.volume_slider.connect('changed', self.on_volume_change)
215
videobox.volume_slider.connect('released', self.on_volume_set)
216
videobox.volume_muter.connect('clicked', self.on_volume_mute)
217
videobox.controls.play.connect('clicked', self.on_play_clicked)
218
videobox.controls.stop.connect('clicked', self.on_stop_clicked)
219
videobox.controls.forward.connect('clicked', self.on_forward_clicked)
220
videobox.controls.forward.connect('held-down', self.on_fast_forward)
221
videobox.controls.forward.connect('released', self.on_stop_fast_playback)
222
videobox.controls.previous.connect('clicked', self.on_previous_clicked)
223
videobox.controls.previous.connect('held-down', self.on_fast_backward)
224
videobox.controls.previous.connect('released', self.on_stop_fast_playback)
225
videobox.controls.fullscreen.connect('clicked', self.on_fullscreen_clicked)
227
messages.TrackPlaylists().send_to_backend()
228
messages.TrackDownloadCount().send_to_backend()
229
messages.TrackPausedCount().send_to_backend()
230
messages.TrackNewVideoCount().send_to_backend()
231
messages.TrackNewAudioCount().send_to_backend()
232
messages.TrackUnwatchedCount().send_to_backend()
234
def get_main_window_dimensions(self):
235
"""Override this to provide platform-specific Main Window dimensions.
239
return Rect(100, 300, 800, 600)
241
def get_right_width(self):
242
"""Returns the width of the right side of the splitter.
244
return self.window.get_frame().get_width() - self.window.splitter.get_left_width()
246
def on_volume_change(self, slider, volume):
247
app.playback_manager.set_volume(volume)
249
def on_volume_mute(self, button=None):
250
slider = self.window.videobox.volume_slider
251
if slider.get_value() == 0:
252
value = getattr(self, "previous_volume_value", 0.75)
254
self.previous_volume_value = slider.get_value()
257
slider.set_value(value)
258
self.on_volume_change(slider, value)
259
self.on_volume_set(slider)
261
def on_volume_set(self, slider):
262
self.on_volume_value_set(slider.get_value())
264
def on_volume_value_set(self, value):
265
config.set(prefs.VOLUME_LEVEL, value)
268
def on_play_clicked(self, button=None):
269
if app.playback_manager.is_playing:
270
app.playback_manager.play_pause()
272
self.play_selection()
274
def play_selection(self):
275
app.item_list_controller_manager.play_selection()
277
def on_stop_clicked(self, button=None):
278
app.playback_manager.stop()
280
def on_forward_clicked(self, button=None):
281
app.playback_manager.play_next_item()
283
def on_previous_clicked(self, button=None):
284
app.playback_manager.play_prev_item(from_user=True)
286
def on_skip_forward(self):
287
app.playback_manager.skip_forward()
289
def on_skip_backward(self):
290
app.playback_manager.skip_backward()
292
def on_fast_forward(self, button=None):
293
app.playback_manager.fast_forward()
295
def on_fast_backward(self, button=None):
296
app.playback_manager.fast_backward()
298
def on_stop_fast_playback(self, button):
299
app.playback_manager.stop_fast_playback()
301
def on_fullscreen_clicked(self, button=None):
302
app.playback_manager.fullscreen()
304
def on_toggle_detach_clicked(self, button=None):
305
app.playback_manager.toggle_detached_mode()
308
slider = self.window.videobox.volume_slider
309
v = min(slider.get_value() + 0.05, MAX_VOLUME)
311
self.on_volume_change(slider, v)
312
self.on_volume_set(slider)
314
def down_volume(self):
315
slider = self.window.videobox.volume_slider
316
v = max(slider.get_value() - 0.05, 0.0)
318
self.on_volume_change(slider, v)
319
self.on_volume_set(slider)
321
def share_item(self, item):
322
share_items = {"file_url": item.file_url,
323
"item_name": item.name.encode('utf-8')}
325
share_items["feed_url"] = item.feed_url
326
query_string = "&".join(["%s=%s" % (key, urllib.quote(val)) for key, val in share_items.items()])
327
share_url = "%s/item/?%s" % (config.get(prefs.SHARE_URL), query_string)
328
self.open_url(share_url)
330
def share_feed(self):
331
t, channel_infos = app.tab_list_manager.get_selection()
332
if t in ('feed', 'audio-feed') and len(channel_infos) == 1:
333
ci = channel_infos[0]
334
share_items = {"feed_url": ci.base_href}
335
query_string = "&".join(["%s=%s" % (key, urllib.quote(val)) for key, val in share_items.items()])
336
share_url = "%s/feed/?%s" % (config.get(prefs.SHARE_URL), query_string)
337
self.open_url(share_url)
339
def delete_backup_databases(self):
340
dbs = app.db.get_backup_databases()
344
def check_then_reveal_file(self, filename):
345
if not os.path.exists(filename):
346
basename = os.path.basename(filename)
347
dialogs.show_message(
348
_("Error Revealing File"),
349
_("The file %(filename)s was deleted from outside %(appname)s.",
350
{"filename": basename,
351
"appname": config.get(prefs.SHORT_APP_NAME)}),
352
dialogs.WARNING_MESSAGE)
354
self.reveal_file(filename)
356
def reveal_conversions_folder(self):
357
self.reveal_file(videoconversion.get_conversions_folder())
359
def open_video(self):
360
title = _('Open Files...')
361
filenames = dialogs.ask_for_open_pathname(title, select_multiple=True)
366
filenames_good = [mem for mem in filenames if os.path.isfile(mem)]
367
if len(filenames_good) != len(filenames):
368
filenames_bad = set(filenames) - set(filenames_good)
369
if len(filenames_bad) == 1:
370
filename = list(filenames_bad)[0]
371
dialogs.show_message(_('Open Files - Error'),
372
_('File %(filename)s does not exist.',
373
{"filename": filename}),
374
dialogs.WARNING_MESSAGE)
376
dialogs.show_message(_('Open Files - Error'),
377
_('The following files do not exist:') +
378
'\n' + '\n'.join(filenames_bad),
379
dialogs.WARNING_MESSAGE)
381
if len(filenames_good) == 1:
382
messages.OpenIndividualFile(filenames_good[0]).send_to_backend()
384
messages.OpenIndividualFiles(filenames_good).send_to_backend()
386
def ask_for_url(self, title, description, error_title, error_description):
387
"""Ask the user to enter a url in a TextEntry box.
389
If the URL the user enters is invalid, she will be asked to re-enter
390
it again. This process repeats until the user enters a valid URL, or
393
The initial text for the TextEntry will be the clipboard contents (if
396
text = app.widgetapp.get_clipboard_text()
397
if text is not None and feed.validate_feed_url(text):
398
text = feed.normalize_feed_url(text)
402
text = dialogs.ask_for_string(title, description, initial_text=text)
406
normalized_url = feed.normalize_feed_url(text)
407
if feed.validate_feed_url(normalized_url):
408
return normalized_url
411
description = error_description
413
def new_download(self):
414
url = self.ask_for_url( _('New Download'),
415
_('Enter the URL of the item to download'),
416
_('New Download - Invalid URL'),
417
_('The address you entered is not a valid url.\nPlease check the URL and try again.\n\nEnter the URL of the item to download'))
419
messages.DownloadURL(url).send_to_backend()
421
def check_version(self):
422
# this gets called by the backend, so it has to send a message to
423
# the frontend to open a dialog
424
def up_to_date_callback():
425
messages.MessageToUser(_("%(appname)s is up to date",
426
{'appname': config.get(prefs.SHORT_APP_NAME)}),
427
_("%(appname)s is up to date!",
428
{'appname': config.get(prefs.SHORT_APP_NAME)})).send_to_frontend()
430
messages.CheckVersion(up_to_date_callback).send_to_backend()
432
def preferences(self):
433
prefpanel.show_window()
435
def remove_items(self, selection=None):
437
selection = app.item_list_controller_manager.get_selection()
438
selection = [s for s in selection if s.downloaded]
443
external_count = len([s for s in selection if s.is_external])
444
folder_count = len([s for s in selection if s.is_container_item])
445
total_count = len(selection)
447
if total_count == 1 and external_count == folder_count == 0:
448
messages.DeleteVideo(selection[0].id).send_to_backend()
451
title = ngettext('Remove item', 'Remove items', total_count)
453
if external_count > 0:
454
description = ngettext(
455
'One of these items was not downloaded from a feed. '
456
'Would you like to delete it or just remove it from the Library?',
458
'Some of these items were not downloaded from a feed. '
459
'Would you like to delete them or just remove them from the Library?',
464
description += '\n\n' + ngettext(
465
'One of these items is a folder. Delete/Removeing a '
466
"folder will delete/remove it's contents",
468
'Some of these items are folders. Delete/Removeing a '
469
"folder will delete/remove it's contents",
473
ret = dialogs.show_choice_dialog(title, description,
474
[dialogs.BUTTON_REMOVE_ENTRY,
475
dialogs.BUTTON_DELETE_FILE,
476
dialogs.BUTTON_CANCEL])
479
description = ngettext(
480
'Are you sure you want to delete the item?',
481
'Are you sure you want to delete all %(count)d items?',
483
{"count": total_count}
486
description += '\n\n' + ngettext(
487
'One of these items is a folder. Deleting a '
488
"folder will delete it's contents",
490
'Some of these items are folders. Deleting a '
491
"folder will delete it's contents",
495
ret = dialogs.show_choice_dialog(title, description,
496
[dialogs.BUTTON_DELETE,
497
dialogs.BUTTON_CANCEL])
499
if ret in (dialogs.BUTTON_OK, dialogs.BUTTON_DELETE_FILE,
500
dialogs.BUTTON_DELETE):
501
for mem in selection:
502
messages.DeleteVideo(mem.id).send_to_backend()
504
elif ret == dialogs.BUTTON_REMOVE_ENTRY:
505
for mem in selection:
507
messages.RemoveVideoEntry(mem.id).send_to_backend()
509
messages.DeleteVideo(mem.id).send_to_backend()
512
selection = app.item_list_controller_manager.get_selection()
513
selection = [s for s in selection if s.downloaded]
518
item_info = selection[0]
520
change_dict = itemedit.run_dialog(item_info)
522
messages.EditItem(item_info.id, change_dict).send_to_backend()
525
selection = app.item_list_controller_manager.get_selection()
526
selection = [s for s in selection if s.downloaded]
531
title = _('Save Item As...')
532
filename = selection[0].video_path
533
filename = os.path.basename(filename)
534
filename = dialogs.ask_for_save_pathname(title, filename)
539
messages.SaveItemAs(selection[0].id, filename).send_to_backend()
541
def convert_items(self, converter_id):
542
selection = app.item_list_controller_manager.get_selection()
543
for item_info in selection:
544
videoconversion.convert(converter_id, item_info)
546
def copy_item_url(self):
547
selection = app.item_list_controller_manager.get_selection()
548
selection = [s for s in selection if s.downloaded]
550
if not selection and app.playback_manager.is_playing:
551
selection = [app.playback_manager.get_playing_item()]
556
selection = selection[0]
557
if selection.file_url:
558
app.widgetapp.copy_text_to_clipboard(selection.file_url)
560
def add_new_feed(self):
561
url, section = newfeed.run_dialog()
563
messages.NewFeed(url, section).send_to_backend()
565
def add_new_search_feed(self):
566
data = newsearchfeed.run_dialog()
571
if data[0] == "feed":
572
messages.NewFeedSearchFeed(data[1], data[2], data[3]).send_to_backend()
573
elif data[0] == "search_engine":
574
messages.NewFeedSearchEngine(data[1], data[2], data[3]).send_to_backend()
575
elif data[0] == "url":
576
messages.NewFeedSearchURL(data[1], data[2], data[3]).send_to_backend()
578
def add_new_feed_folder(self, add_selected=False, default_type='feed'):
579
name, section = newfolder.run_dialog(default_type)
582
t, infos = app.tab_list_manager.get_selection()
583
child_ids = [info.id for info in infos]
586
messages.NewFeedFolder(name, section, child_ids).send_to_backend()
588
def add_new_guide(self):
589
url = self.ask_for_url(_('Add Website'),
590
_('Enter the URL of the website to add'),
591
_('Add Website - Invalid URL'),
592
_("The address you entered is not a valid url.\n"
593
"Please check the URL and try again.\n\n"
594
"Enter the URL of the website to add"))
597
messages.NewGuide(url).send_to_backend()
599
def remove_something(self):
600
t, infos = app.tab_list_manager.get_selection_and_children()
601
if t in ('feed', 'audio-feed'):
602
self.remove_feeds(infos)
604
self.remove_sites(infos)
606
def remove_current_feed(self):
607
t, channel_infos = app.tab_list_manager.get_selection_and_children()
608
if t in ('feed', 'audio-feed'):
609
self.remove_feeds(channel_infos)
611
def remove_feeds(self, channel_infos):
612
has_watched_feeds = False
613
downloaded_items = False
614
downloading_items = False
616
for ci in channel_infos:
617
if not ci.is_directory_feed:
618
if ci.num_downloaded > 0:
619
downloaded_items = True
621
if ci.has_downloading:
622
downloading_items = True
624
has_watched_feeds = True
626
ret = removefeeds.run_dialog(channel_infos, downloaded_items,
627
downloading_items, has_watched_feeds)
629
for ci in channel_infos:
630
if ci.is_directory_feed:
631
messages.SetWatchedFolderVisible(ci.id, False).send_to_backend()
633
messages.DeleteFeed(ci.id, ci.is_folder,
634
ret[removefeeds.KEEP_ITEMS]
637
def update_selected_feeds(self):
638
t, channel_infos = app.tab_list_manager.get_selection()
639
if t in ('feed', 'audio-feed'):
640
for ci in channel_infos:
642
messages.UpdateFeedFolder(ci.id).send_to_backend()
644
messages.UpdateFeed(ci.id).send_to_backend()
646
def update_all_feeds(self):
647
messages.UpdateAllFeeds().send_to_backend()
649
def import_feeds(self):
650
title = _('Import OPML File')
651
filename = dialogs.ask_for_open_pathname(title,
652
filters=[(_('OPML Files'), ['opml'])])
656
if os.path.isfile(filename):
657
messages.ImportFeeds(filename).send_to_backend()
659
dialogs.show_message(_('Import OPML File - Error'),
660
_('File %(filename)s does not exist.',
661
{"filename": filename}),
662
dialogs.WARNING_MESSAGE)
664
def export_feeds(self):
665
title = _('Export OPML File')
666
slug = config.get(prefs.SHORT_APP_NAME).lower()
667
slug = slug.replace(' ', '_').replace('-', '_')
668
filepath = dialogs.ask_for_save_pathname(title, "%s_subscriptions.opml" % slug)
673
messages.ExportSubscriptions(filepath).send_to_backend()
675
def copy_feed_url(self):
676
t, channel_infos = app.tab_list_manager.get_selection()
677
if t in ('feed', 'audio-feed') and len(channel_infos) == 1:
678
app.widgetapp.copy_text_to_clipboard(channel_infos[0].url)
680
def copy_site_url(self):
681
t, site_infos = app.tab_list_manager.get_selection()
683
app.widgetapp.copy_text_to_clipboard(site_infos[0].url)
685
def add_new_playlist(self):
686
selection = app.item_list_controller_manager.get_selection()
687
ids = [s.id for s in selection if s.downloaded]
689
title = _('Create Playlist')
690
description = _('Enter a name for the new playlist')
692
name = dialogs.ask_for_string(title, description)
694
messages.NewPlaylist(name, ids).send_to_backend()
696
def add_to_playlist(self):
697
selection = app.item_list_controller_manager.get_selection()
698
ids = [s.id for s in selection if s.downloaded]
700
data = addtoplaylistdialog.run_dialog()
705
if data[0] == "existing":
706
messages.AddVideosToPlaylist(data[1].id, ids).send_to_backend()
707
elif data[0] == "new":
708
messages.NewPlaylist(data[1], ids).send_to_backend()
710
def add_new_playlist_folder(self, add_selected=False):
711
title = _('Create Playlist Folder')
712
description = _('Enter a name for the new playlist folder')
714
name = dialogs.ask_for_string(title, description)
717
t, infos = app.tab_list_manager.get_selection()
718
child_ids = [info.id for info in infos]
721
messages.NewPlaylistFolder(name, child_ids).send_to_backend()
723
def rename_something(self):
724
t, channel_infos = app.tab_list_manager.get_selection()
725
info = channel_infos[0]
727
if t in ('feed', 'audio-feed') and info.is_folder:
729
elif t == 'playlist' and info.is_folder:
730
t = 'playlist-folder'
732
if t == 'feed-folder':
733
title = _('Rename Feed Folder')
734
description = _('Enter a new name for the feed folder %(name)s',
737
elif t in ('feed', 'audio-feed'):
738
title = _('Rename Feed')
739
description = _('Enter a new name for the feed %(name)s',
742
elif t == 'playlist':
743
title = _('Rename Playlist')
744
description = _('Enter a new name for the playlist %(name)s',
747
elif t == 'playlist-folder':
748
title = _('Rename Playlist Folder')
749
description = _('Enter a new name for the playlist folder %(name)s',
752
title = _('Rename Website')
753
description = _('Enter a new name for the website %(name)s',
757
raise AssertionError("Unknown tab type: %s" % t)
759
name = dialogs.ask_for_string(title, description,
760
initial_text=info.name)
762
messages.RenameObject(t, info.id, name).send_to_backend()
764
def revert_feed_name(self):
765
t, channel_infos = app.tab_list_manager.get_selection()
766
if not channel_infos:
768
info = channel_infos[0]
769
messages.RevertFeedTitle(info.id).send_to_backend()
771
def remove_current_playlist(self):
772
t, infos = app.tab_list_manager.get_selection()
774
self.remove_playlists(infos)
776
def remove_playlists(self, playlist_infos):
777
title = ngettext('Remove playlist', 'Remove playlists', len(playlist_infos))
778
description = ngettext(
779
'Are you sure you want to remove this playlist?',
780
'Are you sure you want to remove these %(count)s playlists?',
782
{"count": len(playlist_infos)}
785
ret = dialogs.show_choice_dialog(title, description,
786
[dialogs.BUTTON_REMOVE,
787
dialogs.BUTTON_CANCEL])
789
if ret == dialogs.BUTTON_REMOVE:
790
for pi in playlist_infos:
791
messages.DeletePlaylist(pi.id, pi.is_folder).send_to_backend()
793
def remove_current_site(self):
794
t, infos = app.tab_list_manager.get_selection()
796
self.remove_sites(infos)
798
def remove_sites(self, infos):
799
title = ngettext('Remove website', 'Remove websites', len(infos))
800
description = ngettext(
801
'Are you sure you want to remove this website?',
802
'Are you sure you want to remove these %(count)s websites?',
804
{"count": len(infos)}
807
ret = dialogs.show_choice_dialog(title, description,
808
[dialogs.BUTTON_REMOVE, dialogs.BUTTON_CANCEL])
810
if ret == dialogs.BUTTON_REMOVE:
812
messages.DeleteSite(si.id).send_to_backend()
815
"""Quit out of the UI event loop."""
816
raise NotImplementedError()
821
def diagnostics(self):
822
diagnostics.run_dialog()
825
"""This is called when the close button is pressed."""
829
ok1 = self._confirm_quit_if_downloading()
830
ok2 = self._confirm_quit_if_converting()
834
def _confirm_quit_if_downloading(self):
835
if config.get(prefs.WARN_IF_DOWNLOADING_ON_QUIT) and self.download_count > 0:
836
ret = quitconfirmation.rundialog(
837
_("Are you sure you want to quit?"),
839
"You have %(count)d download in progress. Quit anyway?",
840
"You have %(count)d downloads in progress. Quit anyway?",
842
{"count": self.download_count}
844
_("Warn me when I attempt to quit with downloads in progress"),
845
prefs.WARN_IF_DOWNLOADING_ON_QUIT
850
def _confirm_quit_if_converting(self):
851
running_count = videoconversion.conversion_manager.running_tasks_count()
852
pending_count = videoconversion.conversion_manager.pending_tasks_count()
853
conversions_count = running_count + pending_count
854
if config.get(prefs.WARN_IF_CONVERTING_ON_QUIT) and conversions_count > 0:
855
ret = quitconfirmation.rundialog(
856
_("Are you sure you want to quit?"),
858
"You have %(count)d conversion in progress. Quit anyway?",
859
"You have %(count)d conversions in progress or pending. Quit anyway?",
861
{"count": conversions_count}
863
_("Warn me when I attempt to quit with conversions in progress"),
864
prefs.WARN_IF_CONVERTING_ON_QUIT
870
if self.window is not None:
872
if self.ui_initialized:
873
if app.playback_manager.is_playing:
874
app.playback_manager.stop()
875
app.display_manager.deselect_all_displays()
876
if self.window is not None:
877
self.window.destroy()
878
app.controller.shutdown()
881
def connect_to_signals(self):
882
signals.system.connect('error', self.handle_error)
883
signals.system.connect('update-available', self.handle_update_available)
884
signals.system.connect('new-dialog', self.handle_dialog)
885
signals.system.connect('shutdown', self.on_backend_shutdown)
887
def handle_unwatched_count_changed(self):
890
def handle_dialog(self, obj, dialog):
891
call_on_ui_thread(rundialog.run, dialog)
893
def handle_update_available(self, obj, item):
894
print "Update available! -- not implemented"
896
def handle_up_to_date(self):
897
print "Up to date! -- not implemented"
899
def handle_error(self, obj, report):
900
call_on_ui_thread(self.handle_crash_report, report)
902
def handle_crash_report(self, report):
903
if self.ignore_errors:
904
logging.warn("Ignoring Error:\n%s", report)
907
ret = crashdialog.run_dialog(report)
908
if ret == crashdialog.IGNORE_ERRORS:
909
self.ignore_errors = True
911
def on_backend_shutdown(self, obj):
912
logging.info('Shutting down...')
914
class InfoUpdaterCallbackList(object):
915
"""Tracks the list of callbacks for InfoUpdater.
921
def add(self, type_, id_, callback):
922
"""Adds the callback to the list for ``type_`` ``id_``.
924
:param type_: the type of the thing (feed, audio-feed, site, ...)
925
:param id_: the id for the thing
926
:param callback: the callback function to add
929
self._callbacks.setdefault(key, set()).add(callback)
931
def remove(self, type_, id_, callback):
932
"""Removes the callback from the list for ``type_`` ``id_``.
934
:param type_: the type of the thing (feed, audio-feed, site, ...)
935
:param id_: the id for the thing
936
:param callback: the callback function to remove
939
callback_set = self._callbacks[key]
940
callback_set.remove(callback)
941
if len(callback_set) == 0:
942
del self._callbacks[key]
944
def get(self, type_, id_):
945
"""Get the list of callbacks for ``type_``, ``id_``.
947
:param type_: the type of the thing (feed, audio-feed, site, ...)
948
:param id_: the id for the thing
951
if key not in self._callbacks:
954
# return a new list of callbacks, so that if we iterate over the
955
# return value, we don't have to worry about callbacks being
957
return list(self._callbacks[key])
959
class InfoUpdater(signals.SignalEmitter):
960
"""Track channel/item updates from the backend.
962
To track item updates, use ``add_item_callback()``. To track tab
963
updates, connect to one of the signals below.
967
* feeds-added (self, info_list) -- New video feeds were added
968
* feeds-changed (self, info_list) -- Video feeds were changed
969
* feeds-removed (self, info_list) -- Video feeds were removed
970
* audio-feeds-added (self, info_list) -- New audio feeds were added
971
* audio-feeds-changed (self, info_list) -- Audio feeds were changed
972
* audio-feeds-removed (self, info_list) -- Audio feeds were removed
973
* sites-added (self, info_list) -- New sites were added
974
* sites-changed (self, info_list) -- Sites were changed
975
* sites-removed (self, info_list) -- Sites were removed
976
* playlists-added (self, info_list) -- New playlists were added
977
* playlists-changed (self, info_list) -- Playlists were changed
978
* playlists-removed (self, info_list) -- Playlists were removed
981
signals.SignalEmitter.__init__(self)
982
for prefix in ('feeds', 'audio-feeds', 'sites', 'playlists'):
983
self.create_signal('%s-added' % prefix)
984
self.create_signal('%s-changed' % prefix)
985
self.create_signal('%s-removed' % prefix)
987
self.item_list_callbacks = InfoUpdaterCallbackList()
988
self.item_changed_callbacks = InfoUpdaterCallbackList()
990
def handle_items_changed(self, message):
991
callback_list = self.item_changed_callbacks
992
for callback in callback_list.get(message.type, message.id):
995
def handle_item_list(self, message):
996
callback_list = self.item_list_callbacks
997
for callback in callback_list.get(message.type, message.id):
1000
def handle_tabs_changed(self, message):
1001
if message.type == 'feed':
1002
signal_start = 'feeds'
1003
elif message.type == 'audio-feed':
1004
signal_start = 'audio-feeds'
1005
elif message.type == 'guide':
1006
signal_start = 'sites'
1007
elif message.type == 'playlist':
1008
signal_start = 'playlists'
1012
self.emit('%s-added' % signal_start, message.added)
1014
self.emit('%s-changed' % signal_start, message.changed)
1016
self.emit('%s-removed' % signal_start, message.removed)
1018
class WidgetsMessageHandler(messages.MessageHandler):
1019
"""Handles frontend messages.
1021
There's a method to handle each frontend message type. See
1022
:mod:`miro.messages` (``lib/messages.py``) and
1023
:mod:`miro.messagehandler` (``lib/messagehandler.py``) for
1027
messages.MessageHandler.__init__(self)
1028
# Messages that we need to see before the UI is ready
1029
self._pre_startup_messages = set([
1034
if config.get(prefs.OPEN_CHANNEL_ON_STARTUP) is not None or \
1035
config.get(prefs.OPEN_FOLDER_ON_STARTUP) is not None:
1036
self._pre_startup_messages.add('feed-tab-list')
1037
self._pre_startup_messages.add('audio-feed-tab-list')
1038
self.progress_dialog = None
1039
self.dbupgrade_progress_dialog = None
1041
def handle_frontend_quit(self, message):
1042
app.widgetapp.do_quit()
1044
def handle_database_upgrade_start(self, message):
1045
if self.dbupgrade_progress_dialog is None:
1046
self.dbupgrade_progress_dialog = dialogs.DBUpgradeProgressDialog(
1047
_('Upgrading database'))
1048
self.dbupgrade_progress_dialog.run()
1049
# run() will return when we destroy the dialog because of a future
1053
def handle_database_upgrade_progress(self, message):
1054
self.dbupgrade_progress_dialog.update(message.stage,
1055
message.stage_progress, message.total_progress)
1057
def handle_database_upgrade_end(self, message):
1058
self.dbupgrade_progress_dialog.destroy()
1059
self.dbupgrade_progress_dialog = None
1061
def handle_startup_failure(self, message):
1062
if hasattr(self, "_startup_failure_mode"):
1064
self._startup_failure_mode = True
1066
dialogs.show_message(message.summary, message.description,
1067
dialogs.CRITICAL_MESSAGE)
1068
app.widgetapp.do_quit()
1070
def handle_startup_database_failure(self, message):
1071
if hasattr(self, "_database_failure_mode"):
1072
logging.info("already in db failure mode--skipping")
1074
self._database_failure_mode = True
1076
ret = dialogs.show_choice_dialog(
1077
message.summary, message.description,
1078
choices=[dialogs.BUTTON_QUIT, dialogs.BUTTON_START_FRESH])
1079
if ret == dialogs.BUTTON_START_FRESH:
1082
app.db.reset_database()
1085
"gah! exception in the "
1086
"handle_startup_database_failure section")
1087
app.widgetapp.do_quit()
1088
eventloop.add_urgent_call(start_fresh, "start fresh and quit")
1091
app.widgetapp.do_quit()
1093
def handle_startup_success(self, message):
1094
app.widgetapp.startup_ui()
1096
def _saw_pre_startup_message(self, name):
1097
if name not in self._pre_startup_messages:
1098
# we get here with the (audio-)?feed-tab-list messages when
1099
# OPEN_(CHANNEL|FOLDER)_ON_STARTUP isn't defined
1101
self._pre_startup_messages.remove(name)
1102
if len(self._pre_startup_messages) == 0:
1103
app.widgetapp.build_window()
1105
def call_handler(self, method, message):
1106
# uncomment this next line if you need frontend messages
1107
# logging.debug("handling frontend %s", message)
1108
call_on_ui_thread(method, message)
1110
def handle_current_search_info(self, message):
1111
app.search_manager.set_search_info(message.engine, message.text)
1112
self._saw_pre_startup_message('search-info')
1114
def tablist_for_message(self, message):
1115
if message.type == 'feed':
1116
return app.tab_list_manager.feed_list
1117
elif message.type == 'audio-feed':
1118
return app.tab_list_manager.audio_feed_list
1119
elif message.type == 'playlist':
1120
return app.tab_list_manager.playlist_list
1121
elif message.type == 'guide':
1122
return app.tab_list_manager.site_list
1124
raise ValueError("Unknown Type: %s" % message.type)
1126
def handle_tab_list(self, message):
1127
tablist = self.tablist_for_message(message)
1128
tablist.reset_list(message)
1129
if 'feed' in message.type:
1130
pre_startup_message = message.type + '-tab-list'
1131
self._saw_pre_startup_message(pre_startup_message)
1133
def handle_guide_list(self, message):
1134
app.widgetapp.default_guide_info = message.default_guide
1135
self.initial_guides = message.added_guides
1136
self._saw_pre_startup_message('guide-list')
1138
def update_default_guide(self, guide_info):
1139
app.widgetapp.default_guide_info = guide_info
1140
guide_tab = app.tab_list_manager.static_tab_list.get_tab('guide')
1141
guide_tab.update(guide_info)
1143
def handle_watched_folder_list(self, message):
1144
app.watched_folder_manager.handle_watched_folder_list(
1145
message.watched_folders)
1147
def handle_watched_folders_changed(self, message):
1148
app.watched_folder_manager.handle_watched_folders_changed(
1149
message.added, message.changed, message.removed)
1151
def handle_tabs_changed(self, message):
1152
if message.type == 'guide':
1153
for info in list(message.changed):
1155
self.update_default_guide(info)
1156
message.changed.remove(info)
1158
tablist = self.tablist_for_message(message)
1160
tablist.remove(message.removed)
1161
for info in message.changed:
1162
tablist.update(info)
1163
for info in message.added:
1164
# some things don't have parents (e.g. sites)
1165
if hasattr(info, "parent_id"):
1166
tablist.add(info, info.parent_id)
1169
tablist.model_changed()
1170
app.info_updater.handle_tabs_changed(message)
1172
def handle_item_list(self, message):
1173
app.info_updater.handle_item_list(message)
1174
app.menu_manager.update_menus()
1176
def handle_items_changed(self, message):
1177
app.info_updater.handle_items_changed(message)
1178
app.menu_manager.update_menus()
1180
def handle_download_count_changed(self, message):
1181
app.widgetapp.download_count = message.count
1182
library_tab_list = app.tab_list_manager.library_tab_list
1183
library_tab_list.update_download_count(message.count,
1184
message.non_downloading_count)
1186
def handle_paused_count_changed(self, message):
1187
app.widgetapp.paused_count = message.count
1189
def handle_new_video_count_changed(self, message):
1190
library_tab_list = app.tab_list_manager.library_tab_list
1191
library_tab_list.update_new_video_count(message.count)
1193
def handle_new_audio_count_changed(self, message):
1194
library_tab_list = app.tab_list_manager.library_tab_list
1195
library_tab_list.update_new_audio_count(message.count)
1197
def handle_unwatched_count_changed(self, message):
1198
app.widgetapp.unwatched_count = message.count
1199
app.widgetapp.handle_unwatched_count_changed()
1201
def handle_video_conversions_count_changed(self, message):
1202
library_tab_list = app.tab_list_manager.library_tab_list
1203
library_tab_list.update_conversions_count(message.running_count,
1204
message.other_count)
1206
def handle_video_conversion_tasks_list(self, message):
1207
current_display = app.display_manager.get_current_display()
1208
if isinstance(current_display, displays.VideoConversionsDisplay):
1209
current_display.controller.handle_task_list(message.running_tasks,
1210
message.pending_tasks, message.finished_tasks)
1212
def handle_video_conversion_task_created(self, message):
1213
current_display = app.display_manager.get_current_display()
1214
if isinstance(current_display, displays.VideoConversionsDisplay):
1215
current_display.controller.handle_task_added(message.task)
1217
def handle_video_conversion_task_removed(self, message):
1218
current_display = app.display_manager.get_current_display()
1219
if isinstance(current_display, displays.VideoConversionsDisplay):
1220
current_display.controller.handle_task_removed(message.task)
1222
def handle_all_video_conversion_task_removed(self, message):
1223
current_display = app.display_manager.get_current_display()
1224
if isinstance(current_display, displays.VideoConversionsDisplay):
1225
current_display.controller.handle_all_tasks_removed()
1227
def handle_video_conversion_task_changed(self, message):
1228
current_display = app.display_manager.get_current_display()
1229
if isinstance(current_display, displays.VideoConversionsDisplay):
1230
current_display.controller.handle_task_changed(message.task)
1232
def handle_play_movie(self, message):
1233
app.playback_manager.start_with_items(message.item_infos)
1235
def handle_open_in_external_browser(self, message):
1236
app.widgetapp.open_url(message.url)
1238
def handle_message_to_user(self, message):
1239
title = message.title or _("Message")
1241
print "handle_message_to_user"
1242
dialogs.show_message(title, desc)
1244
def handle_notify_user(self, message):
1245
# if the user has selected that they aren't interested in this
1246
# notification type, return here...
1248
# otherwise, we default to sending the notification
1249
app.widgetapp.send_notification(message.title, message.body)
1251
def handle_search_complete(self, message):
1252
if app.widgetapp.ui_initialized:
1253
app.search_manager.handle_search_complete(message)
1255
def handle_current_frontend_state(self, message):
1256
app.frontend_states_memory = FrontendStatesStore(message)
1257
self._saw_pre_startup_message('frontend-state')
1259
def handle_progress_dialog_start(self, message):
1260
self.progress_dialog = dialogs.ProgressDialog(message.title)
1261
self.progress_dialog.run()
1262
# run() will return when we destroy the dialog because of a future
1265
def handle_progress_dialog(self, message):
1266
self.progress_dialog.update(message.description, message.progress)
1268
def handle_progress_dialog_finished(self, message):
1269
self.progress_dialog.destroy()
1270
self.progress_dialog = None
1272
def handle_feedless_download_started(self, message):
1273
library_tab_list = app.tab_list_manager.library_tab_list
1274
library_tab_list.blink_tab("downloading")
1276
class FrontendStatesStore(object):
1277
"""Stores which views were left in list mode by the user.
1280
# Maybe this should get its own module, but it seems small enough to
1283
def __init__(self, message):
1284
self.current_displays = set(message.list_view_displays)
1285
self.sort_states = message.sort_states
1286
self.active_filters = message.active_filters
1288
def _key(self, type, id):
1289
return '%s:%s' % (type, id)
1291
def query_list_view(self, type, id):
1292
return self._key(type, id) in self.current_displays
1294
def query_sort_state(self, type, id):
1295
key = self._key(type, id)
1296
if key in self.sort_states:
1297
state = self.sort_states[key]
1298
if state.startswith('-'):
1299
sort_key = state[1:]
1304
return itemlist.SORT_KEY_MAP[sort_key](ascending)
1307
def query_filters(self, type, id):
1308
return self.active_filters.get(self._key(type, id), [])
1310
def set_filters(self, type, id, filters):
1311
self.active_filters[self._key(type, id)] = filters
1314
def set_sort_state(self, type, id, sorter):
1315
# we have a ItemSort object and we need to create a string that will
1316
# represent it. Use the sort key, with '-' prepended if the sort is
1317
# descending (for example: "date", "-name", "-size", ...)
1319
if not sorter.is_ascending():
1321
self.sort_states[self._key(type, id)] = state
1324
def set_list_view(self, type, id):
1325
self.current_displays.add(self._key(type, id))
1328
def set_std_view(self, type, id):
1329
self.current_displays.discard(self._key(type, id))
1332
def save_state(self):
1333
m = messages.SaveFrontendState(list(self.current_displays),
1334
self.sort_states, self.active_filters)