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