Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from datetime import timedelta
2from functools import partial
3from random import randint
4from typing import cast, List, Sequence
6import bleach
8from gi.repository import Gio, GLib, GObject, Gtk, Pango
10from ..adapters import (
11 AdapterManager,
12 api_objects as API,
13 CacheMissError,
14 SongCacheStatus,
15)
16from ..config import AppConfiguration
17from ..ui import util
18from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
21class ArtistsPanel(Gtk.Paned):
22 """Defines the arist panel."""
24 __gsignals__ = {
25 "song-clicked": (
26 GObject.SignalFlags.RUN_FIRST,
27 GObject.TYPE_NONE,
28 (int, object, object),
29 ),
30 "refresh-window": (
31 GObject.SignalFlags.RUN_FIRST,
32 GObject.TYPE_NONE,
33 (object, bool),
34 ),
35 }
37 def __init__(self, *args, **kwargs):
38 Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
40 self.artist_list = ArtistList()
41 self.pack1(self.artist_list, False, False)
43 self.artist_detail_panel = ArtistDetailPanel()
44 self.artist_detail_panel.connect(
45 "song-clicked",
46 lambda _, *args: self.emit("song-clicked", *args),
47 )
48 self.artist_detail_panel.connect(
49 "refresh-window",
50 lambda _, *args: self.emit("refresh-window", *args),
51 )
52 self.pack2(self.artist_detail_panel, True, False)
54 def update(self, app_config: AppConfiguration, force: bool = False):
55 self.artist_list.update(app_config=app_config)
56 self.artist_detail_panel.update(app_config=app_config)
59class _ArtistModel(GObject.GObject):
60 artist_id = GObject.Property(type=str)
61 name = GObject.Property(type=str)
62 album_count = GObject.Property(type=int)
64 def __init__(self, artist: API.Artist):
65 GObject.GObject.__init__(self)
66 self.artist_id = artist.id
67 self.name = artist.name
68 self.album_count = artist.album_count or 0
71class ArtistList(Gtk.Box):
72 def __init__(self):
73 Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
75 list_actions = Gtk.ActionBar()
77 self.refresh_button = IconButton(
78 "view-refresh-symbolic", "Refresh list of artists"
79 )
80 self.refresh_button.connect("clicked", lambda *a: self.update(force=True))
81 list_actions.pack_end(self.refresh_button)
83 self.add(list_actions)
85 self.error_container = Gtk.Box()
86 self.add(self.error_container)
88 self.loading_indicator = Gtk.ListBox()
89 spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
90 spinner = Gtk.Spinner(name="artist-list-spinner", active=True)
91 spinner_row.add(spinner)
92 self.loading_indicator.add(spinner_row)
93 self.pack_start(self.loading_indicator, False, False, 0)
95 list_scroll_window = Gtk.ScrolledWindow(min_content_width=250)
97 def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow:
98 label_text = [f"<b>{model.name}</b>"]
100 if album_count := model.album_count:
101 label_text.append(
102 "{} {}".format(album_count, util.pluralize("album", album_count))
103 )
105 row = Gtk.ListBoxRow(
106 action_name="app.go-to-artist",
107 action_target=GLib.Variant("s", model.artist_id),
108 )
109 row.add(
110 Gtk.Label(
111 label=bleach.clean("\n".join(label_text)),
112 use_markup=True,
113 margin=12,
114 halign=Gtk.Align.START,
115 ellipsize=Pango.EllipsizeMode.END,
116 )
117 )
118 row.show_all()
119 return row
121 self.artists_store = Gio.ListStore()
122 self.list = Gtk.ListBox(name="artist-list")
123 self.list.bind_model(self.artists_store, create_artist_row)
124 list_scroll_window.add(self.list)
126 self.pack_start(list_scroll_window, True, True, 0)
128 _app_config = None
130 @util.async_callback(
131 AdapterManager.get_artists,
132 before_download=lambda self: self.loading_indicator.show_all(),
133 on_failure=lambda self, e: self.loading_indicator.hide(),
134 )
135 def update(
136 self,
137 artists: Sequence[API.Artist],
138 app_config: AppConfiguration = None,
139 is_partial: bool = False,
140 **kwargs,
141 ):
142 if app_config:
143 self._app_config = app_config
144 self.refresh_button.set_sensitive(not app_config.offline_mode)
146 for c in self.error_container.get_children():
147 self.error_container.remove(c)
148 if is_partial:
149 load_error = LoadError(
150 "Artist list",
151 "load artists",
152 has_data=len(artists) > 0,
153 offline_mode=(
154 self._app_config.offline_mode if self._app_config else False
155 ),
156 )
157 self.error_container.pack_start(load_error, True, True, 0)
158 self.error_container.show_all()
159 else:
160 self.error_container.hide()
162 new_store = []
163 selected_idx = None
164 for i, artist in enumerate(artists):
165 if (
166 self._app_config
167 and self._app_config.state
168 and self._app_config.state.selected_artist_id == artist.id
169 ):
170 selected_idx = i
171 new_store.append(_ArtistModel(artist))
173 util.diff_model_store(self.artists_store, new_store)
175 # Preserve selection
176 if selected_idx is not None:
177 row = self.list.get_row_at_index(selected_idx)
178 self.list.select_row(row)
180 self.loading_indicator.hide()
183class ArtistDetailPanel(Gtk.Box):
184 """Defines the artists list."""
186 __gsignals__ = {
187 "song-clicked": (
188 GObject.SignalFlags.RUN_FIRST,
189 GObject.TYPE_NONE,
190 (int, object, object),
191 ),
192 "refresh-window": (
193 GObject.SignalFlags.RUN_FIRST,
194 GObject.TYPE_NONE,
195 (object, bool),
196 ),
197 }
199 update_order_token = 0
200 artist_details_expanded = False
202 def __init__(self, *args, **kwargs):
203 super().__init__(
204 *args,
205 name="artist-detail-panel",
206 orientation=Gtk.Orientation.VERTICAL,
207 **kwargs,
208 )
209 self.albums: Sequence[API.Album] = []
210 self.artist_id = None
212 # Artist info panel
213 self.big_info_panel = Gtk.Box(
214 orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel"
215 )
217 self.artist_artwork = SpinnerImage(
218 loading=False,
219 image_name="artist-album-artwork",
220 spinner_name="artist-artwork-spinner",
221 image_size=300,
222 )
223 self.big_info_panel.pack_start(self.artist_artwork, False, False, 0)
225 # Action buttons, name, comment, number of songs, etc.
226 artist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
228 artist_details_box.pack_start(Gtk.Box(), True, False, 0)
230 self.artist_indicator = self.make_label(name="artist-indicator")
231 artist_details_box.add(self.artist_indicator)
233 self.artist_name = self.make_label(
234 name="artist-name", ellipsize=Pango.EllipsizeMode.END
235 )
236 artist_details_box.add(self.artist_name)
238 self.artist_bio = self.make_label(
239 name="artist-bio", justify=Gtk.Justification.LEFT
240 )
241 self.artist_bio.set_line_wrap(True)
242 artist_details_box.add(self.artist_bio)
244 self.similar_artists_scrolledwindow = Gtk.ScrolledWindow()
245 similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
247 self.similar_artists_label = self.make_label(name="similar-artists")
248 similar_artists_box.add(self.similar_artists_label)
250 self.similar_artists_button_box = Gtk.Box(
251 orientation=Gtk.Orientation.HORIZONTAL
252 )
253 similar_artists_box.add(self.similar_artists_button_box)
254 self.similar_artists_scrolledwindow.add(similar_artists_box)
256 artist_details_box.add(self.similar_artists_scrolledwindow)
258 self.artist_stats = self.make_label(name="artist-stats")
259 artist_details_box.add(self.artist_stats)
261 self.play_shuffle_buttons = Gtk.Box(
262 orientation=Gtk.Orientation.HORIZONTAL,
263 name="playlist-play-shuffle-buttons",
264 )
266 self.play_button = IconButton(
267 "media-playback-start-symbolic", label="Play All", relief=True
268 )
269 self.play_button.connect("clicked", self.on_play_all_clicked)
270 self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0)
272 self.shuffle_button = IconButton(
273 "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True
274 )
275 self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
276 self.play_shuffle_buttons.pack_start(self.shuffle_button, False, False, 5)
277 artist_details_box.add(self.play_shuffle_buttons)
279 self.big_info_panel.pack_start(artist_details_box, True, True, 0)
281 # Action buttons
282 action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
283 self.artist_action_buttons = Gtk.Box(
284 orientation=Gtk.Orientation.HORIZONTAL, spacing=10
285 )
287 self.download_all_button = IconButton(
288 "folder-download-symbolic", "Download all songs by this artist"
289 )
290 self.download_all_button.connect("clicked", self.on_download_all_click)
291 self.artist_action_buttons.add(self.download_all_button)
293 self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
294 self.refresh_button.connect("clicked", self.on_view_refresh_click)
295 self.artist_action_buttons.add(self.refresh_button)
297 action_buttons_container.pack_start(
298 self.artist_action_buttons, False, False, 10
299 )
301 action_buttons_container.pack_start(Gtk.Box(), True, True, 0)
303 expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
304 self.expand_collapse_button = IconButton(
305 "pan-up-symbolic", "Expand playlist details"
306 )
307 self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
308 expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
309 action_buttons_container.add(expand_button_container)
311 self.big_info_panel.pack_start(action_buttons_container, False, False, 5)
313 self.pack_start(self.big_info_panel, False, True, 0)
315 self.error_container = Gtk.Box()
316 self.add(self.error_container)
318 self.album_list_scrolledwindow = Gtk.ScrolledWindow()
319 self.albums_list = AlbumsListWithSongs()
320 self.albums_list.connect(
321 "song-clicked",
322 lambda _, *args: self.emit("song-clicked", *args),
323 )
324 self.album_list_scrolledwindow.add(self.albums_list)
325 self.pack_start(self.album_list_scrolledwindow, True, True, 0)
327 def update(self, app_config: AppConfiguration):
328 self.artist_id = app_config.state.selected_artist_id
329 self.offline_mode = app_config.offline_mode
330 if app_config.state.selected_artist_id is None:
331 self.big_info_panel.hide()
332 self.album_list_scrolledwindow.hide()
333 self.play_shuffle_buttons.hide()
334 else:
335 self.update_order_token += 1
336 self.album_list_scrolledwindow.show()
337 self.update_artist_view(
338 app_config.state.selected_artist_id,
339 app_config=app_config,
340 order_token=self.update_order_token,
341 )
342 self.refresh_button.set_sensitive(not self.offline_mode)
343 self.download_all_button.set_sensitive(not self.offline_mode)
345 @util.async_callback(
346 AdapterManager.get_artist,
347 before_download=lambda self: self.set_all_loading(True),
348 on_failure=lambda self, e: self.set_all_loading(False),
349 )
350 def update_artist_view(
351 self,
352 artist: API.Artist,
353 app_config: AppConfiguration,
354 force: bool = False,
355 order_token: int = None,
356 is_partial: bool = False,
357 ):
358 if order_token != self.update_order_token:
359 return
361 self.big_info_panel.show_all()
363 if app_config:
364 self.artist_details_expanded = app_config.state.artist_details_expanded
366 up_down = "up" if self.artist_details_expanded else "down"
367 self.expand_collapse_button.set_icon(f"pan-{up_down}-symbolic")
368 self.expand_collapse_button.set_tooltip_text(
369 "Collapse" if self.artist_details_expanded else "Expand"
370 )
372 self.artist_name.set_markup(bleach.clean(f"<b>{artist.name}</b>"))
373 self.artist_name.set_tooltip_text(artist.name)
375 if self.artist_details_expanded:
376 self.artist_artwork.get_style_context().remove_class("collapsed")
377 self.artist_name.get_style_context().remove_class("collapsed")
378 self.artist_indicator.set_text("ARTIST")
379 self.artist_stats.set_markup(self.format_stats(artist))
381 if artist.biography:
382 self.artist_bio.set_markup(bleach.clean(artist.biography))
383 self.artist_bio.show()
384 else:
385 self.artist_bio.hide()
387 if len(artist.similar_artists or []) > 0:
388 self.similar_artists_label.set_markup("<b>Similar Artists:</b> ")
389 for c in self.similar_artists_button_box.get_children():
390 self.similar_artists_button_box.remove(c)
392 for similar_artist in (artist.similar_artists or [])[:5]:
393 self.similar_artists_button_box.add(
394 Gtk.LinkButton(
395 label=similar_artist.name,
396 name="similar-artist-button",
397 action_name="app.go-to-artist",
398 action_target=GLib.Variant("s", similar_artist.id),
399 )
400 )
401 self.similar_artists_scrolledwindow.show_all()
402 else:
403 self.similar_artists_scrolledwindow.hide()
404 else:
405 self.artist_artwork.get_style_context().add_class("collapsed")
406 self.artist_name.get_style_context().add_class("collapsed")
407 self.artist_indicator.hide()
408 self.artist_stats.hide()
409 self.artist_bio.hide()
410 self.similar_artists_scrolledwindow.hide()
412 self.play_shuffle_buttons.show_all()
414 self.update_artist_artwork(
415 artist.artist_image_url,
416 force=force,
417 order_token=order_token,
418 )
420 for c in self.error_container.get_children():
421 self.error_container.remove(c)
422 if is_partial:
423 has_data = len(artist.albums or []) > 0
424 load_error = LoadError(
425 "Artist data",
426 "load artist details",
427 has_data=has_data,
428 offline_mode=self.offline_mode,
429 )
430 self.error_container.pack_start(load_error, True, True, 0)
431 self.error_container.show_all()
432 if not has_data:
433 self.album_list_scrolledwindow.hide()
434 else:
435 self.error_container.hide()
436 self.album_list_scrolledwindow.show()
438 self.albums = artist.albums or []
440 # (Dis|En)able the "Play All" and "Shuffle All" buttons. If in offline mode, it
441 # depends on whether or not there are any cached songs.
442 if self.offline_mode:
443 has_cached_song = False
444 playable_statuses = (
445 SongCacheStatus.CACHED,
446 SongCacheStatus.PERMANENTLY_CACHED,
447 )
449 for album in self.albums:
450 if album.id:
451 try:
452 songs = AdapterManager.get_album(album.id).result().songs or []
453 except CacheMissError as e:
454 if e.partial_data:
455 songs = cast(API.Album, e.partial_data).songs or []
456 else:
457 songs = []
458 statuses = AdapterManager.get_cached_statuses([s.id for s in songs])
459 if any(s in playable_statuses for s in statuses):
460 has_cached_song = True
461 break
463 self.play_button.set_sensitive(has_cached_song)
464 self.shuffle_button.set_sensitive(has_cached_song)
465 else:
466 self.play_button.set_sensitive(not self.offline_mode)
467 self.shuffle_button.set_sensitive(not self.offline_mode)
469 self.albums_list.update(artist, app_config, force=force)
471 @util.async_callback(
472 partial(AdapterManager.get_cover_art_uri, scheme="file"),
473 before_download=lambda self: self.artist_artwork.set_loading(True),
474 on_failure=lambda self, e: self.artist_artwork.set_loading(False),
475 )
476 def update_artist_artwork(
477 self,
478 cover_art_filename: str,
479 app_config: AppConfiguration,
480 force: bool = False,
481 order_token: int = None,
482 is_partial: bool = False,
483 ):
484 if order_token != self.update_order_token:
485 return
486 self.artist_artwork.set_from_file(cover_art_filename)
487 self.artist_artwork.set_loading(False)
489 if self.artist_details_expanded:
490 self.artist_artwork.set_image_size(300)
491 else:
492 self.artist_artwork.set_image_size(70)
494 # Event Handlers
495 # =========================================================================
496 def on_view_refresh_click(self, *args):
497 self.update_artist_view(
498 self.artist_id,
499 force=True,
500 order_token=self.update_order_token,
501 )
503 def on_download_all_click(self, _):
504 AdapterManager.batch_download_songs(
505 self.get_artist_song_ids(),
506 before_download=lambda _: self.update_artist_view(
507 self.artist_id,
508 order_token=self.update_order_token,
509 ),
510 on_song_download_complete=lambda _: self.update_artist_view(
511 self.artist_id,
512 order_token=self.update_order_token,
513 ),
514 )
516 def on_play_all_clicked(self, _):
517 songs = self.get_artist_song_ids()
518 self.emit(
519 "song-clicked",
520 0,
521 songs,
522 {"force_shuffle_state": False},
523 )
525 def on_shuffle_all_button(self, _):
526 songs = self.get_artist_song_ids()
527 self.emit(
528 "song-clicked",
529 randint(0, len(songs) - 1),
530 songs,
531 {"force_shuffle_state": True},
532 )
534 def on_expand_collapse_click(self, _):
535 self.emit(
536 "refresh-window",
537 {"artist_details_expanded": not self.artist_details_expanded},
538 False,
539 )
541 # Helper Methods
542 # =========================================================================
543 def set_all_loading(self, loading_state: bool):
544 if loading_state:
545 self.albums_list.spinner.start()
546 self.albums_list.spinner.show()
547 self.artist_artwork.set_loading(True)
548 else:
549 self.albums_list.spinner.hide()
550 self.artist_artwork.set_loading(False)
552 def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
553 return Gtk.Label(
554 label=text, name=name, halign=Gtk.Align.START, xalign=0, **params
555 )
557 def format_stats(self, artist: API.Artist) -> str:
558 album_count = artist.album_count or len(artist.albums or [])
559 song_count, duration = 0, timedelta(0)
560 for album in artist.albums or []:
561 song_count += album.song_count or 0
562 duration += album.duration or timedelta(0)
564 return util.dot_join(
565 "{} {}".format(album_count, util.pluralize("album", album_count)),
566 "{} {}".format(song_count, util.pluralize("song", song_count)),
567 util.format_sequence_duration(duration),
568 )
570 def get_artist_song_ids(self) -> List[str]:
571 try:
572 artist = AdapterManager.get_artist(self.artist_id).result()
573 except CacheMissError as c:
574 artist = cast(API.Artist, c.partial_data)
576 if not artist:
577 return []
579 songs = []
580 for album in artist.albums or []:
581 assert album.id
582 try:
583 album_with_songs = AdapterManager.get_album(album.id).result()
584 except CacheMissError as c:
585 album_with_songs = cast(API.Album, c.partial_data)
586 if not album_with_songs:
587 continue
588 for song in album_with_songs.songs or []:
589 songs.append(song.id)
591 return songs
594class AlbumsListWithSongs(Gtk.Overlay):
595 __gsignals__ = {
596 "song-clicked": (
597 GObject.SignalFlags.RUN_FIRST,
598 GObject.TYPE_NONE,
599 (int, object, object),
600 ),
601 }
603 def __init__(self):
604 Gtk.Overlay.__init__(self)
605 self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
606 self.add(self.box)
608 self.spinner = Gtk.Spinner(
609 name="albumslist-with-songs-spinner",
610 active=False,
611 halign=Gtk.Align.CENTER,
612 valign=Gtk.Align.CENTER,
613 )
614 self.add_overlay(self.spinner)
616 self.albums = []
618 def update(
619 self, artist: API.Artist, app_config: AppConfiguration, force: bool = False
620 ):
621 def remove_all():
622 for c in self.box.get_children():
623 self.box.remove(c)
625 if artist is None:
626 remove_all()
627 self.spinner.hide()
628 return
630 new_albums = sorted(
631 artist.albums or [], key=lambda a: (a.year or float("inf"), a.name)
632 )
634 if self.albums == new_albums:
635 # Just go through all of the colidren and update them.
636 for c in self.box.get_children():
637 c.update(app_config=app_config, force=force)
639 self.spinner.hide()
640 return
642 self.albums = new_albums
644 remove_all()
646 for album in self.albums:
647 album_with_songs = AlbumWithSongs(album, show_artist_name=False)
648 album_with_songs.connect(
649 "song-clicked",
650 lambda _, *args: self.emit("song-clicked", *args),
651 )
652 album_with_songs.connect("song-selected", self.on_song_selected)
653 album_with_songs.show_all()
654 self.box.add(album_with_songs)
656 # Update everything (no force to ensure that if we are online, then everything
657 # is clickable)
658 for c in self.box.get_children():
659 c.update(app_config=app_config)
661 self.spinner.hide()
663 def on_song_selected(self, album_component: AlbumWithSongs):
664 for child in self.box.get_children():
665 if album_component != child:
666 child.deselect_all()