WIP
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -255,6 +253,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 +326,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
|
||||
|
@@ -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]]:
|
||||
def get_configuration_form() -> Gtk.Box:
|
||||
configs = {
|
||||
"directory": ConfigParamDescriptor(
|
||||
type=Path, description="Music Directory", pathtype="directory"
|
||||
)
|
||||
}
|
||||
def verify_config_store(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
|
||||
return {}
|
||||
|
||||
return ConfigureServerForm(
|
||||
config_store,
|
||||
{
|
||||
"directory": ConfigParamDescriptor(
|
||||
type=Path, description="Music Directory", pathtype="directory"
|
||||
)
|
||||
},
|
||||
verify_config_store,
|
||||
)
|
||||
return configs, verify_config_store
|
||||
|
||||
@staticmethod
|
||||
def migrate_configuration(config_store: ConfigurationStore):
|
||||
|
@@ -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:
|
||||
@@ -206,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):
|
||||
|
@@ -50,12 +50,13 @@ 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
|
||||
@@ -92,13 +93,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
action.connect("activate", fn)
|
||||
self.add_action(action)
|
||||
|
||||
register_action(self, self.change_tab)
|
||||
register_action(self, self.quit, types=tuple())
|
||||
|
||||
# Music provider actions
|
||||
register_action(self, self.add_new_music_provider)
|
||||
register_action(self, self.edit_current_music_provider)
|
||||
register_action(self, self.switch_music_provider)
|
||||
register_action(self, self.remove_music_provider)
|
||||
register_action(self, self.change_tab)
|
||||
|
||||
# Connect after we know there's a server configured.
|
||||
# self.window.connect("notification-closed", self.on_notification_closed)
|
||||
@@ -146,7 +143,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
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', ["space"])
|
||||
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
|
||||
@@ -184,6 +183,12 @@ class SublimeMusicApp(Gtk.Application):
|
||||
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()
|
||||
@@ -204,15 +209,9 @@ 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
|
||||
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
self.window.show_providers_window()
|
||||
else:
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
|
||||
# Configure the players
|
||||
self.last_play_queue_update = timedelta(0)
|
||||
@@ -355,20 +354,21 @@ class SublimeMusicApp(Gtk.Application):
|
||||
GLib.timeout_add(10000, check_if_connected)
|
||||
|
||||
# Update after Adapter Initial Sync
|
||||
def after_initial_sync(_):
|
||||
self.update_window()
|
||||
if self.app_config.provider:
|
||||
def after_initial_sync(_):
|
||||
self.update_window()
|
||||
|
||||
# Prompt to load the play queue from the server.
|
||||
if AdapterManager.can_get_play_queue():
|
||||
self.update_play_state_from_server(prompt_confirm=True)
|
||||
# Prompt to load the play queue from the server.
|
||||
if AdapterManager.can_get_play_queue():
|
||||
self.update_play_state_from_server(prompt_confirm=True)
|
||||
|
||||
# Get the playlists, just so that we don't have tons of cache misses from
|
||||
# DBus trying to get the playlists.
|
||||
if AdapterManager.can_get_playlists():
|
||||
AdapterManager.get_playlists()
|
||||
# Get the playlists, just so that we don't have tons of cache misses from
|
||||
# DBus trying to get the playlists.
|
||||
if AdapterManager.can_get_playlists():
|
||||
AdapterManager.get_playlists()
|
||||
|
||||
inital_sync_result = AdapterManager.initial_sync()
|
||||
inital_sync_result.add_done_callback(after_initial_sync)
|
||||
inital_sync_result = AdapterManager.initial_sync()
|
||||
inital_sync_result.add_done_callback(after_initial_sync)
|
||||
|
||||
# Send out to the bus that we exist.
|
||||
if self.dbus_manager:
|
||||
@@ -622,7 +622,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# setattr(self.app_config.state, k, v)
|
||||
# self.update_window(force=force)
|
||||
|
||||
@dbus_propagate()
|
||||
# @dbus_propagate()
|
||||
def refresh(self):
|
||||
self.update_window(force=False)
|
||||
|
||||
@@ -638,45 +638,6 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.current_notification = None
|
||||
self.update_window()
|
||||
|
||||
def add_new_music_provider(self):
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
def edit_current_music_provider(self):
|
||||
self.show_configure_servers_dialog(self.app_config.provider.clone())
|
||||
|
||||
def switch_music_provider(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 remove_music_provider(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]
|
||||
|
||||
confirm_dialog.destroy()
|
||||
|
||||
_inhibit_cookie = None
|
||||
|
||||
@dbus_propagate()
|
||||
@@ -1039,34 +1000,93 @@ class SublimeMusicApp(Gtk.Application):
|
||||
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)
|
||||
|
||||
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()
|
||||
# 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)
|
||||
# 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()
|
||||
# dialog.destroy()
|
||||
|
||||
def update_window(self, force: bool = False):
|
||||
if not self.window:
|
||||
|
@@ -54,11 +54,11 @@ def register_action(group, fn: Callable, name: Optional[str] = None, types: Tupl
|
||||
name = fn.__name__.replace('_', '-')
|
||||
|
||||
# Determine the type from the signature
|
||||
signature = inspect.signature(fn)
|
||||
if types is None:
|
||||
signature = inspect.signature(fn)
|
||||
types = tuple(p.annotation for p in signature.parameters.values())
|
||||
|
||||
if signature.parameters:
|
||||
if types:
|
||||
if inspect.Parameter.empty in types:
|
||||
raise ValueError('Missing parameter annotation for action ' + name)
|
||||
|
||||
@@ -238,6 +238,8 @@ _VARIANT_CONSTRUCTORS = {
|
||||
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):
|
||||
@@ -248,7 +250,7 @@ def _create_variant(type_str, value):
|
||||
|
||||
vtype = GLib.VariantType(type_str)
|
||||
|
||||
if vtype.is_basic():
|
||||
if type_str in _VARIANT_CONSTRUCTORS:
|
||||
return _VARIANT_CONSTRUCTORS[type_str](value)
|
||||
|
||||
builder = GLib.VariantBuilder.new(vtype)
|
||||
|
@@ -337,8 +337,9 @@ class AlbumsPanel(Handy.Leaflet):
|
||||
# Has to be last because it resets self.updating_query
|
||||
self.populate_genre_combo(app_config, force=force)
|
||||
|
||||
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or self.albums[0]
|
||||
self.album_with_songs.update(selected_album, 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
|
||||
|
@@ -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
612
sublime_music/ui/providers.py
Normal file
612
sublime_music/ui/providers.py
Normal file
@@ -0,0 +1,612 @@
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
self.status_revealer.add(box)
|
||||
|
||||
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()
|
Reference in New Issue
Block a user