1# Copyright 2004-2007 Joe Wreschnig, Michael Urman, Iñigo Serna 2# 2009-2010 Steven Robertson 3# 2012-2018 Nick Boultbee 4# 2009-2014 Christoph Reiter 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 11from __future__ import absolute_import 12 13import os 14 15from gi.repository import Gtk, Pango, Gdk, Gio 16 17from .prefs import Preferences, DEFAULT_PATTERN_TEXT 18from quodlibet.browsers.albums.models import (AlbumModel, 19 AlbumFilterModel, AlbumSortModel) 20from quodlibet.browsers.albums.main import (get_cover_size, 21 AlbumTagCompletion, PreferencesButton, VisibleUpdate) 22 23import quodlibet 24from quodlibet import app 25from quodlibet import ngettext 26from quodlibet import config 27from quodlibet import qltk 28from quodlibet import util 29from quodlibet import _ 30from quodlibet.browsers import Browser 31from quodlibet.browsers._base import DisplayPatternMixin 32from quodlibet.query import Query 33from quodlibet.qltk.information import Information 34from quodlibet.qltk.properties import SongProperties 35from quodlibet.qltk.songsmenu import SongsMenu 36from quodlibet.qltk.x import MenuItem, Align, ScrolledWindow, RadioMenuItem 37from quodlibet.qltk.x import SymbolicIconImage 38from quodlibet.qltk.searchbar import SearchBarBox 39from quodlibet.qltk.menubutton import MenuButton 40from quodlibet.qltk import Icons 41from quodlibet.util import connect_destroy 42from quodlibet.util.library import background_filter 43from quodlibet.util import connect_obj 44from quodlibet.qltk.cover import get_no_cover_pixbuf 45from quodlibet.qltk.image import add_border_widget, get_surface_for_pixbuf 46from quodlibet.qltk import popup_menu_at_widget 47 48 49class PreferencesButton(PreferencesButton): 50 def __init__(self, browser, model): 51 Gtk.HBox.__init__(self) 52 53 sort_orders = [ 54 (_("_Title"), self.__compare_title), 55 (_("_Artist"), self.__compare_artist), 56 (_("_Date"), self.__compare_date), 57 (_("_Genre"), self.__compare_genre), 58 (_("_Rating"), self.__compare_rating), 59 ] 60 61 menu = Gtk.Menu() 62 63 sort_item = Gtk.MenuItem( 64 label=_(u"Sort _by…"), use_underline=True) 65 sort_menu = Gtk.Menu() 66 67 active = config.getint('browsers', 'album_sort', 1) 68 69 item = None 70 for i, (label, func) in enumerate(sort_orders): 71 item = RadioMenuItem(group=item, label=label, 72 use_underline=True) 73 model.set_sort_func(100 + i, func) 74 if i == active: 75 model.set_sort_column_id(100 + i, Gtk.SortType.ASCENDING) 76 item.set_active(True) 77 item.connect("toggled", 78 util.DeferredSignal(self.__sort_toggled_cb), 79 model, i) 80 sort_menu.append(item) 81 82 sort_item.set_submenu(sort_menu) 83 menu.append(sort_item) 84 85 pref_item = MenuItem(_("_Preferences"), Icons.PREFERENCES_SYSTEM) 86 menu.append(pref_item) 87 connect_obj(pref_item, "activate", Preferences, browser) 88 89 menu.show_all() 90 91 button = MenuButton( 92 SymbolicIconImage(Icons.EMBLEM_SYSTEM, Gtk.IconSize.MENU), 93 arrow=True) 94 button.set_menu(menu) 95 self.pack_start(button, True, True, 0) 96 97 98class IconView(Gtk.IconView): 99 # XXX: disable height for width etc. Speeds things up and doesn't seem 100 # to break anyhting in a scrolled window 101 102 def do_get_preferred_width_for_height(self, height): 103 return (1, 1) 104 105 def do_get_preferred_width(self): 106 return (1, 1) 107 108 def do_get_preferred_height(self): 109 return (1, 1) 110 111 def do_get_preferred_height_for_width(self, width): 112 return (1, 1) 113 114 115class CoverGrid(Browser, util.InstanceTracker, VisibleUpdate, 116 DisplayPatternMixin): 117 __gsignals__ = Browser.__gsignals__ 118 __model = None 119 __last_render = None 120 __last_render_surface = None 121 122 _PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "album_pattern") 123 _DEFAULT_PATTERN_TEXT = DEFAULT_PATTERN_TEXT 124 STAR = ["~people", "album"] 125 126 name = _("Cover Grid") 127 accelerated_name = _("_Cover Grid") 128 keys = ["CoverGrid"] 129 priority = 5 130 131 def pack(self, songpane): 132 container = self.songcontainer 133 container.pack1(self, True, False) 134 container.pack2(songpane, True, False) 135 return container 136 137 def unpack(self, container, songpane): 138 container.remove(songpane) 139 container.remove(self) 140 141 @classmethod 142 def init(klass, library): 143 super(CoverGrid, klass).load_pattern() 144 145 def finalize(self, restored): 146 if not restored: 147 # Select the "All Albums" album, which is None 148 self.select_by_func(lambda r: r[0].album is None, one=True) 149 150 @classmethod 151 def _destroy_model(klass): 152 klass.__model.destroy() 153 klass.__model = None 154 155 @classmethod 156 def toggle_text(klass): 157 on = config.getboolean("browsers", "album_text", True) 158 for covergrid in klass.instances(): 159 covergrid.__text_cells.set_visible(on) 160 covergrid.view.queue_resize() 161 162 @classmethod 163 def toggle_wide(klass): 164 wide = config.getboolean("browsers", "covergrid_wide", False) 165 for covergrid in klass.instances(): 166 covergrid.songcontainer.set_orientation( 167 Gtk.Orientation.HORIZONTAL if wide 168 else Gtk.Orientation.VERTICAL) 169 170 @classmethod 171 def update_mag(klass): 172 mag = config.getfloat("browsers", "covergrid_magnification", 3.) 173 for covergrid in klass.instances(): 174 covergrid.__cover.set_property('width', get_cover_size() * mag + 8) 175 covergrid.__cover.set_property('height', 176 get_cover_size() * mag + 8) 177 covergrid.view.set_item_width(get_cover_size() * mag + 8) 178 covergrid.view.queue_resize() 179 covergrid.redraw() 180 181 def redraw(self): 182 model = self.__model 183 for iter_, item in model.iterrows(): 184 album = item.album 185 if album is not None: 186 item.scanned = False 187 model.row_changed(model.get_path(iter_), iter_) 188 189 @classmethod 190 def _init_model(klass, library): 191 klass.__model = AlbumModel(library) 192 klass.__library = library 193 194 @classmethod 195 def _refresh_albums(klass, albums): 196 """We signal all other open album views that we changed something 197 (Only needed for the cover atm) so they redraw as well.""" 198 if klass.__library: 199 klass.__library.albums.refresh(albums) 200 201 @util.cached_property 202 def _no_cover(self): 203 """Returns a cairo surface representing a missing cover""" 204 205 mag = config.getfloat("browsers", "covergrid_magnification", 3.) 206 207 cover_size = get_cover_size() 208 scale_factor = self.get_scale_factor() * mag 209 pb = get_no_cover_pixbuf(cover_size, cover_size, scale_factor) 210 return get_surface_for_pixbuf(self, pb) 211 212 def __init__(self, library): 213 Browser.__init__(self, spacing=6) 214 self.set_orientation(Gtk.Orientation.VERTICAL) 215 self.songcontainer = qltk.paned.ConfigRVPaned( 216 "browsers", "covergrid_pos", 0.4) 217 if config.getboolean("browsers", "covergrid_wide", False): 218 self.songcontainer.set_orientation(Gtk.Orientation.HORIZONTAL) 219 220 self._register_instance() 221 if self.__model is None: 222 self._init_model(library) 223 224 self._cover_cancel = Gio.Cancellable() 225 226 self.scrollwin = sw = ScrolledWindow() 227 sw.set_shadow_type(Gtk.ShadowType.IN) 228 model_sort = AlbumSortModel(model=self.__model) 229 model_filter = AlbumFilterModel(child_model=model_sort) 230 self.view = view = IconView(model_filter) 231 #view.set_item_width(get_cover_size() + 12) 232 self.view.set_row_spacing(config.getint("browsers", "row_spacing", 6)) 233 self.view.set_column_spacing(config.getint("browsers", 234 "column_spacing", 6)) 235 self.view.set_item_padding(config.getint("browsers", 236 "item_padding", 6)) 237 self.view.set_has_tooltip(True) 238 self.view.connect("query-tooltip", self._show_tooltip) 239 240 self.__bg_filter = background_filter() 241 self.__filter = None 242 model_filter.set_visible_func(self.__parse_query) 243 244 mag = config.getfloat("browsers", "covergrid_magnification", 3.) 245 246 self.view.set_item_width(get_cover_size() * mag + 8) 247 248 self.__cover = render = Gtk.CellRendererPixbuf() 249 render.set_property('width', get_cover_size() * mag + 8) 250 render.set_property('height', get_cover_size() * mag + 8) 251 view.pack_start(render, False) 252 253 def cell_data_pb(view, cell, model, iter_, no_cover): 254 item = model.get_value(iter_) 255 256 if item.album is None: 257 surface = None 258 elif item.cover: 259 pixbuf = item.cover 260 pixbuf = add_border_widget(pixbuf, self.view) 261 surface = get_surface_for_pixbuf(self, pixbuf) 262 # don't cache, too much state has an effect on the result 263 self.__last_render_surface = None 264 else: 265 surface = no_cover 266 267 if self.__last_render_surface == surface: 268 return 269 self.__last_render_surface = surface 270 cell.set_property("surface", surface) 271 272 view.set_cell_data_func(render, cell_data_pb, self._no_cover) 273 274 self.__text_cells = render = Gtk.CellRendererText() 275 render.set_visible(config.getboolean("browsers", "album_text", True)) 276 render.set_property('alignment', Pango.Alignment.CENTER) 277 render.set_property('xalign', 0.5) 278 render.set_property('ellipsize', Pango.EllipsizeMode.END) 279 view.pack_start(render, False) 280 281 def cell_data(view, cell, model, iter_, data): 282 album = model.get_album(iter_) 283 284 if album is None: 285 text = "<b>%s</b>" % _("All Albums") 286 text += "\n" + ngettext("%d album", "%d albums", 287 len(model) - 1) % (len(model) - 1) 288 markup = text 289 else: 290 markup = self.display_pattern % album 291 292 if self.__last_render == markup: 293 return 294 self.__last_render = markup 295 cell.markup = markup 296 cell.set_property('markup', markup) 297 298 view.set_cell_data_func(render, cell_data, None) 299 300 view.set_selection_mode(Gtk.SelectionMode.MULTIPLE) 301 sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 302 sw.add(view) 303 304 view.connect('item-activated', self.__play_selection, None) 305 306 self.__sig = connect_destroy( 307 view, 'selection-changed', 308 util.DeferredSignal(self.__update_songs, owner=self)) 309 310 targets = [("text/x-quodlibet-songs", Gtk.TargetFlags.SAME_APP, 1), 311 ("text/uri-list", 0, 2)] 312 targets = [Gtk.TargetEntry.new(*t) for t in targets] 313 314 view.drag_source_set( 315 Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY) 316 view.connect("drag-data-get", self.__drag_data_get) # NOT WORKING 317 connect_obj(view, 'button-press-event', 318 self.__rightclick, view, library) 319 connect_obj(view, 'popup-menu', self.__popup, view, library) 320 321 self.accelerators = Gtk.AccelGroup() 322 search = SearchBarBox(completion=AlbumTagCompletion(), 323 accel_group=self.accelerators) 324 search.connect('query-changed', self.__update_filter) 325 connect_obj(search, 'focus-out', lambda w: w.grab_focus(), view) 326 self.__search = search 327 328 prefs = PreferencesButton(self, model_sort) 329 search.pack_start(prefs, False, True, 0) 330 self.pack_start(Align(search, left=6, top=6), False, True, 0) 331 self.pack_start(sw, True, True, 0) 332 333 self.connect("destroy", self.__destroy) 334 335 self.enable_row_update(view, sw, self.view) 336 337 self.__update_filter() 338 339 self.connect('key-press-event', self.__key_pressed, library.librarian) 340 341 if app.cover_manager: 342 connect_destroy( 343 app.cover_manager, "cover-changed", self._cover_changed) 344 345 self.show_all() 346 347 def _cover_changed(self, manager, songs): 348 model = self.__model 349 songs = set(songs) 350 for iter_, item in model.iterrows(): 351 album = item.album 352 if album is not None and songs & album.songs: 353 item.scanned = False 354 model.row_changed(model.get_path(iter_), iter_) 355 356 def __key_pressed(self, widget, event, librarian): 357 if qltk.is_accel(event, "<Primary>I"): 358 songs = self.__get_selected_songs() 359 if songs: 360 window = Information(librarian, songs, self) 361 window.show() 362 return True 363 elif qltk.is_accel(event, "<Primary>Return", "<Primary>KP_Enter"): 364 qltk.enqueue(self.__get_selected_songs(sort=True)) 365 return True 366 elif qltk.is_accel(event, "<alt>Return"): 367 songs = self.__get_selected_songs() 368 if songs: 369 window = SongProperties(librarian, songs, self) 370 window.show() 371 return True 372 return False 373 374 def _row_needs_update(self, model, iter_): 375 item = model.get_value(iter_) 376 return item.album is not None and not item.scanned 377 378 def _update_row(self, filter_model, iter_): 379 sort_model = filter_model.get_model() 380 model = sort_model.get_model() 381 iter_ = filter_model.convert_iter_to_child_iter(iter_) 382 iter_ = sort_model.convert_iter_to_child_iter(iter_) 383 tref = Gtk.TreeRowReference.new(model, model.get_path(iter_)) 384 mag = config.getfloat("browsers", "covergrid_magnification", 3.) 385 386 def callback(): 387 path = tref.get_path() 388 if path is not None: 389 model.row_changed(path, model.get_iter(path)) 390 # XXX: icon view seems to ignore row_changed signals for pixbufs.. 391 self.queue_draw() 392 393 item = model.get_value(iter_) 394 scale_factor = self.get_scale_factor() * mag 395 item.scan_cover(scale_factor=scale_factor, 396 callback=callback, 397 cancel=self._cover_cancel) 398 399 def __destroy(self, browser): 400 self._cover_cancel.cancel() 401 self.disable_row_update() 402 403 self.view.set_model(None) 404 405 klass = type(browser) 406 if not klass.instances(): 407 klass._destroy_model() 408 409 def __update_filter(self, entry=None, text=None, scroll_up=True, 410 restore=False): 411 model = self.view.get_model() 412 413 self.__filter = None 414 query = self.__search.get_query(self.STAR) 415 if not query.matches_all: 416 self.__filter = query.search 417 self.__bg_filter = background_filter() 418 419 self.__inhibit() 420 421 # If we're hiding "All Albums", then there will always 422 # be something to filter — probably there's a better 423 # way to implement this 424 425 if (not restore or self.__filter or self.__bg_filter) or (not 426 config.getboolean("browsers", "covergrid_all", True)): 427 model.refilter() 428 429 self.__uninhibit() 430 431 def __parse_query(self, model, iter_, data): 432 f, b = self.__filter, self.__bg_filter 433 album = model.get_album(iter_) 434 435 if f is None and b is None and album is not None: 436 return True 437 else: 438 if album is None: 439 return config.getboolean("browsers", "covergrid_all", True) 440 elif b is None: 441 return f(album) 442 elif f is None: 443 return b(album) 444 else: 445 return b(album) and f(album) 446 447 def __search_func(self, model, column, key, iter_, data): 448 album = model.get_album(iter_) 449 if album is None: 450 return config.getboolean("browsers", "covergrid_all", True) 451 key = key.lower() 452 title = album.title.lower() 453 if key in title: 454 return False 455 if config.getboolean("browsers", "album_substrings"): 456 people = (p.lower() for p in album.list("~people")) 457 for person in people: 458 if key in person: 459 return False 460 return True 461 462 def __rightclick(self, view, event, library): 463 x = int(event.x) 464 y = int(event.y) 465 current_path = view.get_path_at_pos(x, y) 466 if event.button == Gdk.BUTTON_SECONDARY and current_path: 467 if not view.path_is_selected(current_path): 468 view.unselect_all() 469 view.select_path(current_path) 470 self.__popup(view, library) 471 472 def __popup(self, view, library): 473 474 albums = self.__get_selected_albums() 475 songs = self.__get_songs_from_albums(albums) 476 477 items = [] 478 num = len(albums) 479 button = MenuItem( 480 ngettext("Reload album _cover", "Reload album _covers", num), 481 Icons.VIEW_REFRESH) 482 button.connect('activate', self.__refresh_album, view) 483 items.append(button) 484 485 menu = SongsMenu(library, songs, items=[items]) 486 menu.show_all() 487 popup_menu_at_widget(menu, view, 488 Gdk.BUTTON_SECONDARY, 489 Gtk.get_current_event_time()) 490 491 def _show_tooltip(self, widget, x, y, keyboard_tip, tooltip): 492 w = self.scrollwin.get_hadjustment().get_value() 493 z = self.scrollwin.get_vadjustment().get_value() 494 path = widget.get_path_at_pos(int(x + w), int(y + z)) 495 if path is None: 496 return False 497 model = widget.get_model() 498 iter = model.get_iter(path) 499 album = model.get_album(iter) 500 if album is None: 501 text = "<b>%s</b>" % _("All Albums") 502 text += "\n" + ngettext("%d album", 503 "%d albums", len(model) - 1) % (len(model) - 1) 504 markup = text 505 else: 506 markup = self.display_pattern % album 507 tooltip.set_markup(markup) 508 return True 509 510 def __refresh_album(self, menuitem, view): 511 items = self.__get_selected_items() 512 for item in items: 513 item.scanned = False 514 model = self.view.get_model() 515 for iter_, item in model.iterrows(): 516 if item in items: 517 model.row_changed(model.get_path(iter_), iter_) 518 519 def __get_selected_items(self): 520 model = self.view.get_model() 521 paths = self.view.get_selected_items() 522 return model.get_items(paths) 523 524 def __get_selected_albums(self): 525 model = self.view.get_model() 526 paths = self.view.get_selected_items() 527 return model.get_albums(paths) 528 529 def __get_songs_from_albums(self, albums, sort=True): 530 # Sort first by how the albums appear in the model itself, 531 # then within the album using the default order. 532 songs = [] 533 if sort: 534 for album in albums: 535 songs.extend(sorted(album.songs, key=lambda s: s.sort_key)) 536 else: 537 for album in albums: 538 songs.extend(album.songs) 539 return songs 540 541 def __get_selected_songs(self, sort=True): 542 albums = self.__get_selected_albums() 543 return self.__get_songs_from_albums(albums, sort) 544 545 def __drag_data_get(self, view, ctx, sel, tid, etime): 546 songs = self.__get_selected_songs() 547 if tid == 1: 548 qltk.selection_set_songs(sel, songs) 549 else: 550 sel.set_uris([song("~uri") for song in songs]) 551 552 def __play_selection(self, view, indices, col): 553 self.songs_activated() 554 555 def active_filter(self, song): 556 for album in self.__get_selected_albums(): 557 if song in album.songs: 558 return True 559 return False 560 561 def can_filter_text(self): 562 return True 563 564 def filter_text(self, text): 565 self.__search.set_text(text) 566 if Query(text).is_parsable: 567 self.__update_filter(self.__search, text) 568 # self.__inhibit() 569 #self.view.set_cursor((0,), None, False) 570 # self.__uninhibit() 571 self.activate() 572 573 def get_filter_text(self): 574 return self.__search.get_text() 575 576 def can_filter(self, key): 577 # Numerics are different for collections, and although title works, 578 # it's not of much use here. 579 if key is not None and (key.startswith("~#") or key == "title"): 580 return False 581 return super(CoverGrid, self).can_filter(key) 582 583 def can_filter_albums(self): 584 return True 585 586 def list_albums(self): 587 model = self.view.get_model() 588 return [row[0].album.key for row in model if row[0].album] 589 590 def select_by_func(self, func, scroll=True, one=False): 591 model = self.view.get_model() 592 if not model: 593 return False 594 595 selection = self.view.get_selected_items() 596 first = True 597 for row in model: 598 if func(row): 599 if not first: 600 selection.select_path(row.path) 601 continue 602 self.view.unselect_all() 603 self.view.select_path(row.path) 604 self.view.set_cursor(row.path, None, False) 605 if scroll: 606 self.view.scroll_to_path(row.path, True, 0.5, 0.5) 607 first = False 608 if one: 609 break 610 return not first 611 612 def filter_albums(self, values): 613 self.__inhibit() 614 changed = self.select_by_func( 615 lambda r: r[0].album and r[0].album.key in values) 616 self.view.grab_focus() 617 self.__uninhibit() 618 if changed: 619 self.activate() 620 621 def unfilter(self): 622 self.filter_text("") 623 624 def activate(self): 625 self.view.emit('selection-changed') 626 627 def __inhibit(self): 628 self.view.handler_block(self.__sig) 629 630 def __uninhibit(self): 631 self.view.handler_unblock(self.__sig) 632 633 def restore(self): 634 text = config.gettext("browsers", "query_text") 635 entry = self.__search 636 entry.set_text(text) 637 638 # update_filter expects a parsable query 639 if Query(text).is_parsable: 640 self.__update_filter(entry, text, scroll_up=False, restore=True) 641 642 keys = config.gettext("browsers", "covergrid", "").split("\n") 643 644 self.__inhibit() 645 if keys != [""]: 646 def select_fun(row): 647 album = row[0].album 648 if not album: # all 649 return False 650 return album.str_key in keys 651 self.select_by_func(select_fun) 652 else: 653 self.select_by_func(lambda r: r[0].album is None) 654 self.__uninhibit() 655 656 def scroll(self, song): 657 album_key = song.album_key 658 select = lambda r: r[0].album and r[0].album.key == album_key 659 self.select_by_func(select, one=True) 660 661 def __get_config_string(self): 662 model = self.view.get_model() 663 paths = self.view.get_selected_items() 664 665 # All is selected 666 if model.contains_all(paths): 667 return "" 668 669 # All selected albums 670 albums = model.get_albums(paths) 671 672 confval = "\n".join((a.str_key for a in albums)) 673 # ConfigParser strips a trailing \n so we move it to the front 674 if confval and confval[-1] == "\n": 675 confval = "\n" + confval[:-1] 676 return confval 677 678 def save(self): 679 conf = self.__get_config_string() 680 config.settext("browsers", "covergrid", conf) 681 text = self.__search.get_text() 682 config.settext("browsers", "query_text", text) 683 684 def __update_songs(self, selection): 685 songs = self.__get_selected_songs(sort=False) 686 self.songs_selected(songs) 687