1# Copyright (C) 2008-2010 Adam Olsen 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2, or (at your option) 6# any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program; if not, write to the Free Software 15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16# 17# 18# The developers of the Exaile media player hereby grant permission 19# for non-GPL compatible GStreamer and Exaile plugins to be used and 20# distributed together with GStreamer and Exaile. This permission is 21# above and beyond the permissions granted by the GPL license by which 22# Exaile is covered. If you modify this code, you may extend this 23# exception to your version of the code, but you are not obligated to 24# do so. If you do not wish to do so, delete this exception statement 25# from your version. 26 27import logging 28import os 29import os.path 30import tempfile 31import threading 32 33import cairo 34from gi.repository import Gio 35from gi.repository import Gdk 36from gi.repository import GdkPixbuf 37from gi.repository import GLib 38from gi.repository import GObject 39from gi.repository import Gtk 40 41from xl import common, event, providers, settings, xdg 42from xl.covers import MANAGER as COVER_MANAGER 43from xl.nls import gettext as _ 44from xlgui.widgets import dialogs, menu 45from xlgui import guiutil 46from xlgui.guiutil import pixbuf_from_data 47 48logger = logging.getLogger(__name__) 49 50 51def save_pixbuf(pixbuf, path, type_): 52 """Save a pixbuf to a local file. 53 54 :param pixbuf: Pixbuf to save 55 :type pixbuf: GdkPixbuf.Pixbuf 56 :param path: Path of file to save to 57 :type path: str 58 :param type_: Type of image file. See GdkPixbuf.savev for valid values. 59 :type type_: str 60 :return: None 61 """ 62 # This wraps the horrible GdkPixbuf.savev API. Can be removed if one day 63 # PyGObject provides an override. 64 pixbuf.savev(path, type_, [None], []) 65 66 67class CoverManager(GObject.GObject): 68 """ 69 Cover manager window 70 """ 71 72 __gsignals__ = { 73 'prefetch-started': (GObject.SignalFlags.RUN_LAST, None, ()), 74 'prefetch-progress': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_INT,)), 75 'prefetch-completed': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_INT,)), 76 'fetch-started': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_INT,)), 77 'fetch-completed': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_INT,)), 78 'fetch-progress': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_INT,)), 79 'cover-fetched': ( 80 GObject.SignalFlags.RUN_LAST, 81 None, 82 (GObject.TYPE_PYOBJECT, GdkPixbuf.Pixbuf), 83 ), 84 } 85 86 def __init__(self, parent, collection): 87 """ 88 Initializes the window 89 """ 90 GObject.GObject.__init__(self) 91 92 # List of identifiers of albums without covers 93 self.outstanding = [] 94 # Map of album identifiers and their tracks 95 self.album_tracks = {} 96 97 self.outstanding_text = _('{outstanding} covers left to fetch') 98 self.completed_text = _('All covers fetched') 99 self.cover_size = (90, 90) 100 self.default_cover_pixbuf = pixbuf_from_data( 101 COVER_MANAGER.get_default_cover(), self.cover_size 102 ) 103 104 builder = Gtk.Builder() 105 builder.add_from_file(xdg.get_data_path('ui', 'covermanager.ui')) 106 builder.connect_signals(self) 107 108 self.window = builder.get_object('window') 109 self.window.set_transient_for(parent) 110 111 self.message = dialogs.MessageBar( 112 parent=builder.get_object('content_area'), buttons=Gtk.ButtonsType.CLOSE 113 ) 114 115 self.previews_box = builder.get_object('previews_box') 116 self.model = builder.get_object('covers_model') 117 # Map of album identifiers and model paths 118 self.model_path_cache = {} 119 self.menu = CoverMenu(self) 120 self.menu.attach_to_widget(self.previews_box, lambda menu, widget: True) 121 122 self.progress_bar = builder.get_object('progressbar') 123 self.progress_bar.set_text(_('Collecting albums and covers...')) 124 self.progress_bar.pulse_timeout = GLib.timeout_add( 125 100, self.on_progress_pulse_timeout 126 ) 127 self.close_button = builder.get_object('close_button') 128 self.stop_button = builder.get_object('stop_button') 129 self.stop_button.set_sensitive(False) 130 self.fetch_button = builder.get_object('fetch_button') 131 132 self.window.show_all() 133 134 self.stopper = threading.Event() 135 thread = threading.Thread( 136 target=self.prefetch, name='CoverPrefetch', args=(collection,) 137 ) 138 thread.daemon = True 139 thread.start() 140 141 def prefetch(self, collection): 142 """ 143 Collects all albums and sets the list of outstanding items 144 """ 145 albums = set() 146 147 for track in collection: 148 if self.stopper.is_set(): 149 return 150 151 try: 152 artist = track.get_tag_raw('artist')[0] 153 album = track.get_tag_raw('album')[0] 154 except TypeError: 155 continue 156 157 if not album or not artist: 158 continue 159 160 album = (artist, album) 161 162 try: 163 self.album_tracks[album].append(track) 164 except KeyError: 165 self.album_tracks[album] = [track] 166 167 albums.add(album) 168 169 albums = sorted(albums) 170 171 outstanding = [] 172 # Speed up the following loop 173 get_cover = COVER_MANAGER.get_cover 174 default_cover_pixbuf = self.default_cover_pixbuf 175 cover_size = self.cover_size 176 177 self.emit('prefetch-started') 178 179 for i, album in enumerate(albums): 180 if self.stopper.is_set(): 181 return 182 183 cover_data = get_cover(self.album_tracks[album][0], set_only=True) 184 cover_pixbuf = pixbuf_from_data(cover_data) if cover_data else None 185 186 try: 187 thumbnail_pixbuf = cover_pixbuf.scale_simple( 188 *cover_size, interp_type=GdkPixbuf.InterpType.BILINEAR 189 ) 190 except AttributeError: # cover_pixbuf is None 191 thumbnail_pixbuf = default_cover_pixbuf 192 outstanding.append(album) 193 194 label = '{0} - {1}'.format(*album) 195 iter = self.model.append((album, thumbnail_pixbuf, label)) 196 self.model_path_cache[album] = self.model.get_path(iter) 197 198 self.emit('prefetch-progress', i + 1) 199 200 self.outstanding = outstanding 201 self.emit('prefetch-completed', len(self.outstanding)) 202 203 def fetch(self): 204 """ 205 Collects covers for all outstanding items 206 """ 207 self.emit('fetch-started', len(self.outstanding)) 208 209 # Speed up the following loop 210 get_cover = COVER_MANAGER.get_cover 211 save = COVER_MANAGER.save 212 213 for i, album in enumerate(self.outstanding[:]): 214 if self.stopper.is_set(): 215 # Allow for "fetch-completed" signal to be emitted 216 break 217 218 cover_data = get_cover(self.album_tracks[album][0], save_cover=True) 219 cover_pixbuf = pixbuf_from_data(cover_data) if cover_data else None 220 221 self.emit('fetch-progress', i + 1) 222 223 if not cover_pixbuf: 224 continue 225 226 self.outstanding.remove(album) 227 self.emit('cover-fetched', album, cover_pixbuf) 228 229 if i % 50 == 0: 230 logger.debug('Saving cover database') 231 save() 232 233 logger.debug('Saving cover database') 234 save() 235 236 self.emit('fetch-completed', len(self.outstanding)) 237 238 def show_cover(self): 239 """ 240 Shows the currently selected cover 241 """ 242 paths = self.previews_box.get_selected_items() 243 244 if paths: 245 path = paths[0] 246 album = self.model[path][0] 247 track = self.album_tracks[album][0] # Arbitrary track in album 248 cover_data = COVER_MANAGER.get_cover(track, set_only=True) 249 cover_pixbuf = pixbuf_from_data(cover_data) if cover_data else None 250 251 # Do not bother showing the dialog if there is no cover 252 if cover_pixbuf: 253 savedir = Gio.File.new_for_uri(track.get_loc_for_io()).get_parent() 254 if savedir: 255 savedir = savedir.get_path() 256 cover_window = CoverWindow(self.window, cover_pixbuf, album[1], savedir) 257 cover_window.show_all() 258 259 def fetch_cover(self): 260 """ 261 Shows the cover chooser for the currently selected album 262 """ 263 paths = self.previews_box.get_selected_items() 264 265 if paths: 266 path = paths[0] 267 album = self.model[path][0] 268 track = self.album_tracks[album][0] 269 cover_chooser = CoverChooser(self.window, track) 270 # Make sure we're updating the correct album after selection 271 cover_chooser.path = path 272 cover_chooser.connect('cover-chosen', self.on_cover_chosen) 273 274 def remove_cover(self): 275 """ 276 Removes the cover of the currently selected album 277 """ 278 paths = self.previews_box.get_selected_items() 279 280 if paths: 281 path = paths[0] 282 album = self.model[path][0] 283 track = self.album_tracks[album][0] 284 COVER_MANAGER.remove_cover(track) 285 self.model[path][1] = self.default_cover_pixbuf 286 287 @common.idle_add() 288 def do_prefetch_started(self): 289 """ 290 Sets the widget states to prefetching 291 """ 292 self.previews_box.set_model(None) 293 self.model.clear() 294 self.previews_box.set_sensitive(False) 295 self.fetch_button.set_sensitive(False) 296 self.progress_bar.set_fraction(0) 297 GLib.source_remove(self.progress_bar.pulse_timeout) 298 299 @common.idle_add() 300 def do_prefetch_completed(self, outstanding): 301 """ 302 Sets the widget states to ready for fetching 303 """ 304 self.previews_box.set_sensitive(True) 305 self.previews_box.set_model(self.model) 306 self.fetch_button.set_sensitive(True) 307 self.progress_bar.set_fraction(0) 308 self.progress_bar.set_text( 309 self.outstanding_text.format(outstanding=outstanding) 310 ) 311 312 @common.idle_add() 313 def do_prefetch_progress(self, progress): 314 """ 315 Updates the wiedgets to reflect the processed album 316 """ 317 fraction = progress / float(len(self.album_tracks)) 318 self.progress_bar.set_fraction(fraction) 319 320 @common.idle_add() 321 def do_fetch_started(self, outstanding): 322 """ 323 Sets the widget states to fetching 324 """ 325 self.previews_box.set_sensitive(False) 326 self.stop_button.set_sensitive(True) 327 self.fetch_button.set_sensitive(False) 328 self.progress_bar.set_fraction(0) 329 # We need float for the fraction during progress 330 self.progress_bar.outstanding_total = float(outstanding) 331 332 @common.idle_add() 333 def do_fetch_completed(self, outstanding): 334 """ 335 Sets the widget states to ready for fetching 336 """ 337 self.previews_box.set_sensitive(True) 338 self.stop_button.set_sensitive(False) 339 340 if outstanding > 0: 341 # If there are covers left for some reason, allow re-fetch 342 self.fetch_button.set_sensitive(True) 343 344 self.progress_bar.set_fraction(0) 345 346 @common.idle_add() 347 def do_fetch_progress(self, progress): 348 """ 349 Updates the widgets to reflect the processed album 350 """ 351 outstanding = len(self.outstanding) 352 353 if outstanding > 0: 354 progress_text = self.outstanding_text.format(outstanding=outstanding) 355 else: 356 progress_text = self.completed_text 357 358 self.progress_bar.set_text(progress_text) 359 360 fraction = progress / self.progress_bar.outstanding_total 361 self.progress_bar.set_fraction(fraction) 362 363 @common.idle_add() 364 def do_cover_fetched(self, album, pixbuf): 365 """ 366 Updates the widgets to reflect the newly fetched cover 367 """ 368 path = self.model_path_cache[album] 369 self.model[path][1] = pixbuf.scale_simple( 370 *self.cover_size, interp_type=GdkPixbuf.InterpType.BILINEAR 371 ) 372 373 def on_cover_chosen(self, cover_chooser, track, cover_data): 374 """ 375 Updates the cover of the current album after user selection 376 """ 377 path = cover_chooser.path 378 379 if path: 380 album = self.model[path][0] 381 pixbuf = pixbuf_from_data(cover_data) 382 383 self.emit('cover-fetched', album, pixbuf) 384 385 try: 386 self.outstanding.remove(album) 387 except ValueError: 388 pass 389 else: 390 outstanding = len(self.outstanding) 391 392 if outstanding > 0: 393 progress_text = self.outstanding_text.format( 394 outstanding=outstanding 395 ) 396 else: 397 progress_text = self.completed_text 398 399 self.progress_bar.set_text(progress_text) 400 401 def on_previews_box_item_activated(self, iconview, path): 402 """ 403 Shows the currently selected cover 404 """ 405 self.show_cover() 406 407 def on_previews_box_button_press_event(self, widget, e): 408 """ 409 Shows the cover menu upon click 410 """ 411 path = self.previews_box.get_path_at_pos(int(e.x), int(e.y)) 412 413 if path: 414 self.previews_box.select_path(path) 415 416 if e.triggers_context_menu(): 417 self.menu.popup(None, None, None, None, 3, e.time) 418 419 def on_previews_box_popup_menu(self, menu): 420 """ 421 Shows the cover menu upon keyboard interaction 422 """ 423 paths = self.previews_box.get_selected_items() 424 425 if paths: 426 self.menu.popup(None, None, None, None, 0, Gtk.get_current_event_time()) 427 428 def on_previews_box_query_tooltip(self, widget, x, y, keyboard_mode, tooltip): 429 """ 430 Custom tooltip display to prevent markup errors 431 (e.g. due to album names containing "<") 432 """ 433 x, y = self.previews_box.convert_widget_to_bin_window_coords(x, y) 434 path = self.previews_box.get_path_at_pos(x, y) 435 436 if path: 437 tooltip.set_text(self.model[path][2]) 438 self.previews_box.set_tooltip_item(tooltip, path) 439 440 return True 441 442 return False 443 444 def on_progress_pulse_timeout(self): 445 """ 446 Updates the progress during prefetching 447 """ 448 self.progress_bar.pulse() 449 450 return True 451 452 def on_close_button_clicked(self, button): 453 """ 454 Stops the current fetching process and closes the dialog 455 """ 456 self.stopper.set() 457 self.window.destroy() 458 459 # Free some memory 460 self.model.clear() 461 del self.outstanding 462 del self.album_tracks 463 del self.model_path_cache 464 465 def on_stop_button_clicked(self, button): 466 """ 467 Stops the current fetching process 468 """ 469 self.stopper.set() 470 471 def on_fetch_button_clicked(self, button): 472 """ 473 Starts the cover fetching process 474 """ 475 self.stopper.clear() 476 thread = threading.Thread(target=self.fetch, name='CoverFetch') 477 thread.daemon = True 478 thread.start() 479 480 def on_window_delete_event(self, window, e): 481 """ 482 Stops the current fetching process and closes the dialog 483 """ 484 self.close_button.clicked() 485 486 return True 487 488 489class CoverMenu(menu.Menu): 490 """ 491 Cover menu 492 """ 493 494 def __init__(self, widget): 495 """ 496 Initializes the menu 497 """ 498 menu.Menu.__init__(self, widget) 499 self.w = widget 500 501 self.add_simple(_('Show Cover'), self.on_show_clicked) 502 self.add_simple(_('Fetch Cover'), self.on_fetch_clicked) 503 self.add_simple(_('Remove Cover'), self.on_remove_clicked) 504 505 def on_show_clicked(self, *e): 506 """ 507 Shows the current cover 508 """ 509 self.w.show_cover() 510 511 def on_fetch_clicked(self, *e): 512 self.w.fetch_cover() 513 514 def on_remove_clicked(self, *e): 515 self.w.remove_cover() 516 517 518class CoverWidget(Gtk.EventBox): 519 """ 520 Represents the cover widget displayed by the track information 521 """ 522 523 __gsignals__ = {'cover-found': (GObject.SignalFlags.RUN_LAST, None, (object,))} 524 525 def __init__(self, image): 526 """ 527 Initializes the widget 528 529 :param image: the image to wrap 530 :type image: :class:`Gtk.Image` 531 """ 532 GObject.GObject.__init__(self) 533 534 self.image = image 535 self.cover_data = None 536 self.menu = CoverMenu(self) 537 self.menu.attach_to_widget(self) 538 539 self.filename = None 540 541 guiutil.gtk_widget_replace(image, self) 542 self.add(self.image) 543 self.set_track(None) 544 self.image.show() 545 546 event.add_callback(self.on_quit_application, 'quit_application') 547 548 if settings.get_option('gui/use_alpha', False): 549 self.set_app_paintable(True) 550 551 def destroy(self): 552 """ 553 Cleanups 554 """ 555 if self.filename is not None and os.path.exists(self.filename): 556 os.remove(self.filename) 557 self.filename = None 558 559 event.remove_callback(self.on_quit_application, 'quit-application') 560 561 def set_track(self, track): 562 """ 563 Fetches album covers, and displays them 564 """ 565 566 self.__track = track 567 568 self.set_blank() 569 self.drag_dest_set( 570 Gtk.DestDefaults.ALL, 571 [Gtk.TargetEntry.new('text/uri-list', 0, 0)], 572 Gdk.DragAction.COPY | Gdk.DragAction.DEFAULT | Gdk.DragAction.MOVE, 573 ) 574 575 @common.threaded 576 def __get_cover(): 577 578 fetch = not settings.get_option('covers/automatic_fetching', True) 579 cover_data = COVER_MANAGER.get_cover(track, set_only=fetch) 580 581 if not cover_data: 582 return 583 584 GLib.idle_add(self.on_cover_chosen, None, track, cover_data) 585 586 if track is not None: 587 __get_cover() 588 589 def show_cover(self): 590 """ 591 Shows the current cover 592 """ 593 if not self.cover_data: 594 return 595 596 pixbuf = pixbuf_from_data(self.cover_data) 597 598 if pixbuf: 599 savedir = Gio.File.new_for_uri(self.__track.get_loc_for_io()).get_parent() 600 if savedir: 601 savedir = savedir.get_path() 602 window = CoverWindow( 603 self.get_toplevel(), 604 pixbuf, 605 self.__track.get_tag_display('album'), 606 savedir, 607 ) 608 window.show_all() 609 610 def fetch_cover(self): 611 """ 612 Fetches a cover for the current track 613 """ 614 if not self.__track: 615 return 616 617 window = CoverChooser(self.get_toplevel(), self.__track) 618 window.connect('cover-chosen', self.on_cover_chosen) 619 620 def remove_cover(self): 621 """ 622 Removes the cover for the current track from the database 623 """ 624 COVER_MANAGER.remove_cover(self.__track) 625 self.set_blank() 626 627 def set_blank(self): 628 """ 629 Sets the default cover to display 630 """ 631 632 self.drag_dest_unset() 633 634 pixbuf = pixbuf_from_data(COVER_MANAGER.get_default_cover()) 635 self.image.set_from_pixbuf(pixbuf) 636 self.set_drag_source_enabled(False) 637 self.cover_data = None 638 639 self.emit('cover-found', None) 640 641 def set_drag_source_enabled(self, enabled): 642 """ 643 Changes the behavior for drag and drop 644 645 :param drag_enabled: Whether to allow 646 drag to other applications 647 :type enabled: bool 648 """ 649 if enabled == getattr(self, '__drag_source_enabled', None): 650 return 651 652 if enabled: 653 self.drag_source_set( 654 Gdk.ModifierType.BUTTON1_MASK, 655 [Gtk.TargetEntry.new('text/uri-list', 0, 0)], 656 Gdk.DragAction.DEFAULT | Gdk.DragAction.MOVE, 657 ) 658 else: 659 self.drag_source_unset() 660 661 self.__drag_source_enabled = enabled 662 663 def do_button_press_event(self, event): 664 """ 665 Called when someone clicks on the cover widget 666 """ 667 if self.__track is None or self.get_toplevel() is None: 668 return 669 670 if event.type == Gdk.EventType._2BUTTON_PRESS: 671 self.show_cover() 672 elif event.triggers_context_menu(): 673 self.menu.popup(event) 674 675 def do_expose_event(self, event): 676 """ 677 Paints alpha transparency 678 """ 679 opacity = 1 - settings.get_option('gui/transparency', 0.3) 680 context = self.props.window.cairo_create() 681 background = self.style.bg[Gtk.StateType.NORMAL] 682 context.set_source_rgba( 683 float(background.red) / 256 ** 2, 684 float(background.green) / 256 ** 2, 685 float(background.blue) / 256 ** 2, 686 opacity, 687 ) 688 context.set_operator(cairo.OPERATOR_SOURCE) 689 context.paint() 690 691 Gtk.EventBox.do_expose_event(self, event) 692 693 def do_drag_begin(self, context): 694 """ 695 Sets the cover as drag icon 696 """ 697 self.drag_source_set_icon_pixbuf(self.image.get_pixbuf()) 698 699 def do_drag_data_get(self, context, selection, info, time): 700 """ 701 Fills the selection with the current cover 702 """ 703 if self.filename is None: 704 self.filename = tempfile.mkstemp(prefix='exaile_cover_')[1] 705 706 pixbuf = pixbuf_from_data(self.cover_data) 707 save_pixbuf(pixbuf, self.filename, 'png') 708 selection.set_uris([Gio.File.new_for_path(self.filename).get_uri()]) 709 710 def do_drag_data_delete(self, context): 711 """ 712 Cleans up after drag from cover widget 713 """ 714 if self.filename is not None and os.path.exists(self.filename): 715 os.remove(self.filename) 716 self.filename = None 717 718 def do_drag_data_received(self, context, x, y, selection, info, time): 719 """ 720 Sets the cover based on the dragged data 721 """ 722 if self.__track is not None: 723 uri = selection.get_uris()[0] 724 db_string = 'localfile:%s' % uri 725 726 try: 727 stream = Gio.File.new_for_uri(uri).read() 728 except GLib.Error: 729 return 730 731 self.cover_data = stream.read() 732 width = settings.get_option('gui/cover_width', 100) 733 pixbuf = pixbuf_from_data(self.cover_data, (width, width)) 734 735 if pixbuf is not None: 736 self.image.set_from_pixbuf(pixbuf) 737 COVER_MANAGER.set_cover(self.__track, db_string, self.cover_data) 738 739 def on_cover_chosen(self, object, track, cover_data): 740 """ 741 Called when a cover is selected 742 from the coverchooser 743 """ 744 745 if self.__track != track: 746 return 747 748 width = settings.get_option('gui/cover_width', 100) 749 pixbuf = pixbuf_from_data(cover_data, (width, width)) 750 self.image.set_from_pixbuf(pixbuf) 751 self.set_drag_source_enabled(True) 752 self.cover_data = cover_data 753 754 self.emit('cover-found', pixbuf) 755 756 def on_track_tags_changed(self, e, track, tags): 757 """ 758 Updates the displayed cover upon tag changes 759 """ 760 if self.__track == track: 761 cover_data = COVER_MANAGER.get_cover(track) 762 763 if not cover_data: 764 return 765 766 GLib.idle_add(self.on_cover_chosen, None, cover_data) 767 768 def on_quit_application(self, type, exaile, nothing): 769 """ 770 Cleans up temporary files 771 """ 772 if self.filename is not None and os.path.exists(self.filename): 773 os.remove(self.filename) 774 self.filename = None 775 776 777class CoverWindow: 778 """Shows the cover in a simple image viewer""" 779 780 def __init__(self, parent, pixbuf, album=None, savedir=None): 781 """Initializes and shows the cover 782 783 :param parent: Parent window to attach to 784 :type parent: Gtk.Window 785 :param pixbuf: Pixbuf of the cover image 786 :type pixbuf: GdkPixbuf.Pixbuf 787 :param album: Album title 788 :type album: basestring 789 :param savedir: Initial directory for the Save As functionality 790 :type savedir: basestring 791 """ 792 self.builder = Gtk.Builder() 793 self.builder.add_from_file(xdg.get_data_path('ui', 'coverwindow.ui')) 794 self.builder.connect_signals(self) 795 796 self.cover_window = self.builder.get_object('CoverWindow') 797 self.layout = self.builder.get_object('layout') 798 self.toolbar = self.builder.get_object('toolbar') 799 self.save_as_button = self.builder.get_object('save_as_button') 800 self.zoom_in_button = self.builder.get_object('zoom_in_button') 801 self.zoom_out_button = self.builder.get_object('zoom_out_button') 802 self.zoom_100_button = self.builder.get_object('zoom_100_button') 803 self.zoom_fit_button = self.builder.get_object('zoom_fit_button') 804 self.close_button = self.builder.get_object('close_button') 805 self.image = self.builder.get_object('image') 806 self.statusbar = self.builder.get_object('statusbar') 807 self.scrolledwindow = self.builder.get_object('scrolledwindow') 808 self.scrolledwindow.set_hadjustment(self.layout.get_hadjustment()) 809 self.scrolledwindow.set_vadjustment(self.layout.get_vadjustment()) 810 811 if album: 812 title = _('Cover for %s') % album 813 else: 814 title = _('Cover') 815 self.savedir = savedir 816 817 self.cover_window.set_title(title) 818 self.cover_window.set_transient_for(parent) 819 self.cover_window_width = 500 820 tb_min_height, tb_natural_height = self.toolbar.get_preferred_height() 821 sb_min_height, sb_natural_height = self.statusbar.get_preferred_height() 822 self.cover_window_height = 500 + tb_natural_height + sb_natural_height 823 self.cover_window.set_default_size( 824 self.cover_window_width, self.cover_window_height 825 ) 826 827 self.image_original_pixbuf = pixbuf 828 self.image_pixbuf = self.image_original_pixbuf 829 self.min_percent = 1 830 self.max_percent = 500 831 self.ratio = 1.5 832 self.image_interp = GdkPixbuf.InterpType.BILINEAR 833 self.image_fitted = True 834 self.set_ratio_to_fit() 835 self.update_widgets() 836 837 def show_all(self): 838 self.cover_window.show_all() 839 840 def available_image_width(self): 841 """Returns the available horizontal space for the image""" 842 return self.cover_window.get_size()[0] 843 844 def available_image_height(self): 845 """Returns the available vertical space for the image""" 846 tb_min_height, tb_natural_height = self.toolbar.get_preferred_height() 847 sb_min_height, sb_natural_height = self.statusbar.get_preferred_height() 848 849 return self.cover_window.get_size()[1] - tb_natural_height - sb_natural_height 850 851 def center_image(self): 852 """Centers the image in the layout""" 853 new_x = max( 854 0, (self.available_image_width() - self.image_pixbuf.get_width()) // 2 855 ) 856 new_y = max( 857 0, (self.available_image_height() - self.image_pixbuf.get_height()) // 2 858 ) 859 self.layout.move(self.image, new_x, new_y) 860 861 def update_widgets(self): 862 """Updates image, layout, scrolled window, tool bar and status bar""" 863 window = self.cover_window.get_window() 864 if window: 865 window.freeze_updates() 866 self.apply_zoom() 867 self.layout.set_size( 868 self.image_pixbuf.get_width(), self.image_pixbuf.get_height() 869 ) 870 if self.image_fitted or ( 871 self.image_pixbuf.get_width() == self.available_image_width() 872 and self.image_pixbuf.get_height() == self.available_image_height() 873 ): 874 self.scrolledwindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) 875 else: 876 self.scrolledwindow.set_policy( 877 Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC 878 ) 879 percent = int(100 * self.image_ratio) 880 message = _("{width}x{height} pixels ({zoom}%)").format( 881 width=self.image_original_pixbuf.get_width(), 882 height=self.image_original_pixbuf.get_height(), 883 zoom=percent, 884 ) 885 self.zoom_in_button.set_sensitive(percent < self.max_percent) 886 self.zoom_out_button.set_sensitive(percent > self.min_percent) 887 self.statusbar.pop(self.statusbar.get_context_id('')) 888 self.statusbar.push(self.statusbar.get_context_id(''), message) 889 self.image.set_from_pixbuf(self.image_pixbuf) 890 self.center_image() 891 if window: 892 window.thaw_updates() 893 894 def apply_zoom(self): 895 """Scales the image if needed""" 896 new_width = int(self.image_original_pixbuf.get_width() * self.image_ratio) 897 new_height = int(self.image_original_pixbuf.get_height() * self.image_ratio) 898 if ( 899 new_width != self.image_pixbuf.get_width() 900 or new_height != self.image_pixbuf.get_height() 901 ): 902 self.image_pixbuf = self.image_original_pixbuf.scale_simple( 903 new_width, new_height, self.image_interp 904 ) 905 906 def set_ratio_to_fit(self): 907 """Calculates and sets the needed ratio to show the full image""" 908 width_ratio = ( 909 float(self.image_original_pixbuf.get_width()) / self.available_image_width() 910 ) 911 height_ratio = ( 912 float(self.image_original_pixbuf.get_height()) 913 / self.available_image_height() 914 ) 915 self.image_ratio = 1 / max(1, width_ratio, height_ratio) 916 917 def on_key_press(self, widget, event, data=None): 918 """ 919 Closes the cover window when Escape or Ctrl+W is pressed 920 """ 921 if event.keyval == Gdk.KEY_Escape or ( 922 event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_w 923 ): 924 widget.destroy() 925 926 def on_save_as_button_clicked(self, widget): 927 """ 928 Saves image to user-specified location 929 """ 930 dialog = Gtk.FileChooserDialog( 931 _("Save File"), 932 self.cover_window, 933 Gtk.FileChooserAction.SAVE, 934 ( 935 Gtk.STOCK_CANCEL, 936 Gtk.ResponseType.CANCEL, 937 Gtk.STOCK_SAVE, 938 Gtk.ResponseType.ACCEPT, 939 ), 940 ) 941 names = settings.get_option('covers/localfile/preferred_names') 942 filename = (names[0] if names else 'cover') + '.png' 943 dialog.set_current_name(filename) 944 if self.savedir: 945 dialog.set_current_folder(self.savedir) 946 if dialog.run() == Gtk.ResponseType.ACCEPT: 947 filename = dialog.get_filename() 948 lowfilename = filename.lower() 949 if lowfilename.endswith('.jpg') or lowfilename.endswith('.jpeg'): 950 type_ = 'jpeg' 951 else: 952 type_ = 'png' 953 save_pixbuf(self.image_pixbuf, filename, type_) 954 dialog.destroy() 955 956 def on_zoom_in_button_clicked(self, widget): 957 """ 958 Zooms into the image 959 """ 960 self.image_fitted = False 961 self.image_ratio *= self.ratio 962 self.update_widgets() 963 964 def on_zoom_out_button_clicked(self, widget): 965 """ 966 Zooms out of the image 967 """ 968 self.image_fitted = False 969 self.image_ratio *= 1 / self.ratio 970 self.update_widgets() 971 972 def on_zoom_100_button_clicked(self, widget): 973 """ 974 Restores the original image zoom 975 """ 976 self.image_fitted = False 977 self.image_ratio = 1 978 self.update_widgets() 979 980 def on_zoom_fit_button_clicked(self, widget): 981 """ 982 Zooms the image to fit the window width 983 """ 984 self.image_fitted = True 985 self.set_ratio_to_fit() 986 self.update_widgets() 987 988 def on_close_button_clicked(self, widget): 989 """ 990 Hides the window 991 """ 992 self.cover_window.hide() 993 994 def cover_window_size_allocate(self, widget, allocation): 995 if ( 996 self.cover_window_width != allocation.width 997 or self.cover_window_height != allocation.height 998 ): 999 if self.image_fitted: 1000 self.set_ratio_to_fit() 1001 self.update_widgets() 1002 self.cover_window_width = allocation.width 1003 self.cover_window_height = allocation.height 1004 1005 1006class CoverChooser(GObject.GObject): 1007 """ 1008 Fetches all album covers for a string, and allows the user to choose 1009 one out of the list 1010 """ 1011 1012 __gsignals__ = { 1013 'covers-fetched': (GObject.SignalFlags.RUN_LAST, None, (object,)), 1014 'cover-chosen': (GObject.SignalFlags.RUN_LAST, None, (object, object)), 1015 } 1016 1017 def __init__(self, parent, track, search=None): 1018 """ 1019 Expects the parent control, a track, an an optional search string 1020 """ 1021 GObject.GObject.__init__(self) 1022 self.parent = parent 1023 self.builder = Gtk.Builder() 1024 self.builder.add_from_file(xdg.get_data_path('ui', 'coverchooser.ui')) 1025 self.builder.connect_signals(self) 1026 self.window = self.builder.get_object('CoverChooser') 1027 1028 self.window.set_title( 1029 _("Cover options for %(artist)s - %(album)s") 1030 % { 1031 'artist': track.get_tag_display('artist'), 1032 'album': track.get_tag_display('album'), 1033 } 1034 ) 1035 self.window.set_transient_for(parent) 1036 1037 self.message = dialogs.MessageBar( 1038 parent=self.builder.get_object('main_container'), 1039 buttons=Gtk.ButtonsType.CLOSE, 1040 ) 1041 self.message.connect('response', self.on_message_response) 1042 1043 self.track = track 1044 self.covers = [] 1045 self.current = 0 1046 1047 self.cover = guiutil.ScalableImageWidget() 1048 self.cover.set_image_size(350, 350) 1049 1050 self.cover_image_box = self.builder.get_object('cover_image_box') 1051 1052 self.stack = self.builder.get_object('stack') 1053 self.stack_ready = self.builder.get_object('stack_ready') 1054 1055 self.size_label = self.builder.get_object('size_label') 1056 self.source_label = self.builder.get_object('source_label') 1057 1058 self.covers_model = self.builder.get_object('covers_model') 1059 self.previews_box = self.builder.get_object('previews_box') 1060 self.previews_box.set_no_show_all(True) 1061 self.previews_box.hide() 1062 self.previews_box.set_model(None) 1063 1064 self.set_button = self.builder.get_object('set_button') 1065 self.set_button.set_sensitive(False) 1066 1067 self.window.show_all() 1068 1069 self.stopper = threading.Event() 1070 self.fetcher_thread = threading.Thread( 1071 target=self.fetch_cover, name='Coverfetcher' 1072 ) 1073 self.fetcher_thread.start() 1074 1075 def fetch_cover(self): 1076 """ 1077 Searches for covers for the current track 1078 """ 1079 db_strings = COVER_MANAGER.find_covers(self.track) 1080 1081 if db_strings: 1082 for db_string in db_strings: 1083 if self.stopper.is_set(): 1084 return 1085 1086 coverdata = COVER_MANAGER.get_cover_data(db_string) 1087 # Pre-render everything for faster display later 1088 pixbuf = pixbuf_from_data(coverdata) 1089 1090 if pixbuf: 1091 self.covers_model.append( 1092 [ 1093 (db_string, coverdata), 1094 pixbuf, 1095 pixbuf.scale_simple(50, 50, GdkPixbuf.InterpType.BILINEAR), 1096 ] 1097 ) 1098 1099 self.emit('covers-fetched', db_strings) 1100 1101 def do_covers_fetched(self, db_strings): 1102 """ 1103 Finishes the dialog setup after all covers have been fetched 1104 """ 1105 if self.stopper.is_set(): 1106 return 1107 1108 self.stack.set_visible_child(self.stack_ready) 1109 self.previews_box.set_model(self.covers_model) 1110 1111 if db_strings: 1112 self.cover_image_box.pack_start(self.cover, True, True, 0) 1113 self.cover.show() 1114 self.set_button.set_sensitive(True) 1115 1116 # Show thumbnail bar if more than one cover was found 1117 if len(db_strings) > 1: 1118 self.previews_box.set_no_show_all(False) 1119 self.previews_box.show_all() 1120 1121 # Try to select the current cover of the track, fallback to first 1122 track_db_string = COVER_MANAGER.get_db_string(self.track) 1123 position = ( 1124 db_strings.index(track_db_string) 1125 if track_db_string in db_strings 1126 else 0 1127 ) 1128 self.previews_box.select_path(Gtk.TreePath(position)) 1129 else: 1130 self.builder.get_object('stack').hide() 1131 self.builder.get_object('actions_box').hide() 1132 self.message.show_warning( 1133 _('No covers found.'), 1134 _( 1135 'None of the enabled sources has a cover for this track, try enabling more sources.' 1136 ), 1137 ) 1138 1139 def on_cancel_button_clicked(self, button): 1140 """ 1141 Closes the cover chooser 1142 """ 1143 # Notify the fetcher thread to stop 1144 self.stopper.set() 1145 1146 self.window.destroy() 1147 1148 def on_set_button_clicked(self, button): 1149 """ 1150 Chooses the current cover and saves it to the database 1151 """ 1152 paths = self.previews_box.get_selected_items() 1153 1154 if paths: 1155 path = paths[0] 1156 coverdata = self.covers_model[path][0] 1157 1158 COVER_MANAGER.set_cover(self.track, coverdata[0], coverdata[1]) 1159 1160 self.emit('cover-chosen', self.track, coverdata[1]) 1161 self.window.destroy() 1162 1163 def on_previews_box_selection_changed(self, iconview): 1164 """ 1165 Switches the currently displayed cover 1166 """ 1167 paths = self.previews_box.get_selected_items() 1168 1169 if paths: 1170 path = paths[0] 1171 db_string = self.covers_model[path][0] 1172 source = db_string[0].split(':', 1)[0] 1173 provider = providers.get_provider('covers', source) 1174 pixbuf = self.covers_model[path][1] 1175 1176 self.cover.set_image_pixbuf(pixbuf) 1177 self.size_label.set_text( 1178 _('{width}x{height} pixels').format( 1179 width=pixbuf.get_width(), height=pixbuf.get_height() 1180 ) 1181 ) 1182 # Display readable title of the provider, fallback to its name 1183 self.source_label.set_text(getattr(provider, 'title', source)) 1184 1185 self.set_button.set_sensitive(True) 1186 else: 1187 self.set_button.set_sensitive(False) 1188 1189 def on_previews_box_item_activated(self, iconview, path): 1190 """ 1191 Triggers selecting the current cover 1192 """ 1193 self.set_button.clicked() 1194 1195 def on_message_response(self, widget, response): 1196 """ 1197 Handles the response for closing 1198 """ 1199 if response == Gtk.ResponseType.CLOSE: 1200 self.window.destroy() 1201