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