Rename package
0
sublime_music/ui/__init__.py
Normal file
969
sublime_music/ui/albums.py
Normal 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
|
340
sublime_music/ui/app_styles.css
Normal 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
@@ -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
@@ -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
|
15
sublime_music/ui/common/__init__.py
Normal 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",
|
||||
)
|
356
sublime_music/ui/common/album_with_songs.py
Normal 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)
|
108
sublime_music/ui/common/icon_button.py
Normal 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)
|
60
sublime_music/ui/common/load_error.py
Normal 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)
|
23
sublime_music/ui/common/song_list_column.py
Normal 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)
|
54
sublime_music/ui/common/spinner_image.py
Normal 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)
|
228
sublime_music/ui/configure_provider.py
Normal 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()
|
3
sublime_music/ui/icons/chromecast-connected-symbolic.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
3
sublime_music/ui/icons/chromecast-symbolic.svg
Normal 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 |
3
sublime_music/ui/icons/cloud-offline-symbolic.svg
Normal 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 |
1
sublime_music/ui/icons/queue-back-symbolic.svg
Normal 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 |
1
sublime_music/ui/icons/queue-front-symbolic.svg
Normal 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 |
3
sublime_music/ui/icons/server-connected-symbolic.svg
Normal 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 |
3
sublime_music/ui/icons/server-error-symbolic.svg
Normal 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 |
3
sublime_music/ui/icons/server-offline-symbolic.svg
Normal 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 |
BIN
sublime_music/ui/images/play-queue-play.png
Normal file
After Width: | Height: | Size: 903 B |
4
sublime_music/ui/images/play-queue-play.svg
Normal 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
860
sublime_music/ui/player_controls.py
Normal 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
|
967
sublime_music/ui/playlists.py
Normal 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
@@ -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
@@ -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 & <a href='ohea'>test</a>"
|
||||
>>> esc(None)
|
||||
''
|
||||
"""
|
||||
if string is None:
|
||||
return ""
|
||||
return string.replace("&", "&").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
|