Started work on making adapter-defined config
@@ -49,6 +49,8 @@ class should be enough to implement the entire adapter.
|
||||
After you've created the class, you will want to implement the following
|
||||
functions and properties first:
|
||||
|
||||
* ``ui_info``: Returns a :class:`sublime.adapters.UIInfo` with the info for the
|
||||
adapter.
|
||||
* ``__init__``: Used to initialize your adapter. See the
|
||||
:class:`sublime.adapters.Adapter.__init__` documentation for the function
|
||||
signature of the ``__init__`` function.
|
||||
@@ -70,10 +72,14 @@ functions and properties first:
|
||||
|
||||
.. TODO: these are totally wrong
|
||||
|
||||
* ``get_config_parameters``: Specifies the settings which can be configured on
|
||||
for the adapter. See :ref:`adapter-api:Handling Configuration` for details.
|
||||
* ``verify_configuration``: Verifies whether or not a given set of configuration
|
||||
values are valid. See :ref:`adapter-api:Handling Configuration` for details.
|
||||
* ``get_configuration_form``: 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 values.
|
||||
|
||||
If you don't want to implement all of the GTK logic yourself, and just want a
|
||||
simple form, then you can use the
|
||||
:class:`sublime.adapters.ConfigureServerForm` class to generate a form in a
|
||||
declarative manner.
|
||||
|
||||
.. note::
|
||||
|
||||
|
@@ -50,6 +50,7 @@ autodoc_default_options = {
|
||||
autosectionlabel_prefix_document = True
|
||||
intersphinx_mapping = {
|
||||
"python": ("https://docs.python.org/3", None),
|
||||
"gtk": ("https://lazka.github.io/pgi-docs/Gtk-3.0/", None),
|
||||
}
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
|
@@ -1,11 +1,27 @@
|
||||
bottle==0.12.18
|
||||
casttube==0.2.1
|
||||
certifi==2020.4.5.1
|
||||
chardet==3.0.4
|
||||
dataclasses-json==0.4.4
|
||||
deepdiff==4.3.2
|
||||
fuzzywuzzy==0.18.0
|
||||
idna==2.9
|
||||
ifaddr==0.1.6
|
||||
marshmallow-enum==1.5.1
|
||||
marshmallow==3.6.0
|
||||
mypy-extensions==0.4.3
|
||||
ordered-set==4.0.1
|
||||
peewee==3.13.3
|
||||
protobuf==3.12.2
|
||||
pychromecast==5.3.0
|
||||
pycparser==2.20
|
||||
python-dateutil==2.8.1
|
||||
python-levenshtein==0.12.0
|
||||
python-mpv==0.4.6
|
||||
pyyaml==5.3.1
|
||||
requests==2.23.0
|
||||
stringcase==1.2.0
|
||||
typing-extensions==3.7.4.2
|
||||
typing-inspect==0.6.0
|
||||
urllib3==1.25.9
|
||||
zeroconf==0.27.0
|
||||
|
22
setup.py
@@ -14,12 +14,16 @@ with open(here.joinpath("sublime", "__init__.py")) as f:
|
||||
version = eval(line.split()[-1])
|
||||
break
|
||||
|
||||
icons_dir = here.joinpath("sublime", "ui", "icons")
|
||||
icon_filenames = []
|
||||
for icon in icons_dir.iterdir():
|
||||
if not str(icon).endswith(".svg"):
|
||||
continue
|
||||
icon_filenames.append(str(icon))
|
||||
package_data_dirs = [
|
||||
here.joinpath("sublime", "ui", "icons"),
|
||||
here.joinpath("sublime", "dbus", "mpris_specs"),
|
||||
]
|
||||
package_data_files = []
|
||||
for data_dir in package_data_dirs:
|
||||
for icon in data_dir.iterdir():
|
||||
if not str(icon).endswith(".svg"):
|
||||
continue
|
||||
package_data_files.append(str(icon))
|
||||
|
||||
setup(
|
||||
name="sublime-music",
|
||||
@@ -51,11 +55,7 @@ setup(
|
||||
"ui/app_styles.css",
|
||||
"ui/images/play-queue-play.png",
|
||||
"adapters/images/default-album-art.png",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml",
|
||||
*icon_filenames,
|
||||
*package_data_files,
|
||||
]
|
||||
},
|
||||
install_requires=[
|
||||
|
@@ -57,8 +57,9 @@ def main():
|
||||
or os.environ.get("APPDATA")
|
||||
or os.path.join("~/.config")
|
||||
)
|
||||
.joinpath("sublime-music", "config.json")
|
||||
.expanduser()
|
||||
.joinpath("sublime-music", "config.yaml")
|
||||
.resolve()
|
||||
)
|
||||
|
||||
app = SublimeMusicApp(Path(config_file))
|
||||
|
@@ -4,7 +4,10 @@ from .adapter_base import (
|
||||
CacheMissError,
|
||||
CachingAdapter,
|
||||
ConfigParamDescriptor,
|
||||
ConfigurationStore,
|
||||
ConfigureServerForm,
|
||||
SongCacheStatus,
|
||||
UIInfo,
|
||||
)
|
||||
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
||||
|
||||
@@ -15,8 +18,11 @@ __all__ = (
|
||||
"CacheMissError",
|
||||
"CachingAdapter",
|
||||
"ConfigParamDescriptor",
|
||||
"ConfigurationStore",
|
||||
"ConfigureServerForm",
|
||||
"DownloadProgress",
|
||||
"Result",
|
||||
"SearchResult",
|
||||
"SongCacheStatus",
|
||||
"UIInfo",
|
||||
)
|
||||
|
@@ -6,6 +6,7 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Optional,
|
||||
@@ -16,6 +17,9 @@ from typing import (
|
||||
Union,
|
||||
)
|
||||
|
||||
from dataclasses_json import dataclass_json
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .api_objects import (
|
||||
Album,
|
||||
Artist,
|
||||
@@ -152,6 +156,49 @@ class CacheMissError(Exception):
|
||||
super().__init__(*args)
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class ConfigurationStore:
|
||||
"""
|
||||
This defines an abstract store for all configuration parameters for a given Adapter.
|
||||
"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
self._store: Dict[str, Any] = {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get the configuration value in the store with the given key. If the key doesn't
|
||||
exist in the store, return the default.
|
||||
"""
|
||||
return self._store.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
"""
|
||||
Set the value of the given key in the store.
|
||||
"""
|
||||
self._store[key] = value
|
||||
|
||||
def get_secret(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get the secret value in the store with the given key. If the key doesn't exist
|
||||
in the store, return the default. This will retrieve the secret from whatever is
|
||||
configured as the underlying secret storage mechanism so you don't have to deal
|
||||
with secret storage yourself.
|
||||
"""
|
||||
# TODO make secret storage less stupid.
|
||||
return self._store.get(key, default)
|
||||
|
||||
def set_secret(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Set the secret value of the given key in the store. This should be used for
|
||||
things such as passwords or API tokens. This will store the secret in whatever
|
||||
is configured as the underlying secret storage mechanism so you don't have to
|
||||
deal with secret storage yourself.
|
||||
"""
|
||||
return self._store.get(key, default)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigParamDescriptor:
|
||||
"""
|
||||
@@ -167,6 +214,14 @@ class ConfigParamDescriptor:
|
||||
* 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 string ``"directory"``: corresponds to a directory picker in the UI.
|
||||
* The literal sting ``"fold"``: corresponds to a expander where following components
|
||||
are under an expander component. The title of the expander is the description of
|
||||
this class. All of the components until the next ``"fold"``, and ``"endfold"``
|
||||
component, or the end of the configuration paramter dictionary are included under
|
||||
the expander component.
|
||||
* The literal string ``endfold``: end a ``"fold"``. The description must be the same
|
||||
as the corresponding start form.
|
||||
|
||||
The :class:`hidden_behind` is an optional string representing the name of the
|
||||
expander that the component should be displayed underneath. For example, one common
|
||||
@@ -195,6 +250,56 @@ class ConfigParamDescriptor:
|
||||
options: Optional[Iterable[str]] = None
|
||||
|
||||
|
||||
class ConfigureServerForm(Gtk.Box):
|
||||
def __init__(
|
||||
self,
|
||||
config_store: ConfigurationStore,
|
||||
config_parameters: Dict[str, ConfigParamDescriptor],
|
||||
verify_configuration: Callable[[], Dict[str, Optional[str]]],
|
||||
):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
Gtk.Box.__init__(self)
|
||||
content_grid = Gtk.Grid(
|
||||
column_spacing=10, row_spacing=5, margin_left=10, margin_right=10,
|
||||
)
|
||||
|
||||
for key, cpd in config_parameters.items():
|
||||
print(key, cpd)
|
||||
|
||||
self.add(content_grid)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIInfo:
|
||||
name: str
|
||||
description: str
|
||||
icon_basename: str
|
||||
icon_dir: Optional[Path] = None
|
||||
|
||||
def icon_name(self) -> str:
|
||||
return f"{self.icon_basename}-symbolic"
|
||||
|
||||
def status_icon_name(self, status: str) -> str:
|
||||
return f"{self.icon_basename}-{status.lower()}-symbolic"
|
||||
|
||||
|
||||
class Adapter(abc.ABC):
|
||||
"""
|
||||
Defines the interface for a Sublime Music Adapter.
|
||||
@@ -205,45 +310,38 @@ class Adapter(abc.ABC):
|
||||
"""
|
||||
|
||||
# Configuration and Initialization Properties
|
||||
# These properties determine how the adapter can be configured and how to
|
||||
# These functions determine how the adapter can be configured and how to
|
||||
# initialize the adapter given those configuration values.
|
||||
# ==================================================================================
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
|
||||
def get_ui_info() -> UIInfo:
|
||||
"""
|
||||
Specifies the settings which can be configured for the adapter.
|
||||
|
||||
:returns: 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.
|
||||
:returns: A :class:`UIInfo` object.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
||||
"""
|
||||
Specifies a function for verifying whether or not the config is valid.
|
||||
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
|
||||
values.
|
||||
|
||||
:param config: The adapter configuration. The keys of are the configuration
|
||||
parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual value of
|
||||
the configuration parameter. It is guaranteed that all configuration
|
||||
parameters that are marked as required will have a value in ``config``.
|
||||
If you don't want to implement all of the GTK logic yourself, and just want a
|
||||
simple form, then you can use the :class:`ConfigureServerForm` class to generate
|
||||
a form in a declarative manner.
|
||||
"""
|
||||
|
||||
:returns: A dictionary containing varification errors. The keys of the returned
|
||||
dictionary should be the same as the passed in via the ``config`` parameter.
|
||||
The values should be strings describing why the corresponding value in the
|
||||
``config`` dictionary is invalid.
|
||||
|
||||
Not all keys need be returned (for example, if there's no error for a given
|
||||
configuration parameter), and returning `None` indicates no error.
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def migrate_configuration(config_store: ConfigurationStore):
|
||||
"""
|
||||
This function allows the adapter to migrate its configuration.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, config: dict, data_directory: Path):
|
||||
def __init__(self, config_store: ConfigurationStore, data_directory: Path):
|
||||
"""
|
||||
This function should be overridden by inheritors of :class:`Adapter` and should
|
||||
be used to do whatever setup is required for the adapter.
|
||||
@@ -291,6 +389,14 @@ class Adapter(abc.ABC):
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
@staticmethod
|
||||
def can_be_ground_truth() -> bool:
|
||||
"""
|
||||
Whether or not this adapter can be used as a ground truth adapter.
|
||||
"""
|
||||
return True
|
||||
|
||||
# Network Properties
|
||||
# These properties determine whether or not the adapter requires connection over a
|
||||
# network and whether the underlying server can be pinged.
|
||||
|
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, Dict, Optional, Sequence, Set, Tuple, Union
|
||||
|
||||
from gi.repository import Gtk
|
||||
from peewee import fn, prefetch
|
||||
|
||||
from sublime.adapters import api_objects as API
|
||||
@@ -16,10 +17,12 @@ from .. import (
|
||||
CacheMissError,
|
||||
CachingAdapter,
|
||||
ConfigParamDescriptor,
|
||||
ConfigurationStore,
|
||||
ConfigureServerForm,
|
||||
SongCacheStatus,
|
||||
UIInfo,
|
||||
)
|
||||
|
||||
|
||||
KEYS = CachingAdapter.CachedDataKey
|
||||
|
||||
|
||||
@@ -31,16 +34,31 @@ class FilesystemAdapter(CachingAdapter):
|
||||
# Configuration and Initialization Properties
|
||||
# ==================================================================================
|
||||
@staticmethod
|
||||
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
|
||||
return {
|
||||
# TODO (#188): directory path, whether or not to scan tags
|
||||
}
|
||||
def get_ui_info() -> UIInfo:
|
||||
return UIInfo(
|
||||
name="Local Filesystem",
|
||||
description="Add a directory on your local filesystem",
|
||||
icon_basename="folder-music",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
return {
|
||||
# TODO (#188): verify that the path exists
|
||||
}
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
||||
def verify_config_store() -> Dict[str, Optional[str]]:
|
||||
return {}
|
||||
|
||||
return ConfigureServerForm(
|
||||
config_store,
|
||||
{
|
||||
"directory": ConfigParamDescriptor(
|
||||
type="directory", description="Music Directory"
|
||||
)
|
||||
},
|
||||
verify_config_store,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def migrate_configuration(config_store: ConfigurationStore):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self, config: dict, data_directory: Path, is_cache: bool = False,
|
||||
@@ -78,6 +96,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
# Usage and Availability Properties
|
||||
# ==================================================================================
|
||||
can_be_cached = False # Can't be cached (there's no need).
|
||||
can_be_ground_truth = False # TODO (#188)
|
||||
is_networked = False # Doesn't access the network.
|
||||
|
||||
# TODO (#200) make these dependent on cache state. Need to do this kinda efficiently
|
||||
|
@@ -24,7 +24,6 @@ from typing import (
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
@@ -50,6 +49,7 @@ from .api_objects import (
|
||||
)
|
||||
from .filesystem import FilesystemAdapter
|
||||
from .subsonic import SubsonicAdapter
|
||||
from sublime.config import ProviderConfiguration
|
||||
|
||||
REQUEST_DELAY: Optional[Tuple[float, float]] = None
|
||||
if delay_str := os.environ.get("REQUEST_DELAY"):
|
||||
@@ -228,12 +228,6 @@ class AdapterManager:
|
||||
|
||||
_instance: Optional[_AdapterManagerInternal] = None
|
||||
|
||||
@staticmethod
|
||||
def register_adapter(adapter_class: Type):
|
||||
if not issubclass(adapter_class, Adapter):
|
||||
raise TypeError("Attempting to register a class that is not an adapter.")
|
||||
AdapterManager.available_adapters.add(adapter_class)
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
This should not ever be called. You should only ever use the static methods on
|
||||
@@ -289,26 +283,25 @@ class AdapterManager:
|
||||
|
||||
# TODO (#197): actually do stuff with the config to determine which adapters to
|
||||
# create, etc.
|
||||
assert config.server is not None
|
||||
source_data_dir = Path(config.cache_location, config.server.strhash())
|
||||
assert config.provider is not None
|
||||
assert isinstance(config.provider, ProviderConfiguration)
|
||||
assert config.cache_location
|
||||
|
||||
source_data_dir = config.cache_location.joinpath(config.provider.id)
|
||||
source_data_dir.joinpath("g").mkdir(parents=True, exist_ok=True)
|
||||
source_data_dir.joinpath("c").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ground_truth_adapter_type = SubsonicAdapter
|
||||
ground_truth_adapter = ground_truth_adapter_type(
|
||||
{
|
||||
key: getattr(config.server, key)
|
||||
for key in ground_truth_adapter_type.get_config_parameters()
|
||||
},
|
||||
source_data_dir.joinpath("g"),
|
||||
ground_truth_adapter = config.provider.ground_truth_adapter_type(
|
||||
config.provider.ground_truth_adapter_config, source_data_dir.joinpath("g")
|
||||
)
|
||||
|
||||
caching_adapter_type = FilesystemAdapter
|
||||
caching_adapter = None
|
||||
if caching_adapter_type and ground_truth_adapter_type.can_be_cached:
|
||||
if (
|
||||
caching_adapter_type := config.provider.caching_adapter_type
|
||||
) and config.provider.ground_truth_adapter_type.can_be_cached:
|
||||
caching_adapter = caching_adapter_type(
|
||||
{
|
||||
key: getattr(config.server, key)
|
||||
key: getattr(config.provider, key)
|
||||
for key in caching_adapter_type.get_config_parameters()
|
||||
},
|
||||
source_data_dir.joinpath("c"),
|
||||
|
@@ -23,9 +23,18 @@ from typing import (
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .api_objects import Directory, Response
|
||||
from .. import Adapter, AlbumSearchQuery, api_objects as API, ConfigParamDescriptor
|
||||
from .. import (
|
||||
Adapter,
|
||||
AlbumSearchQuery,
|
||||
api_objects as API,
|
||||
ConfigParamDescriptor,
|
||||
ConfigurationStore,
|
||||
ConfigureServerForm,
|
||||
UIInfo,
|
||||
)
|
||||
|
||||
try:
|
||||
import gi
|
||||
@@ -63,15 +72,29 @@ class SubsonicAdapter(Adapter):
|
||||
# Configuration and Initialization Properties
|
||||
# ==================================================================================
|
||||
@staticmethod
|
||||
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
|
||||
# TODO (#197) some way to test the connection to the server and a way to open
|
||||
# the server URL in a browser
|
||||
def get_ui_info() -> UIInfo:
|
||||
return UIInfo(
|
||||
name="Subsonic",
|
||||
description="Connect to a Subsonic-compatible server",
|
||||
icon_basename="subsonic",
|
||||
icon_dir=Path(__file__).parent.joinpath("icons"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
||||
configs = {
|
||||
"server_address": ConfigParamDescriptor(str, "Server address"),
|
||||
"disable_cert_verify": ConfigParamDescriptor("password", "Password", False),
|
||||
"username": ConfigParamDescriptor(str, "Username"),
|
||||
"password": ConfigParamDescriptor("password", "Password"),
|
||||
"-": ConfigParamDescriptor("fold", "Advanced"),
|
||||
"disable_cert_verify": ConfigParamDescriptor(
|
||||
bool, "Verify certificate", True
|
||||
),
|
||||
"sync_enabled": ConfigParamDescriptor(
|
||||
bool, "Synchronize play queue state", True
|
||||
),
|
||||
}
|
||||
|
||||
if networkmanager_imported:
|
||||
configs.update(
|
||||
{
|
||||
@@ -83,17 +106,21 @@ class SubsonicAdapter(Adapter):
|
||||
),
|
||||
}
|
||||
)
|
||||
return configs
|
||||
|
||||
def verify_configuration() -> Dict[str, Optional[str]]:
|
||||
errors: Dict[str, Optional[str]] = {}
|
||||
|
||||
# TODO (#197): verify the URL and ping it.
|
||||
# Maybe have a special key like __ping_future__ or something along those
|
||||
# lines to add a function that allows the UI to check whether or not
|
||||
# connecting to the server will work?
|
||||
return errors
|
||||
|
||||
return ConfigureServerForm(config_store, configs, verify_configuration)
|
||||
|
||||
@staticmethod
|
||||
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
errors: Dict[str, Optional[str]] = {}
|
||||
|
||||
# TODO (#197): verify the URL and ping it.
|
||||
# Maybe have a special key like __ping_future__ or something along those lines
|
||||
# to add a function that allows the UI to check whether or not connecting to the
|
||||
# server will work?
|
||||
return errors
|
||||
def migrate_configuration(config_store: ConfigurationStore):
|
||||
pass
|
||||
|
||||
def __init__(self, config: dict, data_directory: Path):
|
||||
self.data_directory = data_directory
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
4
sublime/adapters/subsonic/icons/subsonic-symbolic.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24">
|
||||
<path d="M6.93 2.13v.04h-.08l-1.39.26c-.15.04-.22.2-.22.38l.22 4.39c0 .22.16.37.34.34l1.69-.57c.11-.03.19-.18.15-.37l-.49-4.17c0-.18-.11-.3-.22-.3zm4.65 4.54c-1.35 0-2.25 0-3.49.68l-2.32.82c-.15.04-.23.19-.27.34a3.6 3.6 0 00-.15 2.1 24 24 0 00-1.46.75c-.45-.71-1.87-.64-1.87.37-.08.5-.42 1.2.22 1.47.3.37-.53.75-.68 1.16-.15.37-.22.75-.7.68-.5.18-.72.63-.34 1.04.37.3 1 0 .9.68.07.49.4.97.6 1.35-.45.37-.38.98-.72 1.43-.26.56.15 1.27.83 1.12a4.73 4.73 0 002.02-.75c3 .82 6.03 1.75 9.13 1.92a6.1 6.1 0 01-.86-4.97l-.02-.21c0-1.02.19-1.62.83-2.25.26-.27.64-.43 1.04-.52a6.1 6.1 0 013.97-1.5 6.01 6.01 0 015.43 3.43 5.5 5.5 0 00-.36-1.94c-.52-1.42-1.87-2.51-3.15-3.26-.75-.38-1.72-.41-2.54-.53l-.27-1.27c.11-.68-.64-.6-1.12-.75l-2.03-.34c-.48-.67-1.87-1.05-2.62-1.05zM5.9 13.05c.38.03.57.67.57 1.83 0 1.1-.23 1.77-.75 2.25-.38.34-.6.3-.83-.18-.45-1.05-.22-2.89.41-3.57.23-.22.42-.37.6-.33zm3.72.22c.56 0 .86.6.93 1.77.04 1.3-.26 2.2-.97 2.73-.34.23-.75.34-.97.23-.3-.12-.64-.98-.68-1.7-.11-1.38.41-2.58 1.27-2.92.13-.06.27-.1.42-.1z" fill="#e4e4e4"/>
|
||||
<path d="M18.2 13.17A5.23 5.23 0 0013 18.4a5.23 5.23 0 005.24 5.24 5.23 5.23 0 005.23-5.24 5.23 5.23 0 00-5.27-5.23z" fill="#c70e0e" class="success"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
102
sublime/app.py
@@ -39,10 +39,10 @@ from .adapters import (
|
||||
SongCacheStatus,
|
||||
)
|
||||
from .adapters.api_objects import Playlist, PlayQueue, Song
|
||||
from .config import AppConfiguration
|
||||
from .config import AppConfiguration, ProviderConfiguration
|
||||
from .dbus import dbus_propagate, DBusManager
|
||||
from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent
|
||||
from .ui.configure_servers import ConfigureServersDialog
|
||||
from .ui.configure_provider import ConfigureProviderDialog
|
||||
from .ui.main import MainWindow
|
||||
from .ui.state import RepeatType, UIState
|
||||
|
||||
@@ -74,7 +74,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.add_action(action)
|
||||
|
||||
# Add action for menu items.
|
||||
add_action("configure-servers", self.on_configure_servers)
|
||||
add_action("add-new-provider", self.on_add_new_provider)
|
||||
add_action("edit-current-provider", self.on_edit_current_provider)
|
||||
add_action("switch-provider", self.on_switch_provider)
|
||||
add_action("remove-provider", self.on_remove_provider)
|
||||
|
||||
# Add actions for player controls
|
||||
add_action("play-pause", self.on_play_pause)
|
||||
@@ -115,8 +118,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
return
|
||||
|
||||
# Configure Icons
|
||||
default_icon_theme = Gtk.IconTheme.get_default()
|
||||
for adapter in AdapterManager.available_adapters:
|
||||
if icon_dir := adapter.get_ui_info().icon_dir:
|
||||
default_icon_theme.append_search_path(str(icon_dir))
|
||||
|
||||
icon_dir = Path(__file__).parent.joinpath("ui", "icons")
|
||||
Gtk.IconTheme.get_default().append_search_path(str(icon_dir))
|
||||
default_icon_theme.append_search_path(str(icon_dir))
|
||||
|
||||
# Windows are associated with the application when the last one is
|
||||
# closed the application shuts down.
|
||||
@@ -139,17 +147,23 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
# Load the state for the server, if it exists.
|
||||
self.app_config.load_state()
|
||||
|
||||
# If there is no current provider, use the first one if there are any
|
||||
# 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.app_config._current_provider_id = 0
|
||||
else:
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
# If they didn't add one with the dialog, close the window.
|
||||
if self.app_config.provider is None:
|
||||
self.window.close()
|
||||
return
|
||||
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
|
||||
# If there is no current server, show the dialog to select a server.
|
||||
if self.app_config.server is None:
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
# If they didn't add one with the dialog, close the window.
|
||||
if self.app_config.server is None:
|
||||
self.window.close()
|
||||
return
|
||||
|
||||
# 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)
|
||||
@@ -274,7 +288,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
GLib.timeout_add(10000, periodic_update)
|
||||
|
||||
# Prompt to load the play queue from the server.
|
||||
if self.app_config.server.sync_enabled:
|
||||
if AdapterManager.can_get_play_queue():
|
||||
self.update_play_state_from_server(prompt_confirm=True)
|
||||
|
||||
# Send out to the bus that we exist.
|
||||
@@ -527,8 +541,32 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.current_notification = None
|
||||
self.update_window()
|
||||
|
||||
def on_configure_servers(self, *args):
|
||||
self.show_configure_servers_dialog()
|
||||
# TODO
|
||||
def on_server_list_changed(self, action: Any, servers: GLib.Variant):
|
||||
assert 0
|
||||
self.app_config.providers = servers
|
||||
self.app_config.save()
|
||||
|
||||
def on_connected_server_changed(self, action: Any, current_server_id: str):
|
||||
assert 0
|
||||
if self.app_config.provider:
|
||||
self.app_config.save()
|
||||
self.app_config.current_server_id = current_server_id
|
||||
self.app_config.save()
|
||||
|
||||
self.reset_state()
|
||||
|
||||
def on_add_new_provider(self, _, provider: ProviderConfiguration):
|
||||
pass
|
||||
|
||||
def on_edit_current_provider(self, _):
|
||||
pass
|
||||
|
||||
def on_switch_provider(self, _, provider_id: str):
|
||||
pass
|
||||
|
||||
def on_remove_provider(self, _, provider_id: str):
|
||||
pass
|
||||
|
||||
def on_window_go_to(self, win: Any, action: str, value: str):
|
||||
{
|
||||
@@ -679,20 +717,6 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def on_go_online(self, *args):
|
||||
self.on_refresh_window(None, {"__settings__": {"offline_mode": False}})
|
||||
|
||||
def on_server_list_changed(self, action: Any, servers: GLib.Variant):
|
||||
self.app_config.servers = servers
|
||||
self.app_config.save()
|
||||
|
||||
def on_connected_server_changed(
|
||||
self, action: Any, current_server_index: int,
|
||||
):
|
||||
if self.app_config.server:
|
||||
self.app_config.save()
|
||||
self.app_config.current_server_index = current_server_index
|
||||
self.app_config.save()
|
||||
|
||||
self.reset_state()
|
||||
|
||||
def reset_state(self):
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
@@ -876,7 +900,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if tap_imported and self.tap:
|
||||
self.tap.stop()
|
||||
|
||||
if self.app_config.server is None:
|
||||
if self.app_config.provider is None:
|
||||
return
|
||||
|
||||
if self.player:
|
||||
@@ -891,12 +915,14 @@ class SublimeMusicApp(Gtk.Application):
|
||||
AdapterManager.shutdown()
|
||||
|
||||
# ########## HELPER METHODS ########## #
|
||||
def show_configure_servers_dialog(self):
|
||||
def show_configure_servers_dialog(
|
||||
self, provider_config: Optional[ProviderConfiguration] = None,
|
||||
):
|
||||
"""Show the Connect to Server dialog."""
|
||||
dialog = ConfigureServersDialog(self.window, self.app_config)
|
||||
dialog.connect("server-list-changed", self.on_server_list_changed)
|
||||
dialog.connect("connected-server-changed", self.on_connected_server_changed)
|
||||
dialog.run()
|
||||
dialog = ConfigureProviderDialog(self.window, provider_config)
|
||||
result = dialog.run()
|
||||
print(result)
|
||||
print(dialog)
|
||||
dialog.destroy()
|
||||
|
||||
def update_window(self, force: bool = False):
|
||||
@@ -1267,7 +1293,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def save_play_queue(self, song_playing_order_token: int = None):
|
||||
if (
|
||||
len(self.app_config.state.play_queue) == 0
|
||||
or self.app_config.server is None
|
||||
or self.app_config.provider is None
|
||||
or (
|
||||
song_playing_order_token
|
||||
and song_playing_order_token != self.song_playing_order_token
|
||||
@@ -1278,7 +1304,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
position = self.app_config.state.song_progress
|
||||
self.last_play_queue_update = position or timedelta(0)
|
||||
|
||||
if self.app_config.server.sync_enabled and self.app_config.state.current_song:
|
||||
if AdapterManager.can_save_play_queue() and self.app_config.state.current_song:
|
||||
AdapterManager.save_play_queue(
|
||||
song_ids=self.app_config.state.play_queue,
|
||||
current_song_index=self.app_config.state.current_song_index,
|
||||
|
@@ -1,17 +1,35 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Dict, Optional, Type
|
||||
|
||||
import yaml
|
||||
import dataclasses_json
|
||||
from dataclasses_json import dataclass_json, DataClassJsonMixin
|
||||
|
||||
from sublime.adapters import ConfigurationStore
|
||||
from sublime.ui.state import UIState
|
||||
|
||||
|
||||
# JSON decoder and encoder translations
|
||||
decoder_functions = {
|
||||
Path: (lambda p: Path(p) if p else None),
|
||||
}
|
||||
encoder_functions = {
|
||||
Path: (lambda p: str(p.resolve()) if p else None),
|
||||
}
|
||||
|
||||
for type_, translation_function in decoder_functions.items():
|
||||
dataclasses_json.cfg.global_config.decoders[type_] = translation_function
|
||||
dataclasses_json.cfg.global_config.decoders[Optional[type_]] = translation_function
|
||||
|
||||
for type_, translation_function in encoder_functions.items():
|
||||
dataclasses_json.cfg.global_config.encoders[type_] = translation_function
|
||||
dataclasses_json.cfg.global_config.encoders[Optional[type_]] = translation_function
|
||||
|
||||
|
||||
class ReplayGainType(Enum):
|
||||
NO = 0
|
||||
TRACK = 1
|
||||
@@ -30,55 +48,33 @@ class ReplayGainType(Enum):
|
||||
}[replay_gain_type.lower()]
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class ServerConfiguration:
|
||||
name: str = "Default"
|
||||
server_address: str = "http://yourhost"
|
||||
local_network_address: str = ""
|
||||
local_network_ssid: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
sync_enabled: bool = True
|
||||
disable_cert_verify: bool = False
|
||||
version: int = 0
|
||||
class ProviderConfiguration:
|
||||
id: str
|
||||
name: str
|
||||
ground_truth_adapter_type: Type
|
||||
ground_truth_adapter_config: ConfigurationStore
|
||||
caching_adapter_type: Optional[Type] = None
|
||||
caching_adapter_config: Optional[ConfigurationStore] = None
|
||||
|
||||
def migrate(self):
|
||||
self.version = 0
|
||||
|
||||
_strhash: Optional[str] = None
|
||||
|
||||
def strhash(self) -> str:
|
||||
# TODO (#197): make this configurable by the adapters the combination of the
|
||||
# hashes will be the hash dir
|
||||
"""
|
||||
Returns the MD5 hash of the server's name, server address, and
|
||||
username. This should be used whenever it's necessary to uniquely
|
||||
identify the server, rather than using the name (which is not
|
||||
necessarily unique).
|
||||
|
||||
>>> sc = ServerConfiguration(
|
||||
... name='foo',
|
||||
... server_address='bar',
|
||||
... username='baz',
|
||||
... )
|
||||
>>> sc.strhash()
|
||||
'6df23dc03f9b54cc38a0fc1483df6e21'
|
||||
"""
|
||||
if not self._strhash:
|
||||
server_info = self.name + self.server_address + self.username
|
||||
self._strhash = hashlib.md5(server_info.encode("utf-8")).hexdigest()
|
||||
return self._strhash
|
||||
self.ground_truth_adapter_type.migrate_configuration(
|
||||
self.ground_truth_adapter_config
|
||||
)
|
||||
if self.caching_adapter_type:
|
||||
self.caching_adapter_type.migrate_configuration(self.caching_adapter_config)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfiguration:
|
||||
version: int = 3
|
||||
cache_location: str = ""
|
||||
class AppConfiguration(DataClassJsonMixin):
|
||||
version: int = 5
|
||||
cache_location: Optional[Path] = None
|
||||
filename: Optional[Path] = None
|
||||
|
||||
# Servers
|
||||
servers: List[ServerConfiguration] = field(default_factory=list)
|
||||
current_server_index: int = -1
|
||||
# Providers
|
||||
providers: Dict[str, ProviderConfiguration] = field(default_factory=dict)
|
||||
current_provider_id: Optional[str] = None
|
||||
|
||||
# Global Settings
|
||||
song_play_notification: bool = True
|
||||
@@ -96,19 +92,16 @@ class AppConfiguration:
|
||||
|
||||
@staticmethod
|
||||
def load_from_file(filename: Path) -> "AppConfiguration":
|
||||
args = {}
|
||||
config = AppConfiguration()
|
||||
try:
|
||||
if filename.exists():
|
||||
with open(filename, "r") as f:
|
||||
field_names = {f.name for f in fields(AppConfiguration)}
|
||||
args = yaml.load(f, Loader=yaml.CLoader).items()
|
||||
args = dict(filter(lambda kv: kv[0] in field_names, args))
|
||||
config = AppConfiguration.from_json(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
config = AppConfiguration(**args)
|
||||
config.filename = filename
|
||||
|
||||
config.save()
|
||||
return config
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -116,82 +109,67 @@ class AppConfiguration:
|
||||
if not self.cache_location:
|
||||
path = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
|
||||
path = path.expanduser().joinpath("sublime-music").resolve()
|
||||
self.cache_location = path.as_posix()
|
||||
|
||||
# Deserialize the YAML into the ServerConfiguration object.
|
||||
if len(self.servers) > 0 and type(self.servers[0]) != ServerConfiguration:
|
||||
self.servers = [ServerConfiguration(**sc) for sc in self.servers]
|
||||
self.cache_location = str(path)
|
||||
|
||||
self._state = None
|
||||
self._current_server_hash = None
|
||||
self._current_provider_id = None
|
||||
self.migrate()
|
||||
|
||||
def migrate(self):
|
||||
for server in self.servers:
|
||||
server.migrate()
|
||||
for _, provider in self.providers.items():
|
||||
provider.migrate()
|
||||
|
||||
if self.version < 4:
|
||||
self.allow_song_downloads = not self.always_stream
|
||||
|
||||
self.version = 4
|
||||
self.version = 5
|
||||
self.state.migrate()
|
||||
|
||||
@property
|
||||
def server(self) -> Optional[ServerConfiguration]:
|
||||
if 0 <= self.current_server_index < len(self.servers):
|
||||
return self.servers[self.current_server_index]
|
||||
|
||||
return None
|
||||
def provider(self) -> Optional[ProviderConfiguration]:
|
||||
return self.providers.get(self._current_provider_id or "")
|
||||
|
||||
@property
|
||||
def state(self) -> UIState:
|
||||
server = self.server
|
||||
if not server:
|
||||
if not (provider := self.provider):
|
||||
return UIState()
|
||||
|
||||
# If the server has changed, then retrieve the new server's state.
|
||||
if self._current_server_hash != server.strhash():
|
||||
# If the provider has changed, then retrieve the new provider's state.
|
||||
if self._current_provider_id != provider.id:
|
||||
self.load_state()
|
||||
|
||||
return self._state
|
||||
|
||||
def load_state(self):
|
||||
self._state = UIState()
|
||||
if not self.server:
|
||||
if not (provider := self.provider):
|
||||
return
|
||||
|
||||
self._current_server_hash = self.server.strhash()
|
||||
if (
|
||||
state_file_location := self.state_file_location
|
||||
) and state_file_location.exists():
|
||||
self._current_provider_id = provider.id
|
||||
if (state_filename := self._state_file_location) and state_filename.exists():
|
||||
try:
|
||||
with open(state_file_location, "rb") as f:
|
||||
with open(state_filename, "rb") as f:
|
||||
self._state = pickle.load(f)
|
||||
except Exception:
|
||||
logging.warning(f"Couldn't load state from {state_file_location}")
|
||||
logging.warning(f"Couldn't load state from {state_filename}")
|
||||
# Just ignore any errors, it is only UI state.
|
||||
self._state = UIState()
|
||||
|
||||
@property
|
||||
def state_file_location(self) -> Optional[Path]:
|
||||
if self.server is None:
|
||||
def _state_file_location(self) -> Optional[Path]:
|
||||
if not (provider := self.provider):
|
||||
return None
|
||||
|
||||
server_hash = self.server.strhash()
|
||||
|
||||
state_file_location = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
|
||||
return state_file_location.expanduser().joinpath(
|
||||
"sublime-music", server_hash, "state.pickle"
|
||||
state_filename = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
|
||||
return state_filename.expanduser().joinpath(
|
||||
"sublime-music", provider.id, "state.pickle"
|
||||
)
|
||||
|
||||
def save(self):
|
||||
# Save the config as YAML.
|
||||
self.filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.filename, "w+") as f:
|
||||
f.write(yaml.dump(asdict(self)))
|
||||
f.write(self.to_json(indent=2, sort_keys=True))
|
||||
|
||||
# Save the state for the current server.
|
||||
if state_file_location := self.state_file_location:
|
||||
state_file_location.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(state_file_location, "wb+") as f:
|
||||
# Save the state for the current provider.
|
||||
if state_filename := self._state_file_location:
|
||||
state_filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(state_filename, "wb+") as f:
|
||||
pickle.dump(self.state, f)
|
||||
|
@@ -519,7 +519,7 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
page: int = 0
|
||||
num_pages: Optional[int] = None
|
||||
next_page_fn = None
|
||||
server_hash: Optional[str] = None
|
||||
server_id: Optional[str] = None
|
||||
|
||||
def update_params(self, query: AlbumSearchQuery, offline_mode: bool) -> int:
|
||||
# If there's a diff, increase the ratchet.
|
||||
@@ -620,10 +620,10 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
self.page_size = app_config.state.album_page_size
|
||||
self.page = app_config.state.album_page
|
||||
|
||||
new_hash = server.strhash() if (server := app_config.server) else None
|
||||
if self.server_hash != new_hash:
|
||||
assert app_config.provider
|
||||
if self.server_id != app_config.provider.id:
|
||||
self.order_ratchet += 1
|
||||
self.server_hash = new_hash
|
||||
self.server_id = app_config.provider.id
|
||||
|
||||
self.update_grid(
|
||||
order_token,
|
||||
|
@@ -64,6 +64,11 @@
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
/* ********** Configure Provider Dialog ********** */
|
||||
#ground-truth-adapter-options-list {
|
||||
margin: 0 40px;
|
||||
}
|
||||
|
||||
/* ********** Playlist ********** */
|
||||
#playlist-list-listbox row {
|
||||
margin: 0;
|
||||
|
@@ -441,9 +441,9 @@ class MusicDirectoryList(Gtk.Box):
|
||||
)
|
||||
)
|
||||
|
||||
icon = Gio.ThemedIcon(name="go-next-symbolic")
|
||||
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
|
||||
image = Gtk.Image.new_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON)
|
||||
rowbox.pack_end(image, False, False, 5)
|
||||
|
||||
row.add(rowbox)
|
||||
row.show_all()
|
||||
return row
|
||||
|
107
sublime/ui/configure_provider.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.adapters import Adapter, AdapterManager, UIInfo
|
||||
from sublime.config import 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 ConfigureProviderDialog(Gtk.Dialog):
|
||||
__gsignals__ = {
|
||||
"server-list-changed": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object,),
|
||||
),
|
||||
"connected-server-changed": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object,),
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, parent: Any, config: Optional[ProviderConfiguration]):
|
||||
title = "Add New Music Source" if not config else "Edit {config.name}"
|
||||
Gtk.Dialog.__init__(
|
||||
self,
|
||||
title=title,
|
||||
transient_for=parent,
|
||||
flags=Gtk.DialogFlags.MODAL,
|
||||
add_buttons=(),
|
||||
)
|
||||
self.set_default_size(400, 500)
|
||||
|
||||
# HEADER
|
||||
header = Gtk.HeaderBar()
|
||||
header.props.title = title
|
||||
|
||||
cancel_button = Gtk.Button(label="Cancel")
|
||||
cancel_button.connect("clicked", lambda *a: self.close())
|
||||
header.pack_start(cancel_button)
|
||||
|
||||
next_button = Gtk.Button(label="Next")
|
||||
next_button.connect("clicked", self._on_next_clicked)
|
||||
header.pack_end(next_button)
|
||||
|
||||
self.set_titlebar(header)
|
||||
|
||||
content_area = self.get_content_area()
|
||||
|
||||
# ADAPTER TYPE OPTIONS
|
||||
self.adapter_type_store = Gio.ListStore()
|
||||
self.adapter_options_list = Gtk.ListBox(
|
||||
name="ground-truth-adapter-options-list"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
# TODO
|
||||
available_ground_truth_adapters = 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))
|
||||
|
||||
content_area.pack_start(self.adapter_options_list, True, True, 10)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def _on_next_clicked(self, _):
|
||||
index = self.adapter_options_list.get_selected_row().get_index()
|
||||
adapter_type = self.adapter_type_store[index].adapter_type
|
||||
print(adapter_type)
|
@@ -3,7 +3,7 @@ from typing import Any
|
||||
|
||||
from gi.repository import GObject, Gtk
|
||||
|
||||
from sublime.config import AppConfiguration, ServerConfiguration
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.ui.common import EditFormDialog, IconButton
|
||||
|
||||
|
||||
|
@@ -137,8 +137,8 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self.notification_revealer.set_reveal_child(False)
|
||||
|
||||
# Update the Connected to label on the popup menu.
|
||||
if app_config.server:
|
||||
self.connected_to_label.set_markup(f"<b>{app_config.server.name}</b>")
|
||||
if app_config.provider:
|
||||
self.connected_to_label.set_markup(f"<b>{app_config.provider.name}</b>")
|
||||
else:
|
||||
self.connected_to_label.set_markup("<i>No Music Source Selected</i>")
|
||||
|
||||
|
@@ -24,7 +24,7 @@ def test_server_property():
|
||||
expected_state_file_location = expected_state_file_location.joinpath(
|
||||
"sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "state.pickle",
|
||||
)
|
||||
assert config.state_file_location == expected_state_file_location
|
||||
assert config._state_file_location == expected_state_file_location
|
||||
|
||||
|
||||
def test_yaml_load_unload():
|
||||
|