1# Copyright 2004-2005 Joe Wreschnig, Michael Urman, Iñigo Serna
2#           2012 Christoph Reiter
3#           2012-2017 Nick Boultbee
4#           2017 Uriel Zajaczkovski
5#
6# This program 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 2 of the License, or
9# (at your option) any later version.
10
11import os
12
13from gi.repository import Gtk, Gdk, GLib, Gio, GObject
14from senf import uri2fsn, fsnative, path2fsn
15
16import quodlibet
17
18from quodlibet import browsers
19from quodlibet import config
20from quodlibet import const
21from quodlibet import formats
22from quodlibet import qltk
23from quodlibet import util
24from quodlibet import app
25from quodlibet import _
26from quodlibet.qltk.paned import ConfigRHPaned
27
28from quodlibet.qltk.appwindow import AppWindow
29from quodlibet.update import UpdateDialog
30from quodlibet.formats.remote import RemoteFile
31from quodlibet.qltk.browser import LibraryBrowser, FilterMenu
32from quodlibet.qltk.chooser import choose_folders, choose_files, \
33    create_chooser_filter
34from quodlibet.qltk.controls import PlayControls
35from quodlibet.qltk.cover import CoverImage
36from quodlibet.qltk.getstring import GetStringDialog
37from quodlibet.qltk.bookmarks import EditBookmarks
38from quodlibet.qltk.shortcuts import show_shortcuts
39from quodlibet.qltk.info import SongInfo
40from quodlibet.qltk.information import Information
41from quodlibet.qltk.msg import ErrorMessage, WarningMessage
42from quodlibet.qltk.notif import StatusBar, TaskController
43from quodlibet.qltk.playorder import PlayOrderWidget, RepeatSongForever, \
44    RepeatListForever
45from quodlibet.qltk.pluginwin import PluginWindow
46from quodlibet.qltk.properties import SongProperties
47from quodlibet.qltk.prefs import PreferencesWindow
48from quodlibet.qltk.queue import QueueExpander
49from quodlibet.qltk.songlist import SongList, get_columns, set_columns
50from quodlibet.qltk.songmodel import PlaylistMux
51from quodlibet.qltk.x import RVPaned, Align, ScrolledWindow, Action
52from quodlibet.qltk.x import ToggleAction, RadioAction, HighlightToggleButton
53from quodlibet.qltk.x import SeparatorMenuItem, MenuItem
54from quodlibet.qltk import Icons
55from quodlibet.qltk.about import AboutDialog
56from quodlibet.util import copool, connect_destroy, connect_after_destroy
57from quodlibet.util.library import get_scan_dirs
58from quodlibet.util import connect_obj, print_d
59from quodlibet.util.library import background_filter, scan_library
60from quodlibet.util.path import uri_is_valid
61from quodlibet.qltk.window import PersistentWindowMixin, Window, on_first_map
62from quodlibet.qltk.songlistcolumns import CurrentColumn
63
64
65class PlayerOptions(GObject.Object):
66    """Provides a simplified interface for playback options.
67
68    This currently provides a limited view on the play order state which is
69    useful for external interfaces (mpd, mpris, etc.) and for reducing
70    the dependency on the state holding widgets in the main window.
71
72    Usable as long as the main window is not destroyed, or until `destroy()`
73    is called.
74    """
75
76    __gproperties__ = {
77        'shuffle': (bool, '', '', False,
78                   GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE),
79        'repeat': (bool, '', '', False,
80                   GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE),
81        'single': (bool, '', '', False,
82                   GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE),
83        'stop-after': (
84            bool, '', '', False,
85            GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE),
86    }
87
88    def __init__(self, window):
89        """`window` is a QuodLibetWindow"""
90
91        super(PlayerOptions, self).__init__()
92
93        self._stop_after = window.stop_after
94        self._said = self._stop_after.connect(
95            "toggled", lambda *x: self.notify("stop-after"))
96
97        def order_changed(*args):
98            self.notify("shuffle")
99            self.notify("single")
100
101        self._order_widget = window.order
102        self._oid = self._order_widget.connect("changed", order_changed)
103
104        window.connect("destroy", self._window_destroy)
105
106    def _window_destroy(self, window):
107        self.destroy()
108
109    def destroy(self):
110        if self._order_widget:
111            self._order_widget.disconnect(self._oid)
112            self._order_widget = None
113        if self._stop_after:
114            self._stop_after.disconnect(self._said)
115            self._stop_after = None
116
117    def do_get_property(self, param):
118        return getattr(self, param.name.replace("-", "_"))
119
120    def do_set_property(self, param, value):
121        setattr(self, param.name.replace("-", "_"), value)
122
123    @property
124    def single(self):
125        """If only the current song is considered as next track
126
127        When `repeat` is False the playlist will end after this song finishes.
128        When `repeat` is True the current song will be replayed.
129        """
130
131        return (self._order_widget and self._order_widget.repeated and
132                self._order_widget.repeater is RepeatSongForever)
133
134    @single.setter
135    def single(self, value):
136        if value:
137            self.repeat = True
138            self._order_widget.repeater = RepeatSongForever
139        else:
140            self.repeat = False
141            self._order_widget.repeater = RepeatListForever
142
143    @property
144    def shuffle(self):
145        """If a shuffle-like (reordering) play order is active"""
146
147        return self._order_widget.shuffled
148
149    @shuffle.setter
150    def shuffle(self, value):
151        self._order_widget.shuffled = value
152
153    @property
154    def repeat(self):
155        """If the player is in some kind of repeat mode"""
156
157        return self._order_widget.repeated
158
159    @repeat.setter
160    def repeat(self, value):
161        print_d("setting repeated to %s" % value)
162        self._order_widget.repeated = value
163
164    @property
165    def stop_after(self):
166        """If the player will pause after the current song ends"""
167
168        return self._stop_after.get_active()
169
170    @stop_after.setter
171    def stop_after(self, value):
172        self._stop_after.set_active(value)
173
174
175class DockMenu(Gtk.Menu):
176    """Menu used for the OSX dock and the tray icon"""
177
178    def __init__(self, app):
179        super(DockMenu, self).__init__()
180
181        player = app.player
182
183        play_item = MenuItem(_("_Play"), Icons.MEDIA_PLAYBACK_START)
184        play_item.connect("activate", self._on_play, player)
185        pause_item = MenuItem(_("P_ause"), Icons.MEDIA_PLAYBACK_PAUSE)
186        pause_item.connect("activate", self._on_pause, player)
187        self.append(play_item)
188        self.append(pause_item)
189
190        previous = MenuItem(_("Pre_vious"), Icons.MEDIA_SKIP_BACKWARD)
191        previous.connect('activate', lambda *args: player.previous())
192        self.append(previous)
193
194        next_ = MenuItem(_("_Next"), Icons.MEDIA_SKIP_FORWARD)
195        next_.connect('activate', lambda *args: player.next())
196        self.append(next_)
197
198        browse = qltk.MenuItem(_("_Browse Library"), Icons.EDIT_FIND)
199        browse_sub = Gtk.Menu()
200        for Kind in browsers.browsers:
201            i = Gtk.MenuItem(label=Kind.accelerated_name, use_underline=True)
202            connect_obj(i,
203                'activate', LibraryBrowser.open, Kind, app.library, app.player)
204            browse_sub.append(i)
205
206        browse.set_submenu(browse_sub)
207        self.append(SeparatorMenuItem())
208        self.append(browse)
209
210        self.show_all()
211        self.hide()
212
213    def _on_play(self, item, player):
214        player.paused = False
215
216    def _on_pause(self, item, player):
217        player.paused = True
218
219
220class MainSongList(SongList):
221    """SongList for the main browser's displayed songs."""
222
223    _activated = False
224
225    def __init__(self, library, player):
226        super(MainSongList, self).__init__(library, player, update=True)
227        self.set_first_column_type(CurrentColumn)
228
229        self.connect('row-activated', self.__select_song, player)
230
231        # ugly.. so the main window knows if the next song-started
232        # comes from an row-activated or anything else.
233        def reset_activated(*args):
234            self._activated = False
235        connect_after_destroy(player, 'song-started', reset_activated)
236
237        self.connect("orders-changed", self.__orders_changed)
238
239    def __orders_changed(self, *args):
240        l = []
241        for tag, reverse in self.get_sort_orders():
242            l.append("%d%s" % (int(reverse), tag))
243        config.setstringlist('memory', 'sortby', l)
244
245    def __select_song(self, widget, indices, col, player):
246        self._activated = True
247        iter = self.model.get_iter(indices)
248        if player.go_to(iter, explicit=True, source=self.model):
249            player.paused = False
250
251
252class TopBar(Gtk.Toolbar):
253    def __init__(self, parent, player, library):
254        super(TopBar, self).__init__()
255
256        # play controls
257        control_item = Gtk.ToolItem()
258        self.insert(control_item, 0)
259        t = PlayControls(player, library.librarian)
260        self.volume = t.volume
261
262        # only restore the volume in case it is managed locally, otherwise
263        # this could affect the system volume
264        if not player.has_external_volume:
265            player.volume = config.getfloat("memory", "volume")
266
267        connect_destroy(player, "notify::volume", self._on_volume_changed)
268        control_item.add(t)
269
270        self.insert(Gtk.SeparatorToolItem(), 1)
271
272        info_item = Gtk.ToolItem()
273        self.insert(info_item, 2)
274        info_item.set_expand(True)
275
276        box = Gtk.Box(spacing=6)
277        info_item.add(box)
278        qltk.add_css(self, "GtkToolbar {padding: 3px;}")
279
280        self._pattern_box = Gtk.VBox()
281
282        # song text
283        info_pattern_path = os.path.join(quodlibet.get_user_dir(), "songinfo")
284        text = SongInfo(library.librarian, player, info_pattern_path)
285        self._pattern_box.pack_start(Align(text, border=3), True, True, 0)
286        box.pack_start(self._pattern_box, True, True, 0)
287
288        # cover image
289        self.image = CoverImage(resize=True)
290        connect_destroy(player, 'song-started', self.__new_song)
291
292        # FIXME: makes testing easier
293        if app.cover_manager:
294            connect_destroy(
295                app.cover_manager, 'cover-changed',
296                self.__song_art_changed, library)
297
298        box.pack_start(Align(self.image, border=2), False, True, 0)
299
300        # On older Gtk+ (3.4, at least)
301        # setting a margin on CoverImage leads to errors and result in the
302        # QL window not being visible for some reason.
303        assert self.image.props.margin == 0
304
305        for child in self.get_children():
306            child.show_all()
307
308        context = self.get_style_context()
309        context.add_class("primary-toolbar")
310
311    def set_seekbar_widget(self, widget):
312        children = self._pattern_box.get_children()
313        if len(children) > 1:
314            self._pattern_box.remove(children[-1])
315
316        if widget:
317            self._pattern_box.pack_start(widget, False, True, 0)
318
319    def _on_volume_changed(self, player, *args):
320        config.set("memory", "volume", str(player.volume))
321
322    def __new_song(self, player, song):
323        self.image.set_song(song)
324
325    def __song_art_changed(self, player, songs, library):
326        self.image.refresh()
327
328
329class QueueButton(HighlightToggleButton):
330
331    def __init__(self):
332        # XXX: view-list isn't part of the fdo spec, so fall back t justify..
333        gicon = Gio.ThemedIcon.new_from_names(
334            ["view-list-symbolic", "format-justify-fill-symbolic",
335             "view-list", "format-justify"])
336        image = Gtk.Image.new_from_gicon(gicon, Gtk.IconSize.SMALL_TOOLBAR)
337
338        super(QueueButton, self).__init__(image=image)
339
340        self.set_name("ql-queue-button")
341        qltk.add_css(self, """
342            #ql-queue-button {
343                padding: 0px;
344            }
345        """)
346        self.set_size_request(26, 26)
347
348        self.set_tooltip_text(_("Toggle queue visibility"))
349
350
351class StatusBarBox(Gtk.HBox):
352
353    def __init__(self, play_order, queue):
354        super(StatusBarBox, self).__init__(spacing=6)
355        self.pack_start(play_order, False, True, 0)
356        self.statusbar = StatusBar(TaskController.default_instance)
357        self.pack_start(self.statusbar, True, True, 0)
358        queue_button = QueueButton()
359        queue_button.bind_property("active", queue, "visible",
360                                   GObject.BindingFlags.BIDIRECTIONAL)
361        queue_button.props.active = queue.props.visible
362
363        self.pack_start(queue_button, False, True, 0)
364
365
366class PlaybackErrorDialog(ErrorMessage):
367
368    def __init__(self, parent, player_error):
369        add_full_stop = lambda s: s and (s.rstrip(".") + ".")
370        description = add_full_stop(util.escape(player_error.short_desc))
371        details = add_full_stop(util.escape(player_error.long_desc or ""))
372        if details:
373            description += " " + details
374
375        super(PlaybackErrorDialog, self).__init__(
376            parent, _("Playback Error"), description)
377
378
379class ConfirmLibDirSetup(WarningMessage):
380
381    RESPONSE_SETUP = 1
382
383    def __init__(self, parent):
384        title = _("Set up library directories?")
385        description = _("You don't have any music library set up. "
386                        "Would you like to do that now?")
387
388        super(ConfirmLibDirSetup, self).__init__(
389            parent, title, description, buttons=Gtk.ButtonsType.NONE)
390
391        self.add_button(_("_Not Now"), Gtk.ResponseType.CANCEL)
392        self.add_button(_("_Set Up"), self.RESPONSE_SETUP)
393        self.set_default_response(Gtk.ResponseType.CANCEL)
394
395
396MENU = """
397<ui>
398  <menubar name='Menu'>
399
400    <menu action='File'>
401      <menuitem action='AddFolders' always-show-image='true'/>
402      <menuitem action='AddFiles' always-show-image='true'/>
403      <menuitem action='AddLocation' always-show-image='true'/>
404      <separator/>
405      <menuitem action='Preferences' always-show-image='true'/>
406      <menuitem action='Plugins' always-show-image='true'/>
407      <separator/>
408      <menuitem action='RefreshLibrary' always-show-image='true'/>
409      <separator/>
410      <menuitem action='Quit' always-show-image='true'/>
411    </menu>
412
413    <menu action='Song'>
414      <menuitem action='EditBookmarks' always-show-image='true'/>
415      <menuitem action='EditTags' always-show-image='true'/>
416      <separator/>
417      <menuitem action='Information' always-show-image='true'/>
418      <separator/>
419      <menuitem action='Jump' always-show-image='true'/>
420    </menu>
421
422    <menu action='Control'>
423      <menuitem action='Previous' always-show-image='true'/>
424      <menuitem action='PlayPause' always-show-image='true'/>
425      <menuitem action='Next' always-show-image='true'/>
426      <menuitem action='StopAfter' always-show-image='true'/>
427    </menu>
428
429    <menu action='Browse'>
430      %(filters_menu)s
431      <separator/>
432      <menu action='BrowseLibrary' always-show-image='true'>
433        %(browsers)s
434      </menu>
435      <separator />
436
437      %(views)s
438    </menu>
439
440    <menu action='Help'>
441      <menuitem action='OnlineHelp' always-show-image='true'/>
442      <menuitem action='Shortcuts' always-show-image='true'/>
443      <menuitem action='SearchHelp' always-show-image='true'/>
444      <separator/>
445      <menuitem action='CheckUpdates' always-show-image='true'/>
446      <menuitem action='About' always-show-image='true'/>
447    </menu>
448
449  </menubar>
450</ui>
451"""
452
453
454def secondary_browser_menu_items():
455    items = (_browser_items('Browser') + ["<separator />"] +
456             _browser_items('Browser', True))
457    return "\n".join(items)
458
459
460def browser_menu_items():
461    items = (_browser_items('View') + ["<separator />"] +
462             _browser_items('View', True))
463    return "\n".join(items)
464
465
466def _browser_items(prefix, external=False):
467    return ["<menuitem action='%s%s'/>" % (prefix, kind.__name__)
468            for kind in browsers.browsers if kind.uses_main_library ^ external]
469
470
471DND_URI_LIST, = range(1)
472
473
474class SongListPaned(RVPaned):
475
476    def __init__(self, song_scroller, qexpander):
477        super(SongListPaned, self).__init__()
478
479        self.pack1(song_scroller, resize=True, shrink=False)
480        self.pack2(qexpander, resize=True, shrink=False)
481
482        self.set_relative(config.getfloat("memory", "queue_position", 0.75))
483        self.connect(
484            'notify::position', self._changed, "memory", "queue_position")
485
486        self._handle_position = self.get_relative()
487        qexpander.connect('notify::visible', self._expand_or)
488        qexpander.connect('notify::expanded', self._expand_or)
489        qexpander.connect('draw', self._check_minimize)
490
491        self.connect("button-press-event", self._on_button_press)
492        self.connect('notify', self._moved_pane_handle)
493
494    @property
495    def _expander(self):
496        return self.get_child2()
497
498    def _on_button_press(self, pane, event):
499        # If we start to drag the pane handle while the
500        # queue expander is unexpanded, expand it and move the handle
501        # to the bottom, so we can 'drag' the queue out
502
503        if event.window != pane.get_handle_window():
504            return False
505
506        if not self._expander.get_expanded():
507            self._expander.set_expanded(True)
508            pane.set_relative(1.0)
509        return False
510
511    def _expand_or(self, widget, prop):
512        if self._expander.get_property('expanded'):
513            self.set_relative(self._handle_position)
514
515    def _moved_pane_handle(self, widget, prop):
516        if self._expander.get_property('expanded'):
517            self._handle_position = self.get_relative()
518
519    def _check_minimize(self, *args):
520        if not self._expander.get_property('expanded'):
521            p_max = self.get_property("max-position")
522            p_cur = self.get_property("position")
523            if p_max != p_cur:
524                self.set_property("position", p_max)
525
526    def _changed(self, widget, event, section, option):
527        if self._expander.get_expanded() and self.get_property('position-set'):
528            config.set(section, option, str(self.get_relative()))
529
530
531class QuodLibetWindow(Window, PersistentWindowMixin, AppWindow):
532
533    def __init__(self, library, player, headless=False, restore_cb=None):
534        super(QuodLibetWindow, self).__init__(dialog=False)
535
536        self.__destroyed = False
537        self.__update_title(player)
538        self.set_default_size(600, 480)
539
540        main_box = Gtk.VBox()
541        self.add(main_box)
542        self.side_book = qltk.Notebook()
543
544        # get the playlist up before other stuff
545        self.songlist = MainSongList(library, player)
546        self.songlist.connect("key-press-event", self.__songlist_key_press)
547        self.songlist.connect_after(
548            'drag-data-received', self.__songlist_drag_data_recv)
549        self.song_scroller = ScrolledWindow()
550        self.song_scroller.set_policy(
551            Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
552        self.song_scroller.set_shadow_type(Gtk.ShadowType.IN)
553        self.song_scroller.add(self.songlist)
554
555        self.qexpander = QueueExpander(library, player)
556        self.qexpander.set_no_show_all(True)
557        self.qexpander.set_visible(config.getboolean("memory", "queue"))
558
559        def on_queue_visible(qex, param):
560            config.set("memory", "queue", str(qex.get_visible()))
561
562        self.qexpander.connect("notify::visible", on_queue_visible)
563
564        self.playlist = PlaylistMux(
565            player, self.qexpander.model, self.songlist.model)
566
567        self.__player = player
568        # create main menubar, load/restore accelerator groups
569        self.__library = library
570        ui = self.__create_menu(player, library)
571        accel_group = ui.get_accel_group()
572        self.add_accel_group(accel_group)
573
574        def scroll_and_jump(*args):
575            self.__jump_to_current(True, None, True)
576
577        keyval, mod = Gtk.accelerator_parse("<Primary><shift>J")
578        accel_group.connect(keyval, mod, 0, scroll_and_jump)
579
580        # custom accel map
581        accel_fn = os.path.join(quodlibet.get_user_dir(), "accels")
582        Gtk.AccelMap.load(accel_fn)
583        # save right away so we fill the file with example comments of all
584        # accels
585        Gtk.AccelMap.save(accel_fn)
586
587        menubar = ui.get_widget("/Menu")
588
589        # Since https://git.gnome.org/browse/gtk+/commit/?id=b44df22895c79
590        # toplevel menu items show an empty 16x16 image. While we don't
591        # need image items there UIManager creates them by default.
592        # Work around by removing the empty GtkImages
593        for child in menubar.get_children():
594            if isinstance(child, Gtk.ImageMenuItem):
595                child.set_image(None)
596
597        main_box.pack_start(menubar, False, True, 0)
598
599        top_bar = TopBar(self, player, library)
600        main_box.pack_start(top_bar, False, True, 0)
601        self.top_bar = top_bar
602
603        self.__browserbox = Align(bottom=3)
604        self.__paned = paned = ConfigRHPaned("memory", "sidebar_pos", 0.25)
605        paned.pack1(self.__browserbox, resize=True)
606        # We'll pack2 when necessary (when the first sidebar plugin is set up)
607
608        main_box.pack_start(paned, True, True, 0)
609
610        play_order = PlayOrderWidget(self.songlist.model, player)
611        statusbox = StatusBarBox(play_order, self.qexpander)
612        self.order = play_order
613        self.statusbar = statusbox.statusbar
614
615        main_box.pack_start(
616            Align(statusbox, border=3, top=-3),
617            False, True, 0)
618
619        self.songpane = SongListPaned(self.song_scroller, self.qexpander)
620        self.songpane.show_all()
621
622        try:
623            orders = []
624            for e in config.getstringlist('memory', 'sortby', []):
625                orders.append((e[1:], int(e[0])))
626        except ValueError:
627            pass
628        else:
629            self.songlist.set_sort_orders(orders)
630
631        self.browser = None
632        self.ui = ui
633
634        main_box.show_all()
635
636        self._playback_error_dialog = None
637        connect_destroy(player, 'song-started', self.__song_started)
638        connect_destroy(player, 'paused', self.__update_paused, True)
639        connect_destroy(player, 'unpaused', self.__update_paused, False)
640        # make sure we redraw all error indicators before opening
641        # a dialog (blocking the main loop), so connect after default handlers
642        connect_after_destroy(player, 'error', self.__player_error)
643        # connect after to let SongTracker update stats
644        connect_after_destroy(player, "song-ended", self.__song_ended)
645
646        # set at least the playlist. the song should be restored
647        # after the browser emits the song list
648        player.setup(self.playlist, None, 0)
649        self.__restore_cb = restore_cb
650        self.__first_browser_set = True
651
652        restore_browser = not headless
653        try:
654            self._select_browser(
655                self, config.get("memory", "browser"), library, player,
656                restore_browser)
657        except:
658            config.set("memory", "browser", browsers.name(browsers.default))
659            config.save()
660            raise
661
662        self.songlist.connect('popup-menu', self.__songs_popup_menu)
663        self.songlist.connect('columns-changed', self.__cols_changed)
664        self.songlist.connect('columns-changed', self.__hide_headers)
665        self.songlist.info.connect("changed", self.__set_totals)
666
667        lib = library.librarian
668        connect_destroy(lib, 'changed', self.__song_changed, player)
669
670        targets = [("text/uri-list", Gtk.TargetFlags.OTHER_APP, DND_URI_LIST)]
671        targets = [Gtk.TargetEntry.new(*t) for t in targets]
672
673        self.drag_dest_set(
674            Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY)
675        self.connect('drag-data-received', self.__drag_data_received)
676
677        if not headless:
678            on_first_map(self, self.__configure_scan_dirs, library)
679
680        if config.getboolean('library', 'refresh_on_start'):
681            self.__rebuild(None, False)
682
683        self.connect("key-press-event", self.__key_pressed, player)
684
685        self.connect("destroy", self.__destroy)
686
687        self.enable_window_tracking("quodlibet")
688
689    def hide_side_book(self):
690        self.side_book.hide()
691
692    def add_sidebar(self, box, name):
693        vbox = Gtk.Box(margin=0)
694        vbox.pack_start(box, True, True, 0)
695        vbox.show()
696        if self.side_book_empty:
697            self.add_sidebar_to_layout(self.side_book)
698        self.side_book.append_page(vbox, label=name)
699        self.side_book.set_tab_detachable(vbox, False)
700        self.side_book.show_all()
701        return vbox
702
703    def remove_sidebar(self, widget):
704        self.side_book.remove_page(self.side_book.page_num(widget))
705        if self.side_book_empty:
706            print_d("Hiding sidebar")
707            self.__paned.remove(self.__paned.get_children()[1])
708
709    def add_sidebar_to_layout(self, widget):
710        print_d("Recreating sidebar")
711        align = Align(widget, top=6, bottom=3)
712        self.__paned.pack2(align, shrink=True)
713        align.show_all()
714
715    @property
716    def side_book_empty(self):
717        return not self.side_book.get_children()
718
719    def set_seekbar_widget(self, widget):
720        """Add an alternative seek bar widget.
721
722        Args:
723            widget (Gtk.Widget): a new widget or None to remove the current one
724        """
725
726        self.top_bar.set_seekbar_widget(widget)
727
728    def set_as_osx_window(self, osx_app):
729        assert osx_app
730
731        self._dock_menu = DockMenu(app)
732        osx_app.set_dock_menu(self._dock_menu)
733
734        menu = self.ui.get_widget("/Menu")
735        menu.hide()
736        osx_app.set_menu_bar(menu)
737        # Reparent some items to the "Application" menu
738        item = self.ui.get_widget('/Menu/Help/About')
739        osx_app.insert_app_menu_item(item, 0)
740        osx_app.insert_app_menu_item(Gtk.SeparatorMenuItem(), 1)
741        item = self.ui.get_widget('/Menu/File/Preferences')
742        osx_app.insert_app_menu_item(item, 2)
743        quit_item = self.ui.get_widget('/Menu/File/Quit')
744        quit_item.hide()
745
746    def get_is_persistent(self):
747        return True
748
749    def open_file(self, filename):
750        assert isinstance(filename, fsnative)
751
752        song = self.__library.add_filename(filename, add=False)
753        if song is not None:
754            if self.__player.go_to(song):
755                self.__player.paused = False
756            return True
757        else:
758            return False
759
760    def __player_error(self, player, song, player_error):
761        # it's modal, but mmkeys etc. can still trigger new ones
762        if self._playback_error_dialog:
763            self._playback_error_dialog.destroy()
764        dialog = PlaybackErrorDialog(self, player_error)
765        self._playback_error_dialog = dialog
766        dialog.run()
767        self._playback_error_dialog = None
768
769    def __configure_scan_dirs(self, library):
770        """Get user to configure scan dirs, if none is set up"""
771        if not get_scan_dirs() and not len(library) and \
772                quodlibet.is_first_session("quodlibet"):
773            print_d("Couldn't find any scan dirs")
774
775            resp = ConfirmLibDirSetup(self).run()
776            if resp == ConfirmLibDirSetup.RESPONSE_SETUP:
777                prefs = PreferencesWindow(self)
778                prefs.set_page("library")
779                prefs.show()
780
781    def __keyboard_shortcuts(self, action):
782        show_shortcuts(self)
783
784    def __edit_bookmarks(self, librarian, player):
785        if player.song:
786            window = EditBookmarks(self, librarian, player)
787            window.show()
788
789    def __key_pressed(self, widget, event, player):
790        if not player.song:
791            return
792
793        def seek_relative(seconds):
794            current = player.get_position()
795            current += seconds * 1000
796            current = min(player.song("~#length") * 1000 - 1, current)
797            current = max(0, current)
798            player.seek(current)
799
800        if qltk.is_accel(event, "<alt>Right"):
801            seek_relative(10)
802            return True
803        elif qltk.is_accel(event, "<alt>Left"):
804            seek_relative(-10)
805            return True
806
807    def __destroy(self, *args):
808        self.playlist.destroy()
809
810        # The tray icon plugin tries to unhide QL because it gets disabled
811        # on Ql exit. The window should stay hidden after destroy.
812        self.show = lambda: None
813        self.present = self.show
814
815    def __drag_data_received(self, widget, ctx, x, y, sel, tid, etime):
816        assert tid == DND_URI_LIST
817
818        uris = sel.get_uris()
819
820        dirs = []
821        error = False
822        for uri in uris:
823            try:
824                filename = uri2fsn(uri)
825            except ValueError:
826                filename = None
827
828            if filename is not None:
829                loc = os.path.normpath(filename)
830                if os.path.isdir(loc):
831                    dirs.append(loc)
832                else:
833                    loc = os.path.realpath(loc)
834                    if loc not in self.__library:
835                        self.__library.add_filename(loc)
836            elif app.player.can_play_uri(uri):
837                if uri not in self.__library:
838                    self.__library.add([RemoteFile(uri)])
839            else:
840                error = True
841                break
842        Gtk.drag_finish(ctx, not error, False, etime)
843        if error:
844            ErrorMessage(
845                self, _("Unable to add songs"),
846                _("%s uses an unsupported protocol.") % util.bold(uri)).run()
847        else:
848            if dirs:
849                copool.add(
850                    self.__library.scan, dirs,
851                    cofuncid="library", funcid="library")
852
853    def __songlist_key_press(self, songlist, event):
854        return self.browser.key_pressed(event)
855
856    def __songlist_drag_data_recv(self, view, *args):
857        if self.browser.can_reorder:
858            songs = view.get_songs()
859            self.browser.reordered(songs)
860        self.songlist.clear_sort()
861
862    def __create_menu(self, player, library):
863        def add_view_items(ag):
864            act = Action(name="Information", label=_('_Information'),
865                         icon_name=Icons.DIALOG_INFORMATION)
866            act.connect('activate', self.__current_song_info)
867            ag.add_action(act)
868
869            act = Action(name="Jump", label=_('_Jump to Playing Song'),
870                         icon_name=Icons.GO_JUMP)
871            self.__jump_to_current(True, None, True)
872            act.connect('activate', self.__jump_to_current)
873            ag.add_action_with_accel(act, "<Primary>J")
874
875        def add_top_level_items(ag):
876            ag.add_action(Action(name="File", label=_("_File")))
877            ag.add_action(Action(name="Song", label=_("_Song")))
878            ag.add_action(Action(name="View", label=_('_View')))
879            ag.add_action(Action(name="Browse", label=_("_Browse")))
880            ag.add_action(Action(name="Control", label=_('_Control')))
881            ag.add_action(Action(name="Help", label=_('_Help')))
882
883        ag = Gtk.ActionGroup.new('QuodLibetWindowActions')
884        add_top_level_items(ag)
885        add_view_items(ag)
886
887        act = Action(name="AddFolders", label=_(u'_Add a Folder…'),
888                     icon_name=Icons.LIST_ADD)
889        act.connect('activate', self.open_chooser)
890        ag.add_action_with_accel(act, "<Primary>O")
891
892        act = Action(name="AddFiles", label=_(u'_Add a File…'),
893                     icon_name=Icons.LIST_ADD)
894        act.connect('activate', self.open_chooser)
895        ag.add_action(act)
896
897        act = Action(name="AddLocation", label=_(u'_Add a Location…'),
898                     icon_name=Icons.LIST_ADD)
899        act.connect('activate', self.open_location)
900        ag.add_action(act)
901
902        act = Action(name="BrowseLibrary", label=_('Open _Browser'),
903                     icon_name=Icons.EDIT_FIND)
904        ag.add_action(act)
905
906        act = Action(name="Preferences", label=_('_Preferences'),
907                     icon_name=Icons.PREFERENCES_SYSTEM)
908        act.connect('activate', self.__preferences)
909        ag.add_action(act)
910
911        act = Action(name="Plugins", label=_('_Plugins'),
912                     icon_name=Icons.SYSTEM_RUN)
913        act.connect('activate', self.__plugins)
914        ag.add_action(act)
915
916        act = Action(name="Quit", label=_('_Quit'),
917                     icon_name=Icons.APPLICATION_EXIT)
918        act.connect('activate', lambda *x: self.destroy())
919        ag.add_action_with_accel(act, "<Primary>Q")
920
921        act = Action(name="EditTags", label=_('Edit _Tags'),
922                     icon_name=Icons.DOCUMENT_PROPERTIES)
923        act.connect('activate', self.__current_song_prop)
924        ag.add_action(act)
925
926        act = Action(name="EditBookmarks", label=_(u"Edit Bookmarks…"))
927        connect_obj(act, 'activate', self.__edit_bookmarks,
928                           library.librarian, player)
929        ag.add_action_with_accel(act, "<Primary>B")
930
931        act = Action(name="Previous", label=_('Pre_vious'),
932                     icon_name=Icons.MEDIA_SKIP_BACKWARD)
933        act.connect('activate', self.__previous_song)
934        ag.add_action_with_accel(act, "<Primary>comma")
935
936        act = Action(name="PlayPause", label=_('_Play'),
937                     icon_name=Icons.MEDIA_PLAYBACK_START)
938        act.connect('activate', self.__play_pause)
939        ag.add_action_with_accel(act, "<Primary>space")
940
941        act = Action(name="Next", label=_('_Next'),
942                     icon_name=Icons.MEDIA_SKIP_FORWARD)
943        act.connect('activate', self.__next_song)
944        ag.add_action_with_accel(act, "<Primary>period")
945
946        act = ToggleAction(name="StopAfter", label=_("Stop After This Song"))
947        ag.add_action_with_accel(act, "<shift>space")
948
949        # access point for the tray icon
950        self.stop_after = act
951
952        act = Action(name="Shortcuts", label=_("_Keyboard Shortcuts"))
953        act.connect('activate', self.__keyboard_shortcuts)
954        ag.add_action_with_accel(act, "<Primary>question")
955
956        act = Action(name="About", label=_("_About"),
957                     icon_name=Icons.HELP_ABOUT)
958        act.connect('activate', self.__show_about)
959        ag.add_action_with_accel(act, None)
960
961        act = Action(name="OnlineHelp", label=_("Online Help"),
962                     icon_name=Icons.HELP_BROWSER)
963
964        def website_handler(*args):
965            util.website(const.ONLINE_HELP)
966
967        act.connect('activate', website_handler)
968        ag.add_action_with_accel(act, "F1")
969
970        act = Action(name="SearchHelp", label=_("Search Help"))
971
972        def search_help_handler(*args):
973            util.website(const.SEARCH_HELP)
974
975        act.connect('activate', search_help_handler)
976        ag.add_action_with_accel(act, None)
977
978        act = Action(name="CheckUpdates", label=_("_Check for Updates…"),
979                     icon_name=Icons.NETWORK_SERVER)
980
981        def check_updates_handler(*args):
982            d = UpdateDialog(self)
983            d.run()
984            d.destroy()
985
986        act.connect('activate', check_updates_handler)
987        ag.add_action_with_accel(act, None)
988
989        act = Action(
990            name="RefreshLibrary", label=_("_Scan Library"),
991            icon_name=Icons.VIEW_REFRESH)
992        act.connect('activate', self.__rebuild, False)
993        ag.add_action(act)
994
995        current = config.get("memory", "browser")
996        try:
997            browsers.get(current)
998        except ValueError:
999            current = browsers.name(browsers.default)
1000
1001        first_action = None
1002        for Kind in browsers.browsers:
1003            name = browsers.name(Kind)
1004            index = browsers.index(name)
1005            action_name = "View" + Kind.__name__
1006            act = RadioAction(name=action_name, label=Kind.accelerated_name,
1007                              value=index)
1008            act.join_group(first_action)
1009            first_action = first_action or act
1010            if name == current:
1011                act.set_active(True)
1012            ag.add_action_with_accel(act, "<Primary>%d" % ((index + 1) % 10,))
1013        assert first_action
1014        self._browser_action = first_action
1015
1016        def action_callback(view_action, current_action):
1017            current = browsers.name(
1018                browsers.get(current_action.get_current_value()))
1019            self._select_browser(view_action, current, library, player)
1020
1021        first_action.connect("changed", action_callback)
1022
1023        for Kind in browsers.browsers:
1024            action = "Browser" + Kind.__name__
1025            label = Kind.accelerated_name
1026            name = browsers.name(Kind)
1027            index = browsers.index(name)
1028            act = Action(name=action, label=label)
1029
1030            def browser_activate(action, Kind):
1031                LibraryBrowser.open(Kind, library, player)
1032
1033            act.connect('activate', browser_activate, Kind)
1034            ag.add_action_with_accel(act,
1035                                     "<Primary><alt>%d" % ((index + 1) % 10,))
1036
1037        ui = Gtk.UIManager()
1038        ui.insert_action_group(ag, -1)
1039
1040        menustr = MENU % {
1041            "views": browser_menu_items(),
1042            "browsers": secondary_browser_menu_items(),
1043            "filters_menu": FilterMenu.MENU
1044        }
1045        ui.add_ui_from_string(menustr)
1046        self._filter_menu = FilterMenu(library, player, ui)
1047
1048        # Cute. So. UIManager lets you attach tooltips, but when they're
1049        # for menu items, they just get ignored. So here I get to actually
1050        # attach them.
1051        ui.get_widget("/Menu/File/RefreshLibrary").set_tooltip_text(
1052            _("Check for changes in your library"))
1053
1054        return ui
1055
1056    def __show_about(self, *args):
1057        about = AboutDialog(self, app)
1058        about.run()
1059        about.destroy()
1060
1061    def select_browser(self, browser_key, library, player):
1062        """Given a browser name (see browsers.get()) changes the current
1063        browser.
1064
1065        Returns True if the passed browser ID is known and the change
1066        was initiated.
1067        """
1068
1069        try:
1070            Browser = browsers.get(browser_key)
1071        except ValueError:
1072            return False
1073
1074        action_name = "View%s" % Browser.__name__
1075        for action in self._browser_action.get_group():
1076            if action.get_name() == action_name:
1077                action.set_active(True)
1078                return True
1079        return False
1080
1081    def _select_browser(self, activator, current, library, player,
1082                        restore=False):
1083
1084        Browser = browsers.get(current)
1085
1086        window = self.get_window()
1087        if window:
1088            window.set_cursor(Gdk.Cursor.new(Gdk.CursorType.WATCH))
1089
1090        # Wait for the cursor to update before continuing
1091        while Gtk.events_pending():
1092            Gtk.main_iteration()
1093
1094        config.set("memory", "browser", current)
1095        if self.browser:
1096            if not (self.browser.uses_main_library and
1097                    Browser.uses_main_library):
1098                self.songlist.clear()
1099            container = self.browser.__container
1100            self.browser.unpack(container, self.songpane)
1101            if self.browser.accelerators:
1102                self.remove_accel_group(self.browser.accelerators)
1103            container.destroy()
1104            self.browser.destroy()
1105        self.browser = Browser(library)
1106        self.browser.connect('songs-selected',
1107            self.__browser_cb, library, player)
1108        self.browser.connect('songs-activated', self.__browser_activate)
1109        if restore:
1110            self.browser.restore()
1111            self.browser.activate()
1112        self.browser.finalize(restore)
1113        if not restore:
1114            self.browser.unfilter()
1115        if self.browser.can_reorder:
1116            self.songlist.enable_drop()
1117        elif self.browser.dropped:
1118            self.songlist.enable_drop(False)
1119        else:
1120            self.songlist.disable_drop()
1121        if self.browser.accelerators:
1122            self.add_accel_group(self.browser.accelerators)
1123
1124        container = self.browser.__container = self.browser.pack(self.songpane)
1125
1126        # Reset the cursor when done loading the browser
1127        if window:
1128            GLib.idle_add(window.set_cursor, None)
1129
1130        player.replaygain_profiles[1] = self.browser.replaygain_profiles
1131        player.reset_replaygain()
1132        self.__browserbox.add(container)
1133        container.show()
1134        self._filter_menu.set_browser(self.browser)
1135        self.__hide_headers()
1136
1137    def __update_paused(self, player, paused):
1138        menu = self.ui.get_widget("/Menu/Control/PlayPause")
1139        image = menu.get_image()
1140
1141        if paused:
1142            label, icon = _("_Play"), Icons.MEDIA_PLAYBACK_START
1143        else:
1144            label, icon = _("P_ause"), Icons.MEDIA_PLAYBACK_PAUSE
1145
1146        menu.set_label(label)
1147        image.set_from_icon_name(icon, Gtk.IconSize.MENU)
1148
1149    def __song_ended(self, player, song, stopped):
1150        # Check if the song should be removed, based on the
1151        # active filter of the current browser.
1152        active_filter = self.browser.active_filter
1153        if song and active_filter and not active_filter(song):
1154            iter_ = self.songlist.model.find(song)
1155            if iter_:
1156                self.songlist.remove_iters([iter_])
1157
1158        if self.stop_after.get_active():
1159            player.paused = True
1160            self.stop_after.set_active(False)
1161
1162    def __song_changed(self, library, songs, player):
1163        if player.info in songs:
1164            self.__update_title(player)
1165
1166    def __update_title(self, player):
1167        song = player.info
1168        title = "Quod Libet"
1169        if song:
1170            tag = config.gettext("settings", "window_title_pattern")
1171            if tag:
1172                title = song.comma(tag) + " - " + title
1173        self.set_title(title)
1174
1175    def __song_started(self, player, song):
1176        self.__update_title(player)
1177
1178        for wid in ["Control/Next", "Control/StopAfter",
1179                    "Song/EditTags", "Song/Information",
1180                    "Song/EditBookmarks", "Song/Jump"]:
1181            self.ui.get_widget('/Menu/' + wid).set_sensitive(bool(song))
1182
1183        # don't jump on stream changes (player.info != player.song)
1184        main_should_jump = (song and player.song is song and
1185                       not self.songlist._activated and
1186                       config.getboolean("settings", "jump") and
1187                       self.songlist.sourced)
1188        queue_should_jump = (song and player.song is song and
1189                        not self.qexpander.queue._activated and
1190                        config.getboolean("settings", "jump") and
1191                        self.qexpander.queue.sourced and
1192                        config.getboolean("memory", "queue_keep_songs"))
1193        if main_should_jump:
1194            self.__jump_to_current(False, self.songlist)
1195        elif queue_should_jump:
1196            self.__jump_to_current(False, self.qexpander.queue)
1197
1198    def __play_pause(self, *args):
1199        app.player.playpause()
1200
1201    def __jump_to_current(self, explicit, songlist=None, force_scroll=False):
1202        """Select/scroll to the current playing song in the playlist.
1203        If it can't be found tell the browser to properly fill the playlist
1204        with an appropriate selection containing the song.
1205
1206        explicit means that the jump request comes from the user and not
1207        from an event like song-started.
1208
1209        songlist is the songlist to be jumped within. Usually the main song
1210        list or the queue. If None, the currently sourced songlist will be
1211        used.
1212
1213        force_scroll will ask the browser to refill the playlist in any case.
1214        """
1215
1216        def idle_jump_to(song, select):
1217            ok = songlist.jump_to_song(song, select=select)
1218            if ok:
1219                songlist.grab_focus()
1220            return False
1221
1222        if not songlist:
1223            if (config.getboolean("memory", "queue_keep_songs")
1224                    and self.qexpander.queue.sourced):
1225                songlist = self.qexpander.queue
1226            else:
1227                songlist = self.songlist
1228
1229        if app.player is None:
1230            return
1231
1232        song = app.player.song
1233
1234        # We are not playing a song
1235        if song is None:
1236            return
1237
1238        if not force_scroll:
1239            ok = songlist.jump_to_song(song, select=explicit)
1240        else:
1241            assert explicit
1242            ok = False
1243
1244        if ok:
1245            songlist.grab_focus()
1246        elif explicit:
1247            # if we can't find it and the user requested it, try harder
1248            self.browser.scroll(song)
1249            # We need to wait until the browser has finished
1250            # scrolling/filling and the songlist is ready.
1251            # Not perfect, but works for now.
1252            GLib.idle_add(
1253                idle_jump_to, song, explicit, priority=GLib.PRIORITY_LOW)
1254
1255    def __next_song(self, *args):
1256        app.player.next()
1257
1258    def __previous_song(self, *args):
1259        app.player.previous()
1260
1261    def __rebuild(self, activator, force):
1262        scan_library(self.__library, force)
1263
1264    # Set up the preferences window.
1265    def __preferences(self, activator):
1266        window = PreferencesWindow(self)
1267        window.show()
1268
1269    def __plugins(self, activator):
1270        window = PluginWindow(self)
1271        window.show()
1272
1273    def open_location(self, action):
1274        name = GetStringDialog(self, _("Add a Location"),
1275            _("Enter the location of an audio file:"),
1276            button_label=_("_Add"), button_icon=Icons.LIST_ADD).run()
1277        if name:
1278            if not uri_is_valid(name):
1279                ErrorMessage(
1280                    self, _("Unable to add location"),
1281                    _("%s is not a valid location.") % (
1282                    util.bold(util.escape(name)))).run()
1283            elif not app.player.can_play_uri(name):
1284                ErrorMessage(
1285                    self, _("Unable to add location"),
1286                    _("%s uses an unsupported protocol.") % (
1287                    util.bold(util.escape(name)))).run()
1288            else:
1289                if name not in self.__library:
1290                    self.__library.add([RemoteFile(name)])
1291
1292    def open_chooser(self, action):
1293        if action.get_name() == "AddFolders":
1294            fns = choose_folders(self, _("Add Music"), _("_Add Folders"))
1295            if fns:
1296                # scan them
1297                copool.add(self.__library.scan, fns, cofuncid="library",
1298                           funcid="library")
1299        else:
1300            patterns = ["*" + path2fsn(k) for k in formats.loaders.keys()]
1301            choose_filter = create_chooser_filter(_("Music Files"), patterns)
1302            fns = choose_files(
1303                self, _("Add Music"), _("_Add Files"), choose_filter)
1304            if fns:
1305                for filename in fns:
1306                    self.__library.add_filename(filename)
1307
1308    def __songs_popup_menu(self, songlist):
1309        path, col = songlist.get_cursor()
1310        header = col.header_name
1311        menu = self.songlist.Menu(header, self.browser, self.__library)
1312        if menu is not None:
1313            return self.songlist.popup_menu(menu, 0,
1314                    Gtk.get_current_event_time())
1315
1316    def __current_song_prop(self, *args):
1317        song = app.player.song
1318        if song:
1319            librarian = self.__library.librarian
1320            window = SongProperties(librarian, [song], parent=self)
1321            window.show()
1322
1323    def __current_song_info(self, *args):
1324        song = app.player.song
1325        if song:
1326            librarian = self.__library.librarian
1327            window = Information(librarian, [song], self)
1328            window.show()
1329
1330    def __browser_activate(self, browser):
1331        app.player._reset()
1332
1333    def __browser_cb(self, browser, songs, sorted, library, player):
1334        if browser.background:
1335            bg = background_filter()
1336            if bg:
1337                songs = list(filter(bg, songs))
1338        self.songlist.set_songs(songs, sorted)
1339
1340        # After the first time the browser activates, which should always
1341        # happen if we start up and restore, restore the playing song.
1342        # Because the browser has send us songs we can be sure it has
1343        # registered all its libraries.
1344        if self.__first_browser_set:
1345            self.__first_browser_set = False
1346
1347            song = library.librarian.get(config.get("memory", "song"))
1348            seek_pos = config.getfloat("memory", "seek", 0)
1349            config.set("memory", "seek", 0)
1350            if song is not None:
1351                player.setup(self.playlist, song, seek_pos)
1352
1353            if self.__restore_cb:
1354                self.__restore_cb()
1355                self.__restore_cb = None
1356
1357    def __hide_headers(self, activator=None):
1358        for column in self.songlist.get_columns():
1359            if self.browser.headers is None:
1360                column.set_visible(True)
1361            else:
1362                for tag in util.tagsplit(column.header_name):
1363                    if tag in self.browser.headers:
1364                        column.set_visible(True)
1365                        break
1366                else:
1367                    column.set_visible(False)
1368
1369    def __cols_changed(self, songlist):
1370        headers = [col.header_name for col in songlist.get_columns()]
1371        try:
1372            headers.remove('~current')
1373        except ValueError:
1374            pass
1375        if len(headers) == len(get_columns()):
1376            # Not an addition or removal (handled separately)
1377            set_columns(headers)
1378            SongList.headers = headers
1379
1380    def __make_query(self, query):
1381        if self.browser.can_filter_text():
1382            self.browser.filter_text(query.encode('utf-8'))
1383            self.browser.activate()
1384
1385    def __set_totals(self, info, songs):
1386        length = sum(song.get("~#length", 0) for song in songs)
1387        t = self.browser.status_text(count=len(songs),
1388                                     time=util.format_time_preferred(length))
1389        self.statusbar.set_default_text(t)
1390