1015 lines
37 KiB
Python
1015 lines
37 KiB
Python
import datetime
|
|
import itertools
|
|
import logging
|
|
import math
|
|
from typing import Any, Callable, cast, Iterable, List, Optional, Tuple
|
|
|
|
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Handy
|
|
|
|
from ..adapters import (
|
|
AdapterManager,
|
|
AlbumSearchQuery,
|
|
api_objects as API,
|
|
CacheMissError,
|
|
Result,
|
|
)
|
|
from ..config import AppConfiguration
|
|
from . import util
|
|
from .common import AlbumWithSongs, IconButton, LoadError, SpinnerImage, Sizer
|
|
from .actions import run_action
|
|
|
|
|
|
COVER_ART_WIDTH = 150
|
|
|
|
|
|
def _to_type(query_type: AlbumSearchQuery.Type) -> str:
|
|
return {
|
|
AlbumSearchQuery.Type.RANDOM: "random",
|
|
AlbumSearchQuery.Type.NEWEST: "newest",
|
|
AlbumSearchQuery.Type.FREQUENT: "frequent",
|
|
AlbumSearchQuery.Type.RECENT: "recent",
|
|
AlbumSearchQuery.Type.STARRED: "starred",
|
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME: "alphabetical",
|
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST: "alphabetical",
|
|
AlbumSearchQuery.Type.YEAR_RANGE: "year_range",
|
|
AlbumSearchQuery.Type.GENRE: "genre",
|
|
}[query_type]
|
|
|
|
|
|
def _from_str(type_str: str) -> AlbumSearchQuery.Type:
|
|
return {
|
|
"random": AlbumSearchQuery.Type.RANDOM,
|
|
"newest": AlbumSearchQuery.Type.NEWEST,
|
|
"frequent": AlbumSearchQuery.Type.FREQUENT,
|
|
"recent": AlbumSearchQuery.Type.RECENT,
|
|
"starred": AlbumSearchQuery.Type.STARRED,
|
|
"alphabetical_by_name": AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
|
|
"alphabetical_by_artist": AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST,
|
|
"year_range": AlbumSearchQuery.Type.YEAR_RANGE,
|
|
"genre": AlbumSearchQuery.Type.GENRE,
|
|
}[type_str]
|
|
|
|
|
|
class AlbumsPanel(Handy.Leaflet):
|
|
current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
|
|
offline_mode = False
|
|
provider_id: Optional[str] = None
|
|
|
|
populating_genre_combo = False
|
|
album_sort_direction: str = "ascending"
|
|
|
|
current_albums_result: Result = None
|
|
|
|
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()
|
|
|
|
# Sort by
|
|
actionbar.add(Gtk.Label(label="Sort"))
|
|
self.sort_type_combo, self.sort_type_combo_store = self.make_combobox(
|
|
(
|
|
("random", "randomly", True),
|
|
("genre", "by genre", AdapterManager.can_get_genres()),
|
|
("newest", "by most recently added", True),
|
|
("frequent", "by most played", True),
|
|
("recent", "by most recently played", True),
|
|
("alphabetical", "alphabetically", True),
|
|
("starred", "by starred only", True),
|
|
("year_range", "by year", True),
|
|
),
|
|
self.on_type_combo_changed,
|
|
)
|
|
actionbar.pack_start(self.sort_type_combo)
|
|
|
|
self.filter_stack = Gtk.Stack(no_show_all=True)
|
|
|
|
self.alphabetical_type_combo, _ = self.make_combobox(
|
|
(("by_name", "by album name", True), ("by_artist", "by artist name", True)),
|
|
self.on_alphabetical_type_change,
|
|
)
|
|
self.filter_stack.add(self.alphabetical_type_combo)
|
|
|
|
self.genre_combo, self.genre_combo_store = self.make_combobox(
|
|
(), self.on_genre_change
|
|
)
|
|
self.filter_stack.add(self.genre_combo)
|
|
|
|
filter_time_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
next_decade = (datetime.datetime.now().year // 10) * 10 + 10
|
|
|
|
self.from_year_label = Gtk.Label(label="from")
|
|
filter_time_box.add(self.from_year_label)
|
|
self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
|
self.from_year_spin_button.connect("value-changed", self.on_year_changed)
|
|
filter_time_box.add(self.from_year_spin_button)
|
|
|
|
self.to_year_label = Gtk.Label(label="to")
|
|
filter_time_box.add(self.to_year_label)
|
|
self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
|
self.to_year_spin_button.connect("value-changed", self.on_year_changed)
|
|
filter_time_box.add(self.to_year_spin_button)
|
|
|
|
self.filter_stack.add(filter_time_box)
|
|
|
|
actionbar.pack_start(self.filter_stack)
|
|
|
|
self.sort_toggle = IconButton(
|
|
"view-sort-descending-symbolic", "Sort descending", relief=True
|
|
)
|
|
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)
|
|
|
|
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)
|
|
|
|
box.add(self.grid)
|
|
|
|
scrolled_window.add(box)
|
|
grid_sizer.add(scrolled_window)
|
|
self.grid_box.pack_start(grid_sizer, True, True, 0)
|
|
|
|
self.add(self.grid_box)
|
|
|
|
self.album_container = Sizer(natural_width=500)
|
|
|
|
self.album_with_songs = AlbumWithSongs(scroll_contents=True)
|
|
self.album_with_songs.get_style_context().add_class("details-panel")
|
|
|
|
def back_clicked(_):
|
|
self.grid.unselect_all()
|
|
self.set_visible_child(self.grid_box)
|
|
self.album_with_songs.connect("back-clicked", back_clicked)
|
|
|
|
self.album_container.add(self.album_with_songs)
|
|
self.add(self.album_container)
|
|
|
|
def folded_changed(*_):
|
|
if not self.get_folded():
|
|
self.set_visible_child(self.grid_box)
|
|
|
|
self.album_with_songs.show_back_button = self.get_folded()
|
|
self.connect("notify::folded", folded_changed)
|
|
|
|
|
|
def make_combobox(
|
|
self,
|
|
items: Iterable[Tuple[str, str, bool]],
|
|
on_change: Callable[["AlbumsPanel", Gtk.ComboBox], None],
|
|
) -> Tuple[Gtk.ComboBox, Gtk.ListStore]:
|
|
store = Gtk.ListStore(str, str, bool)
|
|
for item in items:
|
|
store.append(item)
|
|
|
|
combo = Gtk.ComboBox.new_with_model(store)
|
|
combo.set_id_column(0)
|
|
combo.connect("changed", on_change)
|
|
|
|
renderer_text = Gtk.CellRendererText()
|
|
combo.pack_start(renderer_text, True)
|
|
combo.add_attribute(renderer_text, "text", 1)
|
|
combo.add_attribute(renderer_text, "sensitive", 2)
|
|
|
|
return combo, store
|
|
|
|
def populate_genre_combo(
|
|
self,
|
|
app_config: AppConfiguration = None,
|
|
force: bool = False,
|
|
):
|
|
if not AdapterManager.can_get_genres():
|
|
self.updating_query = False
|
|
return
|
|
|
|
def get_genres_done(f: Result):
|
|
try:
|
|
genre_names = map(lambda g: g.name, f.result() or [])
|
|
new_store = [(name, name, True) for name in sorted(genre_names)]
|
|
|
|
util.diff_song_store(self.genre_combo_store, new_store)
|
|
|
|
if app_config:
|
|
current_genre_id = self.get_id(self.genre_combo)
|
|
genre = app_config.state.current_album_search_query.genre
|
|
if genre and current_genre_id != (genre_name := genre.name):
|
|
self.genre_combo.set_active_id(genre_name)
|
|
finally:
|
|
self.updating_query = False
|
|
|
|
try:
|
|
force = force and (
|
|
app_config is not None
|
|
and (state := app_config.state) is not None
|
|
and state.current_album_search_query.type == AlbumSearchQuery.Type.GENRE
|
|
)
|
|
genres_future = AdapterManager.get_genres(force=force)
|
|
genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f))
|
|
except Exception:
|
|
self.updating_query = False
|
|
|
|
def update(self, app_config: AppConfiguration, force: bool = False):
|
|
self.updating_query = True
|
|
|
|
supported_type_strings = {
|
|
_to_type(t) for t in AdapterManager.get_supported_artist_query_types()
|
|
}
|
|
for i, el in enumerate(self.sort_type_combo_store):
|
|
self.sort_type_combo_store[i][2] = el[0] in supported_type_strings
|
|
|
|
# (En|Dis)able getting genres.
|
|
self.sort_type_combo_store[1][2] = AdapterManager.can_get_genres()
|
|
|
|
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
|
|
or force):
|
|
|
|
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_loaded, f)
|
|
)
|
|
|
|
if self.album_sort_direction != app_config.state.album_sort_direction:
|
|
self.album_sort_direction = app_config.state.album_sort_direction
|
|
|
|
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(
|
|
{
|
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME: "by_name",
|
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST: "by_artist",
|
|
}.get(self.current_query.type)
|
|
or "by_name"
|
|
)
|
|
self.sort_type_combo.set_active_id(_to_type(self.current_query.type))
|
|
|
|
if year_range := self.current_query.year_range:
|
|
self.from_year_spin_button.set_value(year_range[0])
|
|
self.to_year_spin_button.set_value(year_range[1])
|
|
|
|
# Update the page display
|
|
if app_config:
|
|
self.refresh_button.set_sensitive(not app_config.offline_mode)
|
|
|
|
# Show/hide the combo boxes.
|
|
def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
|
|
for element in elements:
|
|
if self.current_query.type in sort_type:
|
|
element.show()
|
|
else:
|
|
element.hide()
|
|
|
|
show_if(
|
|
(
|
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
|
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST,
|
|
),
|
|
self.alphabetical_type_combo,
|
|
)
|
|
show_if((AlbumSearchQuery.Type.GENRE,), self.genre_combo)
|
|
show_if(
|
|
(AlbumSearchQuery.Type.YEAR_RANGE,),
|
|
self.from_year_label,
|
|
self.from_year_spin_button,
|
|
self.to_year_label,
|
|
self.to_year_spin_button,
|
|
)
|
|
|
|
# (En|Dis)able the sort button
|
|
self.sort_toggle.set_sensitive(
|
|
self.current_query.type != AlbumSearchQuery.Type.RANDOM
|
|
)
|
|
|
|
if app_config:
|
|
self.album_sort_direction = app_config.state.album_sort_direction
|
|
self.sort_toggle.set_icon(f"view-sort-{self.album_sort_direction}-symbolic")
|
|
self.sort_toggle.set_tooltip_text(
|
|
"Change sort order to "
|
|
+ self._get_opposite_sort_dir(self.album_sort_direction)
|
|
)
|
|
|
|
# Has to be last because it resets self.updating_query
|
|
self.populate_genre_combo(app_config, force=force)
|
|
|
|
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or (self.albums and self.albums[0])
|
|
if selected_album is not 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,
|
|
single_line_mode=True,
|
|
halign=Gtk.Align.START,
|
|
)
|
|
|
|
def _create_cover_art_widget(self, album) -> Gtk.Box:
|
|
sizer = Sizer(natural_width=COVER_ART_WIDTH)
|
|
|
|
widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=COVER_ART_WIDTH)
|
|
|
|
# Cover art image
|
|
artwork = SpinnerImage(
|
|
loading=False,
|
|
image_name="grid-artwork",
|
|
spinner_name="grid-artwork-spinner",
|
|
image_size=COVER_ART_WIDTH,
|
|
)
|
|
widget_box.pack_start(artwork, True, False, 0)
|
|
|
|
# Header for the widget
|
|
header_label = self._make_label(album.name, "grid-header-label")
|
|
header_label.set_size_request(COVER_ART_WIDTH, -1)
|
|
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")
|
|
info_label.set_size_request(COVER_ART_WIDTH, -1)
|
|
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)
|
|
)
|
|
|
|
sizer.add(widget_box)
|
|
|
|
sizer.show_all()
|
|
return sizer
|
|
|
|
def _get_opposite_sort_dir(self, sort_dir: str) -> str:
|
|
return ("ascending", "descending")[0 if sort_dir == "descending" else 1]
|
|
|
|
def get_id(self, combo: Gtk.ComboBox) -> Optional[str]:
|
|
tree_iter = combo.get_active_iter()
|
|
if tree_iter is not None:
|
|
return combo.get_model()[tree_iter][0]
|
|
return None
|
|
|
|
def on_sort_toggle_clicked(self, _):
|
|
run_action(self, 'albums.set-search-query', self.current_query, self._get_opposite_sort_dir(self.album_sort_direction))
|
|
|
|
def on_refresh_clicked(self, _):
|
|
run_action(self, "app.force-refresh")
|
|
|
|
def on_type_combo_changed(self, combo: Gtk.ComboBox):
|
|
id = self.get_id(combo)
|
|
assert id
|
|
if id == "alphabetical":
|
|
id += "_" + cast(str, self.get_id(self.alphabetical_type_combo))
|
|
run_action(
|
|
self,
|
|
'albums.set-search-query',
|
|
AlbumSearchQuery(
|
|
_from_str(id),
|
|
self.current_query.year_range,
|
|
self.current_query.genre,
|
|
),
|
|
self.album_sort_direction)
|
|
|
|
def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
|
|
id = "alphabetical_" + cast(str, self.get_id(combo))
|
|
run_action(
|
|
self,
|
|
'albums.set-search-query',
|
|
AlbumSearchQuery(
|
|
_from_str(id),
|
|
self.current_query.year_range,
|
|
self.current_query.genre,
|
|
),
|
|
self.album_sort_direction)
|
|
|
|
def on_genre_change(self, combo: Gtk.ComboBox):
|
|
genre = self.get_id(combo)
|
|
assert genre
|
|
run_action(
|
|
self,
|
|
'albums.set-search-query',
|
|
AlbumSearchQuery(
|
|
self.current_query.type,
|
|
self.current_query.year_range,
|
|
AlbumSearchQuery.Genre(genre),
|
|
),
|
|
self.album_sort_direction)
|
|
|
|
def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
|
|
year = int(entry.get_value())
|
|
assert self.current_query.year_range
|
|
if self.to_year_spin_button == entry:
|
|
new_year_tuple = (self.current_query.year_range[0], year)
|
|
else:
|
|
new_year_tuple = (year, self.current_query.year_range[1])
|
|
|
|
run_action(
|
|
self,
|
|
'albums.set-search-query',
|
|
AlbumSearchQuery(
|
|
self.current_query.type, new_year_tuple, self.current_query.genre
|
|
),
|
|
self.album_sort_direction)
|
|
|
|
return False
|
|
|
|
def on_page_entry_changed(self, entry: Gtk.Entry) -> bool:
|
|
if len(text := entry.get_text()) > 0:
|
|
run_action(self, 'albums.set-page', int(text) - 1)
|
|
return False
|
|
|
|
def on_page_entry_insert_text(
|
|
self, entry: Gtk.Entry, text: str, length: int, position: int
|
|
) -> bool:
|
|
if self.updating_query:
|
|
return False
|
|
if not text.isdigit():
|
|
entry.emit_stop_by_name("insert-text")
|
|
return True
|
|
page_num = int(entry.get_text() + text)
|
|
if self.grid_pages_count is None or self.grid_pages_count < page_num:
|
|
entry.emit_stop_by_name("insert-text")
|
|
return True
|
|
return False
|
|
|
|
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.album_container)
|
|
|
|
run_action(self, 'albums.select-album', album.id)
|
|
|
|
"""
|
|
|
|
# TODO: REMOVE
|
|
class AlbumsGrid(Gtk.Overlay):
|
|
|
|
__gsignals__ = {
|
|
"cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
|
|
"refresh-window": (
|
|
GObject.SignalFlags.RUN_FIRST,
|
|
GObject.TYPE_NONE,
|
|
(object, bool),
|
|
),
|
|
"song-clicked": (
|
|
GObject.SignalFlags.RUN_FIRST,
|
|
GObject.TYPE_NONE,
|
|
(int, object, object),
|
|
),
|
|
"num-pages-changed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int,)),
|
|
}
|
|
|
|
class _AlbumModel(GObject.Object):
|
|
def __init__(self, album: API.Album):
|
|
self.album = album
|
|
super().__init__()
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
assert self.album.id
|
|
return self.album.id
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<AlbumsGrid._AlbumModel {self.album}>"
|
|
|
|
current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
|
|
current_models: List[_AlbumModel] = []
|
|
latest_applied_order_ratchet: int = 0
|
|
order_ratchet: int = 0
|
|
offline_mode: bool = False
|
|
|
|
currently_selected_index: Optional[int] = None
|
|
currently_selected_id: Optional[str] = None
|
|
sort_dir: str = ""
|
|
page_size: int = 30
|
|
page: int = 0
|
|
num_pages: Optional[int] = None
|
|
next_page_fn = None
|
|
provider_id: Optional[str] = None
|
|
|
|
def update_params(self, app_config: AppConfiguration) -> int:
|
|
# If there's a diff, increase the ratchet.
|
|
if (
|
|
self.current_query.strhash()
|
|
!= (search_query := app_config.state.current_album_search_query).strhash()
|
|
):
|
|
self.order_ratchet += 1
|
|
self.current_query = search_query
|
|
|
|
if self.offline_mode != (offline_mode := app_config.offline_mode):
|
|
self.order_ratchet += 1
|
|
self.offline_mode = offline_mode
|
|
|
|
if self.provider_id != (provider_id := app_config.current_provider_id):
|
|
self.order_ratchet += 1
|
|
self.provider_id = provider_id
|
|
|
|
return self.order_ratchet
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.items_per_row = 4
|
|
|
|
scrolled_window = Gtk.ScrolledWindow()
|
|
grid_detail_grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
self.error_container = Gtk.Box()
|
|
grid_detail_grid_box.add(self.error_container)
|
|
|
|
def create_flowbox(**kwargs) -> Gtk.FlowBox:
|
|
flowbox = Gtk.FlowBox(
|
|
**kwargs,
|
|
hexpand=True,
|
|
row_spacing=5,
|
|
column_spacing=5,
|
|
margin_top=5,
|
|
homogeneous=True,
|
|
valign=Gtk.Align.START,
|
|
halign=Gtk.Align.CENTER,
|
|
selection_mode=Gtk.SelectionMode.SINGLE,
|
|
)
|
|
flowbox.set_max_children_per_line(7)
|
|
return flowbox
|
|
|
|
self.grid_top = create_flowbox()
|
|
self.grid_top.connect("child-activated", self.on_child_activated)
|
|
self.grid_top.connect("size-allocate", self.on_grid_resize)
|
|
|
|
self.list_store_top = Gio.ListStore()
|
|
self.grid_top.bind_model(self.list_store_top, self._create_cover_art_widget)
|
|
|
|
grid_detail_grid_box.add(self.grid_top)
|
|
|
|
self.detail_box_revealer = Gtk.Revealer(valign=Gtk.Align.END)
|
|
self.detail_box = Gtk.Box(
|
|
orientation=Gtk.Orientation.HORIZONTAL, name="artist-detail-box"
|
|
)
|
|
self.detail_box.pack_start(Gtk.Box(), True, True, 0)
|
|
|
|
self.detail_box_inner = Gtk.Box()
|
|
self.detail_box.pack_start(self.detail_box_inner, False, False, 0)
|
|
|
|
self.detail_box.pack_start(Gtk.Box(), True, True, 0)
|
|
self.detail_box_revealer.add(self.detail_box)
|
|
grid_detail_grid_box.add(self.detail_box_revealer)
|
|
|
|
self.grid_bottom = create_flowbox(vexpand=True)
|
|
self.grid_bottom.connect("child-activated", self.on_child_activated)
|
|
|
|
self.list_store_bottom = Gio.ListStore()
|
|
self.grid_bottom.bind_model(
|
|
self.list_store_bottom, self._create_cover_art_widget
|
|
)
|
|
|
|
grid_detail_grid_box.add(self.grid_bottom)
|
|
|
|
scrolled_window.add(grid_detail_grid_box)
|
|
self.add(scrolled_window)
|
|
|
|
self.spinner = Gtk.Spinner(
|
|
name="grid-spinner",
|
|
active=True,
|
|
halign=Gtk.Align.CENTER,
|
|
valign=Gtk.Align.CENTER,
|
|
)
|
|
self.add_overlay(self.spinner)
|
|
|
|
def update(
|
|
self, order_token: int, app_config: AppConfiguration = None, force: bool = False
|
|
):
|
|
if order_token < self.latest_applied_order_ratchet:
|
|
return
|
|
|
|
force_grid_reload_from_master = False
|
|
if app_config:
|
|
self.currently_selected_id = app_config.state.selected_album_id
|
|
|
|
if (
|
|
self.sort_dir != app_config.state.album_sort_direction
|
|
or self.page_size != app_config.state.album_page_size
|
|
or self.page != app_config.state.album_page
|
|
):
|
|
force_grid_reload_from_master = True
|
|
self.sort_dir = app_config.state.album_sort_direction
|
|
self.page_size = app_config.state.album_page_size
|
|
self.page = app_config.state.album_page
|
|
|
|
self.update_grid(
|
|
order_token,
|
|
use_ground_truth_adapter=force,
|
|
force_grid_reload_from_master=force_grid_reload_from_master,
|
|
)
|
|
|
|
# Update the detail panel.
|
|
children = self.detail_box_inner.get_children()
|
|
if len(children) > 0 and hasattr(children[0], "update"):
|
|
children[0].update(app_config=app_config, force=force)
|
|
|
|
error_dialog = None
|
|
|
|
def update_grid(
|
|
self,
|
|
order_token: int,
|
|
use_ground_truth_adapter: bool = False,
|
|
force_grid_reload_from_master: bool = False,
|
|
):
|
|
if not AdapterManager.can_get_artists():
|
|
self.spinner.hide()
|
|
return
|
|
|
|
force_grid_reload_from_master = (
|
|
force_grid_reload_from_master
|
|
or use_ground_truth_adapter
|
|
or self.latest_applied_order_ratchet < order_token
|
|
)
|
|
|
|
def do_update_grid(selected_index: Optional[int]):
|
|
if self.sort_dir == "descending" and selected_index:
|
|
selected_index = len(self.current_models) - selected_index - 1
|
|
|
|
self.reflow_grids(
|
|
force_reload_from_master=force_grid_reload_from_master,
|
|
selected_index=selected_index,
|
|
models=self.current_models,
|
|
)
|
|
self.spinner.hide()
|
|
|
|
def reload_store(f: Result[Iterable[API.Album]]):
|
|
# Don't override more recent results
|
|
if order_token < self.latest_applied_order_ratchet:
|
|
return
|
|
self.latest_applied_order_ratchet = order_token
|
|
|
|
is_partial = False
|
|
try:
|
|
albums = list(f.result())
|
|
except CacheMissError as e:
|
|
albums = cast(Optional[List[API.Album]], e.partial_data) or []
|
|
is_partial = True
|
|
except Exception as e:
|
|
if self.error_dialog:
|
|
self.spinner.hide()
|
|
return
|
|
# TODO (#122): make this non-modal
|
|
self.error_dialog = Gtk.MessageDialog(
|
|
transient_for=self.get_toplevel(),
|
|
message_type=Gtk.MessageType.ERROR,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text="Failed to retrieve albums",
|
|
)
|
|
self.error_dialog.format_secondary_markup(
|
|
# TODO (#204) make this error better.
|
|
f"Getting albums by {self.current_query.type} failed due to the "
|
|
f"following error\n\n{e}"
|
|
)
|
|
logging.exception("Failed to retrieve albums")
|
|
self.error_dialog.run()
|
|
self.error_dialog.destroy()
|
|
self.error_dialog = None
|
|
self.spinner.hide()
|
|
return
|
|
|
|
for c in self.error_container.get_children():
|
|
self.error_container.remove(c)
|
|
if is_partial and (
|
|
len(albums) == 0
|
|
or self.current_query.type != AlbumSearchQuery.Type.RANDOM
|
|
):
|
|
load_error = LoadError(
|
|
"Album list",
|
|
"load albums",
|
|
has_data=albums is not None and len(albums) > 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()
|
|
|
|
selected_index = None
|
|
self.current_models = []
|
|
for i, album in enumerate(albums):
|
|
model = AlbumsGrid._AlbumModel(album)
|
|
|
|
if model.id == self.currently_selected_id:
|
|
selected_index = i
|
|
|
|
self.current_models.append(model)
|
|
|
|
self.emit(
|
|
"num-pages-changed",
|
|
math.ceil(len(self.current_models) / self.page_size),
|
|
)
|
|
do_update_grid(selected_index)
|
|
|
|
if force_grid_reload_from_master:
|
|
albums_result = AdapterManager.get_albums(
|
|
self.current_query, use_ground_truth_adapter=use_ground_truth_adapter
|
|
)
|
|
if albums_result.data_is_available:
|
|
# Don't idle add if the data is already available.
|
|
albums_result.add_done_callback(reload_store)
|
|
else:
|
|
self.spinner.show()
|
|
albums_result.add_done_callback(
|
|
lambda f: GLib.idle_add(reload_store, f)
|
|
)
|
|
else:
|
|
selected_index = None
|
|
for i, album in enumerate(self.current_models):
|
|
if album.id == self.currently_selected_id:
|
|
selected_index = i
|
|
self.emit(
|
|
"num-pages-changed",
|
|
math.ceil(len(self.current_models) / self.page_size),
|
|
)
|
|
do_update_grid(selected_index)
|
|
|
|
# Event Handlers
|
|
# =========================================================================
|
|
def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget):
|
|
click_top = flowbox == self.grid_top
|
|
selected_index = child.get_index()
|
|
|
|
if click_top:
|
|
page_offset = self.page_size * self.page
|
|
if self.currently_selected_index is not None and (
|
|
selected_index == self.currently_selected_index - page_offset
|
|
):
|
|
self.emit("cover-clicked", None)
|
|
else:
|
|
self.emit("cover-clicked", self.list_store_top[selected_index].id)
|
|
else:
|
|
self.emit("cover-clicked", self.list_store_bottom[selected_index].id)
|
|
|
|
def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle):
|
|
# TODO (#124): this doesn't work at all consistency, especially with themes that
|
|
# add extra padding.
|
|
# 200 + (10 * 2) + (5 * 2) = 230
|
|
# picture + (padding * 2) + (margin * 2)
|
|
new_items_per_row = min((rect.width // 230), 7)
|
|
if new_items_per_row != self.items_per_row:
|
|
self.items_per_row = new_items_per_row
|
|
self.detail_box_inner.set_size_request(self.items_per_row * 230 - 10, -1)
|
|
|
|
self.reflow_grids(
|
|
force_reload_from_master=True,
|
|
selected_index=self.currently_selected_index,
|
|
)
|
|
|
|
# Helper Methods
|
|
# =========================================================================
|
|
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, item: _AlbumModel) -> 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=150,
|
|
)
|
|
widget_box.pack_start(artwork, False, False, 0)
|
|
|
|
# Header for the widget
|
|
header_label = self._make_label(item.album.name, "grid-header-label")
|
|
widget_box.pack_start(header_label, False, False, 0)
|
|
|
|
# Extra info for the widget
|
|
info_text = util.dot_join(
|
|
item.album.artist.name if item.album.artist else "-", item.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(
|
|
item.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 reflow_grids(
|
|
self,
|
|
force_reload_from_master: bool = False,
|
|
selected_index: int = None,
|
|
models: List[_AlbumModel] = None,
|
|
):
|
|
# Calculate the page that the currently_selected_index is in. If it's a
|
|
# different page, then update the window.
|
|
if selected_index is not None:
|
|
page_of_selected_index = selected_index // self.page_size
|
|
if page_of_selected_index != self.page:
|
|
self.emit(
|
|
"refresh-window", {"album_page": page_of_selected_index}, False
|
|
)
|
|
return
|
|
page_offset = self.page_size * self.page
|
|
|
|
# Calculate the look-at window.
|
|
if models:
|
|
if self.sort_dir == "ascending":
|
|
window = models[page_offset : (page_offset + self.page_size)]
|
|
else:
|
|
reverse_sorted_models = reversed(models)
|
|
# remove to the offset
|
|
for _ in range(page_offset):
|
|
next(reverse_sorted_models, page_offset)
|
|
window = list(itertools.islice(reverse_sorted_models, self.page_size))
|
|
else:
|
|
window = list(self.list_store_top) + list(self.list_store_bottom)
|
|
|
|
# Determine where the cuttoff is between the top and bottom grids.
|
|
entries_before_fold = self.page_size
|
|
if selected_index is not None and self.items_per_row:
|
|
relative_selected_index = selected_index - page_offset
|
|
entries_before_fold = (
|
|
(relative_selected_index // self.items_per_row) + 1
|
|
) * self.items_per_row
|
|
|
|
# Unreveal the current album details first
|
|
if selected_index is None:
|
|
self.detail_box_revealer.set_reveal_child(False)
|
|
|
|
if force_reload_from_master:
|
|
# Just remove everything and re-add all of the items. It's not worth trying
|
|
# to diff in this case.
|
|
self.list_store_top.splice(
|
|
0,
|
|
len(self.list_store_top),
|
|
window[:entries_before_fold],
|
|
)
|
|
self.list_store_bottom.splice(
|
|
0,
|
|
len(self.list_store_bottom),
|
|
window[entries_before_fold:],
|
|
)
|
|
elif selected_index or entries_before_fold != self.page_size:
|
|
# This case handles when the selection changes and the entries need to be
|
|
# re-allocated to the top and bottom grids
|
|
# Move entries between the two stores.
|
|
top_store_len = len(self.list_store_top)
|
|
bottom_store_len = len(self.list_store_bottom)
|
|
diff = abs(entries_before_fold - top_store_len)
|
|
|
|
if diff > 0:
|
|
if entries_before_fold - top_store_len > 0:
|
|
# Move entries from the bottom store.
|
|
self.list_store_top.splice(
|
|
top_store_len, 0, self.list_store_bottom[:diff]
|
|
)
|
|
self.list_store_bottom.splice(0, min(diff, bottom_store_len), [])
|
|
else:
|
|
# Move entries to the bottom store.
|
|
self.list_store_bottom.splice(0, 0, self.list_store_top[-diff:])
|
|
self.list_store_top.splice(top_store_len - diff, diff, [])
|
|
|
|
if selected_index is not None:
|
|
relative_selected_index = selected_index - page_offset
|
|
to_select = self.grid_top.get_child_at_index(relative_selected_index)
|
|
if not to_select:
|
|
return
|
|
self.grid_top.select_child(to_select)
|
|
|
|
if self.currently_selected_index == selected_index:
|
|
return
|
|
|
|
for c in self.detail_box_inner.get_children():
|
|
self.detail_box_inner.remove(c)
|
|
|
|
model = self.list_store_top[relative_selected_index]
|
|
detail_element = AlbumWithSongs(model.album, cover_art_size=300)
|
|
detail_element.connect(
|
|
"song-clicked",
|
|
lambda _, *args: self.emit("song-clicked", *args),
|
|
)
|
|
detail_element.connect("song-selected", lambda *a: None)
|
|
|
|
self.detail_box_inner.pack_start(detail_element, True, True, 0)
|
|
self.detail_box_inner.show_all()
|
|
self.detail_box_revealer.set_reveal_child(True)
|
|
|
|
# TODO (#88): scroll so that the grid_top is visible, and the
|
|
# detail_box is visible, with preference to the grid_top. May need
|
|
# to add another flag for this function.
|
|
else:
|
|
self.grid_top.unselect_all()
|
|
self.grid_bottom.unselect_all()
|
|
|
|
self.currently_selected_index = selected_index
|
|
"""
|