from datetime import timedelta from functools import partial from random import randint from typing import cast, List, Sequence from gi.repository import Gio, GLib, GObject, Gtk, Pango from ..adapters import ( AdapterManager, api_objects as API, CacheMissError, SongCacheStatus, ) from ..config import AppConfiguration from ..ui import util from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage class ArtistsPanel(Gtk.Paned): """Defines the arist panel.""" __gsignals__ = { "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), ), } def __init__(self, *args, **kwargs): Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) self.artist_list = ArtistList() self.pack1(self.artist_list, False, False) self.artist_detail_panel = ArtistDetailPanel() self.artist_detail_panel.connect( "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.artist_detail_panel.connect( "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) self.pack2(self.artist_detail_panel, True, False) def update(self, app_config: AppConfiguration, force: bool = False): self.artist_list.update(app_config=app_config) self.artist_detail_panel.update(app_config=app_config) class _ArtistModel(GObject.GObject): artist_id = GObject.Property(type=str) name = GObject.Property(type=str) album_count = GObject.Property(type=int) def __init__(self, artist: API.Artist): GObject.GObject.__init__(self) self.artist_id = artist.id self.name = artist.name self.album_count = artist.album_count or 0 class ArtistList(Gtk.Box): def __init__(self): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) list_actions = Gtk.ActionBar() self.refresh_button = IconButton( "view-refresh-symbolic", "Refresh list of artists" ) self.refresh_button.connect("clicked", lambda *a: self.update(force=True)) list_actions.pack_end(self.refresh_button) self.add(list_actions) self.error_container = Gtk.Box() self.add(self.error_container) self.loading_indicator = Gtk.ListBox() spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) spinner = Gtk.Spinner(name="artist-list-spinner", active=True) spinner_row.add(spinner) self.loading_indicator.add(spinner_row) self.pack_start(self.loading_indicator, False, False, 0) list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow: label_text = [f"{util.esc(model.name)}"] album_count = model.album_count if album_count: label_text.append( "{} {}".format(album_count, util.pluralize("album", album_count)) ) row = Gtk.ListBoxRow( action_name="app.go-to-artist", action_target=GLib.Variant("s", model.artist_id), ) row.add( Gtk.Label( label="\n".join(label_text), use_markup=True, margin=12, halign=Gtk.Align.START, ellipsize=Pango.EllipsizeMode.END, ) ) row.show_all() return row self.artists_store = Gio.ListStore() self.list = Gtk.ListBox(name="artist-list") self.list.bind_model(self.artists_store, create_artist_row) list_scroll_window.add(self.list) self.pack_start(list_scroll_window, True, True, 0) _app_config = None @util.async_callback( AdapterManager.get_artists, before_download=lambda self: self.loading_indicator.show_all(), on_failure=lambda self, e: self.loading_indicator.hide(), ) def update( self, artists: Sequence[API.Artist], app_config: AppConfiguration = None, is_partial: bool = False, **kwargs, ): if app_config: self._app_config = app_config self.refresh_button.set_sensitive(not app_config.offline_mode) for c in self.error_container.get_children(): self.error_container.remove(c) if is_partial: load_error = LoadError( "Artist list", "load artists", has_data=len(artists) > 0, offline_mode=( self._app_config.offline_mode if self._app_config else False ), ) self.error_container.pack_start(load_error, True, True, 0) self.error_container.show_all() else: self.error_container.hide() new_store = [] selected_idx = None for i, artist in enumerate(artists): if ( self._app_config and self._app_config.state and self._app_config.state.selected_artist_id == artist.id ): selected_idx = i new_store.append(_ArtistModel(artist)) util.diff_model_store(self.artists_store, new_store) # Preserve selection if selected_idx is not None: row = self.list.get_row_at_index(selected_idx) self.list.select_row(row) self.loading_indicator.hide() class ArtistDetailPanel(Gtk.Box): """Defines the artists list.""" __gsignals__ = { "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), ), } update_order_token = 0 artist_details_expanded = False def __init__(self, *args, **kwargs): super().__init__( *args, name="artist-detail-panel", orientation=Gtk.Orientation.VERTICAL, **kwargs, ) self.albums: Sequence[API.Album] = [] self.artist_id = None # Artist info panel self.big_info_panel = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel" ) self.artist_artwork = SpinnerImage( loading=False, image_name="artist-album-artwork", spinner_name="artist-artwork-spinner", image_size=300, ) self.big_info_panel.pack_start(self.artist_artwork, False, False, 0) # Action buttons, name, comment, number of songs, etc. artist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) artist_details_box.pack_start(Gtk.Box(), True, False, 0) self.artist_indicator = self.make_label(name="artist-indicator") artist_details_box.add(self.artist_indicator) self.artist_name = self.make_label( name="artist-name", ellipsize=Pango.EllipsizeMode.END ) artist_details_box.add(self.artist_name) self.artist_bio = self.make_label( name="artist-bio", justify=Gtk.Justification.LEFT ) self.artist_bio.set_line_wrap(True) artist_details_box.add(self.artist_bio) self.similar_artists_scrolledwindow = Gtk.ScrolledWindow() similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.similar_artists_label = self.make_label(name="similar-artists") similar_artists_box.add(self.similar_artists_label) self.similar_artists_button_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL ) similar_artists_box.add(self.similar_artists_button_box) self.similar_artists_scrolledwindow.add(similar_artists_box) artist_details_box.add(self.similar_artists_scrolledwindow) self.artist_stats = self.make_label(name="artist-stats") artist_details_box.add(self.artist_stats) self.play_shuffle_buttons = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, name="playlist-play-shuffle-buttons", ) self.play_button = IconButton( "media-playback-start-symbolic", label="Play All", relief=True ) self.play_button.connect("clicked", self.on_play_all_clicked) self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0) self.shuffle_button = IconButton( "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True ) self.shuffle_button.connect("clicked", self.on_shuffle_all_button) self.play_shuffle_buttons.pack_start(self.shuffle_button, False, False, 5) artist_details_box.add(self.play_shuffle_buttons) self.big_info_panel.pack_start(artist_details_box, True, True, 0) # Action buttons action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.artist_action_buttons = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=10 ) self.download_all_button = IconButton( "folder-download-symbolic", "Download all songs by this artist" ) self.download_all_button.connect("clicked", self.on_download_all_click) self.artist_action_buttons.add(self.download_all_button) self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info") self.refresh_button.connect("clicked", self.on_view_refresh_click) self.artist_action_buttons.add(self.refresh_button) action_buttons_container.pack_start( self.artist_action_buttons, False, False, 10 ) action_buttons_container.pack_start(Gtk.Box(), True, True, 0) expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.expand_collapse_button = IconButton( "pan-up-symbolic", "Expand playlist details" ) self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click) expand_button_container.pack_end(self.expand_collapse_button, False, False, 0) action_buttons_container.add(expand_button_container) self.big_info_panel.pack_start(action_buttons_container, False, False, 5) self.pack_start(self.big_info_panel, False, True, 0) self.error_container = Gtk.Box() self.add(self.error_container) self.album_list_scrolledwindow = Gtk.ScrolledWindow() self.albums_list = AlbumsListWithSongs() self.albums_list.connect( "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.album_list_scrolledwindow.add(self.albums_list) self.pack_start(self.album_list_scrolledwindow, True, True, 0) def update(self, app_config: AppConfiguration): self.artist_id = app_config.state.selected_artist_id self.offline_mode = app_config.offline_mode if app_config.state.selected_artist_id is None: self.big_info_panel.hide() self.album_list_scrolledwindow.hide() self.play_shuffle_buttons.hide() else: self.update_order_token += 1 self.album_list_scrolledwindow.show() self.update_artist_view( app_config.state.selected_artist_id, app_config=app_config, order_token=self.update_order_token, ) self.refresh_button.set_sensitive(not self.offline_mode) self.download_all_button.set_sensitive(not self.offline_mode) @util.async_callback( AdapterManager.get_artist, before_download=lambda self: self.set_all_loading(True), on_failure=lambda self, e: self.set_all_loading(False), ) def update_artist_view( self, artist: API.Artist, app_config: AppConfiguration, force: bool = False, order_token: int = None, is_partial: bool = False, ): if order_token != self.update_order_token: return self.big_info_panel.show_all() if app_config: self.artist_details_expanded = app_config.state.artist_details_expanded up_down = "up" if self.artist_details_expanded else "down" self.expand_collapse_button.set_icon(f"pan-{up_down}-symbolic") self.expand_collapse_button.set_tooltip_text( "Collapse" if self.artist_details_expanded else "Expand" ) self.artist_name.set_markup(util.esc(f"{artist.name}")) self.artist_name.set_tooltip_text(artist.name) if self.artist_details_expanded: self.artist_artwork.get_style_context().remove_class("collapsed") self.artist_name.get_style_context().remove_class("collapsed") self.artist_indicator.set_text("ARTIST") self.artist_stats.set_markup(self.format_stats(artist)) if artist.biography: self.artist_bio.set_markup(util.esc(artist.biography)) self.artist_bio.show() else: self.artist_bio.hide() if len(artist.similar_artists or []) > 0: self.similar_artists_label.set_markup("Similar Artists: ") for c in self.similar_artists_button_box.get_children(): self.similar_artists_button_box.remove(c) for similar_artist in (artist.similar_artists or [])[:5]: self.similar_artists_button_box.add( Gtk.LinkButton( label=similar_artist.name, name="similar-artist-button", action_name="app.go-to-artist", action_target=GLib.Variant("s", similar_artist.id), ) ) self.similar_artists_scrolledwindow.show_all() else: self.similar_artists_scrolledwindow.hide() else: self.artist_artwork.get_style_context().add_class("collapsed") self.artist_name.get_style_context().add_class("collapsed") self.artist_indicator.hide() self.artist_stats.hide() self.artist_bio.hide() self.similar_artists_scrolledwindow.hide() self.play_shuffle_buttons.show_all() self.update_artist_artwork( artist.artist_image_url, force=force, order_token=order_token, ) for c in self.error_container.get_children(): self.error_container.remove(c) if is_partial: has_data = len(artist.albums or []) > 0 load_error = LoadError( "Artist data", "load artist details", has_data=has_data, offline_mode=self.offline_mode, ) self.error_container.pack_start(load_error, True, True, 0) self.error_container.show_all() if not has_data: self.album_list_scrolledwindow.hide() else: self.error_container.hide() self.album_list_scrolledwindow.show() self.albums = artist.albums or [] # (Dis|En)able the "Play All" and "Shuffle All" buttons. If in offline mode, it # depends on whether or not there are any cached songs. if self.offline_mode: has_cached_song = False playable_statuses = ( SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED, ) for album in self.albums: if album.id: try: songs = AdapterManager.get_album(album.id).result().songs or [] except CacheMissError as e: if e.partial_data: songs = cast(API.Album, e.partial_data).songs or [] else: songs = [] statuses = AdapterManager.get_cached_statuses([s.id for s in songs]) if any(s in playable_statuses for s in statuses): has_cached_song = True break self.play_button.set_sensitive(has_cached_song) self.shuffle_button.set_sensitive(has_cached_song) else: self.play_button.set_sensitive(not self.offline_mode) self.shuffle_button.set_sensitive(not self.offline_mode) self.albums_list.update(artist, app_config, force=force) @util.async_callback( partial(AdapterManager.get_cover_art_uri, scheme="file"), before_download=lambda self: self.artist_artwork.set_loading(True), on_failure=lambda self, e: self.artist_artwork.set_loading(False), ) def update_artist_artwork( self, cover_art_filename: str, app_config: AppConfiguration, force: bool = False, order_token: int = None, is_partial: bool = False, ): if order_token != self.update_order_token: return self.artist_artwork.set_from_file(cover_art_filename) self.artist_artwork.set_loading(False) if self.artist_details_expanded: self.artist_artwork.set_image_size(300) else: self.artist_artwork.set_image_size(70) # Event Handlers # ========================================================================= def on_view_refresh_click(self, *args): self.update_artist_view( self.artist_id, force=True, order_token=self.update_order_token, ) def on_download_all_click(self, _): AdapterManager.batch_download_songs( self.get_artist_song_ids(), before_download=lambda _: self.update_artist_view( self.artist_id, order_token=self.update_order_token, ), on_song_download_complete=lambda _: self.update_artist_view( self.artist_id, order_token=self.update_order_token, ), ) def on_play_all_clicked(self, _): songs = self.get_artist_song_ids() self.emit( "song-clicked", 0, songs, {"force_shuffle_state": False}, ) def on_shuffle_all_button(self, _): songs = self.get_artist_song_ids() self.emit( "song-clicked", randint(0, len(songs) - 1), songs, {"force_shuffle_state": True}, ) def on_expand_collapse_click(self, _): self.emit( "refresh-window", {"artist_details_expanded": not self.artist_details_expanded}, False, ) # Helper Methods # ========================================================================= def set_all_loading(self, loading_state: bool): if loading_state: self.albums_list.spinner.start() self.albums_list.spinner.show() self.artist_artwork.set_loading(True) else: self.albums_list.spinner.hide() self.artist_artwork.set_loading(False) def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label: return Gtk.Label( label=text, name=name, halign=Gtk.Align.START, xalign=0, **params ) def format_stats(self, artist: API.Artist) -> str: album_count = artist.album_count or len(artist.albums or []) song_count, duration = 0, timedelta(0) for album in artist.albums or []: song_count += album.song_count or 0 duration += album.duration or timedelta(0) return util.dot_join( "{} {}".format(album_count, util.pluralize("album", album_count)), "{} {}".format(song_count, util.pluralize("song", song_count)), util.format_sequence_duration(duration), ) def get_artist_song_ids(self) -> List[str]: try: artist = AdapterManager.get_artist(self.artist_id).result() except CacheMissError as c: artist = cast(API.Artist, c.partial_data) if not artist: return [] songs = [] for album in artist.albums or []: assert album.id try: album_with_songs = AdapterManager.get_album(album.id).result() except CacheMissError as c: album_with_songs = cast(API.Album, c.partial_data) if not album_with_songs: continue for song in album_with_songs.songs or []: songs.append(song.id) return songs class AlbumsListWithSongs(Gtk.Overlay): __gsignals__ = { "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), } def __init__(self): Gtk.Overlay.__init__(self) self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.add(self.box) self.spinner = Gtk.Spinner( name="albumslist-with-songs-spinner", active=False, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, ) self.add_overlay(self.spinner) self.albums = [] def update( self, artist: API.Artist, app_config: AppConfiguration, force: bool = False ): def remove_all(): for c in self.box.get_children(): self.box.remove(c) if artist is None: remove_all() self.spinner.hide() return new_albums = sorted( artist.albums or [], key=lambda a: (a.year or float("inf"), a.name) ) if self.albums == new_albums: # Just go through all of the colidren and update them. for c in self.box.get_children(): c.update(app_config=app_config, force=force) self.spinner.hide() return self.albums = new_albums remove_all() for album in self.albums: album_with_songs = AlbumWithSongs(album, show_artist_name=False) album_with_songs.connect( "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) album_with_songs.connect("song-selected", self.on_song_selected) album_with_songs.show_all() self.box.add(album_with_songs) # Update everything (no force to ensure that if we are online, then everything # is clickable) for c in self.box.get_children(): c.update(app_config=app_config) self.spinner.hide() def on_song_selected(self, album_component: AlbumWithSongs): for child in self.box.get_children(): if album_component != child: child.deselect_all()