From 56ae24b47926b1074b56e47d083f9a57bccd9939 Mon Sep 17 00:00:00 2001 From: Benjamin Schaaf Date: Sat, 6 Feb 2021 23:51:54 +1100 Subject: [PATCH] WIP --- sublime_music/ui/albums.py | 271 ++++++++++++++++---- sublime_music/ui/app_styles.css | 4 + sublime_music/ui/common/__init__.py | 2 + sublime_music/ui/common/album_with_songs.py | 97 ++++--- sublime_music/ui/common/sizer.py | 44 ++++ 5 files changed, 329 insertions(+), 89 deletions(-) create mode 100644 sublime_music/ui/common/sizer.py diff --git a/sublime_music/ui/albums.py b/sublime_music/ui/albums.py index 0d4d29f..a6f094e 100644 --- a/sublime_music/ui/albums.py +++ b/sublime_music/ui/albums.py @@ -15,7 +15,7 @@ from ..adapters import ( ) from ..config import AppConfiguration from ..ui import util -from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage +from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage, Sizer def _to_type(query_type: AlbumSearchQuery.Type) -> str: @@ -46,7 +46,7 @@ def _from_str(type_str: str) -> AlbumSearchQuery.Type: }[type_str] -class AlbumsPanel(Gtk.Box): +class AlbumsPanel(Handy.Leaflet): __gsignals__ = { "song-clicked": ( GObject.SignalFlags.RUN_FIRST, @@ -60,18 +60,25 @@ class AlbumsPanel(Gtk.Box): ), } + current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM) offline_mode = False + provider_id: Optional[str] = None + populating_genre_combo = False - grid_order_token: int = 0 album_sort_direction: str = "ascending" - album_page_size: int = 30 + # album_page_size: int = 30 album_page: int = 0 grid_pages_count: int = 0 - def __init__(self): - super().__init__(orientation=Gtk.Orientation.VERTICAL) + current_albums_result: Result = None - leaflet = Handy.Leaflet(transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False) + albums = [] + albums_by_id = {} + + def __init__(self): + super().__init__(transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False, interpolate_size=False) + + self.grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) actionbar = Gtk.ActionBar() @@ -123,6 +130,57 @@ class AlbumsPanel(Gtk.Box): self.sort_toggle.connect("clicked", self.on_sort_toggle_clicked) actionbar.pack_start(self.sort_toggle) + self.refresh_button = IconButton( + "view-refresh-symbolic", "Refresh list of albums", relief=True + ) + self.refresh_button.connect("clicked", self.on_refresh_clicked) + actionbar.pack_end(self.refresh_button) + + # actionbar.pack_end(Gtk.Label(label="albums per page")) + # self.show_count_dropdown, _ = self.make_combobox( + # ((x, x, True) for x in ("20", "30", "40", "50")), + # self.on_show_count_dropdown_change, + # ) + # actionbar.pack_end(self.show_count_dropdown) + # actionbar.pack_end(Gtk.Label(label="Show")) + + self.grid_box.pack_start(actionbar, False, False, 0) + + # 700 shows ~3 albums + grid_sizer = Sizer(natural_width=700) + + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.grid = Gtk.FlowBox( + hexpand=True, + margin_top=5, + row_spacing=5, + column_spacing=5, + homogeneous=True, + max_children_per_line=999, + valign=Gtk.Align.START, + halign=Gtk.Align.CENTER, + selection_mode=Gtk.SelectionMode.SINGLE) + self.grid.connect("child-activated", self.on_album_clicked) + + # self.grid.connect( + # "song-clicked", + # lambda _, *args: self.emit("song-clicked", *args), + # ) + # self.grid.connect( + # "refresh-window", + # lambda _, *args: self.emit("refresh-window", *args), + # ) + # self.grid.connect("cover-clicked", self.on_album_clicked) + # self.grid.connect("num-pages-changed", self.on_grid_num_pages_changed) + + box.add(self.grid) + + bottom_actionbar = Gtk.ActionBar() + # Add the page widget. page_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) self.prev_page = IconButton( @@ -145,39 +203,46 @@ class AlbumsPanel(Gtk.Box): ) self.next_page.connect("clicked", self.on_next_page_clicked) page_widget.add(self.next_page) - actionbar.set_center_widget(page_widget) + bottom_actionbar.set_center_widget(page_widget) - self.refresh_button = IconButton( - "view-refresh-symbolic", "Refresh list of albums", relief=True - ) - self.refresh_button.connect("clicked", self.on_refresh_clicked) - actionbar.pack_end(self.refresh_button) + box.add(bottom_actionbar) - actionbar.pack_end(Gtk.Label(label="albums per page")) - self.show_count_dropdown, _ = self.make_combobox( - ((x, x, True) for x in ("20", "30", "40", "50")), - self.on_show_count_dropdown_change, - ) - actionbar.pack_end(self.show_count_dropdown) - actionbar.pack_end(Gtk.Label(label="Show")) + scrolled_window.add(box) + grid_sizer.add(scrolled_window) + self.grid_box.pack_start(grid_sizer, True, True, 0) - # self.add(actionbar) - leaflet.add(actionbar) + self.add(self.grid_box) + + self.details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.details_box.get_style_context().add_class("details-panel") + + details_top_bar_revealer = Gtk.Revealer(reveal_child=False) + details_top_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + + back_button = IconButton("go-previous-symbolic") + + def back_clicked(*_): + self.set_visible_child(self.grid_box) + back_button.connect("clicked", back_clicked) + + details_top_bar.pack_start(back_button, False, False, 0) + + details_top_bar_revealer.add(details_top_bar) + + self.details_box.pack_start(details_top_bar_revealer, False, False, 0) + + self.album_with_songs = AlbumWithSongs(cover_art_size=100) + self.details_box.pack_start(self.album_with_songs, True, True, 0) + + self.add(self.details_box) + + def folded_changed(*_): + if not self.get_folded(): + self.set_visible_child(self.grid_box) + + details_top_bar_revealer.set_reveal_child(self.get_folded()) + self.connect("notify::folded", folded_changed) - scrolled_window = Gtk.ScrolledWindow() - self.grid = AlbumsGrid() - self.grid.connect( - "song-clicked", - lambda _, *args: self.emit("song-clicked", *args), - ) - self.grid.connect( - "refresh-window", - lambda _, *args: self.emit("refresh-window", *args), - ) - self.grid.connect("cover-clicked", self.on_grid_cover_clicked) - self.grid.connect("num-pages-changed", self.on_grid_num_pages_changed) - scrolled_window.add(self.grid) - self.add(scrolled_window) def make_combobox( self, @@ -234,7 +299,7 @@ class AlbumsPanel(Gtk.Box): except Exception: self.updating_query = False - def update(self, app_config: AppConfiguration = None, force: bool = False): + def update(self, app_config: AppConfiguration, force: bool = False): self.updating_query = True supported_type_strings = { @@ -246,9 +311,38 @@ class AlbumsPanel(Gtk.Box): # (En|Dis)able getting genres. self.sort_type_combo_store[1][2] = AdapterManager.can_get_genres() - if app_config: + if (self.current_query != app_config.state.current_album_search_query + or self.offline_mode != app_config.offline_mode + or self.provider_id != app_config.current_provider_id): + self.current_query = app_config.state.current_album_search_query self.offline_mode = app_config.offline_mode + self.provider_id = app_config.current_provider_id + + if self.current_albums_result is not None: + self.current_albums_result.cancel() + + self.current_albums_result = AdapterManager.get_albums( + self.current_query, use_ground_truth_adapter=force) + + if self.current_albums_result.data_is_available: + # Don't idle add if the data is already available. + self.current_albums_result.add_done_callback(self._albums_loaded) + else: + # self.spinner.show() + self.current_albums_result.add_done_callback( + lambda f: GLib.idle_add(self._albums_received, f) + ) + + if (self.album_sort_direction != app_config.state.album_sort_direction + or self.album_page != app_config.state.album_page): + + self.album_sort_direction = app_config.state.album_sort_direction + self.album_page = app_config.state.album_page + + self._update_albums() + # self.current_query = app_config.state.current_album_search_query + # self.offline_mode = app_config.offline_mode self.alphabetical_type_combo.set_active_id( { @@ -266,7 +360,6 @@ class AlbumsPanel(Gtk.Box): # Update the page display if app_config: self.album_page = app_config.state.album_page - self.album_page_size = app_config.state.album_page_size self.refresh_button.set_sensitive(not app_config.offline_mode) self.prev_page.set_sensitive(self.album_page > 0) @@ -309,16 +402,93 @@ class AlbumsPanel(Gtk.Box): + self._get_opposite_sort_dir(self.album_sort_direction) ) - self.show_count_dropdown.set_active_id( - str(app_config.state.album_page_size) - ) - # Has to be last because it resets self.updating_query self.populate_genre_combo(app_config, force=force) - # At this point, the current query should be totally updated. - self.grid_order_token = self.grid.update_params(app_config) - self.grid.update(self.grid_order_token, app_config, force=force) + selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) + self.album_with_songs.update(selected_album, app_config, force=force) + + def _albums_loaded(self, result: Result[Iterable[API.Album]]): + self.current_albums_result = None + + # TODO: Error handling + self.albums = [] + + try: + self.albums = list(result.result()) + except CacheMissError as e: + print(e) + except Exception as e: + print(e) + + self.albums_by_id = {album.id: album for album in self.albums} + + self._update_albums() + + def _update_albums(self): + # Update ordering + if self.album_sort_direction == "descending": + self.albums.reverse() + + # TODO: Update instead of re-create + for child in self.grid.get_children(): + self.grid.remove(child) + + for album in self.albums: + self.grid.add(self._create_cover_art_widget(album)) + + def _make_label(self, text: str, name: str) -> Gtk.Label: + return Gtk.Label( + name=name, + label=text, + tooltip_text=text, + ellipsize=Pango.EllipsizeMode.END, + max_width_chars=22, + halign=Gtk.Align.START, + ) + + def _create_cover_art_widget(self, album) -> Gtk.Box: + widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + # Cover art image + artwork = SpinnerImage( + loading=False, + image_name="grid-artwork", + spinner_name="grid-artwork-spinner", + image_size=200, + ) + widget_box.pack_start(artwork, False, False, 0) + + # Header for the widget + header_label = self._make_label(album.name, "grid-header-label") + widget_box.pack_start(header_label, False, False, 0) + + # Extra info for the widget + info_text = util.dot_join( + album.artist.name if album.artist else "-", album.year + ) + if info_text: + info_label = self._make_label(info_text, "grid-info-label") + widget_box.pack_start(info_label, False, False, 0) + + # Download the cover art. + def on_artwork_downloaded(filename: Result[str]): + artwork.set_from_file(filename.result()) + artwork.set_loading(False) + + cover_art_filename_future = AdapterManager.get_cover_art_uri( + album.cover_art, "file" + ) + if cover_art_filename_future.data_is_available: + on_artwork_downloaded(cover_art_filename_future) + else: + artwork.set_loading(True) + cover_art_filename_future.add_done_callback( + lambda f: GLib.idle_add(on_artwork_downloaded, f) + ) + + widget_box.show_all() + return widget_box def _get_opposite_sort_dir(self, sort_dir: str) -> str: return ("ascending", "descending")[0 if sort_dir == "descending" else 1] @@ -469,10 +639,15 @@ class AlbumsPanel(Gtk.Box): False, ) - def on_grid_cover_clicked(self, grid: Any, id: str): + def on_album_clicked(self, _:Any, child: Gtk.FlowBoxChild): + album = self.albums[child.get_index()] + + if self.get_folded() and self.get_visible_child() == self.grid_box: + self.set_visible_child(self.details_box) + self.emit( "refresh-window", - {"selected_album_id": id}, + {"selected_album_id": album.id}, False, ) diff --git a/sublime_music/ui/app_styles.css b/sublime_music/ui/app_styles.css index 0ffdd48..941665b 100644 --- a/sublime_music/ui/app_styles.css +++ b/sublime_music/ui/app_styles.css @@ -339,3 +339,7 @@ entry.invalid { inset 0 -5px 5px @box_shadow_color; background-color: @box_shadow_color; } + +.details-panel { + box-shadow: inset 5px 0 5px @box_shadow_color; +} diff --git a/sublime_music/ui/common/__init__.py b/sublime_music/ui/common/__init__.py index 73f3179..b251527 100644 --- a/sublime_music/ui/common/__init__.py +++ b/sublime_music/ui/common/__init__.py @@ -1,6 +1,7 @@ from .album_with_songs import AlbumWithSongs from .icon_button import IconButton, IconMenuButton, IconToggleButton from .load_error import LoadError +from .sizer import Sizer from .song_list_column import SongListColumn from .spinner_image import SpinnerImage from .spinner_picture import SpinnerPicture @@ -11,6 +12,7 @@ __all__ = ( "IconMenuButton", "IconToggleButton", "LoadError", + "Sizer", "SongListColumn", "SpinnerImage", "SpinnerPicture", diff --git a/sublime_music/ui/common/album_with_songs.py b/sublime_music/ui/common/album_with_songs.py index b16dc8e..092f8d2 100644 --- a/sublime_music/ui/common/album_with_songs.py +++ b/sublime_music/ui/common/album_with_songs.py @@ -23,55 +23,43 @@ class AlbumWithSongs(Gtk.Box): ), } + album = None offline_mode = True + cover_art_result = None + def __init__( self, - album: API.Album, cover_art_size: int = 200, show_artist_name: bool = True, ): Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) - self.album = album + + self.show_artist_name = show_artist_name box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - artist_artwork = SpinnerImage( + self.artist_artwork = SpinnerImage( loading=False, image_name="artist-album-list-artwork", spinner_name="artist-artwork-spinner", image_size=cover_art_size, ) # Account for 10px margin on all sides with "+ 20". - # artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20) - box.pack_start(artist_artwork, False, False, 0) + self.artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20) + box.pack_start(self.artist_artwork, False, False, 0) box.pack_start(Gtk.Box(), True, True, 0) self.pack_start(box, False, False, 0) - def cover_art_future_done(f: Result): - artist_artwork.set_from_file(f.result()) - artist_artwork.set_loading(False) - - cover_art_filename_future = AdapterManager.get_cover_art_uri( - album.cover_art, - "file", - before_download=lambda: artist_artwork.set_loading(True), - ) - cover_art_filename_future.add_done_callback( - lambda f: GLib.idle_add(cover_art_future_done, f) - ) - album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # TODO (#43): deal with super long-ass titles - album_title_and_buttons.add( - Gtk.Label( - label=album.name, - name="artist-album-list-album-name", - halign=Gtk.Align.START, - ellipsize=Pango.EllipsizeMode.END, - ) + self.title = Gtk.Label( + name="artist-album-list-album-name", + halign=Gtk.Align.START, + ellipsize=Pango.EllipsizeMode.END, ) + album_title_and_buttons.add(self.title) self.play_btn = IconButton( "media-playback-start-symbolic", @@ -113,20 +101,11 @@ class AlbumWithSongs(Gtk.Box): album_details.add(album_title_and_buttons) - stats: List[Any] = [ - album.artist.name if show_artist_name and album.artist else None, - album.year, - album.genre.name if album.genre else None, - util.format_sequence_duration(album.duration) if album.duration else None, - ] - - album_details.add( - Gtk.Label( - label=util.dot_join(*stats), - halign=Gtk.Align.START, - margin_left=10, - ) + self.stats = Gtk.Label( + halign=Gtk.Align.START, + margin_left=10, ) + album_details.add(self.stats) self.loading_indicator_container = Gtk.Box() album_details.add(self.loading_indicator_container) @@ -169,7 +148,7 @@ class AlbumWithSongs(Gtk.Box): self.pack_end(album_details, True, True, 0) - self.update_album_songs(album.id) + # self.update_album_songs(album.id) # Event Handlers # ========================================================================= @@ -257,7 +236,9 @@ class AlbumWithSongs(Gtk.Box): def deselect_all(self): self.album_songs.get_selection().unselect_all() - def update(self, app_config: AppConfiguration = None, force: bool = False): + 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. @@ -266,9 +247,43 @@ class AlbumWithSongs(Gtk.Box): for c in self.error_container.get_children(): self.error_container.remove(c) + update_songs = True + self.offline_mode = app_config.offline_mode - self.update_album_songs(self.album.id, app_config=app_config, force=force) + if album != self.album: + self.album = album + + self.title.set_label(album.name) + + self.stats.set_label(util.dot_join( + album.artist.name if self.show_artist_name and album.artist else None, + album.year, + album.genre.name if album.genre else None, + util.format_sequence_duration(album.duration) if album.duration else None, + )) + + 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: + self.update_album_songs(self.album.id, app_config=app_config, force=force) def set_loading(self, loading: bool): if loading: diff --git a/sublime_music/ui/common/sizer.py b/sublime_music/ui/common/sizer.py new file mode 100644 index 0000000..b0187dc --- /dev/null +++ b/sublime_music/ui/common/sizer.py @@ -0,0 +1,44 @@ +from gi.repository import Gtk, GObject + +class Sizer(Gtk.Bin): + """ A widget that lets you control the natural size like the size request """ + + natural_width = GObject.Property(type=int, default=0) + natural_height = GObject.Property(type=int, default=0) + + def __init__(self, **kwargs): + Gtk.Bin.__init__(self, **kwargs) + + def do_get_preferred_width(self) -> (int, int): + minimum, natural = Gtk.Bin.do_get_preferred_width(self) + + if self.natural_width > 0: + natural = max(minimum, self.natural_width) + + return (minimum, natural) + + def do_get_preferred_height(self) -> (int, int): + minimum, natural = Gtk.Bin.do_get_preferred_height(self) + + if self.natural_height > 0: + natural = max(minimum, self.natural_height) + + return (minimum, natural) + + def do_get_preferred_width_for_height(self, height: int) -> (int, int): + minimum, natural = Gtk.Bin.do_get_preferred_width_for_height(self, height) + + if self.natural_width > 0: + natural = max(minimum, self.natural_width) + + return (minimum, natural) + + def do_get_preferred_height_for_width(self, width: int) -> (int, int): + minimum, natural = Gtk.Bin.do_get_preferred_height_for_width(self, width) + + if self.natural_height > 0: + natural = max(minimum, self.natural_height) + + return (minimum, natural) + +