from random import randint from typing import Any, cast, List from gi.repository import Gdk, GLib, GObject, Gtk, Pango, Handy from sublime_music.adapters import AdapterManager, api_objects as API, Result from sublime_music.config import AppConfiguration from sublime_music.ui import util from .icon_button import IconButton from .load_error import LoadError from .song_list_column import SongListColumn from .spinner_image import SpinnerImage from ..actions import run_action class AlbumWithSongs(Gtk.Box): __gsignals__ = { "back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()), } show_back_button = GObject.Property(type=bool, default=False) album = None offline_mode = True cover_art_result = None def __init__(self, show_artist_name: bool = True, scroll_contents: bool = False, **kwargs): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) self.show_artist_name = show_artist_name action_bar = Gtk.ActionBar() back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE) self.bind_property("show-back-button", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE) back_button = IconButton("go-previous-symbolic") back_button.connect("clicked", lambda *_: self.emit("back-clicked")) back_button_revealer.add(back_button) action_bar.pack_start(back_button_revealer) self.download_all_btn = IconButton( "folder-download-symbolic", "Download all songs in this album", sensitive=False, ) self.download_all_btn.connect("clicked", self.on_download_all_click) action_bar.pack_end(self.download_all_btn) self.add_to_queue_btn = IconButton( "queue-back-symbolic", "Add all the songs in this album to the end of the play queue", sensitive=False, ) action_bar.pack_end(self.add_to_queue_btn) self.play_next_btn = IconButton( "queue-front-symbolic", "Play all of the songs in this album next", sensitive=False, ) action_bar.pack_end(self.play_next_btn) self.shuffle_btn = IconButton( "media-playlist-shuffle-symbolic", "Shuffle all songs in this album", sensitive=False, ) self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked) action_bar.pack_end(self.shuffle_btn) self.play_btn = IconButton( "media-playback-start-symbolic", "Play all songs in this album", sensitive=False, ) self.play_btn.connect("clicked", self.play_btn_clicked) action_bar.pack_end(self.play_btn) self.pack_start(action_bar, False, False, 0) if scroll_contents: self.pack_start(Gtk.Separator(), False, False, 0) scrolled_window = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER) self.pack_start(scrolled_window, True, True, 0) contents_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) scrolled_window.add(contents_box) else: contents_box = self box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.artist_artwork = SpinnerImage( loading=False, image_name="artist-album-list-artwork", spinner_name="artist-artwork-spinner", image_size=80, ) # Account for 10px margin on all sides with "+ 20". # self.artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20) box.pack_start(self.artist_artwork, False, False, 0) album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # TODO (#43): deal with super long-ass titles self.title = Gtk.Label( name="artist-album-list-album-name", halign=Gtk.Align.START, ellipsize=Pango.EllipsizeMode.END, ) album_details.pack_start(self.title, False, False, 0) self.artist_and_year = Gtk.Label( halign=Gtk.Align.START, margin_left=10, margin_right=10, ellipsize=Pango.EllipsizeMode.END, ) album_details.pack_start(self.artist_and_year, False, False, 0) details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.genre_and_song_count = Gtk.Label() details_bottom_box.pack_start(self.genre_and_song_count, False, False, 10) squeezer = Handy.Squeezer(homogeneous=False) self.song_duration_long = Gtk.Label(halign=Gtk.Align.END) squeezer.add(self.song_duration_long) self.song_duration_short = Gtk.Label(halign=Gtk.Align.END) squeezer.add(self.song_duration_short) details_bottom_box.pack_end(squeezer, False, False, 20) album_details.pack_start(details_bottom_box, False, False, 0) self.loading_indicator_container = Gtk.Box() album_details.pack_start(self.loading_indicator_container, False, False, 0) self.error_container = Gtk.Box() album_details.pack_start(self.error_container, False, False, 0) box.pack_start(album_details, True, True, 0) contents_box.pack_start(box, False, False, 0) # clickable, cache status, title, duration, song ID self.album_song_store = Gtk.ListStore(bool, str, str, str, str) self.album_songs = Gtk.TreeView( model=self.album_song_store, name="album-songs-list", headers_visible=False, margin_top=15, margin_left=10, margin_right=10, margin_bottom=10, ) selection = self.album_songs.get_selection() selection.set_mode(Gtk.SelectionMode.SINGLE) # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) column = Gtk.TreeViewColumn("", renderer, icon_name=1) column.set_resizable(True) self.album_songs.append_column(column) self.album_songs.append_column(SongListColumn("TITLE", 2, bold=True)) self.album_songs.append_column(SongListColumn("DURATION", 3, align=1, width=40)) self.album_songs.connect("row-activated", self.on_song_activated) self.album_songs.connect("button-press-event", self.on_song_button_press) self.album_songs.get_selection().connect( "changed", self.on_song_selection_change ) contents_box.pack_start(self.album_songs, True, True, 0) # Event Handlers # ========================================================================= def on_song_selection_change(self, selection: Gtk.TreeSelection): paths = selection.get_selected_rows()[1] if not paths: return assert len(paths) == 1 self.play_song(paths[0].get_indices()[0], {}) def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): if not self.album_song_store[idx[0]][0]: return self.play_song(idx.get_indices()[0], {}) def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) if not clicked_path: return False store, paths = tree.get_selection().get_selected_rows() allow_deselect = False def on_download_state_change(song_id: str): self.update_album_songs(self.album.id) # Use the new selection instead of the old one for calculating what # to do the right click on. if clicked_path[0] not in paths: paths = [clicked_path[0]] allow_deselect = True song_ids = [self.album_song_store[p][-1] for p in paths] # Used to adjust for the header row. bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y) util.show_song_popover( song_ids, event.x, event.y + abs(bin_coords.by - widget_coords.wy), tree, self.offline_mode, on_download_state_change=on_download_state_change, ) # If the click was on a selected row, don't deselect anything. if not allow_deselect: return True return False def on_download_all_click(self, btn: Any): AdapterManager.batch_download_songs( [x[-1] for x in self.album_song_store], before_download=lambda _: self.update(), on_song_download_complete=lambda _: self.update(), ) def play_btn_clicked(self, btn: Any): self.play_song(0, {"force_shuffle_state": False}) def shuffle_btn_clicked(self, btn: Any): self.play_song(randint(0, len(self.album_song_store) - 1), {"force_shuffle_state": True}) def play_song(self, index: int, metadata: Any): run_action(self, 'app.play-song', index, [m[-1] for m in self.album_song_store], metadata) # Helper Methods # ========================================================================= def deselect_all(self): self.album_songs.get_selection().unselect_all() def update(self, album: API.Album, app_config: AppConfiguration, force: bool = False): update_songs = False if app_config: # Deselect everything and reset the error container if switching between # online and offline. if self.offline_mode != app_config.offline_mode: self.album_songs.get_selection().unselect_all() for c in self.error_container.get_children(): self.error_container.remove(c) update_songs = True self.offline_mode = app_config.offline_mode if album != self.album: self.album = album self.title.set_label(album.name) artist = album.artist.name if self.show_artist_name and album.artist else None self.artist_and_year.set_label(util.dot_join(artist, album.year)) self.genre_and_song_count.set_label(util.dot_join( f"{album.song_count} " + util.pluralize("song", album.song_count), album.genre.name if album.genre else None)) self.song_duration_long.set_label( util.format_sequence_duration(album.duration) if album.duration else "") self.song_duration_short.set_label( util.format_song_duration(album.duration) if album.duration else "") if self.cover_art_result is not None: self.cover_art_result.cancel() def cover_art_future_done(f: Result): self.artist_artwork.set_from_file(f.result()) self.artist_artwork.set_loading(False) self.cover_art_result = None self.cover_art_result = AdapterManager.get_cover_art_uri( album.cover_art, "file", before_download=lambda: self.artist_artwork.set_loading(True), ) self.cover_art_result.add_done_callback( lambda f: GLib.idle_add(cover_art_future_done, f) ) update_songs = True if update_songs and self.album: self.update_album_songs(self.album.id, app_config=app_config, force=force) def set_loading(self, loading: bool): if loading: if len(self.loading_indicator_container.get_children()) == 0: self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0) spinner = Gtk.Spinner(name="album-list-song-list-spinner") spinner.start() self.loading_indicator_container.add(spinner) self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0) self.loading_indicator_container.show_all() else: self.loading_indicator_container.hide() @util.async_callback( AdapterManager.get_album, before_download=lambda self: self.set_loading(True), on_failure=lambda self, e: self.set_loading(False), ) def update_album_songs( self, album: API.Album, app_config: AppConfiguration, force: bool = False, order_token: int = None, is_partial: bool = False, ): songs = album.songs or [] if is_partial: if len(self.error_container.get_children()) == 0: load_error = LoadError( "Song list", "retrieve songs", has_data=len(songs) > 0, offline_mode=self.offline_mode, ) self.error_container.pack_start(load_error, True, True, 0) self.error_container.show_all() else: self.error_container.hide() song_ids = [s.id for s in songs] new_store = [] any_song_playable = False if len(songs) == 0: self.album_songs.hide() else: self.album_songs.show() for status, song in zip(util.get_cached_status_icons(song_ids), songs): playable = not self.offline_mode or status in ( "folder-download-symbolic", "view-pin-symbolic", ) new_store.append( [ playable, status, song.title or "", util.format_song_duration(song.duration), song.id, ] ) any_song_playable |= playable song_ids = [cast(str, song[-1]) for song in new_store] util.diff_song_store(self.album_song_store, new_store) self.play_btn.set_sensitive(any_song_playable) self.shuffle_btn.set_sensitive(any_song_playable) self.download_all_btn.set_sensitive( not self.offline_mode and AdapterManager.can_batch_download_songs() ) if any_song_playable: self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) self.play_next_btn.set_action_name("app.queue-next-songs") self.add_to_queue_btn.set_action_name("app.queue-songs") else: self.play_next_btn.set_action_name("") self.add_to_queue_btn.set_action_name("") # Have to idle_add here so that his happens after the component is rendered. self.set_loading(False)