Rename package

This commit is contained in:
Sumner Evans
2020-09-19 09:52:28 -06:00
parent c74e775ed8
commit aec54f8a1d
85 changed files with 2 additions and 2 deletions

View File

969
sublime_music/ui/albums.py Normal file
View File

@@ -0,0 +1,969 @@
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
from sublime.adapters import (
AdapterManager,
AlbumSearchQuery,
api_objects as API,
CacheMissError,
Result,
)
from sublime.config import AppConfiguration
from sublime.ui import util
from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
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(Gtk.Box):
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
offline_mode = False
populating_genre_combo = False
grid_order_token: int = 0
album_sort_direction: str = "ascending"
album_page_size: int = 30
album_page: int = 0
grid_pages_count: int = 0
def __init__(self):
super().__init__(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.alphabetical_type_combo, _ = self.make_combobox(
(("by_name", "by album name", True), ("by_artist", "by artist name", True)),
self.on_alphabetical_type_change,
)
actionbar.pack_start(self.alphabetical_type_combo)
self.genre_combo, self.genre_combo_store = self.make_combobox(
(), self.on_genre_change
)
actionbar.pack_start(self.genre_combo)
next_decade = (datetime.datetime.now().year // 10) * 10 + 10
self.from_year_label = Gtk.Label(label="from")
actionbar.pack_start(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)
actionbar.pack_start(self.from_year_spin_button)
self.to_year_label = Gtk.Label(label="to")
actionbar.pack_start(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)
actionbar.pack_start(self.to_year_spin_button)
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)
# Add the page widget.
page_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
self.prev_page = IconButton(
"go-previous-symbolic", "Go to the previous page", sensitive=False
)
self.prev_page.connect("clicked", self.on_prev_page_clicked)
page_widget.add(self.prev_page)
page_widget.add(Gtk.Label(label="Page"))
self.page_entry = Gtk.Entry()
self.page_entry.set_width_chars(1)
self.page_entry.set_max_width_chars(1)
self.page_entry.connect("changed", self.on_page_entry_changed)
self.page_entry.connect("insert-text", self.on_page_entry_insert_text)
page_widget.add(self.page_entry)
page_widget.add(Gtk.Label(label="of"))
self.page_count_label = Gtk.Label(label="-")
page_widget.add(self.page_count_label)
self.next_page = IconButton(
"go-next-symbolic", "Go to the next page", sensitive=False
)
self.next_page.connect("clicked", self.on_next_page_clicked)
page_widget.add(self.next_page)
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)
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.add(actionbar)
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,
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 = None, 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 app_config:
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.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)
self.page_entry.set_text(str(self.album_page + 1))
# 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)
)
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)
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, _):
self.emit(
"refresh-window",
{
"album_sort_direction": self._get_opposite_sort_dir(
self.album_sort_direction
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
def on_refresh_clicked(self, _):
self.emit("refresh-window", {}, True)
class _Genre(API.Genre):
def __init__(self, name: str):
self.name = name
def on_grid_num_pages_changed(self, grid: Any, pages: int):
self.grid_pages_count = pages
pages_str = str(self.grid_pages_count)
self.page_count_label.set_text(pages_str)
self.next_page.set_sensitive(self.album_page < self.grid_pages_count - 1)
num_digits = len(pages_str)
self.page_entry.set_width_chars(num_digits)
self.page_entry.set_max_width_chars(num_digits)
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))
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
_from_str(id),
self.current_query.year_range,
self.current_query.genre,
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
id = "alphabetical_" + cast(str, self.get_id(combo))
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
_from_str(id),
self.current_query.year_range,
self.current_query.genre,
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
def on_genre_change(self, combo: Gtk.ComboBox):
genre = self.get_id(combo)
assert genre
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
self.current_query.type,
self.current_query.year_range,
AlbumsPanel._Genre(genre),
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
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])
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
self.current_query.type, new_year_tuple, self.current_query.genre
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
return False
def on_page_entry_changed(self, entry: Gtk.Entry) -> bool:
if len(text := entry.get_text()) > 0:
self.emit_if_not_updating(
"refresh-window",
{"album_page": int(text) - 1, "selected_album_id": None},
False,
)
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_prev_page_clicked(self, _):
self.emit_if_not_updating(
"refresh-window",
{"album_page": self.album_page - 1, "selected_album_id": None},
False,
)
def on_next_page_clicked(self, _):
self.emit_if_not_updating(
"refresh-window",
{"album_page": self.album_page + 1, "selected_album_id": None},
False,
)
def on_grid_cover_clicked(self, grid: Any, id: str):
self.emit(
"refresh-window",
{"selected_album_id": id},
False,
)
def on_show_count_dropdown_change(self, combo: Gtk.ComboBox):
show_count = int(self.get_id(combo) or 30)
self.emit(
"refresh-window",
{"album_page_size": show_count, "album_page": 0},
False,
)
def emit_if_not_updating(self, *args):
if self.updating_query:
return
self.emit(*args)
class AlbumsGrid(Gtk.Overlay):
"""Defines the albums panel."""
__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=200,
)
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

View File

@@ -0,0 +1,340 @@
/* ********** Main ********** */
#connected-to-label {
margin: 5px 15px;
font-size: 1.2em;
min-width: 200px;
}
#connected-status-row {
margin-bottom: 5px;
}
#online-status-icon {
margin-right: 10px;
}
#menu-header {
margin: 10px 15px 10px 5px;
font-weight: bold;
}
#menu-settings-separator {
margin-bottom: 5px;
font-weight: bold;
}
#current-downloads-list {
min-height: 30px;
min-width: 350px;
}
#current-downloads-list-placeholder {
margin: 10px;
}
#current-downloads-list-pending-count,
#current-downloads-list-failed-count {
margin: 0 5px;
}
#current-downloads-cover-art-image {
margin: 3px 5px;
}
.menu-label {
margin-right: 15px;
}
#main-menu-box {
min-width: 230px;
}
#icon-button-box image {
margin: 5px 2px;
min-width: 15px;
}
#icon-button-box label {
margin-left: 5px;
margin-right: 3px;
}
#menu-item-download-settings,
#menu-item-clear-cache {
min-width: 230px;
}
/* ********** Configure Provider Dialog ********** */
#ground-truth-adapter-options-list {
margin: 0 40px;
}
#music-source-config-name-entry-grid {
margin: 10px 0;
}
#config-verification-separator {
margin: 5px -10px;
}
#verify-config-spinner {
min-height: 32px;
min-width: 32px;
}
.configure-form-help-icon {
margin-left: 10px;
}
entry.invalid {
border-color: red;
}
/* ********** Playlist ********** */
#playlist-list-listbox row {
margin: 0;
padding: 0;
}
#playlist-list-spinner:checked,
#artist-list-spinner:checked,
#drilldown-list-spinner:checked {
margin: 10px;
padding: 0px;
}
#playlist-list-new-playlist-entry {
margin: 10px 10px 5px 10px;
}
#playlist-list-new-playlist-cancel {
margin: 5px 0 10px 0;
}
#playlist-list-new-playlist-confirm {
margin: 5px 10px 10px 0;
}
#playlist-artwork-spinner,
#artist-artwork-spinner,
#album-artwork-spinner,
#albumslist-with-songs-spinner {
min-height: 35px;
min-width: 35px;
}
#browse-spinner {
min-height: 100px;
min-width: 100px;
}
#menu-item-add-to-playlist {
min-width: 170px;
}
#menu-item-spinner {
margin: 10px;
}
#playlist-album-artwork {
margin: 10px 15px 0 10px;
}
#playlist-name, #artist-detail-panel #artist-name {
font-size: 40px;
margin-bottom: 10px;
}
#playlist-name.collapsed,
#artist-detail-panel #artist-name.collapsed {
font-size: 30px;
}
#playlist-comment, #playlist-stats, #artist-bio, #artist-stats, #similar-artists {
margin-bottom: 10px;
}
#similar-artist-button {
padding: 0;
margin: -10px 0 0 10px;
}
/* ********** Playback Controls ********** */
#player-controls-album-artwork {
min-height: 70px;
min-width: 70px;
margin-right: 10px;
}
#player-controls-bar #play-button {
min-height: 45px;
min-width: 35px;
border-width: 1px;
border-radius: 45px;
}
/* Make the play icon look centered. */
#player-controls-bar #play-button image {
margin-left: 5px;
margin-right: 5px;
margin-top: 1px;
margin-bottom: 1px;
}
#player-controls-bar #song-scrubber {
min-width: 400px;
}
#player-controls-bar #volume-slider {
min-width: 90px;
}
#player-controls-bar #volume-slider value {
margin-left: 8px;
}
#player-controls-bar #song-title {
margin-bottom: 3px;
font-weight: bold;
}
#player-controls-bar #album-name {
margin-bottom: 3px;
font-style: italic;
}
#device-popover-box {
min-width: 150px;
}
#device-type-section-title {
margin: 5px;
font-style: italic;
}
#up-next-popover #label {
margin: 10px;
}
#play-queue-playing-icon {
min-height: 40px;
min-width: 40px;
}
#play-queue-row-image {
min-height: 50px;
min-width: 50px;
}
#play-queue-image-disabled {
opacity: 0.5;
}
#play-queue-spinner {
min-height: 35px;
min-width: 35px;
}
/* ********** General ********** */
.menu-button {
padding: 5px;
}
/* ********** Search ********** */
#search-results {
min-width: 400px;
}
#search-spinner {
margin: 15px;
}
.search-result-row {
padding: 5px;
}
.search-result-header {
font-weight: bold;
font-size: 1.2em;
}
#search-artwork {
margin-right: 5px;
min-width: 30px;
min-height: 30px;
}
/* ********** Error Indicator ********** */
#load-error-box {
margin: 15px;
}
#load-error-image,
#load-error-label {
margin-bottom: 5px;
margin-right: 20px;
}
/* ********** Artists & Albums ********** */
#grid-artwork-spinner, #album-list-song-list-spinner {
min-height: 35px;
min-width: 35px;
}
#grid-artwork {
min-height: 200px;
min-width: 200px;
margin: 10px;
}
#grid-spinner {
min-height: 50px;
min-width: 50px;
margin: 20px;
}
#grid-header-label {
margin-left: 10px;
margin-right: 10px;
margin-bottom: 3px;
font-weight: bold;
}
#grid-info-label {
margin-left: 10px;
margin-right: 10px;
margin-bottom: 10px;
}
#artist-album-artwork {
margin: 10px 15px 0 10px;
}
#artist-album-list-artwork {
margin: 10px;
}
#artist-album-list-album-name {
margin: 10px 10px 5px 10px;
font-size: 25px;
}
#album-list-song-list-spinner {
margin: 15px;
}
@define-color box_shadow_color rgba(0, 0, 0, 0.2);
#artist-info-panel {
box-shadow: 0 5px 5px @box_shadow_color;
margin-bottom: 10px;
padding-bottom: 10px;
}
#artist-detail-box {
padding-top: 10px;
padding-bottom: 10px;
box-shadow: inset 0 5px 5px @box_shadow_color,
inset 0 -5px 5px @box_shadow_color;
background-color: @box_shadow_color;
}

665
sublime_music/ui/artists.py Normal file
View File

@@ -0,0 +1,665 @@
from datetime import timedelta
from functools import partial
from random import randint
from typing import cast, List, Sequence
from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.adapters import (
AdapterManager,
api_objects as API,
CacheMissError,
SongCacheStatus,
)
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", ellipsize=Pango.EllipsizeMode.END
)
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",
)
self.play_button = IconButton(
"media-playback-start-symbolic", label="Play All", relief=True
)
self.play_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0)
self.shuffle_button = IconButton(
"media-playlist-shuffle-symbolic", label="Shuffle All", relief=True
)
self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(self.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 []
# (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 _: 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]:
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):
__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.year or float("inf"), 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()

507
sublime_music/ui/browse.py Normal file
View File

