Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4ce2f222f1 | ||
![]() |
82a881ebfd | ||
![]() |
1f31e62340 | ||
![]() |
c7b0bc0bc8 | ||
![]() |
5ce4f1c944 | ||
![]() |
6a68cd1c99 | ||
![]() |
6c2dbf192e | ||
![]() |
d0861460fd | ||
![]() |
d35dd2c96d | ||
![]() |
827636ade6 | ||
![]() |
47850356b3 | ||
![]() |
4ced8e607b | ||
![]() |
0f3e04007f | ||
![]() |
5c4a29e6ae | ||
![]() |
87d32ddc7f | ||
![]() |
7c050555e3 | ||
![]() |
a8c5fc2f4d | ||
![]() |
cec0bf1285 | ||
![]() |
bef07bcdf1 | ||
![]() |
e511d471fc | ||
![]() |
8329cd3cfc | ||
![]() |
9fe6cb4519 | ||
![]() |
14a7b2bb77 | ||
![]() |
95c3e5a018 | ||
![]() |
4ba2e09cf1 | ||
![]() |
56ae24b479 | ||
![]() |
d7d774c579 | ||
![]() |
c612f31f42 | ||
![]() |
e1dcf8da4c |
@@ -65,3 +65,6 @@ def main():
|
||||
|
||||
app = SublimeMusicApp(Path(config_file))
|
||||
app.run(unknown_args)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
@@ -6,8 +6,8 @@ from .adapter_base import (
|
||||
ConfigurationStore,
|
||||
SongCacheStatus,
|
||||
UIInfo,
|
||||
ConfigParamDescriptor,
|
||||
)
|
||||
from .configure_server_form import ConfigParamDescriptor, ConfigureServerForm
|
||||
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
||||
|
||||
__all__ = (
|
||||
@@ -18,7 +18,6 @@ __all__ = (
|
||||
"CachingAdapter",
|
||||
"ConfigParamDescriptor",
|
||||
"ConfigurationStore",
|
||||
"ConfigureServerForm",
|
||||
"DownloadProgress",
|
||||
"Result",
|
||||
"SearchResult",
|
||||
|
@@ -16,13 +16,11 @@ from typing import (
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
Type,
|
||||
Callable,
|
||||
)
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
try:
|
||||
import keyring
|
||||
|
||||
@@ -81,9 +79,12 @@ class AlbumSearchQuery:
|
||||
:class:`AlbumSearchQuery.Type.GENRE`) return albums of the given genre
|
||||
"""
|
||||
|
||||
class _Genre(Genre):
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
@dataclass
|
||||
class Genre:
|
||||
name: str
|
||||
|
||||
# Pickle backwards compatibility
|
||||
_Genre = Genre
|
||||
|
||||
class Type(Enum):
|
||||
"""
|
||||
@@ -116,7 +117,7 @@ class AlbumSearchQuery:
|
||||
|
||||
type: Type
|
||||
year_range: Tuple[int, int] = this_decade()
|
||||
genre: Genre = _Genre("Rock")
|
||||
genre: Genre = Genre("Rock")
|
||||
|
||||
_strhash: Optional[str] = None
|
||||
|
||||
@@ -255,6 +256,57 @@ class UIInfo:
|
||||
return f"{self.icon_basename}-{status.lower()}-symbolic"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigParamDescriptor:
|
||||
"""
|
||||
Describes a parameter that can be used to configure an adapter. The
|
||||
:class:`description`, :class:`required` and :class:`default:` should be self-evident
|
||||
as to what they do.
|
||||
|
||||
The :class:`helptext` parameter is optional detailed text that will be shown in a
|
||||
help bubble corresponding to the field.
|
||||
|
||||
The :class:`type` must be one of the following:
|
||||
|
||||
* The literal type ``str``: corresponds to a freeform text entry field in the UI.
|
||||
* The literal type ``bool``: corresponds to a toggle in the UI.
|
||||
* The literal type ``int``: corresponds to a numeric input in the UI.
|
||||
* The literal string ``"password"``: corresponds to a password entry field in the
|
||||
UI.
|
||||
* The literal string ``"option"``: corresponds to dropdown in the UI.
|
||||
* The literal type ``Path``: corresponds to a file picker in the UI.
|
||||
|
||||
The :class:`advanced` parameter specifies whether the setting should be behind an
|
||||
"Advanced" expander.
|
||||
|
||||
The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
|
||||
`int`. It specifies the min and max values that the UI control can have.
|
||||
|
||||
The :class:`numeric_step` parameter only has an effect if the :class:`type` is
|
||||
`int`. It specifies the step that will be taken using the "+" and "-" buttons on the
|
||||
UI control (if supported).
|
||||
|
||||
The :class:`options` parameter only has an effect if the :class:`type` is
|
||||
``"option"``. It specifies the list of options that will be available in the
|
||||
dropdown in the UI.
|
||||
|
||||
The :class:`pathtype` parameter only has an effect if the :class:`type` is
|
||||
``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file
|
||||
picker and a directory picker, respectively.
|
||||
"""
|
||||
|
||||
type: Union[Type, str]
|
||||
description: str
|
||||
required: bool = True
|
||||
helptext: Optional[str] = None
|
||||
advanced: Optional[bool] = None
|
||||
default: Any = None
|
||||
numeric_bounds: Optional[Tuple[int, int]] = None
|
||||
numeric_step: Optional[int] = None
|
||||
options: Optional[Iterable[str]] = None
|
||||
pathtype: Optional[str] = None
|
||||
|
||||
|
||||
class Adapter(abc.ABC):
|
||||
"""
|
||||
Defines the interface for a Sublime Music Adapter.
|
||||
@@ -277,7 +329,7 @@ class Adapter(abc.ABC):
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Tuple[Dict[str, ConfigParamDescriptor], Callable[[ConfigurationStore], Dict[str, Optional[str]]]]:
|
||||
"""
|
||||
This function should return a :class:`Gtk.Box` that gets any inputs required
|
||||
from the user and uses the given ``config_store`` to store the configuration
|
||||
|
@@ -5,6 +5,7 @@ import abc
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache, partial
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
@@ -145,18 +146,23 @@ class SearchResult:
|
||||
both server and local results.
|
||||
"""
|
||||
|
||||
class Kind(Enum):
|
||||
ARTIST = 0
|
||||
ALBUM = 1
|
||||
SONG = 2
|
||||
PLAYLIST = 2
|
||||
|
||||
ValueType = Union[Artist, Album, Song, Playlist]
|
||||
|
||||
def __init__(self, query: str = None):
|
||||
self.query = query
|
||||
self.similiarity_partial = partial(
|
||||
similarity_ratio, self.query.lower() if self.query else ""
|
||||
)
|
||||
self._artists: Dict[str, Artist] = {}
|
||||
self._albums: Dict[str, Album] = {}
|
||||
self._songs: Dict[str, Song] = {}
|
||||
self._playlists: Dict[str, Playlist] = {}
|
||||
self._results: Dict[Tuple[Kind, str], ValueType] = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
fields = ("query", "_artists", "_albums", "_songs", "_playlists")
|
||||
fields = ("query", "_results")
|
||||
formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
|
||||
return f"<SearchResult {' '.join(formatted_fields)}>"
|
||||
|
||||
@@ -165,76 +171,57 @@ class SearchResult:
|
||||
if results is None:
|
||||
return
|
||||
|
||||
member = f"_{result_type}"
|
||||
cast(Dict[str, Any], getattr(self, member)).update({r.id: r for r in results})
|
||||
for result in results:
|
||||
if result_type == 'artists':
|
||||
kind = self.Kind.ARTIST
|
||||
elif result_type == 'albums':
|
||||
kind = self.Kind.ALBUM
|
||||
elif result_type == 'songs':
|
||||
kind = self.Kind.SONG
|
||||
elif result_type == 'playlists':
|
||||
kind = self.Kind.PLAYLIST
|
||||
else:
|
||||
assert False
|
||||
|
||||
self._results[(kind, result.id)] = result
|
||||
|
||||
def update(self, other: "SearchResult"):
|
||||
assert self.query == other.query
|
||||
self._artists.update(other._artists)
|
||||
self._albums.update(other._albums)
|
||||
self._songs.update(other._songs)
|
||||
self._playlists.update(other._playlists)
|
||||
self._results.update(other._results)
|
||||
|
||||
_S = TypeVar("_S")
|
||||
def _transform(self, kind: Kind, value: ValueType) -> Tuple[str, ...]:
|
||||
if kind is self.Kind.ARTIST:
|
||||
return (value.name,)
|
||||
elif kind is self.Kind.ALBUM:
|
||||
return (value.name, value.artist and value.artist.name)
|
||||
elif kind is self.Kind.SONG:
|
||||
return (value.title, value.artist and value.artist.name)
|
||||
elif kind is self.Kind.PLAYLIST:
|
||||
return (value.name,)
|
||||
else:
|
||||
assert False
|
||||
|
||||
def _to_result(
|
||||
self,
|
||||
it: Dict[str, _S],
|
||||
transform: Callable[[_S], Tuple[Optional[str], ...]],
|
||||
) -> List[_S]:
|
||||
def get_results(self) -> List[Tuple[Kind, ValueType]]:
|
||||
assert self.query
|
||||
|
||||
all_results = []
|
||||
for value in it.values():
|
||||
transformed = transform(value)
|
||||
if any(t is None for t in transformed):
|
||||
for (kind, _), value in self._results.items():
|
||||
try:
|
||||
transformed = self._transform(kind, value)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
max_similarity = max(
|
||||
self.similiarity_partial(t.lower())
|
||||
for t in transformed
|
||||
if t is not None
|
||||
)
|
||||
(self.similiarity_partial(t.lower()) for t in transformed
|
||||
if t is not None),
|
||||
default=0)
|
||||
|
||||
if max_similarity < 60:
|
||||
continue
|
||||
|
||||
all_results.append((max_similarity, value))
|
||||
all_results.append((max_similarity, (kind, value)))
|
||||
|
||||
all_results.sort(key=lambda rx: rx[0], reverse=True)
|
||||
|
||||
result: List[SearchResult._S] = []
|
||||
for ratio, x in all_results:
|
||||
if ratio >= 60 and len(result) < 20:
|
||||
result.append(x)
|
||||
else:
|
||||
# No use going on, all the rest are less.
|
||||
break
|
||||
|
||||
logging.debug(similarity_ratio.cache_info())
|
||||
return result
|
||||
|
||||
@property
|
||||
def artists(self) -> List[Artist]:
|
||||
return self._to_result(self._artists, lambda a: (a.name,))
|
||||
|
||||
def _try_get_artist_name(self, obj: Union[Album, Song]) -> Optional[str]:
|
||||
try:
|
||||
assert obj.artist
|
||||
return obj.artist.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def albums(self) -> List[Album]:
|
||||
return self._to_result(
|
||||
self._albums, lambda a: (a.name, self._try_get_artist_name(a))
|
||||
)
|
||||
|
||||
@property
|
||||
def songs(self) -> List[Song]:
|
||||
return self._to_result(
|
||||
self._songs, lambda s: (s.title, self._try_get_artist_name(s))
|
||||
)
|
||||
|
||||
@property
|
||||
def playlists(self) -> List[Playlist]:
|
||||
return self._to_result(self._playlists, lambda p: (p.name,))
|
||||
return [r for _, r in all_results]
|
||||
|
@@ -1,368 +0,0 @@
|
||||
"""
|
||||
This file contains all of the classes related for a shared server configuration form.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from typing import Any, Callable, cast, Dict, Iterable, Optional, Tuple, Type, Union
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import GLib, GObject, Gtk, Pango
|
||||
|
||||
from . import ConfigurationStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigParamDescriptor:
|
||||
"""
|
||||
Describes a parameter that can be used to configure an adapter. The
|
||||
:class:`description`, :class:`required` and :class:`default:` should be self-evident
|
||||
as to what they do.
|
||||
|
||||
The :class:`helptext` parameter is optional detailed text that will be shown in a
|
||||
help bubble corresponding to the field.
|
||||
|
||||
The :class:`type` must be one of the following:
|
||||
|
||||
* The literal type ``str``: corresponds to a freeform text entry field in the UI.
|
||||
* The literal type ``bool``: corresponds to a toggle in the UI.
|
||||
* The literal type ``int``: corresponds to a numeric input in the UI.
|
||||
* The literal string ``"password"``: corresponds to a password entry field in the
|
||||
UI.
|
||||
* The literal string ``"option"``: corresponds to dropdown in the UI.
|
||||
* The literal type ``Path``: corresponds to a file picker in the UI.
|
||||
|
||||
The :class:`advanced` parameter specifies whether the setting should be behind an
|
||||
"Advanced" expander.
|
||||
|
||||
The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
|
||||
`int`. It specifies the min and max values that the UI control can have.
|
||||
|
||||
The :class:`numeric_step` parameter only has an effect if the :class:`type` is
|
||||
`int`. It specifies the step that will be taken using the "+" and "-" buttons on the
|
||||
UI control (if supported).
|
||||
|
||||
The :class:`options` parameter only has an effect if the :class:`type` is
|
||||
``"option"``. It specifies the list of options that will be available in the
|
||||
dropdown in the UI.
|
||||
|
||||
The :class:`pathtype` parameter only has an effect if the :class:`type` is
|
||||
``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file
|
||||
picker and a directory picker, respectively.
|
||||
"""
|
||||
|
||||
type: Union[Type, str]
|
||||
description: str
|
||||
required: bool = True
|
||||
helptext: Optional[str] = None
|
||||
advanced: Optional[bool] = None
|
||||
default: Any = None
|
||||
numeric_bounds: Optional[Tuple[int, int]] = None
|
||||
numeric_step: Optional[int] = None
|
||||
options: Optional[Iterable[str]] = None
|
||||
pathtype: Optional[str] = None
|
||||
|
||||
|
||||
class ConfigureServerForm(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
"config-valid-changed": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(bool,),
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_store: ConfigurationStore,
|
||||
config_parameters: Dict[str, ConfigParamDescriptor],
|
||||
verify_configuration: Callable[[], Dict[str, Optional[str]]],
|
||||
is_networked: bool = True,
|
||||
):
|
||||
"""
|
||||
Inititialize a :class:`ConfigureServerForm` with the given configuration
|
||||
parameters.
|
||||
|
||||
:param config_store: The :class:`ConfigurationStore` to use to store
|
||||
configuration values for this adapter.
|
||||
:param config_parameters: An dictionary where the keys are the name of the
|
||||
configuration paramter and the values are the :class:`ConfigParamDescriptor`
|
||||
object corresponding to that configuration parameter. The order of the keys
|
||||
in the dictionary correspond to the order that the configuration parameters
|
||||
will be shown in the UI.
|
||||
:param verify_configuration: A function that verifies whether or not the
|
||||
current state of the ``config_store`` is valid. The output should be a
|
||||
dictionary containing verification errors. The keys of the returned
|
||||
dictionary should be the same as the keys passed in via the
|
||||
``config_parameters`` parameter. The values should be strings describing
|
||||
why the corresponding value in the ``config_store`` is invalid.
|
||||
|
||||
If the adapter ``is_networked``, and the special ``"__ping__"`` key is
|
||||
returned, then the error will be shown below all of the other settings in
|
||||
the ping status box.
|
||||
:param is_networked: whether or not the adapter is networked.
|
||||
"""
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
self.config_store = config_store
|
||||
self.required_config_parameter_keys = set()
|
||||
self.verify_configuration = verify_configuration
|
||||
self.entries = {}
|
||||
self.is_networked = is_networked
|
||||
|
||||
content_grid = Gtk.Grid(
|
||||
column_spacing=10,
|
||||
row_spacing=5,
|
||||
margin_left=10,
|
||||
margin_right=10,
|
||||
)
|
||||
advanced_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
|
||||
|
||||
def create_string_input(is_password: bool, key: str) -> Gtk.Entry:
|
||||
entry = Gtk.Entry(
|
||||
text=cast(
|
||||
Callable[[str], None],
|
||||
(config_store.get_secret if is_password else config_store.get),
|
||||
)(key),
|
||||
hexpand=True,
|
||||
)
|
||||
if is_password:
|
||||
entry.set_visibility(False)
|
||||
|
||||
entry.connect(
|
||||
"changed",
|
||||
lambda e: self._on_config_change(key, e.get_text(), secret=is_password),
|
||||
)
|
||||
return entry
|
||||
|
||||
def create_bool_input(key: str) -> Gtk.Switch:
|
||||
switch = Gtk.Switch(active=config_store.get(key), halign=Gtk.Align.START)
|
||||
switch.connect(
|
||||
"notify::active",
|
||||
lambda s, _: self._on_config_change(key, s.get_active()),
|
||||
)
|
||||
return switch
|
||||
|
||||
def create_int_input(key: str) -> Gtk.SpinButton:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_option_input(key: str) -> Gtk.ComboBox:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_path_input(key: str) -> Gtk.FileChooser:
|
||||
raise NotImplementedError()
|
||||
|
||||
content_grid_i = 0
|
||||
advanced_grid_i = 0
|
||||
for key, cpd in config_parameters.items():
|
||||
if cpd.required:
|
||||
self.required_config_parameter_keys.add(key)
|
||||
if cpd.default is not None:
|
||||
config_store[key] = config_store.get(key, cpd.default)
|
||||
|
||||
label = Gtk.Label(label=cpd.description, halign=Gtk.Align.END)
|
||||
|
||||
input_el_box = Gtk.Box()
|
||||
self.entries[key] = cast(
|
||||
Callable[[str], Gtk.Widget],
|
||||
{
|
||||
str: partial(create_string_input, False),
|
||||
"password": partial(create_string_input, True),
|
||||
bool: create_bool_input,
|
||||
int: create_int_input,
|
||||
"option": create_option_input,
|
||||
Path: create_path_input,
|
||||
}[cpd.type],
|
||||
)(key)
|
||||
input_el_box.add(self.entries[key])
|
||||
|
||||
if cpd.helptext:
|
||||
help_icon = Gtk.Image.new_from_icon_name(
|
||||
"help-about",
|
||||
Gtk.IconSize.BUTTON,
|
||||
)
|
||||
help_icon.get_style_context().add_class("configure-form-help-icon")
|
||||
help_icon.set_tooltip_markup(cpd.helptext)
|
||||
input_el_box.add(help_icon)
|
||||
|
||||
if not cpd.advanced:
|
||||
content_grid.attach(label, 0, content_grid_i, 1, 1)
|
||||
content_grid.attach(input_el_box, 1, content_grid_i, 1, 1)
|
||||
content_grid_i += 1
|
||||
else:
|
||||
advanced_grid.attach(label, 0, advanced_grid_i, 1, 1)
|
||||
advanced_grid.attach(input_el_box, 1, advanced_grid_i, 1, 1)
|
||||
advanced_grid_i += 1
|
||||
|
||||
# Add a button and revealer for the advanced section of the configuration.
|
||||
if advanced_grid_i > 0:
|
||||
advanced_component = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
advanced_expander = Gtk.Revealer()
|
||||
advanced_expander_icon = Gtk.Image.new_from_icon_name(
|
||||
"go-down-symbolic", Gtk.IconSize.BUTTON
|
||||
)
|
||||
revealed = False
|
||||
|
||||
def toggle_expander(*args):
|
||||
nonlocal revealed
|
||||
revealed = not revealed
|
||||
advanced_expander.set_reveal_child(revealed)
|
||||
icon_dir = "up" if revealed else "down"
|
||||
advanced_expander_icon.set_from_icon_name(
|
||||
f"go-{icon_dir}-symbolic", Gtk.IconSize.BUTTON
|
||||
)
|
||||
|
||||
advanced_expander_button = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
|
||||
advanced_expander_button_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=10
|
||||
)
|
||||
|
||||
advanced_label = Gtk.Label(
|
||||
label="<b>Advanced Settings</b>", use_markup=True
|
||||
)
|
||||
advanced_expander_button_box.add(advanced_label)
|
||||
advanced_expander_button_box.add(advanced_expander_icon)
|
||||
|
||||
advanced_expander_button.add(advanced_expander_button_box)
|
||||
advanced_expander_button.connect("clicked", toggle_expander)
|
||||
advanced_component.add(advanced_expander_button)
|
||||
|
||||
advanced_expander.add(advanced_grid)
|
||||
advanced_component.add(advanced_expander)
|
||||
|
||||
content_grid.attach(advanced_component, 0, content_grid_i, 2, 1)
|
||||
content_grid_i += 1
|
||||
|
||||
content_grid.attach(
|
||||
Gtk.Separator(name="config-verification-separator"), 0, content_grid_i, 2, 1
|
||||
)
|
||||
content_grid_i += 1
|
||||
|
||||
self.config_verification_box = Gtk.Box(spacing=10)
|
||||
content_grid.attach(self.config_verification_box, 0, content_grid_i, 2, 1)
|
||||
|
||||
self.pack_start(content_grid, False, False, 10)
|
||||
self._verification_status_ratchet = 0
|
||||
self._verify_config(self._verification_status_ratchet)
|
||||
|
||||
had_all_required_keys = False
|
||||
verifying_in_progress = False
|
||||
|
||||
def _set_verification_status(
|
||||
self, verifying: bool, is_valid: bool = False, error_text: str = None
|
||||
):
|
||||
if verifying:
|
||||
if not self.verifying_in_progress:
|
||||
for c in self.config_verification_box.get_children():
|
||||
self.config_verification_box.remove(c)
|
||||
self.config_verification_box.add(
|
||||
Gtk.Spinner(active=True, name="verify-config-spinner")
|
||||
)
|
||||
self.config_verification_box.add(
|
||||
Gtk.Label(
|
||||
label="<b>Verifying configuration...</b>", use_markup=True
|
||||
)
|
||||
)
|
||||
self.verifying_in_progress = True
|
||||
else:
|
||||
self.verifying_in_progress = False
|
||||
for c in self.config_verification_box.get_children():
|
||||
self.config_verification_box.remove(c)
|
||||
|
||||
def set_icon_and_label(icon_name: str, label_text: str):
|
||||
self.config_verification_box.add(
|
||||
Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND)
|
||||
)
|
||||
label = Gtk.Label(
|
||||
label=label_text,
|
||||
use_markup=True,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
label.set_tooltip_markup(label_text)
|
||||
self.config_verification_box.add(label)
|
||||
|
||||
if is_valid:
|
||||
set_icon_and_label(
|
||||
"config-ok-symbolic", "<b>Configuration is valid</b>"
|
||||
)
|
||||
elif escaped := bleach.clean(error_text or ""):
|
||||
set_icon_and_label("config-error-symbolic", escaped)
|
||||
|
||||
self.config_verification_box.show_all()
|
||||
|
||||
def _on_config_change(self, key: str, value: Any, secret: bool = False):
|
||||
if secret:
|
||||
self.config_store.set_secret(key, value)
|
||||
else:
|
||||
self.config_store[key] = value
|
||||
self._verification_status_ratchet += 1
|
||||
self._verify_config(self._verification_status_ratchet)
|
||||
|
||||
def _verify_config(self, ratchet: int):
|
||||
self.emit("config-valid-changed", False)
|
||||
|
||||
from sublime_music.adapters import Result
|
||||
|
||||
if self.required_config_parameter_keys.issubset(set(self.config_store.keys())):
|
||||
if self._verification_status_ratchet != ratchet:
|
||||
return
|
||||
|
||||
self._set_verification_status(True)
|
||||
|
||||
has_empty = False
|
||||
if self.had_all_required_keys:
|
||||
for key in self.required_config_parameter_keys:
|
||||
if self.config_store.get(key) == "":
|
||||
self.entries[key].get_style_context().add_class("invalid")
|
||||
self.entries[key].set_tooltip_markup("This field is required")
|
||||
has_empty = True
|
||||
else:
|
||||
self.entries[key].get_style_context().remove_class("invalid")
|
||||
self.entries[key].set_tooltip_markup(None)
|
||||
|
||||
self.had_all_required_keys = True
|
||||
if has_empty:
|
||||
self._set_verification_status(
|
||||
False,
|
||||
error_text="<b>There are missing fields</b>\n"
|
||||
"Please fill out all required fields.",
|
||||
)
|
||||
return
|
||||
|
||||
def on_verify_result(verification_errors: Dict[str, Optional[str]]):
|
||||
if self._verification_status_ratchet != ratchet:
|
||||
return
|
||||
|
||||
if len(verification_errors) == 0:
|
||||
self.emit("config-valid-changed", True)
|
||||
for entry in self.entries.values():
|
||||
entry.get_style_context().remove_class("invalid")
|
||||
self._set_verification_status(False, is_valid=True)
|
||||
return
|
||||
|
||||
for key, entry in self.entries.items():
|
||||
if error_text := verification_errors.get(key):
|
||||
entry.get_style_context().add_class("invalid")
|
||||
entry.set_tooltip_markup(error_text)
|
||||
else:
|
||||
entry.get_style_context().remove_class("invalid")
|
||||
entry.set_tooltip_markup(None)
|
||||
|
||||
self._set_verification_status(
|
||||
False, error_text=verification_errors.get("__ping__")
|
||||
)
|
||||
|
||||
def verify_with_delay() -> Dict[str, Optional[str]]:
|
||||
sleep(0.75)
|
||||
if self._verification_status_ratchet != ratchet:
|
||||
return {}
|
||||
|
||||
return self.verify_configuration()
|
||||
|
||||
errors_result: Result[Dict[str, Optional[str]]] = Result(verify_with_delay)
|
||||
errors_result.add_done_callback(
|
||||
lambda f: GLib.idle_add(on_verify_result, f.result())
|
||||
)
|
@@ -18,7 +18,6 @@ from .. import (
|
||||
CachingAdapter,
|
||||
ConfigParamDescriptor,
|
||||
ConfigurationStore,
|
||||
ConfigureServerForm,
|
||||
SongCacheStatus,
|
||||
UIInfo,
|
||||
)
|
||||
@@ -42,19 +41,16 @@ class FilesystemAdapter(CachingAdapter):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
||||
def verify_config_store() -> Dict[str, Optional[str]]:
|
||||
return {}
|
||||
|
||||
return ConfigureServerForm(
|
||||
config_store,
|
||||
{
|
||||
def get_configuration_form() -> Gtk.Box:
|
||||
configs = {
|
||||
"directory": ConfigParamDescriptor(
|
||||
type=Path, description="Music Directory", pathtype="directory"
|
||||
)
|
||||
},
|
||||
verify_config_store,
|
||||
)
|
||||
}
|
||||
def verify_config_store(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
|
||||
return {}
|
||||
|
||||
return configs, verify_config_store
|
||||
|
||||
@staticmethod
|
||||
def migrate_configuration(config_store: ConfigurationStore):
|
||||
@@ -779,17 +775,15 @@ class FilesystemAdapter(CachingAdapter):
|
||||
|
||||
elif data_key == KEYS.SEARCH_RESULTS:
|
||||
data = cast(API.SearchResult, data)
|
||||
for a in data._artists.values():
|
||||
self._do_ingest_new_data(KEYS.ARTIST, a.id, a, partial=True)
|
||||
|
||||
for a in data._albums.values():
|
||||
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
|
||||
|
||||
for s in data._songs.values():
|
||||
self._do_ingest_new_data(KEYS.SONG, s.id, s, partial=True)
|
||||
|
||||
for p in data._playlists.values():
|
||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, p.id, p, partial=True)
|
||||
for (kind, id), v in data._results.items():
|
||||
if kind is API.SearchResult.Kind.ARTIST:
|
||||
self._do_ingest_new_data(KEYS.ARTIST, id, v, partial=True)
|
||||
elif kind is API.SearchResult.Kind.ALBUM:
|
||||
self._do_ingest_new_data(KEYS.ALBUM, id, v, partial=True)
|
||||
elif kind is API.SearchResult.Kind.SONG:
|
||||
self._do_ingest_new_data(KEYS.SONG, id, v, partial=True)
|
||||
elif kind is API.SearchResult.Kind.PLAYLIST:
|
||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, id, v, partial=True)
|
||||
|
||||
elif data_key == KEYS.SONG:
|
||||
api_song = cast(API.Song, data)
|
||||
|
@@ -458,7 +458,7 @@ class AdapterManager:
|
||||
f"expected size ({expected_size})."
|
||||
)
|
||||
|
||||
block_size = 1024 # 1 KiB
|
||||
block_size = 512 * 1024 # 512 KiB
|
||||
total_consumed = 0
|
||||
|
||||
with open(download_tmp_filename, "wb+") as f:
|
||||
@@ -473,9 +473,6 @@ class AdapterManager:
|
||||
)
|
||||
raise Exception("Download Cancelled")
|
||||
|
||||
if i % 100 == 0:
|
||||
# Only delay (if configured) and update the progress UI
|
||||
# every 100 KiB.
|
||||
if DOWNLOAD_BLOCK_DELAY is not None:
|
||||
sleep(DOWNLOAD_BLOCK_DELAY)
|
||||
|
||||
|
@@ -38,7 +38,6 @@ from .. import (
|
||||
api_objects as API,
|
||||
ConfigParamDescriptor,
|
||||
ConfigurationStore,
|
||||
ConfigureServerForm,
|
||||
UIInfo,
|
||||
)
|
||||
|
||||
@@ -90,7 +89,7 @@ class SubsonicAdapter(Adapter):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
||||
def get_configuration_form():
|
||||
configs = {
|
||||
"server_address": ConfigParamDescriptor(str, "Server Address"),
|
||||
"username": ConfigParamDescriptor(str, "Username"),
|
||||
@@ -145,7 +144,7 @@ class SubsonicAdapter(Adapter):
|
||||
}
|
||||
)
|
||||
|
||||
def verify_configuration() -> Dict[str, Optional[str]]:
|
||||
def verify_configuration(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
|
||||
errors: Dict[str, Optional[str]] = {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir_name:
|
||||
@@ -168,10 +167,11 @@ class SubsonicAdapter(Adapter):
|
||||
"Double check the server address."
|
||||
)
|
||||
except ServerError as e:
|
||||
if e.status_code in (10, 41) and config_store["salt_auth"]:
|
||||
if e.status_code in (10, 40, 41) and config_store["salt_auth"]:
|
||||
# status code 10: if salt auth is not enabled, server will
|
||||
# return error server error with status_code 10 since it'll
|
||||
# interpret it as a missing (password) parameter
|
||||
# status code 41: returned by ampache
|
||||
# status code 41: as per subsonic api docs, description of
|
||||
# status_code 41 is "Token authentication not supported for
|
||||
# LDAP users." so fall back to password auth
|
||||
@@ -205,7 +205,7 @@ class SubsonicAdapter(Adapter):
|
||||
|
||||
return errors
|
||||
|
||||
return ConfigureServerForm(config_store, configs, verify_configuration)
|
||||
return configs, verify_configuration
|
||||
|
||||
@staticmethod
|
||||
def migrate_configuration(config_store: ConfigurationStore):
|
||||
|
@@ -6,7 +6,7 @@ import sys
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import bleach
|
||||
@@ -18,11 +18,19 @@ try:
|
||||
except Exception:
|
||||
tap_imported = False
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
|
||||
import gi
|
||||
gi.require_version('GIRepository', '2.0')
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, GIRepository
|
||||
|
||||
# Temporary for development
|
||||
repo = GIRepository.Repository.get_default()
|
||||
repo.prepend_library_path('../libhandy/_build/src')
|
||||
repo.prepend_search_path('../libhandy/_build/src')
|
||||
|
||||
gi.require_version('Handy', '1')
|
||||
from gi.repository import Handy
|
||||
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Notify", "0.7")
|
||||
from gi.repository import Notify
|
||||
|
||||
@@ -42,14 +50,16 @@ from .adapters import (
|
||||
DownloadProgress,
|
||||
Result,
|
||||
SongCacheStatus,
|
||||
ConfigurationStore,
|
||||
)
|
||||
from .adapters.filesystem import FilesystemAdapter
|
||||
from .adapters.api_objects import Playlist, PlayQueue, Song
|
||||
from .config import AppConfiguration, ProviderConfiguration
|
||||
from .dbus import dbus_propagate, DBusManager
|
||||
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
||||
from .ui.configure_provider import ConfigureProviderDialog
|
||||
from .ui.main import MainWindow
|
||||
from .ui.state import RepeatType, UIState
|
||||
from .ui.actions import register_action, register_dataclass_actions
|
||||
from .util import resolve_path
|
||||
|
||||
|
||||
@@ -59,12 +69,16 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if glib_notify_exists:
|
||||
Notify.init("Sublime Music")
|
||||
|
||||
Handy.init()
|
||||
|
||||
self.window: Optional[Gtk.Window] = None
|
||||
self.app_config = AppConfiguration.load_from_file(config_file)
|
||||
self.dbus_manager: Optional[DBusManager] = None
|
||||
|
||||
self.connect("shutdown", self.on_app_shutdown)
|
||||
|
||||
self._download_progress = {}
|
||||
|
||||
player_manager: Optional[PlayerManager] = None
|
||||
exiting: bool = False
|
||||
|
||||
@@ -79,38 +93,40 @@ class SublimeMusicApp(Gtk.Application):
|
||||
action.connect("activate", fn)
|
||||
self.add_action(action)
|
||||
|
||||
# Add action for menu items.
|
||||
add_action("add-new-music-provider", self.on_add_new_music_provider)
|
||||
add_action("edit-current-music-provider", self.on_edit_current_music_provider)
|
||||
add_action(
|
||||
"switch-music-provider", self.on_switch_music_provider, parameter_type="s"
|
||||
)
|
||||
add_action(
|
||||
"remove-music-provider", self.on_remove_music_provider, parameter_type="s"
|
||||
)
|
||||
register_action(self, self.quit, types=tuple())
|
||||
|
||||
register_action(self, self.change_tab)
|
||||
|
||||
# Connect after we know there's a server configured.
|
||||
# self.window.connect("notification-closed", self.on_notification_closed)
|
||||
# self.window.connect("key-press-event", self.on_window_key_press)
|
||||
|
||||
# Add actions for player controls
|
||||
add_action("play-pause", self.on_play_pause)
|
||||
add_action("next-track", self.on_next_track)
|
||||
add_action("prev-track", self.on_prev_track)
|
||||
add_action("repeat-press", self.on_repeat_press)
|
||||
add_action("shuffle-press", self.on_shuffle_press)
|
||||
register_action(self, self.seek)
|
||||
register_action(self, self.play_pause)
|
||||
register_action(self, self.next_track)
|
||||
register_action(self, self.prev_track)
|
||||
register_action(self, self.repeat)
|
||||
register_action(self, self.shuffle)
|
||||
register_action(self, self.play_song_action, name='play-song')
|
||||
# self.window.connect("songs-removed", self.on_songs_removed)
|
||||
|
||||
register_action(self, self.select_device)
|
||||
register_action(self, self.toggle_mute)
|
||||
register_action(self, self.set_volume)
|
||||
|
||||
# Navigation actions.
|
||||
add_action("play-next", self.on_play_next, parameter_type="as")
|
||||
add_action("add-to-queue", self.on_add_to_queue, parameter_type="as")
|
||||
add_action("go-to-album", self.on_go_to_album, parameter_type="s")
|
||||
add_action("go-to-artist", self.on_go_to_artist, parameter_type="s")
|
||||
add_action("browse-to", self.browse_to, parameter_type="s")
|
||||
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
|
||||
register_action(self, self.queue_next_songs)
|
||||
register_action(self, self.queue_songs)
|
||||
register_action(self, self.go_to_album)
|
||||
register_action(self, self.go_to_artist)
|
||||
register_action(self, self.browse_to)
|
||||
register_action(self, self.go_to_playlist)
|
||||
|
||||
add_action("go-online", self.on_go_online)
|
||||
add_action("refresh-devices", self.on_refresh_devices)
|
||||
add_action(
|
||||
"refresh-window",
|
||||
lambda *a: self.on_refresh_window(None, {}, True),
|
||||
)
|
||||
add_action("mute-toggle", self.on_mute_toggle)
|
||||
register_action(self, self.refresh)
|
||||
register_action(self, self.force_refresh)
|
||||
add_action(
|
||||
"update-play-queue-from-server",
|
||||
lambda a, p: self.update_play_state_from_server(),
|
||||
@@ -118,11 +134,19 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
if tap_imported:
|
||||
self.tap = osxmmkeys.Tap()
|
||||
self.tap.on("play_pause", self.on_play_pause)
|
||||
self.tap.on("next_track", self.on_next_track)
|
||||
self.tap.on("prev_track", self.on_prev_track)
|
||||
self.tap.on("play_pause", self.play_pause)
|
||||
self.tap.on("next_track", self.next_track)
|
||||
self.tap.on("prev_track", self.prev_track)
|
||||
self.tap.start()
|
||||
|
||||
# self.set_accels_for_action('app.play-pause', ["<Ctrl>F"])
|
||||
# self.set_accels_for_action('app.play-pause', ["space"])
|
||||
# self.set_accels_for_action('app.prev-track', ["Home"])
|
||||
# self.set_accels_for_action('app.next-track', ["End"])
|
||||
# self.set_accels_for_action('app.quit', ["<Ctrl>q"])
|
||||
# self.set_accels_for_action('app.quit', ["<Ctrl>w"])
|
||||
|
||||
|
||||
def do_activate(self):
|
||||
# We only allow a single window and raise any existing ones
|
||||
if self.window:
|
||||
@@ -143,6 +167,36 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# closed the application shuts down.
|
||||
self.window = MainWindow(application=self, title="Sublime Music")
|
||||
|
||||
albums = Gio.SimpleActionGroup()
|
||||
register_action(albums, self.albums_set_search_query, 'set-search-query')
|
||||
register_action(albums, self.albums_set_page, 'set-page')
|
||||
register_action(albums, self.albums_select_album, 'select-album')
|
||||
self.window.insert_action_group('albums', albums)
|
||||
|
||||
playlists = Gio.SimpleActionGroup()
|
||||
register_action(playlists, self.playlists_set_details_expanded, 'set-details-expanded')
|
||||
self.window.insert_action_group('playlists', playlists)
|
||||
|
||||
search = Gio.SimpleActionGroup()
|
||||
register_action(search, self.search_set_query, 'set-query')
|
||||
self.window.insert_action_group('search', search)
|
||||
|
||||
settings = Gio.SimpleActionGroup()
|
||||
register_dataclass_actions(settings, self.app_config, after=self._save_and_refresh)
|
||||
self.window.insert_action_group('settings', settings)
|
||||
|
||||
players = Gio.SimpleActionGroup()
|
||||
register_action(players, self.players_set_option, name='set-str-option', types=(str, str, str))
|
||||
register_action(players, self.players_set_option, name='set-bool-option', types=(str, str, bool))
|
||||
register_action(players, self.players_set_option, name='set-int-option', types=(str, str, int))
|
||||
self.window.insert_action_group('players', players)
|
||||
|
||||
providers = Gio.SimpleActionGroup()
|
||||
register_action(providers, self.providers_set_config, name='set-config')
|
||||
register_action(providers, self.providers_switch, name='switch')
|
||||
register_action(providers, self.providers_remove, name='remove')
|
||||
self.window.insert_action_group('providers', providers)
|
||||
|
||||
# Configure the CSS provider so that we can style elements on the
|
||||
# window.
|
||||
css_provider = Gtk.CssProvider()
|
||||
@@ -163,28 +217,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# configured, and if none are configured, then show the dialog to create a new
|
||||
# one.
|
||||
if self.app_config.provider is None:
|
||||
if len(self.app_config.providers) == 0:
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
# If they didn't add one with the dialog, close the window.
|
||||
if len(self.app_config.providers) == 0:
|
||||
self.window.close()
|
||||
return
|
||||
|
||||
self.window.show_providers_window()
|
||||
else:
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
|
||||
# Connect after we know there's a server configured.
|
||||
self.window.stack.connect("notify::visible-child", self.on_stack_change)
|
||||
self.window.connect("song-clicked", self.on_song_clicked)
|
||||
self.window.connect("songs-removed", self.on_songs_removed)
|
||||
self.window.connect("refresh-window", self.on_refresh_window)
|
||||
self.window.connect("notification-closed", self.on_notification_closed)
|
||||
self.window.connect("go-to", self.on_window_go_to)
|
||||
self.window.connect("key-press-event", self.on_window_key_press)
|
||||
self.window.player_controls.connect("song-scrub", self.on_song_scrub)
|
||||
self.window.player_controls.connect("device-update", self.on_device_update)
|
||||
self.window.player_controls.connect("volume-change", self.on_volume_change)
|
||||
|
||||
# Configure the players
|
||||
self.last_play_queue_update = timedelta(0)
|
||||
self.loading_state = False
|
||||
@@ -204,7 +240,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
self.app_config.state.song_progress = timedelta(seconds=value)
|
||||
GLib.idle_add(
|
||||
self.window.player_controls.update_scrubber,
|
||||
self.window.update_song_progress,
|
||||
self.app_config.state.song_progress,
|
||||
self.app_config.state.current_song.duration,
|
||||
self.app_config.state.song_stream_cache_progress,
|
||||
@@ -233,7 +269,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
return
|
||||
|
||||
GLib.idle_add(self.on_next_track)
|
||||
GLib.idle_add(self.next_track)
|
||||
|
||||
def on_player_event(event: PlayerEvent):
|
||||
if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE:
|
||||
@@ -262,7 +298,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
seconds=event.stream_cache_duration
|
||||
)
|
||||
GLib.idle_add(
|
||||
self.window.player_controls.update_scrubber,
|
||||
self.window.update_song_progress,
|
||||
self.app_config.state.song_progress,
|
||||
self.app_config.state.current_song.duration,
|
||||
self.app_config.state.song_stream_cache_progress,
|
||||
@@ -326,6 +362,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
GLib.timeout_add(10000, check_if_connected)
|
||||
|
||||
# Update after Adapter Initial Sync
|
||||
if self.app_config.provider:
|
||||
def after_initial_sync(_):
|
||||
self.update_window()
|
||||
|
||||
@@ -376,18 +413,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# a duration, but the Child object has `duration` optional because
|
||||
# it could be a directory.
|
||||
assert self.app_config.state.current_song.duration is not None
|
||||
self.on_song_scrub(
|
||||
None,
|
||||
(
|
||||
new_seconds.total_seconds()
|
||||
/ self.app_config.state.current_song.duration.total_seconds()
|
||||
)
|
||||
* 100,
|
||||
)
|
||||
self.window.player_manager.scrubber = new_seconds.total_seconds()
|
||||
|
||||
def set_pos_fn(track_id: str, position: float = 0):
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
pos_seconds = timedelta(microseconds=position)
|
||||
self.app_config.state.song_progress = pos_seconds
|
||||
track_id, occurrence = track_id.split("/")[-2:]
|
||||
@@ -451,7 +481,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if self.app_config.state.shuffle_on:
|
||||
song_idx = random.randint(0, len(playlist.songs) - 1)
|
||||
|
||||
self.on_song_clicked(
|
||||
self.play_song(
|
||||
None,
|
||||
song_idx,
|
||||
tuple(s.id for s in playlist.songs),
|
||||
@@ -502,11 +532,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
def play():
|
||||
if not self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
def pause():
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
method_call_map: Dict[str, Dict[str, Any]] = {
|
||||
"org.mpris.MediaPlayer2": {
|
||||
@@ -514,10 +544,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
"Quit": self.window and self.window.destroy,
|
||||
},
|
||||
"org.mpris.MediaPlayer2.Player": {
|
||||
"Next": self.on_next_track,
|
||||
"Previous": self.on_prev_track,
|
||||
"Next": self.next_track,
|
||||
"Previous": self.prev_track,
|
||||
"Pause": pause,
|
||||
"PlayPause": self.on_play_pause,
|
||||
"PlayPause": self.play_pause,
|
||||
"Stop": pause,
|
||||
"Play": play,
|
||||
"Seek": seek_fn,
|
||||
@@ -555,10 +585,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
def set_shuffle(new_val: GLib.Variant):
|
||||
if new_val.get_boolean() != self.app_config.state.shuffle_on:
|
||||
self.on_shuffle_press(None, None)
|
||||
self.shuffle()
|
||||
|
||||
def set_volume(new_val: GLib.Variant):
|
||||
self.on_volume_change(None, new_val.get_double() * 100)
|
||||
self.set_volume(new_val.get_double() * 100)
|
||||
|
||||
setter_map: Dict[str, Dict[str, Any]] = {
|
||||
"org.mpris.MediaPlayer2.Player": {
|
||||
@@ -577,81 +607,49 @@ class SublimeMusicApp(Gtk.Application):
|
||||
setter(value)
|
||||
|
||||
# ########## ACTION HANDLERS ########## #
|
||||
# @dbus_propagate()
|
||||
# def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False):
|
||||
# if settings := state_updates.get("__settings__"):
|
||||
# for k, v in settings.items():
|
||||
# setattr(self.app_config, k, v)
|
||||
# if (offline_mode := settings.get("offline_mode")) is not None:
|
||||
# AdapterManager.on_offline_mode_change(offline_mode)
|
||||
|
||||
# del state_updates["__settings__"]
|
||||
# self.app_config.save()
|
||||
|
||||
# if player_setting := state_updates.get("__player_setting__"):
|
||||
# player_name, option_name, value = player_setting
|
||||
# self.app_config.player_config[player_name][option_name] = value
|
||||
# del state_updates["__player_setting__"]
|
||||
# if pm := self.player_manager:
|
||||
# pm.change_settings(self.app_config.player_config)
|
||||
# self.app_config.save()
|
||||
|
||||
# for k, v in state_updates.items():
|
||||
# setattr(self.app_config.state, k, v)
|
||||
# self.update_window(force=force)
|
||||
|
||||
# @dbus_propagate()
|
||||
def refresh(self):
|
||||
self.update_window(force=False)
|
||||
|
||||
@dbus_propagate()
|
||||
def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False):
|
||||
if settings := state_updates.get("__settings__"):
|
||||
for k, v in settings.items():
|
||||
setattr(self.app_config, k, v)
|
||||
if (offline_mode := settings.get("offline_mode")) is not None:
|
||||
AdapterManager.on_offline_mode_change(offline_mode)
|
||||
def force_refresh(self):
|
||||
self.update_window(force=True)
|
||||
|
||||
del state_updates["__settings__"]
|
||||
def _save_and_refresh(self):
|
||||
self.app_config.save()
|
||||
|
||||
if player_setting := state_updates.get("__player_setting__"):
|
||||
player_name, option_name, value = player_setting
|
||||
self.app_config.player_config[player_name][option_name] = value
|
||||
del state_updates["__player_setting__"]
|
||||
if pm := self.player_manager:
|
||||
pm.change_settings(self.app_config.player_config)
|
||||
self.app_config.save()
|
||||
|
||||
for k, v in state_updates.items():
|
||||
setattr(self.app_config.state, k, v)
|
||||
self.update_window(force=force)
|
||||
self.refresh()
|
||||
|
||||
def on_notification_closed(self, _):
|
||||
self.app_config.state.current_notification = None
|
||||
self.update_window()
|
||||
|
||||
def on_add_new_music_provider(self, *args):
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
def on_edit_current_music_provider(self, *args):
|
||||
self.show_configure_servers_dialog(self.app_config.provider.clone())
|
||||
|
||||
def on_switch_music_provider(self, _, provider_id: GLib.Variant):
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.app_config.save()
|
||||
self.app_config.current_provider_id = provider_id.get_string()
|
||||
self.reset_state()
|
||||
self.app_config.save()
|
||||
|
||||
def on_remove_music_provider(self, _, provider_id: GLib.Variant):
|
||||
provider = self.app_config.providers[provider_id.get_string()]
|
||||
confirm_dialog = Gtk.MessageDialog(
|
||||
transient_for=self.window,
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=(
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_DELETE,
|
||||
Gtk.ResponseType.YES,
|
||||
),
|
||||
text=f"Are you sure you want to delete the {provider.name} music provider?",
|
||||
)
|
||||
confirm_dialog.format_secondary_markup(
|
||||
"Deleting this music provider will delete all cached songs and metadata "
|
||||
"associated with this provider."
|
||||
)
|
||||
if confirm_dialog.run() == Gtk.ResponseType.YES:
|
||||
assert self.app_config.cache_location
|
||||
provider_dir = self.app_config.cache_location.joinpath(provider.id)
|
||||
shutil.rmtree(str(provider_dir), ignore_errors=True)
|
||||
del self.app_config.providers[provider.id]
|
||||
|
||||
confirm_dialog.destroy()
|
||||
|
||||
def on_window_go_to(self, win: Any, action: str, value: str):
|
||||
{
|
||||
"album": self.on_go_to_album,
|
||||
"artist": self.on_go_to_artist,
|
||||
"playlist": self.on_go_to_playlist,
|
||||
}[action](None, GLib.Variant("s", value))
|
||||
_inhibit_cookie = None
|
||||
|
||||
@dbus_propagate()
|
||||
def on_play_pause(self, *args):
|
||||
def play_pause(self):
|
||||
if self.app_config.state.current_song_index < 0:
|
||||
return
|
||||
|
||||
@@ -666,9 +664,17 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# This is from a restart, start playing the file.
|
||||
self.play_song(self.app_config.state.current_song_index)
|
||||
|
||||
# Inhibit suspend when playing
|
||||
if self._inhibit_cookie:
|
||||
self.uninhibit(self._inhibit_cookie)
|
||||
self._inhibit_cookie = None
|
||||
|
||||
if self.app_config.state.playing:
|
||||
self._inhibit_cookie = self.inhibit(None, Gtk.ApplicationInhibitFlags.SUSPEND, "Playing music")
|
||||
|
||||
self.update_window()
|
||||
|
||||
def on_next_track(self, *args):
|
||||
def next_track(self):
|
||||
if self.app_config.state.current_song is None:
|
||||
# This may happen due to DBUS, ignore.
|
||||
return
|
||||
@@ -694,7 +700,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
else:
|
||||
self.update_window()
|
||||
|
||||
def on_prev_track(self, *args):
|
||||
def prev_track(self):
|
||||
if self.app_config.state.current_song is None:
|
||||
# This may happen due to DBUS, ignore.
|
||||
return
|
||||
@@ -727,14 +733,15 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_repeat_press(self, *args):
|
||||
def repeat(self):
|
||||
# Cycle through the repeat types.
|
||||
new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
|
||||
self.app_config.state.repeat_type = new_repeat_type
|
||||
self.update_window()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_shuffle_press(self, *args):
|
||||
def shuffle(self):
|
||||
if self.app_config.state.current_song:
|
||||
if self.app_config.state.shuffle_on:
|
||||
# Revert to the old play queue.
|
||||
old_play_queue_copy = self.app_config.state.old_play_queue
|
||||
@@ -758,8 +765,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_play_next(self, action: Any, song_ids: GLib.Variant):
|
||||
song_ids = tuple(song_ids)
|
||||
def queue_next_songs(self, song_ids: List[str]):
|
||||
if self.app_config.state.current_song is None:
|
||||
insert_at = 0
|
||||
else:
|
||||
@@ -774,13 +780,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_add_to_queue(self, action: Any, song_ids: GLib.Variant):
|
||||
def queue_songs(self, song_ids: List[str]):
|
||||
song_ids = tuple(song_ids)
|
||||
self.app_config.state.play_queue += tuple(song_ids)
|
||||
self.app_config.state.old_play_queue += tuple(song_ids)
|
||||
self.update_window()
|
||||
|
||||
def on_go_to_album(self, action: Any, album_id: GLib.Variant):
|
||||
def go_to_album(self, album_id: str):
|
||||
# Switch to the Alphabetical by Name view to guarantee that the album is there.
|
||||
self.app_config.state.current_album_search_query = AlbumSearchQuery(
|
||||
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
|
||||
@@ -789,22 +795,22 @@ class SublimeMusicApp(Gtk.Application):
|
||||
)
|
||||
|
||||
self.app_config.state.current_tab = "albums"
|
||||
self.app_config.state.selected_album_id = album_id.get_string()
|
||||
self.app_config.state.selected_album_id = album_id
|
||||
self.update_window()
|
||||
|
||||
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
|
||||
def go_to_artist(self, artist_id: str):
|
||||
self.app_config.state.current_tab = "artists"
|
||||
self.app_config.state.selected_artist_id = artist_id.get_string()
|
||||
self.app_config.state.selected_artist_id = artist_id
|
||||
self.update_window()
|
||||
|
||||
def browse_to(self, action: Any, item_id: GLib.Variant):
|
||||
def browse_to(self, item_id: str):
|
||||
self.app_config.state.current_tab = "browse"
|
||||
self.app_config.state.selected_browse_element_id = item_id.get_string()
|
||||
self.app_config.state.selected_browse_element_id = item_id
|
||||
self.update_window()
|
||||
|
||||
def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
|
||||
def go_to_playlist(self, playlist_id: str):
|
||||
self.app_config.state.current_tab = "playlists"
|
||||
self.app_config.state.selected_playlist_id = playlist_id.get_string()
|
||||
self.app_config.state.selected_playlist_id = playlist_id or None
|
||||
self.update_window()
|
||||
|
||||
def on_go_online(self, *args):
|
||||
@@ -815,7 +821,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
def reset_state(self):
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
self.loading_state = True
|
||||
self.player_manager.reset()
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
@@ -824,17 +830,14 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# Update the window according to the new server configuration.
|
||||
self.update_window()
|
||||
|
||||
def on_stack_change(self, stack: Gtk.Stack, _):
|
||||
self.app_config.state.current_tab = stack.get_visible_child_name()
|
||||
def change_tab(self, tab_id: str):
|
||||
self.app_config.state.current_tab = tab_id
|
||||
self.update_window()
|
||||
|
||||
def on_song_clicked(
|
||||
self,
|
||||
win: Any,
|
||||
song_index: int,
|
||||
song_queue: Tuple[str, ...],
|
||||
metadata: Dict[str, Any],
|
||||
):
|
||||
def play_song_action(self, song_index: int, song_queue: List[str], metadata: Dict[str, Any]):
|
||||
if not song_queue:
|
||||
song_queue = self.app_config.state.play_queue
|
||||
|
||||
song_queue = tuple(song_queue)
|
||||
# Reset the play queue so that we don't ever revert back to the
|
||||
# previous one.
|
||||
@@ -879,7 +882,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
if self.app_config.state.current_song_index in song_indexes_to_remove:
|
||||
if len(self.app_config.state.play_queue) == 0:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
self.app_config.state.current_song_index = -1
|
||||
self.update_window()
|
||||
return
|
||||
@@ -892,7 +895,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.save_play_queue()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_song_scrub(self, _, scrub_value: float):
|
||||
def seek(self, value: float):
|
||||
if not self.app_config.state.current_song or not self.window:
|
||||
return
|
||||
|
||||
@@ -900,14 +903,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# a duration, but the Child object has `duration` optional because
|
||||
# it could be a directory.
|
||||
assert self.app_config.state.current_song.duration is not None
|
||||
new_time = self.app_config.state.current_song.duration * (scrub_value / 100)
|
||||
|
||||
new_time = timedelta(seconds=value)
|
||||
self.app_config.state.song_progress = new_time
|
||||
self.window.player_controls.update_scrubber(
|
||||
self.app_config.state.song_progress,
|
||||
self.app_config.state.current_song.duration,
|
||||
self.app_config.state.song_stream_cache_progress,
|
||||
)
|
||||
|
||||
# If already playing, then make the player itself seek.
|
||||
if self.player_manager and self.player_manager.song_loaded:
|
||||
@@ -915,14 +913,14 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
self.save_play_queue()
|
||||
|
||||
def on_device_update(self, _, device_id: str):
|
||||
def select_device(self, device_id: str):
|
||||
assert self.player_manager
|
||||
if device_id == self.app_config.state.current_device:
|
||||
return
|
||||
self.app_config.state.current_device = device_id
|
||||
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
self.player_manager.set_current_device_id(self.app_config.state.current_device)
|
||||
|
||||
@@ -932,54 +930,37 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
if was_playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
if self.dbus_manager:
|
||||
self.dbus_manager.property_diff()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_mute_toggle(self, *args):
|
||||
def toggle_mute(self):
|
||||
self.app_config.state.is_muted = not self.app_config.state.is_muted
|
||||
self.player_manager.set_muted(self.app_config.state.is_muted)
|
||||
self.update_window()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_volume_change(self, _, value: float):
|
||||
assert self.player_manager
|
||||
def set_volume(self, value: float):
|
||||
self.app_config.state.volume = value
|
||||
self.player_manager.set_volume(self.app_config.state.volume)
|
||||
self.update_window()
|
||||
|
||||
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool:
|
||||
# Need to use bitwise & here to see if CTRL is pressed.
|
||||
if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
|
||||
# Ctrl + F
|
||||
window.search_entry.grab_focus()
|
||||
return False
|
||||
|
||||
# Allow spaces to work in the text entry boxes.
|
||||
if (
|
||||
window.search_entry.has_focus()
|
||||
or window.playlists_panel.playlist_list.new_playlist_entry.has_focus()
|
||||
):
|
||||
return False
|
||||
|
||||
# Spacebar, home/prev
|
||||
keymap = {
|
||||
32: self.on_play_pause,
|
||||
65360: self.on_prev_track,
|
||||
65367: self.on_next_track,
|
||||
}
|
||||
|
||||
action = keymap.get(event.keyval)
|
||||
if action:
|
||||
action()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
||||
assert self.window
|
||||
GLib.idle_add(self.window.update_song_download_progress, song_id, progress)
|
||||
if len(self._download_progress) == 0:
|
||||
GLib.timeout_add(100, self._propagate_download_progress)
|
||||
|
||||
# Amortize progress updates
|
||||
self._download_progress[song_id] = progress
|
||||
|
||||
def _propagate_download_progress(self):
|
||||
items = list(self._download_progress.items())
|
||||
self._download_progress = {}
|
||||
|
||||
for song_id, progress in items:
|
||||
self.window.update_song_download_progress(song_id, progress)
|
||||
|
||||
return False
|
||||
|
||||
def on_app_shutdown(self, app: "SublimeMusicApp"):
|
||||
self.exiting = True
|
||||
@@ -1003,33 +984,126 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.dbus_manager.shutdown()
|
||||
AdapterManager.shutdown()
|
||||
|
||||
# ########## HELPER METHODS ########## #
|
||||
def show_configure_servers_dialog(
|
||||
self,
|
||||
provider_config: Optional[ProviderConfiguration] = None,
|
||||
):
|
||||
"""Show the Connect to Server dialog."""
|
||||
dialog = ConfigureProviderDialog(self.window, provider_config)
|
||||
result = dialog.run()
|
||||
if result == Gtk.ResponseType.APPLY:
|
||||
assert dialog.provider_config is not None
|
||||
provider_id = dialog.provider_config.id
|
||||
dialog.provider_config.persist_secrets()
|
||||
self.app_config.providers[provider_id] = dialog.provider_config
|
||||
self.app_config.save()
|
||||
|
||||
if provider_id == self.app_config.current_provider_id:
|
||||
# Just update the window.
|
||||
def albums_set_search_query(self, query: AlbumSearchQuery, sort_direction: str):
|
||||
self.app_config.state.current_album_search_query = query
|
||||
self.app_config.state.album_sort_direction = sort_direction
|
||||
self.app_config.state.album_page = 0
|
||||
self.app_config.state.selected_album_id = None
|
||||
self.update_window()
|
||||
else:
|
||||
# Switch to the new provider.
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.app_config.current_provider_id = provider_id
|
||||
|
||||
def albums_set_page(self, page: int):
|
||||
self.app_config.state.album_page = page
|
||||
self.app_config.state.selected_album_id = None
|
||||
self.update_window()
|
||||
|
||||
def albums_select_album(self, album_id: str):
|
||||
self.app_config.state.selected_album_id = album_id
|
||||
self.update_window()
|
||||
|
||||
def playlists_set_details_expanded(self, expanded: bool):
|
||||
self.app_config.state.playlist_details_expanded = expanded
|
||||
self.update_window()
|
||||
|
||||
def search_set_query(self, query: str):
|
||||
self.app_config.state.search_query = query
|
||||
self.update_window()
|
||||
|
||||
def players_set_option(self, player: str, option: str, value: Any):
|
||||
self.app_config.player_config[player][option] = value
|
||||
|
||||
if pm := self.player_manager:
|
||||
pm.change_settings(self.app_config.player_config)
|
||||
|
||||
self.app_config.save()
|
||||
self.update_window()
|
||||
|
||||
def providers_set_config(self, id: str, name: str, adapter_name: str, config: Dict[str, Any]):
|
||||
adapter_type = None
|
||||
for adapter in AdapterManager.available_adapters:
|
||||
if adapter.get_ui_info().name == adapter_name:
|
||||
adapter_type = adapter
|
||||
break
|
||||
assert adapter_type is not None
|
||||
|
||||
provider_config = ProviderConfiguration(
|
||||
id,
|
||||
name,
|
||||
adapter_type,
|
||||
ConfigurationStore(**config))
|
||||
|
||||
if adapter_type.can_be_cached:
|
||||
provider_config.caching_adapter_type = FilesystemAdapter
|
||||
provider_config.caching_adapter_config = ConfigurationStore()
|
||||
|
||||
provider_config.persist_secrets()
|
||||
self.app_config.providers[provider_config.id] = provider_config
|
||||
self.app_config.current_provider_id = provider_config.id
|
||||
self.reset_state()
|
||||
self.app_config.save()
|
||||
self.update_window(force=True)
|
||||
|
||||
dialog.destroy()
|
||||
def providers_switch(self, provider_id: str):
|
||||
if self.app_config.state.playing:
|
||||
self.play_pause()
|
||||
self.app_config.save()
|
||||
self.app_config.current_provider_id = provider_id
|
||||
self.reset_state()
|
||||
self.app_config.save()
|
||||
|
||||
def providers_remove(self, provider_id: str):
|
||||
provider = self.app_config.providers[provider_id]
|
||||
confirm_dialog = Gtk.MessageDialog(
|
||||
transient_for=self.window,
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=(
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_DELETE,
|
||||
Gtk.ResponseType.YES,
|
||||
),
|
||||
text=f"Are you sure you want to delete the {provider.name} music provider?",
|
||||
)
|
||||
confirm_dialog.format_secondary_markup(
|
||||
"Deleting this music provider will delete all cached songs and metadata "
|
||||
"associated with this provider."
|
||||
)
|
||||
if confirm_dialog.run() == Gtk.ResponseType.YES:
|
||||
assert self.app_config.cache_location
|
||||
provider_dir = self.app_config.cache_location.joinpath(provider.id)
|
||||
shutil.rmtree(str(provider_dir), ignore_errors=True)
|
||||
del self.app_config.providers[provider.id]
|
||||
self.update_window()
|
||||
|
||||
confirm_dialog.destroy()
|
||||
|
||||
|
||||
# ########## HELPER METHODS ########## #
|
||||
# def show_configure_servers_dialog(
|
||||
# self,
|
||||
# provider_config: Optional[ProviderConfiguration] = None,
|
||||
# ):
|
||||
# """Show the Connect to Server dialog."""
|
||||
# dialog = ConfigureProviderDialog(self.window, provider_config)
|
||||
# result = dialog.run()
|
||||
# if result == Gtk.ResponseType.APPLY:
|
||||
# assert dialog.provider_config is not None
|
||||
# provider_id = dialog.provider_config.id
|
||||
# dialog.provider_config.persist_secrets()
|
||||
# self.app_config.providers[provider_id] = dialog.provider_config
|
||||
# self.app_config.save()
|
||||
|
||||
# if provider_id == self.app_config.current_provider_id:
|
||||
# # Just update the window.
|
||||
# self.update_window()
|
||||
# else:
|
||||
# # Switch to the new provider.
|
||||
# if self.app_config.state.playing:
|
||||
# self.play_pause()
|
||||
# self.app_config.current_provider_id = provider_id
|
||||
# self.app_config.save()
|
||||
# self.update_window(force=True)
|
||||
|
||||
# dialog.destroy()
|
||||
|
||||
def update_window(self, force: bool = False):
|
||||
if not self.window:
|
||||
@@ -1064,7 +1138,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def do_resume(clear_notification: bool):
|
||||
assert self.player_manager
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
self.app_config.state.play_queue = new_play_queue
|
||||
self.app_config.state.song_progress = play_queue.position
|
||||
@@ -1076,7 +1150,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
if was_playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
if prompt_confirm:
|
||||
# If there's not a significant enough difference in the song state,
|
||||
@@ -1383,13 +1457,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# There are no songs that can be played. Show a notification that you
|
||||
# have to go online to play anything and then don't go further.
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
def go_online_clicked():
|
||||
self.app_config.state.current_notification = None
|
||||
self.on_go_online()
|
||||
if was_playing:
|
||||
self.on_play_pause()
|
||||
self.play_pause()
|
||||
|
||||
if all(s == SongCacheStatus.NOT_CACHED for s in statuses):
|
||||
markup = (
|
||||
|
@@ -191,7 +191,7 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
|
||||
if self.version < 6:
|
||||
self.player_config = {
|
||||
"Local Playback": {"Replay Gain": ["no", "track", "album"][self._rg]},
|
||||
"Local Playback": {"Replay Gain": ["Disabled", "Track", "Album"][self._rg]},
|
||||
"Chromecast": {
|
||||
"Serve Local Files to Chromecasts on the LAN": self._sol,
|
||||
"LAN Server Port Number": self._pn,
|
||||
|
288
sublime_music/ui/actions.py
Normal file
288
sublime_music/ui/actions.py
Normal file
@@ -0,0 +1,288 @@
|
||||
import inspect
|
||||
import enum
|
||||
import dataclasses
|
||||
import pathlib
|
||||
from typing import Callable, Optional, Tuple, Any, Union, List, Type
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
|
||||
NoneType = type(None)
|
||||
|
||||
|
||||
def run_action(widget, name, *args):
|
||||
print('run action', name, args)
|
||||
|
||||
group, action = name.split('.')
|
||||
action_group = widget.get_action_group(group)
|
||||
|
||||
if args:
|
||||
type_str = action_group.get_action_parameter_type(action)
|
||||
assert type_str
|
||||
|
||||
if len(args) > 1:
|
||||
param = _create_variant(type_str.dup_string(), tuple(args))
|
||||
else:
|
||||
param = _create_variant(type_str.dup_string(), args[0])
|
||||
else:
|
||||
param = None
|
||||
|
||||
action_group.activate_action(action, param)
|
||||
|
||||
|
||||
def register_dataclass_actions(group, data, after=None):
|
||||
fields = dataclasses.fields(type(data))
|
||||
|
||||
for field in fields:
|
||||
if field.name[0] == '_':
|
||||
continue
|
||||
|
||||
def set_field(value, name=field.name):
|
||||
setattr(data, name, value)
|
||||
if after:
|
||||
after()
|
||||
|
||||
name = field.name.replace('_', '-')
|
||||
try:
|
||||
register_action(group, set_field, name=f'set-{name}', types=(field.type,))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
|
||||
def register_action(group, fn: Callable, name: Optional[str] = None, types: Tuple[Type] = None):
|
||||
if name is None:
|
||||
name = fn.__name__.replace('_', '-')
|
||||
|
||||
# Determine the type from the signature
|
||||
if types is None:
|
||||
signature = inspect.signature(fn)
|
||||
types = tuple(p.annotation for p in signature.parameters.values())
|
||||
|
||||
if types:
|
||||
if inspect.Parameter.empty in types:
|
||||
raise ValueError('Missing parameter annotation for action ' + name)
|
||||
|
||||
has_multiple = len(types) > 1
|
||||
if has_multiple:
|
||||
param_type = Tuple.__getitem__(types)
|
||||
else:
|
||||
param_type = types[0]
|
||||
|
||||
type_str = variant_type_from_python(param_type)
|
||||
var_type = GLib.VariantType(type_str)
|
||||
|
||||
build = generate_build_function(param_type)
|
||||
if not build:
|
||||
build = lambda a: a
|
||||
else:
|
||||
var_type = None
|
||||
|
||||
action = Gio.SimpleAction.new(name, var_type)
|
||||
def activate(action, param):
|
||||
if param is not None:
|
||||
if has_multiple:
|
||||
fn(*build(param.unpack()))
|
||||
else:
|
||||
fn(build(param.unpack()))
|
||||
else:
|
||||
fn()
|
||||
action.connect('activate', activate)
|
||||
|
||||
if hasattr(group, 'add_action'):
|
||||
group.add_action(action)
|
||||
else:
|
||||
group.insert(action)
|
||||
|
||||
|
||||
def variant_type_from_python(py_type: type) -> str:
|
||||
if py_type is bool:
|
||||
return 'b'
|
||||
elif py_type is int:
|
||||
return 'x'
|
||||
elif py_type is float:
|
||||
return 'd'
|
||||
elif py_type is str:
|
||||
return 's'
|
||||
elif py_type is Any:
|
||||
return 'v'
|
||||
elif isinstance(py_type, type) and issubclass(py_type, pathlib.PurePath):
|
||||
return 's'
|
||||
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
|
||||
return variant_type_from_python(type(list(py_type)[0].value))
|
||||
elif dataclasses.is_dataclass(py_type):
|
||||
types = (f.type for f in dataclasses.fields(py_type))
|
||||
|
||||
return '(' + ''.join(map(variant_type_from_python, types)) + ')'
|
||||
else:
|
||||
origin = py_type.__origin__
|
||||
|
||||
if origin is list:
|
||||
assert len(py_type.__args__) == 1
|
||||
|
||||
return 'a' + variant_type_from_python(py_type.__args__[0])
|
||||
elif origin is tuple:
|
||||
return '(' + ''.join(map(variant_type_from_python, py_type.__args__)) + ')'
|
||||
elif origin is dict:
|
||||
assert len(py_type.__args__) == 2
|
||||
|
||||
key = variant_type_from_python(py_type.__args__[0])
|
||||
value = variant_type_from_python(py_type.__args__[1])
|
||||
return 'a{' + key + value + '}'
|
||||
elif origin is Union:
|
||||
non_maybe = [t for t in py_type.__args__ if t is not NoneType]
|
||||
has_maybe = len(non_maybe) != len(py_type.__args__)
|
||||
|
||||
if has_maybe and len(non_maybe) == 1:
|
||||
return 'm' + variant_type_from_python(non_maybe[0])
|
||||
|
||||
return ('m[' if has_maybe else '[') + ''.join(''.join(map(variant_type_from_python, non_maybe))) + ']'
|
||||
else:
|
||||
raise ValueError('{} does not have an equivalent'.format(py_type))
|
||||
|
||||
|
||||
def unbuilt_type(py_type: type) -> type:
|
||||
if isinstance(py_type, type) and issubclass(py_type, pathlib.PurePath):
|
||||
return str
|
||||
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
|
||||
return type(list(py_type)[0].value)
|
||||
elif dataclasses.is_dataclass(py_type):
|
||||
return tuple
|
||||
return py_type
|
||||
|
||||
|
||||
def generate_build_function(py_type: type) -> Optional[Callable]:
|
||||
"""
|
||||
Return a function for reconstructing dataclasses and enumerations after
|
||||
unpacking a GVariant. When no reconstruction is needed None is returned.
|
||||
"""
|
||||
|
||||
if isinstance(py_type, type) and issubclass(py_type, pathlib.PurePath):
|
||||
return py_type
|
||||
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
|
||||
return py_type
|
||||
elif dataclasses.is_dataclass(py_type):
|
||||
types = tuple(f.type for f in dataclasses.fields(py_type))
|
||||
tuple_build = generate_build_function(Tuple.__getitem__(types))
|
||||
|
||||
if not tuple_build:
|
||||
return lambda values: py_type(*values)
|
||||
|
||||
return lambda values: py_type(*tuple_build(values))
|
||||
elif hasattr(py_type, '__origin__'):
|
||||
origin = py_type.__origin__
|
||||
|
||||
if origin is list:
|
||||
assert len(py_type.__args__) == 1
|
||||
|
||||
build = generate_build_function(py_type.__args__[0])
|
||||
|
||||
if build:
|
||||
return lambda values: [build(v) for v in values]
|
||||
|
||||
elif origin is tuple:
|
||||
builds = list(map(generate_build_function, py_type.__args__))
|
||||
|
||||
if not any(builds):
|
||||
return None
|
||||
|
||||
return lambda values: tuple((build(value) if build else value) for build, value in zip(builds, values))
|
||||
elif origin is dict:
|
||||
assert len(py_type.__args__) == 2
|
||||
|
||||
build_key = generate_build_function(py_type.__args__[0])
|
||||
build_value = generate_build_function(py_type.__args__[1])
|
||||
|
||||
if not build_key and not build_value:
|
||||
return None
|
||||
|
||||
return lambda values: {
|
||||
(build_key(key) if build_key else key): (build_value(value) if build_value else value)
|
||||
for key, value in values.items()}
|
||||
elif origin is Union:
|
||||
builds = list(map(generate_build_function, py_type.__args__))
|
||||
|
||||
if not any(builds):
|
||||
return None
|
||||
|
||||
unbuilt_types = list(map(unbuilt_type, py_type.__args__))
|
||||
|
||||
def build(value):
|
||||
for bld, type_ in zip(builds, unbuilt_types):
|
||||
if isinstance(value, type_):
|
||||
if bld:
|
||||
return bld(value)
|
||||
else:
|
||||
return value
|
||||
return value
|
||||
return build
|
||||
return None
|
||||
|
||||
|
||||
_VARIANT_CONSTRUCTORS = {
|
||||
'b': GLib.Variant.new_boolean,
|
||||
'y': GLib.Variant.new_byte,
|
||||
'n': GLib.Variant.new_int16,
|
||||
'q': GLib.Variant.new_uint16,
|
||||
'i': GLib.Variant.new_int32,
|
||||
'u': GLib.Variant.new_uint32,
|
||||
'x': GLib.Variant.new_int64,
|
||||
't': GLib.Variant.new_uint64,
|
||||
'h': GLib.Variant.new_handle,
|
||||
'd': GLib.Variant.new_double,
|
||||
's': GLib.Variant.new_string,
|
||||
'o': GLib.Variant.new_object_path,
|
||||
'g': GLib.Variant.new_signature,
|
||||
'v': GLib.Variant.new_variant,
|
||||
}
|
||||
|
||||
from gi._gi import variant_type_from_string
|
||||
|
||||
def _create_variant(type_str, value):
|
||||
assert type_str
|
||||
|
||||
if isinstance(value, enum.Enum):
|
||||
value = value.value
|
||||
elif isinstance(value, pathlib.PurePath):
|
||||
value = str(value)
|
||||
elif dataclasses.is_dataclass(type(value)):
|
||||
fields = dataclasses.fields(type(value))
|
||||
value = tuple(getattr(value, field.name) for field in fields)
|
||||
|
||||
vtype = GLib.VariantType(type_str)
|
||||
|
||||
if type_str in _VARIANT_CONSTRUCTORS:
|
||||
return _VARIANT_CONSTRUCTORS[type_str](value)
|
||||
|
||||
builder = GLib.VariantBuilder.new(vtype)
|
||||
if value is None:
|
||||
return builder.end()
|
||||
|
||||
if vtype.is_maybe():
|
||||
builder.add_value(_create_variant(vtype.element().dup_string(), value))
|
||||
return builder.end()
|
||||
|
||||
try:
|
||||
iter(value)
|
||||
except TypeError:
|
||||
raise TypeError("Could not create array, tuple or dictionary entry from non iterable value %s %s" %
|
||||
(type_str, value))
|
||||
|
||||
if vtype.is_tuple() and vtype.n_items() != len(value):
|
||||
raise TypeError("Tuple mismatches value's number of elements %s %s" % (type_str, value))
|
||||
if vtype.is_dict_entry() and len(value) != 2:
|
||||
raise TypeError("Dictionary entries must have two elements %s %s" % (type_str, value))
|
||||
|
||||
if vtype.is_array():
|
||||
element_type = vtype.element().dup_string()
|
||||
if isinstance(value, dict):
|
||||
value = value.items()
|
||||
for i in value:
|
||||
builder.add_value(_create_variant(element_type, i))
|
||||
else:
|
||||
remainer_format = type_str[1:]
|
||||
for i in value:
|
||||
dup = variant_type_from_string(remainer_format).dup_string()
|
||||
builder.add_value(_create_variant(dup, i))
|
||||
remainer_format = remainer_format[len(dup):]
|
||||
|
||||
return builder.end()
|
@@ -4,7 +4,7 @@ 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 gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
from ..adapters import (
|
||||
AdapterManager,
|
||||
@@ -14,8 +14,12 @@ from ..adapters import (
|
||||
Result,
|
||||
)
|
||||
from ..config import AppConfiguration
|
||||
from ..ui import util
|
||||
from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
|
||||
from . import util
|
||||
from .common import AlbumWithSongs, IconButton, LoadError, SpinnerImage, Sizer
|
||||
from .actions import run_action
|
||||
|
||||
|
||||
COVER_ART_WIDTH = 150
|
||||
|
||||
|
||||
def _to_type(query_type: AlbumSearchQuery.Type) -> str:
|
||||
@@ -46,30 +50,23 @@ def _from_str(type_str: str) -> AlbumSearchQuery.Type:
|
||||
}[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),
|
||||
),
|
||||
}
|
||||
|
||||
class AlbumsPanel(Handy.Leaflet):
|
||||
current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
|
||||
offline_mode = False
|
||||
provider_id: Optional[str] = None
|
||||
|
||||
populating_genre_combo = False
|
||||
grid_order_token: int = 0
|
||||
album_sort_direction: str = "ascending"
|
||||
album_page_size: int = 30
|
||||
album_page: int = 0
|
||||
grid_pages_count: int = 0
|
||||
|
||||
current_albums_result: Result = None
|
||||
|
||||
albums = []
|
||||
albums_by_id = {}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
super().__init__(transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False, interpolate_size=False)
|
||||
|
||||
self.grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
actionbar = Gtk.ActionBar()
|
||||
|
||||
@@ -90,30 +87,37 @@ class AlbumsPanel(Gtk.Box):
|
||||
)
|
||||
actionbar.pack_start(self.sort_type_combo)
|
||||
|
||||
self.filter_stack = Gtk.Stack(no_show_all=True)
|
||||
|
||||
self.alphabetical_type_combo, _ = self.make_combobox(
|
||||
(("by_name", "by album name", True), ("by_artist", "by artist name", True)),
|
||||
self.on_alphabetical_type_change,
|
||||
)
|
||||
actionbar.pack_start(self.alphabetical_type_combo)
|
||||
self.filter_stack.add(self.alphabetical_type_combo)
|
||||
|
||||
self.genre_combo, self.genre_combo_store = self.make_combobox(
|
||||
(), self.on_genre_change
|
||||
)
|
||||
actionbar.pack_start(self.genre_combo)
|
||||
self.filter_stack.add(self.genre_combo)
|
||||
|
||||
filter_time_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
next_decade = (datetime.datetime.now().year // 10) * 10 + 10
|
||||
|
||||
self.from_year_label = Gtk.Label(label="from")
|
||||
actionbar.pack_start(self.from_year_label)
|
||||
filter_time_box.add(self.from_year_label)
|
||||
self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
||||
self.from_year_spin_button.connect("value-changed", self.on_year_changed)
|
||||
actionbar.pack_start(self.from_year_spin_button)
|
||||
filter_time_box.add(self.from_year_spin_button)
|
||||
|
||||
self.to_year_label = Gtk.Label(label="to")
|
||||
actionbar.pack_start(self.to_year_label)
|
||||
filter_time_box.add(self.to_year_label)
|
||||
self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
||||
self.to_year_spin_button.connect("value-changed", self.on_year_changed)
|
||||
actionbar.pack_start(self.to_year_spin_button)
|
||||
filter_time_box.add(self.to_year_spin_button)
|
||||
|
||||
self.filter_stack.add(filter_time_box)
|
||||
|
||||
actionbar.pack_start(self.filter_stack)
|
||||
|
||||
self.sort_toggle = IconButton(
|
||||
"view-sort-descending-symbolic", "Sort descending", relief=True
|
||||
@@ -121,60 +125,62 @@ class AlbumsPanel(Gtk.Box):
|
||||
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.grid_box.pack_start(actionbar, False, False, 0)
|
||||
|
||||
self.add(actionbar)
|
||||
# 700 shows ~3 albums
|
||||
grid_sizer = Sizer(natural_width=700)
|
||||
|
||||
scrolled_window = Gtk.ScrolledWindow(
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.grid = Gtk.FlowBox(
|
||||
hexpand=True,
|
||||
margin_top=5,
|
||||
row_spacing=5,
|
||||
column_spacing=5,
|
||||
homogeneous=True,
|
||||
max_children_per_line=999,
|
||||
valign=Gtk.Align.START,
|
||||
halign=Gtk.Align.CENTER,
|
||||
selection_mode=Gtk.SelectionMode.SINGLE)
|
||||
self.grid.connect("child-activated", self.on_album_clicked)
|
||||
|
||||
box.add(self.grid)
|
||||
|
||||
scrolled_window.add(box)
|
||||
grid_sizer.add(scrolled_window)
|
||||
self.grid_box.pack_start(grid_sizer, True, True, 0)
|
||||
|
||||
self.add(self.grid_box)
|
||||
|
||||
self.album_container = Sizer(natural_width=500)
|
||||
|
||||
self.album_with_songs = AlbumWithSongs(scroll_contents=True)
|
||||
self.album_with_songs.get_style_context().add_class("details-panel")
|
||||
|
||||
def back_clicked(_):
|
||||
self.grid.unselect_all()
|
||||
self.set_visible_child(self.grid_box)
|
||||
self.album_with_songs.connect("back-clicked", back_clicked)
|
||||
|
||||
self.album_container.add(self.album_with_songs)
|
||||
self.add(self.album_container)
|
||||
|
||||
def folded_changed(*_):
|
||||
if not self.get_folded():
|
||||
self.set_visible_child(self.grid_box)
|
||||
|
||||
self.album_with_songs.show_back_button = self.get_folded()
|
||||
self.connect("notify::folded", folded_changed)
|
||||
|
||||
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,
|
||||
@@ -231,7 +237,7 @@ class AlbumsPanel(Gtk.Box):
|
||||
except Exception:
|
||||
self.updating_query = False
|
||||
|
||||
def update(self, app_config: AppConfiguration = None, force: bool = False):
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.updating_query = True
|
||||
|
||||
supported_type_strings = {
|
||||
@@ -243,9 +249,36 @@ class AlbumsPanel(Gtk.Box):
|
||||
# (En|Dis)able getting genres.
|
||||
self.sort_type_combo_store[1][2] = AdapterManager.can_get_genres()
|
||||
|
||||
if app_config:
|
||||
if (self.current_query != app_config.state.current_album_search_query
|
||||
or self.offline_mode != app_config.offline_mode
|
||||
or self.provider_id != app_config.current_provider_id
|
||||
or force):
|
||||
|
||||
self.current_query = app_config.state.current_album_search_query
|
||||
self.offline_mode = app_config.offline_mode
|
||||
self.provider_id = app_config.current_provider_id
|
||||
|
||||
if self.current_albums_result is not None:
|
||||
self.current_albums_result.cancel()
|
||||
|
||||
self.current_albums_result = AdapterManager.get_albums(
|
||||
self.current_query, use_ground_truth_adapter=force)
|
||||
|
||||
if self.current_albums_result.data_is_available:
|
||||
# Don't idle add if the data is already available.
|
||||
self.current_albums_result.add_done_callback(self._albums_loaded)
|
||||
else:
|
||||
# self.spinner.show()
|
||||
self.current_albums_result.add_done_callback(
|
||||
lambda f: GLib.idle_add(self._albums_loaded, f)
|
||||
)
|
||||
|
||||
if self.album_sort_direction != app_config.state.album_sort_direction:
|
||||
self.album_sort_direction = app_config.state.album_sort_direction
|
||||
|
||||
self._update_albums()
|
||||
# self.current_query = app_config.state.current_album_search_query
|
||||
# self.offline_mode = app_config.offline_mode
|
||||
|
||||
self.alphabetical_type_combo.set_active_id(
|
||||
{
|
||||
@@ -262,13 +295,8 @@ class AlbumsPanel(Gtk.Box):
|
||||
|
||||
# Update the page display
|
||||
if app_config:
|
||||
self.album_page = app_config.state.album_page
|
||||
self.album_page_size = app_config.state.album_page_size
|
||||
self.refresh_button.set_sensitive(not app_config.offline_mode)
|
||||
|
||||
self.prev_page.set_sensitive(self.album_page > 0)
|
||||
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:
|
||||
@@ -306,16 +334,100 @@ class AlbumsPanel(Gtk.Box):
|
||||
+ self._get_opposite_sort_dir(self.album_sort_direction)
|
||||
)
|
||||
|
||||
self.show_count_dropdown.set_active_id(
|
||||
str(app_config.state.album_page_size)
|
||||
)
|
||||
|
||||
# Has to be last because it resets self.updating_query
|
||||
self.populate_genre_combo(app_config, force=force)
|
||||
|
||||
# At this point, the current query should be totally updated.
|
||||
self.grid_order_token = self.grid.update_params(app_config)
|
||||
self.grid.update(self.grid_order_token, app_config, force=force)
|
||||
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or (self.albums and self.albums[0])
|
||||
if selected_album is not None:
|
||||
self.album_with_songs.update(selected_album, app_config, force=force)
|
||||
|
||||
def _albums_loaded(self, result: Result[Iterable[API.Album]]):
|
||||
self.current_albums_result = None
|
||||
|
||||
# TODO: Error handling
|
||||
self.albums = []
|
||||
|
||||
try:
|
||||
self.albums = list(result.result())
|
||||
except CacheMissError as e:
|
||||
print(e)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
self.albums_by_id = {album.id: album for album in self.albums}
|
||||
|
||||
self._update_albums()
|
||||
|
||||
def _update_albums(self):
|
||||
# Update ordering
|
||||
if self.album_sort_direction == "descending":
|
||||
self.albums.reverse()
|
||||
|
||||
# TODO: Update instead of re-create
|
||||
for child in self.grid.get_children():
|
||||
self.grid.remove(child)
|
||||
|
||||
for album in self.albums:
|
||||
self.grid.add(self._create_cover_art_widget(album))
|
||||
|
||||
def _make_label(self, text: str, name: str) -> Gtk.Label:
|
||||
return Gtk.Label(
|
||||
name=name,
|
||||
label=text,
|
||||
tooltip_text=text,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
single_line_mode=True,
|
||||
halign=Gtk.Align.START,
|
||||
)
|
||||
|
||||
def _create_cover_art_widget(self, album) -> Gtk.Box:
|
||||
sizer = Sizer(natural_width=COVER_ART_WIDTH)
|
||||
|
||||
widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=COVER_ART_WIDTH)
|
||||
|
||||
# Cover art image
|
||||
artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_name="grid-artwork",
|
||||
spinner_name="grid-artwork-spinner",
|
||||
image_size=COVER_ART_WIDTH,
|
||||
)
|
||||
widget_box.pack_start(artwork, True, False, 0)
|
||||
|
||||
# Header for the widget
|
||||
header_label = self._make_label(album.name, "grid-header-label")
|
||||
header_label.set_size_request(COVER_ART_WIDTH, -1)
|
||||
widget_box.pack_start(header_label, False, False, 0)
|
||||
|
||||
# Extra info for the widget
|
||||
info_text = util.dot_join(
|
||||
album.artist.name if album.artist else "-", album.year
|
||||
)
|
||||
if info_text:
|
||||
info_label = self._make_label(info_text, "grid-info-label")
|
||||
info_label.set_size_request(COVER_ART_WIDTH, -1)
|
||||
widget_box.pack_start(info_label, False, False, 0)
|
||||
|
||||
# Download the cover art.
|
||||
def on_artwork_downloaded(filename: Result[str]):
|
||||
artwork.set_from_file(filename.result())
|
||||
artwork.set_loading(False)
|
||||
|
||||
cover_art_filename_future = AdapterManager.get_cover_art_uri(
|
||||
album.cover_art, "file"
|
||||
)
|
||||
if cover_art_filename_future.data_is_available:
|
||||
on_artwork_downloaded(cover_art_filename_future)
|
||||
else:
|
||||
artwork.set_loading(True)
|
||||
cover_art_filename_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(on_artwork_downloaded, f)
|
||||
)
|
||||
|
||||
sizer.add(widget_box)
|
||||
|
||||
sizer.show_all()
|
||||
return sizer
|
||||
|
||||
def _get_opposite_sort_dir(self, sort_dir: str) -> str:
|
||||
return ("ascending", "descending")[0 if sort_dir == "descending" else 1]
|
||||
@@ -327,85 +439,50 @@ class AlbumsPanel(Gtk.Box):
|
||||
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,
|
||||
)
|
||||
run_action(self, 'albums.set-search-query', self.current_query, self._get_opposite_sort_dir(self.album_sort_direction))
|
||||
|
||||
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)
|
||||
run_action(self, "app.force-refresh")
|
||||
|
||||
def on_type_combo_changed(self, combo: Gtk.ComboBox):
|
||||
id = self.get_id(combo)
|
||||
assert id
|
||||
if id == "alphabetical":
|
||||
id += "_" + cast(str, self.get_id(self.alphabetical_type_combo))
|
||||
self.emit_if_not_updating(
|
||||
"refresh-window",
|
||||
{
|
||||
"current_album_search_query": AlbumSearchQuery(
|
||||
run_action(
|
||||
self,
|
||||
'albums.set-search-query',
|
||||
AlbumSearchQuery(
|
||||
_from_str(id),
|
||||
self.current_query.year_range,
|
||||
self.current_query.genre,
|
||||
),
|
||||
"album_page": 0,
|
||||
"selected_album_id": None,
|
||||
},
|
||||
False,
|
||||
)
|
||||
self.album_sort_direction)
|
||||
|
||||
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(
|
||||
run_action(
|
||||
self,
|
||||
'albums.set-search-query',
|
||||
AlbumSearchQuery(
|
||||
_from_str(id),
|
||||
self.current_query.year_range,
|
||||
self.current_query.genre,
|
||||
),
|
||||
"album_page": 0,
|
||||
"selected_album_id": None,
|
||||
},
|
||||
False,
|
||||
)
|
||||
self.album_sort_direction)
|
||||
|
||||
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(
|
||||
run_action(
|
||||
self,
|
||||
'albums.set-search-query',
|
||||
AlbumSearchQuery(
|
||||
self.current_query.type,
|
||||
self.current_query.year_range,
|
||||
AlbumsPanel._Genre(genre),
|
||||
AlbumSearchQuery.Genre(genre),
|
||||
),
|
||||
"album_page": 0,
|
||||
"selected_album_id": None,
|
||||
},
|
||||
False,
|
||||
)
|
||||
self.album_sort_direction)
|
||||
|
||||
def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
|
||||
year = int(entry.get_value())
|
||||
@@ -415,27 +492,19 @@ class AlbumsPanel(Gtk.Box):
|
||||
else:
|
||||
new_year_tuple = (year, self.current_query.year_range[1])
|
||||
|
||||
self.emit_if_not_updating(
|
||||
"refresh-window",
|
||||
{
|
||||
"current_album_search_query": AlbumSearchQuery(
|
||||
run_action(
|
||||
self,
|
||||
'albums.set-search-query',
|
||||
AlbumSearchQuery(
|
||||
self.current_query.type, new_year_tuple, self.current_query.genre
|
||||
),
|
||||
"album_page": 0,
|
||||
"selected_album_id": None,
|
||||
},
|
||||
False,
|
||||
)
|
||||
self.album_sort_direction)
|
||||
|
||||
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,
|
||||
)
|
||||
run_action(self, 'albums.set-page', int(text) - 1)
|
||||
return False
|
||||
|
||||
def on_page_entry_insert_text(
|
||||
@@ -452,43 +521,18 @@ class AlbumsPanel(Gtk.Box):
|
||||
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_album_clicked(self, _:Any, child: Gtk.FlowBoxChild):
|
||||
album = self.albums[child.get_index()]
|
||||
|
||||
def on_next_page_clicked(self, _):
|
||||
self.emit_if_not_updating(
|
||||
"refresh-window",
|
||||
{"album_page": self.album_page + 1, "selected_album_id": None},
|
||||
False,
|
||||
)
|
||||
if self.get_folded() and self.get_visible_child() == self.grid_box:
|
||||
self.set_visible_child(self.album_container)
|
||||
|
||||
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)
|
||||
run_action(self, 'albums.select-album', album.id)
|
||||
|
||||
"""
|
||||
|
||||
# TODO: REMOVE
|
||||
class AlbumsGrid(Gtk.Overlay):
|
||||
"""Defines the albums panel."""
|
||||
|
||||
__gsignals__ = {
|
||||
"cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
|
||||
@@ -824,7 +868,7 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
loading=False,
|
||||
image_name="grid-artwork",
|
||||
spinner_name="grid-artwork-spinner",
|
||||
image_size=200,
|
||||
image_size=150,
|
||||
)
|
||||
widget_box.pack_start(artwork, False, False, 0)
|
||||
|
||||
@@ -967,3 +1011,4 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
self.grid_bottom.unselect_all()
|
||||
|
||||
self.currently_selected_index = selected_index
|
||||
"""
|
||||
|
@@ -49,12 +49,12 @@
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
#icon-button-box image {
|
||||
.icon-button-box image {
|
||||
margin: 5px 2px;
|
||||
min-width: 15px;
|
||||
}
|
||||
|
||||
#icon-button-box label {
|
||||
.icon-button-box label {
|
||||
margin-left: 5px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
@@ -161,12 +161,12 @@ entry.invalid {
|
||||
|
||||
/* ********** Playback Controls ********** */
|
||||
#player-controls-album-artwork {
|
||||
min-height: 70px;
|
||||
min-width: 70px;
|
||||
/*min-height: 70px;
|
||||
min-width: 70px;*/
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#player-controls-bar #play-button {
|
||||
.play-button-large {
|
||||
min-height: 45px;
|
||||
min-width: 35px;
|
||||
border-width: 1px;
|
||||
@@ -174,7 +174,7 @@ entry.invalid {
|
||||
}
|
||||
|
||||
/* Make the play icon look centered. */
|
||||
#player-controls-bar #play-button image {
|
||||
.play-button-large image {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
margin-top: 1px;
|
||||
@@ -182,7 +182,7 @@ entry.invalid {
|
||||
}
|
||||
|
||||
#player-controls-bar #song-scrubber {
|
||||
min-width: 400px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#player-controls-bar #volume-slider {
|
||||
@@ -194,6 +194,7 @@ entry.invalid {
|
||||
}
|
||||
|
||||
#player-controls-bar #song-title {
|
||||
min-width: 150px;
|
||||
margin-bottom: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -235,11 +236,6 @@ entry.invalid {
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
/* ********** General ********** */
|
||||
.menu-button {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* ********** Search ********** */
|
||||
#search-results {
|
||||
min-width: 400px;
|
||||
@@ -276,34 +272,12 @@ entry.invalid {
|
||||
}
|
||||
|
||||
/* ********** 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;
|
||||
margin-top: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#grid-info-label {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#artist-album-artwork {
|
||||
@@ -338,3 +312,7 @@ entry.invalid {
|
||||
inset 0 -5px 5px @box_shadow_color;
|
||||
background-color: @box_shadow_color;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
box-shadow: inset 5px 0 5px @box_shadow_color;
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ from typing import cast, List, Sequence
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
from ..adapters import (
|
||||
AdapterManager,
|
||||
@@ -15,41 +15,44 @@ from ..adapters import (
|
||||
)
|
||||
from ..config import AppConfiguration
|
||||
from ..ui import util
|
||||
from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
|
||||
from ..ui.common import AlbumWithSongs, IconButton, IconToggleButton, LoadError, SpinnerImage, Sizer
|
||||
from .actions import run_action
|
||||
|
||||
|
||||
class ArtistsPanel(Gtk.Paned):
|
||||
class ArtistsPanel(Handy.Leaflet):
|
||||
"""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)
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
transition_type=Handy.LeafletTransitionType.SLIDE,
|
||||
can_swipe_forward=False,
|
||||
interpolate_size=False)
|
||||
|
||||
list_sizer = Sizer(natural_width=400)
|
||||
self.artist_list = ArtistList()
|
||||
self.pack1(self.artist_list, False, False)
|
||||
list_sizer.add(self.artist_list)
|
||||
self.add(list_sizer)
|
||||
|
||||
details_sizer = Sizer(hexpand=True, natural_width=800)
|
||||
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)
|
||||
details_sizer.add(self.artist_detail_panel)
|
||||
self.add(details_sizer)
|
||||
|
||||
def artist_clicked(_):
|
||||
if self.get_folded():
|
||||
self.set_visible_child(details_sizer)
|
||||
self.artist_list.connect("artist-clicked", artist_clicked)
|
||||
|
||||
def back_clicked(_):
|
||||
self.set_visible_child(list_sizer)
|
||||
self.artist_detail_panel.connect("back-clicked", back_clicked)
|
||||
|
||||
def folded_changed(*_):
|
||||
if not self.get_folded():
|
||||
self.set_visible_child(list_sizer)
|
||||
|
||||
self.artist_detail_panel.show_mobile = self.get_folded()
|
||||
self.connect("notify::folded", folded_changed)
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.artist_list.update(app_config=app_config)
|
||||
@@ -69,6 +72,10 @@ class _ArtistModel(GObject.GObject):
|
||||
|
||||
|
||||
class ArtistList(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
"artist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
@@ -119,8 +126,9 @@ class ArtistList(Gtk.Box):
|
||||
return row
|
||||
|
||||
self.artists_store = Gio.ListStore()
|
||||
self.list = Gtk.ListBox(name="artist-list")
|
||||
self.list = Gtk.ListBox(name="artist-list", selection_mode=Gtk.SelectionMode.BROWSE)
|
||||
self.list.bind_model(self.artists_store, create_artist_row)
|
||||
self.list.connect("row-selected", lambda *_: self.emit("artist-clicked"))
|
||||
list_scroll_window.add(self.list)
|
||||
|
||||
self.pack_start(list_scroll_window, True, True, 0)
|
||||
@@ -180,24 +188,20 @@ class ArtistList(Gtk.Box):
|
||||
self.loading_indicator.hide()
|
||||
|
||||
|
||||
ARTIST_ARTWORK_SIZE_DESKTOP=200
|
||||
ARTIST_ARTWORK_SIZE_MOBILE=80
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
show_mobile = GObject.Property(type=bool, default=False)
|
||||
|
||||
update_order_token = 0
|
||||
artist_details_expanded = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(
|
||||
@@ -206,134 +210,136 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.connect("notify::show-mobile", self.on_show_mobile_changed)
|
||||
|
||||
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"
|
||||
)
|
||||
action_bar = Gtk.ActionBar()
|
||||
|
||||
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)
|
||||
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
||||
self.bind_property("show-mobile", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
|
||||
|
||||
# Action buttons, name, comment, number of songs, etc.
|
||||
artist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
back_button = IconButton("go-previous-symbolic")
|
||||
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
|
||||
back_button_revealer.add(back_button)
|
||||
|
||||
artist_details_box.pack_start(Gtk.Box(), True, False, 0)
|
||||
action_bar.pack_start(back_button_revealer)
|
||||
|
||||
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.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
|
||||
self.refresh_button.connect("clicked", self.on_view_refresh_click)
|
||||
action_bar.pack_end(self.refresh_button)
|
||||
|
||||
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)
|
||||
action_bar.pack_end(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)
|
||||
self.shuffle_button = IconButton("media-playlist-shuffle-symbolic")
|
||||
self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
|
||||
action_bar.pack_end(self.shuffle_button)
|
||||
|
||||
action_buttons_container.pack_start(
|
||||
self.artist_action_buttons, False, False, 10
|
||||
self.play_button = IconButton("media-playback-start-symbolic")
|
||||
self.play_button.connect("clicked", self.on_play_all_clicked)
|
||||
action_bar.pack_end(self.play_button)
|
||||
|
||||
self.pack_start(action_bar, False, False, 0)
|
||||
self.pack_start(Gtk.Separator(), False, False, 0)
|
||||
|
||||
self.scrolled_window = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# Artist info panel
|
||||
info_panel = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel"
|
||||
)
|
||||
|
||||
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.artist_artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_size=ARTIST_ARTWORK_SIZE_DESKTOP,
|
||||
valign=Gtk.Align.START,
|
||||
)
|
||||
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)
|
||||
info_panel.pack_start(self.artist_artwork, False, False, 10)
|
||||
|
||||
self.big_info_panel.pack_start(action_buttons_container, False, False, 5)
|
||||
# Action buttons, name, comment, number of songs, etc.
|
||||
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.pack_start(self.big_info_panel, False, True, 0)
|
||||
self.artist_name = self.make_label(
|
||||
name="artist-name", wrap=True,
|
||||
)
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
self.artist_bio_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, reveal_child=True)
|
||||
self.artist_bio = self.make_label(
|
||||
name="artist-bio", justify=Gtk.Justification.LEFT
|
||||
)
|
||||
self.artist_bio.set_line_wrap(True)
|
||||
self.artist_bio_revealer.add(self.artist_bio)
|
||||
details_box.add(self.artist_bio_revealer)
|
||||
|
||||
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
artist_stats_squeezer = Handy.Squeezer(homogeneous=False)
|
||||
|
||||
self.artist_stats_long = self.make_label(name="artist-stats")
|
||||
artist_stats_squeezer.add(self.artist_stats_long)
|
||||
|
||||
self.artist_stats_medium = self.make_label(name="artist-stats")
|
||||
artist_stats_squeezer.add(self.artist_stats_medium)
|
||||
|
||||
self.artist_stats_short = self.make_label(name="artist-stats")
|
||||
artist_stats_squeezer.add(self.artist_stats_short)
|
||||
|
||||
details_bottom_box.pack_start(artist_stats_squeezer, False, False, 0)
|
||||
|
||||
self.expand_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE, margin_left=10)
|
||||
self.expand_button = IconToggleButton(
|
||||
"pan-down-symbolic", "Expand"
|
||||
)
|
||||
self.expand_button.bind_property("active", self.artist_bio_revealer, "reveal-child")
|
||||
self.expand_button.connect("clicked", self.on_expand_button_clicked)
|
||||
self.expand_button_revealer.add(self.expand_button)
|
||||
details_bottom_box.pack_end(self.expand_button_revealer, False, False, 0)
|
||||
|
||||
details_box.pack_end(details_bottom_box, False, False, 0)
|
||||
|
||||
info_panel.pack_start(details_box, True, True, 0)
|
||||
|
||||
box.pack_start(info_panel, False, False, 0)
|
||||
|
||||
self.error_container = Gtk.Box()
|
||||
self.add(self.error_container)
|
||||
# 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),
|
||||
box.pack_start(self.albums_list, True, True, 0)
|
||||
|
||||
self.scrolled_window.add(box)
|
||||
|
||||
self.pack_start(self.scrolled_window, True, True, 0)
|
||||
|
||||
def on_show_mobile_changed(self, *_):
|
||||
self.expand_button.set_active(not self.show_mobile)
|
||||
self.artist_bio_revealer.set_reveal_child(not self.show_mobile)
|
||||
self.expand_button_revealer.set_reveal_child(self.show_mobile)
|
||||
self.artist_artwork.set_image_size(ARTIST_ARTWORK_SIZE_MOBILE if self.show_mobile else ARTIST_ARTWORK_SIZE_DESKTOP)
|
||||
|
||||
def on_expand_button_clicked(self, *_):
|
||||
up_down = "up" if self.expand_button.get_active() else "down"
|
||||
self.expand_button.set_icon(f"pan-{up_down}-symbolic")
|
||||
self.expand_button.set_tooltip_text(
|
||||
"Collapse" if self.expand_button.get_active() else "Expand"
|
||||
)
|
||||
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()
|
||||
self.shuffle_button.set_sensitive(False)
|
||||
self.play_button.set_sensitive(False)
|
||||
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,
|
||||
@@ -358,58 +364,28 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
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"
|
||||
)
|
||||
# Scroll to top
|
||||
self.scrolled_window.get_vadjustment().set_value(0)
|
||||
|
||||
self.artist_name.set_markup(bleach.clean(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))
|
||||
self.artist_stats_long.set_markup(self.format_stats(artist, short_time=False))
|
||||
self.artist_stats_medium.set_markup(self.format_stats(artist, short_time=True))
|
||||
self.artist_stats_short.set_markup(self.format_stats(artist, short_time=True, short_count=True))
|
||||
|
||||
biography = ""
|
||||
if artist.biography:
|
||||
self.artist_bio.set_markup(bleach.clean(artist.biography))
|
||||
self.artist_bio.show()
|
||||
else:
|
||||
self.artist_bio.hide()
|
||||
biography += bleach.clean(artist.biography)
|
||||
|
||||
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)
|
||||
if artist.similar_artists:
|
||||
biography += "\n\n<b>Similar Artists:</b> "
|
||||
|
||||
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()
|
||||
# TODO: Make links work
|
||||
biography += ", ".join(f"<a href=\"{a.id}\">{bleach.clean(a.name)}</a>" for a in artist.similar_artists[:6])
|
||||
|
||||
self.play_shuffle_buttons.show_all()
|
||||
self.artist_bio.set_markup(biography)
|
||||
self.expand_button.set_sensitive(bool(biography))
|
||||
|
||||
self.update_artist_artwork(
|
||||
artist.artist_image_url,
|
||||
@@ -429,11 +405,8 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
)
|
||||
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 []
|
||||
|
||||
@@ -486,10 +459,10 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
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)
|
||||
# if self.artist_details_expanded:
|
||||
# self.artist_artwork.set_image_size(300)
|
||||
# else:
|
||||
# self.artist_artwork.set_image_size(70)
|
||||
|
||||
# Event Handlers
|
||||
# =========================================================================
|
||||
@@ -503,40 +476,28 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
def on_download_all_click(self, _):
|
||||
AdapterManager.batch_download_songs(
|
||||
self.get_artist_song_ids(),
|
||||
before_download=lambda _: self.update_artist_view(
|
||||
before_download=lambda _: GLib.idle_add(
|
||||
lambda: self.update_artist_view(
|
||||
self.artist_id,
|
||||
order_token=self.update_order_token,
|
||||
)
|
||||
),
|
||||
on_song_download_complete=lambda _: self.update_artist_view(
|
||||
on_song_download_complete=lambda _: GLib.idle_add(
|
||||
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},
|
||||
)
|
||||
run_action(self, 'app.play-song', 0, songs, {"force_shuffle_state": GLib.Variant('b', 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,
|
||||
)
|
||||
song_idx = randint(0, len(songs) - 1)
|
||||
run_action(self, 'app.play-song', song_idx, songs, {"force_shuffle_state": GLib.Variant('b', True)})
|
||||
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
@@ -554,18 +515,27 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
label=text, name=name, halign=Gtk.Align.START, xalign=0, **params
|
||||
)
|
||||
|
||||
def format_stats(self, artist: API.Artist) -> str:
|
||||
def format_stats(self, artist: API.Artist, short_time=False, short_count=False) -> 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),
|
||||
)
|
||||
parts = []
|
||||
|
||||
if short_count:
|
||||
parts.append(f"{album_count}/{song_count}")
|
||||
else:
|
||||
parts.append("{} {}".format(album_count, util.pluralize("album", album_count)))
|
||||
parts.append("{} {}".format(song_count, util.pluralize("song", song_count)))
|
||||
|
||||
if short_time:
|
||||
parts.append(util.format_song_duration(duration))
|
||||
else:
|
||||
parts.append(util.format_sequence_duration(duration))
|
||||
|
||||
return util.dot_join(*parts)
|
||||
|
||||
def get_artist_song_ids(self) -> List[str]:
|
||||
try:
|
||||
@@ -592,14 +562,6 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
|
||||
|
||||
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)
|
||||
@@ -633,8 +595,8 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
|
||||
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)
|
||||
for c, album in zip(self.box.get_children(), self.albums):
|
||||
c.update(album, app_config=app_config, force=force)
|
||||
|
||||
self.spinner.hide()
|
||||
return
|
||||
@@ -644,19 +606,16 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
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 = AlbumWithSongs(show_artist_name=False)
|
||||
album_with_songs.update(album, app_config, force=force)
|
||||
# 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)
|
||||
# for c in self.box.get_children():
|
||||
# c.update(app_config=app_config)
|
||||
|
||||
self.spinner.hide()
|
||||
|
||||
|
@@ -440,7 +440,7 @@ class MusicDirectoryList(Gtk.Box):
|
||||
self.loading_indicator.hide()
|
||||
|
||||
def on_download_state_change(self, _):
|
||||
self.update()
|
||||
GLib.idle_add(self.update)
|
||||
|
||||
# Create Element Helper Functions
|
||||
# ==================================================================================
|
||||
|
@@ -1,8 +1,10 @@
|
||||
from .album_with_songs import AlbumWithSongs
|
||||
from .icon_button import IconButton, IconMenuButton, IconToggleButton
|
||||
from .load_error import LoadError
|
||||
from .sizer import Sizer
|
||||
from .song_list_column import SongListColumn
|
||||
from .spinner_image import SpinnerImage
|
||||
from .spinner_picture import SpinnerPicture
|
||||
|
||||
__all__ = (
|
||||
"AlbumWithSongs",
|
||||
@@ -10,6 +12,8 @@ __all__ = (
|
||||
"IconMenuButton",
|
||||
"IconToggleButton",
|
||||
"LoadError",
|
||||
"Sizer",
|
||||
"SongListColumn",
|
||||
"SpinnerImage",
|
||||
"SpinnerPicture",
|
||||
)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
from random import randint
|
||||
from typing import Any, cast, List
|
||||
from typing import Any, cast, List, Dict
|
||||
|
||||
from gi.repository import Gdk, GLib, GObject, Gtk, Pango
|
||||
from gi.repository import Gdk, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
from sublime_music.adapters import AdapterManager, api_objects as API, Result
|
||||
from sublime_music.config import AppConfiguration
|
||||
@@ -11,97 +11,36 @@ from .icon_button import IconButton
|
||||
from .load_error import LoadError
|
||||
from .song_list_column import SongListColumn
|
||||
from .spinner_image import SpinnerImage
|
||||
from ..actions import run_action
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
show_back_button = GObject.Property(type=bool, default=False)
|
||||
|
||||
album = None
|
||||
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
|
||||
cover_art_result = None
|
||||
|
||||
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 __init__(self, show_artist_name: bool = True, scroll_contents: bool = False, **kwargs):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
def cover_art_future_done(f: Result):
|
||||
artist_artwork.set_from_file(f.result())
|
||||
artist_artwork.set_loading(False)
|
||||
self.show_artist_name = show_artist_name
|
||||
|
||||
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)
|
||||
)
|
||||
action_bar = Gtk.ActionBar()
|
||||
|
||||
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
||||
self.bind_property("show-back-button", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
back_button = IconButton("go-previous-symbolic")
|
||||
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
|
||||
back_button_revealer.add(back_button)
|
||||
|
||||
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)
|
||||
action_bar.pack_start(back_button_revealer)
|
||||
|
||||
self.download_all_btn = IconButton(
|
||||
"folder-download-symbolic",
|
||||
@@ -109,30 +48,112 @@ class AlbumWithSongs(Gtk.Box):
|
||||
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)
|
||||
action_bar.pack_end(self.download_all_btn)
|
||||
|
||||
album_details.add(album_title_and_buttons)
|
||||
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,
|
||||
)
|
||||
action_bar.pack_end(self.add_to_queue_btn)
|
||||
|
||||
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,
|
||||
]
|
||||
self.play_next_btn = IconButton(
|
||||
"queue-front-symbolic",
|
||||
"Play all of the songs in this album next",
|
||||
sensitive=False,
|
||||
)
|
||||
action_bar.pack_end(self.play_next_btn)
|
||||
|
||||
album_details.add(
|
||||
Gtk.Label(
|
||||
label=util.dot_join(*stats),
|
||||
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)
|
||||
action_bar.pack_end(self.shuffle_btn)
|
||||
|
||||
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)
|
||||
action_bar.pack_end(self.play_btn)
|
||||
|
||||
self.pack_start(action_bar, False, False, 0)
|
||||
|
||||
if scroll_contents:
|
||||
self.pack_start(Gtk.Separator(), False, False, 0)
|
||||
scrolled_window = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
self.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
contents_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
scrolled_window.add(contents_box)
|
||||
else:
|
||||
contents_box = self
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.artist_artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_name="artist-album-list-artwork",
|
||||
spinner_name="artist-artwork-spinner",
|
||||
image_size=80,
|
||||
)
|
||||
# Account for 10px margin on all sides with "+ 20".
|
||||
# self.artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
|
||||
box.pack_start(self.artist_artwork, False, False, 0)
|
||||
|
||||
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.title = Gtk.Label(
|
||||
name="artist-album-list-album-name",
|
||||
halign=Gtk.Align.START,
|
||||
wrap=True,
|
||||
)
|
||||
album_details.pack_start(self.title, False, False, 0)
|
||||
|
||||
self.artist_and_year = Gtk.Label(
|
||||
halign=Gtk.Align.START,
|
||||
margin_left=10,
|
||||
margin_right=10,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
)
|
||||
album_details.pack_start(self.artist_and_year, False, False, 0)
|
||||
|
||||
squeezer = Handy.Squeezer(homogeneous=False)
|
||||
|
||||
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.genre_and_song_count_long = Gtk.Label()
|
||||
details_bottom_box.pack_start(self.genre_and_song_count_long, False, False, 10)
|
||||
|
||||
self.song_duration_long = Gtk.Label(halign=Gtk.Align.END)
|
||||
details_bottom_box.pack_end(self.song_duration_long, False, False, 10)
|
||||
|
||||
squeezer.add(details_bottom_box)
|
||||
|
||||
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.genre_and_song_count_short = Gtk.Label(ellipsize=Pango.EllipsizeMode.END)
|
||||
details_bottom_box.pack_start(self.genre_and_song_count_short, False, False, 10)
|
||||
|
||||
self.song_duration_short = Gtk.Label(halign=Gtk.Align.END)
|
||||
details_bottom_box.pack_end(self.song_duration_short, False, False, 10)
|
||||
|
||||
squeezer.add(details_bottom_box)
|
||||
|
||||
album_details.pack_start(squeezer, False, False, 0)
|
||||
|
||||
self.loading_indicator_container = Gtk.Box()
|
||||
album_details.add(self.loading_indicator_container)
|
||||
album_details.pack_start(self.loading_indicator_container, False, False, 0)
|
||||
|
||||
self.error_container = Gtk.Box()
|
||||
album_details.add(self.error_container)
|
||||
album_details.pack_start(self.error_container, False, False, 0)
|
||||
|
||||
box.pack_start(album_details, True, True, 0)
|
||||
|
||||
contents_box.pack_start(box, False, False, 0)
|
||||
|
||||
# clickable, cache status, title, duration, song ID
|
||||
self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
|
||||
@@ -147,8 +168,7 @@ class AlbumWithSongs(Gtk.Box):
|
||||
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])
|
||||
selection.set_mode(Gtk.SelectionMode.SINGLE)
|
||||
|
||||
# Song status column.
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
@@ -165,28 +185,23 @@ class AlbumWithSongs(Gtk.Box):
|
||||
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)
|
||||
contents_box.pack_start(self.album_songs, True, True, 0)
|
||||
|
||||
# Event Handlers
|
||||
# =========================================================================
|
||||
def on_song_selection_change(self, event: Any):
|
||||
if not self.album_songs.has_focus():
|
||||
self.emit("song-selected")
|
||||
def on_song_selection_change(self, selection: Gtk.TreeSelection):
|
||||
paths = selection.get_selected_rows()[1]
|
||||
if not paths:
|
||||
return
|
||||
|
||||
assert len(paths) == 1
|
||||
self.play_song(paths[0].get_indices()[0], {})
|
||||
|
||||
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],
|
||||
{},
|
||||
)
|
||||
|
||||
self.play_song(idx.get_indices()[0], {})
|
||||
|
||||
def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
@@ -198,7 +213,7 @@ class AlbumWithSongs(Gtk.Box):
|
||||
allow_deselect = False
|
||||
|
||||
def on_download_state_change(song_id: str):
|
||||
self.update_album_songs(self.album.id)
|
||||
GLib.idle_add(lambda: 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.
|
||||
@@ -230,34 +245,28 @@ class AlbumWithSongs(Gtk.Box):
|
||||
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(),
|
||||
before_download=lambda _: GLib.idle_add(self.update),
|
||||
on_song_download_complete=lambda _: GLib.idle_add(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},
|
||||
)
|
||||
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', 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},
|
||||
)
|
||||
self.play_song(randint(0, len(self.album_song_store) - 1),
|
||||
{"force_shuffle_state": GLib.Variant('b', True)})
|
||||
|
||||
def play_song(self, index: int, metadata: Dict[str, GLib.Variant]):
|
||||
run_action(self, 'app.play-song', index, [m[-1] for m in self.album_song_store], metadata)
|
||||
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
def deselect_all(self):
|
||||
self.album_songs.get_selection().unselect_all()
|
||||
|
||||
def update(self, app_config: AppConfiguration = None, force: bool = False):
|
||||
def update(self, album: API.Album, app_config: AppConfiguration, force: bool = False):
|
||||
update_songs = False
|
||||
|
||||
if app_config:
|
||||
# Deselect everything and reset the error container if switching between
|
||||
# online and offline.
|
||||
@@ -266,8 +275,52 @@ class AlbumWithSongs(Gtk.Box):
|
||||
for c in self.error_container.get_children():
|
||||
self.error_container.remove(c)
|
||||
|
||||
update_songs = True
|
||||
|
||||
self.offline_mode = app_config.offline_mode
|
||||
|
||||
if album != self.album:
|
||||
self.album = album
|
||||
|
||||
self.title.set_label(album.name)
|
||||
|
||||
artist = album.artist.name if self.show_artist_name and album.artist else None
|
||||
self.artist_and_year.set_label(util.dot_join(artist, album.year))
|
||||
|
||||
self.genre_and_song_count_long.set_label(util.dot_join(
|
||||
f"{album.song_count} " + util.pluralize("song", album.song_count),
|
||||
album.genre.name if album.genre else None))
|
||||
|
||||
self.genre_and_song_count_short.set_label(util.dot_join(
|
||||
f"{album.song_count}",
|
||||
album.genre.name if album.genre else None))
|
||||
|
||||
self.song_duration_long.set_label(
|
||||
util.format_sequence_duration(album.duration) if album.duration else "")
|
||||
|
||||
self.song_duration_short.set_label(
|
||||
util.format_song_duration(album.duration) if album.duration else "")
|
||||
|
||||
if self.cover_art_result is not None:
|
||||
self.cover_art_result.cancel()
|
||||
|
||||
def cover_art_future_done(f: Result):
|
||||
self.artist_artwork.set_from_file(f.result())
|
||||
self.artist_artwork.set_loading(False)
|
||||
self.cover_art_result = None
|
||||
|
||||
self.cover_art_result = AdapterManager.get_cover_art_uri(
|
||||
album.cover_art,
|
||||
"file",
|
||||
before_download=lambda: self.artist_artwork.set_loading(True),
|
||||
)
|
||||
self.cover_art_result.add_done_callback(
|
||||
lambda f: GLib.idle_add(cover_art_future_done, f)
|
||||
)
|
||||
|
||||
update_songs = True
|
||||
|
||||
if update_songs and self.album:
|
||||
self.update_album_songs(self.album.id, app_config=app_config, force=force)
|
||||
|
||||
def set_loading(self, loading: bool):
|
||||
@@ -346,8 +399,8 @@ class AlbumWithSongs(Gtk.Box):
|
||||
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")
|
||||
self.play_next_btn.set_action_name("app.queue-next-songs")
|
||||
self.add_to_queue_btn.set_action_name("app.queue-songs")
|
||||
else:
|
||||
self.play_next_btn.set_action_name("")
|
||||
self.add_to_queue_btn.set_action_name("")
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gtk, GObject
|
||||
|
||||
|
||||
class IconButton(Gtk.Button):
|
||||
@@ -16,8 +16,10 @@ class IconButton(Gtk.Button):
|
||||
Gtk.Button.__init__(self, **kwargs)
|
||||
|
||||
self.icon_size = icon_size
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
box.get_style_context().add_class("icon-button-box")
|
||||
|
||||
self._icon_name = icon_name
|
||||
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
|
||||
box.pack_start(self.image, False, False, 0)
|
||||
|
||||
@@ -30,7 +32,21 @@ class IconButton(Gtk.Button):
|
||||
self.add(box)
|
||||
self.set_tooltip_text(tooltip_text)
|
||||
|
||||
# TODO: Remove
|
||||
def set_icon(self, icon_name: Optional[str]):
|
||||
self.icon_name = icon_name
|
||||
# self.image.set_from_icon_name(icon_name, self.icon_size)
|
||||
|
||||
@GObject.Property(type=str)
|
||||
def icon_name(self):
|
||||
return self._icon_name
|
||||
|
||||
@icon_name.setter
|
||||
def icon_name(self, icon_name):
|
||||
if icon_name == self._icon_name:
|
||||
return
|
||||
|
||||
self._icon_name = icon_name
|
||||
self.image.set_from_icon_name(icon_name, self.icon_size)
|
||||
|
||||
|
||||
@@ -46,8 +62,10 @@ class IconToggleButton(Gtk.ToggleButton):
|
||||
):
|
||||
Gtk.ToggleButton.__init__(self, **kwargs)
|
||||
self.icon_size = icon_size
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
box.get_style_context().add_class("icon-button-box")
|
||||
|
||||
self._icon_name = icon_name
|
||||
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
|
||||
box.add(self.image)
|
||||
|
||||
@@ -60,15 +78,22 @@ class IconToggleButton(Gtk.ToggleButton):
|
||||
self.add(box)
|
||||
self.set_tooltip_text(tooltip_text)
|
||||
|
||||
# TODO: Remove
|
||||
def set_icon(self, icon_name: Optional[str]):
|
||||
self.icon_name = icon_name
|
||||
|
||||
@GObject.Property(type=str)
|
||||
def icon_name(self):
|
||||
return self._icon_name
|
||||
|
||||
@icon_name.setter
|
||||
def icon_name(self, icon_name):
|
||||
if icon_name == self._icon_name:
|
||||
return
|
||||
|
||||
self._icon_name = icon_name
|
||||
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__(
|
||||
@@ -88,7 +113,8 @@ class IconMenuButton(Gtk.MenuButton):
|
||||
self.set_popover(popover)
|
||||
|
||||
self.icon_size = icon_size
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
box.get_style_context().add_class("icon-button-box")
|
||||
|
||||
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
|
||||
box.add(self.image)
|
||||
|
44
sublime_music/ui/common/sizer.py
Normal file
44
sublime_music/ui/common/sizer.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from gi.repository import Gtk, GObject
|
||||
|
||||
class Sizer(Gtk.Bin):
|
||||
""" A widget that lets you control the natural size like the size request """
|
||||
|
||||
natural_width = GObject.Property(type=int, default=0)
|
||||
natural_height = GObject.Property(type=int, default=0)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
Gtk.Bin.__init__(self, **kwargs)
|
||||
|
||||
def do_get_preferred_width(self) -> (int, int):
|
||||
minimum, natural = Gtk.Bin.do_get_preferred_width(self)
|
||||
|
||||
if self.natural_width > 0:
|
||||
natural = max(minimum, self.natural_width)
|
||||
|
||||
return (minimum, natural)
|
||||
|
||||
def do_get_preferred_height(self) -> (int, int):
|
||||
minimum, natural = Gtk.Bin.do_get_preferred_height(self)
|
||||
|
||||
if self.natural_height > 0:
|
||||
natural = max(minimum, self.natural_height)
|
||||
|
||||
return (minimum, natural)
|
||||
|
||||
def do_get_preferred_width_for_height(self, height: int) -> (int, int):
|
||||
minimum, natural = Gtk.Bin.do_get_preferred_width_for_height(self, height)
|
||||
|
||||
if self.natural_width > 0:
|
||||
natural = max(minimum, self.natural_width)
|
||||
|
||||
return (minimum, natural)
|
||||
|
||||
def do_get_preferred_height_for_width(self, width: int) -> (int, int):
|
||||
minimum, natural = Gtk.Bin.do_get_preferred_height_for_width(self, width)
|
||||
|
||||
if self.natural_height > 0:
|
||||
natural = max(minimum, self.natural_height)
|
||||
|
||||
return (minimum, natural)
|
||||
|
||||
|
81
sublime_music/ui/common/spinner_picture.py
Normal file
81
sublime_music/ui/common/spinner_picture.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from typing import Optional
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk
|
||||
|
||||
|
||||
class SpinnerPicture(Gtk.Bin):
|
||||
def __init__(
|
||||
self,
|
||||
loading: bool = True,
|
||||
spinner_name: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""An picture with a loading overlay."""
|
||||
super().__init__()
|
||||
self.filename: Optional[str] = None
|
||||
self.pixbuf = None
|
||||
|
||||
self.offset = (0, 0)
|
||||
self.sized_pixbuf = None
|
||||
|
||||
self.spinner = Gtk.Spinner(
|
||||
name=spinner_name,
|
||||
active=loading,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self.add(self.spinner)
|
||||
|
||||
def set_from_file(self, filename: Optional[str]):
|
||||
"""Set the picture to the given filename."""
|
||||
filename = filename or None
|
||||
if self.filename == filename:
|
||||
return
|
||||
|
||||
self.filename = filename
|
||||
|
||||
if self.filename:
|
||||
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.filename)
|
||||
else:
|
||||
self.pixbuf = None
|
||||
self._update_sized_pixbuf()
|
||||
|
||||
def set_loading(self, loading_status: bool):
|
||||
if loading_status:
|
||||
self.spinner.start()
|
||||
self.spinner.show()
|
||||
else:
|
||||
self.spinner.stop()
|
||||
self.spinner.hide()
|
||||
|
||||
def do_size_allocate(self, allocation):
|
||||
Gtk.Bin.do_size_allocate(self, allocation)
|
||||
|
||||
self._update_sized_pixbuf()
|
||||
|
||||
def do_draw(self, cr):
|
||||
if self.sized_pixbuf:
|
||||
Gdk.cairo_set_source_pixbuf(cr, self.sized_pixbuf, self.offset[0], self.offset[1])
|
||||
cr.paint()
|
||||
|
||||
Gtk.Bin.do_draw(self, cr)
|
||||
|
||||
def _update_sized_pixbuf(self):
|
||||
if self.pixbuf is None:
|
||||
self.sized_pixbuf = None
|
||||
return
|
||||
|
||||
pix_width = self.pixbuf.get_width()
|
||||
pix_height = self.pixbuf.get_height()
|
||||
alloc_width = self.get_allocated_width()
|
||||
alloc_height = self.get_allocated_height()
|
||||
|
||||
scale = max(alloc_width / pix_width, alloc_height / pix_height)
|
||||
scaled_width = pix_width * scale
|
||||
scaled_height = pix_height * scale
|
||||
self.sized_pixbuf = self.pixbuf.scale_simple(scaled_width, scaled_height, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
self.offset = (
|
||||
(alloc_width - scaled_width) / 2,
|
||||
(alloc_height - scaled_height) / 2,
|
||||
)
|
@@ -1,228 +0,0 @@
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from gi.repository import Gio, GObject, Gtk, Pango
|
||||
|
||||
from ..adapters import AdapterManager, UIInfo
|
||||
from ..adapters.filesystem import FilesystemAdapter
|
||||
from ..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()
|
File diff suppressed because it is too large
Load Diff
@@ -1,845 +0,0 @@
|
||||
import copy
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
import bleach
|
||||
|
||||
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(
|
||||
bleach.clean(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(bleach.clean(album.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.album_name.set_markup("")
|
||||
self.album_name.hide()
|
||||
if artist:
|
||||
self.artist_name.set_markup(bleach.clean(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
|
||||
|
||||
# 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 = song_details.title
|
||||
# TODO (#71): use walrus once MYPY gets its act together
|
||||
# album = a.name if (a := song_details.album) else None
|
||||
# artist = a.name if (a := song_details.artist) else None
|
||||
album = song_details.album.name if song_details.album else None
|
||||
artist = song_details.artist.name if song_details.artist else None
|
||||
return bleach.clean(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
|
||||
|
||||
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
|
||||
|
||||
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),
|
||||
)
|
||||
):
|
||||
song_details_result = AdapterManager.get_song_details(song_id)
|
||||
|
||||
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))
|
||||
|
||||
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,
|
||||
]
|
||||
)
|
||||
|
||||
util.diff_song_store(self.play_queue_store, new_store)
|
||||
|
||||
# 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
|
3
sublime_music/ui/player_controls/__init__.py
Normal file
3
sublime_music/ui/player_controls/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .desktop import Desktop
|
||||
from .mobile import MobileHandle, MobileFlap
|
||||
from .manager import Manager
|
191
sublime_music/ui/player_controls/common.py
Normal file
191
sublime_music/ui/player_controls/common.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from typing import List, Any
|
||||
|
||||
from .. import util
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
|
||||
from .manager import Manager
|
||||
from ...util import resolve_path
|
||||
from ..actions import run_action
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
def create_play_button(manager: Manager, large=True, **kwargs):
|
||||
button = IconButton(
|
||||
"media-playback-start-symbolic",
|
||||
"Play",
|
||||
relief=large,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if large:
|
||||
button.get_style_context().add_class("play-button-large")
|
||||
|
||||
button.set_action_name("app.play-pause")
|
||||
manager.bind_property("has-song", button, "sensitive")
|
||||
manager.bind_property("play-button-icon", button, "icon-name")
|
||||
manager.bind_property("play-button-tooltip", button, "tooltip-text")
|
||||
|
||||
return button
|
||||
|
||||
def create_repeat_button(manager: Manager, **kwargs):
|
||||
button = IconToggleButton("media-playlist-repeat", "Switch between repeat modes", **kwargs)
|
||||
button.set_action_name("app.repeat")
|
||||
manager.bind_property("repeat-button-icon", button, "icon-name")
|
||||
|
||||
def on_active_change(*_):
|
||||
if manager.repeat_button_active != button.get_active():
|
||||
# Don't run the action, just update visual state
|
||||
button.set_action_name(None)
|
||||
button.set_active(manager.repeat_button_active)
|
||||
button.set_action_name("app.repeat")
|
||||
manager.connect("notify::repeat-button-active", on_active_change)
|
||||
|
||||
return button
|
||||
|
||||
def create_shuffle_button(manager: Manager, **kwargs):
|
||||
button = IconToggleButton("media-playlist-shuffle-symbolic", "Toggle playlist shuffling", **kwargs)
|
||||
button.set_action_name("app.shuffle")
|
||||
|
||||
def on_active_change(*_):
|
||||
if manager.shuffle_button_active != button.get_active():
|
||||
# Don't run the action, just update visual state
|
||||
button.set_action_name(None)
|
||||
button.set_active(manager.shuffle_button_active)
|
||||
button.set_action_name("app.shuffle")
|
||||
manager.connect("notify::shuffle-button-active", on_active_change)
|
||||
|
||||
return button
|
||||
|
||||
def create_next_button(manager: Manager, **kwargs):
|
||||
button = IconButton(
|
||||
"media-skip-forward-symbolic",
|
||||
"Go to next song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
**kwargs,
|
||||
)
|
||||
button.set_action_name("app.next-track")
|
||||
manager.bind_property("has-song", button, "sensitive")
|
||||
|
||||
return button
|
||||
|
||||
def create_prev_button(manager: Manager, **kwargs):
|
||||
button = IconButton(
|
||||
"media-skip-backward-symbolic",
|
||||
"Go to previous song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
**kwargs,
|
||||
)
|
||||
button.set_action_name("app.prev-track")
|
||||
manager.bind_property("has-song", button, "sensitive")
|
||||
|
||||
return button
|
||||
|
||||
def create_label(manager: Manager, property: str, **kwargs):
|
||||
label = Gtk.Label(**kwargs)
|
||||
manager.bind_property(property, label, "label")
|
||||
return label
|
||||
|
||||
def show_play_queue_popover(manager: Manager, relative_to, paths: List[str], pos_x: int, pos_y: int):
|
||||
song_ids = [manager.play_queue_store[p][-1] for p in paths]
|
||||
|
||||
remove_text = (
|
||||
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
|
||||
)
|
||||
|
||||
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.
|
||||
manager.emit("refresh-window", {}, False)
|
||||
|
||||
def on_remove_songs_click(_: Any):
|
||||
manager.emit("songs-removed", [p.get_indices()[0] for p in paths])
|
||||
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
pos_x,
|
||||
pos_y,
|
||||
relative_to,
|
||||
manager.offline_mode,
|
||||
on_download_state_change=on_download_state_change,
|
||||
extra_menu_items=[
|
||||
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
|
||||
],
|
||||
)
|
||||
|
||||
# Album Art column. This function defines what image to use for the play queue
|
||||
# song icon.
|
||||
def filename_to_pixbuf(manager: Manager):
|
||||
def f2p(
|
||||
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)
|
||||
playing = model.get_value(tree_iter, 3)
|
||||
|
||||
pixbuf = manager.get_play_queue_cover_art(filename, playing)
|
||||
if not pixbuf:
|
||||
cell.set_property("icon_name", "")
|
||||
else:
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
return f2p
|
||||
|
||||
|
||||
def create_play_queue_list(manager: Manager):
|
||||
view = Gtk.TreeView(
|
||||
model=manager.play_queue_store,
|
||||
reorderable=True,
|
||||
headers_visible=False,
|
||||
)
|
||||
view.get_style_context().add_class("background")
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(55, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, filename_to_pixbuf(manager))
|
||||
column.set_resizable(True)
|
||||
view.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
|
||||
column.set_expand(True)
|
||||
view.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
|
||||
renderer.set_fixed_size(45, 60)
|
||||
view_more_column = Gtk.TreeViewColumn("", renderer)
|
||||
view_more_column.set_resizable(True)
|
||||
view.append_column(view_more_column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(icon_name="window-close-symbolic")
|
||||
renderer.set_fixed_size(45, 60)
|
||||
close_column = Gtk.TreeViewColumn("", renderer)
|
||||
close_column.set_resizable(True)
|
||||
view.append_column(close_column)
|
||||
|
||||
def on_play_queue_button_press(tree: Any, event: Gdk.EventButton) -> bool:
|
||||
if event.button == 1:
|
||||
path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y)
|
||||
song_idx = path.get_indices()[0]
|
||||
|
||||
tree_width = tree.get_allocation().width
|
||||
|
||||
if column == close_column:
|
||||
# manager.emit("songs-removed", [song_idx])
|
||||
pass
|
||||
elif column == view_more_column:
|
||||
area = tree.get_cell_area(path, view_more_column)
|
||||
x = area.x + area.width / 2
|
||||
y = area.y + area.height / 2
|
||||
show_play_queue_popover(manager, view, [path], x, y)
|
||||
else:
|
||||
run_action(view, 'app.play-song', song_idx, [], {"no_reshuffle": None})
|
||||
|
||||
return True
|
||||
return False
|
||||
view.connect("button-press-event", on_play_queue_button_press)
|
||||
return view
|
402
sublime_music/ui/player_controls/desktop.py
Normal file
402
sublime_music/ui/player_controls/desktop.py
Normal file
@@ -0,0 +1,402 @@
|
||||
import copy
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
import bleach
|
||||
|
||||
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
|
||||
from . import common
|
||||
|
||||
|
||||
class Desktop(Gtk.Box):
|
||||
"""
|
||||
Defines the player controls panel that appears at the bottom of the window
|
||||
on the desktop view.
|
||||
"""
|
||||
|
||||
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, ...] = ()
|
||||
# play_queue_update_order_token = 0
|
||||
offline_mode = False
|
||||
|
||||
def __init__(self, state):
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.set_name("player-controls-bar")
|
||||
|
||||
self.state = state
|
||||
self.state.add_control(self)
|
||||
|
||||
song_display = self.create_song_display()
|
||||
playback_controls = self.create_playback_controls()
|
||||
play_queue_volume = self.create_play_queue_volume()
|
||||
|
||||
self.pack_start(song_display, False, False, 0)
|
||||
self.set_center_widget(playback_controls)
|
||||
self.child_set_property(playback_controls, "expand", True)
|
||||
self.pack_end(play_queue_volume, False, False, 0)
|
||||
|
||||
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
|
||||
|
||||
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.song_scrubber.set_sensitive(has_current_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")
|
||||
|
||||
self.play_queue_button.set_sensitive(len(app_config.state.play_queue) > 0)
|
||||
|
||||
# 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.volume_slider.set_value(
|
||||
0 if app_config.state.is_muted else app_config.state.volume
|
||||
)
|
||||
self.volume_slider.set_sensitive(not app_config.state.is_muted)
|
||||
|
||||
# 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.song_title.set_markup(bleach.clean(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(bleach.clean(album.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.album_name.set_markup("")
|
||||
self.album_name.hide()
|
||||
if artist:
|
||||
self.artist_name.set_markup(bleach.clean(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.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
|
||||
|
||||
# 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
|
||||
|
||||
# self.editing_play_queue_song_list = False
|
||||
|
||||
def set_cover_art(self, cover_art_filename: str, loading: bool):
|
||||
self.album_art.set_from_file(cover_art_filename)
|
||||
self.album_art.set_loading(loading)
|
||||
|
||||
def on_play_queue_open(self, *_):
|
||||
if not self.get_child_visible():
|
||||
self.play_queue_popover.popdown()
|
||||
return
|
||||
|
||||
if not self.state.play_queue_open:
|
||||
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
|
||||
|
||||
self.get_action_group('app').activate_action(
|
||||
'play-song',
|
||||
idx.get_indices()[0],
|
||||
None,
|
||||
{"no_reshuffle": True})
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
common.show_play_queue_popover(self.state, tree, paths, event.x, event.y)
|
||||
|
||||
# If the click was on a selected row, don't deselect anything.
|
||||
if not allow_deselect:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
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)
|
||||
|
||||
scrubber_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
|
||||
|
||||
self.song_scrubber = Gtk.Scale(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
adjustment=self.state.scrubber,
|
||||
hexpand=True,
|
||||
draw_value=False,
|
||||
restrict_to_fill_level=False,
|
||||
show_fill_level=True)
|
||||
self.song_scrubber.set_name("song-scrubber")
|
||||
self.state.bind_property("scrubber-cache", self.song_scrubber, "fill-level")
|
||||
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
|
||||
|
||||
scrubber_box.pack_start(common.create_label(self.state, "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 = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(repeat_button, False, False, 5)
|
||||
|
||||
# Previous button
|
||||
prev_button = common.create_prev_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(prev_button, False, False, 5)
|
||||
|
||||
buttons.pack_start(common.create_play_button(self.state), False, False, 0)
|
||||
|
||||
# Next button
|
||||
next_button = common.create_next_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(next_button, False, False, 5)
|
||||
|
||||
# Shuffle button
|
||||
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(shuffle_button, 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.state.popup_devices)
|
||||
box.pack_start(self.device_button, False, True, 5)
|
||||
|
||||
# Play Queue button
|
||||
self.play_queue_button = IconToggleButton(
|
||||
"view-list-symbolic",
|
||||
"Open play queue",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.state.bind_property("play-queue-open", self.play_queue_button, "active", GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.state.connect("notify::play-queue-open", self.on_play_queue_open)
|
||||
box.pack_start(self.play_queue_button, False, True, 5)
|
||||
|
||||
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover", constrain_to=Gtk.PopoverConstraint.WINDOW)
|
||||
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,
|
||||
propagate_natural_height=True,
|
||||
)
|
||||
|
||||
self.play_queue_list = common.create_play_queue_list(self.state)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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.toggle-mute")
|
||||
box.pack_start(self.volume_mute_toggle, False, True, 0)
|
||||
|
||||
# Volume slider
|
||||
self.volume_slider = Gtk.Scale.new(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.state.volume)
|
||||
self.volume_slider.set_name("volume-slider")
|
||||
self.volume_slider.set_draw_value(False)
|
||||
|
||||
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
|
444
sublime_music/ui/player_controls/manager.py
Normal file
444
sublime_music/ui/player_controls/manager.py
Normal file
@@ -0,0 +1,444 @@
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional, Callable, Dict, Set, Tuple
|
||||
from functools import partial
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import GObject, Gtk, GLib, GdkPixbuf
|
||||
|
||||
from .. import util
|
||||
from ...util import resolve_path
|
||||
from ...adapters import AdapterManager, Result
|
||||
from ...adapters.api_objects import Song
|
||||
from ...config import AppConfiguration
|
||||
from ..state import RepeatType
|
||||
from ..actions import run_action
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
|
||||
|
||||
class Manager(GObject.GObject):
|
||||
"""
|
||||
Common state for player controls.
|
||||
"""
|
||||
|
||||
volume = GObject.Property(type=Gtk.Adjustment)
|
||||
scrubber = GObject.Property(type=Gtk.Adjustment)
|
||||
scrubber_cache = GObject.Property(type=int, default=0)
|
||||
play_queue_open = GObject.Property(type=bool, default=False)
|
||||
play_queue_store = GObject.Property(type=Gtk.ListStore)
|
||||
flap_reveal_progress = GObject.Property(type=float, default=False)
|
||||
offline_mode = GObject.Property(type=bool, default=False)
|
||||
|
||||
has_song = GObject.Property(type=bool, default=False)
|
||||
play_button_icon = GObject.Property(type=str)
|
||||
play_button_tooltip = GObject.Property(type=str)
|
||||
repeat_button_icon = GObject.Property(type=str)
|
||||
repeat_button_active = GObject.Property(type=bool, default=False)
|
||||
shuffle_button_active = GObject.Property(type=bool, default=False)
|
||||
duration_label = GObject.Property(type=str, default="-:--")
|
||||
progress_label = GObject.Property(type=str, default="-:--")
|
||||
|
||||
updating_scrubber = False
|
||||
cover_art_update_order_token = 0
|
||||
play_queue_update_order_token = 0
|
||||
|
||||
current_song_index = None
|
||||
|
||||
_updating = False
|
||||
_updating_song_progress = False
|
||||
|
||||
def __init__(self, widget):
|
||||
super().__init__()
|
||||
|
||||
self.widget = widget
|
||||
|
||||
self.volume = Gtk.Adjustment.new(100, 0, 100, 1, 0, 0)
|
||||
self.volume.connect('value-changed', self.on_volume_changed)
|
||||
|
||||
self.scrubber = Gtk.Adjustment()
|
||||
self.scrubber.set_step_increment(1)
|
||||
self.scrubber.connect("value-changed", self.on_scrubber_changed)
|
||||
self.scrubber.connect("changed", self.update_duration_label)
|
||||
|
||||
self.play_queue_store = Gtk.ListStore(
|
||||
bool, # playable
|
||||
str, # image filename
|
||||
str, # title, album, artist
|
||||
bool, # playing
|
||||
str, # song ID
|
||||
)
|
||||
self.play_queue_store_art_cache = {}
|
||||
|
||||
# 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)
|
||||
|
||||
self._controls = []
|
||||
|
||||
# Device popover
|
||||
self.device_popover = Gtk.PopoverMenu(modal=True, name="device-popover")
|
||||
|
||||
device_popover_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name="device-popover-box",
|
||||
)
|
||||
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
popover_label = Gtk.Label(
|
||||
label="<b>Devices</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=5,
|
||||
)
|
||||
device_popover_header.add(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)
|
||||
|
||||
def add_control(self, control):
|
||||
self._controls.append(control)
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self._updating = True
|
||||
|
||||
self.volume.set_value(app_config.state.volume)
|
||||
|
||||
self.has_song = app_config.state.current_song is not None
|
||||
self.current_song_index = app_config.state.current_song_index
|
||||
|
||||
self.update_song_progress(
|
||||
app_config.state.song_progress,
|
||||
app_config.state.current_song and app_config.state.current_song.duration,
|
||||
app_config.state.song_stream_cache_progress)
|
||||
|
||||
if self.has_song:
|
||||
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)
|
||||
|
||||
for control in self._controls:
|
||||
control.update(app_config, force=force)
|
||||
|
||||
if not self.has_song:
|
||||
self.set_cover_art(None, False)
|
||||
|
||||
self.update_player_queue(app_config)
|
||||
self.update_device_list(app_config)
|
||||
|
||||
icon = "pause" if app_config.state.playing else "start"
|
||||
self.play_button_icon = f"media-playback-{icon}-symbolic"
|
||||
self.play_button_tooltip = "Pause" if app_config.state.playing else "Play"
|
||||
|
||||
repeat_on = app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
)
|
||||
self.repeat_button_icon = app_config.state.repeat_type.icon
|
||||
self.repeat_button_active = repeat_on
|
||||
|
||||
self.shuffle_button_active = app_config.state.shuffle_on
|
||||
|
||||
self._updating = False
|
||||
|
||||
def update_song_progress(self,
|
||||
progress: Optional[timedelta],
|
||||
duration: Optional[timedelta],
|
||||
cache_progess: Optional[timedelta]):
|
||||
self._updating_song_progress = True
|
||||
|
||||
if progress is None or duration is None:
|
||||
self.scrubber.set_value(0)
|
||||
self.scrubber.set_upper(0)
|
||||
self.scrubber_cache = 0
|
||||
self._updating_song_progress = False
|
||||
return
|
||||
|
||||
self.scrubber.set_value(progress.total_seconds())
|
||||
self.scrubber.set_upper(duration.total_seconds())
|
||||
if cache_progess is not None:
|
||||
self.scrubber_cache = cache_progess.total_seconds()
|
||||
else:
|
||||
self.scrubber_cache = duration.total_seconds()
|
||||
|
||||
self._updating_song_progress = False
|
||||
|
||||
@util.async_callback(
|
||||
partial(AdapterManager.get_cover_art_uri, scheme="file"),
|
||||
before_download=lambda self: self.set_cover_art(None, True),
|
||||
on_failure=lambda self, e: self.set_cover_art(None, 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.set_cover_art(cover_art_filename, False)
|
||||
|
||||
def set_cover_art(self, cover_art_filename: Optional[str], loading: bool):
|
||||
for control in self._controls:
|
||||
control.set_cover_art(cover_art_filename, loading)
|
||||
|
||||
def get_play_queue_cover_art(self, filename: Optional[str], playing: bool):
|
||||
if filename in self.play_queue_store_art_cache:
|
||||
return self.play_queue_store_art_cache[filename]
|
||||
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
|
||||
|
||||
# If this is the playing song, then overlay the play icon.
|
||||
if playing:
|
||||
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
|
||||
)
|
||||
|
||||
self.play_queue_store_art_cache[filename] = pixbuf
|
||||
return pixbuf
|
||||
|
||||
def on_play_queue_model_row_move(self, *args):
|
||||
self.play_queue_store_art_cache = {}
|
||||
|
||||
# TODO
|
||||
return
|
||||
|
||||
# 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.state.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 update_player_queue(self, app_config: AppConfiguration):
|
||||
new_store = []
|
||||
|
||||
def calculate_label(song_details: Song) -> str:
|
||||
title = bleach.clean(song_details.title)
|
||||
# TODO (#71): use walrus once MYPY works with this
|
||||
# album = bleach.clean(album.name if (album := song_details.album) else None)
|
||||
# artist = bleach.clean(artist.name if (artist := song_details.artist) else None) # noqa
|
||||
album = bleach.clean(song_details.album.name if song_details.album else None)
|
||||
artist = bleach.clean(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
|
||||
|
||||
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
|
||||
|
||||
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),
|
||||
)
|
||||
):
|
||||
song_details_result = AdapterManager.get_song_details(song_id)
|
||||
|
||||
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))
|
||||
|
||||
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,
|
||||
]
|
||||
)
|
||||
|
||||
util.diff_song_store(self.play_queue_store, new_store)
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
def popup_devices(self, relative_to):
|
||||
self.device_popover.set_relative_to(relative_to)
|
||||
self.device_popover.popup()
|
||||
self.device_popover.show_all()
|
||||
|
||||
_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 == app_config.state.current_device
|
||||
else None
|
||||
)
|
||||
button = IconButton(icon, label=player_name)
|
||||
button.get_style_context().add_class("menu-button")
|
||||
button.connect(
|
||||
"clicked",
|
||||
lambda _, player_id: run_action(button, "app.select-device", player_id),
|
||||
player_id,
|
||||
)
|
||||
self.device_list.add(button)
|
||||
|
||||
self.device_list.show_all()
|
||||
|
||||
def on_volume_changed(self, _: Any):
|
||||
if self._updating:
|
||||
return
|
||||
|
||||
run_action(self.widget, 'app.set-volume', self.volume.get_value())
|
||||
|
||||
def on_scrubber_changed(self, _: Any):
|
||||
if self.scrubber.get_upper() == 0:
|
||||
self.progress_label = "-:--"
|
||||
else:
|
||||
self.progress_label = util.format_song_duration(
|
||||
int(self.scrubber.get_value()))
|
||||
|
||||
if self._updating_song_progress:
|
||||
return
|
||||
|
||||
run_action(self.widget, 'app.seek', self.scrubber.get_value())
|
||||
|
||||
def update_duration_label(self, _: Any):
|
||||
upper = self.scrubber.get_upper()
|
||||
if upper == 0:
|
||||
self.duration_label = "-:--"
|
||||
else:
|
||||
self.duration_label = util.format_song_duration(int(upper))
|
||||
|
289
sublime_music/ui/player_controls/mobile.py
Normal file
289
sublime_music/ui/player_controls/mobile.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
import bleach
|
||||
|
||||
from .. import util
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
|
||||
from ...config import AppConfiguration
|
||||
from ...util import resolve_path
|
||||
from . import common
|
||||
|
||||
class MobileHandle(Gtk.ActionBar):
|
||||
def __init__(self, state):
|
||||
super().__init__()
|
||||
self.get_style_context().add_class("background")
|
||||
|
||||
self.state = state
|
||||
self.state.add_control(self)
|
||||
|
||||
self.pack_start(self.create_song_display())
|
||||
|
||||
buttons = Gtk.Overlay()
|
||||
|
||||
open_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.END)
|
||||
|
||||
self.play_queue_button = IconToggleButton(
|
||||
"view-list-symbolic",
|
||||
"Open play queue",
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
|
||||
def play_queue_button_toggled(*_):
|
||||
if self.play_queue_button.get_active() != self.state.play_queue_open:
|
||||
self.state.play_queue_open = self.play_queue_button.get_active()
|
||||
self.play_queue_button.connect("toggled", play_queue_button_toggled)
|
||||
|
||||
def on_play_queue_open(*_):
|
||||
self.play_queue_button.set_active(self.state.play_queue_open)
|
||||
self.state.connect("notify::play-queue-open", on_play_queue_open)
|
||||
|
||||
open_buttons.pack_start(self.play_queue_button, False, False, 5)
|
||||
|
||||
self.menu_button = IconButton(
|
||||
"view-more-symbolic",
|
||||
"Menu",
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
|
||||
def on_menu(*_):
|
||||
x = self.menu_button.get_allocated_width() / 2
|
||||
y = self.menu_button.get_allocated_height()
|
||||
util.show_song_popover(
|
||||
[self.state.play_queue_store[self.state.current_song_index][-1]],
|
||||
x, y,
|
||||
self.menu_button,
|
||||
self.state.offline_mode)
|
||||
self.menu_button.connect('clicked', on_menu)
|
||||
|
||||
open_buttons.pack_start(self.menu_button, False, False, 5)
|
||||
|
||||
buttons.add(open_buttons)
|
||||
|
||||
close_buttons = Handy.Squeezer(halign=Gtk.Align.END, homogeneous=False)
|
||||
|
||||
expanded_close_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
expanded_close_buttons.pack_start(common.create_prev_button(self.state), False, False, 5)
|
||||
expanded_close_buttons.pack_start(common.create_play_button(self.state, large=False), False, False, 5)
|
||||
expanded_close_buttons.pack_start(common.create_next_button(self.state), False, False, 5)
|
||||
|
||||
close_buttons.add(expanded_close_buttons)
|
||||
close_buttons.add(common.create_play_button(self.state, large=False, halign=Gtk.Align.END))
|
||||
|
||||
close_buttons.get_style_context().add_class("background")
|
||||
|
||||
buttons.add_overlay(close_buttons)
|
||||
|
||||
def flap_reveal(*_):
|
||||
progress = 1 - self.state.flap_reveal_progress
|
||||
close_buttons.set_opacity(progress)
|
||||
close_buttons.set_visible(progress > 0.05)
|
||||
|
||||
if progress < 0.8:
|
||||
self.state.play_queue_open = False
|
||||
self.state.connect("notify::flap-reveal-progress", flap_reveal)
|
||||
|
||||
self.pack_end(buttons)
|
||||
|
||||
def create_song_display(self):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, hexpand=True)
|
||||
box.set_property('width-request', 200)
|
||||
|
||||
self.cover_art = SpinnerImage(
|
||||
image_name="player-controls-album-artwork",
|
||||
image_size=50,
|
||||
)
|
||||
box.pack_start(self.cover_art, False, False, 0)
|
||||
|
||||
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
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.artist_name = make_label("artist-name")
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
box.pack_start(details_box, False, False, 5)
|
||||
|
||||
return box
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
empty_queue = len(app_config.state.play_queue) == 0
|
||||
|
||||
self.play_queue_button.set_sensitive(not empty_queue)
|
||||
self.menu_button.set_sensitive(not empty_queue)
|
||||
|
||||
if empty_queue:
|
||||
self.state.play_queue_open = False
|
||||
|
||||
if app_config.state.current_song is not None:
|
||||
self.song_title.set_markup(bleach.clean(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 artist:
|
||||
self.artist_name.set_markup(bleach.clean(artist.name))
|
||||
self.artist_name.show()
|
||||
elif album:
|
||||
self.artist_name.set_markup(bleach.clean(album.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.song_title.set_markup("")
|
||||
self.artist_name.set_markup("")
|
||||
|
||||
def set_cover_art(self, cover_art_filename: str, loading: bool):
|
||||
self.cover_art.set_from_file(cover_art_filename)
|
||||
self.cover_art.set_loading(loading)
|
||||
|
||||
|
||||
class MobileFlap(Gtk.Stack):
|
||||
def __init__(self, state):
|
||||
super().__init__(
|
||||
transition_type=Gtk.StackTransitionType.OVER_DOWN_UP, vexpand=True)
|
||||
|
||||
self.state = state
|
||||
self.state.add_control(self)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True)
|
||||
box.get_style_context().add_class("background")
|
||||
|
||||
box.pack_start(Gtk.Separator(), False, False, 0)
|
||||
|
||||
overlay = Gtk.Overlay(hexpand=True, vexpand=True, height_request=100)
|
||||
|
||||
self.cover_art = SpinnerPicture(image_name="player-controls-album-artwork")
|
||||
overlay.add(self.cover_art)
|
||||
|
||||
overlay_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
overlay.add_overlay(overlay_box)
|
||||
|
||||
overlay_row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
overlay_box.pack_end(overlay_row_box, False, False, 0)
|
||||
|
||||
overlay_row_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
|
||||
overlay_row_box.pack_end(common.create_label(self.state, "duration-label"), False, False, 5)
|
||||
|
||||
# Device
|
||||
self.device_button = IconButton(
|
||||
"chromecast-symbolic",
|
||||
"Show available audio output devices",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
def on_device_click(_: Any):
|
||||
if self.device_popover.is_visible():
|
||||
self.device_popover.popdown()
|
||||
else:
|
||||
self.device_popover.popup()
|
||||
self.device_popover.show_all()
|
||||
self.device_button.connect("clicked", self.state.popup_devices)
|
||||
overlay_row_box.set_center_widget(self.device_button)
|
||||
|
||||
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)
|
||||
|
||||
box.pack_start(overlay, True, True, 0)
|
||||
|
||||
# Song Scrubber
|
||||
scrubber = Gtk.Scale(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
adjustment=self.state.scrubber,
|
||||
name="song-scrubber",
|
||||
draw_value=False,
|
||||
restrict_to_fill_level=False,
|
||||
show_fill_level=True)
|
||||
self.state.bind_property("scrubber-cache", scrubber, "fill-level")
|
||||
box.pack_start(scrubber, False, False, 0)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Repeat button
|
||||
repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER, margin_left=10)
|
||||
buttons.pack_start(repeat_button, False, False, 0)
|
||||
|
||||
center_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Previous button
|
||||
center_buttons.add(common.create_prev_button(self.state, valign=Gtk.Align.CENTER))
|
||||
|
||||
# Play button
|
||||
center_buttons.add(common.create_play_button(self.state))
|
||||
|
||||
# Next button
|
||||
center_buttons.add(common.create_next_button(self.state, valign=Gtk.Align.CENTER))
|
||||
|
||||
buttons.set_center_widget(center_buttons)
|
||||
|
||||
# Shuffle button
|
||||
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER, margin_right=10)
|
||||
buttons.pack_end(shuffle_button, False, False, 0)
|
||||
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
|
||||
box.pack_start(Gtk.Box(), False, False, 5)
|
||||
|
||||
self.add(box)
|
||||
|
||||
play_queue_scrollbox = Gtk.ScrolledWindow(vexpand=True)
|
||||
|
||||
play_queue_scrollbox.add(common.create_play_queue_list(self.state))
|
||||
self.add(play_queue_scrollbox)
|
||||
|
||||
def on_play_queue_open(*_):
|
||||
if self.state.play_queue_open:
|
||||
self.set_visible_child(play_queue_scrollbox)
|
||||
else:
|
||||
self.set_visible_child(box)
|
||||
on_play_queue_open()
|
||||
self.state.connect("notify::play-queue-open", on_play_queue_open)
|
||||
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
pass
|
||||
|
||||
def set_cover_art(self, cover_art_filename: str, loading: bool):
|
||||
self.cover_art.set_from_file(cover_art_filename)
|
||||
self.cover_art.set_loading(loading)
|
@@ -4,114 +4,161 @@ 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 gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
from ..adapters import AdapterManager, api_objects as API
|
||||
from ..config import AppConfiguration
|
||||
from ..ui import util
|
||||
from ..ui.common import (
|
||||
from . import util
|
||||
from .common import (
|
||||
IconButton,
|
||||
LoadError,
|
||||
SongListColumn,
|
||||
SpinnerImage,
|
||||
Sizer,
|
||||
)
|
||||
from .actions import run_action
|
||||
|
||||
|
||||
class EditPlaylistDialog(Gtk.Dialog):
|
||||
def __init__(self, parent: Any, playlist: API.Playlist):
|
||||
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
|
||||
class EditPlaylistWindow(Handy.Window):
|
||||
def __init__(self, main_window: Any, playlist: API.Playlist):
|
||||
Handy.Window.__init__(
|
||||
self,
|
||||
modal=True,
|
||||
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
|
||||
destroy_with_parent=True,
|
||||
type_hint=Gdk.WindowTypeHint.DIALOG,
|
||||
default_width=640,
|
||||
default_height=576)
|
||||
self.main_window = main_window
|
||||
self.playlist = playlist
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# HEADER
|
||||
self.header = Gtk.HeaderBar()
|
||||
self.header_bar = Handy.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.header_bar.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.save_button = Gtk.Button(label="Save")
|
||||
self.save_button.get_style_context().add_class("suggested-action")
|
||||
self.save_button.connect("clicked", self._on_save_clicked)
|
||||
self.header_bar.pack_end(self.save_button)
|
||||
|
||||
self.set_titlebar(self.header)
|
||||
box.add(self.header_bar)
|
||||
|
||||
content_area = self.get_content_area()
|
||||
content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10)
|
||||
clamp = Handy.Clamp(margin=12)
|
||||
|
||||
make_label = lambda label_text: Gtk.Label(label_text, halign=Gtk.Align.END)
|
||||
inner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
content_grid.attach(make_label("Playlist Name"), 0, 0, 1, 1)
|
||||
self.name_entry = Gtk.Entry(text=playlist.name, hexpand=True)
|
||||
list_box = Gtk.ListBox()
|
||||
list_box.get_style_context().add_class('content')
|
||||
|
||||
row = Handy.ActionRow(title="Playlist Name")
|
||||
self.name_entry = Gtk.Entry(valign=Gtk.Align.CENTER, text=(playlist.name or ''))
|
||||
self.name_entry.connect("changed", self._on_name_change)
|
||||
content_grid.attach(self.name_entry, 1, 0, 1, 1)
|
||||
row.add(self.name_entry)
|
||||
list_box.add(row)
|
||||
|
||||
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)
|
||||
row = Handy.ActionRow(title="Comment")
|
||||
self.comment_entry = Gtk.Entry(valign=Gtk.Align.CENTER, text=(playlist.comment or ''))
|
||||
row.add(self.comment_entry)
|
||||
list_box.add(row)
|
||||
|
||||
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)
|
||||
row = Handy.ActionRow(title="Public")
|
||||
self.public_switch = Gtk.Switch(valign=Gtk.Align.CENTER, active=playlist.public)
|
||||
row.add(self.public_switch)
|
||||
list_box.add(row)
|
||||
|
||||
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)
|
||||
inner_box.add(list_box)
|
||||
|
||||
content_area.add(content_grid)
|
||||
self.show_all()
|
||||
delete_button = IconButton(label="Delete", icon_name="user-trash-symbolic", relief=True, halign=Gtk.Align.END)
|
||||
delete_button.get_style_context().add_class('destructive-action')
|
||||
delete_button.connect('clicked', self._on_delete_clicked)
|
||||
inner_box.pack_start(delete_button, False, False, 10)
|
||||
|
||||
clamp.add(inner_box)
|
||||
|
||||
box.add(clamp)
|
||||
|
||||
self.add(box)
|
||||
|
||||
def _on_delete_clicked(self, *_):
|
||||
# Confirm
|
||||
confirm_dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text="Confirm deletion",
|
||||
)
|
||||
confirm_dialog.add_buttons(
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_DELETE,
|
||||
Gtk.ResponseType.YES,
|
||||
)
|
||||
confirm_dialog.format_secondary_markup(
|
||||
f'Are you sure you want to delete the "{self.playlist.name}" playlist?'
|
||||
)
|
||||
result = confirm_dialog.run()
|
||||
confirm_dialog.destroy()
|
||||
if result == Gtk.ResponseType.YES:
|
||||
AdapterManager.delete_playlist(self.playlist.id)
|
||||
run_action(self.main_window, 'app.go-to-playlist', '')
|
||||
self.close()
|
||||
|
||||
def _on_save_clicked(self, *_):
|
||||
AdapterManager.update_playlist(
|
||||
self.playlist.id,
|
||||
name=self.name_entry.get_text(),
|
||||
comment=self.comment_entry.get_text(),
|
||||
public=self.public_switch.get_active())
|
||||
run_action(self.main_window, 'app.refresh')
|
||||
self.close()
|
||||
|
||||
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)
|
||||
self.save_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(),
|
||||
}
|
||||
self.header_bar.props.title = f"Edit {playlist_name}"
|
||||
|
||||
|
||||
class PlaylistsPanel(Gtk.Paned):
|
||||
class PlaylistsPanel(Handy.Leaflet):
|
||||
"""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)
|
||||
Gtk.Paned.__init__(self, transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False, interpolate_size=False)
|
||||
|
||||
list_sizer = Sizer(natural_width=400)
|
||||
self.playlist_list = PlaylistList()
|
||||
self.pack1(self.playlist_list, False, False)
|
||||
list_sizer.add(self.playlist_list)
|
||||
self.add(list_sizer)
|
||||
|
||||
details_sizer = Sizer(hexpand=True, natural_width=800)
|
||||
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)
|
||||
details_sizer.add(self.playlist_detail_panel)
|
||||
self.add(details_sizer)
|
||||
|
||||
def playlist_clicked(_):
|
||||
if self.get_folded():
|
||||
self.set_visible_child(details_sizer)
|
||||
self.playlist_list.connect("playlist-clicked", playlist_clicked)
|
||||
|
||||
def back_clicked(_):
|
||||
self.set_visible_child(list_sizer)
|
||||
self.playlist_detail_panel.connect("back-clicked", back_clicked)
|
||||
|
||||
def folded_changed(*_):
|
||||
if not self.get_folded():
|
||||
self.set_visible_child(list_sizer)
|
||||
|
||||
self.playlist_detail_panel.show_mobile = self.get_folded()
|
||||
self.connect("notify::folded", folded_changed)
|
||||
|
||||
def update(self, app_config: AppConfiguration = None, force: bool = False):
|
||||
self.playlist_list.update(app_config=app_config, force=force)
|
||||
@@ -125,6 +172,7 @@ class PlaylistList(Gtk.Box):
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
),
|
||||
"playlist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
offline_mode = False
|
||||
@@ -220,6 +268,7 @@ class PlaylistList(Gtk.Box):
|
||||
self.playlists_store = Gio.ListStore()
|
||||
self.list = Gtk.ListBox(name="playlist-list-listbox")
|
||||
self.list.bind_model(self.playlists_store, create_playlist_row)
|
||||
self.list.connect("row-selected", lambda *_: self.emit("playlist-clicked"))
|
||||
list_scroll_window.add(self.list)
|
||||
self.pack_start(list_scroll_window, True, True, 0)
|
||||
|
||||
@@ -311,20 +360,12 @@ class PlaylistList(Gtk.Box):
|
||||
|
||||
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),
|
||||
),
|
||||
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
show_mobile = GObject.Property(type=bool, default=False)
|
||||
|
||||
playlist_id = None
|
||||
playlist_details_expanded = False
|
||||
offline_mode = False
|
||||
|
||||
editing_playlist_song_list: bool = False
|
||||
@@ -332,8 +373,61 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
def __init__(self):
|
||||
Gtk.Overlay.__init__(self, name="playlist-view-overlay")
|
||||
|
||||
self.connect("notify::show-mobile", self.on_show_mobile_changed)
|
||||
|
||||
self.playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
action_bar = Gtk.ActionBar()
|
||||
|
||||
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
||||
self.bind_property("show-mobile", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
|
||||
|
||||
back_button = IconButton("go-previous-symbolic")
|
||||
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
|
||||
back_button_revealer.add(back_button)
|
||||
|
||||
action_bar.pack_start(back_button_revealer)
|
||||
|
||||
self.view_refresh_button = IconButton(
|
||||
"view-refresh-symbolic", "Refresh playlist info"
|
||||
)
|
||||
self.view_refresh_button.connect("clicked", self.on_view_refresh_click)
|
||||
action_bar.pack_end(self.view_refresh_button)
|
||||
|
||||
self.playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
|
||||
self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
|
||||
action_bar.pack_end(self.playlist_edit_button)
|
||||
|
||||
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
|
||||
)
|
||||
action_bar.pack_end(self.download_all_button)
|
||||
|
||||
self.shuffle_all_button = IconButton(
|
||||
"media-playlist-shuffle-symbolic",
|
||||
)
|
||||
self.shuffle_all_button.connect("clicked", self.on_shuffle_all_button)
|
||||
action_bar.pack_end(self.shuffle_all_button)
|
||||
|
||||
self.play_all_button = IconButton(
|
||||
"media-playback-start-symbolic",
|
||||
)
|
||||
self.play_all_button.connect("clicked", self.on_play_all_clicked)
|
||||
action_bar.pack_end(self.play_all_button)
|
||||
|
||||
self.playlist_box.add(action_bar)
|
||||
|
||||
self.error_container = Gtk.Box()
|
||||
self.playlist_box.add(self.error_container)
|
||||
|
||||
self.scrolled_window = Gtk.ScrolledWindow()
|
||||
|
||||
scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
playlist_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.playlist_artwork = SpinnerImage(
|
||||
@@ -360,79 +454,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
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)
|
||||
scrolled_box.add(playlist_info_box)
|
||||
|
||||
# Playlist songs list
|
||||
self.playlist_song_scroll_window = Gtk.ScrolledWindow()
|
||||
|
||||
self.playlist_song_store = Gtk.ListStore(
|
||||
bool, # clickable
|
||||
str, # cache status
|
||||
@@ -492,9 +518,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
)
|
||||
self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
|
||||
|
||||
self.playlist_song_scroll_window.add(self.playlist_songs)
|
||||
scrolled_box.pack_start(self.playlist_songs, True, True, 0)
|
||||
|
||||
self.scrolled_window.add(scrolled_box)
|
||||
self.playlist_box.pack_start(self.scrolled_window, True, True, 0)
|
||||
|
||||
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)
|
||||
@@ -506,6 +534,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
self.playlist_view_loading_box.add(playlist_view_spinner)
|
||||
self.add_overlay(self.playlist_view_loading_box)
|
||||
|
||||
def on_show_mobile_changed(self, *_):
|
||||
self.playlist_artwork.set_image_size( 120 if self.show_mobile else 200)
|
||||
|
||||
update_playlist_view_order_token = 0
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
@@ -555,25 +586,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
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)
|
||||
@@ -582,13 +598,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
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)
|
||||
@@ -606,10 +615,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
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()
|
||||
self.scrolled_window.hide()
|
||||
else:
|
||||
self.error_container.hide()
|
||||
self.playlist_song_scroll_window.show()
|
||||
self.scrolled_window.show()
|
||||
|
||||
# Update the song list model. This requires some fancy diffing to
|
||||
# update the list.
|
||||
@@ -677,7 +686,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
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"),
|
||||
@@ -698,11 +706,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
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, _):
|
||||
@@ -715,52 +718,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
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
|
||||
window = EditPlaylistWindow(self.get_toplevel(), playlist)
|
||||
|
||||
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()
|
||||
window.set_transient_for(self.get_toplevel())
|
||||
window.show_all()
|
||||
|
||||
def on_playlist_list_download_all_button_click(self, _):
|
||||
def download_state_change(song_id: str):
|
||||
@@ -777,39 +738,26 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
on_song_download_complete=download_state_change,
|
||||
)
|
||||
|
||||
def play_song(self, index: int, metadata: Dict[str, Any]):
|
||||
metadata["active_playlist_id"] = GLib.Variant('s', self.playlist_id)
|
||||
run_action(self, 'app.play-song', index, [m[-1] for m in self.playlist_song_store], metadata)
|
||||
|
||||
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},
|
||||
)
|
||||
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', False)})
|
||||
|
||||
def on_shuffle_all_button(self, _):
|
||||
self.emit(
|
||||
"song-clicked",
|
||||
self.play_song(
|
||||
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},
|
||||
)
|
||||
{"force_shuffle_state": GLib.Variant('b', True)})
|
||||
|
||||
def on_expand_collapse_click(self, _):
|
||||
self.emit(
|
||||
"refresh-window",
|
||||
{"playlist_details_expanded": not self.playlist_details_expanded},
|
||||
False,
|
||||
)
|
||||
run_action(self, 'playlists.set-details-expanded', not self.playlist_details_expanded)
|
||||
|
||||
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},
|
||||
)
|
||||
self.play_song(idx.get_indices()[0], {})
|
||||
|
||||
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
@@ -881,7 +829,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
on_remove_songs_click,
|
||||
)
|
||||
],
|
||||
on_playlist_state_change=lambda: self.emit("refresh-window", {}, True),
|
||||
on_playlist_state_change=lambda: run_action(self, 'app.refresh'),
|
||||
)
|
||||
|
||||
# If the click was on a selected row, don't deselect anything.
|
||||
|
615
sublime_music/ui/providers.py
Normal file
615
sublime_music/ui/providers.py
Normal file
@@ -0,0 +1,615 @@
|
||||
import uuid
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, List, Any
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import Gdk, Gtk, Handy, GLib, Pango
|
||||
|
||||
from ..adapters import (
|
||||
AdapterManager,
|
||||
api_objects as API,
|
||||
DownloadProgress,
|
||||
Result,
|
||||
ConfigurationStore,
|
||||
ConfigParamDescriptor,
|
||||
)
|
||||
from ..config import AppConfiguration, ProviderConfiguration
|
||||
from ..players import PlayerManager
|
||||
from .actions import run_action, variant_type_from_python
|
||||
from .common import IconButton
|
||||
|
||||
|
||||
class ProvidersWindow(Handy.Window):
|
||||
is_initial = True
|
||||
|
||||
def __init__(self, main_window):
|
||||
Handy.Window.__init__(self,
|
||||
modal=True,
|
||||
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
|
||||
destroy_with_parent=True,
|
||||
type_hint=Gdk.WindowTypeHint.DIALOG,
|
||||
default_width=640,
|
||||
default_height=576)
|
||||
self.main_window = main_window
|
||||
|
||||
# Don't die when closed
|
||||
def hide_not_destroy(*_):
|
||||
if self.is_initial:
|
||||
run_action(self.main_window, 'app.quit')
|
||||
return True
|
||||
|
||||
self.hide()
|
||||
return True
|
||||
self.connect('delete-event', hide_not_destroy)
|
||||
|
||||
self.stack = Gtk.Stack()
|
||||
|
||||
self.create_page = CreateProviderPage(self)
|
||||
self.stack.add(self.create_page)
|
||||
|
||||
self.configure_page = ConfigureProviderPage(self)
|
||||
self.stack.add(self.configure_page)
|
||||
|
||||
self.status_page = ProviderStatusPage(self)
|
||||
self.stack.add(self.status_page)
|
||||
|
||||
self.add(self.stack)
|
||||
|
||||
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
|
||||
self.is_initial = app_config.current_provider_id is None
|
||||
|
||||
self.status_page.update(app_config, player_manager)
|
||||
|
||||
def _set_transition(self, going_back: bool):
|
||||
if not self.is_visible():
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
|
||||
return
|
||||
|
||||
if going_back:
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
|
||||
else:
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
|
||||
|
||||
def open_create_page(self, go_back: Optional[Callable] = None, going_back=False):
|
||||
self._set_transition(going_back)
|
||||
|
||||
self.create_page.setup(go_back)
|
||||
self.create_page.show()
|
||||
self.stack.set_visible_child(self.create_page)
|
||||
|
||||
def open_configure_page(self, id, adapter, config, go_back: Callable):
|
||||
self._set_transition(False)
|
||||
|
||||
self.configure_page.setup(id, adapter, config, go_back)
|
||||
self.configure_page.show()
|
||||
self.stack.set_visible_child(self.configure_page)
|
||||
|
||||
def open_status_page(self):
|
||||
self._set_transition(True)
|
||||
|
||||
self.status_page.show()
|
||||
self.stack.set_visible_child(self.status_page)
|
||||
|
||||
|
||||
class CreateProviderPage(Gtk.Box):
|
||||
def __init__(self, window):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
self.window = window
|
||||
|
||||
header_bar = Handy.HeaderBar(title="Add Music Source")
|
||||
|
||||
self.cancel_button = Gtk.Button(label='Quit')
|
||||
self.cancel_button.connect('clicked', self._on_cancel_clicked)
|
||||
header_bar.pack_start(self.cancel_button)
|
||||
|
||||
self.add(header_bar)
|
||||
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
list_box = Gtk.ListBox(expand=True, selection_mode=Gtk.SelectionMode.NONE)
|
||||
|
||||
adapters = AdapterManager.available_adapters
|
||||
adapters = sorted(
|
||||
filter(lambda a: a.can_be_ground_truth, adapters),
|
||||
key=lambda a: a.get_ui_info().name)
|
||||
|
||||
for adapter in adapters:
|
||||
ui_info: UIInfo = adapter.get_ui_info()
|
||||
|
||||
row = Handy.ActionRow(
|
||||
title=ui_info.name,
|
||||
icon_name=ui_info.icon_name(),
|
||||
subtitle=ui_info.description,
|
||||
subtitle_lines=4,
|
||||
use_underline=True,
|
||||
activatable=True)
|
||||
|
||||
def activated(*_, adapter=adapter):
|
||||
self.window.open_configure_page(
|
||||
None,
|
||||
adapter,
|
||||
ConfigurationStore(),
|
||||
lambda: self.window.open_create_page(self._go_back, going_back=True))
|
||||
row.connect('activated', activated)
|
||||
|
||||
row.add(Gtk.Image(icon_name='go-next-symbolic'))
|
||||
|
||||
list_box.add(row)
|
||||
|
||||
scrolled_box.add(list_box)
|
||||
|
||||
scrolled_window.add(scrolled_box)
|
||||
self.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
def setup(self, go_back: Optional[Callable]):
|
||||
self._go_back = go_back
|
||||
|
||||
if go_back:
|
||||
self.cancel_button.set_label("Cancel")
|
||||
else:
|
||||
self.cancel_button.set_label("Quit")
|
||||
|
||||
def _on_cancel_clicked(self, *_):
|
||||
if self._go_back:
|
||||
self._go_back()
|
||||
else:
|
||||
self.window.close()
|
||||
|
||||
|
||||
class ConfigureProviderPage(Gtk.Box):
|
||||
_id = None
|
||||
_adapter = None
|
||||
_go_back = None
|
||||
_config_store = None
|
||||
_config_widgets = {}
|
||||
_config_updates = {}
|
||||
_required_fields: Dict[str, Callable[[Any], Optional[str]]] = {}
|
||||
|
||||
_errors = {}
|
||||
_validation_ratchet = 0
|
||||
_had_all_required = False
|
||||
|
||||
def __init__(self, window):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
self.window = window
|
||||
|
||||
self.header_bar = Handy.HeaderBar(show_close_button=False, title="Configuration")
|
||||
|
||||
back_button = Gtk.Button(label="Back")
|
||||
back_button.connect('clicked', lambda *_: self._go_back())
|
||||
self.header_bar.pack_start(back_button)
|
||||
|
||||
self.create_button = Gtk.Button(label="Create", sensitive=False)
|
||||
self.create_button.get_style_context().add_class('suggested-action')
|
||||
self.create_button.connect('clicked', self._on_create_clicked)
|
||||
self.header_bar.pack_end(self.create_button)
|
||||
|
||||
self.add(self.header_bar)
|
||||
|
||||
self.status_revealer = Gtk.Revealer(reveal_child=False)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin=10)
|
||||
|
||||
clamp = Handy.Clamp()
|
||||
|
||||
self.status_stack = Gtk.Stack()
|
||||
|
||||
self.status_spinner = Gtk.Spinner()
|
||||
self.status_stack.add(self.status_spinner)
|
||||
|
||||
self.status_ok_image = Gtk.Image(icon_name="config-ok-symbolic")
|
||||
self.status_stack.add(self.status_ok_image)
|
||||
|
||||
self.status_error_image = Gtk.Image(icon_name="config-error-symbolic")
|
||||
self.status_stack.add(self.status_error_image)
|
||||
|
||||
box.pack_start(self.status_stack, False, True, 0)
|
||||
|
||||
self.status_label = Gtk.Label(ellipsize=Pango.EllipsizeMode.END, lines=4, justify=Gtk.Justification.LEFT, wrap=True)
|
||||
box.pack_start(self.status_label, False, True, 0)
|
||||
|
||||
clamp.add(box)
|
||||
self.status_revealer.add(clamp)
|
||||
|
||||
self.pack_start(self.status_revealer, False, True, 0)
|
||||
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
|
||||
clamp = Handy.Clamp(margin=12)
|
||||
|
||||
self.settings_list = Gtk.ListBox()
|
||||
self.settings_list.get_style_context().add_class('content')
|
||||
clamp.add(self.settings_list)
|
||||
|
||||
scrolled_window.add(clamp)
|
||||
|
||||
self.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
def setup(self, id_, adapter, config_store, go_back):
|
||||
self._id = id_
|
||||
self._adapter = adapter
|
||||
self._go_back = go_back
|
||||
|
||||
# Reset
|
||||
self._config_store = config_store
|
||||
self._config_updates = {}
|
||||
self._required_fields = {}
|
||||
|
||||
self._errors = {}
|
||||
self._validation_ratchet = 0
|
||||
self._had_all_required = False
|
||||
|
||||
self.create_button.set_sensitive(False)
|
||||
|
||||
if self._id is not None:
|
||||
self.create_button.set_label('Save')
|
||||
else:
|
||||
self.create_button.set_label('Create')
|
||||
|
||||
for child in self.settings_list.get_children():
|
||||
self.settings_list.remove(child)
|
||||
|
||||
name = adapter.get_ui_info().name
|
||||
self.header_bar.set_title(f'{name} Configuration')
|
||||
|
||||
# Reset status revealer
|
||||
self.status_revealer.set_reveal_child(False)
|
||||
|
||||
# First row is always the name
|
||||
name_row = self._create_entry_row('name', ConfigParamDescriptor(str, 'Name'))
|
||||
|
||||
if 'name' in self._config_store:
|
||||
self._config_updates['name'](self._config_store['name'])
|
||||
|
||||
self.settings_list.add(name_row)
|
||||
|
||||
# Collected advanced settings in an expander row
|
||||
advanced_row = Handy.ExpanderRow(title="Advanced", expanded=False)
|
||||
|
||||
self._params, self._validate = adapter.get_configuration_form()
|
||||
|
||||
for name, config in self._params.items():
|
||||
row = {
|
||||
str: self._create_entry_row,
|
||||
bool: self._create_switch_row,
|
||||
int: self._create_spin_button_row,
|
||||
'password': lambda *a: self._create_entry_row(*a, is_password=True),
|
||||
'option': self._create_option_row,
|
||||
Path: self._create_path_row,
|
||||
}[config.type](name, config)
|
||||
|
||||
# Set the initial value from the config store
|
||||
if name in self._config_store:
|
||||
self._config_updates[name](self._config_store[name])
|
||||
elif config.default is not None:
|
||||
self._config_store[name] = config.default
|
||||
|
||||
if config.advanced:
|
||||
advanced_row.add(row)
|
||||
else:
|
||||
self.settings_list.add(row)
|
||||
|
||||
|
||||
self.settings_list.add(advanced_row)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def _create_entry_row(self, name, config, is_password=False):
|
||||
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
|
||||
entry = Gtk.Entry(
|
||||
valign=Gtk.Align.CENTER,
|
||||
visibility=not is_password,
|
||||
input_purpose=Gtk.InputPurpose.PASSWORD if is_password else Gtk.InputPurpose.FREE_FORM)
|
||||
|
||||
if config.default is not None:
|
||||
if is_password:
|
||||
entry.set_text(config.default[1])
|
||||
else:
|
||||
entry.set_text(config.default)
|
||||
|
||||
if config.required:
|
||||
if is_password:
|
||||
self._required_fields[name] = lambda v: len(v[1]) > 0
|
||||
else:
|
||||
self._required_fields[name] = lambda v: len(v) > 0
|
||||
|
||||
entry.connect('notify::text', lambda *_: self._update_config(name, entry.get_text(), is_password))
|
||||
|
||||
def set_value(value):
|
||||
if is_password:
|
||||
if value[0] != 'plaintext':
|
||||
return
|
||||
|
||||
value = value[1]
|
||||
entry.set_text(value)
|
||||
self._config_updates[name] = set_value
|
||||
self._config_widgets[name] = entry
|
||||
|
||||
row.add(entry)
|
||||
return row
|
||||
|
||||
def _create_switch_row(self, name, config):
|
||||
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
|
||||
switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
|
||||
assert config.default is not None
|
||||
switch.set_active(config.default)
|
||||
|
||||
switch.connect('notify::active', lambda *_: self._update_config(name, switch.get_active()))
|
||||
self._config_updates[name] = switch.set_active
|
||||
self._config_widgets[name] = switch
|
||||
|
||||
row.add(switch)
|
||||
return row
|
||||
|
||||
def _create_spin_button_row(self, name, config):
|
||||
pass
|
||||
|
||||
def _create_option_row(self, name, config):
|
||||
pass
|
||||
|
||||
def _create_path_row(self, name, config):
|
||||
pass
|
||||
|
||||
def _verify_required(self):
|
||||
errors = {}
|
||||
for name, verify in self._required_fields.items():
|
||||
if name not in self._config_store or not verify(self._config_store[name]):
|
||||
errors[name] = 'Missing field'
|
||||
return errors
|
||||
|
||||
def _update_config(self, name, value, is_secret=False):
|
||||
if is_secret:
|
||||
self._config_store.set_secret(name, value)
|
||||
else:
|
||||
self._config_store[name] = value
|
||||
|
||||
# Reset errors
|
||||
for name in self._errors.keys():
|
||||
widget = self._config_widgets[name]
|
||||
widget.get_style_context().remove_class('error')
|
||||
widget.set_tooltip_markup(None)
|
||||
|
||||
self._errors = {}
|
||||
self.create_button.set_sensitive(False)
|
||||
|
||||
if not self._had_all_required and self._verify_required():
|
||||
return
|
||||
|
||||
self.status_revealer.set_reveal_child(True)
|
||||
self._update_status(True, '')
|
||||
|
||||
self._had_all_required = True
|
||||
|
||||
self._validation_ratchet += 1
|
||||
ratchet = self._validation_ratchet
|
||||
|
||||
def on_verify_result(errors: Optional[Dict[str, str]]):
|
||||
if errors is None:
|
||||
return
|
||||
|
||||
errors.update(self._verify_required())
|
||||
|
||||
# Update controls after validation, as fields may have changed
|
||||
for name, update in self._config_updates.items():
|
||||
if name in self._config_store:
|
||||
update(self._config_store[name])
|
||||
|
||||
self._errors = errors
|
||||
|
||||
self.create_button.set_sensitive(len(errors) == 0)
|
||||
|
||||
if '__ping__' in self._errors:
|
||||
ping_error = self._errors.pop('__ping__')
|
||||
else:
|
||||
ping_error = None
|
||||
|
||||
for name, error in self._errors.items():
|
||||
widget = self._config_widgets[name]
|
||||
widget.get_style_context().add_class('error')
|
||||
widget.set_tooltip_markup(error)
|
||||
|
||||
self._update_status(False, ping_error)
|
||||
|
||||
def validate():
|
||||
time.sleep(0.75)
|
||||
if self._validation_ratchet != ratchet:
|
||||
return None
|
||||
|
||||
return self._validate(self._config_store)
|
||||
|
||||
result = Result(validate)
|
||||
result.add_done_callback(
|
||||
lambda f: GLib.idle_add(on_verify_result, f.result())
|
||||
)
|
||||
|
||||
def _update_status(self, verifying: bool, error: Optional[str]):
|
||||
if verifying:
|
||||
self.status_spinner.start()
|
||||
self.status_stack.set_visible_child(self.status_spinner)
|
||||
self.status_label.set_markup('<b>Verifying Connection...</b>')
|
||||
elif error:
|
||||
self.status_stack.set_visible_child(self.status_error_image)
|
||||
self.status_label.set_markup(bleach.clean(error))
|
||||
else:
|
||||
self.status_stack.set_visible_child(self.status_ok_image)
|
||||
self.status_label.set_markup('<b>Connected Successfully</b>')
|
||||
|
||||
if not verifying:
|
||||
self.status_spinner.stop()
|
||||
|
||||
def _on_create_clicked(self, *_):
|
||||
id = self._id or str(uuid.uuid4())
|
||||
name = self._config_store.pop('name')
|
||||
|
||||
config = {}
|
||||
for key, item in self._config_store.items():
|
||||
type = self._params[key].type
|
||||
if type == 'password':
|
||||
type = List[str]
|
||||
elif type == 'option':
|
||||
type = str
|
||||
|
||||
config[key] = GLib.Variant(variant_type_from_python(type), item)
|
||||
|
||||
adapter_name = self._adapter.get_ui_info().name
|
||||
run_action(
|
||||
self.window.main_window,
|
||||
'providers.set-config',
|
||||
id,
|
||||
name,
|
||||
adapter_name,
|
||||
config)
|
||||
|
||||
GLib.idle_add(lambda *_: self.window.close())
|
||||
|
||||
|
||||
class ProviderStatusPage(Gtk.Box):
|
||||
_current_provider = None
|
||||
|
||||
def __init__(self, window):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
self.window = window
|
||||
|
||||
header_bar = Handy.HeaderBar(show_close_button=True, title="Connection Status")
|
||||
self.add(header_bar)
|
||||
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
|
||||
clamp = Handy.Clamp(margin=12)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
|
||||
self.title = Gtk.Label(wrap=True, justify=Gtk.Justification.LEFT)
|
||||
self.title.get_style_context().add_class('title')
|
||||
self.title.get_style_context().add_class('large-title')
|
||||
box.add(self.title)
|
||||
|
||||
self.status_stack = Gtk.Stack()
|
||||
|
||||
self.status_offline = self._create_status_box('offline', 'Offline')
|
||||
self.status_stack.add(self.status_offline)
|
||||
|
||||
self.status_connected = self._create_status_box('connected', 'Connected')
|
||||
self.status_stack.add(self.status_connected)
|
||||
|
||||
self.status_error = self._create_status_box('error', 'Error Connecting to Server')
|
||||
self.status_stack.add(self.status_error)
|
||||
|
||||
box.add(self.status_stack)
|
||||
|
||||
list_box = Gtk.ListBox()
|
||||
list_box.get_style_context().add_class('content')
|
||||
|
||||
row = Handy.ActionRow(title='Offline Mode', can_focus=False, selectable=False)
|
||||
self.offline_switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
|
||||
row.add(self.offline_switch)
|
||||
|
||||
list_box.add(row)
|
||||
|
||||
row = Handy.ActionRow(
|
||||
title='Edit Configuration...',
|
||||
use_underline=True,
|
||||
activatable=True)
|
||||
|
||||
def activated(*_):
|
||||
config = self._current_provider.ground_truth_adapter_config.clone()
|
||||
config['name'] = self._current_provider.name
|
||||
|
||||
self.window.open_configure_page(
|
||||
self._current_provider.id,
|
||||
self._current_provider.ground_truth_adapter_type,
|
||||
config,
|
||||
lambda: self.window.open_status_page())
|
||||
row.connect('activated', activated)
|
||||
|
||||
row.add(Gtk.Image(icon_name='go-next-symbolic'))
|
||||
|
||||
list_box.add(row)
|
||||
|
||||
self.provider_list = Handy.ExpanderRow(title="Other Providers", expanded=False)
|
||||
|
||||
add_button = IconButton(icon_name='list-add-symbolic', valign=Gtk.Align.CENTER, relief=True)
|
||||
|
||||
def add_clicked(*_):
|
||||
self.window.open_create_page(lambda: self.window.open_status_page())
|
||||
add_button.connect('clicked', add_clicked)
|
||||
|
||||
self.provider_list.add_action(add_button)
|
||||
|
||||
list_box.add(self.provider_list)
|
||||
|
||||
box.pack_start(list_box, False, True, 10)
|
||||
|
||||
clamp.add(box)
|
||||
|
||||
scrolled_window.add(clamp)
|
||||
|
||||
self.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
def _create_status_box(self, icon: str, label: str):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, halign=Gtk.Align.CENTER)
|
||||
|
||||
icon = Gtk.Image(icon_name=f"server-{icon}-symbolic")
|
||||
box.add(icon)
|
||||
|
||||
label = Gtk.Label(label=label)
|
||||
box.add(label)
|
||||
|
||||
return box
|
||||
|
||||
_other_providers_cache = None
|
||||
_other_providers = []
|
||||
|
||||
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
|
||||
assert AdapterManager.ground_truth_adapter_is_networked
|
||||
|
||||
self._current_provider = app_config.provider
|
||||
|
||||
self.title.set_label(app_config.provider.name)
|
||||
|
||||
if app_config.offline_mode:
|
||||
self.status_stack.set_visible_child(self.status_offline)
|
||||
elif AdapterManager.get_ping_status():
|
||||
self.status_stack.set_visible_child(self.status_connected)
|
||||
else:
|
||||
self.status_stack.set_visible_child(self.status_error)
|
||||
|
||||
other_providers = [id for id in app_config.providers.keys() if id != app_config.current_provider_id]
|
||||
|
||||
if self._other_providers_cache is None or self._other_providers_cache != other_providers:
|
||||
self._other_providers_cache = other_providers
|
||||
|
||||
for child in self._other_providers:
|
||||
self.provider_list.remove(child)
|
||||
self._other_providers = []
|
||||
|
||||
self.provider_list.set_enable_expansion(len(other_providers) > 0)
|
||||
|
||||
for id in other_providers:
|
||||
provider = app_config.providers[id]
|
||||
|
||||
row = Handy.ActionRow(title=provider.name, can_focus=False, selectable=False)
|
||||
|
||||
button = Gtk.Button(label="Switch", valign=Gtk.Align.CENTER)
|
||||
def on_clicked(*_, id=id):
|
||||
run_action(self.window.main_window, 'providers.switch', id)
|
||||
button.connect('clicked', on_clicked)
|
||||
|
||||
row.add(button)
|
||||
|
||||
button = IconButton(icon_name='user-trash-symbolic', valign=Gtk.Align.CENTER, relief=True)
|
||||
button.get_style_context().add_class('destructive-action')
|
||||
def on_clicked(*_, id=id):
|
||||
run_action(self.window.main_window, 'providers.remove', id)
|
||||
button.connect('clicked', on_clicked)
|
||||
|
||||
row.add(button)
|
||||
|
||||
self.provider_list.add(row)
|
||||
self._other_providers.append(row)
|
||||
|
||||
self.provider_list.show_all()
|
262
sublime_music/ui/search.py
Normal file
262
sublime_music/ui/search.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from functools import partial
|
||||
from typing import Optional, List, Any, Union, Tuple
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy, Gdk, GdkPixbuf
|
||||
|
||||
from ..adapters import (
|
||||
AdapterManager,
|
||||
api_objects as API,
|
||||
Result,
|
||||
)
|
||||
from . import util
|
||||
from ..config import AppConfiguration, ProviderConfiguration
|
||||
from .actions import run_action
|
||||
|
||||
class SearchPanel(Gtk.ScrolledWindow):
|
||||
_ratchet = 0
|
||||
_query: str = ''
|
||||
_search: Optional[Result] = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._art_cache = {}
|
||||
|
||||
clamp = Handy.Clamp(margin=12)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.entry = Gtk.Entry(hexpand=True, placeholder_text="Search")
|
||||
self.entry.connect("notify::text", lambda *_: run_action(self, 'search.set-query', self.entry.get_text()))
|
||||
box.pack_start(self.entry, False, False, 10)
|
||||
|
||||
scrolled_window = ()
|
||||
|
||||
self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.NONE, homogeneous=True)
|
||||
|
||||
self.spinner = Gtk.Spinner(active=False, hexpand=True, vexpand=True)
|
||||
self.stack.add(self.spinner)
|
||||
|
||||
self.store = Gtk.ListStore(
|
||||
int, # type
|
||||
str, # art
|
||||
str, # title, subtitle
|
||||
str, # id
|
||||
)
|
||||
self.art_cache = {}
|
||||
|
||||
self.list = Gtk.TreeView(
|
||||
model=self.store,
|
||||
reorderable=False,
|
||||
headers_visible=False)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(stock_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
renderer.set_fixed_size(40, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, self._get_type_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(45, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, self._get_result_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=2)
|
||||
column.set_expand(True)
|
||||
self.list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
|
||||
renderer.set_fixed_size(45, 60)
|
||||
self.options_column = Gtk.TreeViewColumn("", renderer)
|
||||
self.options_column.set_resizable(True)
|
||||
self.list.append_column(self.options_column)
|
||||
|
||||
self.list.connect("button-press-event", self._on_list_button_press)
|
||||
|
||||
self.stack.add(self.list)
|
||||
|
||||
box.pack_start(self.stack, True, True, 10)
|
||||
|
||||
clamp.add(box)
|
||||
|
||||
self.add(clamp)
|
||||
|
||||
def _get_type_pixbuf(self,
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any):
|
||||
kind = model.get_value(tree_iter, 0)
|
||||
|
||||
if kind == API.SearchResult.Kind.ARTIST.value:
|
||||
cell.set_property("icon-name", "avatar-default-symbolic")
|
||||
elif kind == API.SearchResult.Kind.ALBUM.value:
|
||||
cell.set_property("icon-name", "media-optical-symbolic")
|
||||
elif kind == API.SearchResult.Kind.SONG.value:
|
||||
cell.set_property("icon-name", "folder-music-symbolic")
|
||||
elif kind == API.SearchResult.Kind.PLAYLIST.value:
|
||||
cell.set_property("icon-name", "open-menu-symbolic")
|
||||
else:
|
||||
assert False
|
||||
|
||||
def _get_pixbuf_from_path(self, path: Optional[str]):
|
||||
if not path:
|
||||
return None
|
||||
|
||||
if path in self._art_cache:
|
||||
return self._art_cache[path]
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 50, 50, True)
|
||||
|
||||
self._art_cache[path] = pixbuf
|
||||
return pixbuf
|
||||
|
||||
def _get_result_pixbuf(self,
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any):
|
||||
filename = model.get_value(tree_iter, 1)
|
||||
pixbuf = self._get_pixbuf_from_path(filename)
|
||||
if not pixbuf:
|
||||
cell.set_property("icon-name", "")
|
||||
else:
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
def _on_list_button_press(self, tree: Gtk.ListStore, event: Gdk.EventButton) -> bool:
|
||||
if event.button != 1:
|
||||
return False
|
||||
|
||||
path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y)
|
||||
index = path.get_indices()[0]
|
||||
row = self.store[index]
|
||||
|
||||
if column == self.options_column:
|
||||
area = tree.get_cell_area(path, self.options_column)
|
||||
x = area.x + area.width / 2
|
||||
y = area.y + area.height / 2
|
||||
|
||||
# TODO: Show popup
|
||||
return True
|
||||
else:
|
||||
if row[0] == API.SearchResult.Kind.ARTIST.value:
|
||||
run_action(self, 'app.go-to-artist', row[3])
|
||||
elif row[0] == API.SearchResult.Kind.ALBUM.value:
|
||||
run_action(self, 'app.go-to-album', row[3])
|
||||
elif row[0] == API.SearchResult.Kind.SONG.value:
|
||||
run_action(self, 'app.play-song', 0, [row[3]], {"force_shuffle_state": GLib.Variant('b', False)})
|
||||
elif row[0] == API.SearchResult.Kind.PLAYLIST.value:
|
||||
run_action(self, 'app.go-to-playlist', row[3])
|
||||
else:
|
||||
assert False
|
||||
|
||||
return True
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
query = app_config.state.search_query
|
||||
|
||||
if query != self._query:
|
||||
self._query = query
|
||||
|
||||
self.entry.set_text(self._query)
|
||||
|
||||
if self._search:
|
||||
self._search.cancel()
|
||||
|
||||
self._ratchet += 1
|
||||
|
||||
if self._query:
|
||||
def search_callback(ratchet, result: API.SearchResult):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
GLib.idle_add(self._update_search_results, result.get_results(), ratchet)
|
||||
|
||||
self._search = AdapterManager.search(
|
||||
self._query,
|
||||
search_callback=partial(search_callback, self._ratchet))
|
||||
self._set_loading(True)
|
||||
else:
|
||||
self._update_search_results([], self._ratchet)
|
||||
|
||||
def _set_loading(self, loading: bool):
|
||||
if loading:
|
||||
self.spinner.start()
|
||||
self.stack.set_visible_child(self.spinner)
|
||||
else:
|
||||
self.spinner.stop()
|
||||
self.stack.set_visible_child(self.list)
|
||||
|
||||
def _set_art(self, index: int, path: str, ratchet: int):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
self.store[index][1] = path
|
||||
|
||||
def _get_art_path(self, index: int, art_id: Optional[str], ratchet: int):
|
||||
cover_art_result = AdapterManager.get_cover_art_uri(art_id, "file")
|
||||
if not cover_art_result.data_is_available:
|
||||
def on_done(result: Result):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
GLib.idle_add(self._set_art, index, result.result(), ratchet)
|
||||
|
||||
cover_art_result.add_done_callback(on_done)
|
||||
return None
|
||||
|
||||
# The cover art is already cached.
|
||||
return cover_art_result.result()
|
||||
|
||||
def _update_search_results(
|
||||
self,
|
||||
results: List[Tuple[API.SearchResult.Kind, API.SearchResult.ValueType]],
|
||||
ratchet: int,
|
||||
):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
self._set_loading(False)
|
||||
|
||||
self.store.clear()
|
||||
self._art_cache = {}
|
||||
|
||||
for index, (kind, result) in enumerate(results):
|
||||
id = result.id
|
||||
|
||||
if kind is API.SearchResult.Kind.ARTIST:
|
||||
art_path = None
|
||||
title = f"<b>{bleach.clean(result.name)}</b>"
|
||||
|
||||
elif kind is API.SearchResult.Kind.ALBUM:
|
||||
art_path = self._get_art_path(index, result.cover_art, ratchet)
|
||||
|
||||
artist = bleach.clean(result.artist.name if result.artist else None)
|
||||
song_count = f"{result.song_count} {util.pluralize('song', result.song_count)}"
|
||||
title = f"<b>{bleach.clean(result.name)}</b>\n{util.dot_join(artist, song_count)}"
|
||||
|
||||
elif kind is API.SearchResult.Kind.SONG:
|
||||
art_path = self._get_art_path(index, result.cover_art, ratchet)
|
||||
|
||||
name = bleach.clean(result.title)
|
||||
album = bleach.clean(result.album.name if result.album else None)
|
||||
artist = bleach.clean(result.artist.name if result.artist else None)
|
||||
title = f"<b>{name}</b>\n{util.dot_join(album, artist)}"
|
||||
|
||||
elif kind is API.SearchResult.Kind.PLAYLIST:
|
||||
art_path = None
|
||||
|
||||
title = f"<b>{bleach.clean(result.name)}</b>\n{result.song_count} {util.pluralize('song', result.song_count)}"
|
||||
|
||||
else:
|
||||
assert False
|
||||
|
||||
self.store.append((kind.value, art_path, title, id))
|
@@ -81,20 +81,18 @@ class UIState:
|
||||
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"
|
||||
search_query: str = ''
|
||||
|
||||
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
|
||||
AlbumSearchQuery.Type.RANDOM,
|
||||
genre=_DefaultGenre(),
|
||||
year_range=this_decade(),
|
||||
)
|
||||
|
||||
active_playlist_id: Optional[str] = None
|
||||
|
||||
# Pickle backwards compatibility
|
||||
_DefaultGenre = AlbumSearchQuery.Genre
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
del state["song_stream_cache_progress"]
|
||||
@@ -109,6 +107,11 @@ class UIState:
|
||||
self.current_notification = None
|
||||
self.playing = False
|
||||
|
||||
# Ensure a song is selected if the play queue isn't empty
|
||||
if self.play_queue and self.current_song_index < 0:
|
||||
self.current_song_index = 0
|
||||
self.song_progress = timedelta(0)
|
||||
|
||||
def __init_available_players__(self):
|
||||
from sublime_music.players import PlayerManager
|
||||
|
||||
|
@@ -213,9 +213,9 @@ def show_song_popover(
|
||||
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_name("app.queue-next-songs")
|
||||
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_name("app.queue_songs")
|
||||
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)
|
||||
@@ -248,9 +248,9 @@ def show_song_popover(
|
||||
):
|
||||
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")
|
||||
play_next_button.set_action_name("app.queue-next-songs")
|
||||
add_to_queue_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_name("app.queue-songs")
|
||||
|
||||
albums, artists, parents = set(), set(), set()
|
||||
for song in songs:
|
||||
|
19
tests/ui_actions_test.py
Normal file
19
tests/ui_actions_test.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from typing import Any, List, Tuple, Optional, Union, Dict
|
||||
|
||||
from sublime_music.ui import actions
|
||||
|
||||
def test_variant_type_from_python():
|
||||
assert actions.variant_type_from_python(bool) == 'b'
|
||||
assert actions.variant_type_from_python(int) == 'x'
|
||||
assert actions.variant_type_from_python(float) == 'd'
|
||||
assert actions.variant_type_from_python(str) == 's'
|
||||
assert actions.variant_type_from_python(Any) == 'v'
|
||||
assert actions.variant_type_from_python(List[int]) == 'ax'
|
||||
assert actions.variant_type_from_python(Tuple[str, int, bool]) == '(sxb)'
|
||||
assert actions.variant_type_from_python(Optional[str]) == 'ms'
|
||||
assert actions.variant_type_from_python(Union[str, int]) == '[sx]'
|
||||
assert actions.variant_type_from_python(Dict[str, int]) == 'a{sx}'
|
||||
|
||||
assert (actions.variant_type_from_python(
|
||||
Tuple[Dict[Optional[str], List[bool]], List[List[Any]]])
|
||||
== '(a{msab}aav)')
|
Reference in New Issue
Block a user