This commit is contained in:
Benjamin Schaaf
2022-01-09 00:03:38 +11:00
parent 47850356b3
commit 827636ade6
11 changed files with 1266 additions and 1339 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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())
)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View 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()