@@ -0,0 +1,507 @@
from functools import partial
from typing import Any, cast, List, Optional, Tuple
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, api_objects as API, CacheMissError, Result
from sublime.config import AppConfiguration
from sublime.ui import util
from sublime.ui.common import IconButton, LoadError, SongListColumn
class BrowsePanel(Gtk.Overlay):
"""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),
),
}
update_order_token = 0
def __init__(self):
super().__init__()
scrolled_window = Gtk.ScrolledWindow()
window_box = Gtk.Box()
self.error_container = Gtk.Box()
window_box.pack_start(self.error_container, True, True, 0)
self.root_directory_listing = ListAndDrilldown()
self.root_directory_listing.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
self.root_directory_listing.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
window_box.add(self.root_directory_listing)
scrolled_window.add(window_box)
self.add(scrolled_window)
self.spinner = Gtk.Spinner(
name="browse-spinner",
active=True,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.add_overlay(self.spinner)
def update(self, app_config: AppConfiguration, force: bool = False):
self.update_order_token += 1
def do_update(update_order_token: int, id_stack: Tuple[str, ...]):
if self.update_order_token != update_order_token:
return
if len(id_stack) == 0:
self.root_directory_listing.hide()
if len(self.error_container.get_children()) == 0:
load_error = LoadError(
"Directory list",
"browse to song",
has_data=False,
offline_mode=app_config.offline_mode,
)
self.error_container.pack_start(load_error, True, True, 0)
self.error_container.show_all()
else:
for c in self.error_container.get_children():
self.error_container.remove(c)
self.error_container.hide()
self.root_directory_listing.update(id_stack, app_config, force)
self.spinner.hide()
def calculate_path() -> Tuple[str, ...]:
if (current_dir_id := app_config.state.selected_browse_element_id) is None:
return ("root",)
id_stack = []
while current_dir_id:
try:
directory = AdapterManager.get_directory(
current_dir_id,
before_download=self.spinner.show,
).result()
except CacheMissError as e:
directory = cast(API.Directory, e.partial_data)
if not directory:
break
else:
id_stack.append(directory.id)
current_dir_id = directory.parent_id
return tuple(id_stack)
path_result: Result[Tuple[str, ...]] = Result(calculate_path)
path_result.add_done_callback(
lambda f: GLib.idle_add(
partial(do_update, self.update_order_token), f.result()
)
)
class ListAndDrilldown(Gtk.Paned):
__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):
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
self.list = MusicDirectoryList()
self.list.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
self.list.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.pack1(self.list, False, False)
self.box = Gtk.Box()
self.pack2(self.box, True, False)
def update(
self,
id_stack: Tuple[str, ...],
app_config: AppConfiguration,
force: bool = False,
):
*child_id_stack, dir_id = id_stack
selected_id = child_id_stack[-1] if len(child_id_stack) > 0 else None
self.show()
self.list.update(
directory_id=dir_id,
selected_id=selected_id,
app_config=app_config,
force=force,
)
children = self.box.get_children()
if len(child_id_stack) == 0:
if len(children) > 0:
self.box.remove(children[0])
else:
if len(children) == 0:
drilldown = ListAndDrilldown()
drilldown.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
drilldown.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.box.add(drilldown)
self.box.show_all()
self.box.get_children()[0].update(
tuple(child_id_stack), app_config, force=force
)
class MusicDirectoryList(Gtk.Box):
__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
directory_id: Optional[str] = None
selected_id: Optional[str] = None
offline_mode = False
class DrilldownElement(GObject.GObject):
id = GObject.Property(type=str)
name = GObject.Property(type=str)
def __init__(self, element: API.Directory):
GObject.GObject.__init__(self)
self.id = element.id
self.name = element.name
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
list_actions = Gtk.ActionBar()
self.refresh_button = IconButton("view-refresh-symbolic", "Refresh folder")
self.refresh_button.connect("clicked", lambda *a: self.update(force=True))
list_actions.pack_end(self.refresh_button)
self.add(list_actions)
self.loading_indicator = Gtk.ListBox()
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
spinner = Gtk.Spinner(name="drilldown-list-spinner", active=True)
spinner_row.add(spinner)
self.loading_indicator.add(spinner_row)
self.pack_start(self.loading_indicator, False, False, 0)
self.error_container = Gtk.Box()
self.add(self.error_container)
self.scroll_window = Gtk.ScrolledWindow(min_content_width=250)
scrollbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.drilldown_directories_store = Gio.ListStore()
self.list = Gtk.ListBox()
self.list.bind_model(self.drilldown_directories_store, self.create_row)
scrollbox.add(self.list)
# clickable, cache status, title, duration, song ID
self.directory_song_store = Gtk.ListStore(bool, str, str, str, str)
self.directory_song_list = Gtk.TreeView(
model=self.directory_song_store,
name="directory-songs-list",
headers_visible=False,
)
selection = self.directory_song_list.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn("", renderer, icon_name=1)
column.set_resizable(True)
self.directory_song_list.append_column(column)
self.directory_song_list.append_column(SongListColumn("TITLE", 2, bold=True))
self.directory_song_list.append_column(
SongListColumn("DURATION", 3, align=1, width=40)
)
self.directory_song_list.connect("row-activated", self.on_song_activated)
self.directory_song_list.connect(
"button-press-event", self.on_song_button_press
)
scrollbox.add(self.directory_song_list)
self.scroll_window.add(scrollbox)
self.pack_start(self.scroll_window, True, True, 0)
def update(
self,
app_config: AppConfiguration = None,
force: bool = False,
directory_id: str = None,
selected_id: str = None,
):
self.directory_id = directory_id or self.directory_id
self.selected_id = selected_id or self.selected_id
self.update_store(
self.directory_id,
force=force,
order_token=self.update_order_token,
)
if app_config:
# Deselect everything if switching online to offline.
if self.offline_mode != app_config.offline_mode:
self.directory_song_list.get_selection().unselect_all()
for c in self.error_container.get_children():
self.error_container.remove(c)
self.offline_mode = app_config.offline_mode
self.refresh_button.set_sensitive(not self.offline_mode)
_current_child_ids: List[str] = []
@util.async_callback(
AdapterManager.get_directory,
before_download=lambda self: self.loading_indicator.show(),
on_failure=lambda self, e: self.loading_indicator.hide(),
)
def update_store(
self,
directory: API.Directory,
app_config: AppConfiguration = None,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if order_token != self.update_order_token:
return
dir_children = directory.children or []
for c in self.error_container.get_children():
self.error_container.remove(c)
if is_partial:
load_error = LoadError(
"Directory listing",
"load directory",
has_data=len(dir_children) > 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()
# This doesn't look efficient, since it's doing a ton of passses over the data,
# but there is some annoying memory overhead for generating the stores to diff,
# so we are short-circuiting by checking to see if any of the the IDs have
# changed.
#
# The entire algorithm ends up being O(2n), but the first loop is very tight,
# and the expensive parts of the second loop are avoided if the IDs haven't
# changed.
children_ids, children, song_ids = [], [], []
selected_dir_idx = None
if len(self._current_child_ids) != len(dir_children):
force = True
for i, c in enumerate(dir_children):
if i >= len(self._current_child_ids) or c.id != self._current_child_ids[i]:
force = True
if c.id == self.selected_id:
selected_dir_idx = i
children_ids.append(c.id)
children.append(c)
if not hasattr(c, "children"):
song_ids.append(c.id)
if force:
new_directories_store = []
self._current_child_ids = children_ids
songs = []
for el in children:
if hasattr(el, "children"):
new_directories_store.append(
MusicDirectoryList.DrilldownElement(cast(API.Directory, el))
)
else:
songs.append(cast(API.Song, el))
util.diff_model_store(
self.drilldown_directories_store, new_directories_store
)
new_songs_store = [
[
(
not self.offline_mode
or status_icon
in ("folder-download-symbolic", "view-pin-symbolic")
),
status_icon,
util.esc(song.title),
util.format_song_duration(song.duration),
song.id,
]
for status_icon, song in zip(
util.get_cached_status_icons(song_ids), songs
)
]
else:
new_songs_store = [
[
(
not self.offline_mode
or status_icon
in ("folder-download-symbolic", "view-pin-symbolic")
),
status_icon,
*song_model[2:],
]
for status_icon, song_model in zip(
util.get_cached_status_icons(song_ids), self.directory_song_store
)
]
util.diff_song_store(self.directory_song_store, new_songs_store)
self.directory_song_list.show()
if len(self.drilldown_directories_store) == 0:
self.list.hide()
else:
self.list.show()
if len(self.directory_song_store) == 0:
self.directory_song_list.hide()
self.scroll_window.set_min_content_width(275)
else:
self.directory_song_list.show()
self.scroll_window.set_min_content_width(350)
# Preserve selection
if selected_dir_idx is not None:
row = self.list.get_row_at_index(selected_dir_idx)
self.list.select_row(row)
self.loading_indicator.hide()
def on_download_state_change(self, _):
self.update()
# Create Element Helper Functions
# ==================================================================================
def create_row(self, model: DrilldownElement) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow(
action_name="app.browse-to",
action_target=GLib.Variant("s", model.id),
)
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.add(
Gtk.Label(
label=f"<b>{util.esc(model.name)}</b>",
use_markup=True,
margin=8,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
image = Gtk.Image.new_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON)
rowbox.pack_end(image, False, False, 5)
row.add(rowbox)
row.show_all()
return row
# Event Handlers
# ==================================================================================
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
if not self.directory_song_store[idx[0]][0]:
return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.directory_song_store],
{},
)
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path:
return False
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
# Use the new selection instead of the old one for calculating what
# to do the right click on.
if clicked_path[0] not in paths:
paths = [clicked_path[0]]
allow_deselect = True
song_ids = [self.directory_song_store[p][-1] for p in paths]
# Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
util.show_song_popover(
song_ids,
event.x,
event.y + abs(bin_coords.by - widget_coords.wy),
tree,
self.offline_mode,
on_download_state_change=self.on_download_state_change,
)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
return False

View File

@@ -0,0 +1,15 @@
from .album_with_songs import AlbumWithSongs
from .icon_button import IconButton, IconMenuButton, IconToggleButton
from .load_error import LoadError
from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage
__all__ = (
"AlbumWithSongs",
"IconButton",
"IconMenuButton",
"IconToggleButton",
"LoadError",
"SongListColumn",
"SpinnerImage",
)

View File

@@ -0,0 +1,356 @@
from random import randint
from typing import Any, cast, List
from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.config import AppConfiguration
from sublime.ui import util
from .icon_button import IconButton
from .load_error import LoadError
from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage
class AlbumWithSongs(Gtk.Box):
__gsignals__ = {
"song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
}
offline_mode = True
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
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
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)
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.play_btn = IconButton(
"media-playback-start-symbolic",
"Play all songs in this album",
sensitive=False,
)
self.play_btn.connect("clicked", self.play_btn_clicked)
album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
self.shuffle_btn = IconButton(
"media-playlist-shuffle-symbolic",
"Shuffle all songs in this album",
sensitive=False,
)
self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked)
album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
self.play_next_btn = IconButton(
"queue-front-symbolic",
"Play all of the songs in this album next",
sensitive=False,
)
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
self.add_to_queue_btn = IconButton(
"queue-back-symbolic",
"Add all the songs in this album to the end of the play queue",
sensitive=False,
)
album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5)
self.download_all_btn = IconButton(
"folder-download-symbolic",
"Download all songs in this album",
sensitive=False,
)
self.download_all_btn.connect("clicked", self.on_download_all_click)
album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5)
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.loading_indicator_container = Gtk.Box()
album_details.add(self.loading_indicator_container)
self.error_container = Gtk.Box()
album_details.add(self.error_container)
# clickable, cache status, title, duration, song ID
self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
self.album_songs = Gtk.TreeView(
model=self.album_song_store,
name="album-songs-list",
headers_visible=False,
margin_top=15,
margin_left=10,
margin_right=10,
margin_bottom=10,
)
selection = self.album_songs.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn("", renderer, icon_name=1)
column.set_resizable(True)
self.album_songs.append_column(column)
self.album_songs.append_column(SongListColumn("TITLE", 2, bold=True))
self.album_songs.append_column(SongListColumn("DURATION", 3, align=1, width=40))
self.album_songs.connect("row-activated", self.on_song_activated)
self.album_songs.connect("button-press-event", self.on_song_button_press)
self.album_songs.get_selection().connect(
"changed", self.on_song_selection_change
)
album_details.add(self.album_songs)
self.pack_end(album_details, True, True, 0)
self.update_album_songs(album.id)
# Event Handlers
# =========================================================================
def on_song_selection_change(self, event: Any):
if not self.album_songs.has_focus():
self.emit("song-selected")
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
if not self.album_song_store[idx[0]][0]:
return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.album_song_store],
{},
)
def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path:
return False
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
def on_download_state_change(song_id: str):
self.update_album_songs(self.album.id)
# Use the new selection instead of the old one for calculating what
# to do the right click on.
if clicked_path[0] not in paths:
paths = [clicked_path[0]]
allow_deselect = True
song_ids = [self.album_song_store[p][-1] for p in paths]
# Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
util.show_song_popover(
song_ids,
event.x,
event.y + abs(bin_coords.by - widget_coords.wy),
tree,
self.offline_mode,
on_download_state_change=on_download_state_change,
)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
return False
def on_download_all_click(self, btn: Any):
AdapterManager.batch_download_songs(
[x[-1] for x in self.album_song_store],
before_download=lambda _: self.update(),
on_song_download_complete=lambda _: self.update(),
)
def play_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store]
self.emit(
"song-clicked",
0,
song_ids,
{"force_shuffle_state": False},
)
def shuffle_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store]
self.emit(
"song-clicked",
randint(0, len(self.album_song_store) - 1),
song_ids,
{"force_shuffle_state": True},
)
# Helper Methods
# =========================================================================
def deselect_all(self):
self.album_songs.get_selection().unselect_all()
def update(self, app_config: AppConfiguration = None, force: bool = False):
if app_config:
# Deselect everything and reset the error container if switching between
# online and offline.
if self.offline_mode != app_config.offline_mode:
self.album_songs.get_selection().unselect_all()
for c in self.error_container.get_children():
self.error_container.remove(c)
self.offline_mode = app_config.offline_mode
self.update_album_songs(self.album.id, app_config=app_config, force=force)
def set_loading(self, loading: bool):
if loading:
if len(self.loading_indicator_container.get_children()) == 0:
self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
spinner = Gtk.Spinner(name="album-list-song-list-spinner")
spinner.start()
self.loading_indicator_container.add(spinner)
self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
self.loading_indicator_container.show_all()
else:
self.loading_indicator_container.hide()
@util.async_callback(
AdapterManager.get_album,
before_download=lambda self: self.set_loading(True),
on_failure=lambda self, e: self.set_loading(False),
)
def update_album_songs(
self,
album: API.Album,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
songs = album.songs or []
if is_partial:
if len(self.error_container.get_children()) == 0:
load_error = LoadError(
"Song list",
"retrieve songs",
has_data=len(songs) > 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()
song_ids = [s.id for s in songs]
new_store = []
any_song_playable = False
if len(songs) == 0:
self.album_songs.hide()
else:
self.album_songs.show()
for status, song in zip(util.get_cached_status_icons(song_ids), songs):
playable = not self.offline_mode or status in (
"folder-download-symbolic",
"view-pin-symbolic",
)
new_store.append(
[
playable,
status,
util.esc(song.title),
util.format_song_duration(song.duration),
song.id,
]
)
any_song_playable |= playable
song_ids = [cast(str, song[-1]) for song in new_store]
util.diff_song_store(self.album_song_store, new_store)
self.play_btn.set_sensitive(any_song_playable)
self.shuffle_btn.set_sensitive(any_song_playable)
self.download_all_btn.set_sensitive(
not self.offline_mode and AdapterManager.can_batch_download_songs()
)
if any_song_playable:
self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
self.play_next_btn.set_action_name("app.play-next")
self.add_to_queue_btn.set_action_name("app.add-to-queue")
else:
self.play_next_btn.set_action_name("")
self.add_to_queue_btn.set_action_name("")
# Have to idle_add here so that his happens after the component is rendered.
self.set_loading(False)

View File

@@ -0,0 +1,108 @@
from typing import Any, Optional
from gi.repository import Gtk
class IconButton(Gtk.Button):
def __init__(
self,
icon_name: Optional[str],
tooltip_text: str = "",
relief: bool = False,
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label: str = None,
**kwargs,
):
Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.pack_start(self.image, False, False, 0)
if label is not None:
box.add(Gtk.Label(label=label))
if not relief:
self.props.relief = Gtk.ReliefStyle.NONE
self.add(box)
self.set_tooltip_text(tooltip_text)
def set_icon(self, icon_name: Optional[str]):
self.image.set_from_icon_name(icon_name, self.icon_size)
class IconToggleButton(Gtk.ToggleButton):
def __init__(
self,
icon_name: Optional[str],
tooltip_text: str = "",
relief: bool = False,
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label: str = None,
**kwargs,
):
Gtk.ToggleButton.__init__(self, **kwargs)
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image)
if label is not None:
box.add(Gtk.Label(label=label))
if not relief:
self.props.relief = Gtk.ReliefStyle.NONE
self.add(box)
self.set_tooltip_text(tooltip_text)
def set_icon(self, icon_name: Optional[str]):
self.image.set_from_icon_name(icon_name, self.icon_size)
def get_active(self) -> bool:
return super().get_active()
def set_active(self, active: bool):
super().set_active(active)
class IconMenuButton(Gtk.MenuButton):
def __init__(
self,
icon_name: Optional[str] = None,
tooltip_text: str = "",
relief: bool = True,
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label: str = None,
popover: Any = None,
**kwargs,
):
Gtk.MenuButton.__init__(self, **kwargs)
if popover:
self.set_use_popover(True)
self.set_popover(popover)
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image)
if label is not None:
box.add(Gtk.Label(label=label))
self.props.relief = Gtk.ReliefStyle.NORMAL
self.add(box)
self.set_tooltip_text(tooltip_text)
def set_icon(self, icon_name: Optional[str]):
self.image.set_from_icon_name(icon_name, self.icon_size)
def set_from_file(self, icon_file: Optional[str]):
self.image.set_from_file(icon_file)

View File

@@ -0,0 +1,60 @@
from gi.repository import Gtk
class LoadError(Gtk.Box):
def __init__(
self, entity_name: str, action: str, has_data: bool, offline_mode: bool
):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.pack_start(Gtk.Box(), True, True, 0)
inner_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, name="load-error-box"
)
inner_box.pack_start(Gtk.Box(), True, True, 0)
error_and_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
icon_and_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
if offline_mode:
icon_name = "cloud-offline-symbolic"
label = f"{entity_name} may be incomplete.\n" if has_data else ""
label += f"Go online to {action}."
else:
icon_name = "network-error-symbolic"
label = f"Error attempting to {action}."
self.image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
self.image.set_name("load-error-image")
icon_and_button_box.add(self.image)
self.label = Gtk.Label(label=label, name="load-error-label")
icon_and_button_box.add(self.label)
error_and_button_box.add(icon_and_button_box)
button_centerer_box = Gtk.Box()
button_centerer_box.pack_start(Gtk.Box(), True, True, 0)
if offline_mode:
go_online_button = Gtk.Button(label="Go Online")
go_online_button.set_action_name("app.go-online")
button_centerer_box.add(go_online_button)
else:
retry_button = Gtk.Button(label="Retry")
retry_button.set_action_name("app.refresh-window")
button_centerer_box.add(retry_button)
button_centerer_box.pack_start(Gtk.Box(), True, True, 0)
error_and_button_box.add(button_centerer_box)
inner_box.add(error_and_button_box)
inner_box.pack_start(Gtk.Box(), True, True, 0)
self.add(inner_box)
self.pack_start(Gtk.Box(), True, True, 0)

View File

@@ -0,0 +1,23 @@
from gi.repository import Gtk, Pango
class SongListColumn(Gtk.TreeViewColumn):
def __init__(
self,
header: str,
text_idx: int,
bold: bool = False,
align: float = 0,
width: int = None,
):
"""Represents a column in a song list."""
renderer = Gtk.CellRendererText(
xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
ellipsize=Pango.EllipsizeMode.END,
)
renderer.set_fixed_size(width or -1, 35)
super().__init__(header, renderer, text=text_idx, sensitive=0)
self.set_resizable(True)
self.set_expand(not width)

View File

@@ -0,0 +1,54 @@
from typing import Optional
from gi.repository import GdkPixbuf, Gtk
class SpinnerImage(Gtk.Overlay):
def __init__(
self,
loading: bool = True,
image_name: str = None,
spinner_name: str = None,
image_size: int = None,
**kwargs,
):
"""An image with a loading overlay."""
Gtk.Overlay.__init__(self)
self.image_size = image_size
self.filename: Optional[str] = None
self.image = Gtk.Image(name=image_name, **kwargs)
self.add(self.image)
self.spinner = Gtk.Spinner(
name=spinner_name,
active=loading,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.add_overlay(self.spinner)
def set_from_file(self, filename: Optional[str]):
"""Set the image to the given filename."""
if filename == "":
filename = None
self.filename = filename
if self.image_size is not None and filename:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename, self.image_size, self.image_size, True
)
self.image.set_from_pixbuf(pixbuf)
else:
self.image.set_from_file(filename)
def set_loading(self, loading_status: bool):
if loading_status:
self.spinner.start()
self.spinner.show()
else:
self.spinner.stop()
self.spinner.hide()
def set_image_size(self, size: int):
self.image_size = size
self.set_from_file(self.filename)

View File

@@ -0,0 +1,228 @@
import uuid
from enum import Enum
from typing import Any, Optional, Type
from gi.repository import Gio, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, UIInfo
from sublime.adapters.filesystem import FilesystemAdapter
from sublime.config import ConfigurationStore, ProviderConfiguration
class AdapterTypeModel(GObject.GObject):
adapter_type = GObject.Property(type=object)
def __init__(self, adapter_type: Type):
GObject.GObject.__init__(self)
self.adapter_type = adapter_type
class DialogStage(Enum):
SELECT_ADAPTER = "select"
CONFIGURE_ADAPTER = "configure"
class ConfigureProviderDialog(Gtk.Dialog):
_current_index = -1
stage = DialogStage.SELECT_ADAPTER
def set_title(self, editing: bool, provider_config: ProviderConfiguration = None):
if editing:
assert provider_config is not None
title = f"Edit {provider_config.name}"
else:
title = "Add New Music Source"
self.header.props.title = title
def __init__(self, parent: Any, provider_config: Optional[ProviderConfiguration]):
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
self.provider_config = provider_config
self.editing = provider_config is not None
self.set_default_size(400, 350)
# HEADER
self.header = Gtk.HeaderBar()
self.set_title(self.editing, provider_config)
self.cancel_back_button = Gtk.Button(label="Cancel")
self.cancel_back_button.connect("clicked", self._on_cancel_back_clicked)
self.header.pack_start(self.cancel_back_button)
self.next_add_button = Gtk.Button(label="Edit" if self.editing else "Next")
self.next_add_button.get_style_context().add_class("suggested-action")
self.next_add_button.connect("clicked", self._on_next_add_clicked)
self.header.pack_end(self.next_add_button)
self.set_titlebar(self.header)
content_area = self.get_content_area()
self.stack = Gtk.Stack()
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
# ADAPTER TYPE OPTIONS
adapter_type_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.adapter_type_store = Gio.ListStore()
self.adapter_options_list = Gtk.ListBox(
name="ground-truth-adapter-options-list", activate_on_single_click=False
)
self.adapter_options_list.connect("row-activated", self._on_next_add_clicked)
def create_row(model: AdapterTypeModel) -> Gtk.ListBoxRow:
ui_info: UIInfo = model.adapter_type.get_ui_info()
row = Gtk.ListBoxRow()
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.pack_start(
Gtk.Image.new_from_icon_name(ui_info.icon_name(), Gtk.IconSize.DND),
False,
False,
5,
)
rowbox.add(
Gtk.Label(
label=f"<b>{ui_info.name}</b>\n{ui_info.description}",
use_markup=True,
margin=8,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
row.add(rowbox)
row.show_all()
return row
self.adapter_options_list.bind_model(self.adapter_type_store, create_row)
available_ground_truth_adapters = filter(
lambda a: a.can_be_ground_truth, AdapterManager.available_adapters
)
for adapter_type in sorted(
available_ground_truth_adapters, key=lambda a: a.get_ui_info().name
):
self.adapter_type_store.append(AdapterTypeModel(adapter_type))
adapter_type_box.pack_start(self.adapter_options_list, True, True, 10)
self.stack.add_named(adapter_type_box, "select")
self.configure_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.stack.add_named(self.configure_box, "configure")
content_area.pack_start(self.stack, True, True, 0)
self.show_all()
if self.editing:
assert self.provider_config
for i, adapter_type in enumerate(self.adapter_type_store):
if (
adapter_type.adapter_type
== self.provider_config.ground_truth_adapter_type
):
row = self.adapter_options_list.get_row_at_index(i)
self.adapter_options_list.select_row(row)
break
self._name_is_valid = True
self._on_next_add_clicked()
def _on_cancel_back_clicked(self, _):
if self.stage == DialogStage.SELECT_ADAPTER:
self.close()
else:
self.stack.set_visible_child_name("select")
self.stage = DialogStage.SELECT_ADAPTER
self.cancel_back_button.set_label("Cancel")
self.next_add_button.set_label("Next")
self.next_add_button.set_sensitive(True)
def _on_next_add_clicked(self, *args):
if self.stage == DialogStage.SELECT_ADAPTER:
index = self.adapter_options_list.get_selected_row().get_index()
if index != self._current_index:
for c in self.configure_box.get_children():
self.configure_box.remove(c)
name_entry_grid = Gtk.Grid(
column_spacing=10,
row_spacing=5,
margin_left=10,
margin_right=10,
name="music-source-config-name-entry-grid",
)
name_label = Gtk.Label(label="Music Source Name:")
name_entry_grid.attach(name_label, 0, 0, 1, 1)
self.name_field = Gtk.Entry(
text=self.provider_config.name if self.provider_config else "",
hexpand=True,
)
self.name_field.connect("changed", self._on_name_change)
name_entry_grid.attach(self.name_field, 1, 0, 1, 1)
self.configure_box.add(name_entry_grid)
self.configure_box.add(Gtk.Separator())
self.adapter_type = self.adapter_type_store[index].adapter_type
self.config_store = (
self.provider_config.ground_truth_adapter_config
if self.provider_config
else ConfigurationStore()
)
form = self.adapter_type.get_configuration_form(self.config_store)
form.connect("config-valid-changed", self._on_config_form_valid_changed)
self.configure_box.pack_start(form, True, True, 0)
self.configure_box.show_all()
self._adapter_config_is_valid = False
self.stack.set_visible_child_name("configure")
self.stage = DialogStage.CONFIGURE_ADAPTER
self.cancel_back_button.set_label("Change Type" if self.editing else "Back")
self.next_add_button.set_label("Edit" if self.editing else "Add")
self.next_add_button.set_sensitive(
index == self._current_index and self._adapter_config_is_valid
)
self._current_index = index
else:
if self.provider_config is None:
self.provider_config = ProviderConfiguration(
str(uuid.uuid4()),
self.name_field.get_text(),
self.adapter_type,
self.config_store,
)
if self.adapter_type.can_be_cached:
self.provider_config.caching_adapter_type = FilesystemAdapter
self.provider_config.caching_adapter_config = ConfigurationStore()
else:
self.provider_config.name = self.name_field.get_text()
self.provider_config.ground_truth_adapter_config = self.config_store
self.response(Gtk.ResponseType.APPLY)
_name_is_valid = False
_adapter_config_is_valid = False
def _update_add_button_sensitive(self):
self.next_add_button.set_sensitive(
self._name_is_valid and self._adapter_config_is_valid
)
def _on_name_change(self, entry: Gtk.Entry):
if entry.get_text():
self._name_is_valid = True
entry.get_style_context().remove_class("invalid")
entry.set_tooltip_markup(None)
if self.editing:
assert self.provider_config
self.provider_config.name = entry.get_text()
self.set_title(self.editing, self.provider_config)
else:
self._name_is_valid = False
entry.get_style_context().add_class("invalid")
entry.set_tooltip_markup("This field is required")
self._update_add_button_sensitive()
def _on_config_form_valid_changed(self, _, valid: bool):
self._adapter_config_is_valid = valid
self._update_add_button_sensitive()

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2a9 9 0 019 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3z"/>
<path d="M1 14v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7z" opacity=".3"/>
<path d="M1 10v2a9 9 0 019 9h2c0-6.08-4.93-11-11-11z" opacity=".3"/>
<path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3z" opacity=".3"/>
<path d="M1 14v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7z"/>
<path d="M1 10v2a9 9 0 019 9h2c0-6.08-4.93-11-11-11z" opacity=".3"/>
<path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zM1 14v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7z" opacity=".3"/>
<path d="M1 10v2a9 9 0 019 9h2c0-6.08-4.93-11-11-11z"/>
<path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2a9 9 0 019 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M3.42 2.38a1.04 1.04 0 00-.73 1.77l2.8 2.81L18 19.46l1.88 1.88a1.04 1.04 0 101.47-1.47l-.97-.96L7.12 5.65 4.15 2.68c-.2-.2-.47-.3-.73-.3zm7.53 2.17c-1.08.01-2.11.25-3.04.68l13.24 13.24A5.77 5.77 0 0017.4 7.93a7.62 7.62 0 00-6.44-3.38zm-6 3.08A7.59 7.59 0 003.5 11.1a4.2 4.2 0 00.98 8.3l12.28.04L4.94 7.63z"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1 1v2.8h14V1zm0 3.813V8.98a3.256 3.256 0 003.241 3.242h1.33l-1.187 1.186a1.01 1.01 0 00-.287.666V15h.926c.287 0 .511-.084.694-.26l3.386-3.444-3.386-3.444c-.183-.176-.407-.26-.694-.26h-.926v.925c0 .238.12.49.289.666l1.185 1.187H4.24c-.778 0-1.389-.612-1.389-1.39V4.813zM10.124 6.6v2.8H15V6.6zm0 5.6V15H15v-2.8z"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1 15v-2.8h14V15zm0-3.813V7.02a3.256 3.256 0 013.241-3.242h1.33L4.384 2.592a1.01 1.01 0 01-.287-.666V1h.926c.287 0 .511.084.694.26l3.386 3.444-3.386 3.444c-.183.176-.407.26-.694.26h-.926v-.925c0-.238.12-.49.289-.666L5.571 5.63H4.24c-.778 0-1.389.612-1.389 1.39v4.167zM10.124 9.4V6.6H15v2.8zm0-5.6V1H15v2.8z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7.937 7.937" height="30" width="30">
<circle class="success" r="3.385" cy="3.969" cx="3.969"/>
</svg>

After

Width:  |  Height:  |  Size: 157 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7.937 7.937" height="30" width="30">
<circle class="error" r="3.385" cy="3.969" cx="3.969"/>
</svg>

After

Width:  |  Height:  |  Size: 155 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7.937 7.937" height="30" width="30">
<circle class="warning" r="3.385" cy="3.969" cx="3.969"/>
</svg>

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 13.229 13.229">
<path d="M1.838 12.271L1.832.958l9.801 5.651z"/>
<path d="M2.764 10.648L2.76 2.581l6.989 4.03z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 213 B

1216
sublime_music/ui/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,860 @@
import copy
import math
from datetime import timedelta
from functools import partial
from typing import Any, Callable, Dict, Optional, Set, Tuple
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
from . import util
from .common import IconButton, IconToggleButton, SpinnerImage
from .state import RepeatType
from ..adapters import AdapterManager, Result, SongCacheStatus
from ..adapters.api_objects import Song
from ..config import AppConfiguration
from ..util import resolve_path
class PlayerControls(Gtk.ActionBar):
"""
Defines the player controls panel that appears at the bottom of the window.
"""
__gsignals__ = {
"song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
"volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
editing: bool = False
editing_play_queue_song_list: bool = False
reordering_play_queue_song_list: bool = False
current_song = None
current_device = None
current_playing_index: Optional[int] = None
current_play_queue: Tuple[str, ...] = ()
cover_art_update_order_token = 0
play_queue_update_order_token = 0
offline_mode = False
def __init__(self):
Gtk.ActionBar.__init__(self)
self.set_name("player-controls-bar")
song_display = self.create_song_display()
playback_controls = self.create_playback_controls()
play_queue_volume = self.create_play_queue_volume()
self.last_device_list_update = None
self.pack_start(song_display)
self.set_center_widget(playback_controls)
self.pack_end(play_queue_volume)
connecting_to_device_token = 0
connecting_icon_index = 0
def update(self, app_config: AppConfiguration, force: bool = False):
self.current_device = app_config.state.current_device
self.update_device_list(app_config)
duration = (
app_config.state.current_song.duration
if app_config.state.current_song
else None
)
song_stream_cache_progress = (
app_config.state.song_stream_cache_progress
if app_config.state.current_song
else None
)
self.update_scrubber(
app_config.state.song_progress, duration, song_stream_cache_progress
)
icon = "pause" if app_config.state.playing else "start"
self.play_button.set_icon(f"media-playback-{icon}-symbolic")
self.play_button.set_tooltip_text(
"Pause" if app_config.state.playing else "Play"
)
has_current_song = app_config.state.current_song is not None
has_next_song = False
if app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
):
has_next_song = True
elif has_current_song:
last_idx_in_queue = len(app_config.state.play_queue) - 1
has_next_song = app_config.state.current_song_index < last_idx_in_queue
# Toggle button states.
self.repeat_button.set_action_name(None)
self.shuffle_button.set_action_name(None)
repeat_on = app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
)
self.repeat_button.set_active(repeat_on)
self.repeat_button.set_icon(app_config.state.repeat_type.icon)
self.shuffle_button.set_active(app_config.state.shuffle_on)
self.repeat_button.set_action_name("app.repeat-press")
self.shuffle_button.set_action_name("app.shuffle-press")
self.song_scrubber.set_sensitive(has_current_song)
self.prev_button.set_sensitive(has_current_song)
self.play_button.set_sensitive(has_current_song)
self.next_button.set_sensitive(has_current_song and has_next_song)
self.connecting_to_device = app_config.state.connecting_to_device
def cycle_connecting(connecting_to_device_token: int):
if (
self.connecting_to_device_token != connecting_to_device_token
or not self.connecting_to_device
):
return
icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic"
self.device_button.set_icon(icon)
self.connecting_icon_index = (self.connecting_icon_index + 1) % 3
GLib.timeout_add(350, cycle_connecting, connecting_to_device_token)
icon = ""
if app_config.state.connecting_to_device:
icon = "-connecting-0"
self.connecting_icon_index = 0
self.connecting_to_device_token += 1
GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token)
elif app_config.state.current_device != "this device":
icon = "-connected"
self.device_button.set_icon(f"chromecast{icon}-symbolic")
# Volume button and slider
if app_config.state.is_muted:
icon_name = "muted"
elif app_config.state.volume < 30:
icon_name = "low"
elif app_config.state.volume < 70:
icon_name = "medium"
else:
icon_name = "high"
self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
self.editing = True
self.volume_slider.set_value(
0 if app_config.state.is_muted else app_config.state.volume
)
self.editing = False
# Update the current song information.
# TODO (#126): add popup of bigger cover art photo here
if app_config.state.current_song is not None:
self.cover_art_update_order_token += 1
self.update_cover_art(
app_config.state.current_song.cover_art,
order_token=self.cover_art_update_order_token,
)
self.song_title.set_markup(util.esc(app_config.state.current_song.title))
# TODO (#71): use walrus once MYPY gets its act together
album = app_config.state.current_song.album
artist = app_config.state.current_song.artist
if album:
self.album_name.set_markup(util.esc(album.name))
self.artist_name.show()
else:
self.album_name.set_markup("")
self.album_name.hide()
if artist:
self.artist_name.set_markup(util.esc(artist.name))
self.artist_name.show()
else:
self.artist_name.set_markup("")
self.artist_name.hide()
else:
# Clear out the cover art and song tite if no song
self.album_art.set_from_file(None)
self.album_art.set_loading(False)
self.song_title.set_markup("")
self.album_name.set_markup("")
self.artist_name.set_markup("")
self.load_play_queue_button.set_sensitive(not self.offline_mode)
if app_config.state.loading_play_queue:
self.play_queue_spinner.start()
self.play_queue_spinner.show()
else:
self.play_queue_spinner.stop()
self.play_queue_spinner.hide()
# Short circuit if no changes to the play queue
force |= self.offline_mode != app_config.offline_mode
self.offline_mode = app_config.offline_mode
if not force and (
self.current_play_queue == app_config.state.play_queue
and self.current_playing_index == app_config.state.current_song_index
):
return
self.current_play_queue = app_config.state.play_queue
self.current_playing_index = app_config.state.current_song_index
print("DIFF STORE")
from time import time
s = time()
# Set the Play Queue button popup.
play_queue_len = len(app_config.state.play_queue)
if play_queue_len == 0:
self.popover_label.set_markup("<b>Play Queue</b>")
else:
song_label = util.pluralize("song", play_queue_len)
self.popover_label.set_markup(
f"<b>Play Queue:</b> {play_queue_len} {song_label}"
)
# TODO (#207) this is super freaking stupid inefficient.
# IDEAS: batch it, don't get the queue until requested
self.editing_play_queue_song_list = True
new_store = []
def calculate_label(song_details: Song) -> str:
title = util.esc(song_details.title)
# TODO (#71): use walrus once MYPY works with this
# album = util.esc(album.name if (album := song_details.album) else None)
# artist = util.esc(artist.name if (artist := song_details.artist) else None) # noqa
album = util.esc(song_details.album.name if song_details.album else None)
artist = util.esc(song_details.artist.name if song_details.artist else None)
return f"<b>{title}</b>\n{util.dot_join(album, artist)}"
def make_idle_index_capturing_function(
idx: int,
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
def on_cover_art_future_done(
idx: int,
order_token: int,
cover_art_filename: str,
):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][1] = cover_art_filename
def get_cover_art_filename_or_create_future(
cover_art_id: Optional[str], idx: int, order_token: int
) -> Optional[str]:
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
if not cover_art_result.data_is_available:
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
idx, order_token, on_cover_art_future_done
)
)
return None
# The cover art is already cached.
return cover_art_result.result()
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][2] = calculate_label(song_details)
# Cover Art
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, idx, order_token
)
if filename:
self.play_queue_store[idx][1] = filename
print("A", time() - s)
current_play_queue = [x[-1] for x in self.play_queue_store]
if app_config.state.play_queue != current_play_queue:
self.play_queue_update_order_token += 1
print("B", time() - s)
ohea = {1: 0.0, 2: 0.0, 3: 0.0}
song_details_results = []
for i, (song_id, cached_status) in enumerate(
zip(
app_config.state.play_queue,
AdapterManager.get_cached_statuses(app_config.state.play_queue),
)
):
f = time()
song_details_result = AdapterManager.get_song_details(song_id)
ohea[1] += time() - f
cover_art_filename = ""
label = "\n"
if song_details_result.data_is_available:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, i, self.play_queue_update_order_token
)
if filename:
cover_art_filename = filename
else:
song_details_results.append((i, song_details_result))
ohea[2] += time() - f
new_store.append(
[
(
not self.offline_mode
or cached_status
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
),
cover_art_filename,
label,
i == app_config.state.current_song_index,
song_id,
]
)
ohea[3] += time() - f
print(
"ohea",
ohea,
list(map(lambda x: x / len(app_config.state.play_queue), ohea.values())),
)
print("C", time() - s)
util.diff_song_store(self.play_queue_store, new_store)
print("FOO", time() - s)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function(
idx,
self.play_queue_update_order_token,
on_song_details_future_done,
)
)
self.editing_play_queue_song_list = False
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
before_download=lambda self: self.album_art.set_loading(True),
on_failure=lambda self, e: self.album_art.set_loading(False),
)
def update_cover_art(
self,
cover_art_filename: str,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if order_token != self.cover_art_update_order_token:
return
self.album_art.set_from_file(cover_art_filename)
self.album_art.set_loading(False)
def update_scrubber(
self,
current: Optional[timedelta],
duration: Optional[timedelta],
song_stream_cache_progress: Optional[timedelta],
):
if current is None or duration is None:
self.song_duration_label.set_text("-:--")
self.song_progress_label.set_text("-:--")
self.song_scrubber.set_value(0)
return
percent_complete = current / duration * 100
if not self.editing:
self.song_scrubber.set_value(percent_complete)
self.song_scrubber.set_show_fill_level(song_stream_cache_progress is not None)
if song_stream_cache_progress is not None:
percent_cached = song_stream_cache_progress / duration * 100
self.song_scrubber.set_fill_level(percent_cached)
self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text(
util.format_song_duration(math.floor(current.total_seconds()))
)
def on_volume_change(self, scale: Gtk.Scale):
if not self.editing:
self.emit("volume-change", scale.get_value())
def on_play_queue_click(self, _: Any):
if self.play_queue_popover.is_visible():
self.play_queue_popover.popdown()
else:
# TODO (#88): scroll the currently playing song into view.
self.play_queue_popover.popup()
self.play_queue_popover.show_all()
# Hide the load play queue button if the adapter can't do that.
if not AdapterManager.can_get_play_queue():
self.load_play_queue_button.hide()
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
if not self.play_queue_store[idx[0]][0]:
return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.play_queue_store],
{"no_reshuffle": True},
)
_current_player_id = None
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
def update_device_list(self, app_config: AppConfiguration):
if (
self._current_available_players == app_config.state.available_players
and self._current_player_id == app_config.state.current_device
):
return
self._current_player_id = app_config.state.current_device
self._current_available_players = copy.deepcopy(
app_config.state.available_players
)
for c in self.device_list.get_children():
self.device_list.remove(c)
for i, (player_type, players) in enumerate(
app_config.state.available_players.items()
):
if len(players) == 0:
continue
if i > 0:
self.device_list.add(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
)
self.device_list.add(
Gtk.Label(
label=f"{player_type.name} Devices",
halign=Gtk.Align.START,
name="device-type-section-title",
)
)
for player_id, player_name in sorted(players, key=lambda p: p[1]):
icon = (
"audio-volume-high-symbolic"
if player_id == self.current_device
else None
)
button = IconButton(icon, label=player_name)
button.get_style_context().add_class("menu-button")
button.connect(
"clicked",
lambda _, player_id: self.emit("device-update", player_id),
player_id,
)
self.device_list.add(button)
self.device_list.show_all()
def on_device_click(self, _: Any):
if self.device_popover.is_visible():
self.device_popover.popdown()
else:
self.device_popover.popup()
self.device_popover.show_all()
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y)
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
def on_download_state_change(song_id: str):
# Refresh the entire window (no force) because the song could
# be in a list anywhere in the window.
self.emit("refresh-window", {}, False)
# Use the new selection instead of the old one for calculating what
# to do the right click on.
if clicked_path[0] not in paths:
paths = [clicked_path[0]]
allow_deselect = True
song_ids = [self.play_queue_store[p][-1] for p in paths]
remove_text = (
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
)
def on_remove_songs_click(_: Any):
self.emit("songs-removed", [p.get_indices()[0] for p in paths])
util.show_song_popover(
song_ids,
event.x,
event.y,
tree,
self.offline_mode,
on_download_state_change=on_download_state_change,
extra_menu_items=[
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
],
)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
return False
def on_play_queue_model_row_move(self, *args):
# If we are programatically editing the song list, don't do anything.
if self.editing_play_queue_song_list:
return
# We get both a delete and insert event, I think it's deterministic
# which one comes first, but just in case, we have this
# reordering_play_queue_song_list flag.
if self.reordering_play_queue_song_list:
currently_playing_index = [
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
][0]
self.emit(
"refresh-window",
{
"current_song_index": currently_playing_index,
"play_queue": tuple(s[-1] for s in self.play_queue_store),
},
False,
)
self.reordering_play_queue_song_list = False
else:
self.reordering_play_queue_song_list = True
def create_song_display(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.album_art = SpinnerImage(
image_name="player-controls-album-artwork",
image_size=70,
)
box.pack_start(self.album_art, False, False, 0)
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
details_box.pack_start(Gtk.Box(), True, True, 0)
def make_label(name: str) -> Gtk.Label:
return Gtk.Label(
name=name,
halign=Gtk.Align.START,
xalign=0,
use_markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
self.song_title = make_label("song-title")
details_box.add(self.song_title)
self.album_name = make_label("album-name")
details_box.add(self.album_name)
self.artist_name = make_label("artist-name")
details_box.add(self.artist_name)
details_box.pack_start(Gtk.Box(), True, True, 0)
box.pack_start(details_box, False, False, 5)
return box
def create_playback_controls(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Scrubber and song progress/length labels
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.song_progress_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_progress_label, False, False, 5)
self.song_scrubber = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
)
self.song_scrubber.set_name("song-scrubber")
self.song_scrubber.set_draw_value(False)
self.song_scrubber.set_restrict_to_fill_level(False)
self.song_scrubber.connect(
"change-value", lambda s, t, v: self.emit("song-scrub", v)
)
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
self.song_duration_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_duration_label, False, False, 5)
box.add(scrubber_box)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
buttons.pack_start(Gtk.Box(), True, True, 0)
# Repeat button
repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.repeat_button = IconToggleButton(
"media-playlist-repeat", "Switch between repeat modes"
)
self.repeat_button.set_action_name("app.repeat-press")
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
repeat_button_box.pack_start(self.repeat_button, False, False, 0)
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
buttons.pack_start(repeat_button_box, False, False, 5)
# Previous button
self.prev_button = IconButton(
"media-skip-backward-symbolic",
"Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.prev_button.set_action_name("app.prev-track")
buttons.pack_start(self.prev_button, False, False, 5)
# Play button
self.play_button = IconButton(
"media-playback-start-symbolic",
"Play",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.play_button.set_name("play-button")
self.play_button.set_action_name("app.play-pause")
buttons.pack_start(self.play_button, False, False, 0)
# Next button
self.next_button = IconButton(
"media-skip-forward-symbolic",
"Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.next_button.set_action_name("app.next-track")
buttons.pack_start(self.next_button, False, False, 5)
# Shuffle button
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.shuffle_button = IconToggleButton(
"media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
)
self.shuffle_button.set_action_name("app.shuffle-press")
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
shuffle_button_box.pack_start(self.shuffle_button, False, False, 0)
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
buttons.pack_start(shuffle_button_box, False, False, 5)
buttons.pack_start(Gtk.Box(), True, True, 0)
box.add(buttons)
return box
def create_play_queue_volume(self) -> Gtk.Box:
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vbox.pack_start(Gtk.Box(), True, True, 0)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Device button (for chromecast)
self.device_button = IconButton(
"chromecast-symbolic",
"Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.device_button.connect("clicked", self.on_device_click)
box.pack_start(self.device_button, False, True, 5)
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(self.popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
# Play Queue button
self.play_queue_button = IconButton(
"view-list-symbolic",
"Open play queue",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.play_queue_button.connect("clicked", self.on_play_queue_click)
box.pack_start(self.play_queue_button, False, True, 5)
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover")
self.play_queue_popover.set_relative_to(self.play_queue_button)
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Play Queue</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=10,
)
play_queue_popover_header.add(self.popover_label)
self.load_play_queue_button = IconButton(
"folder-download-symbolic", "Load Queue from Server", margin=5
)
self.load_play_queue_button.set_action_name("app.update-play-queue-from-server")
play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0)
play_queue_popover_box.add(play_queue_popover_header)
play_queue_loading_overlay = Gtk.Overlay()
play_queue_scrollbox = Gtk.ScrolledWindow(
min_content_height=600,
min_content_width=400,
)
self.play_queue_store = Gtk.ListStore(
bool, # playable
str, # image filename
str, # title, album, artist
bool, # playing
str, # song ID
)
self.play_queue_list = Gtk.TreeView(
model=self.play_queue_store,
reorderable=True,
headers_visible=False,
)
selection = self.play_queue_list.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Album Art column. This function defines what image to use for the play queue
# song icon.
def filename_to_pixbuf(
column: Any,
cell: Gtk.CellRendererPixbuf,
model: Gtk.ListStore,
tree_iter: Gtk.TreeIter,
flags: Any,
):
cell.set_property("sensitive", model.get_value(tree_iter, 0))
filename = model.get_value(tree_iter, 1)
if not filename:
cell.set_property("icon_name", "")
return
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
# If this is the playing song, then overlay the play icon.
if model.get_value(tree_iter, 3):
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
str(resolve_path("ui/images/play-queue-play.png"))
)
play_overlay_pixbuf.composite(
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
)
cell.set_property("pixbuf", pixbuf)
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60)
column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, filename_to_pixbuf)
column.set_resizable(True)
self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
self.play_queue_list.append_column(column)
self.play_queue_list.connect("row-activated", self.on_song_activated)
self.play_queue_list.connect(
"button-press-event", self.on_play_queue_button_press
)
# Set up drag-and-drop on the song list for editing the order of the
# playlist.
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
play_queue_scrollbox.add(self.play_queue_list)
play_queue_loading_overlay.add(play_queue_scrollbox)
self.play_queue_spinner = Gtk.Spinner(
name="play-queue-spinner",
active=False,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
play_queue_loading_overlay.add_overlay(self.play_queue_spinner)
play_queue_popover_box.pack_end(play_queue_loading_overlay, True, True, 0)
self.play_queue_popover.add(play_queue_popover_box)
# Volume mute toggle
self.volume_mute_toggle = IconButton(
"audio-volume-high-symbolic", "Toggle mute"
)
self.volume_mute_toggle.set_action_name("app.mute-toggle")
box.pack_start(self.volume_mute_toggle, False, True, 0)
# Volume slider
self.volume_slider = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
)
self.volume_slider.set_name("volume-slider")
self.volume_slider.set_draw_value(False)
self.volume_slider.connect("value-changed", self.on_volume_change)
box.pack_start(self.volume_slider, True, True, 0)
vbox.pack_start(box, False, True, 0)
vbox.pack_start(Gtk.Box(), True, True, 0)
return vbox

View File

@@ -0,0 +1,967 @@
import math
from functools import lru_cache, partial
from random import randint
from typing import Any, cast, Dict, List, Tuple
from fuzzywuzzy import fuzz
from gi.repository import Gdk, 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 (
IconButton,
LoadError,
SongListColumn,
SpinnerImage,
)
class EditPlaylistDialog(Gtk.Dialog):
def __init__(self, parent: Any, playlist: API.Playlist):
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
# HEADER
self.header = Gtk.HeaderBar()
self._set_title(playlist.name)
cancel_button = Gtk.Button(label="Cancel")
cancel_button.connect("clicked", lambda _: self.close())
self.header.pack_start(cancel_button)
self.edit_button = Gtk.Button(label="Edit")
self.edit_button.get_style_context().add_class("suggested-action")
self.edit_button.connect(
"clicked", lambda *a: self.response(Gtk.ResponseType.APPLY)
)
self.header.pack_end(self.edit_button)
self.set_titlebar(self.header)
content_area = self.get_content_area()
content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10)
make_label = lambda label_text: Gtk.Label(label_text, halign=Gtk.Align.END)
content_grid.attach(make_label("Playlist Name"), 0, 0, 1, 1)
self.name_entry = Gtk.Entry(text=playlist.name, hexpand=True)
self.name_entry.connect("changed", self._on_name_change)
content_grid.attach(self.name_entry, 1, 0, 1, 1)
content_grid.attach(make_label("Comment"), 0, 1, 1, 1)
self.comment_entry = Gtk.Entry(text=playlist.comment, hexpand=True)
content_grid.attach(self.comment_entry, 1, 1, 1, 1)
content_grid.attach(make_label("Public"), 0, 2, 1, 1)
self.public_switch = Gtk.Switch(active=playlist.public, halign=Gtk.Align.START)
content_grid.attach(self.public_switch, 1, 2, 1, 1)
delete_button = Gtk.Button(label="Delete")
delete_button.connect("clicked", lambda *a: self.response(Gtk.ResponseType.NO))
content_grid.attach(delete_button, 0, 3, 1, 2)
content_area.add(content_grid)
self.show_all()
def _on_name_change(self, entry: Gtk.Entry):
text = entry.get_text()
if len(text) > 0:
self._set_title(text)
self.edit_button.set_sensitive(len(text) > 0)
def _set_title(self, playlist_name: str):
self.header.props.title = f"Edit {playlist_name}"
def get_data(self) -> Dict[str, Any]:
return {
"name": self.name_entry.get_text(),
"comment": self.comment_entry.get_text(),
"public": self.public_switch.get_active(),
}
class PlaylistsPanel(Gtk.Paned):
"""Defines the playlists 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.playlist_list = PlaylistList()
self.pack1(self.playlist_list, False, False)
self.playlist_detail_panel = PlaylistDetailPanel()
self.playlist_detail_panel.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
self.playlist_detail_panel.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.pack2(self.playlist_detail_panel, True, False)
def update(self, app_config: AppConfiguration = None, force: bool = False):
self.playlist_list.update(app_config=app_config, force=force)
self.playlist_detail_panel.update(app_config=app_config, force=force)
class PlaylistList(Gtk.Box):
__gsignals__ = {
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
offline_mode = False
class PlaylistModel(GObject.GObject):
playlist_id = GObject.Property(type=str)
name = GObject.Property(type=str)
def __init__(self, playlist_id: str, name: str):
GObject.GObject.__init__(self)
self.playlist_id = playlist_id
self.name = name
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
playlist_list_actions = Gtk.ActionBar()
self.new_playlist_button = IconButton("list-add-symbolic", label="New Playlist")
self.new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
playlist_list_actions.pack_start(self.new_playlist_button)
self.list_refresh_button = IconButton(
"view-refresh-symbolic", "Refresh list of playlists"
)
self.list_refresh_button.connect("clicked", self.on_list_refresh_click)
playlist_list_actions.pack_end(self.list_refresh_button)
self.add(playlist_list_actions)
self.error_container = Gtk.Box()
self.add(self.error_container)
loading_new_playlist = Gtk.ListBox()
self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False)
loading_spinner = Gtk.Spinner(name="playlist-list-spinner", active=True)
self.loading_indicator.add(loading_spinner)
loading_new_playlist.add(self.loading_indicator)
self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False)
new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False)
self.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-entry")
self.new_playlist_entry.connect("activate", self.new_entry_activate)
new_playlist_box.add(self.new_playlist_entry)
new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
confirm_button = IconButton(
"object-select-symbolic",
"Create playlist",
name="playlist-list-new-playlist-confirm",
relief=True,
)
confirm_button.connect("clicked", self.confirm_button_clicked)
new_playlist_actions.pack_end(confirm_button, False, True, 0)
self.cancel_button = IconButton(
"process-stop-symbolic",
"Cancel create playlist",
name="playlist-list-new-playlist-cancel",
relief=True,
)
self.cancel_button.connect("clicked", self.cancel_button_clicked)
new_playlist_actions.pack_end(self.cancel_button, False, True, 0)
new_playlist_box.add(new_playlist_actions)
self.new_playlist_row.add(new_playlist_box)
loading_new_playlist.add(self.new_playlist_row)
self.add(loading_new_playlist)
list_scroll_window = Gtk.ScrolledWindow(min_content_width=220)
def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow(
action_name="app.go-to-playlist",
action_target=GLib.Variant("s", model.playlist_id),
)
row.add(
Gtk.Label(
label=f"<b>{model.name}</b>",
use_markup=True,
margin=10,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
row.show_all()
return row
self.playlists_store = Gio.ListStore()
self.list = Gtk.ListBox(name="playlist-list-listbox")
self.list.bind_model(self.playlists_store, create_playlist_row)
list_scroll_window.add(self.list)
self.pack_start(list_scroll_window, True, True, 0)
def update(self, app_config: AppConfiguration = None, force: bool = False):
if app_config:
self.offline_mode = app_config.offline_mode
self.new_playlist_button.set_sensitive(not app_config.offline_mode)
self.list_refresh_button.set_sensitive(not app_config.offline_mode)
self.new_playlist_row.hide()
self.update_list(app_config=app_config, force=force)
@util.async_callback(
AdapterManager.get_playlists,
before_download=lambda self: self.loading_indicator.show_all(),
on_failure=lambda self, e: self.loading_indicator.hide(),
)
def update_list(
self,
playlists: List[API.Playlist],
app_config: AppConfiguration = None,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
for c in self.error_container.get_children():
self.error_container.remove(c)
if is_partial:
load_error = LoadError(
"Playlist list",
"load playlists",
has_data=len(playlists) > 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()
new_store = []
selected_idx = None
for i, playlist in enumerate(playlists or []):
if (
app_config
and app_config.state
and app_config.state.selected_playlist_id == playlist.id
):
selected_idx = i
new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name))
util.diff_model_store(self.playlists_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()
# Event Handlers
# =========================================================================
def on_new_playlist_clicked(self, _):
self.new_playlist_entry.set_text("Untitled Playlist")
self.new_playlist_entry.grab_focus()
self.new_playlist_row.show()
def on_list_refresh_click(self, _):
self.update(force=True)
def new_entry_activate(self, entry: Gtk.Entry):
self.create_playlist(entry.get_text())
def cancel_button_clicked(self, _):
self.new_playlist_row.hide()
def confirm_button_clicked(self, _):
self.create_playlist(self.new_playlist_entry.get_text())
def create_playlist(self, playlist_name: str):
def on_playlist_created(_):
self.update(force=True)
self.loading_indicator.show()
playlist_ceate_future = AdapterManager.create_playlist(name=playlist_name)
playlist_ceate_future.add_done_callback(
lambda f: GLib.idle_add(on_playlist_created, f)
)
class PlaylistDetailPanel(Gtk.Overlay):
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
playlist_id = None
playlist_details_expanded = False
offline_mode = False
editing_playlist_song_list: bool = False
reordering_playlist_song_list: bool = False
def __init__(self):
Gtk.Overlay.__init__(self, name="playlist-view-overlay")
self.playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
playlist_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.playlist_artwork = SpinnerImage(
image_name="playlist-album-artwork",
spinner_name="playlist-artwork-spinner",
image_size=200,
)
playlist_info_box.add(self.playlist_artwork)
# Name, comment, number of songs, etc.
playlist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
playlist_details_box.pack_start(Gtk.Box(), True, False, 0)
self.playlist_indicator = self.make_label(name="playlist-indicator")
playlist_details_box.add(self.playlist_indicator)
self.playlist_name = self.make_label(name="playlist-name")
playlist_details_box.add(self.playlist_name)
self.playlist_comment = self.make_label(name="playlist-comment")
playlist_details_box.add(self.playlist_comment)
self.playlist_stats = self.make_label(name="playlist-stats")
playlist_details_box.add(self.playlist_stats)
self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
name="playlist-play-shuffle-buttons",
)
self.play_all_button = IconButton(
"media-playback-start-symbolic",
label="Play All",
relief=True,
)
self.play_all_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(self.play_all_button, False, False, 0)
self.shuffle_all_button = IconButton(
"media-playlist-shuffle-symbolic",
label="Shuffle All",
relief=True,
)
self.shuffle_all_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(self.shuffle_all_button, False, False, 5)
playlist_details_box.add(self.play_shuffle_buttons)
playlist_info_box.pack_start(playlist_details_box, True, True, 0)
# Action buttons & expand/collapse button
action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.playlist_action_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=10
)
self.download_all_button = IconButton(
"folder-download-symbolic", "Download all songs in the playlist"
)
self.download_all_button.connect(
"clicked", self.on_playlist_list_download_all_button_click
)
self.playlist_action_buttons.add(self.download_all_button)
self.playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
self.playlist_action_buttons.add(self.playlist_edit_button)
self.view_refresh_button = IconButton(
"view-refresh-symbolic", "Refresh playlist info"
)
self.view_refresh_button.connect("clicked", self.on_view_refresh_click)
self.playlist_action_buttons.add(self.view_refresh_button)
action_buttons_container.pack_start(
self.playlist_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)
playlist_info_box.pack_end(action_buttons_container, False, False, 5)
self.playlist_box.add(playlist_info_box)
self.error_container = Gtk.Box()
self.playlist_box.add(self.error_container)
# Playlist songs list
self.playlist_song_scroll_window = Gtk.ScrolledWindow()
self.playlist_song_store = Gtk.ListStore(
bool, # clickable
str, # cache status
str, # title
str, # album
str, # artist
str, # duration
str, # song ID
)
@lru_cache(maxsize=1024)
def row_score(key: str, row_items: Tuple[str]) -> int:
return fuzz.partial_ratio(key, " ".join(row_items).lower())
def playlist_song_list_search_fn(
store: Gtk.ListStore,
col: int,
key: str,
treeiter: Gtk.TreeIter,
data: Any = None,
) -> bool:
threshold = math.ceil(math.ceil(len(key) * 0.8) / len(key) * 100)
return row_score(key.lower(), tuple(store[treeiter][2:5])) < threshold
self.playlist_songs = Gtk.TreeView(
model=self.playlist_song_store,
reorderable=True,
margin_top=15,
enable_search=True,
)
self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn)
selection = self.playlist_songs.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn("", renderer, icon_name=1)
column.set_resizable(True)
self.playlist_songs.append_column(column)
self.playlist_songs.append_column(SongListColumn("TITLE", 2, bold=True))
self.playlist_songs.append_column(SongListColumn("ALBUM", 3))
self.playlist_songs.append_column(SongListColumn("ARTIST", 4))
self.playlist_songs.append_column(
SongListColumn("DURATION", 5, align=1, width=40)
)
self.playlist_songs.connect("row-activated", self.on_song_activated)
self.playlist_songs.connect("button-press-event", self.on_song_button_press)
# Set up drag-and-drop on the song list for editing the order of the
# playlist.
self.playlist_song_store.connect(
"row-inserted", self.on_playlist_model_row_move
)
self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
self.playlist_song_scroll_window.add(self.playlist_songs)
self.playlist_box.pack_start(self.playlist_song_scroll_window, True, True, 0)
self.add(self.playlist_box)
playlist_view_spinner = Gtk.Spinner(active=True)
playlist_view_spinner.start()
self.playlist_view_loading_box = Gtk.Alignment(
name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1
)
self.playlist_view_loading_box.add(playlist_view_spinner)
self.add_overlay(self.playlist_view_loading_box)
update_playlist_view_order_token = 0
def update(self, app_config: AppConfiguration, force: bool = False):
# Deselect everything if switching online to offline.
if self.offline_mode != app_config.offline_mode:
self.playlist_songs.get_selection().unselect_all()
self.offline_mode = app_config.offline_mode
if app_config.state.selected_playlist_id is None:
self.playlist_box.hide()
self.playlist_view_loading_box.hide()
else:
self.update_playlist_view_order_token += 1
self.playlist_box.show()
self.update_playlist_view(
app_config.state.selected_playlist_id,
app_config=app_config,
force=force,
order_token=self.update_playlist_view_order_token,
)
self.download_all_button.set_sensitive(not app_config.offline_mode)
self.playlist_edit_button.set_sensitive(not app_config.offline_mode)
self.view_refresh_button.set_sensitive(not app_config.offline_mode)
_current_song_ids: List[str] = []
@util.async_callback(
AdapterManager.get_playlist_details,
before_download=lambda self: self.show_loading_all(),
on_failure=lambda self, e: self.hide_loading_all(),
)
def update_playlist_view(
self,
playlist: API.Playlist,
app_config: AppConfiguration = None,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if self.update_playlist_view_order_token != order_token:
return
# If the selected playlist has changed, then clear the selections in
# the song list.
if self.playlist_id != playlist.id:
self.playlist_songs.get_selection().unselect_all()
self.playlist_id = playlist.id
if app_config:
self.playlist_details_expanded = app_config.state.playlist_details_expanded
up_down = "up" if self.playlist_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.playlist_details_expanded else "Expand"
)
# Update the info display.
self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
self.playlist_name.set_tooltip_text(playlist.name)
if self.playlist_details_expanded:
self.playlist_artwork.get_style_context().remove_class("collapsed")
self.playlist_name.get_style_context().remove_class("collapsed")
self.playlist_box.show_all()
self.playlist_indicator.set_markup("PLAYLIST")
if playlist.comment:
self.playlist_comment.set_text(playlist.comment)
self.playlist_comment.set_tooltip_text(playlist.comment)
self.playlist_comment.show()
else:
self.playlist_comment.hide()
self.playlist_stats.set_markup(self._format_stats(playlist))
else:
self.playlist_artwork.get_style_context().add_class("collapsed")
self.playlist_name.get_style_context().add_class("collapsed")
self.playlist_box.show_all()
self.playlist_indicator.hide()
self.playlist_comment.hide()
self.playlist_stats.hide()
# Update the artwork.
self.update_playlist_artwork(playlist.cover_art, order_token=order_token)
for c in self.error_container.get_children():
self.error_container.remove(c)
if is_partial:
has_data = len(playlist.songs) > 0
load_error = LoadError(
"Playlist data",
"load playlist 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.playlist_song_scroll_window.hide()
else:
self.error_container.hide()
self.playlist_song_scroll_window.show()
# Update the song list model. This requires some fancy diffing to
# update the list.
self.editing_playlist_song_list = True
# This doesn't look efficient, since it's doing a ton of passses over the data,
# but there is some annoying memory overhead for generating the stores to diff,
# so we are short-circuiting by checking to see if any of the the IDs have
# changed.
#
# The entire algorithm ends up being O(2n), but the first loop is very tight,
# and the expensive parts of the second loop are avoided if the IDs haven't
# changed.
song_ids, songs = [], []
if len(self._current_song_ids) != len(playlist.songs):
force = True
for i, c in enumerate(playlist.songs):
if i >= len(self._current_song_ids) or c.id != self._current_song_ids[i]:
force = True
song_ids.append(c.id)
songs.append(c)
new_songs_store = []
can_play_any_song = False
cached_status_icons = ("folder-download-symbolic", "view-pin-symbolic")
if force:
self._current_song_ids = song_ids
# Regenerate the store from the actual song data (this is more expensive
# because when coming from the cache, we are doing 2N fk requests to
# albums).
for status_icon, song in zip(
util.get_cached_status_icons(song_ids),
[cast(API.Song, s) for s in songs],
):
playable = not self.offline_mode or status_icon in cached_status_icons
can_play_any_song |= playable
new_songs_store.append(
[
playable,
status_icon,
song.title,
album.name if (album := song.album) else None,
artist.name if (artist := song.artist) else None,
util.format_song_duration(song.duration),
song.id,
]
)
else:
# Just update the clickable state and download state.
for status_icon, song_model in zip(
util.get_cached_status_icons(song_ids), self.playlist_song_store
):
playable = not self.offline_mode or status_icon in cached_status_icons
can_play_any_song |= playable
new_songs_store.append([playable, status_icon, *song_model[2:]])
util.diff_song_store(self.playlist_song_store, new_songs_store)
self.play_all_button.set_sensitive(can_play_any_song)
self.shuffle_all_button.set_sensitive(can_play_any_song)
self.editing_playlist_song_list = False
self.playlist_view_loading_box.hide()
self.playlist_action_buttons.show_all()
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
before_download=lambda self: self.playlist_artwork.set_loading(True),
on_failure=lambda self, e: self.playlist_artwork.set_loading(False),
)
def update_playlist_artwork(
self,
cover_art_filename: str,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if self.update_playlist_view_order_token != order_token:
return
self.playlist_artwork.set_from_file(cover_art_filename)
self.playlist_artwork.set_loading(False)
if self.playlist_details_expanded:
self.playlist_artwork.set_image_size(200)
else:
self.playlist_artwork.set_image_size(70)
# Event Handlers
# =========================================================================
def on_view_refresh_click(self, _):
self.update_playlist_view(
self.playlist_id,
force=True,
order_token=self.update_playlist_view_order_token,
)
def on_playlist_edit_button_click(self, _):
assert self.playlist_id
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
playlist_deleted = False
result = dialog.run()
# Using ResponseType.NO as the delete event.
if result not in (Gtk.ResponseType.APPLY, Gtk.ResponseType.NO):
dialog.destroy()
return
if result == Gtk.ResponseType.APPLY:
AdapterManager.update_playlist(self.playlist_id, **dialog.get_data())
elif result == Gtk.ResponseType.NO:
# Delete the playlist.
confirm_dialog = Gtk.MessageDialog(
transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.NONE,
text="Confirm deletion",
)
confirm_dialog.add_buttons(
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
)
confirm_dialog.format_secondary_markup(
f'Are you sure you want to delete the "{playlist.name}" playlist?'
)
result = confirm_dialog.run()
confirm_dialog.destroy()
if result == Gtk.ResponseType.YES:
AdapterManager.delete_playlist(self.playlist_id)
playlist_deleted = True
else:
# In this case, we don't want to do any invalidation of
# anything.
dialog.destroy()
return
# Force a re-fresh of the view
self.emit(
"refresh-window",
{"selected_playlist_id": None if playlist_deleted else self.playlist_id},
True,
)
dialog.destroy()
def on_playlist_list_download_all_button_click(self, _):
def download_state_change(song_id: str):
GLib.idle_add(
lambda: self.update_playlist_view(
self.playlist_id, order_token=self.update_playlist_view_order_token
)
)
song_ids = [s[-1] for s in self.playlist_song_store]
AdapterManager.batch_download_songs(
song_ids,
before_download=download_state_change,
on_song_download_complete=download_state_change,
)
def on_play_all_clicked(self, _):
self.emit(
"song-clicked",
0,
[m[-1] for m in self.playlist_song_store],
{"force_shuffle_state": False, "active_playlist_id": self.playlist_id},
)
def on_shuffle_all_button(self, _):
self.emit(
"song-clicked",
randint(0, len(self.playlist_song_store) - 1),
[m[-1] for m in self.playlist_song_store],
{"force_shuffle_state": True, "active_playlist_id": self.playlist_id},
)
def on_expand_collapse_click(self, _):
self.emit(
"refresh-window",
{"playlist_details_expanded": not self.playlist_details_expanded},
False,
)
def on_song_activated(self, _, idx: Gtk.TreePath, col: Any):
if not self.playlist_song_store[idx[0]][0]:
return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.playlist_song_store],
{"active_playlist_id": self.playlist_id},
)
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path:
return False
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
def on_download_state_change(song_id: str):
GLib.idle_add(
lambda: self.update_playlist_view(
self.playlist_id,
order_token=self.update_playlist_view_order_token,
)
)
# Use the new selection instead of the old one for calculating what
# to do the right click on.
if clicked_path[0] not in paths:
paths = [clicked_path[0]]
allow_deselect = True
song_ids = [self.playlist_song_store[p][-1] for p in paths]
# Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
def on_remove_songs_click(_):
assert self.playlist_id
delete_idxs = {p.get_indices()[0] for p in paths}
new_song_ids = [
model[-1]
for i, model in enumerate(self.playlist_song_store)
if i not in delete_idxs
]
AdapterManager.update_playlist(
playlist_id=self.playlist_id, song_ids=new_song_ids
).result()
self.update_playlist_view(
self.playlist_id,
force=True,
order_token=self.update_playlist_view_order_token,
)
remove_text = (
"Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
)
util.show_song_popover(
song_ids,
event.x,
event.y + abs(bin_coords.by - widget_coords.wy),
tree,
self.offline_mode,
on_download_state_change=on_download_state_change,
on_remove_downloads_click=(
lambda: (
self.offline_mode
and self.playlist_songs.get_selection().unselect_all()
)
),
extra_menu_items=[
(
Gtk.ModelButton(
text=remove_text, sensitive=not self.offline_mode
),
on_remove_songs_click,
)
],
on_playlist_state_change=lambda: self.emit("refresh-window", {}, True),
)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
return False
def on_playlist_model_row_move(self, *args):
# If we are programatically editing the song list, don't do anything.
if self.editing_playlist_song_list:
return
# We get both a delete and insert event, I think it's deterministic
# which one comes first, but just in case, we have this
# reordering_playlist_song_list flag.
if self.reordering_playlist_song_list:
self._update_playlist_order(self.playlist_id)
self.reordering_playlist_song_list = False
else:
self.reordering_playlist_song_list = True
# Helper Methods
# =========================================================================
def show_loading_all(self):
self.playlist_artwork.set_loading(True)
self.playlist_view_loading_box.show_all()
def hide_loading_all(self):
self.playlist_artwork.set_loading(False)
self.playlist_view_loading_box.hide()
def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
return Gtk.Label(
label=text,
name=name,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
**params,
)
@util.async_callback(AdapterManager.get_playlist_details)
def _update_playlist_order(
self,
playlist: API.Playlist,
app_config: AppConfiguration,
**kwargs,
):
self.playlist_view_loading_box.show_all()
update_playlist_future = AdapterManager.update_playlist(
playlist.id, song_ids=[s[-1] for s in self.playlist_song_store]
)
update_playlist_future.add_done_callback(
lambda f: GLib.idle_add(
lambda: self.update_playlist_view(
playlist.id,
force=True,
order_token=self.update_playlist_view_order_token,
)
)
)
def _format_stats(self, playlist: API.Playlist) -> str:
created_date_text = ""
if playlist.created:
created_date_text = f" on {playlist.created.strftime('%B %d, %Y')}"
created_text = f"Created by {playlist.owner}{created_date_text}"
lines = [
util.dot_join(
created_text,
f"{'Not v' if not playlist.public else 'V'}isible to others",
),
util.dot_join(
"{} {}".format(
playlist.song_count,
util.pluralize("song", playlist.song_count or 0),
),
util.format_sequence_duration(playlist.duration),
),
]
return "\n".join(lines)

