Files
sublime-music/sublime/ui/artists.py

601 lines
21 KiB
Python

from datetime import timedelta
from random import randint
from typing import List, Sequence
from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, api_objects as API
from sublime.config import AppConfiguration
from sublime.ui import util
from sublime.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"<b>{util.esc(model.name)}</b>"]
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")
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",
)
# TODO: make these disabled if there are no songs that can be played.
play_button = IconButton(
"media-playback-start-symbolic", label="Play All", relief=True
)
play_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
shuffle_button = IconButton(
"media-playlist-shuffle-symbolic", label="Shuffle All", relief=True
)
shuffle_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(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"<b>{artist.name}</b>"))
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("<b>Similar Artists:</b> ")
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 []
self.albums_list.update(artist, app_config, force=force)
@util.async_callback(
AdapterManager.get_cover_art_filename,
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]:
songs = []
for album in AdapterManager.get_artist(self.artist_id).result().albums or []:
assert album.id
album_songs = AdapterManager.get_album(album.id).result()
for song in album_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.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()