626 lines
22 KiB
Python
626 lines
22 KiB
Python
from datetime import timedelta
|
|
from functools import partial
|
|
from random import randint
|
|
from typing import cast, List, Sequence
|
|
|
|
import bleach
|
|
|
|
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy
|
|
|
|
from ..adapters import (
|
|
AdapterManager,
|
|
api_objects as API,
|
|
CacheMissError,
|
|
SongCacheStatus,
|
|
)
|
|
from ..config import AppConfiguration
|
|
from ..ui import util
|
|
from ..ui.common import AlbumWithSongs, IconButton, IconToggleButton, LoadError, SpinnerImage, Sizer
|
|
from .actions import run_action
|
|
|
|
|
|
class ArtistsPanel(Handy.Leaflet):
|
|
"""Defines the arist panel."""
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
transition_type=Handy.LeafletTransitionType.SLIDE,
|
|
can_swipe_forward=False,
|
|
interpolate_size=False)
|
|
|
|
list_sizer = Sizer(natural_width=400)
|
|
self.artist_list = ArtistList()
|
|
list_sizer.add(self.artist_list)
|
|
self.add(list_sizer)
|
|
|
|
details_sizer = Sizer(hexpand=True, natural_width=800)
|
|
self.artist_detail_panel = ArtistDetailPanel()
|
|
details_sizer.add(self.artist_detail_panel)
|
|
self.add(details_sizer)
|
|
|
|
def artist_clicked(_):
|
|
if self.get_folded():
|
|
self.set_visible_child(details_sizer)
|
|
self.artist_list.connect("artist-clicked", artist_clicked)
|
|
|
|
def back_clicked(_):
|
|
self.set_visible_child(list_sizer)
|
|
self.artist_detail_panel.connect("back-clicked", back_clicked)
|
|
|
|
def folded_changed(*_):
|
|
if not self.get_folded():
|
|
self.set_visible_child(list_sizer)
|
|
|
|
self.artist_detail_panel.show_mobile = self.get_folded()
|
|
self.connect("notify::folded", folded_changed)
|
|
|
|
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):
|
|
__gsignals__ = {
|
|
"artist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
|
}
|
|
|
|
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>{model.name}</b>"]
|
|
|
|
if album_count := model.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=bleach.clean("\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", selection_mode=Gtk.SelectionMode.BROWSE)
|
|
self.list.bind_model(self.artists_store, create_artist_row)
|
|
self.list.connect("row-selected", lambda *_: self.emit("artist-clicked"))
|
|
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()
|
|
|
|
|
|
ARTIST_ARTWORK_SIZE_DESKTOP=200
|
|
ARTIST_ARTWORK_SIZE_MOBILE=80
|
|
|
|
|
|
class ArtistDetailPanel(Gtk.Box):
|
|
"""Defines the artists list."""
|
|
|
|
__gsignals__ = {
|
|
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
|
}
|
|
|
|
show_mobile = GObject.Property(type=bool, default=False)
|
|
|
|
update_order_token = 0
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(
|
|
*args,
|
|
name="artist-detail-panel",
|
|
orientation=Gtk.Orientation.VERTICAL,
|
|
**kwargs,
|
|
)
|
|
|
|
self.connect("notify::show-mobile", self.on_show_mobile_changed)
|
|
|
|
self.albums: Sequence[API.Album] = []
|
|
self.artist_id = None
|
|
|
|
action_bar = Gtk.ActionBar()
|
|
|
|
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
|
self.bind_property("show-mobile", 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.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
|
|
self.refresh_button.connect("clicked", self.on_view_refresh_click)
|
|
action_bar.pack_end(self.refresh_button)
|
|
|
|
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)
|
|
action_bar.pack_end(self.download_all_button)
|
|
|
|
self.shuffle_button = IconButton("media-playlist-shuffle-symbolic")
|
|
self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
|
|
action_bar.pack_end(self.shuffle_button)
|
|
|
|
self.play_button = IconButton("media-playback-start-symbolic")
|
|
self.play_button.connect("clicked", self.on_play_all_clicked)
|
|
action_bar.pack_end(self.play_button)
|
|
|
|
self.pack_start(action_bar, False, False, 0)
|
|
self.pack_start(Gtk.Separator(), False, False, 0)
|
|
|
|
self.scrolled_window = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
# Artist info panel
|
|
info_panel = Gtk.Box(
|
|
orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel"
|
|
)
|
|
|
|
self.artist_artwork = SpinnerImage(
|
|
loading=False,
|
|
image_size=ARTIST_ARTWORK_SIZE_DESKTOP,
|
|
valign=Gtk.Align.START,
|
|
)
|
|
info_panel.pack_start(self.artist_artwork, False, False, 10)
|
|
|
|
# Action buttons, name, comment, number of songs, etc.
|
|
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
self.artist_name = self.make_label(
|
|
name="artist-name", wrap=True,
|
|
)
|
|
details_box.add(self.artist_name)
|
|
|
|
self.artist_bio_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, reveal_child=True)
|
|
self.artist_bio = self.make_label(
|
|
name="artist-bio", justify=Gtk.Justification.LEFT
|
|
)
|
|
self.artist_bio.set_line_wrap(True)
|
|
self.artist_bio_revealer.add(self.artist_bio)
|
|
details_box.add(self.artist_bio_revealer)
|
|
|
|
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
|
|
artist_stats_squeezer = Handy.Squeezer(homogeneous=False)
|
|
|
|
self.artist_stats_long = self.make_label(name="artist-stats")
|
|
artist_stats_squeezer.add(self.artist_stats_long)
|
|
|
|
self.artist_stats_medium = self.make_label(name="artist-stats")
|
|
artist_stats_squeezer.add(self.artist_stats_medium)
|
|
|
|
self.artist_stats_short = self.make_label(name="artist-stats")
|
|
artist_stats_squeezer.add(self.artist_stats_short)
|
|
|
|
details_bottom_box.pack_start(artist_stats_squeezer, False, False, 0)
|
|
|
|
self.expand_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE, margin_left=10)
|
|
self.expand_button = IconToggleButton(
|
|
"pan-down-symbolic", "Expand"
|
|
)
|
|
self.expand_button.bind_property("active", self.artist_bio_revealer, "reveal-child")
|
|
self.expand_button.connect("clicked", self.on_expand_button_clicked)
|
|
self.expand_button_revealer.add(self.expand_button)
|
|
details_bottom_box.pack_end(self.expand_button_revealer, False, False, 0)
|
|
|
|
details_box.pack_end(details_bottom_box, False, False, 0)
|
|
|
|
info_panel.pack_start(details_box, True, True, 0)
|
|
|
|
box.pack_start(info_panel, False, False, 0)
|
|
|
|
self.error_container = Gtk.Box()
|
|
# self.add(self.error_container)
|
|
|
|
self.albums_list = AlbumsListWithSongs()
|
|
box.pack_start(self.albums_list, True, True, 0)
|
|
|
|
self.scrolled_window.add(box)
|
|
|
|
self.pack_start(self.scrolled_window, True, True, 0)
|
|
|
|
def on_show_mobile_changed(self, *_):
|
|
self.expand_button.set_active(not self.show_mobile)
|
|
self.artist_bio_revealer.set_reveal_child(not self.show_mobile)
|
|
self.expand_button_revealer.set_reveal_child(self.show_mobile)
|
|
self.artist_artwork.set_image_size(ARTIST_ARTWORK_SIZE_MOBILE if self.show_mobile else ARTIST_ARTWORK_SIZE_DESKTOP)
|
|
|
|
def on_expand_button_clicked(self, *_):
|
|
up_down = "up" if self.expand_button.get_active() else "down"
|
|
self.expand_button.set_icon(f"pan-{up_down}-symbolic")
|
|
self.expand_button.set_tooltip_text(
|
|
"Collapse" if self.expand_button.get_active() else "Expand"
|
|
)
|
|
|
|
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.shuffle_button.set_sensitive(False)
|
|
self.play_button.set_sensitive(False)
|
|
else:
|
|
self.update_order_token += 1
|
|
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
|
|
|
|
# Scroll to top
|
|
self.scrolled_window.get_vadjustment().set_value(0)
|
|
|
|
self.artist_name.set_markup(bleach.clean(f"<b>{artist.name}</b>"))
|
|
self.artist_name.set_tooltip_text(artist.name)
|
|
|
|
self.artist_stats_long.set_markup(self.format_stats(artist, short_time=False))
|
|
self.artist_stats_medium.set_markup(self.format_stats(artist, short_time=True))
|
|
self.artist_stats_short.set_markup(self.format_stats(artist, short_time=True, short_count=True))
|
|
|
|
biography = ""
|
|
if artist.biography:
|
|
biography += bleach.clean(artist.biography)
|
|
|
|
if artist.similar_artists:
|
|
biography += "\n\n<b>Similar Artists:</b> "
|
|
|
|
# TODO: Make links work
|
|
biography += ", ".join(f"<a href=\"{a.id}\">{bleach.clean(a.name)}</a>" for a in artist.similar_artists[:6])
|
|
|
|
self.artist_bio.set_markup(biography)
|
|
self.expand_button.set_sensitive(bool(biography))
|
|
|
|
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()
|
|
else:
|
|
self.error_container.hide()
|
|
|
|
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 _: GLib.idle_add(
|
|
lambda: self.update_artist_view(
|
|
self.artist_id,
|
|
order_token=self.update_order_token,
|
|
)
|
|
),
|
|
on_song_download_complete=lambda _: GLib.idle_add(
|
|
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()
|
|
run_action(self, 'app.play-song', 0, songs, {"force_shuffle_state": GLib.Variant('b', False)})
|
|
|
|
def on_shuffle_all_button(self, _):
|
|
songs = self.get_artist_song_ids()
|
|
song_idx = randint(0, len(songs) - 1)
|
|
run_action(self, 'app.play-song', song_idx, songs, {"force_shuffle_state": GLib.Variant('b', True)})
|
|
|
|
# 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, short_time=False, short_count=False) -> 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)
|
|
|
|
parts = []
|
|
|
|
if short_count:
|
|
parts.append(f"{album_count}/{song_count}")
|
|
else:
|
|
parts.append("{} {}".format(album_count, util.pluralize("album", album_count)))
|
|
parts.append("{} {}".format(song_count, util.pluralize("song", song_count)))
|
|
|
|
if short_time:
|
|
parts.append(util.format_song_duration(duration))
|
|
else:
|
|
parts.append(util.format_sequence_duration(duration))
|
|
|
|
return util.dot_join(*parts)
|
|
|
|
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):
|
|
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, album in zip(self.box.get_children(), self.albums):
|
|
c.update(album, 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(show_artist_name=False)
|
|
album_with_songs.update(album, app_config, force=force)
|
|
# 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()
|