145
sublime_music/ui/state.py Normal file
View File

@@ -0,0 +1,145 @@
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum
from typing import Any, Callable, Dict, Optional, Set, Tuple, Type
from sublime.adapters import AlbumSearchQuery
from sublime.adapters.api_objects import Genre, Song
class RepeatType(Enum):
NO_REPEAT = 0
REPEAT_QUEUE = 1
REPEAT_SONG = 2
@property
def icon(self) -> str:
"""
Get the icon for the repeat type.
>>> RepeatType.NO_REPEAT.icon, RepeatType.REPEAT_QUEUE.icon
('media-playlist-repeat-symbolic', 'media-playlist-repeat-symbolic')
>>> RepeatType.REPEAT_SONG.icon
'media-playlist-repeat-song-symbolic'
"""
song_str = "-song" if self == RepeatType.REPEAT_SONG else ""
return f"media-playlist-repeat{song_str}-symbolic"
def as_mpris_loop_status(self) -> str:
return ["None", "Playlist", "Track"][self.value]
@staticmethod
def from_mpris_loop_status(loop_status: str) -> "RepeatType":
return {
"None": RepeatType.NO_REPEAT,
"Track": RepeatType.REPEAT_SONG,
"Playlist": RepeatType.REPEAT_QUEUE,
}[loop_status]
@dataclass
class UIState:
"""Represents the UI state of the application."""
@dataclass(unsafe_hash=True)
class UINotification:
markup: str
actions: Tuple[Tuple[str, Callable[[], None]], ...] = field(
default_factory=tuple
)
icon: Optional[str] = None
version: int = 1
# Play state
playing: bool = False
current_song_index: int = -1
play_queue: Tuple[str, ...] = field(default_factory=tuple)
old_play_queue: Tuple[str, ...] = field(default_factory=tuple)
_volume: Dict[str, float] = field(default_factory=lambda: {"this device": 100.0})
is_muted: bool = False
repeat_type: RepeatType = RepeatType.NO_REPEAT
shuffle_on: bool = False
song_progress: timedelta = timedelta()
song_stream_cache_progress: Optional[timedelta] = timedelta()
current_device: str = "this device"
connecting_to_device: bool = False
connected_device_name: Optional[str] = None
available_players: Dict[Type, Set[Tuple[str, str]]] = field(default_factory=dict)
# UI state
current_tab: str = "albums"
selected_album_id: Optional[str] = None
selected_artist_id: Optional[str] = None
selected_browse_element_id: Optional[str] = None
selected_playlist_id: Optional[str] = None
album_sort_direction: str = "ascending"
album_page_size: int = 30
album_page: int = 0
current_notification: Optional[UINotification] = None
playlist_details_expanded: bool = True
artist_details_expanded: bool = True
loading_play_queue: bool = False
# State for Album sort.
class _DefaultGenre(Genre):
def __init__(self):
self.name = "Rock"
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
AlbumSearchQuery.Type.RANDOM,
genre=_DefaultGenre(),
year_range=(2010, 2020),
)
active_playlist_id: Optional[str] = None
def __getstate__(self):
state = self.__dict__.copy()
del state["song_stream_cache_progress"]
del state["current_notification"]
del state["playing"]
del state["available_players"]
return state
def __setstate__(self, state: Dict[str, Any]):
self.__dict__.update(state)
self.song_stream_cache_progress = None
self.current_notification = None
self.playing = False
def __init_available_players__(self):
from sublime.players import PlayerManager
self.available_players = {
pt: set() for pt in PlayerManager.available_player_types
}
def migrate(self):
pass
_current_song: Optional[Song] = None
@property
def current_song(self) -> Optional[Song]:
if not self.play_queue or self.current_song_index < 0:
return None
from sublime.adapters import AdapterManager
current_song_id = self.play_queue[self.current_song_index]
if not self._current_song or self._current_song.id != current_song_id:
self._current_song = AdapterManager.get_song_details(
current_song_id
).result()
return self._current_song
@property
def volume(self) -> float:
return self._volume.get(self.current_device, 100.0)
@volume.setter
def volume(self, value: float):
self._volume[self.current_device] = value

453
sublime_music/ui/util.py Normal file
View File

@@ -0,0 +1,453 @@
import functools
import re
from datetime import timedelta
from typing import (
Any,
Callable,
cast,
Iterable,
List,
Match,
Optional,
Tuple,
Union,
)
from deepdiff import DeepDiff
from gi.repository import Gdk, GLib, Gtk
from sublime.adapters import AdapterManager, CacheMissError, Result, SongCacheStatus
from sublime.adapters.api_objects import Playlist, Song
from sublime.config import AppConfiguration
def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
"""
Formats the song duration as mins:seconds with the seconds being
zero-padded if necessary.
>>> format_song_duration(80)
'1:20'
>>> format_song_duration(62)
'1:02'
>>> format_song_duration(timedelta(seconds=68.2))
'1:08'
>>> format_song_duration(None)
'-:--'
"""
if isinstance(duration_secs, timedelta):
duration_secs = round(duration_secs.total_seconds())
if duration_secs is None:
return "-:--"
duration_secs = max(duration_secs, 0)
return f"{duration_secs // 60}:{duration_secs % 60:02}"
def pluralize(string: str, number: int, pluralized_form: str = None) -> str:
"""
Pluralize the given string given the count as a number.
>>> pluralize('foo', 1)
'foo'
>>> pluralize('foo', 2)
'foos'
>>> pluralize('foo', 0)
'foos'
"""
if number != 1:
return pluralized_form or f"{string}s"
return string
def format_sequence_duration(duration: Optional[timedelta]) -> str:
"""
Formats duration in English.
>>> format_sequence_duration(timedelta(seconds=90))
'1 minute, 30 seconds'
>>> format_sequence_duration(timedelta(seconds=(60 * 60 + 120)))
'1 hour, 2 minutes'
>>> format_sequence_duration(None)
'0 seconds'
"""
duration_secs = round(duration.total_seconds()) if duration else 0
duration_mins = (duration_secs // 60) % 60
duration_hrs = duration_secs // 60 // 60
duration_secs = duration_secs % 60
format_components = []
if duration_hrs > 0:
hrs = "{} {}".format(duration_hrs, pluralize("hour", duration_hrs))
format_components.append(hrs)
if duration_mins > 0:
mins = "{} {}".format(duration_mins, pluralize("minute", duration_mins))
format_components.append(mins)
# Show seconds if there are no hours.
if duration_hrs == 0:
secs = "{} {}".format(duration_secs, pluralize("second", duration_secs))
format_components.append(secs)
return ", ".join(format_components)
def esc(string: Optional[str]) -> str:
"""
>>> esc("test & <a href='ohea' target='_blank'>test</a>")
"test &amp; <a href='ohea'>test</a>"
>>> esc(None)
''
"""
if string is None:
return ""
return string.replace("&", "&amp;").replace(" target='_blank'", "")
def dot_join(*items: Any) -> str:
"""
Joins the given strings with a dot character. Filters out ``None`` values.
>>> dot_join(None, "foo", "bar", None, "baz")
'foo • bar • baz'
"""
return "".join(map(str, filter(lambda x: x is not None, items)))
def get_cached_status_icons(song_ids: List[str]) -> List[str]:
cache_icon = {
SongCacheStatus.CACHED: "folder-download-symbolic",
SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic",
SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic",
}
return [
cache_icon.get(cache_status, "")
for cache_status in AdapterManager.get_cached_statuses(song_ids)
]
def _parse_diff_location(location: str) -> Tuple:
"""
Parses a diff location as returned by deepdiff.
>>> _parse_diff_location("root[22]")
('22',)
>>> _parse_diff_location("root[22][4]")
('22', '4')
>>> _parse_diff_location("root[22].foo")
('22', 'foo')
"""
match = re.match(r"root\[(\d*)\](?:\[(\d*)\]|\.(.*))?", location)
return tuple(g for g in cast(Match, match).groups() if g is not None)
def diff_song_store(store_to_edit: Any, new_store: Iterable[Any]):
"""
Diffing song stores is nice, because we can easily make edits by modifying
the underlying store.
"""
old_store = [row[:] for row in store_to_edit]
# Diff the lists to determine what needs to be changed.
diff = DeepDiff(old_store, new_store)
changed = diff.get("values_changed", {})
added = diff.get("iterable_item_added", {})
removed = diff.get("iterable_item_removed", {})
for edit_location, diff in changed.items():
idx, field = _parse_diff_location(edit_location)
store_to_edit[int(idx)][int(field)] = diff["new_value"]
for _, value in added.items():
store_to_edit.append(value)
for remove_location, _ in reversed(list(removed.items())):
remove_at = int(_parse_diff_location(remove_location)[0])
del store_to_edit[remove_at]
def diff_model_store(store_to_edit: Any, new_store: Iterable[Any]):
"""
The diff here is that if there are any differences, then we refresh the
entire list. This is because it is too hard to do editing.
"""
old_store = store_to_edit[:]
diff = DeepDiff(old_store, new_store)
if diff == {}:
return
store_to_edit.splice(0, len(store_to_edit), new_store)
def show_song_popover(
song_ids: List[str],
x: int,
y: int,
relative_to: Any,
offline_mode: bool,
position: Gtk.PositionType = Gtk.PositionType.BOTTOM,
on_download_state_change: Callable[[str], None] = lambda _: None,
on_remove_downloads_click: Callable[[], Any] = lambda: None,
on_playlist_state_change: Callable[[], None] = lambda: None,
show_remove_from_playlist_button: bool = False,
extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = None,
):
def on_download_songs_click(_: Any):
AdapterManager.batch_download_songs(
song_ids,
before_download=on_download_state_change,
on_song_download_complete=on_download_state_change,
)
def do_on_remove_downloads_click(_: Any):
AdapterManager.cancel_download_songs(song_ids)
AdapterManager.batch_delete_cached_songs(
song_ids,
on_song_delete=on_download_state_change,
)
on_remove_downloads_click()
def on_add_to_playlist_click(_: Any, playlist: Playlist):
update_playlist_result = AdapterManager.update_playlist(
playlist_id=playlist.id, append_song_ids=song_ids
)
update_playlist_result.add_done_callback(lambda _: on_playlist_state_change())
popover = Gtk.PopoverMenu()
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Add all of the menu items to the popover.
song_count = len(song_ids)
play_next_button = Gtk.ModelButton(text="Play next", sensitive=False)
add_to_queue_button = Gtk.ModelButton(text="Add to queue", sensitive=False)
if not offline_mode:
play_next_button.set_action_name("app.play-next")
play_next_button.set_action_target_value(GLib.Variant("as", song_ids))
add_to_queue_button.set_action_name("app.add-to-queue")
add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids))
go_to_album_button = Gtk.ModelButton(text="Go to album", sensitive=False)
go_to_artist_button = Gtk.ModelButton(text="Go to artist", sensitive=False)
browse_to_song = Gtk.ModelButton(
text=f"Browse to {pluralize('song', song_count)}", sensitive=False
)
download_song_button = Gtk.ModelButton(
text=f"Download {pluralize('song', song_count)}", sensitive=False
)
remove_download_button = Gtk.ModelButton(
text=f"Remove {pluralize('download', song_count)}", sensitive=False
)
# Retrieve songs and set the buttons as sensitive later.
def on_get_song_details_done(songs: List[Song]):
song_cache_statuses = AdapterManager.get_cached_statuses([s.id for s in songs])
if not offline_mode and any(
status == SongCacheStatus.NOT_CACHED for status in song_cache_statuses
):
download_song_button.set_sensitive(True)
if any(
status
in (
SongCacheStatus.CACHED,
SongCacheStatus.PERMANENTLY_CACHED,
SongCacheStatus.DOWNLOADING,
)
for status in song_cache_statuses
):
remove_download_button.set_sensitive(True)
play_next_button.set_action_target_value(GLib.Variant("as", song_ids))
play_next_button.set_action_name("app.play-next")
add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids))
add_to_queue_button.set_action_name("app.add-to-queue")
albums, artists, parents = set(), set(), set()
for song in songs:
parents.add(parent_id if (parent_id := song.parent_id) else None)
if (al := song.album) and (id_ := al.id) and not id_.startswith("invalid:"):
albums.add(id_)
if (a := song.artist) and (id_ := a.id) and not id_.startswith("invalid:"):
artists.add(id_)
if len(albums) == 1 and list(albums)[0] is not None:
go_to_album_button.set_action_target_value(
GLib.Variant("s", list(albums)[0])
)
go_to_album_button.set_action_name("app.go-to-album")
if len(artists) == 1 and list(artists)[0] is not None:
go_to_artist_button.set_action_target_value(
GLib.Variant("s", list(artists)[0])
)
go_to_artist_button.set_action_name("app.go-to-artist")
if len(parents) == 1 and list(parents)[0] is not None:
browse_to_song.set_action_target_value(GLib.Variant("s", list(parents)[0]))
browse_to_song.set_action_name("app.browse-to")
def batch_get_song_details() -> List[Song]:
return [
AdapterManager.get_song_details(song_id).result() for song_id in song_ids
]
get_song_details_result: Result[List[Song]] = Result(batch_get_song_details)
get_song_details_result.add_done_callback(
lambda f: GLib.idle_add(on_get_song_details_done, f.result())
)
menu_items = [
play_next_button,
add_to_queue_button,
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
go_to_album_button,
go_to_artist_button,
browse_to_song,
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
(download_song_button, on_download_songs_click),
(remove_download_button, do_on_remove_downloads_click),
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
Gtk.ModelButton(
text=f"Add {pluralize('song', song_count)} to playlist",
menu_name="add-to-playlist",
name="menu-item-add-to-playlist",
sensitive=not offline_mode,
),
*(extra_menu_items or []),
]
for item in menu_items:
if type(item) == tuple:
el, fn = item
el.connect("clicked", fn)
el.get_style_context().add_class("menu-button")
vbox.pack_start(item[0], False, True, 0)
else:
item.get_style_context().add_class("menu-button")
vbox.pack_start(item, False, True, 0)
popover.add(vbox)
# Create the "Add song(s) to playlist" sub-menu.
playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if not offline_mode:
# Back button
playlists_vbox.add(
Gtk.ModelButton(inverted=True, centered=True, menu_name="main")
)
# Loading indicator
loading_indicator = Gtk.Spinner(name="menu-item-spinner")
loading_indicator.start()
playlists_vbox.add(loading_indicator)
# Create a future to make the actual playlist buttons
def on_get_playlists_done(f: Result[List[Playlist]]):
playlists_vbox.remove(loading_indicator)
for playlist in f.result():
button = Gtk.ModelButton(text=playlist.name)
button.get_style_context().add_class("menu-button")
button.connect("clicked", on_add_to_playlist_click, playlist)
button.show()
playlists_vbox.pack_start(button, False, True, 0)
playlists_result = AdapterManager.get_playlists()
playlists_result.add_done_callback(on_get_playlists_done)
popover.add(playlists_vbox)
popover.child_set_property(playlists_vbox, "submenu", "add-to-playlist")
# Positioning of the popover.
rect = Gdk.Rectangle()
rect.x, rect.y, rect.width, rect.height = x, y, 1, 1
popover.set_pointing_to(rect)
popover.set_position(position)
popover.set_relative_to(relative_to)
popover.popup()
popover.show_all()
def async_callback(
future_fn: Callable[..., Result],
before_download: Callable[[Any], None] = None,
on_failure: Callable[[Any, Exception], None] = None,
) -> Callable[[Callable], Callable]:
"""
Defines the ``async_callback`` decorator.
When a function is annotated with this decorator, the function becomes the done
callback for the given result-generating lambda function. The annotated function
will be called with the result of the Result generated by said lambda function.
:param future_fn: a function which generates an :class:`AdapterManager.Result`.
"""
def decorator(callback_fn: Callable) -> Callable:
@functools.wraps(callback_fn)
def wrapper(
self: Any,
*args,
app_config: AppConfiguration = None,
force: bool = False,
order_token: int = None,
**kwargs,
):
def on_before_download():
if before_download:
GLib.idle_add(before_download, self)
def future_callback(is_immediate: bool, f: Result):
try:
result = f.result()
is_partial = False
except CacheMissError as e:
result = e.partial_data
if result is None:
if on_failure:
GLib.idle_add(on_failure, self, e)
return
is_partial = True
except Exception as e:
if on_failure:
GLib.idle_add(on_failure, self, e)
return
fn = functools.partial(
callback_fn,
self,
result,
app_config=app_config,
force=force,
order_token=order_token,
is_partial=is_partial,
)
if is_immediate:
# The data is available now, no need to wait for the future to
# finish, and no need to incur the overhead of adding to the GLib
# event queue.
fn()
else:
# We don't have the data yet, meaning that it is a future, and we
# have to idle add so that we don't seg fault GTK.
GLib.idle_add(fn)
result: Result = future_fn(
*args,
before_download=on_before_download,
force=force,
**kwargs,
)
result.add_done_callback(
functools.partial(future_callback, result.data_is_available)
)
return wrapper
return decorator